Evento de parcela atrasada para o accounts

TLDR: quando o webhook do Asaas leva uma parcela de compra padrão para overdue, o checkout-api grava um OutboxEvent (accounts.installment_overdue) com o pagamento inteiro — cliente, todas as parcelas do carnê e a indicação de qual venceu — e despacha para o POST /api/v1/events do accounts. O accounts deixa de receber o PAYMENT_OVERDUE direto do gateway, e o atraso deixa de depender de a parcela — ou mesmo a compra — já existir lá: o payload é o do PURCHASE_CREATED mais purchased_at, status por parcela e a parcela vencida apontada, e é ele que alimenta o purchases.Sync do lado de lá.

Branch: a partir de main — o PURCHASE_CREATED foi mergeado em 7033256 (PR #268) e é o molde inteiro desta spec.

Contexto

Hoje o INSTALLMENT_OVERDUE do accounts nasce da tradução PAYMENT_OVERDUE → INSTALLMENT_OVERDUE no ASAAS_EVENT_MAP do synapse: o webhook do Asaas bate direto lá, e o payload que chega é o do gateway — só a cobrança vencida. A regra R-001 do accounts documenta a consequência como restrição deliberada (“nunca cria, só resolve pelo provider_ref”), porque o payload do Asaas não garante installmentNumber, value nem dueDate.

O preço é a ordem de chegada: se a Installment ainda não existir no accounts quando o atraso chegar, o use case levanta ValueError, soma tentativa e termina FAILED — o atraso simplesmente some.

Quem tem o pagamento completo é o checkout-api: cliente, produto e o carnê inteiro com gateway_id de cada cobrança, buscável ao vivo por Asaas::Installments::FetchFromGateway. Ele já entrega PURCHASE_CREATED para o accounts por esse caminho (outbox → POST /api/v1/events com X-Origin: checkout), em main desde a PR #268. Mandando o atraso da mesma origem, o evento chega autocontido e a restrição da R-001 cai.

O lado do accounts já está escrito e em revisão: branch feat/checkout-installment-overdue, PR ibft-corp/accounts#27, com spec própria (.project/docs/specs/20260922090311_checkout_as_financial_event_source.md). Lá o InstallmentOverdue virou orquestrador — sincroniza a Purchase, delega a conciliação do carnê a installments.Reconcile e marca OVERDUE a parcela apontada — e a via de entrada do Asaas sai inteira (AsaasEventMapper, ASAAS_EVENT_MAP, ASAAS_WEBHOOK_TOKEN). Esta spec cobre só o lado do checkout-api: quem produz o evento.

O contrato mudou em 514c716: a compra é criada, não só resolvida

A primeira versão do accounts resolvia a Purchase por external_id e levantava ValueError se não achasse. Os commits 514c716 (feat: sync the purchase from the overdue event) e f4af3c8 (docs: purchase sync resolves create or update) trocaram isso por purchases.Sync: não existe, cria; existe, atualiza. O motivo está na spec deles — uma compra anterior ao PURCHASE_CREATED nunca vai existir no accounts, e o atraso dela falharia “hoje, amanhã e sempre; não é corrida de entrega, é buraco permanente”.

A consequência direta para esta spec é o payload: como existe ramo de criação, ele precisa carregar tudo que purchases.Create consome — product_slug, payment_method, provider, customer (com e-mail, senão Failure("customer_incomplete")) e o purchased_at novo. Ou seja, o payload do INSTALLMENT_OVERDUE é o do PURCHASE_CREATED mais três coisas: purchased_at, status em cada parcela, e o overdue_installment.

O Sync é chamado com installments: [] de propósito: quem monta o carnê neste fluxo é o Reconcile, o único que aplica o status por parcela.

Nota sobre os nomes citados na task

A task descreve os arquivos com os nomes que a branch do PURCHASE_CREATED tinha no começo. Todos foram renomeados antes do merge; esta spec usa os nomes que estão em main:

Nome na task Nome em main
app/services/payments/purchases/build_accounts_event_payload.rb app/serializers/accounts/purchase_serializer.rb
app/services/payments/purchases/dispatch_accounts_event.rb app/services/accounts/dispatch_purchase_created.rb (Accounts::DispatchPurchaseCreated)
Asaas::Installments::LiveFetcher Asaas::Installments::FetchFromGateway

Por consequência, o orquestrador novo é Accounts::DispatchInstallmentOverdue em app/services/accounts/, e não Payments::DispatchAccountsInstallmentOverdueEvent — o namespace Accounts:: é onde o molde acabou morando.

Objetivos

  • Disparar INSTALLMENT_OVERDUE para o accounts quando o webhook do Asaas levar uma parcela de compra padrão para overdue, com o pagamento inteiro dentro.
  • Mandar o payload completo o bastante para o purchases.Sync do accounts criar a compra quando ela não existe lá — é o que fecha o buraco das compras anteriores ao PURCHASE_CREATED.
  • Reaproveitar o Accounts::PurchaseSerializer inteiro — o payload do atraso é um superconjunto do payload da compra criada.
  • Reaproveitar a infra genérica de outbox (OutboxEvent, Outbox::DispatchEventsJob, Outbox::DispatchRegistry) e o dispatcher HTTP já escrito para o accounts.
  • Idempotência por parcela (provider_ref da cobrança vencida), não por pagamento — um mesmo carnê pode atrasar várias vezes, em parcelas diferentes.
  • Nunca derrubar o processamento do webhook: qualquer falha na montagem ou gravação do evento é logada e engolida, como já faz Accounts::DispatchPurchaseCreated.

Fora de escopo

  • Qualquer mudança no accounts. O contrato consumido já está implementado e em PR aberta (#27). Esta spec só produz o evento no formato que lá já é esperado.
  • Disparo para kind diferente de standard. Purchase só existe no accounts para compra padrão (o PURCHASE_CREATED tem o mesmo recorte) — um atraso de reparcelamento cairia em purchase not found e viraria IncomingEvent FAILED. Reparcelamento já tem o caminho new_checkout.repayment_sync.
  • Backfill dos atrasos históricos. Parcelas que já estão overdue hoje não geram evento retroativo; só a próxima transição observada pelo webhook dispara. (O que deixou de ser problema é a compra antiga: com o purchases.Sync do 514c716, um pagamento que nunca teve PURCHASE_CREATED despachado passa a ter a Purchase criada pelo próprio evento de atraso.)
  • Job de varredura para parcelas que vencem sem webhook. O Asaas é quem avisa; se ele não avisar, não há evento. Mesmo risco aceito na #268.
  • Evento de parcela paga / carnê quitado. PURCHASE_APPROVED e PURCHASE_REFUNDED são outros eventos, ainda sem use case registrado no accounts.

Decisões

O gatilho é o status gravado na parcela, não o nome do evento do webhook. context.installment_params[:status] == :overdue reflete o estado que o webhook acabou de escrever, via a normalização que Asaas::Utils::NormalizePaymentStatus já faz: qualquer evento permitido que deixe a cobrança em overdue dispara — hoje PAYMENT_OVERDUE e um PAYMENT_UPDATED sobre cobrança já vencida. Amarrar no nome do evento significaria manter uma segunda lista em paralelo com PAYMENT_OVERDUE_STATUSES.

O recorte por nome de evento não some, no entanto: ele continua no context.should_update_payment, que é ASAAS_APOLO_PAYMENT_ALLOWED_EVENT_TYPES.include? sobre o nome do webhook. Na prática isso deixa PAYMENT_DUNNING_REQUESTED de fora — ele está em ASAAS_ALL_PAYMENT_EVENTS, mas não na lista permitida, então o && corta na primeira condição e o status nem é avaliado. É inofensivo: o Asaas manda PAYMENT_OVERDUE antes de qualquer negativação da mesma cobrança, então o evento já foi despachado, e a idempotência por provider_ref cortaria a repetição. Incluir negativação exigiria mexer no ASAAS_APOLO_PAYMENT_ALLOWED_EVENT_TYPES, que é compartilhado com o resto do fluxo de atualização de pagamento — fora do escopo desta spec.

Parcela com status intraduzível é filtrada da lista, não mapeada. O FetchFromGateway normaliza para o vocabulário do checkout-api (paid, pending, overdue, refunded, deleted_or_canceled_by_new_payment, draft, creating_on_gateway, unknown) e o accounts só conhece quatro (PENDING/PAID/OVERDUE/REFUNDED) — o Reconcile levanta erro em qualquer outro. Filtrar é seguro porque o Reconcile nunca apaga parcela local ausente do payload, e evita a colisão real de (purchase, number) quando uma cobrança cancelada e a cobrança nova que a substituiu dividem o mesmo installmentNumber. Mapear tudo para PENDING faria uma cobrança cancelada renascer e passar a contar no recálculo da Account.

A parcela apontada é resolvida dentro da lista já filtrada, não pelo webhook. O overdue_installment sai da entrada cujo gateway_id bate com context.gateway_id — não dos installment_params. Isso garante o invariante que o accounts exige (“a parcela apontada precisa estar na lista installments”) por construção: se a cobrança vencida não estiver na lista buscada ao vivo, o evento não é gravado.

Completude do carnê não é exigida, diferente do PURCHASE_CREATED. Lá o installments_complete? existe porque purchases.Create cria o carnê de uma vez e um carnê parcial ficaria errado para sempre. Aqui o Reconcile é incremental e não destrutivo, e o filtro de status por si só já faz a contagem divergir de payment.installment_count sempre que houver cobrança cancelada no plano. Exigir completude silenciaria o atraso justamente nos carnês mais bagunçados. O que impede evento vazio é a checagem da parcela apontada.

Idempotência por provider_ref da cobrança vencida. O PURCHASE_CREATED deduplica por external_id porque é um evento por compra; aqui são N por compra. A checagem é payload -> 'payload' -> 'overdue_installment' ->> 'provider_ref'. É defesa contra ruído (dois webhooks quase simultâneos para a mesma cobrança), não contra dano: o accounts já é idempotente para parcela já OVERDUE (Success() sem data, sem avisar o assinante duas vezes).

Os dois dispatchers do accounts passam a herdar de uma base. Outbox::Dispatchers::AccountsPurchaseCreate e o novo AccountsInstallmentOverdue postam para o mesmo endpoint, com os mesmos headers e o mesmo timeout — a única diferença seria o nome da classe. A base Outbox::Dispatchers::AccountsBase carrega o POST; as duas subclasses existem só para dar um alvo distinto por event_name no DispatchRegistry.

Desvio da task: o [como] pedia um dispatcher novo e independente. A extração é uma deduplicação de 100% do arquivo e mexe num arquivo que já está em main — vetável, mas a recomendação é fazer junto, enquanto só existem dois.

O payload é montado por herança, não por extração. Accounts::InstallmentOverdueSerializer < Accounts::PurchaseSerializer: herda external_id, product_slug, customer, payment_method, provider e installments, e acrescenta purchased_at e overdue_installment.

Supersede a decisão anterior desta spec. Quando o accounts ainda resolvia a Purchase sem criá-la, o payload do atraso não levava product_slug/payment_method/provider, a sobreposição com o PURCHASE_CREATED era de dois campos, e a resposta certa eram dois POROs compartilhados (Accounts::CustomerPayload e Accounts::InstallmentsPayload). Com o purchases.Sync do 514c716 a sobreposição virou total — extrair dois objetos para depois recompor o mesmo hash inteiro seria cerimônia. A herança diz a verdade sobre a relação entre os dois contratos: um é o outro, mais três coisas.

O PURCHASE_CREATED passa a mandar status na parcela. Efeito colateral de o #installments compartilhado passar a traduzir o status. É inofensivo: purchases.Create monta a lista lendo só number, amount, due_on e provider_ref, e ignora chaves extras.

O filtro de status também vale para o PURCHASE_CREATED. Mesmo método #installments, mesmo filtro. Na prática não muda nada lá: o evento só é despachado quando gateway_installments.size == payment.installment_count (que continua sendo medido sobre a lista crua do gateway), e um carnê recém-criado não tem cobrança cancelada. Mas se um dia tiver, o PURCHASE_CREATED passa a emitir menos parcelas do que installment_count — o accounts cria só as que vieram, e o próximo atraso concilia o resto.

Contrato do envelope

Gravado em OutboxEvent#payload e postado cru pelo dispatcher:

json { "event_type": "INSTALLMENT_OVERDUE", "external_id": "payment_2d4be59d215115", "occurred_at": "2026-09-22T09:33:18-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", "provider_ref": "pay_5847293846"}, {"number": 2, "amount": 600.0, "due_on": "2026-09-10", "status": "OVERDUE", "provider_ref": "pay_5847293847"} ], "overdue_installment": {"number": 2, "provider_ref": "pay_5847293847"} } }

Tudo que está acima da linha do customer é idêntico ao PURCHASE_CREATED e sai do PurchaseSerializer sem uma linha nova. O que é específico deste evento:

  • purchased_at = payment.created_at.iso8601. O PURCHASE_CREATED não manda o campo — a compra acabou de acontecer e o timezone.now() do accounts é a resposta certa. Aqui manda, porque a compra que este evento pode estar registrando é de meses atrás, e gravá-la como comprada agora corromperia a ordenação e qualquer relatório por data.
  • installments[].status, no vocabulário do accounts (PENDING/PAID/OVERDUE/REFUNDED, maiúsculas). Quem traduz é o checkout-api, do mesmo jeito que já traduz CREDIT_CARD → CARTAO. Cobrança com status fora dos quatro não entra na lista.
  • overdue_installment, que aponta uma parcela que precisa estar na lista installments — senão o accounts levanta ValueError e o evento vira FAILED.

E o que muda de significado em relação ao PURCHASE_CREATED:

  • occurred_at é Time.current, o momento em que o atraso foi observado — não payment.created_at, que é o que o PURCHASE_CREATED usa (lá os dois coincidem).
  • customer é aplicado, e não só carregado para auditoria: quando a compra ainda não existe, purchases.Create chama customers.Sync com esse bloco. Um customer sem email devolve Failure("customer_incomplete") e o evento termina FAILED — hoje o Customer do checkout-api sempre tem e-mail, mas é uma dependência real do contrato.
  • product_slug precisa resolver. Se não resolver nenhum Product no accounts, ele cria um com dados mínimos (name e external_id = o slug) — não falha, mas suja o catálogo. O PurchaseSerializer lê object.checkout.product.slug, que a R-003 garante existir.
  • amount é número JSON (600.0), 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.

Mudanças

1. Accounts::PurchaseSerializer — status traduzido e filtrado

Modificar: app/serializers/accounts/purchase_serializer.rb

#installments passa a traduzir o status normalizado do FetchFromGateway para o vocabulário do accounts, e a descartar a cobrança cujo status não tem tradução:

ruby INSTALLMENT_STATUSES = { pending: "PENDING", paid: "PAID", overdue: "OVERDUE", refunded: "REFUNDED" }.freeze

Cada entrada vira {number:, amount: total.to_f, due_on: due_date.to_s, status:, provider_ref: gateway_id}. Os outros quatro status possíveis (deleted_or_canceled_by_new_payment, draft, creating_on_gateway, unknown) não entram na lista.

Nada mais muda: external_id, product_slug, customer, payment_method e provider ficam como estão, e são justamente o que o evento de atraso herda.

1b. Accounts::InstallmentOverdueSerializer — o payload do atraso

Criar: app/serializers/accounts/installment_overdue_serializer.rb

```ruby module Accounts class InstallmentOverdueSerializer < PurchaseSerializer attributes :purchased_at, :overdue_installment

def purchased_at
  object.created_at.iso8601
end

def overdue_installment
  overdue = installments.find { |i| i[:provider_ref] == @instance_options.fetch(:overdue_provider_ref) }

  {number: overdue[:number], provider_ref: overdue[:provider_ref]}
end   end end ```

A parcela apontada sai da lista já montada e já filtrada — é o que garante, por construção, o invariante que o accounts exige. O orquestrador é quem checa que ela existe antes de serializar (ver §2), então aqui overdue nunca é nil.

2. Accounts::DispatchInstallmentOverdue — orquestrador

Criar: app/services/accounts/dispatch_installment_overdue.rb

Espelha Accounts::DispatchPurchaseCreated, o irmão no mesmo namespace. Assinatura call(payment:, gateway_id:).

ruby EVENT_NAME = "accounts.installment_overdue" EVENT_TYPE = "INSTALLMENT_OVERDUE"

Fluxo:

  1. return if already_dispatched? — OutboxEvent.where(event_name: EVENT_NAME).exists?(["payload -> 'payload' -> 'overdue_installment' ->> 'provider_ref' = ?", @gateway_id]).
  2. gateway_installments = Asaas::Installments::FetchFromGateway.call(gateway_service:, payment:), com gateway_service = Asaas::Client.new(payment.checkout.organization_id) — igual ao molde.
  3. return unless overdue_in_plan? — a cobrança vencida precisa estar na lista já traduzida e filtrada. Não está (foi cancelada, ou o plano ao vivo não a devolveu) → sai sem gravar, em silêncio: o próximo webhook do mesmo pagamento tenta de novo.
  4. OutboxEvent.create!(event_name: EVENT_NAME, payload: envelope) + Outbox::DispatchEventsJob.perform_later(event).
  5. rescue => e → Rails.logger.error e nil, como no molde (o webhook nunca cai por causa do evento).

O envelope monta Accounts::InstallmentOverdueSerializer.new(@payment, installments: gateway_installments, overdue_provider_ref: @gateway_id).as_json, com occurred_at: Time.current.iso8601.

Diferente do molde, não há installments_complete? — ver a decisão sobre completude.

3. Novo step no PaymentsFlow

Criar: app/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_overdue_event.rb

Ucasy::Base, espelhando DispatchPurchaseCreatedEvent:

```ruby required_attributes(:payment, :installment_params, :should_update_payment, :gateway_id)

def call return unless should_dispatch?

Accounts::DispatchInstallmentOverdue.call( payment: context.payment, gateway_id: context.gateway_id ) end

private

def should_dispatch? context.should_update_payment && context.installment_params[:status] == :overdue && context.payment.kind == “standard” end ```

Modificar: app/use_cases/incoming_webhooks/payment_gateways/asaas/payments/payments_flow.rb — adiciona o step no fim, depois de DispatchPurchaseCreatedEvent. Os dois nunca disparam no mesmo webhook: o de compra criada exige ASAAS_INITIAL_EVENTS, o de atraso exige status overdue, e PAYMENT_OVERDUE não está em ASAAS_INITIAL_EVENTS.

4. Dispatcher e registro

Criar: app/services/outbox/dispatchers/accounts_base.rb — o POST para "#{ENV["ACCOUNTS_API_URL"]}/api/v1/events" com X-Origin: checkout, Authorization: Bearer #{ENV["ACCOUNTS_API_TOKEN"]} e timeout de 3s; levanta erro claro se as envs não estiverem configuradas. É o corpo atual de AccountsPurchaseCreate, movido.

Criar: app/services/outbox/dispatchers/accounts_installment_overdue.rb — class AccountsInstallmentOverdue < AccountsBase.

Modificar: app/services/outbox/dispatchers/accounts_purchase_create.rb — passa a herdar de AccountsBase.

Modificar: app/services/outbox/dispatch_registry.rb — adiciona "accounts.installment_overdue" => Outbox::Dispatchers::AccountsInstallmentOverdue.

.env.example não muda: ACCOUNTS_API_URL e ACCOUNTS_API_TOKEN já entraram na #268.

Como verificar

O que precisa ser verdade Teste
#installments traduz os quatro status conhecidos e ordena por number spec/serializers/accounts/purchase_serializer_spec.rb (existe, atualizado)
Parcela com status intraduzível (ex.: deleted_or_canceled_by_new_payment) não entra na lista idem
O PURCHASE_CREATED segue montando o mesmo payload, agora com status por parcela idem
O payload do atraso herda product_slug, payment_method, provider e customer do PURCHASE_CREATED spec/serializers/accounts/installment_overdue_serializer_spec.rb
purchased_at é o created_at do pagamento, não o momento do atraso idem
overdue_installment aponta a cobrança do webhook e está dentro de installments idem
O envelope INSTALLMENT_OVERDUE bate com o contrato do accounts, com occurred_at no momento do atraso spec/services/accounts/dispatch_installment_overdue_spec.rb
Não grava evento quando a cobrança vencida não está na lista buscada ao vivo idem
Grava evento mesmo quando o carnê ao vivo tem menos parcelas que installment_count idem
Não grava evento duas vezes para o mesmo provider_ref idem
Grava evento novo para uma segunda parcela do mesmo pagamento idem
Falha na busca ao vivo é logada e não levanta idem
O step dispara quando o webhook leva a parcela para overdue spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_overdue_event_spec.rb
O step não dispara para kind diferente de standard idem
O step não dispara quando should_update_payment é falso ou o status não é overdue idem
O dispatcher posta o envelope com X-Origin: checkout e Bearer token spec/services/outbox/dispatchers/accounts_installment_overdue_spec.rb
O registro resolve accounts.installment_overdue spec/services/outbox/dispatch_registry_spec.rb (existe, atualizado)

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, contra o accounts da branch feat/checkout-installment-overdue (PR #27), nos dois ramos do purchases.Sync:

  1. Compra que o accounts já conhece — postar o webhook PAYMENT_OVERDUE numa compra padrão que já teve PURCHASE_CREATED processado.
  2. Compra que o accounts não conhece — postar o mesmo webhook numa compra anterior ao PURCHASE_CREATED, e conferir que a Purchase, o Product e o Customer nascem ali, com purchased_at na data original da compra (não em now()). É o caso que o 514c716 existe para resolver e o que esta spec passa a alimentar.

Nos dois: (a) OutboxEvent sent no admin do checkout-api, (b) IncomingEvent PROCESSED no admin do accounts, (c) a Installment apontada em OVERDUE e a Account do pagador recalculada.

Ordem de deploy

O accounts (PR #27) antes do checkout-api ligar o disparo. Entre os dois deploys o Asaas passa a receber 404 no webhook apontado para o accounts e o evento novo ainda não está sendo disparado — janela curta e sem registro, porque com o mapper removido o evento nem vira IncomingEvent. Se a janela precisar ser zero, a PR 3 do accounts (remoção da via do Asaas) sai depois do disparo daqui estar em produção.

Passo de operação, fora do código: desligar, no painel do Asaas, o webhook apontado para o accounts.

Documentação

  • Criar .project/docs/reference/payments/accounts_installment_overdue_event.md — contrato do envelope, espelhando accounts_purchase_created_event.md, deixando explícito que ele é o payload do PURCHASE_CREATED mais purchased_at, status por parcela e overdue_installment, que as parcelas vêm de busca ao vivo no Asaas (nunca do Installment local) e que status intraduzível é filtrado.
  • Criar .project/docs/rules/payments/accounts_installment_overdue_dispatch.md (R-005) — quando o atraso é despachado (standard + status overdue), idempotência por provider_ref, filtro de status, o silêncio deliberado quando a cobrança vencida não está no plano, e por que aqui não se exige carnê completo.
  • Atualizar .project/docs/rules/payments/outbox_event_dispatch.md (R-004) — citar accounts.installment_overdue como terceiro consumidor da tabela genérica.
  • Atualizar .project/docs/reference/payments/accounts_purchase_created_event.md — a parcela do PURCHASE_CREATED passa a levar status, e cobrança com status intraduzível deixa de entrar na lista.
  • Atualizar .project/docs/RULES.md e .project/docs/README.md — linha do R-005 e o índice dos dois documentos novos.