Status: implementado no
checkout-api(Accounts::DispatchInstallmentPaid, eventoaccounts.installment_paid). Do lado doaccounts, oINSTALLMENT_PAIDé a spec20260922153931_checkout_installment_paid.mddeles — oaccountsprecisa entrar primeiro, senão o evento viraIncomingEventFAILEDe espera reprocessamento.
Evento de parcela paga — accounts
TLDR: quando o webhook do Asaas confirma o pagamento de uma cobrança de um
Payment#kind == "standard", o checkout-api disparaINSTALLMENT_PAIDpara oaccountscom o pagamento inteiro dentro — cliente, carnê buscado ao vivo no gateway e o ponteiro da parcela paga.
Gatilho
Step IncomingWebhooks::PaymentGateways::Asaas::Payments::DispatchInstallmentPaidEvent, no final de PaymentsFlow, logo depois do DispatchPurchaseCreatedEvent. É um no-op silencioso a menos que:
payment.kind == "standard"(reparcelamento/quitação ficam fora — já têmnew_checkout.repayment_sync);- o evento do webhook está em
ASAAS_PAID_EVENTS(PAYMENT_CONFIRMED,PAYMENT_RECEIVED,PAYMENT_ANTICIPATED).
A cobrança paga é a do próprio webhook: context.gateway_id, preenchido por ValidateParams.
Sem exigência de carnê completo, ao contrário do PURCHASE_CREATED: segurar o aviso de pagamento por causa de uma leitura incompleta do gateway é pior que mandá-lo, porque a conciliação do accounts nunca apaga parcela ausente do payload. O que o disparo exige é encontrar a cobrança gateway_id na lista devolvida por Asaas::Installments::FetchFromGateway, como no INSTALLMENT_OVERDUE; não achando, Accounts::DispatchInstallmentPaid sai em silêncio, sem gravar nada e sem log — o próximo webhook da mesma cobrança tenta de novo. Só falha inesperada (gateway fora do ar, erro ao gravar) gera Rails.logger.error prefixado com [accounts.installment_paid] — e mesmo essa não quebra o webhook.
Idempotência
O Asaas manda mais de um evento de pagamento pela mesma cobrança (tipicamente PAYMENT_CONFIRMED e depois PAYMENT_RECEIVED). A idempotência é por cobrança, não por pagamento: antes de montar o payload, Accounts::DispatchInstallmentPaid procura um OutboxEvent com event_name: "accounts.installment_paid" cujo paid_installment.provider_ref seja este gateway_id (payload -> 'payload' -> 'paid_installment' ->> 'provider_ref'). OutboxEvent gravados antes da troca, com paid_installment ainda em string, também contam como já disparados: a consulta casa os dois formatos (... -> 'paid_installment' ->> 'provider_ref' = gateway_id OR ... ->> 'paid_installment' = gateway_id), e cada ramo só casa com o próprio formato. Sem isso, toda cobrança já avisada seria disparada de novo na próxima reentrega do Asaas. Duas parcelas diferentes do mesmo pagamento geram dois eventos; dois webhooks da mesma parcela geram um só.
O accounts também tem o próprio guard (parcela já PAID devolve sucesso sem alterar nada), então uma corrida entre dois webhooks quase simultâneos no máximo gasta um POST.
Requisição (feita por Outbox::Dispatchers::AccountsEvents)
POST /api/v1/events
Content-Type: application/json
X-Origin: checkout
Authorization: Bearer <ACCOUNTS_API_TOKEN>
O despachante é o mesmo do accounts.purchase_create: os dois event_name resolvem para Outbox::Dispatchers::AccountsEvents em Outbox::DispatchRegistry (ver regra de despacho do outbox).
Montagem do payload
Accounts::DispatchInstallmentPaid monta o envelope e delega o payload interno a Accounts::PurchaseSerializer — o mesmo serializer do PURCHASE_CREATED, porque o pagamento é o mesmo. A única diferença é o paid_installment, que o próprio dispatcher acrescenta: quem sabe qual cobrança foi liquidada é ele, não o serializer. Ele segue a forma do overdue_installment: {number, provider_ref, paid_at}, montado a partir da cobrança encontrada no gateway. O paid_at vai junto porque o accounts registra a data do pagamento a partir dele — sem o campo, ele usa o instante do processamento. O serializer não faz I/O: as parcelas vêm do gateway pelo service e entram por instance_options.
occurred_at é o instante do disparo (Time.current), não o created_at do pagamento — este vai no payload como purchased_at.
Payload
json
{
"event_type": "INSTALLMENT_PAID",
"external_id": "<payment.pid>",
"occurred_at": "2026-09-22T09:00:00-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", "paid_at": "2026-08-09", "provider_ref": "pay_1"},
{"number": 2, "amount": 600.0, "due_on": "2026-09-10", "status": "PAID", "paid_at": "2026-09-11", "provider_ref": "pay_2"}
],
"paid_installment": {"number": 2, "provider_ref": "pay_2", "paid_at": "2026-09-11"}
}
}
Pontos de atenção (o que diverge do PURCHASE_CREATED):
purchased_até ocreated_atdo pagamento, em ISO8601.- cada parcela ganha
status, traduzido peloAccounts::PurchaseSerializer::STATUS_MAP:paid → PAID,overdue → OVERDUE,refunded → REFUNDED,pending/draft/creating_on_gateway→PENDING. Cobrança em estado que oaccountsnão conhece (deleted_or_canceled_by_new_payment,unknown) sai da lista — omitir é seguro, porque a conciliação do outro lado não toca em parcela ausente. paid_installmenté um objeto{number, provider_ref, paid_at}, não oprovider_refcru — mesma forma dooverdue_installmentdoINSTALLMENT_OVERDUE, mais opaid_at. OMarkPaiddoaccountslocaliza a parcela porprovider_ref(comnumberde fallback) e lê a data do pagamento dopaid_at. A parcela continua na listainstallments, porque a conciliação doaccountsmonta o carnê por ela.- a busca da cobrança roda na lista do gateway, antes do filtro de status do
PurchaseSerializer— mesmo comportamento doINSTALLMENT_OVERDUEcom cobrança de status desconhecido (ver o doc dele). - cada parcela leva o próprio
paid_at, normalizado porFetchFromGateway(paymentDatedo Asaas, comconfirmedDatecomo fallback);nullpara quem ainda não pagou. amounté número JSON e o campo de vencimento édue_on, como noPURCHASE_CREATED.provider_refé ogateway_idda cobrança individual, nunca ogateway_installment_iddo plano.
Fora de escopo
INSTALLMENT_OVERDUE— oaccountsjá consome, mas o disparo não existe aqui; é trabalho com spec própria.- Estorno (
PAYMENT_REFUNDEDe companhia) não gera evento para oaccounts. - Varredura de pagamentos órfãos: se o Asaas nunca reenviar o webhook, o evento não sai.
Teste vinculado
spec/serializers/accounts/purchase_serializer_spec.rb, spec/services/accounts/dispatch_installment_paid_spec.rb, spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_paid_event_spec.rb, spec/services/outbox/dispatchers/accounts_events_spec.rb.
Referências
- ../../specs/20260922154500_accounts_installment_paid_event.md — spec e decisões
- accounts_purchase_created_event.md — contrato irmão,
PURCHASE_CREATED - ../../rules/payments/outbox_event_dispatch.md — mecanismo de despacho (R-004)