Status do lado checkout-api: implementado, de forma assíncrona (
CheckoutPayments::RepaymentService#generate!grava umOutboxEvent;Outbox::DispatchEventsJob, enfileirado junto com o evento, faz o POST de fato — ver regra de despacho do outbox). O contrato abaixo — payload, resposta e política de retry — é o mesmo independente de o disparo ser síncrono ou assíncrono deste lado. Aguardando oibft-backendexpor o endpoint em staging para validação ponta a ponta.
Endpoint de sincronização de reparcelamento — ibft-backend
TLDR: o
ibft-backendprecisa expor um endpoint que recebe um reparcelamento já criado no Asaas por um sistema de cobrança externo e o registra como um pedido com N parcelas. Este documento é o contrato: o JSON que chega, o JSON que deve voltar e o resultado esperado. Como implementar é decisão de vocês.
Por que este endpoint existe
Reparcelamento é operação financeira, não de checkout. Até existir um módulo financeiro no produto, a operação continua sendo executada em um sistema de cobrança legado, que sincroniza o resultado com o ibft-backend — onde o financeiro consulta e opera.
Quando a chamada chega, o trabalho no gateway já aconteceu: o novo plano de parcelamento existe no Asaas e as parcelas em aberto do plano anterior já foram canceladas lá. O endpoint não cria nem cancela nada no gateway — ele registra no banco o que já é fato.
Escopo desta versão: apenas vendas que não existem no ibft-backend. O pedido de origem nasceu no sistema legado, então não há pedido a atualizar — é sempre criação.
O que precisa ser feito
Em resultado, não em código:
- Receber e autenticar a chamada por um token compartilhado entre os dois sistemas. Fail-closed: sem token configurado no servidor, rejeitar.
- Registrar o pedido com o total, o número de parcelas, o método de pagamento, o comprador e a empresa que vieram no payload.
- Registrar cada parcela individualmente, cada uma com o id da cobrança no Asaas. Este é o ponto crítico do contrato:
process_asaas_webhookresolve o evento do gateway pelo id da cobrança, então uma parcela que não estiver registrada com o seu id nunca é atualizada quando o cliente paga — o evento cai em"Asaas webhook sem charge correspondente"e o pedido congela. Registrar só um log de auditoria não atende. - Ser idempotente por
repayment.reference: o mesmo payload pode chegar mais de uma vez (retry da origem), e a segunda vez não pode duplicar nada. A referência é a chave natural do reparcelamento — estável entre retries e única na origem. Seguindo a convenção que o repo já usa emChargeeIntegrationDispatchLog, o caminho é unique constraint no banco em vez de checagem na aplicação. - Ser atômico: ou os dois pedidos e todas as parcelas entram, ou nada entra. Registrar o reparcelamento sem o histórico do original é pior que falhar — vira uma dívida sem contexto.
- Registrar também o parcelamento antigo, e ligar os dois. O payload traz o plano original com as parcelas dele — as pagas e as que foram removidas no Asaas. Os dois precisam existir no novo sistema, como dois pedidos ligados: o original com o histórico do que foi pago, e o reparcelamento com a dívida renegociada. Sem isso, o financeiro vê uma dívida de R$ 3.600 sem saber que o cliente já tinha pago R$ 1.000 antes.
- Não disparar nenhum evento de pedido para o que é importado. Nem
ORDER_CREATED, nemORDER_PAID, nem e-mail, nem comissão. Isso vale em dobro para o pedido original: as parcelas dele foram pagas no passado e entram comoapproved, então qualquer disparo em cima delas manda e-mail de “compra aprovada” e webhook de pedido pago para pagamento de meses atrás. Só eventos vindos do Asaas depois da importação devem produzir efeito. - Não chamar o Asaas na request. Todo dado necessário está no payload; manter o handler sem I/O externo deixa a resposta rápida e funcional mesmo com o gateway instável. Se quiserem conferência, uma reconciliação assíncrona depois.
- Escolher o
offer_codedos pedidos legados sem colidir com o cleanup de carrinho abandonado. O campo não é inerte: uma rotina existente usashopper+company+offer_codepara cancelar cobranças em aberto. Umoffer_codeconstante para todo pedido legado cancela boleto vivo de dívida renegociada. Ver o ponto 1 de Pontos que precisam de decisão — é resolução obrigatória antes do go-live. - Fazer o pedido aparecer na área de Parcelamento (
/v1/sales/installments). Hoje o queryset dessa área tem filtros próprios — vale conferir se um pedido criado por este caminho passa por eles.
Requisição
Endpoint
POST /v1/sync/repayments
Content-Type: application/json
É para onde a origem já aponta. Fica sob o mesmo prefixo /v1/sync/ da unificação de cliente, e precisa ser estável — mudança de path exige deploy coordenado dos dois lados.
Autenticação
Token único, compartilhado entre os dois sistemas:
| Header | Valor | Validação esperada |
|---|---|---|
Authorization |
Bearer <token> |
comparação em tempo constante contra o token guardado em variável de ambiente |
Fail-closed: se o token não estiver configurado no servidor, rejeitar a requisição em vez de liberar — mesma postura das views de webhook existentes.
Três cuidados que vêm com a escolha de token único:
- Só sobre HTTPS. O token é o único fator, então trafega a cada chamada.
- Nunca em query string, só no header — query aparece em log de acesso e de proxy.
- Fora do log de aplicação, e rotacionável sem redeploy dos dois lados ao mesmo tempo (aceitar dois tokens válidos durante a janela de rotação resolve).
Não usar ?token=<company.code> como nas views de gateway: Company.code é identificador de negócio que circula em URL pública, não segredo. A empresa vem no corpo e é validada como dado, não como credencial.
JSON de entrada
json
{
"event": "payment.repayment.created",
"source": "checkout-api",
"sent_at": "2026-09-09T14:32:10-03:00",
"company_slug": "citrg",
"product_slug": "curso-extensao-2025",
"repayment": {
"reference": "payment_9f2a7c1b4e8d3a5f6b2c1d1757441530_84321",
"external_id": "payment_a1b2c3d4e5",
"kind": "repayment",
"description": "Reparcelamento: Curso de Extensão 2025",
"billing_type": "BOLETO",
"installment_count": 6,
"total": "3600.00",
"installment_amount": "600.00",
"due_date": "2026-09-10",
"gateway": "asaas",
"gateway_id": "pay_083412765901",
"gateway_installment_id": "2765d086-c7c5-5cca-898a-4262d212587c",
"gateway_checkout_url": "https://www.asaas.com/i/083412765901",
"created_at": "2026-09-09T14:31:58-03:00",
"operator_email": "financeiro@ibft.com.br"
},
"installments": [
{
"number": 1,
"gateway_id": "pay_083412765901",
"amount": "600.00",
"net_amount": "586.51",
"due_date": "2026-09-10",
"status": "pending",
"invoice_url": "https://www.asaas.com/i/083412765901",
"bank_slip_url": "https://www.asaas.com/b/pdf/083412765901",
"invoice_number": "00005101"
},
{
"number": 2,
"gateway_id": "pay_083412765902",
"amount": "600.00",
"net_amount": "586.51",
"due_date": "2026-10-10",
"status": "pending",
"invoice_url": "https://www.asaas.com/i/083412765902",
"bank_slip_url": "https://www.asaas.com/b/pdf/083412765902",
"invoice_number": "00005102"
}
],
"original_payment": {
"reference": "payment_4d1e8f60a29b7c3d5e0f1a1740012345_71204",
"external_id": "payment_z9y8x7w6v5",
"description": "Curso de Extensão 2025",
"billing_type": "BOLETO",
"total": "3000.00",
"installment_count": 6,
"installment_amount": "500.00",
"due_date": "2026-03-10",
"gateway_id": "pay_071204338800",
"gateway_installment_id": "aa11bb22-cc33-4dd4-95ee-6f7a8b9c0d1e",
"created_at": "2026-02-28T10:14:02-03:00",
"status": "cancelled",
"paid_installment_count": 2,
"paid_total": "1000.00",
"cancelled_installment_count": 4,
"installments": [
{
"number": 1,
"gateway_id": "pay_071204338801",
"amount": "500.00",
"net_amount": "488.20",
"due_date": "2026-03-10",
"status": "paid",
"paid_at": "2026-03-09T11:02:31-03:00"
},
{
"number": 2,
"gateway_id": "pay_071204338802",
"amount": "500.00",
"net_amount": "488.20",
"due_date": "2026-04-10",
"status": "paid",
"paid_at": "2026-04-08T09:47:12-03:00"
},
{
"number": 3,
"gateway_id": "pay_071204338803",
"amount": "500.00",
"due_date": "2026-05-10",
"status": "cancelled"
}
]
},
"customer": {
"name": "João da Silva",
"email": "joao@example.com",
"document": "12345678900",
"document_type": "CPF",
"phone": "11999999999",
"country": "BR"
}
}
Convenções de formato
| Convenção | Detalhe |
|---|---|
| Valores monetários | string decimal com 2 casas ("3600.00"), nunca float. Moeda sempre BRL nesta versão |
| Datas | YYYY-MM-DD |
| Timestamps | ISO 8601 com timezone |
| Documento | apenas dígitos, sem pontuação |
Referências (reference, external_id) |
strings opacas. Não parsear nem inferir significado do formato |
| Campos ausentes | omitidos ou null — os dois significam a mesma coisa |
Campos da raiz
| Campo | Tipo | Obrig. | Descrição |
|---|---|---|---|
event |
string | sim | Sempre "payment.repayment.created" nesta versão. Valor desconhecido deve ser rejeitado, não ignorado |
source |
string | sim | Sistema de origem. Sempre "checkout-api" |
sent_at |
timestamp | sim | Momento do disparo |
company_slug |
string | sim | Company.slug de destino. Não resolveu → 422, sem fallback |
product_slug |
string | não | Product.slug dentro dessa empresa. Vazio ou ausente → produto legado da empresa. Preenchido e não resolvido → 422, não cair no fallback |
repayment — o pagamento novo, já criado no Asaas
| Campo | Tipo | Obrig. | Descrição |
|---|---|---|---|
reference |
string | sim | Referência única do pagamento na origem |
external_id |
string | sim | Id público do pagamento na origem, para rastro |
kind |
enum | sim | repayment, settlement ou renewal |
description |
string | sim | Descrição da cobrança, já com o prefixo do tipo |
billing_type |
enum | sim | BOLETO, PIX ou CREDIT_CARD |
installment_count |
int | sim | Número de parcelas do plano novo |
total |
decimal string | sim | Total do plano — não o valor da parcela |
installment_amount |
decimal string | sim | Valor de cada parcela |
due_date |
date | sim | Vencimento da primeira parcela |
gateway |
enum | sim | Sempre "asaas" nesta versão |
gateway_id |
string | sim | Id da primeira cobrança no Asaas |
gateway_installment_id |
string | não | Id do plano no Asaas. Nulo quando installment_count = 1 |
gateway_checkout_url |
string | sim | Link de pagamento da primeira cobrança |
created_at |
timestamp | sim | Criação do pagamento na origem |
operator_email |
string | sim | Operador que executou o reparcelamento. Para auditoria |
installments[] — uma entrada por parcela
Ordenado por number, sempre completo: installment_count entradas, cobrindo todas as parcelas do plano.
| Campo | Tipo | Obrig. | Descrição |
|---|---|---|---|
number |
int | sim | Posição da parcela, de 1 a installment_count |
gateway_id |
string | sim | Id da cobrança no Asaas. É por aqui que o webhook do gateway encontra a parcela |
amount |
decimal string | sim | Valor bruto da parcela |
net_amount |
decimal string | não | Valor líquido informado pelo Asaas |
due_date |
date | sim | Vencimento da parcela |
status |
enum | sim | paid, pending, overdue, refunded ou cancelled |
invoice_url |
string | não | Página de pagamento |
bank_slip_url |
string | não | PDF do boleto, quando billing_type = BOLETO |
invoice_number |
string | não | Número da fatura no Asaas |
Em um reparcelamento recém-criado o normal é todas as parcelas em pending.
original_payment — o parcelamento substituído
Não é informativo: precisa ser materializado como pedido. Mesma estrutura do repayment, mais o resumo do que foi pago e o array completo das parcelas antigas.
| Campo | Tipo | Obrig. | Descrição |
|---|---|---|---|
reference |
string | sim | Referência do pagamento original na origem. Chave natural deste pedido |
external_id |
string | sim | Id público do pagamento original na origem |
description |
string | sim | Descrição da cobrança original |
billing_type |
enum | sim | BOLETO, PIX ou CREDIT_CARD |
total |
decimal string | sim | Total do plano original |
installment_count |
int | sim | Parcelas do plano original |
installment_amount |
decimal string | sim | Valor de cada parcela original |
due_date |
date | sim | Vencimento da primeira parcela original |
gateway_id |
string | não | Id da primeira cobrança original no Asaas |
gateway_installment_id |
string | não | Id do plano original no Asaas |
created_at |
timestamp | sim | Quando a venda original foi criada |
status |
enum | sim | cancelled (parcelas em aberto removidas) ou active |
paid_installment_count |
int | sim | Quantas parcelas foram pagas |
paid_total |
decimal string | sim | Quanto foi recebido no plano original |
cancelled_installment_count |
int | sim | Quantas parcelas em aberto foram removidas no Asaas |
installments[] |
array | sim | Todas as parcelas do plano original, mesma estrutura de installments[] do reparcelamento, mais paid_at |
As parcelas do original chegam com status paid (recebida) ou cancelled (removida no Asaas quando o reparcelamento foi gerado). paid_at vem preenchido só nas pagas. As pagas continuam existindo no Asaas, então um estorno futuro nelas gera webhook e resolve normalmente pelo gateway_id. As canceladas foram deletadas no gateway e não vão gerar mais nenhum evento.
Os dois pedidos e o vínculo
O endpoint registra dois pedidos por chamada:
| Pedido | Vem de | Parcelas | Status derivado |
|---|---|---|---|
| Original | original_payment |
as pagas como approved (com paid_at), as removidas como cancelled |
partially_paid se houver parcela paga, cancelled se nenhuma |
| Reparcelamento | repayment |
todas as novas, normalmente pending |
pending |
Os dois precisam ficar ligados, para a área financeira navegar de um para o outro. Sugestão: um self-FK anulável em Order (original_order) preenchido no pedido do reparcelamento. Reaproveitar parent_order é possível, mas o related_name dele é upsell_orders e existe uma constraint condicional em type="upsell" — misturar as duas semânticas provavelmente confunde mais do que economiza.
Cada pedido tem a sua própria chave natural (original_payment.reference e repayment.reference), então a deduplicação é por pedido: o original pode já existir de uma sincronização anterior — quando o mesmo cliente é reparcelado duas vezes, ou num retry — e nesse caso é reaproveitado em vez de recriado.
customer
| Campo | Tipo | Obrig. | Descrição |
|---|---|---|---|
name |
string | sim | Nome do comprador |
email |
string | sim | E-mail. É a chave de deduplicação do comprador |
document |
string | não | CPF/CNPJ, apenas dígitos |
document_type |
enum | não | CPF ou CNPJ |
phone |
string | não | Telefone |
country |
string | não | ISO de 2 letras. BR por padrão |
Invariantes garantidas pela origem
Podem ser assumidas, mas vale validar e recusar quando quebrarem — é sinal de bug na origem, não de dado legítimo:
installmentstem exatamenteinstallment_countentradas.numbervai de 1 ainstallment_count, sem repetição nem lacuna.- A soma de
installments[].amounté igual arepayment.total. gateway_idé único dentro do array.- Todas as cobranças de
installments[]já existem no Asaas. repayment.referenceé único na origem e não muda entre retries da mesma operação.original_payment.installmentstem exatamenteoriginal_payment.installment_countentradas.- A quantidade de parcelas
paidemoriginal_payment.installmentsé igual apaid_installment_count, e a soma dos valores delas é igual apaid_total. original_payment.referenceé diferente derepayment.reference.
Resposta
201 Created — reparcelamento registrado
json
{
"status": "created",
"repayment_order_code": "8f14e45f-ceea-467a-9a1b-2c3d4e5f6a7b",
"repayment_order_status": "pending",
"repayment_installments_registered": 6,
"original_order_code": "2c9e77a1-40b3-4e58-8d21-9f0a5b6c7d8e",
"original_order_status": "partially_paid",
"original_installments_registered": 6,
"original_order_reused": false
}
| Campo | Descrição |
|---|---|
status |
created |
repayment_order_code |
Código do pedido do reparcelamento. A origem guarda para rastro |
repayment_order_status |
Status do pedido do reparcelamento |
repayment_installments_registered |
Parcelas novas registradas |
original_order_code |
Código do pedido do parcelamento antigo |
original_order_status |
Status derivado do pedido antigo |
original_installments_registered |
Parcelas antigas registradas. 0 quando o pedido foi reaproveitado |
original_order_reused |
true quando o pedido antigo já existia de uma sincronização anterior |
200 OK — repayment.reference já processada
Retry da origem. Nada é criado; devolve o pedido que já existe.
json
{
"status": "duplicate",
"repayment_order_code": "8f14e45f-ceea-467a-9a1b-2c3d4e5f6a7b",
"repayment_order_status": "pending",
"repayment_installments_registered": 0,
"original_order_code": "2c9e77a1-40b3-4e58-8d21-9f0a5b6c7d8e",
"original_order_status": "partially_paid",
"original_installments_registered": 0,
"original_order_reused": true
}
401 Unauthorized — token ausente ou inválido
json
{ "detail": "Não autorizado." }
Sem code e sem detalhe do que falhou.
422 Unprocessable Entity — payload inválido ou mapeamento faltando
json
{
"detail": "Empresa não encontrada para o company_slug informado.",
"code": "company_not_found",
"errors": {}
}
O campo code é o que a origem usa para decidir o que fazer — precisa ser estável:
code |
Significado |
|---|---|
invalid_payload |
Falha de schema. errors traz os erros por campo |
unknown_event |
event não reconhecido |
company_not_found |
company_slug não existe |
company_inactive |
Empresa existe mas está inativa |
product_not_resolved |
product_slug preenchido não casou com nenhum produto da empresa, ou a empresa não tem produto legado configurado |
installment_count_mismatch |
installments não confere com installment_count, ou a soma não fecha com total |
Erro de validação com errors por campo:
json
{
"detail": "Payload inválido.",
"code": "invalid_payload",
"errors": {
"repayment": { "billing_type": ["Valor inválido."] },
"installments": { "1": { "status": ["Valor inválido."] } }
}
}
500 Internal Server Error
json
{ "detail": "Erro interno." }
Política de retry da origem
| Resposta | Comportamento da origem |
|---|---|
2xx |
Sucesso, encerra |
4xx (exceto 429) |
Falha definitiva. Registra e alerta, sem retry — exige intervenção |
429, 5xx, timeout |
Retry com backoff, reenviando o payload idêntico — repayment.reference não muda entre tentativas |
Esta tabela descreve o retry entre o checkout-api e o ibft-backend (o “backoff com reenvio idêntico”). Do lado do checkout-api, esse retry é feito pelo outbox local: até 3 tentativas, espaçadas em 5 segundos pelo retry_on do job, sem distinguir 4xx de 5xx — ver regra de despacho do outbox. Os dois retries são independentes: o outbox tenta reenviar o mesmo OutboxEvent até 3 vezes; se o ibft-backend responder 429/5xx numa dessas tentativas, não há retry adicional dentro da mesma tentativa — o reenfileiramento do job já cumpre esse papel.
Identificação do destino
Empresa e produto são identificados por slug, nos dois casos. Não há de-para, não há UUID trafegando e não há lista de códigos para trocar entre os times: os dois sistemas passam a ter um campo slug derivado do nome, e a resolução é por convenção.
| O que | Campo | Obrigatório | Escopo de unicidade |
|---|---|---|---|
| Empresa | company_slug |
sim | global |
| Produto | product_slug |
não | dentro da empresa |
Na fase 1 os valores de company_slug são ibft e citrg, e só a CITRG envia product_slug — ver Slugs do lado legado.
Product.slug já existe no ibft-backend e é exatamente isso — o help_text do campo diz “identificador amigável enviado como product_id nas integrações”, e ele já é usado assim em apps/payments/services.py:120. Company ainda não tem o campo e vai precisar ganhar.
Regra canônica do slug
Os dois lados precisam gerar exatamente o mesmo slug para o mesmo nome, senão a resolução falha. Por isso a regra é especificada aqui, e não delegada a um helper de framework:
- Normalizar em NFKD e descartar os acentos, resultando em ASCII (
Extensão→Extensao). - Passar para minúsculas.
- Trocar toda sequência de caracteres fora de
[a-z0-9]por um único-. - Remover
-do início e do fim. - Truncar em 100 caracteres e remover
-do fim de novo.
Resolução
Empresa — company_slug resolve ou é 422 company_not_found. Sem fallback: errar a empresa é pior que falhar.
Produto:
product_slugpreenchido e resolve dentro da empresa → usa esse produto.product_slugpreenchido e não resolve →422product_not_resolved. Não cair no fallback: slug preenchido que não casa é erro de cadastro, e silenciar isso joga receita no produto errado.product_slugvazio ou ausente → produto legado da empresa.- Sem produto legado configurado →
422product_not_resolved.
O que isso exige de vocês
slugemCompany, derivado do nome pela regra acima, único global. Hoje o modelo não tem o campo.- Backfill de
Product.slugnos produtos da CITRG — é a única empresa que enviaproduct_slugna fase 1. O campo existe mas é opcional (blank=True, default="") e provavelmente está vazio. Produto sem slug não é alcançável por este contrato e o reparcelamento cai no produto legado. - Produto “legado” por empresa, que continua sendo o destino de vendas antigas sem equivalente — e, na fase 1, o destino de todo reparcelamento da IBFT.
A lista de slugs que vão existir do lado legado está em Slugs do lado legado — use como referência para o backfill.
Slugs do lado legado
| product_slug | Produto |
|—|—|
| citrg | CITRG |
### Empresas
Conjunto fechado nesta fase:
company_slug |
Empresa |
|---|---|
ibft |
IBFT |
citrg |
CITRG |
Empresa nova entra depois só preenchendo o slug dos dois lados — não muda contrato nem exige deploy coordenado.
Produtos
Nesta primeira fase só a CITRG envia product_slug. Reparcelamento da IBFT vai sem o campo e cai no produto legado da empresa.
Consequência prática para vocês: o backfill de Product.slug só é obrigatório nos produtos da CITRG. Os produtos da IBFT podem seguir com slug vazio sem quebrar nada, e passam a valer quando quisermos atribuição por produto lá também — sem mudança de contrato, só preenchendo o campo dos dois lados.
A preencher. A lista dos slugs de produto da CITRG sai de um inventário que vamos rodar no
checkout-api, com o slug que cada produto vai gerar pela regra canônica. Colamos aqui para vocês conferirem contra o cadastro antes do backfill.
Credenciais e códigos a nos passar
| Dado | Para quê |
|---|---|
| URL final do endpoint, em staging e produção | Destino do POST |
| Token de integração | Header Authorization: Bearer |
Nenhuma lista de códigos precisa ser trocada: com slug dos dois lados, a identificação é por convenção.
Referências úteis no ibft-backend
apps/webhooks/tasks.py—process_asaas_webhook, que resolve o evento do gateway pelo id da cobrança. É a razão do item 3 de O que precisa ser feitoapps/webhooks/views.py— postura fail-closed das views de webhook existentesapps/sales/views/installments.py— área de Parcelamento e seus filtrosapps/payments/transactions/asaas.py— como as parcelas de um carnê são registradas no fluxo normalapps/payments/tasks.pyeapps/payments/order_events.py—cleanup_asaas_open_chargese_dispatch_access_granted, os efeitos colaterais dos pontos 1 e 2 de decisão