Skip to content

Boleto Bancário (Mercado Pago)

Emissão de boletos imprimíveis com vencimento exato igual à assinatura, via endpoint POST /v1/payments do Mercado Pago (payment_method_id=bolbradesco).

Difere do Checkout Pro Preference porque entrega um PDF imediato com a data de vencimento já fixada — útil quando a escola prefere boleto sobre PIX ou cartão e precisa do documento para baixar no contas a pagar.


Matriz de atores

AtorOndeAçãoEndpoint
CRON faturamento-cronmensal, gerar_faturasEmite boleto automaticamente em background após criar a preferencegerarBoletoBackground interno → createBoleto
Escola (gestor)/escola/pagamentosClica "Boleto" em fatura vencida ou aberta → baixa PDFmercadopago-boleto action=gerar
AdminDrawer em /admin/financeiroDownload / Reemitir (force) / Cancelarmercadopago-boleto actions=gerar (forcar=true) / cancelar
Webhook MPmercadopago-webhookMarca fatura como pago quando o boleto compensa (approved)external_reference = fatura_id

O CRON é best-effort: se a escola estiver sem CNPJ ou endereço, o boleto não é emitido (skip) e o erro é logado como faturamento.boleto_erro. A escola ainda recebe o link da preference normalmente.


Diagrama de estados do boleto

text
[ sem boleto ]

     │ createBoleto OK

[ emitido / pending ]

     ├── webhook approved ───► [ pago ] (status_pagamento='pago')

     ├── admin cancela ─────► [ sem boleto ] (limpa campos boleto_*)

     └── vencimento ultrapassado ───► [ expirado no MP ]

                                           │ admin reemite (forcar=true)

                                      [ emitido novamente ]

Campos em escola_faturas

ColunaOrigemQuando preenche
boleto_payment_idMP data.idSucesso na emissão
boleto_pdf_urltransaction_details.external_resource_urlSucesso na emissão
boleto_barcodebarcode.contentSucesso na emissão
boleto_digitable_linetransaction_details.digitable_lineSucesso na emissão
boleto_expira_emEcho do date_of_expiration enviadoSucesso na emissão
boleto_gerado_emnow()Sucesso na emissão

Cancelamento limpa as 5 primeiras colunas. boleto_gerado_em é preservado para auditoria histórica.


Idempotência

O adapter usa header X-Idempotency-Key = boleto-<fatura_id>-<tag>. Tag default é v1. Reemissão (forcar=true) muda para v<timestamp>, fazendo o MP gerar um novo payment com novo PDF.

Antes de chamar MP, o handler gerar faz curto-circuito quando já existe boleto_payment_id + boleto_pdf_url na fatura e forcar=false → retorna reused: true (200) sem consumir cota do MP.

Reemissão de fatura vencida sempre usa tag v<timestamp> (mesmo sem forcar=true), pois valor e vencimento mudam.


Reemissão de boleto vencido (com encargos)

Quando vencimento_em < hoje_BRT, MP rejeita date_of_expiration no passado. O sistema então recalcula automaticamente:

  • Multa fixa (default 2%) + juros pro-rata diário (default 1% ao mês)
  • Novo vencimento = hoje + 3 dias úteis BRT (pulando sáb/dom)

A política completa, fórmulas e embasamento legal (CDC §52, Lei 9.298/96) estão em Encargos por Atraso.

Fluxo:

  1. UI chama previewEncargos(faturaId) → backend roda dry_run: true (sem chamar MP).
  2. Backend retorna preview ({ requer_confirmacao, multa, juros, valor_total, novo_vencimento }).
  3. UI abre ConfirmarEncargosDialog mostrando breakdown.
  4. Usuário confirma → gerarBoleto({ confirmar_encargos: true }).
  5. Backend recalcula (zero-trust) e chama createBoleto(valor_total, novo_vencimento).
  6. Snapshot salvo em escola_faturas.boleto_valor_cobrado/multa/juros/vencimento_original.
  7. Log emitido como pagamento.boleto_emitido_com_encargos.

Faturas no prazo continuam no fluxo direto (sem dialog).

Sem confirmação: se a UI tentar emitir vencido sem confirmar_encargos: true, o backend retorna 409 com o preview no payload (fail-safe — usuário precisa confirmar explicitamente).


Cobertura de testes (SSOT)

CamadaArquivoCobre
Régua D+3/D+10/D+17 (front)src/lib/__tests__/cobranca-regua.test.tsCadência da régua, fail-close
Régua D+3/D+10/D+17 (edge)supabase/functions/_shared/__tests__/cobranca-regua.test.tsParidade 1:1 com front
Adapter MPsupabase/functions/_shared/__tests__/payment-gateway-boleto.test.tsPayload, idempotency-key, mapeamento, erros 4xx, cancelBoleto
Edge functionsupabase/functions/mercadopago-boleto/__tests__/index.test.tsCORS, auth (401), action desconhecida, GET rejeitado
Hooksrc/hooks/__tests__/useMercadoPagoBoleto.integration.test.tsxgerar/cancelar, toast success/error, invalidação de cache, isGenerating
E2Ee2e/billing-boleto.spec.tsFluxo escola gera + baixa; admin reemite + cancela. Skip por padrão (custo MP). Ativar com BILLING_E2E_ENABLED=1.

Smoke test manual (pré-release)

  1. Em ambiente staging com MP em modo sandbox, criar fatura com vencimento futuro e CNPJ/endereço completos.
  2. Login como gestor da escola → /escola/pagamentos → clicar "Boleto" → baixar PDF.
  3. Verificar que escola_faturas.boleto_pdf_url foi preenchido.
  4. Recarregar a página → botão deve mostrar "Baixar Boleto" (sem nova chamada MP).
  5. Login admin → /admin/financeiro → drawer da mesma escola → "Reemitir" → verificar boleto_payment_id diferente.
  6. "Cancelar" → verificar campos boleto_* limpos exceto boleto_gerado_em.

Observabilidade

Ação de logDisparada por
pagamento.boleto_emitidomercadopago-boleto ação gerar (sucesso)
pagamento.boleto_erromercadopago-boleto ação gerar (falha MP)
pagamento.boleto_canceladomercadopago-boleto ação cancelar
faturamento.boleto_emitidoCRON gerarBoletoBackground (sucesso)
faturamento.boleto_erroCRON gerarBoletoBackground (dados incompletos ou falha MP)

Todas registradas em src/constants/log-actions.ts. Filtro do admin de logs e CRON Monitor reconhecem por padrão.


Referências cruzadas


Cobertura de testes

CamadaArquivoCenários
SSOT régua (front)src/lib/__tests__/cobranca-regua.test.tsD+3 / D+10 / D+17
SSOT régua (edge)supabase/functions/_shared/__tests__/cobranca-regua.test.tsParidade com frontend
Adaptersupabase/functions/_shared/__tests__/payment-gateway-boleto.test.tsPayload bolbradesco, idempotency-key, vencimento BRT, erros 400/401/429/500
Edge fn (contract)supabase/functions/mercadopago-boleto/__tests__/index.test.tsCORS, 401 sem auth, action inválida, GET 405
Edge fn (IDOR)tests/security/idor-mercadopago-boleto.test.tsCross-escola 403 em gerar/cancelar, anti-enumeração
CRON backgroundsupabase/functions/faturamento-cron/__tests__/boleto-background.test.tsSkip CNPJ/endereço/migrada/pago/já-tem-boleto + sucesso + erro MP
Webhook MPsupabase/functions/mercadopago-webhook/__tests__/boleto-bolbradesco.test.tsHMAC obrigatório, eventos não-payment ignorados
Hook UIsrc/hooks/__tests__/useMercadoPagoBoleto.integration.test.tsxgerar/cancelar, invalidação cache, forcar=true, rollback
E2E (gated)e2e/billing-boleto.spec.tsEscola gera + admin reemite/cancela (BILLING_E2E_ENABLED=1)

Lacunas conhecidas (P3): RTL completo de pagamentos-escola.tsx e escola-drawer.tsx — lógica de boleto já coberta indiretamente por hook + adapter + E2E. Tratar quando feature for refatorada. Happy-path real contra MP sandbox fica fora de CI (custo).


Boleto manual via UI Admin (2026-06-19)

Admin tem ícone Receipt em /admin/financeiro (tabela "Faturas") que abre o modal GerarBoletoManualDialog. O modal:

  • Pré-carrega encargos chamando previewEncargos(fatura_id) ao abrir.
  • Permite editar vencimento (default = D+3 úteis sugerido pelo backend) e o flag forçar reemissão.
  • Não permite editar valor principal, multa ou juros: o backend é a fonte da verdade (configuracoes_cobranca + _shared/encargos-atraso.ts). Override real de valor exigirá ADR + coluna boleto_valor_override + auditoria reforçada.
  • Submete via gerarBoleto({ fatura_id, vencimento_override, forcar, confirmar_encargos }).

Bug histórico: Payload inválido (fatura_id: Required)

Causa raiz real (descoberta em 2026-06-19 via Network): quebra de contrato entre frontend e Edge Function. O hook useMercadoPagoBoleto envia via invokeAction('mercadopago-boleto', 'gerar', { fatura_id }), o que produz body { action, params: { fatura_id } }. O handler estava rodando GerarSchema.safeParse(body) no body raiz — fatura_id mora em body.params, não em body, então o Zod sempre rejeitava como "fatura_id: Required" mesmo com UUID válido na requisição.

Hipótese anterior (race com hidratação) era falsa: o Network confirmou que o UUID chegava corretamente em params.fatura_id.

Correção (2026-06-19):

  1. supabase/functions/mercadopago-boleto/index.ts extrai rawParams = body.params ?? body antes do Zod, aceitando ambos os contratos (invokeAction e flat legado).
  2. useMercadoPagoBoleto.ts mantém assertFaturaId (UUID) como defesa client-side para mensagem amigável quando o id estiver ausente — não substitui a Edge Function.
  3. Testes de regressão:
    • supabase/functions/mercadopago-boleto/__tests__/payload-contract.test.ts — parser dual ({ params: {...} } e flat); inclui o body literal do Network.
    • src/hooks/__tests__/useMercadoPagoBoleto.integration.test.tsx — UUIDs reais em todos os cenários + 3 testes novos garantindo que UUID inválido rejeita ANTES do invokeAction.

Limites operacionais do Mercado Pago

Limites confirmados em produção que exigem pré-validação local ANTES da chamada HTTP (evita desperdício de cota + dá mensagem clara ao usuário):

LimiteValorFonteSSOT
Vencimento máximo de boleto29 dias a partir de hojeErro "The expiration date can not be greater than 29 days" (2026-06-19, fatura OLP-2026-0007)MP_BOLETO_MAX_DAYS_AHEAD em _shared/payment-gateway.ts

Fluxo de pré-validação (boleto vencimento > 29 dias)

  1. UI (pagamentos-escola.tsx): botão de boleto fica disabled com title: "Disponível a partir de DD/MM/AAAA (limite de 29 dias do Mercado Pago)".
  2. Edge Function (mercadopago-boleto/index.ts): mesmo se a UI for burlada, rejeita com HTTP 422 + code: 'vencimento_fora_janela_mp' + mensagem amigável. Zero chamadas ao MP.
  3. Log: pagamento.boleto_pre_validacao_falhou em logs_transacoes com vencimento_dias_distancia, max_dias_mp, data_minima_emissao.

Mapeamento de erros do MP → HTTP status (handler)

Erro do MPHTTP statuscodeQuem decide
expiration date422vencimento_fora_janela_mpclassifyMpError()
cpf / cnpj / identification422documento_invalido_mpclassifyMpError()
cep / zip / address422endereco_invalido_mpclassifyMpError()
amount / transaction_amount422valor_invalido_mpclassifyMpError()
Qualquer outro 4xx do MP422dados_invalidos_mpclassifyMpError()
5xx do MP / timeout / network502mp_indisponivelclassifyMpError()

Regra absoluta: NUNCA HTTP 502 para erro de domínio do provedor — o wrapper invokeEdge trata 502 como transitório e dispara retry. Ver PROBLEM_SOLVING Caso 22 e mem://architecture/external-provider-error-status-mapping.


Fora de escopo (rastreado)

Itens deliberadamente excluídos do MVP do boleto manual (entrega 2026-06-19) e onde estão rastreados:

ItemStatusRastreamento
Edição real de valor/multa/juros pelo adminGated em ADRADR-017 (Proposed)
Feriados nacionais no cálculo D+3P2 sprint próximamem://features/billing/feriados-dias-uteis-roadmap + encargos-atraso.md §Limitações
Demais intervenções em mercadopago-boletoTriagem abaixo§Roadmap pós-MVP

Nenhum desses itens deve ser implementado sem antes mover o gate correspondente (ADR aceito, card no TESTING_BACKLOG.md, ou aprovação explícita do roadmap).


Roadmap pós-MVP (triagem 2026-06-20)

Triagem dos itens identificados em revisão de escopo. Cada um tem decisão fixa para não voltar a ser proposto sem novo gatilho real.

#ItemDecisãoJustificativaPróximo passo
1Override real de valor/multa/juros (admin)Adiar P2 — gated por ADRRisco contábil (divergência com SSOT escola_faturas.valor), conciliação MP, auditoria LGPD. Implementação parcial proibida.Aprovar ADR-017 antes de qualquer linha de código.
2Feriados nacionais no D+3Promover P2~10-12 dias/ano de cobrança indevida de juros (CDC §52).Card em TESTING_BACKLOG.md + roadmap memória.
3aRetry com backoff p/ 5xx do MP no edgeAdiar P3invokeEdge já tem retry no frontend; redundância no edge tem custo de complexidade.Reabrir se observabilidade mostrar 5xx > 0.1% no MP.
3bWebhook merchant_orderRejeitarpayment cobre 99% dos casos de boleto.Reabrir só se aparecer caso real de fatura não-reconciliada por falta de merchant_order.
3cCancelamento parcial (refund de boleto pago)Promover P2Hoje só cancela boleto pending; refund de pago usa API diferente. Alto impacto se ocorrer.Action reembolsar separada (não misturar com cancelar). Card em TESTING_BACKLOG.md.
3dUnificar PIX neste handlerRejeitarPIX já tem fluxo próprio via Checkout Pro; handlers separados garantem isolamento de erro.Não reabrir sem refactor de billing inteiro.
3eRate limit por escola para gerarPromover P3Admin pode disparar reemissão em loop por bug de UI; risco operacional moderado.Reusar infra portal_rate_metrics. Card em TESTING_BACKLOG.md.
3fHistórico append-only de boletos por faturaPromover P2Hoje só guardamos o último; auditoria de quantas vezes reemitiu, quem, quando, é cega.Tabela escola_fatura_boleto_historico (append-only). Pré-requisito para ADR-017 (override depende de historiar).

Regra desta seção: itens marcados Rejeitar não voltam à pauta sem um novo incidente/dado concreto. Itens P2/P3 só viram entrega após card no TESTING_BACKLOG.md e revisão de PR.