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
| Ator | Onde | Ação | Endpoint |
|---|---|---|---|
CRON faturamento-cron | mensal, gerar_faturas | Emite boleto automaticamente em background após criar a preference | gerarBoletoBackground interno → createBoleto |
| Escola (gestor) | /escola/pagamentos | Clica "Boleto" em fatura vencida ou aberta → baixa PDF | mercadopago-boleto action=gerar |
| Admin | Drawer em /admin/financeiro | Download / Reemitir (force) / Cancelar | mercadopago-boleto actions=gerar (forcar=true) / cancelar |
| Webhook MP | mercadopago-webhook | Marca 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
[ 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
| Coluna | Origem | Quando preenche |
|---|---|---|
boleto_payment_id | MP data.id | Sucesso na emissão |
boleto_pdf_url | transaction_details.external_resource_url | Sucesso na emissão |
boleto_barcode | barcode.content | Sucesso na emissão |
boleto_digitable_line | transaction_details.digitable_line | Sucesso na emissão |
boleto_expira_em | Echo do date_of_expiration enviado | Sucesso na emissão |
boleto_gerado_em | now() | 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:
- UI chama
previewEncargos(faturaId)→ backend rodadry_run: true(sem chamar MP). - Backend retorna preview (
{ requer_confirmacao, multa, juros, valor_total, novo_vencimento }). - UI abre
ConfirmarEncargosDialogmostrando breakdown. - Usuário confirma →
gerarBoleto({ confirmar_encargos: true }). - Backend recalcula (zero-trust) e chama
createBoleto(valor_total, novo_vencimento). - Snapshot salvo em
escola_faturas.boleto_valor_cobrado/multa/juros/vencimento_original. - 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)
| Camada | Arquivo | Cobre |
|---|---|---|
| Régua D+3/D+10/D+17 (front) | src/lib/__tests__/cobranca-regua.test.ts | Cadência da régua, fail-close |
| Régua D+3/D+10/D+17 (edge) | supabase/functions/_shared/__tests__/cobranca-regua.test.ts | Paridade 1:1 com front |
| Adapter MP | supabase/functions/_shared/__tests__/payment-gateway-boleto.test.ts | Payload, idempotency-key, mapeamento, erros 4xx, cancelBoleto |
| Edge function | supabase/functions/mercadopago-boleto/__tests__/index.test.ts | CORS, auth (401), action desconhecida, GET rejeitado |
| Hook | src/hooks/__tests__/useMercadoPagoBoleto.integration.test.tsx | gerar/cancelar, toast success/error, invalidação de cache, isGenerating |
| E2E | e2e/billing-boleto.spec.ts | Fluxo escola gera + baixa; admin reemite + cancela. Skip por padrão (custo MP). Ativar com BILLING_E2E_ENABLED=1. |
Smoke test manual (pré-release)
- Em ambiente staging com MP em modo sandbox, criar fatura com vencimento futuro e CNPJ/endereço completos.
- Login como gestor da escola →
/escola/pagamentos→ clicar "Boleto" → baixar PDF. - Verificar que
escola_faturas.boleto_pdf_urlfoi preenchido. - Recarregar a página → botão deve mostrar "Baixar Boleto" (sem nova chamada MP).
- Login admin →
/admin/financeiro→ drawer da mesma escola → "Reemitir" → verificarboleto_payment_iddiferente. - "Cancelar" → verificar campos
boleto_*limpos excetoboleto_gerado_em.
Observabilidade
| Ação de log | Disparada por |
|---|---|
pagamento.boleto_emitido | mercadopago-boleto ação gerar (sucesso) |
pagamento.boleto_erro | mercadopago-boleto ação gerar (falha MP) |
pagamento.boleto_cancelado | mercadopago-boleto ação cancelar |
faturamento.boleto_emitido | CRON gerarBoletoBackground (sucesso) |
faturamento.boleto_erro | CRON 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
- Régua de cobrança D+3 / D+10 / D+17
- Subscription Billing
- CRON Jobs §faturamento-cron
- ADR: por que D+3 e não D+1 → janela de compensação bancária de 3 dias úteis.
Cobertura de testes
| Camada | Arquivo | Cenários |
|---|---|---|
| SSOT régua (front) | src/lib/__tests__/cobranca-regua.test.ts | D+3 / D+10 / D+17 |
| SSOT régua (edge) | supabase/functions/_shared/__tests__/cobranca-regua.test.ts | Paridade com frontend |
| Adapter | supabase/functions/_shared/__tests__/payment-gateway-boleto.test.ts | Payload bolbradesco, idempotency-key, vencimento BRT, erros 400/401/429/500 |
| Edge fn (contract) | supabase/functions/mercadopago-boleto/__tests__/index.test.ts | CORS, 401 sem auth, action inválida, GET 405 |
| Edge fn (IDOR) | tests/security/idor-mercadopago-boleto.test.ts | Cross-escola 403 em gerar/cancelar, anti-enumeração |
| CRON background | supabase/functions/faturamento-cron/__tests__/boleto-background.test.ts | Skip CNPJ/endereço/migrada/pago/já-tem-boleto + sucesso + erro MP |
| Webhook MP | supabase/functions/mercadopago-webhook/__tests__/boleto-bolbradesco.test.ts | HMAC obrigatório, eventos não-payment ignorados |
| Hook UI | src/hooks/__tests__/useMercadoPagoBoleto.integration.test.tsx | gerar/cancelar, invalidação cache, forcar=true, rollback |
| E2E (gated) | e2e/billing-boleto.spec.ts | Escola 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 + colunaboleto_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):
supabase/functions/mercadopago-boleto/index.tsextrairawParams = body.params ?? bodyantes do Zod, aceitando ambos os contratos (invokeActione flat legado).useMercadoPagoBoleto.tsmantémassertFaturaId(UUID) como defesa client-side para mensagem amigável quando o id estiver ausente — não substitui a Edge Function.- 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 doinvokeAction.
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):
| Limite | Valor | Fonte | SSOT |
|---|---|---|---|
| Vencimento máximo de boleto | 29 dias a partir de hoje | Erro "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)
- UI (
pagamentos-escola.tsx): botão de boleto ficadisabledcomtitle: "Disponível a partir de DD/MM/AAAA (limite de 29 dias do Mercado Pago)". - 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. - Log:
pagamento.boleto_pre_validacao_falhouemlogs_transacoescomvencimento_dias_distancia,max_dias_mp,data_minima_emissao.
Mapeamento de erros do MP → HTTP status (handler)
| Erro do MP | HTTP status | code | Quem decide |
|---|---|---|---|
expiration date | 422 | vencimento_fora_janela_mp | classifyMpError() |
cpf / cnpj / identification | 422 | documento_invalido_mp | classifyMpError() |
cep / zip / address | 422 | endereco_invalido_mp | classifyMpError() |
amount / transaction_amount | 422 | valor_invalido_mp | classifyMpError() |
| Qualquer outro 4xx do MP | 422 | dados_invalidos_mp | classifyMpError() |
| 5xx do MP / timeout / network | 502 | mp_indisponivel | classifyMpError() |
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:
| Item | Status | Rastreamento |
|---|---|---|
| Edição real de valor/multa/juros pelo admin | Gated em ADR | ADR-017 (Proposed) |
| Feriados nacionais no cálculo D+3 | P2 sprint próxima | mem://features/billing/feriados-dias-uteis-roadmap + encargos-atraso.md §Limitações |
Demais intervenções em mercadopago-boleto | Triagem 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.
| # | Item | Decisão | Justificativa | Próximo passo |
|---|---|---|---|---|
| 1 | Override real de valor/multa/juros (admin) | Adiar P2 — gated por ADR | Risco 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. |
| 2 | Feriados nacionais no D+3 | Promover P2 | ~10-12 dias/ano de cobrança indevida de juros (CDC §52). | Card em TESTING_BACKLOG.md + roadmap memória. |
| 3a | Retry com backoff p/ 5xx do MP no edge | Adiar P3 | invokeEdge já tem retry no frontend; redundância no edge tem custo de complexidade. | Reabrir se observabilidade mostrar 5xx > 0.1% no MP. |
| 3b | Webhook merchant_order | Rejeitar | payment cobre 99% dos casos de boleto. | Reabrir só se aparecer caso real de fatura não-reconciliada por falta de merchant_order. |
| 3c | Cancelamento parcial (refund de boleto pago) | Promover P2 | Hoje 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. |
| 3d | Unificar PIX neste handler | Rejeitar | PIX já tem fluxo próprio via Checkout Pro; handlers separados garantem isolamento de erro. | Não reabrir sem refactor de billing inteiro. |
| 3e | Rate limit por escola para gerar | Promover P3 | Admin pode disparar reemissão em loop por bug de UI; risco operacional moderado. | Reusar infra portal_rate_metrics. Card em TESTING_BACKLOG.md. |
| 3f | Histórico append-only de boletos por fatura | Promover P2 | Hoje 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.