R-005 — Parcela atrasada dispara o evento — idempotência por parcela, sem exigir carnê completo
TLDR:
Accounts::DispatchInstallmentOverduegrava umOutboxEvent(accounts.installment_overdue) quando o webhook do Asaas leva uma parcela de compra padrão paraoverdue. Idempotência é porprovider_refda cobrança vencida, não por pagamento. Diferente doPURCHASE_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] == :overduereflete o estado que o webhook acabou de escrever, via a normalização queAsaas::Utils::NormalizePaymentStatusjá faz: qualquer evento permitido que deixe a cobrança emoverduedispara — hojePAYMENT_OVERDUEe umPAYMENT_UPDATEDsobre cobrança já vencida. Amarrar no nome do evento exigiria manter uma segunda lista em paralelo comPAYMENT_OVERDUE_STATUSES. PAYMENT_DUNNING_REQUESTEDnão dispara. O recorte por nome de evento continua nocontext.should_update_payment(ASAAS_APOLO_PAYMENT_ALLOWED_EVENT_TYPES.include?), e negativação está emASAAS_ALL_PAYMENT_EVENTSmas não na lista permitida — o&&corta antes de olhar o status. Inofensivo: oPAYMENT_OVERDUEda mesma cobrança vem antes e já despachou o evento, e a idempotência porprovider_refcortaria a repetição.- Parcela com status intraduzível é filtrada da lista, não mapeada.
Accounts::PurchaseSerializer::INSTALLMENT_STATUSESsó conhecePENDING/PAID/OVERDUE/REFUNDED. Uma cobrança fora desses quatro (cancelada, em rascunho, ainda sendo criada no gateway) é descartada, não convertida — oReconciledo lado doaccountsnunca 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_installmentsai da entrada deinstallmentscujoprovider_refbate com ogateway_iddo webhook. Isso garante por construção o invariante que oaccountsexige — a parcela apontada está sempre eminstallments— 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 porquepurchases.Createcria o carnê inteiro de uma vez e um carnê parcial ficaria errado para sempre. Aqui oReconcileé incremental e não destrutivo, e o filtro de status por si só já faz a contagem divergir deinstallment_countsempre 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 emgateway_installments(a lista crua do gateway), exatamente como noINSTALLMENT_PAID. - Idempotência por
provider_refda cobrança vencida, não porexternal_iddo pagamento. OPURCHASE_CREATEDdeduplica porexternal_idporque é 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
accountsdespacham pelo mesmoOutbox::Dispatchers::AccountsEvents. Mesmo endpoint, mesmos headers (X-Origin: checkout,Authorization: Bearer), mesmo timeout — o que distingue um evento do outro é oevent_typedentro do payload, não a classe que despacha. Registrar o atraso é uma linha noDispatchRegistry::DISPATCHERS. - Um serializer só para os três eventos:
Accounts::PurchaseSerializer. O payload do atraso é o doPURCHASE_CREATEDmais um campo, então o dispatcher fazas_json.merge( overdue_installment:)— não existeInstallmentOverdueSerializer. É o mesmo caminho doINSTALLMENT_PAIDcompaid_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 oprovider_refcru. OInstallmentOverdue._find_overdue_installmentdoaccountsindexa o campo por chave e usanumbercomo fallback quandoprovider_refvem nulo ou não casa com nenhuma parcela — mandar a string crua estouraTypeErrorlá. OINSTALLMENT_PAIDseguiu o mesmo caminho depois do PR #32 doaccounts: opaid_installmenttambém é objeto,{number, provider_ref, paid_at}— opaid_ata mais porque oaccountsregistra a data do pagamento a partir dele.purchased_até ocreated_atdo pagamento, não o momento do atraso.occurred_atno 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 noaccountsdatada 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
- ../../specs/20260922093318_accounts_installment_overdue_event.md — decisões e escopo
- ../../reference/payments/accounts_installment_overdue_event.md — contrato do envelope
- ./outbox_event_dispatch.md —
R-004, mecanismo de despacho genérico do outbox - ../../reference/payments/accounts_purchase_created_event.md — o evento irmão, molde deste