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:

  1. 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”.
  2. Derivado gravado, não calculado na leitura. O resultado ia para uma coluna, reescrita por before_save no perfil e por after_commit em qualquer filiação do usuário. Como o return :suspended vinha antes do return :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 return derivado automaticamente na frente de admin_approved_at significa 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 #700 do trgclub-api.

Relacionados