Parcela paga como objeto no evento INSTALLMENT_PAID

TLDR: o paid_installment do accounts.installment_paid passa de provider_ref em string para um objeto {number, provider_ref, paid_at}, na mesma forma do overdue_installment. A dedupe passa a reconhecer os dois formatos, para que eventos antigos não sejam disparados de novo.

Contexto

O accounts (PR #32) mudou o contrato de entrada do INSTALLMENT_PAID. Agora o paid_installment é a parcela inteira (number, amount, due_on, status, paid_at, provider_ref), no mesmo formato de uma entrada da lista installments. O formato em string não é mais aceito. Hoje todo evento de parcela paga falha no accounts com string indices must be integers, not 'str', porque o checkout ainda manda paid_installment: "pay_xxx".

Uma primeira tentativa (PR #271, branch refactor/installment-paid-in-evidence) foi fechada sem merge. O commit de docs dela (4d09824) já descreve o contrato novo, mas a dedupe descrita lá ignora os eventos antigos em string, o que faria esses eventos serem disparados de novo. Esta spec corrige esse ponto.

Objetivos

  • Enviar paid_installment como objeto {number, provider_ref, paid_at}, seguindo o padrão do overdue_installment. É o que o MarkPaid do accounts lê: provider_ref (com number de fallback) para localizar a parcela e paid_at para a data do pagamento. O endpoint não valida schema, então os outros campos do exemplo do contrato não são necessários.
  • Fazer a dedupe reconhecer OutboxEvent nos dois formatos: string (legado) e objeto (novo).
  • Aplicar a mesma dedupe de dois formatos ao backfill_accounts_installment_paid.rb.

Fora de escopo

  • Reenvio dos eventos que já falharam no accounts com o payload antigo. Isso fica para depois, em um trabalho separado. O backfill atual não faz esse reenvio: a dedupe dele trata as parcelas com evento legado como já enviadas, e ele só olha os últimos 3 dias (SINCE).
  • Mudanças no Accounts::PurchaseSerializer, no INSTALLMENT_OVERDUE ou no PURCHASE_CREATED.
  • Barrar cobrança com status fora do STATUS_MAP: o paid mantém o mesmo comportamento do overdue (guard na lista do gateway).

Mudanças

app/services/accounts/dispatch_installment_paid.rb

  • O envelope faz o merge de paid_installment: {number:, provider_ref:, paid_at:}, montado a partir da cobrança encontrada em gateway_installments, do mesmo jeito que o DispatchInstallmentOverdue monta o overdue_installment.
  • O guard continua na lista do gateway, sem mudança.
  • A dedupe em already_dispatched? passa a considerar os dois formatos:

    sql payload -> 'payload' -> 'paid_installment' ->> 'provider_ref' = :gateway_id OR payload -> 'payload' ->> 'paid_installment' = :gateway_id

    Em um objeto, ->> devolve o JSON em texto, que nunca é igual a um gateway_id puro. Em uma string, -> ... ->> 'provider_ref' devolve NULL. Assim, cada ramo casa só com o formato dele.

backfill_accounts_installment_paid.rb

  • O already_sent usa a mesma condição de dois formatos, comparando com installments.gateway_id.
  • Nada mais muda. O script continua cobrindo só as parcelas pagas que nunca tiveram evento.
  • O script é local e não versionado, então fica fora do PR. O ajuste é aplicado nele à parte.

spec/services/accounts/dispatch_installment_paid_spec.rb (escrito antes do código, TDD)

  • expected_envelope: "paid_installment" passa a ser {"number" => 2, "provider_ref" => "pay_2", "paid_at" => "2026-09-11"}.
  • Dedupe com evento legado: existe um OutboxEvent com {"payload" => {"paid_installment" => "pay_2"}} → não cria evento.
  • Dedupe com evento novo: existe um OutboxEvent com {"payload" => {"paid_installment" => {"provider_ref" => "pay_2"}}} → não cria evento.
  • Os cenários atuais continuam valendo: um evento por parcela, parcela ausente do gateway e falha do gateway.

Como verificar

  • bundle exec rspec spec/services/accounts/dispatch_installment_paid_spec.rb passa, junto com os specs vinculados no doc do contrato.
  • Backfill com DRY_RUN = true em staging/console: parcelas com evento legado em string não aparecem em Installments to send.
  • Depois do deploy, um pagamento real gera um OutboxEvent com paid_installment em objeto, e o IncomingEvent correspondente no accounts é processado sem erro.

Documentação

  • .project/docs/reference/payments/accounts_installment_paid_event.md: contrato com paid_installment em objeto {number, provider_ref, paid_at}, exemplo de payload atualizado e seção Idempotência com a dedupe nos dois formatos (a do commit 4d09824 corrigida: eventos legados em string continuam contando como já disparados).
  • .project/docs/rules/payments/accounts_installment_overdue_dispatch.md: a menção ao contrato antigo do paid (“nunca um objeto”) passa a dizer que o paid também é objeto.