Status: implementado dos dois lados. No
checkout-api,Accounts::DispatchPurchaseCreatedgrava umOutboxEvent(accounts.purchase_create) a partir do webhook do Asaas;Outbox::DispatchEventsJobfaz o POST de fato (ver regra de despacho do outbox). Noaccounts, o endpoint (POST /api/v1/events, módulosynapse) e o use caseaccounts.use_cases.purchases.Createjá existem na branchfeat/purchase-created-eventdeles.
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 disparaPURCHASE_CREATEDpara oaccounts.
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êmnew_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_methodmapeiaPayment#billing_typepara o vocabulário doaccounts:BOLETO → BOLETO,PIX → PIX,CREDIT_CARD → CARTAO(Accounts::PurchaseSerializer::PAYMENT_METHODS). Umbilling_typefora do mapa levantaKeyError— melhor nenhum evento do que um evento que oaccountsnã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ãodue_date. purchased_até ocreated_atdo pagamento. Entrou junto com oINSTALLMENT_PAID: antes o campo não era enviado e oaccountsgravava a hora da ingestão como data da compra.- Cada parcela leva
statustraduzido (Accounts::PurchaseSerializer::STATUS_MAP) e o própriopaid_at(nullpara quem não pagou), e cobrança em estado que oaccountsnão conhece (deleted_or_canceled_by_new_payment,unknown) sai da lista. Também entrou junto com oINSTALLMENT_PAID— o payload é o mesmo para os dois eventos. organization_slugé oslugda organização do checkout, e vainullquando a organização não tem slug (coluna anulável, ver R-003). O evento sai mesmo assim. Vale para os três eventos doaccounts, porque todos usam oPurchaseSerializer.customer.provider_customer_idé o id do cliente no Asaas (cus_...), lido do campocustomerda primeira cobrança que oFetchFromGatewaybusca ao vivo. Não vem doOrganizationCustomerlocal, porque o que vale é o cliente que está de fato na cobrança. Vainullquando a lista vem vazia. Também vale para os três eventos.provider_refé ogateway_idda parcela individual no Asaas.product_slugé sempre enviado:accountslê a chave com colchete (params["product_slug"]) e quebraria sem ela, e o slug doProducté gerado a partir do nome pelo concernSlugable, então nunca fica em branco.- Cada parcela leva um
status(PENDING/PAID/OVERDUE/REFUNDED,Accounts::PurchaseSerializer::INSTALLMENT_STATUSES) — efeito colateral de compartilhar o#installmentscom o evento de parcela atrasada.purchases.Createignora 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é oaccountsmodelar 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
- ../../specs/20260921114603_accounts_purchase_created_event.md — spec e decisões de arquitetura
- ../../rules/payments/outbox_event_dispatch.md — mecanismo de despacho (R-004)