R-005 — Parcela atrasada dispara o evento — idempotência por parcela, sem exigir carnê completo

TLDR: Accounts::DispatchInstallmentOverdue grava um OutboxEvent (accounts.installment_overdue) quando o webhook do Asaas leva uma parcela de compra padrão para overdue. Idempotência é por provider_ref da cobrança vencida, não por pagamento. Diferente do PURCHASE_CREATED, não exige que o carnê buscado ao vivo esteja completo — só que a cobrança apontada esteja nele.

Given / When / Then

Dado um webhook que leva a parcela de uma compra standard para overdue, Quando PaymentsFlow roda o step DispatchInstallmentOverdueEvent, Então Accounts::DispatchInstallmentOverdue grava o OutboxEvent e enfileira Outbox::DispatchEventsJob.

Dado um pagamento de kind diferente de standard (reparcelamento, quitação, renovação), Quando o webhook leva uma parcela dele para overdue, Então o step é um no-op — só compra padrão tem Purchase no accounts.

Dado um webhook cujo should_update_payment é falso, ou cujo status normalizado não é overdue, Quando PaymentsFlow roda, Então o step é um no-op.

Dado a cobrança vencida que não aparece na lista de parcelas buscada ao vivo no Asaas (foi cancelada, ou o Asaas ainda não a publicou), Quando Accounts::DispatchInstallmentOverdue executa, Então sai em silêncio, sem gravar OutboxEvent — o próximo webhook do mesmo pagamento tenta de novo.

Dado já existe um OutboxEvent accounts.installment_overdue com o mesmo overdue_installment.provider_ref, Quando o webhook chega de novo para a mesma cobrança, Então nada é gravado — idempotência por parcela.

Dado uma segunda parcela do mesmo pagamento que também vence, Quando o webhook dela chega, Então um novo OutboxEvent é gravado — a idempotência não é por pagamento.

Dado o plano buscado ao vivo no Asaas tem menos cobranças do que payment.installment_count (por exemplo, uma cobrança cancelada e recriada com status intraduzível filtrado), Quando a cobrança vencida ainda está na lista filtrada, Então o evento é gravado normalmente — carnê incompleto não bloqueia o atraso.

Dado uma falha inesperada (gateway fora do ar, erro ao gravar o OutboxEvent), Quando Accounts::DispatchInstallmentOverdue executa, Então loga Rails.logger.error prefixado com [accounts.installment_overdue] e devolve nil — o webhook nunca cai por causa deste evento.

Tabela de decisão

Situação Ação
standard + status overdue + cobrança no plano ao vivo grava o OutboxEvent
kind diferente de standard não dispara
status diferente de overdue, ou should_update_payment falso não dispara
cobrança vencida fora do plano buscado ao vivo (cancelada ou não publicada) não dispara, em silêncio
já existe evento com o mesmo overdue_installment.provider_ref não dispara
segunda parcela do mesmo pagamento dispara — evento novo
plano ao vivo menor que installment_count dispara mesmo assim
falha inesperada loga e devolve nil, webhook segue

Restrições

  • O gatilho é o status gravado na parcela, não o nome do evento do webhook. context.installment_params[:status] == :overdue reflete o estado que o webhook acabou de escrever, via a normalização que Asaas::Utils::NormalizePaymentStatus já faz: qualquer evento permitido que deixe a cobrança em overdue dispara — hoje PAYMENT_OVERDUE e um PAYMENT_UPDATED sobre cobrança já vencida. Amarrar no nome do evento exigiria manter uma segunda lista em paralelo com PAYMENT_OVERDUE_STATUSES.
  • PAYMENT_DUNNING_REQUESTED não dispara. O recorte por nome de evento continua no context.should_update_payment (ASAAS_APOLO_PAYMENT_ALLOWED_EVENT_TYPES.include?), e negativação está em ASAAS_ALL_PAYMENT_EVENTS mas não na lista permitida — o && corta antes de olhar o status. Inofensivo: o PAYMENT_OVERDUE da mesma cobrança vem antes e já despachou o evento, e a idempotência por provider_ref cortaria a repetição.
  • Parcela com status intraduzível é filtrada da lista, não mapeada. Accounts::PurchaseSerializer::INSTALLMENT_STATUSES só conhece PENDING/PAID/OVERDUE/REFUNDED. Uma cobrança fora desses quatro (cancelada, em rascunho, ainda sendo criada no gateway) é descartada, não convertida — o Reconcile do lado do accounts nunca apaga parcela ausente do payload, então filtrar é seguro, e evita a colisão real de (purchase, number) quando uma cobrança cancelada e a que a substituiu dividem o mesmo número.
  • A parcela apontada é resolvida dentro da lista já filtrada, não a partir dos dados crus do webhook. overdue_installment sai da entrada de installments cujo provider_ref bate com o gateway_id do webhook. Isso garante por construção o invariante que o accounts exige — a parcela apontada está sempre em installments — em vez de depender de o consumidor validar isso depois.
  • Completude do carnê não é exigida, diferente do PURCHASE_CREATED (Accounts::DispatchPurchaseCreated#installments_complete?). Lá a checagem existe porque purchases.Create cria o carnê inteiro de uma vez e um carnê parcial ficaria errado para sempre. Aqui o Reconcile é incremental e não destrutivo, e o filtro de status por si só já faz a contagem divergir de installment_count sempre que houver cobrança cancelada no plano — exigir completude silenciaria o atraso justamente nos carnês mais bagunçados. O que impede um evento vazio é só a checagem da parcela apontada, feita em gateway_installments (a lista crua do gateway), exatamente como no INSTALLMENT_PAID.
  • Idempotência por provider_ref da cobrança vencida, não por external_id do pagamento. O PURCHASE_CREATED deduplica por external_id porque é um evento por compra; este é N por compra — um mesmo carnê pode atrasar em parcelas diferentes, cada uma com o seu próprio evento.
  • Os três eventos do accounts despacham pelo mesmo Outbox::Dispatchers::AccountsEvents. Mesmo endpoint, mesmos headers (X-Origin: checkout, Authorization: Bearer), mesmo timeout — o que distingue um evento do outro é o event_type dentro do payload, não a classe que despacha. Registrar o atraso é uma linha no DispatchRegistry::DISPATCHERS.
  • Um serializer só para os três eventos: Accounts::PurchaseSerializer. O payload do atraso é o do PURCHASE_CREATED mais um campo, então o dispatcher faz as_json.merge( overdue_installment:) — não existe InstallmentOverdueSerializer. É o mesmo caminho do INSTALLMENT_PAID com paid_installment. Uma subclasse por evento multiplicaria classes e specs para acrescentar uma chave a um contrato que já é comum aos três.
  • overdue_installment é um objeto {number, provider_ref}, não o provider_ref cru. O InstallmentOverdue._find_overdue_installment do accounts indexa o campo por chave e usa number como fallback quando provider_ref vem nulo ou não casa com nenhuma parcela — mandar a string crua estoura TypeError lá. O INSTALLMENT_PAID seguiu o mesmo caminho depois do PR #32 do accounts: o paid_installment também é objeto, {number, provider_ref, paid_at} — o paid_at a mais porque o accounts registra a data do pagamento a partir dele.
  • purchased_at é o created_at do pagamento, não o momento do atraso. occurred_at no envelope é que registra quando o atraso foi observado (Time.current); purchased_at é quando a compra aconteceu. Confundir os dois faria uma compra de meses atrás nascer no accounts datada de hoje.

Teste vinculado

spec/serializers/accounts/purchase_serializer_spec.rb (tradução e filtro de status, compartilhado com o PURCHASE_CREATED), spec/services/accounts/dispatch_installment_overdue_spec.rb (payload completo com purchased_at e overdue_installment sempre dentro de installments, mais cada ramo do orquestrador: idempotência por parcela, silêncio quando a cobrança não está no plano, carnê incompleto não bloqueia, falha logada), e spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_overdue_event_spec.rb (gatilho no PaymentsFlow).

Referências