Estado derivado de um filho e gravado no pai vaza para todos os irmãos
O que aconteceu
Suspender uma filiação marcava, além da própria filiação, o perfil do usuário como suspenso — porque UserProfile#calculate_documentation_status resolvia :suspended sempre que user.memberships.suspended.any?. Qualquer filiação suspensa servia: vencida, arquivada, de qualquer época.
Uma cliente suspensa por chargeback em dezembro/2025 comprou nova filiação em julho/2026, passou pelo fluxo de compra e teve a documentação aprovada. Continuou bloqueada. O apolo_membership selecionava corretamente a filiação nova e devolvia apolo_access_status: true e suspended: false, mas no mesmo payload devolvia profile.status: "suspended" — e é esse campo que o trgclub-api usa como gate de PRO (ValidateEligibility#documentation_approved?), derrubando a assinatura via ExpireWhenIneligible.
Causa raiz
Duas decisões que isoladas parecem inofensivas e juntas travam o sistema:
- Agregação sem escopo.
any?sobre o histórico inteiro transforma um evento pontual em um atributo permanente da pessoa. Não existe “estava suspenso em dezembro” — existe só “é suspenso”. - Derivado gravado, não calculado na leitura. O resultado ia para uma coluna, reescrita por
before_saveno perfil e porafter_commitem qualquer filiação do usuário. Como oreturn :suspendedvinha antes doreturn :ok if admin_approved_at, aprovar a documentação pelo admin era desfeito no salvamento seguinte. O sistema não tinha estado de saída: nenhuma ação de interface conseguia limpar a marca.
O agravante é de contrato: o payload saía internamente contraditório — “esta filiação está liberada” e “esta pessoa está suspensa” — e o consumidor externo escolheu acreditar na metade errada. Um payload que se contradiz sempre vai ser lido pela metade que dói.
Correção
A suspensão passou a viver só na filiação suspensa. O perfil voltou a falar apenas de documentação (pending, analysis, ok) e os consumidores leem suspensão pelo campo suspended, que já era por filiação. Sem backfill: a coluna se recalcula sozinha no próximo save do perfil ou commit de qualquer filiação do usuário.
Como evitar
- Antes de escrever
parent.algo? = children.any? { ... }, perguntar se o estado é do filho ou do pai. Se um filho novo e saudável não apaga a marca, ela está no lugar errado. - Agregação sobre histórico precisa de escopo temporal explícito (vigente, não arquivado).
any?sem escopo trata um registro de 2015 como se fosse de hoje. - Estado derivado e gravado precisa de um caminho de saída testado: se nenhuma ação de interface consegue mudar o valor porque um callback o reescreve, o campo virou uma prisão. Vale escrever o teste “admin aprova → continua aprovado depois de salvar de novo”.
- Colocar as condições mais fortes depois das que representam decisão humana explícita. Um
returnderivado automaticamente na frente deadmin_approved_atsignifica que a máquina vence o admin, silenciosamente. - Ao mudar um campo que sai em payload público, varrer os consumidores na hora — premissas sobre quem lê o quê envelhecem rápido. A spec de 29/07/2026 registrou “o trg-club nem lê
profile.status”; era verdade, e deixou de ser em 04/08/2026 com o#700dotrgclub-api.