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 doaccounts(POST /api/v1/events), no contratoPURCHASE_CREATEDjá implementado do lado deles.
Branch:
feat/accounts-purchase-created-event, criada a partir defeat/repayment-sync-via-outbox(infra genérica de outbox ainda não mesclada emmain).
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:
- Parcelas (
Installment) nunca existem de forma síncrona. Nenhum código do repo criaInstallmentno momento da compra — só o webhook do Asaas, depois, uma parcela por webhook.CheckoutPayments::RepaymentService#generate!(branch-basefeat/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. - A infra de outbox já existe, mas não em
main. A branchfeat/repayment-sync-via-outbox(commits190d829,695b0d2) criououtbox_events(tabela genérica),OutboxEvent,Outbox::DispatchEventsJobeOutbox::DispatchRegistry— pensados desde o início para múltiplosevent_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_CREATEDpara oaccountsassim 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: repaymentoukind: 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
Paymentquandogateway_installment_idestivesse 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 noRepaymentService.
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
OutboxEventpara este pagamento:OutboxEvent.where(event_name: "accounts.purchase_create").where("payload ->> 'external_id' = ?", payment.pid).exists?retornafalse. Asaas::Installments::FetchFromGateway.call(...)retorna exatamentepayment.installment_countparcelas — 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_methodmapeiaPayment#billing_type(BOLETO/CREDIT_CARD/PIX,config/initializers/payment_statuses.rb:84) para o vocabulário doaccounts:BOLETO → BOLETO,PIX → PIX,CREDIT_CARD → CARTAO.amounté número (Float/Decimalserializado como JSON number), não string formatada — diferente doformat_new_checkout_money("600.00") usado no contrato de reparcelamento.- O campo de vencimento da parcela se chama
due_on, nãodue_date. provider_refé ogateway_idda parcela/cobrança individual no Asaas (não ogateway_installment_iddo plano).product_slugé omitido do payload quandocheckout.product&.slugestiver em branco (mesmo padrão condicional doRepaymentService).document_typeé inferido comCPF.valid?(customer.doc_number) ? "CPF" : "CNPJ"— mesmo padrão já usado emRepaymentService#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/— criaraccounts_purchase_created_event.mddescrevendo o contrato do envelope (espelhandonew_checkout_repayment_endpoint.md), incluindo a nota de que os dados vêm de busca ao vivo no Asaas, nunca doInstallmentlocal..project/docs/rules/payments/outbox_event_dispatch.md(R-004) — atualizar para citaraccounts.purchase_createcomo 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).