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-api grava um OutboxEvent (accounts.installment_paid) com o pagamento inteiro dentro — cliente, carnê completo buscado ao vivo no gateway e o ponteiro da parcela paga — e despacha para POST /api/v1/events do accounts, no contrato INSTALLMENT_PAID.

Branch: feat/accounts-installment-paid-event, criada a partir de main — a infra de outbox e o PURCHASE_CREATED já 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_PAID para o accounts quando o webhook do Asaas confirmar pagamento de uma cobrança de um Payment#kind == "standard".
  • Reaproveitar Accounts::PurchaseSerializer e Asaas::Installments::FetchFromGateway em 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. O accounts já consome esse evento na branch feat/checkout-installment-overdue dele, mas o disparo nunca foi escrito aqui — não existe código nem spec no checkout-api. Esta spec deixa o caminho pronto para ele (o serializer nasce com status por parcela e ponteiro), mas não o implementa. É trabalho separado, com spec própria.
  • kind diferente de standard. Reparcelamento e quitação têm o evento new_checkout.repayment_sync e ficam fora, como no PURCHASE_CREATED.
  • Estorno. PAYMENT_REFUNDED e companhia não geram evento para o accounts ainda.
  • 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 é o payment.created_at.iso8601 — campo novo no payload (o accounts já o lia em purchases.Create, mas ele nunca era enviado).
  • cada parcela ganha status, já traduzido.
  • paid_installment é o provider_ref de uma parcela que precisa estar na lista installments.
  • cada parcela leva o próprio paid_at, normalizado pelo FetchFromGateway (paymentDate do Asaas, com confirmedDate como fallback).
  • amount é número JSON, não string formatada; o campo de vencimento é due_on.
  • provider_ref é o gateway_id da cobrança individual, nunca o gateway_installment_id do 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 status traduzido; 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ça gateway_id não estiver lá.
  • sai calado se já existir OutboxEvent deste event_name com o mesmo paid_installment.
  • OutboxEvent.create!(event_name:, payload: envelope) + Outbox::DispatchEventsJob.perform_later.
  • rescue => e → Rails.logger.error, devolve nil — 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 o accounts_purchase_created_event.md.
  • .project/docs/rules/payments/outbox_event_dispatch.md (R-004) — citar accounts.installment_paid como 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):

  1. serializer — purchase_serializer.rb (purchased_at, status e o descarte) e spec/serializers/accounts/purchase_serializer_spec.rb.
  2. disparo — accounts/dispatch_installment_paid.rb, dispatch_installment_paid_event.rb, payments_flow.rb e os dois specs correspondentes.
  3. dispatcher genérico — rename para accounts_events.rb, dispatch_registry.rb e a propagação nos specs.
  4. docs — referência do contrato, R-004 e README.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.