Evento de compra criada para o accounts

TLDR: quando o webhook do Asaas confirma que um pagamento novo (não reparcelamento) tem seus dados de gateway completos — pagamento e parcelas —, o checkout-api grava um OutboxEvent (accounts.purchase_create) que é despachado de forma assíncrona para o endpoint de eventos do accounts (POST /api/v1/events), no contrato PURCHASE_CREATED já implementado do lado deles.

Branch: feat/accounts-purchase-created-event, criada a partir de feat/repayment-sync-via-outbox (infra genérica de outbox ainda não mesclada em main).

Contexto

O accounts já tem, numa branch própria (feat/purchase-created-event, não mesclada), um endpoint de ingestão de eventos genérico (synapse) e um use case accounts.use_cases.purchases.Create esperando um evento PURCHASE_CREATED do checkout-api. Do lado do checkout-api esse disparo não existe.

Dois aprendizados do repo mudam como isso precisa ser feito:

  1. Parcelas (Installment) nunca existem de forma síncrona. Nenhum código do repo cria Installment no momento da compra — só o webhook do Asaas, depois, uma parcela por webhook. CheckoutPayments::RepaymentService#generate! (branch-base feat/repayment-sync-via-outbox) já resolveu o mesmo problema para reparcelamento buscando as parcelas ao vivo no Asaas (gateway_service.get_installments) em vez do banco local, com uma entrada sintética quando não há plano de parcelamento.
  2. A infra de outbox já existe, mas não em main. A branch feat/repayment-sync-via-outbox (commits 190d829, 695b0d2) criou outbox_events (tabela genérica), OutboxEvent, Outbox::DispatchEventsJob e Outbox::DispatchRegistry — pensados desde o início para múltiplos event_name, bastando registrar um novo dispatcher (regra .project/docs/rules/payments/outbox_event_dispatch.md, R-004). Esta spec usa essa infra em vez de recriá-la.

Decisão de arquitetura tomada em diálogo com o usuário: o disparo acontece a partir do webhook do Asaas (não de forma síncrona na criação da compra), porque é o webhook que confirma que o gateway processou o pagamento — mesmo que os dados de parcela já pudessem, na prática, ser buscados ao vivo logo após a criação.

Objetivos

  • Disparar um evento PURCHASE_CREATED para o accounts assim que uma compra nova (Payment#kind == "standard") tiver dados completos de pagamento e parcelas vindos do Asaas.
  • Reaproveitar a infra genérica de outbox (OutboxEvent, Outbox::DispatchEventsJob, Outbox::DispatchRegistry) sem recriá-la.
  • Extrair a lógica de busca ao vivo de parcelas (hoje privada em CheckoutPayments::RepaymentService) para um serviço compartilhado, reaproveitado pelos dois fluxos (reparcelamento e compra nova).
  • Garantir que o evento nunca seja disparado com parcelas incompletas (falha fechada: se a contagem não bater, não dispara — e não derruba o processamento do webhook).

Fora de escopo

  • Disparo do evento para kind: repayment ou kind: settlement — esses já têm seu próprio evento (new_checkout.repayment_sync) e ficam fora desta spec.
  • Job de varredura periódica para pagamentos que nunca completam a contagem de parcelas (ex.: Asaas nunca reenvia webhook). Risco aceito por ora — decisão explícita do usuário; revisar se aparecerem casos órfãos em produção.
  • Qualquer mudança no lado do accounts — o contrato de lá já está implementado e não muda.
  • Reparcelamento/settlement dispararem eventos de “purchase” no accounts — fora de escopo até o accounts modelar esse conceito.

Mudanças

1. Asaas::Installments::FetchFromGateway — serviço compartilhado

Criar: app/services/asaas/installments/fetch_from_gateway.rb

Extrai de CheckoutPayments::RepaymentService (branch-base) a lógica genérica de busca ao vivo: FetchFromGateway.call(gateway_service:, payment:) retorna um array normalizado {number:, gateway_id:, total:, net_total:, due_date:, invoice_number:, paid_at:, status:}, buscando via gateway_service.get_installments(payment.gateway_installment_id).

Atualização (2026-09-22): a versão original desta spec previa um fallback sintético a partir dos campos do próprio Payment quando gateway_installment_id estivesse em branco. Consulta em produção mostrou 0 de 213.954 pagamentos com o campo nulo — o Asaas cria plano de parcelamento mesmo em venda à vista (installmentCount: 1). O fallback foi removido junto com o caso equivalente no RepaymentService.

Modificar: app/services/checkout_payments/repayment_service.rb — fetch_gateway_installments passa a checar primeiro o snapshot específico de reparcelamento (original_payment_gateway_snapshot, que só existe para o original_payment cancelado) e, se não houver, delega para Asaas::Installments::FetchFromGateway.call. O restante do RepaymentService (mapeamento de payload do contrato new_checkout) não muda.

2. Novo step no pipeline de webhook do Asaas

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

Novo step do Ucasy::Flow, adicionado ao final de PaymentsFlow (depois de Payments::Update). Condições, todas necessárias para agir (senão, call é um no-op silencioso — nunca levanta exceção que interrompa o webhook):

  • payment.kind == "standard" (não repayment/settlement).
  • O evento do webhook está em ASAAS_INITIAL_EVENTS (PAYMENT_CREATED, PAYMENT_UPDATED; config/initializers/asaas_constants.rb:34).
  • Ainda não existe OutboxEvent para este pagamento: OutboxEvent.where(event_name: "accounts.purchase_create").where("payload ->> 'external_id' = ?", payment.pid).exists? retorna false.
  • Asaas::Installments::FetchFromGateway.call(...) retorna exatamente payment.installment_count parcelas — senão, sai sem disparar (autocurativo: o próximo webhook do mesmo pagamento tenta de novo).

Quando todas as condições passam, monta o envelope (ver seção 4) e grava OutboxEvent.create!(event_name: "accounts.purchase_create", payload: envelope) — sem chamada HTTP síncrona.

Sobre a checagem de idempotência sem coluna dedicada: como o accounts já é idempotente por external_id (Purchase.objects.get_or_create), uma corrida rara entre dois webhooks quase simultâneos do mesmo pagamento resultaria, no pior caso, em dois OutboxEvents e duas tentativas de POST — inofensivo do lado do accounts. Trade-off aceito para evitar migração + coluna nova.

3. Outbox::Dispatchers::AccountsPurchaseCreate

Criar: app/services/outbox/dispatchers/accounts_purchase_create.rb

Mesmo formato de Outbox::Dispatchers::NewCheckoutRepaymentSync (branch-base): POST para "#{ENV["ACCOUNTS_API_URL"]}/api/v1/events", headers "X-Origin" => "checkout" e "Authorization" => "Bearer #{ENV["ACCOUNTS_API_TOKEN"]}", timeout curto (3s), body: payload.to_json (o payload é o próprio envelope gravado em OutboxEvent#payload). Levanta erro claro se as envs não estiverem configuradas — mesmo padrão do dispatcher irmão.

Modificar: app/services/outbox/dispatch_registry.rb — adiciona "accounts.purchase_create" => Outbox::Dispatchers::AccountsPurchaseCreate ao hash DISPATCHERS.

Modificar: .env.example — adiciona ACCOUNTS_API_URL= e ACCOUNTS_API_TOKEN=, junto das demais envs de integração.

4. Contrato do envelope (payload gravado em OutboxEvent)

Mapeamento exato para o contrato já implementado no accounts (.project/docs/specs/20260918172243_purchase_created_event.md do lado deles, confirmado por exploração):

json { "event_type": "PURCHASE_CREATED", "external_id": "<payment.pid>", "occurred_at": "<Time.current.iso8601>", "payload": { "external_id": "<payment.pid>", "product_slug": "<checkout.product&.slug>", "customer": { "name": "<customer.name>", "email": "<customer.email>", "document": "<customer.doc_number>", "document_type": "CPF|CNPJ", "phone": "<customer.phone_number>", "country": "<customer.country&.upcase>" }, "payment_method": "BOLETO|PIX|CARTAO", "provider": "asaas", "installments": [ {"number": 1, "amount": 600.0, "due_on": "2026-09-10", "provider_ref": "pay_1"} ] } }

Pontos de atenção (fáceis de errar por divergirem do contrato new_checkout já existente no repo):

  • payment_method mapeia Payment#billing_type (BOLETO/CREDIT_CARD/PIX, config/initializers/payment_statuses.rb:84) para o vocabulário do accounts: BOLETO → BOLETO, PIX → PIX, CREDIT_CARD → CARTAO.
  • amount é número (Float/Decimal serializado como JSON number), não string formatada — diferente do format_new_checkout_money ("600.00") usado no contrato de reparcelamento.
  • O campo de vencimento da parcela se chama due_on, não due_date.
  • provider_ref é o gateway_id da parcela/cobrança individual no Asaas (não o gateway_installment_id do plano).
  • product_slug é omitido do payload quando checkout.product&.slug estiver em branco (mesmo padrão condicional do RepaymentService).
  • document_type é inferido com CPF.valid?(customer.doc_number) ? "CPF" : "CNPJ" — mesmo padrão já usado em RepaymentService#customer_sync_payload.

Prova

O que precisa ser verdade Teste
FetchFromGateway retorna as parcelas do plano de parcelamento buscando ao vivo no Asaas spec/services/asaas/installments/fetch_from_gateway_spec.rb (a escrever) — “retorna as parcelas do gateway ordenadas”
O step não dispara para pagamentos de reparcelamento/settlement spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_purchase_created_event_spec.rb (a escrever) — “não dispara para kind diferente de standard”
O step não dispara de novo se já existe um OutboxEvent para o mesmo external_id spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_purchase_created_event_spec.rb (a escrever) — “é idempotente por external_id”
O step não dispara quando a contagem de parcelas buscadas ao vivo não bate com installment_count spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_purchase_created_event_spec.rb (a escrever) — “não dispara com parcelas incompletas”
O envelope gravado bate exatamente com o contrato do accounts (campos, amount numérico, due_on, provider_ref) spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_purchase_created_event_spec.rb (a escrever) — “monta o envelope PURCHASE_CREATED no contrato do accounts”
payment_method mapeia CREDIT_CARD para CARTAO spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_purchase_created_event_spec.rb (a escrever) — “mapeia CREDIT_CARD para CARTAO”
O dispatcher posta o envelope com X-Origin: checkout e Bearer token para ACCOUNTS_API_URL spec/services/outbox/dispatchers/accounts_purchase_create_spec.rb (a escrever) — “posta para o endpoint de eventos do accounts com os headers corretos”
RepaymentService continua funcionando após delegar a busca ao vivo para FetchFromGateway spec/services/checkout_payments/repayment_service_spec.rb (já existe na branch-base) — suíte completa continua verde

make run.test path=spec/services/asaas/installments/fetch_from_gateway_spec.rb spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_purchase_created_event_spec.rb spec/services/outbox/dispatchers/accounts_purchase_create_spec.rb spec/services/checkout_payments/repayment_service_spec.rb

Documentação

  • .project/docs/reference/payments/ — criar accounts_purchase_created_event.md descrevendo o contrato do envelope (espelhando new_checkout_repayment_endpoint.md), incluindo a nota de que os dados vêm de busca ao vivo no Asaas, nunca do Installment local.
  • .project/docs/rules/payments/outbox_event_dispatch.md (R-004) — atualizar para citar accounts.purchase_create como segundo consumidor real da tabela genérica, confirmando o desenho “reutilizável sem migração nova”.
  • .project/docs/RULES.md — nenhuma linha nova (R-004 já existe e só é atualizada).