Sincronização de reparcelamento assíncrona via outbox pattern

TLDR: a branch feat/repayment-sync-new-checkout sincroniza o reparcelamento com o ibft-backend por uma chamada HTTP síncrona, inline no member_action do ActiveAdmin. Esta spec substitui isso por um outbox genérico: o evento é gravado numa tabela, na mesma transação que persiste o reparcelamento, e um job dedicado ao evento faz o envio, reenfileirando-se a cada 5s até 3 tentativas antes de marcar como falho.

Contexto

A spec anterior implementou a sincronização como uma chamada HTTParty.post síncrona dentro de CheckoutPayments::RepaymentService#sync_with_new_checkout, com retry inline (sleep + até 3 tentativas) e falha reportada ao Sentry. Isso acopla o tempo de resposta do ActiveAdmin à disponibilidade do ibft-backend, e não deixa rastro persistido de tentativas/falhas além do Sentry.

A decisão agora é desacoplar: gravar o evento a ser enviado numa tabela (outbox pattern) na mesma transação em que o reparcelamento é persistido, e um job enfileirado junto com o evento faz o envio de fato. Isso também abre espaço para outros disparos de saída do checkout-api (hoje só a sincronização de reparcelamento é migrada; a unificação de cliente, que hoje sai direto via IbftEmailSyncService/IbftMergeCustomersJob, fica de fora desta spec, mas a tabela é desenhada para comportar isso no futuro sem migração nova).

Referência de forma (não de comportamento): o accounts tem synapse.IncomingEvent, uma tabela de eventos recebidos com checksum para dedup. Ela não resolve o problema de despacho com tentativas — é usada aqui só como inspiração de forma (payload jsonb, status como enum, timestamps).

Objetivos

  • Criar uma tabela genérica de outbox (outbox_events) com event_name, payload (jsonb), status, attempts, last_error, sent_at.
  • CheckoutPayments::RepaymentService grava um OutboxEvent (event_name: "new_checkout.repayment_sync") na mesma transação de banco que persiste o reparcelamento (e o cancelamento do parcelamento original, quando houver) — nenhuma chamada HTTP acontece nesse fluxo.
  • Um job (Outbox::DispatchEventsJob), enfileirado com o evento no momento em que ele é criado, resolve o despachante certo pelo event_name (registro simples event_name => classe) e tenta o envio; enquanto o evento seguir pending, ele se reenfileira via retry_on a cada 5 segundos.
  • Contagem de tentativas: qualquer falha (HTTP não-2xx, exceção, timeout) soma 1 em attempts; ao atingir 3, o evento vira failed. Sem distinção entre erro retryable/definitivo — o retry_on do job espaça as tentativas.
  • Evento failed fica visível num recurso novo do ActiveAdmin (outbox_events), com ação de reenvio manual que volta o evento para pending e zera attempts.
  • O despachante da sincronização de reparcelamento (Outbox::Dispatchers::NewCheckoutRepaymentSync) mantém o contrato HTTP acordado com o ibft-backend: POST #{NEW_CHECKOUT_API_URL}/v1/sync/repayments, header Authorization: Bearer #{NEW_CHECKOUT_API_TOKEN}, corpo = payload gravado no evento.

Fora de escopo

  • Migrar a sincronização de unificação de cliente (IbftEmailSyncService) para o outbox — fica para outra iniciativa; a tabela é só desenhada para permitir isso sem migração nova.
  • Reenvio automático além das 3 tentativas — depois de failed, só o reenvio manual pelo ActiveAdmin.
  • Backoff exponencial ou agendamento por evento (next_attempt_at) — o retry_on com intervalo fixo de 5s já serve de espaçamento entre tentativas.
  • Alerta automático (Sentry) na falha definitiva — a versão síncrona reportava ao Sentry; esta versão substitui isso pela visibilidade no ActiveAdmin. Não há alerta ativo proativo (e-mail, Slack) nesta spec.
  • Mudar o contrato do endpoint receptor (new_checkout_repayment_endpoint.md) — payload, campos e política de retry do lado do ibft-backend continuam os mesmos; só o mecanismo de disparo do lado do checkout-api muda.
  • Qualquer mudança na lógica de negócio do reparcelamento em si (criação/cancelamento no Asaas) além de separar a chamada ao gateway da persistência local, necessário para a transação atômica do outbox.
  • Controle de concorrência entre execuções do job (lock, SELECT FOR UPDATE SKIP LOCKED) — o pior caso de dois sweeps sobrepostos pegarem o mesmo evento é um envio duplicado, e o contrato do ibft-backend já é idempotente por repayment.reference (responde 200 duplicate em vez de criar de novo). Não há necessidade de lock adicional para esse volume e essa garantia do lado receptor.

Mudanças

db/migrate/<timestamp>_create_outbox_events.rb

Nova tabela outbox_events:

Coluna Tipo Detalhe
event_name string not null, indexado — identifica o tipo de evento (ex.: new_checkout.repayment_sync)
payload jsonb not null — corpo exato a ser enviado
status string not null, default "pending" — pending, sent, failed
attempts integer not null, default 0
last_error text nullable — última mensagem de erro (HTTP status + corpo, ou exceção)
sent_at datetime nullable — preenchido quando o envio é confirmado

Índice composto [:status, :created_at] (consulta do job) e índice simples em :event_name.

app/models/outbox_event.rb

  • enum :status, {pending: "pending", sent: "sent", failed: "failed"}, default: :pending
  • validates :event_name, :payload, presence: true
  • scope :pending, -> { where(status: :pending).order(:created_at) }
  • #register_failure!(error_message): attempts += 1; se attempts >= 3, status = :failed; grava last_error.
  • #mark_sent!: status = :sent, sent_at = Time.current.
  • #retry!: volta pra pending, attempts = 0, last_error = nil — usado pelo reenvio manual.

app/jobs/outbox/dispatch_events_job.rb

  • OutboxEvent.pending.find_each → resolve Outbox::DISPATCHERS[event.event_name]; se não houver despachante registrado, chama register_failure! com essa mensagem (não deve acontecer em operação normal — é guarda contra dado inconsistente).
  • Chama dispatcher.call(event.payload); sucesso (2xx) → mark_sent!; falha (exceção ou resposta não-2xx) → register_failure! com status HTTP + corpo, ou mensagem da exceção.
  • Cada evento processado dentro do seu próprio save/update — uma falha num evento não impede os demais de serem processados no mesmo sweep.

app/services/outbox/dispatchers/new_checkout_repayment_sync.rb

  • Recebe o payload (hash) e faz o POST via HTTParty, exatamente como CheckoutPayments::RepaymentService#perform_new_checkout_request faz hoje (mesma URL, headers, timeout de 3s) — só que sem retry embutido (o retry agora é responsabilidade do job/outbox, via novas tentativas em sweeps futuros).
  • .call(payload) retorna a HTTParty::Response; job decide sucesso/falha a partir dela.
  • Sem NEW_CHECKOUT_API_URL/NEW_CHECKOUT_API_TOKEN configurados: levanta erro claro (register_failure! recebe essa mensagem) — mantém o mesmo fail-safe do lado do checkout-api que existe hoje, só que visível no evento em vez de um log de warning solto.

app/services/outbox/dispatch_registry.rb (ou constante equivalente)

  • Outbox::DISPATCHERS = {"new_checkout.repayment_sync" => Outbox::Dispatchers::NewCheckoutRepaymentSync}.freeze
  • Adicionar um novo tipo de evento no futuro = uma entrada nova aqui + uma classe nova, sem tocar no job.

app/services/checkout_payments/repayment_service.rb

Refatoração para separar a chamada ao gateway (Asaas) da persistência local, permitindo envolver repayment + original_payment + outbox event numa única transação:

  • create_gateway_payment e destroy_original_payment_on_gateway deixam de ser chamados de fora (só o admin os chamava) e viram métodos privados: passam a só fazer a chamada ao Asaas e montar atributos em memória (@repayment/previous_payment), sem salvar.
  • A mudança em destroy_original_payment_on_gateway também corrige um efeito colateral que existe hoje: o código atual salva previous_payment.status antes de confirmar os cancelamentos no Asaas; na versão nova, a persistência só acontece depois que as chamadas ao Asaas (criação do novo pagamento e cancelamento do antigo) já terminaram.
  • Novo método público orquestrador (generate!, chamado pelo admin no lugar dos três métodos antigos) executa os dois passos acima (o segundo só se houver original_payment), monta o payload de sincronização (reaproveitando os métodos privados de payload já existentes, sem mudança de formato) e abre ActiveRecord::Base.transaction do @repayment.save!; previous_payment&.save!; audit_event; OutboxEvent.create!(event_name: "new_checkout.repayment_sync", payload: payload) end. Retorna @repayment, para o admin redirecionar como já faz hoje.
  • Removidos: sync_with_new_checkout, post_repayment_sync, perform_new_checkout_request, as constantes de retry/backoff/timeout (NEW_CHECKOUT_RETRYABLE_CODES, NEW_CHECKOUT_MAX_ATTEMPTS, NEW_CHECKOUT_BACKOFF_SECONDS, NEW_CHECKOUT_REQUEST_TIMEOUT) — essa lógica migra para Outbox::Dispatchers::NewCheckoutRepaymentSync e para o OutboxEvent/job.
  • Mantidos sem mudança: todos os métodos privados de montagem de payload (build_repayment_sync_payload, payment_sync_summary, original_payment_sync_payload, installments_sync_payload, etc.) — o formato do contrato não muda.

app/admin/payments.rb e app/admin/campaign_payments.rb

  • member_action :generate_repayment passa a ter uma única chamada: edited_payment = repayment_service.generate! — remove a checagem externa de original_payment.present? e a chamada separada a sync_with_new_checkout (o orquestrador cuida disso internamente).

app/admin/outbox_events.rb (novo)

  • index: event_name, status, attempts, sent_at, created_at.
  • show: inclui payload e last_error formatados.
  • Ação de reenvio (member_action :retry ou batch_action) disponível só para eventos failed, chama event.retry!.
  • Somente leitura/reenvio — sem criação ou edição manual de eventos pelo admin.

config/initializers/good_job.rb

  • Nenhuma entrada de cron: o despacho é disparado por evento, no momento em que ele é criado.

.env.example

  • NEW_CHECKOUT_API_URL e NEW_CHECKOUT_API_TOKEN continuam existindo, agora lidas por Outbox::Dispatchers::NewCheckoutRepaymentSync em vez de RepaymentService.

Specs

  • spec/models/outbox_event_spec.rb: transições de status, register_failure! (3 tentativas → failed), retry!.
  • spec/jobs/outbox/dispatch_events_job_spec.rb: despacho bem-sucedido, falha incrementando attempts, falha na 3ª tentativa marcando failed, event_name sem despachante registrado.
  • spec/services/outbox/dispatchers/new_checkout_repayment_sync_spec.rb: POST correto (URL, headers, timeout), comportamento sem envs configuradas.
  • spec/services/checkout_payments/repayment_service_spec.rb: reescrita — não mocka mais HTTParty.post diretamente; valida que generate! cria o OutboxEvent com o payload exato do contrato (os mesmos casos de payload da spec anterior: com/sem original_payment, com/sem product_slug, etc.) e que tudo acontece na mesma transação (ex.: se previous_payment.save! falhar, nenhum OutboxEvent é criado).
  • spec/admin/outbox_events_spec.rb (ou request spec equivalente): listagem, visualização de evento failed, ação de reenvio.

Como verificar

  1. make run.test path="spec/models/outbox_event_spec.rb spec/jobs/outbox/dispatch_events_job_spec.rb spec/services/outbox/dispatchers/new_checkout_repayment_sync_spec.rb spec/services/checkout_payments/repayment_service_spec.rb spec/admin/outbox_events_spec.rb" — todos os casos passam, incluindo atomicidade da transação e a contagem de 3 tentativas.
  2. Manualmente: gerar um reparcelamento pelo ActiveAdmin (Gerar Pagamento no Asaas) e confirmar que a resposta do admin não espera nenhuma chamada HTTP externa — o outbox_events recebe uma linha pending com o payload correto imediatamente após o save.
  3. Rodar Outbox::DispatchEventsJob.perform_now com NEW_CHECKOUT_API_URL apontando para um servidor local respondendo 201, confirmar a transição pending → sent com sent_at preenchido.
  4. Simular 3 falhas seguidas (servidor local respondendo 500, três execuções do job) e confirmar a transição para failed; confirmar que o botão de reenvio no ActiveAdmin volta o evento para pending com attempts: 0.
  5. Validação end-to-end real (POST chegando de fato no ibft-backend) continua fora do escopo desta verificação, como já era na spec anterior.

Documentação

  • Atualizar .project/docs/reference/payments/new_checkout_repayment_endpoint.md: adicionar uma nota de que o disparo do lado do checkout-api é assíncrono (outbox + job por evento, até 3 tentativas), sem mudar contrato, payload ou política de retry do lado do ibft-backend.
  • Nova regra em .project/docs/rules/payments/outbox_event_dispatch.md, indexada em RULES.md: semântica de status do OutboxEvent, contagem de tentativas (3 para qualquer falha, sem distinção de tipo de erro) e reset do reenvio manual.
  • Indexar esta spec em .project/docs/README.md.
  • Ao final da implementação, marcar .project/docs/specs/20260909165528_repayment_sync_new_checkout.md e seu plano (.project/docs/plans/20260909170027_repayment_sync_new_checkout.md) com status: superseded, apontando para esta spec — só depois de confirmado que a nova implementação substituiu a antiga de fato.