Evento de parcela paga para o accounts
TLDR: quando o webhook do Asaas confirma o pagamento de uma cobrança de um pagamento padrão, o
checkout-apigrava umOutboxEvent(accounts.installment_paid) com o pagamento inteiro dentro — cliente, carnê completo buscado ao vivo no gateway e o ponteiro da parcela paga — e despacha paraPOST /api/v1/eventsdoaccounts, no contratoINSTALLMENT_PAID.
Branch:
feat/accounts-installment-paid-event, criada a partir demain— a infra de outbox e oPURCHASE_CREATEDjá estão mesclados (#268).
Contexto
O accounts mantém a posição financeira do aluno (Account) derivada das parcelas. Hoje ele
só recebe do checkout-api o PURCHASE_CREATED — sabe que a compra existe, nunca sabe que
uma parcela foi paga. O efeito prático é o aluno que pagou continuar aparecendo como devedor
para quem consome a posição (Apolo, nectar-charges).
O accounts está abrindo o INSTALLMENT_PAID na spec
20260922153931_checkout_installment_paid.md (repo accounts), com um contrato autocontido:
o evento traz o pagamento inteiro, a compra é criada se ainda não existir, o carnê é
conciliado e a parcela apontada é marcada PAID. Este lado é quem produz esse payload.
A peça cara já existe aqui: Asaas::Installments::FetchFromGateway devolve todas as parcelas
do plano com number, total, due_date, gateway_id, paid_at e status normalizado, e
Accounts::PurchaseSerializer já monta cliente, produto e método de pagamento no vocabulário
do accounts. Falta o status por parcela (hoje descartado no map), o purchased_at e o
ponteiro da parcela paga.
Objetivos
- Disparar
INSTALLMENT_PAIDpara oaccountsquando o webhook do Asaas confirmar pagamento de uma cobrança de umPayment#kind == "standard". - Reaproveitar
Accounts::PurchaseSerializereAsaas::Installments::FetchFromGatewayem vez de montar um segundo caminho de payload. - Reaproveitar a infra de outbox (
OutboxEvent,Outbox::DispatchEventsJob,Outbox::DispatchRegistry), sem migration nova. - Nunca derrubar o processamento do webhook: falha ao montar ou gravar o evento vira log, não exceção.
Fora de escopo
- O
INSTALLMENT_OVERDUE. Oaccountsjá consome esse evento na branchfeat/checkout-installment-overduedele, mas o disparo nunca foi escrito aqui — não existe código nem spec nocheckout-api. Esta spec deixa o caminho pronto para ele (o serializer nasce comstatuspor parcela e ponteiro), mas não o implementa. É trabalho separado, com spec própria. kinddiferente destandard. Reparcelamento e quitação têm o eventonew_checkout.repayment_synce ficam fora, como noPURCHASE_CREATED.- Estorno.
PAYMENT_REFUNDEDe companhia não geram evento para oaccountsainda. - Varredura de pagamentos órfãos. Mesmo risco aceito no
PURCHASE_CREATED: se o Asaas nunca reenviar o webhook, o evento não sai.
Decisões
O gatilho é o webhook de pagamento confirmado, não o Installment local. ASAAS_PAID_EVENTS
(PAYMENT_CONFIRMED, PAYMENT_RECEIVED, PAYMENT_ANTICIPATED,
config/initializers/asaas_constants.rb:36) é o que diz “essa cobrança foi paga” — mesmo
critério que o resto do repo já usa. O passo entra no fim do PaymentsFlow, depois de
Payments::Update, ao lado do DispatchPurchaseCreatedEvent.
O Asaas manda mais de um evento de pagamento pela mesma cobrança (tipicamente
PAYMENT_CONFIRMED e depois PAYMENT_RECEIVED). O disparo é idempotente por cobrança,
não por pagamento: já existir um OutboxEvent de accounts.installment_paid cujo
paid_installment é este gateway_id, não dispara de novo. Vale lembrar que o
accounts também tem o próprio guard (parcela já PAID devolve Success() sem data), então
uma corrida entre dois webhooks quase simultâneos no máximo gasta um POST — não avisa o
assinante duas vezes.
As parcelas vêm ao vivo do gateway, não do Installment local. Mesmo motivo do
PURCHASE_CREATED: o registro local é preenchido um webhook por vez e pode estar incompleto.
FetchFromGateway devolve o carnê inteiro com o status de cada cobrança.
Sem exigência de carnê completo. O PURCHASE_CREATED só dispara quando a contagem de
parcelas do gateway bate com installment_count, porque ele cria a compra e um carnê
parcial nasceria errado. Aqui é o contrário: segurar o aviso de pagamento por causa de uma
leitura incompleta é pior que mandá-lo — o accounts concilia o que chegar e nunca apaga
parcela que não veio no payload. O que o disparo exige é encontrar a cobrança paga
(gateway_id) dentro da lista; não achando, não dispara e o próximo webhook tenta de novo.
O status é traduzido aqui, no vocabulário do accounts. Mesma divisão que o
PAYMENT_METHODS já usa: paid → PAID, overdue → OVERDUE, refunded → REFUNDED,
pending/draft/creating_on_gateway → PENDING. O accounts não aprende
deleted_or_canceled_by_new_payment nem unknown — cobrança nesses estados sai da lista.
Omitir é seguro: a conciliação do outro lado nunca apaga parcela ausente do payload, então o
que fica de fora simplesmente não é tocado.
Um serializer só para os dois eventos, não uma subclasse. O pagamento é o mesmo nos
dois: a primeira versão desta spec previa um Accounts::InstallmentPaidSerializer herdando
do Accounts::PurchaseSerializer só para acrescentar purchased_at, status por parcela e
o ponteiro. Duas classes para três campos não se paga. O PurchaseSerializer passa a mandar
purchased_at, status e paid_at por parcela sempre, e o paid_installment — a única
coisa realmente específica do evento — é acrescentado pelo DispatchInstallmentPaid, que é
quem sabe qual cobrança o webhook liquidou.
E o paid_installment é só a referência, não uma cópia da parcela. A parcela paga sempre
está na lista installments: montar um objeto com number e paid_at repetiria o que já
foi serializado e abriria espaço para os dois divergirem. Por isso paid_at passa a ser
campo de cada parcela — o gateway já devolve para todas — e o ponteiro é o provider_ref.
O payload do PURCHASE_CREATED muda junto, de propósito. Ele passa a levar
purchased_at e status por parcela, e a omitir cobrança em estado que o accounts não
conhece. O purchased_at conserta um defeito: sem ele, purchases.Create do accounts
cai em timezone.now() e grava a hora da ingestão como data da compra. O status o
installments.CreateMany ignora, então não muda nada lá hoje. A omissão de cobrança
cancelada é a mesma regra do outro evento — carnê novo não nasce com cobrança que o gateway
já matou.
O dispatcher de outbox passa a ser genérico. Outbox::Dispatchers::AccountsPurchaseCreate
não tem nada de “purchase create”: é um POST para /api/v1/events do accounts com
X-Origin: checkout e Bearer token. Vira Outbox::Dispatchers::AccountsEvents, registrado
para os dois event_name. O rename é seguro porque o OutboxEvent guarda event_name, não
nome de classe — nenhum evento pendente no banco quebra.
Contrato do envelope
json
{
"event_type": "INSTALLMENT_PAID",
"external_id": "payment_2d4be59d215115",
"occurred_at": "2026-09-22T09:00:00-03:00",
"payload": {
"external_id": "payment_2d4be59d215115",
"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"
},
"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": "pay_2"
}
}
Pontos que divergem do PURCHASE_CREATED e são fáceis de errar:
purchased_até opayment.created_at.iso8601— campo novo no payload (oaccountsjá o lia empurchases.Create, mas ele nunca era enviado).- cada parcela ganha
status, já traduzido. paid_installmenté oprovider_refde uma parcela que precisa estar na listainstallments.- cada parcela leva o próprio
paid_at, normalizado peloFetchFromGateway(paymentDatedo Asaas, comconfirmedDatecomo fallback). amounté número JSON, não string formatada; o campo de vencimento édue_on.provider_refé ogateway_idda cobrança individual, nunca ogateway_installment_iddo plano.
Mudanças
app/serializers/accounts/purchase_serializer.rb (modificar)
O único serializer dos dois eventos:
STATUS_MAP—paid → "PAID",overdue → "OVERDUE",refunded → "REFUNDED",pending/draft/creating_on_gateway→"PENDING".purchased_at—object.created_at.iso8601, atributo novo.- cada parcela leva
statustraduzido; parcela cujo status não está no mapa sai da lista.
O paid_installment não entra aqui — é o DispatchInstallmentPaid que o acrescenta ao
payload, como provider_ref puro.
app/services/accounts/dispatch_installment_paid.rb (novo)
Accounts::DispatchInstallmentPaid.call(payment:, gateway_id:), no molde do
Accounts::DispatchPurchaseCreated:
EVENT_NAME = "accounts.installment_paid",EVENT_TYPE = "INSTALLMENT_PAID".- busca o carnê com
Asaas::Installments::FetchFromGateway; sai calado se a cobrançagateway_idnão estiver lá. - sai calado se já existir
OutboxEventdesteevent_namecom o mesmopaid_installment. OutboxEvent.create!(event_name:, payload: envelope)+Outbox::DispatchEventsJob.perform_later.rescue => e→Rails.logger.error, devolvenil— o webhook não cai por causa do evento.
app/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_paid_event.rb (novo)
Step do Ucasy::Flow, required_attributes(:payment, :gateway_id, :event_dispatcher_event_name).
Dispara quando payment.kind == "standard" e o evento está em ASAAS_PAID_EVENTS; fora
disso, no-op.
app/use_cases/incoming_webhooks/payment_gateways/asaas/payments/payments_flow.rb (modificar)
Acrescenta o step novo no fim do flow(...), depois do DispatchPurchaseCreatedEvent.
app/services/outbox/dispatchers/accounts_events.rb (renomeado de accounts_purchase_create.rb)
Mesmo corpo, classe Outbox::Dispatchers::AccountsEvents.
app/services/outbox/dispatch_registry.rb (modificar)
ruby
"accounts.purchase_create" => Outbox::Dispatchers::AccountsEvents,
"accounts.installment_paid" => Outbox::Dispatchers::AccountsEvents
Propagação do rename
spec/services/outbox/dispatchers/accounts_purchase_create_spec.rb →
accounts_events_spec.rb, a referência em spec/services/outbox/dispatch_registry_spec.rb e
a linha 27 de .project/docs/reference/payments/accounts_purchase_created_event.md. A factory
spec/factories/outbox_events.rb não muda: event_name já é sobrescrito por quem a usa.
Como verificar
| O que precisa ser verdade | Teste |
|---|---|
O envelope bate com o contrato do accounts (campos, amount numérico, due_on, provider_ref) |
spec/services/accounts/dispatch_installment_paid_spec.rb — “creates an OutboxEvent with the envelope and enqueues the dispatch job” |
O status de cada parcela é traduzido para o vocabulário do accounts |
spec/serializers/accounts/purchase_serializer_spec.rb — “translates the normalized gateway status to the accounts vocabulary” |
Cobrança em estado que o accounts não conhece fica de fora da lista |
idem — “leaves out an installment in a status the accounts does not know” |
paid_installment é a referência da cobrança do webhook, e cada parcela leva paid_at |
spec/services/accounts/dispatch_installment_paid_spec.rb — “creates an OutboxEvent with the envelope and enqueues the dispatch job” |
O PURCHASE_CREATED passa a levar purchased_at e status |
spec/serializers/accounts/purchase_serializer_spec.rb — “serializes the PURCHASE_CREATED payload” e spec/services/accounts/dispatch_purchase_created_spec.rb |
Não dispara para kind diferente de standard |
spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_paid_event_spec.rb — “não dispara para reparcelamento” |
| Não dispara em evento que não é de pagamento confirmado | idem — “não dispara em PAYMENT_CREATED” |
Dispara em cada um dos ASAAS_PAID_EVENTS |
idem — “dispara em PAYMENT_CONFIRMED, PAYMENT_RECEIVED e PAYMENT_ANTICIPATED” |
| É idempotente por cobrança: o segundo webhook da mesma parcela não grava evento novo | spec/services/accounts/dispatch_installment_paid_spec.rb — “não dispara duas vezes pela mesma cobrança” |
| Duas parcelas diferentes do mesmo pagamento geram dois eventos | idem — “dispara uma vez por parcela paga” |
| Cobrança ausente do carnê do gateway não dispara | idem — “não dispara quando a cobrança não está no plano” |
| Falha ao montar o evento vira log e não derruba o webhook | idem — “loga e segue quando o gateway falha” |
O dispatcher posta com X-Origin: checkout e Bearer token |
spec/services/outbox/dispatchers/accounts_events_spec.rb (renomeado) — “posta para o endpoint de eventos do accounts” |
Os dois event_name do accounts resolvem para o dispatcher |
spec/services/outbox/dispatch_registry_spec.rb — “resolve accounts.installment_paid” |
make run.test path=spec/serializers/accounts spec/services/accounts spec/services/outbox spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments
Ponta a ponta, em dev: disparar o webhook de PAYMENT_RECEIVED do Asaas contra o
PaymentsJob e conferir o OutboxEvent criado no admin (/admin/outbox_events), com o
accounts apontado por ACCOUNTS_API_URL.
Documentação
.project/docs/reference/payments/accounts_installment_paid_event.md(novo) — o contrato do envelope, espelhando oaccounts_purchase_created_event.md..project/docs/rules/payments/outbox_event_dispatch.md(R-004) — citaraccounts.installment_paidcomo terceiro consumidor da tabela genérica e o dispatcher agora compartilhado..project/docs/README.md— índice.
Entrega
PRs sequenciais na branch feat/accounts-installment-paid-event (limite de 5 arquivos):
- serializer —
purchase_serializer.rb(purchased_at,statuse o descarte) espec/serializers/accounts/purchase_serializer_spec.rb. - disparo —
accounts/dispatch_installment_paid.rb,dispatch_installment_paid_event.rb,payments_flow.rbe os dois specs correspondentes. - dispatcher genérico — rename para
accounts_events.rb,dispatch_registry.rbe a propagação nos specs. - docs — referência do contrato,
R-004eREADME.md.
A PR 3 depende da 2 só na ordem de leitura: até ela, accounts.installment_paid não resolve
dispatcher e o OutboxEvent fica failed com “nenhum despachante registrado”. Se as duas não
saírem no mesmo deploy, inverta — registre o dispatcher antes de ligar o disparo.
Ordem de deploy
O accounts precisa entrar primeiro: enquanto o INSTALLMENT_PAID não existir na taxonomia
de lá, o evento vira IncomingEvent FAILED (“no use case registered for event”). Não se
perde nada — o evento fica gravado e pode ser reprocessado pelo admin —, mas o aluno continua
aparecendo como devedor até o reprocessamento.