Status: implementado dos dois lados. No checkout-api, Accounts::DispatchInstallmentOverdue grava um OutboxEvent (accounts.installment_overdue) a partir do webhook do Asaas; Outbox::DispatchEventsJob faz o POST de fato (ver regra de despacho do outbox). No accounts, o use case installments.InstallmentOverdue já existe na branch feat/checkout-installment-overdue deles.

Evento de parcela atrasada — accounts

TLDR: quando o webhook do Asaas leva uma parcela de compra padrão para overdue, o checkout-api dispara INSTALLMENT_OVERDUE para o accounts com o pagamento inteiro — cliente, todas as parcelas do carnê e a indicação de qual venceu. O payload é o do PURCHASE_CREATED mais purchased_at, status por parcela e overdue_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_payment seja verdadeiro (o webhook de fato escreveu na Installment);
  • 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 em overdue dispara: hoje PAYMENT_OVERDUE e um PAYMENT_UPDATED sobre cobrança já vencida. PAYMENT_DUNNING_REQUESTED fica de fora — não está em ASAAS_APOLO_PAYMENT_ALLOWED_EVENT_TYPES, então o should_update_payment acima já corta;
  • payment.kind == "standard" (reparcelamento/settlement ficam fora — só compra padrão tem Purchase no accounts).

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 do STATUS_MAP (ex.: DELETED → :unknown) continua em gateway_installments mas some de installments — nesse caso o evento é despachado apontando para uma parcela ausente da lista, e o accounts levanta ValueError no _find_overdue_installment (evento vira failed no outbox). O INSTALLMENT_PAID tem 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 do accounts (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ão payment.created_at (que é o que o PURCHASE_CREATED usa, e que aqui vira purchased_at).
  • customer é aplicado do lado do accounts quando a compra ainda não existe (purchases.Create chama customers.Sync), diferente do PURCHASE_CREATED onde já era assim — aqui vale destacar porque um customer sem email devolve Failure("customer_incomplete") e o evento termina FAILED.
  • amount é número JSON, não string formatada; o campo de vencimento é due_on, não due_date; provider_ref é o gateway_id da cobrança individual, não o gateway_installment_id do plano.

Fora de escopo

  • Backfill dos atrasos históricos — parcelas já overdue hoje 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 no accounts.

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