Status: implementado dos dois lados. No checkout-api, Accounts::DispatchPurchaseCreated grava um OutboxEvent (accounts.purchase_create) a partir do webhook do Asaas; Outbox::DispatchEventsJob faz o POST de fato (ver regra de despacho do outbox). No accounts, o endpoint (POST /api/v1/events, módulo synapse) e o use case accounts.use_cases.purchases.Create já existem na branch feat/purchase-created-event deles.

Evento de compra criada — accounts

TLDR: quando o webhook do Asaas confirma que um pagamento novo (Payment#kind == "standard") tem dados de gateway completos — pagamento e todas as parcelas —, o checkout-api dispara PURCHASE_CREATED para o accounts.

Gatilho

Novo step IncomingWebhooks::PaymentGateways::Asaas::Payments::DispatchPurchaseCreatedEvent, no final de PaymentsFlow. Roda um no-op silencioso a menos que:

  • payment.kind == "standard" (reparcelamento/settlement ficam fora — já têm new_checkout.repayment_sync);
  • o evento do webhook está em ASAAS_INITIAL_EVENTS (PAYMENT_CREATED, PAYMENT_UPDATED).

Dados completos são garantidos por Asaas::Installments::FetchFromGateway, que busca as parcelas ao vivo no Asaas (nunca do Installment local: nenhum código do repo cria Installment de forma síncrona — eles só passam a existir quando o webhook do Asaas chega, uma parcela por webhook). Se a contagem não bater com installment_count, Accounts::DispatchPurchaseCreated sai em silêncio, sem gravar o OutboxEvent e sem log — é operação normal, porque o evento é avaliado a cada PAYMENT_CREATED/PAYMENT_UPDATED e o plano de parcelamento pode ainda não estar completo no Asaas. Autocurativo: o próximo webhook do mesmo pagamento tenta de novo. Só falha inesperada (gateway fora do ar, erro ao gravar) gera Rails.logger.error prefixado com [accounts.purchase_create] — e mesmo essa não quebra o webhook.

Idempotência

Antes de montar o payload, Accounts::DispatchPurchaseCreated verifica se já existe um OutboxEvent com event_name: "accounts.purchase_create" e external_id igual ao pid do pagamento (payload ->> 'external_id'). Sem coluna dedicada — aceito porque o accounts já é idempotente por external_id (Purchase.objects.get_or_create); no pior caso de corrida entre dois webhooks quase simultâneos, duas tentativas de POST são inofensivas do lado deles.

Requisição (feita por Outbox::Dispatchers::AccountsEvents)

POST /api/v1/events Content-Type: application/json X-Origin: checkout Authorization: Bearer <ACCOUNTS_API_TOKEN>

ACCOUNTS_API_URL e ACCOUNTS_API_TOKEN são as envs novas (ver .env.example).

Montagem do payload

Accounts::DispatchPurchaseCreated monta o envelope (event_type, external_id, occurred_at) e delega o payload interno a Accounts::PurchaseSerializer, um serializer do active_model_serializers como os outros do repo. O serializer não faz I/O: as parcelas são buscadas no Asaas pelo DispatchPurchaseCreated e injetadas via instance_options, porque o Installment local ainda não existe no momento do webhook.

occurred_at é o created_at do pagamento, não a hora em que o payload foi montado.

DispatchPurchaseCreated#envelope é público e não persiste nada: monta o envelope isolado do efeito colateral, o que permite inspecioná-lo sem gravar OutboxEvent nem disparar HTTP.

Payload

json { "event_type": "PURCHASE_CREATED", "external_id": "<payment.pid>", "occurred_at": "2026-09-21T11:00:00-03:00", "payload": { "external_id": "<payment.pid>", "organization_slug": "citrg", "product_slug": "curso-extensao-2026", "customer": { "name": "João da Silva", "email": "joao@example.com", "document": "12345678900", "document_type": "CPF", "phone": "11999999999", "country": "BR", "provider_customer_id": "cus_000006345005" }, "payment_method": "BOLETO", "provider": "asaas", "purchased_at": "2026-09-21T10:55:00-03:00", "installments": [ {"number": 1, "amount": 600.0, "due_on": "2026-09-10", "status": "PENDING", "paid_at": null, "provider_ref": "pay_1"} ] } }

Pontos de atenção (divergem do contrato new_checkout já existente no repo):

  • payment_method mapeia Payment#billing_type para o vocabulário do accounts: BOLETO → BOLETO, PIX → PIX, CREDIT_CARD → CARTAO (Accounts::PurchaseSerializer::PAYMENT_METHODS). Um billing_type fora do mapa levanta KeyError — melhor nenhum evento do que um evento que o accounts não entende.
  • amount é número (não string formatada como "600.00", como no contrato de reparcelamento).
  • O campo de vencimento da parcela é due_on, não due_date.
  • purchased_at é o created_at do pagamento. Entrou junto com o INSTALLMENT_PAID: antes o campo não era enviado e o accounts gravava a hora da ingestão como data da compra.
  • Cada parcela leva status traduzido (Accounts::PurchaseSerializer::STATUS_MAP) e o próprio paid_at (null para quem não pagou), e cobrança em estado que o accounts não conhece (deleted_or_canceled_by_new_payment, unknown) sai da lista. Também entrou junto com o INSTALLMENT_PAID — o payload é o mesmo para os dois eventos.
  • organization_slug é o slug da organização do checkout, e vai null quando a organização não tem slug (coluna anulável, ver R-003). O evento sai mesmo assim. Vale para os três eventos do accounts, porque todos usam o PurchaseSerializer.
  • customer.provider_customer_id é o id do cliente no Asaas (cus_...), lido do campo customer da primeira cobrança que o FetchFromGateway busca ao vivo. Não vem do OrganizationCustomer local, porque o que vale é o cliente que está de fato na cobrança. Vai null quando a lista vem vazia. Também vale para os três eventos.
  • provider_ref é o gateway_id da parcela individual no Asaas.
  • product_slug é sempre enviado: accounts lê a chave com colchete (params["product_slug"]) e quebraria sem ela, e o slug do Product é gerado a partir do nome pelo concern Slugable, então nunca fica em branco.
  • Cada parcela leva um status (PENDING/PAID/OVERDUE/REFUNDED, Accounts::PurchaseSerializer::INSTALLMENT_STATUSES) — efeito colateral de compartilhar o #installments com o evento de parcela atrasada. purchases.Create ignora a chave. Uma cobrança com status fora desses quatro é descartada da lista, em vez de entrar com um valor adivinhado.

Fora de escopo

  • Reparcelamento/settlement disparando PURCHASE_CREATED — fora de escopo até o accounts modelar esse conceito.
  • Varredura periódica para pagamentos que nunca completam a contagem de parcelas (ex.: Asaas nunca reenvia webhook) — risco aceito por ora.

Teste vinculado

spec/services/asaas/installments/fetch_from_gateway_spec.rb, spec/serializers/accounts/purchase_serializer_spec.rb, spec/services/accounts/dispatch_purchase_created_spec.rb, spec/services/outbox/dispatchers/accounts_events_spec.rb, spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_purchase_created_event_spec.rb.

Referências