Status do lado checkout-api: implementado, de forma assíncrona (CheckoutPayments::RepaymentService#generate! grava um OutboxEvent; 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 o ibft-backend expor o endpoint em staging para validação ponta a ponta.

Endpoint de sincronização de reparcelamento — ibft-backend

TLDR: o ibft-backend precisa 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:

  1. Receber e autenticar a chamada por um token compartilhado entre os dois sistemas. Fail-closed: sem token configurado no servidor, rejeitar.
  2. 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.
  3. Registrar cada parcela individualmente, cada uma com o id da cobrança no Asaas. Este é o ponto crítico do contrato: process_asaas_webhook resolve 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.
  4. 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 em Charge e IntegrationDispatchLog, o caminho é unique constraint no banco em vez de checagem na aplicação.
  5. 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.
  6. 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.
  7. Não disparar nenhum evento de pedido para o que é importado. Nem ORDER_CREATED, nem ORDER_PAID, nem e-mail, nem comissão. Isso vale em dobro para o pedido original: as parcelas dele foram pagas no passado e entram como approved, 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.
  8. 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.
  9. Escolher o offer_code dos pedidos legados sem colidir com o cleanup de carrinho abandonado. O campo não é inerte: uma rotina existente usa shopper + company + offer_code para cancelar cobranças em aberto. Um offer_code constante 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.
  10. 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:

  • installments tem exatamente installment_count entradas.
  • number vai de 1 a installment_count, sem repetição nem lacuna.
  • A soma de installments[].amount é igual a repayment.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.installments tem exatamente original_payment.installment_count entradas.
  • A quantidade de parcelas paid em original_payment.installments é igual a paid_installment_count, e a soma dos valores delas é igual a paid_total.
  • original_payment.reference é diferente de repayment.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:

  1. Normalizar em NFKD e descartar os acentos, resultando em ASCII (Extensão → Extensao).
  2. Passar para minúsculas.
  3. Trocar toda sequência de caracteres fora de [a-z0-9] por um único -.
  4. Remover - do início e do fim.
  5. 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:

  1. product_slug preenchido e resolve dentro da empresa → usa esse produto.
  2. product_slug preenchido e não resolve → 422 product_not_resolved. Não cair no fallback: slug preenchido que não casa é erro de cadastro, e silenciar isso joga receita no produto errado.
  3. product_slug vazio ou ausente → produto legado da empresa.
  4. Sem produto legado configurado → 422 product_not_resolved.

O que isso exige de vocês

  1. slug em Company, derivado do nome pela regra acima, único global. Hoje o modelo não tem o campo.
  2. Backfill de Product.slug nos produtos da CITRG — é a única empresa que envia product_slug na 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.
  3. 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 feito
  • apps/webhooks/views.py — postura fail-closed das views de webhook existentes
  • apps/sales/views/installments.py — área de Parcelamento e seus filtros
  • apps/payments/transactions/asaas.py — como as parcelas de um carnê são registradas no fluxo normal
  • apps/payments/tasks.py e apps/payments/order_events.py — cleanup_asaas_open_charges e _dispatch_access_granted, os efeitos colaterais dos pontos 1 e 2 de decisão