Sincronização de reparcelamento assíncrona via outbox pattern
TLDR: a branch
feat/repayment-sync-new-checkoutsincroniza o reparcelamento com oibft-backendpor uma chamada HTTP síncrona, inline nomember_actiondo 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) comevent_name,payload(jsonb),status,attempts,last_error,sent_at. CheckoutPayments::RepaymentServicegrava umOutboxEvent(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 peloevent_name(registro simplesevent_name => classe) e tenta o envio; enquanto o evento seguirpending, ele se reenfileira viaretry_ona cada 5 segundos. - Contagem de tentativas: qualquer falha (HTTP não-2xx, exceção, timeout) soma 1 em
attempts; ao atingir 3, o evento virafailed. Sem distinção entre erro retryable/definitivo — oretry_ondo job espaça as tentativas. - Evento
failedfica visível num recurso novo do ActiveAdmin (outbox_events), com ação de reenvio manual que volta o evento parapendinge zeraattempts. - O despachante da sincronização de reparcelamento (
Outbox::Dispatchers::NewCheckoutRepaymentSync) mantém o contrato HTTP acordado com oibft-backend:POST #{NEW_CHECKOUT_API_URL}/v1/sync/repayments, headerAuthorization: Bearer #{NEW_CHECKOUT_API_TOKEN}, corpo =payloadgravado 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) — oretry_oncom 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 doibft-backendcontinuam os mesmos; só o mecanismo de disparo do lado docheckout-apimuda. - 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 doibft-backendjá é idempotente porrepayment.reference(responde200 duplicateem 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: :pendingvalidates :event_name, :payload, presence: truescope :pending, -> { where(status: :pending).order(:created_at) }#register_failure!(error_message):attempts += 1; seattempts >= 3,status = :failed; gravalast_error.#mark_sent!:status = :sent,sent_at = Time.current.#retry!: volta prapending,attempts = 0,last_error = nil— usado pelo reenvio manual.
app/jobs/outbox/dispatch_events_job.rb
OutboxEvent.pending.find_each→ resolveOutbox::DISPATCHERS[event.event_name]; se não houver despachante registrado, chamaregister_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 viaHTTParty, exatamente comoCheckoutPayments::RepaymentService#perform_new_checkout_requestfaz 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 aHTTParty::Response; job decide sucesso/falha a partir dela.- Sem
NEW_CHECKOUT_API_URL/NEW_CHECKOUT_API_TOKENconfigurados: 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_paymentedestroy_original_payment_on_gatewaydeixam 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_gatewaytambém corrige um efeito colateral que existe hoje: o código atual salvaprevious_payment.statusantes 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 houveroriginal_payment), monta o payload de sincronização (reaproveitando os métodos privados de payload já existentes, sem mudança de formato) e abreActiveRecord::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 paraOutbox::Dispatchers::NewCheckoutRepaymentSynce para oOutboxEvent/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_repaymentpassa a ter uma única chamada:edited_payment = repayment_service.generate!— remove a checagem externa deoriginal_payment.present?e a chamada separada async_with_new_checkout(o orquestrador cuida disso internamente).
app/admin/outbox_events.rb (novo)
index:event_name,status,attempts,sent_at,created_at.show: incluipayloadelast_errorformatados.- Ação de reenvio (
member_action :retryoubatch_action) disponível só para eventosfailed, chamaevent.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_URLeNEW_CHECKOUT_API_TOKENcontinuam existindo, agora lidas porOutbox::Dispatchers::NewCheckoutRepaymentSyncem vez deRepaymentService.
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 incrementandoattempts, falha na 3ª tentativa marcandofailed,event_namesem 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 maisHTTParty.postdiretamente; valida quegenerate!cria oOutboxEventcom o payload exato do contrato (os mesmos casos de payload da spec anterior: com/semoriginal_payment, com/semproduct_slug, etc.) e que tudo acontece na mesma transação (ex.: seprevious_payment.save!falhar, nenhumOutboxEventé criado).spec/admin/outbox_events_spec.rb(ou request spec equivalente): listagem, visualização de eventofailed, ação de reenvio.
Como verificar
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.- Manualmente: gerar um reparcelamento pelo ActiveAdmin (
Gerar Pagamento no Asaas) e confirmar que a resposta do admin não espera nenhuma chamada HTTP externa — ooutbox_eventsrecebe uma linhapendingcom o payload correto imediatamente após o save. - Rodar
Outbox::DispatchEventsJob.perform_nowcomNEW_CHECKOUT_API_URLapontando para um servidor local respondendo201, confirmar a transiçãopending→sentcomsent_atpreenchido. - Simular 3 falhas seguidas (servidor local respondendo
500, três execuções do job) e confirmar a transição parafailed; confirmar que o botão de reenvio no ActiveAdmin volta o evento parapendingcomattempts: 0. - 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 docheckout-apié assíncrono (outbox + job por evento, até 3 tentativas), sem mudar contrato, payload ou política de retry do lado doibft-backend. - Nova regra em
.project/docs/rules/payments/outbox_event_dispatch.md, indexada emRULES.md: semântica de status doOutboxEvent, 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.mde seu plano (.project/docs/plans/20260909170027_repayment_sync_new_checkout.md) comstatus: superseded, apontando para esta spec — só depois de confirmado que a nova implementação substituiu a antiga de fato.