Status: implementado no checkout-api (Accounts::DispatchInstallmentPaid, evento accounts.installment_paid). Do lado do accounts, o INSTALLMENT_PAID é a spec 20260922153931_checkout_installment_paid.md deles — o accounts precisa entrar primeiro, senão o evento vira IncomingEvent FAILED e 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 dispara INSTALLMENT_PAID para o accounts com 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êm new_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 é o created_at do pagamento, em ISO8601.
  • cada parcela ganha status, traduzido pelo Accounts::PurchaseSerializer::STATUS_MAP: paid → PAID, overdue → OVERDUE, refunded → REFUNDED, pending/draft/creating_on_gateway → PENDING. Cobrança em estado que o accounts nã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 o provider_ref cru — mesma forma do overdue_installment do INSTALLMENT_OVERDUE, mais o paid_at. O MarkPaid do accounts localiza a parcela por provider_ref (com number de fallback) e lê a data do pagamento do paid_at. A parcela continua na lista installments, porque a conciliação do accounts monta o carnê por ela.
  • a busca da cobrança roda na lista do gateway, antes do filtro de status do PurchaseSerializer — mesmo comportamento do INSTALLMENT_OVERDUE com cobrança de status desconhecido (ver o doc dele).
  • cada parcela leva o próprio paid_at, normalizado por FetchFromGateway (paymentDate do Asaas, com confirmedDate como fallback); null para quem ainda não pagou.
  • amount é número JSON e o campo de vencimento é due_on, como no PURCHASE_CREATED.
  • provider_ref é o gateway_id da cobrança individual, nunca o gateway_installment_id do plano.

Fora de escopo

  • INSTALLMENT_OVERDUE — o accounts já consome, mas o disparo não existe aqui; é trabalho com spec própria.
  • Estorno (PAYMENT_REFUNDED e companhia) não gera evento para o accounts.
  • 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