Status: implementado dos dois lados. No
checkout-api,Accounts::DispatchInstallmentOverduegrava umOutboxEvent(accounts.installment_overdue) a partir do webhook do Asaas;Outbox::DispatchEventsJobfaz o POST de fato (ver regra de despacho do outbox). Noaccounts, o use caseinstallments.InstallmentOverduejá existe na branchfeat/checkout-installment-overduedeles.
Evento de parcela atrasada — accounts
TLDR: quando o webhook do Asaas leva uma parcela de compra padrão para
overdue, o checkout-api disparaINSTALLMENT_OVERDUEpara oaccountscom o pagamento inteiro — cliente, todas as parcelas do carnê e a indicação de qual venceu. O payload é o doPURCHASE_CREATEDmaispurchased_at,statuspor parcela eoverdue_installment.
Gatilho
Novo step IncomingWebhooks::PaymentGateways::Asaas::Payments::DispatchInstallmentOverdueEvent, no final de PaymentsFlow, depois de DispatchPurchaseCreatedEvent. Roda um no-op silencioso a menos que:
context.should_update_paymentseja verdadeiro (o webhook de fato escreveu naInstallment);context.installment_params[:status] == :overdue— o status normalizado que o próprio webhook acabou de gravar, não o nome do evento do Asaas. Qualquer evento permitido que deixe a cobrança emoverduedispara: hojePAYMENT_OVERDUEe umPAYMENT_UPDATEDsobre cobrança já vencida.PAYMENT_DUNNING_REQUESTEDfica de fora — não está emASAAS_APOLO_PAYMENT_ALLOWED_EVENT_TYPES, então oshould_update_paymentacima já corta;payment.kind == "standard"(reparcelamento/settlement ficam fora — só compra padrão temPurchasenoaccounts).
Os dois steps (DispatchPurchaseCreatedEvent e DispatchInstallmentOverdueEvent) nunca disparam no mesmo webhook: o de compra criada exige ASAAS_INITIAL_EVENTS (PAYMENT_CREATED/PAYMENT_UPDATED), o de atraso exige status overdue, e PAYMENT_OVERDUE não está em ASAAS_INITIAL_EVENTS.
Diferente do PURCHASE_CREATED, não há exigência de carnê completo: Accounts::DispatchInstallmentOverdue dispara mesmo quando o plano buscado ao vivo no Asaas é menor que payment.installment_count. O Reconcile do lado do accounts é incremental e não destrutivo, e o filtro de status por si só já faz a contagem divergir sempre que houver cobrança cancelada no plano — exigir completude aqui silenciaria o atraso justamente nos carnês mais bagunçados. O que impede um evento vazio é a checagem de que a cobrança vencida está no plano buscado ao vivo: se não estiver (o Asaas ainda não a publicou), o dispatcher sai em silêncio e o próximo webhook do mesmo pagamento tenta de novo. Só falha inesperada (gateway fora do ar, erro ao gravar) gera Rails.logger.error prefixado com [accounts.installment_overdue] — e mesmo essa não quebra o webhook.
Idempotência
Por parcela, não por pagamento. Accounts::DispatchInstallmentOverdue verifica se já existe um OutboxEvent com event_name: "accounts.installment_overdue" e overdue_installment.provider_ref igual ao gateway_id da cobrança do webhook (payload -> 'payload' -> 'overdue_installment' ->> 'provider_ref'). Um mesmo carnê pode atrasar várias vezes, em parcelas diferentes — cada cobrança vencida dispara o seu próprio evento.
Requisição (feita por Outbox::Dispatchers::AccountsEvents)
POST /api/v1/events
Content-Type: application/json
X-Origin: checkout
Authorization: Bearer <ACCOUNTS_API_TOKEN>
Mesmo endpoint, mesmos headers do PURCHASE_CREATED — os três eventos do accounts resolvem para o mesmo Outbox::Dispatchers::AccountsEvents em Outbox::DispatchRegistry.
Montagem do payload
O payload sai inteiro do Accounts::PurchaseSerializer — o mesmo do PURCHASE_CREATED, sem subclasse — e o Accounts::DispatchInstallmentOverdue só acrescenta overdue_installment por cima, do mesmo jeito que o INSTALLMENT_PAID acrescenta paid_installment:
ruby
PurchaseSerializer.new(payment, installments: gateway_installments)
.as_json.merge(overdue_installment: {number: ..., provider_ref: gateway_id})
O payload do atraso é o do PURCHASE_CREATED mais um campo — não dois contratos separados, e não um serializer por evento.
purchased_at vem do PurchaseSerializer e é payment.created_at.iso8601. A compra que este evento pode estar registrando é de meses atrás; gravá-la como comprada agora corromperia qualquer relatório por data do lado do accounts.
overdue_installment é resolvido na lista buscada ao vivo no Asaas (gateway_installments), pelo gateway_id do webhook — mesma busca do INSTALLMENT_PAID. É ela que decide se o evento sai: cobrança fora do plano, nada é despachado.
Atenção: a busca roda na lista antes do filtro de status do
PurchaseSerializer. Uma cobrança cujo status normalizado caia fora doSTATUS_MAP(ex.:DELETED→:unknown) continua emgateway_installmentsmas some deinstallments— nesse caso o evento é despachado apontando para uma parcela ausente da lista, e oaccountslevantaValueErrorno_find_overdue_installment(evento virafailedno outbox). OINSTALLMENT_PAIDtem exatamente o mesmo comportamento.
Payload
json
{
"event_type": "INSTALLMENT_OVERDUE",
"external_id": "<payment.pid>",
"occurred_at": "2026-09-22T09:33:18-03:00",
"payload": {
"external_id": "<payment.pid>",
"organization_slug": "citrg",
"product_slug": "curso-extensao-2026",
"payment_method": "BOLETO",
"provider": "asaas",
"purchased_at": "2026-07-15T10:00:00-03:00",
"customer": {
"name": "João da Silva",
"email": "joao@example.com",
"document": "12345678900",
"document_type": "CPF",
"phone": "11999999999",
"country": "BR",
"provider_customer_id": "cus_000006345005"
},
"installments": [
{"number": 1, "amount": 600.0, "due_on": "2026-08-10", "status": "PAID", "provider_ref": "pay_1"},
{"number": 2, "amount": 600.0, "due_on": "2026-09-10", "status": "OVERDUE", "provider_ref": "pay_2"}
],
"overdue_installment": {"number": 2, "provider_ref": "pay_2"}
}
}
Pontos de atenção:
installments[].statusé obrigatório aqui, no vocabulário doaccounts(PENDING/PAID/OVERDUE/REFUNDED, maiúsculas) —Accounts::PurchaseSerializer::INSTALLMENT_STATUSES. Uma cobrança com status fora desses quatro (deleted_or_canceled_by_new_payment,draft,creating_on_gateway,unknown) não entra na lista.occurred_atéTime.current.iso8601— o momento em que o atraso foi observado, nãopayment.created_at(que é o que oPURCHASE_CREATEDusa, e que aqui virapurchased_at).customeré aplicado do lado doaccountsquando a compra ainda não existe (purchases.Createchamacustomers.Sync), diferente doPURCHASE_CREATEDonde já era assim — aqui vale destacar porque umcustomersememaildevolveFailure("customer_incomplete")e o evento terminaFAILED.amounté número JSON, não string formatada; o campo de vencimento édue_on, nãodue_date;provider_refé ogateway_idda cobrança individual, não ogateway_installment_iddo plano.
Fora de escopo
- Backfill dos atrasos históricos — parcelas já
overduehoje não geram evento retroativo; só a próxima transição observada pelo webhook dispara. - Job de varredura para parcelas que vencem sem webhook do Asaas — risco aceito, mesmo padrão do
PURCHASE_CREATED. - Evento de parcela paga / carnê quitado (
PURCHASE_APPROVED,PURCHASE_REFUNDED) — ainda sem use case registrado noaccounts.
Teste vinculado
spec/serializers/accounts/purchase_serializer_spec.rb (tradução e filtro de status), spec/services/accounts/dispatch_installment_overdue_spec.rb, spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_overdue_event_spec.rb.
Referências
- ../../specs/20260922093318_accounts_installment_overdue_event.md — spec e decisões de arquitetura
- ../../rules/payments/accounts_installment_overdue_dispatch.md — regra de negócio (R-005)
- ./accounts_purchase_created_event.md — o contrato irmão, do qual este payload é um superconjunto
- ../../rules/payments/outbox_event_dispatch.md — mecanismo de despacho (R-004)