Mercado Pago — Reconciliação de Pagamentos
Quando ler: webhook MP falhou (HMAC 401), boleto compensado fora de banda, fatura paga ainda aparecendo como
vencidooupendente, ou suspeita de drift entre status MP × status na plataforma.
TL;DR
- Cron automático: roda 4×/dia (
0 2,9,15,21 * * *UTC) e regulariza qualquer faturapendente/vencidocujo Mercado Pago já tenha pagamentoapproved. - Atalho manual (admin): ícone
RefreshCwemAdmin Financeiro → Faturas vencidas(1 fatura)Detalhes da escola → Ciclo Contratual(todas as pendentes/vencidas do ano)
- Trigger manual completo: aba CRON → "Reconciliar Pagamentos" (cobre todas as faturas elegíveis dos últimos 14 dias).
- Fail-safe: se o MP não retornar pagamento
approved, a fatura não é alterada — segue como estava.
Por que existe
O webhook mercadopago-webhook é a fonte primária de confirmação. Quando o HMAC dele rejeita (assinatura quebrada, secret rotacionado, retry após expiração) ou um boleto compensa fora do horário comercial, a fatura fica em vencido mesmo já estando paga no MP.
A reconciliação por polling (payments/search por external_reference) é o plano B: idempotente, fail-safe e cobre tanto erros transitórios quanto falhas crônicas até o webhook ser corrigido.
Componentes
| Camada | Arquivo | Função |
|---|---|---|
| Núcleo | supabase/functions/_shared/mp-reconcile-helper.ts | buscarFaturasCandidatas, reconciliarFaturas |
| Confirmação | supabase/functions/_shared/mp-confirm-payment.ts | confirmarPagamentoFatura (lock otimista, idempotente) |
| Cron | supabase/functions/faturamento-cron/index.ts (actionReconciliarPagamentos) | Action reconciliar_pagamentos |
| Endpoint admin/manual | supabase/functions/mercadopago-reconciliar/index.ts | Aceita admin (cookie) ou X-Internal-Cron-Secret |
| UI hook | src/hooks/useReconciliarPagamento.ts | Mostra resultado por fatura via olpToast |
| UI atalho 1 | src/components/admin-financeiro/vencidas-table.tsx | Botão RefreshCw por linha |
| UI atalho 2 | src/components/ciclo-contratual-dashboard.tsx | Botão "Reconciliar com MP" no header do ciclo |
| Registry CRON | src/components/admin-cron-monitor/jobs-config.ts (reconciliar-pagamentos-diario) | Card + trigger manual |
Como rodar
Via UI
- 1 fatura específica: vá em Admin → Financeiro → Vencidas, clique no ícone
RefreshCwna linha da fatura. Toast informa o resultado. - Todas do ciclo de uma escola: abra Admin → Escolas → [escola] → Ciclo Contratual, clique em "Reconciliar com MP" no canto superior direito.
- Lote completo (últimos 14 dias): Admin → CRON Monitor → "Reconciliar Pagamentos" → Disparar agora.
Via API (cron interno / scripts)
curl -sS -X POST 'https://mjvuzsizjlcalyfmbquy.supabase.co/functions/v1/mercadopago-reconciliar' \
-H 'Content-Type: application/json' \
-H "X-Internal-Cron-Secret: $CRON_SECRET" \
-d '{"fatura_ids": ["<uuid>"]}'Sem fatura_ids, a função busca candidatas (mesmo critério do cron): status_pagamento ∈ {pendente, vencido}, com gateway_preference_id, não migrada, criadas nos últimos 14 dias.
Fail-safe — o que NÃO acontece
A reconciliação nunca infere que a fatura foi paga. Só transiciona pendente|vencido → pago quando payments/search retorna registro com status === 'approved' E a validação validarPagamentoMP passa (valor, moeda, external_reference).
| Resposta MP | Resultado | Status fatura |
|---|---|---|
| Sem pagamento | sem_pagamento_aprovado | inalterado |
Pagamento pending, rejected, in_process, etc. | sem_pagamento_aprovado | inalterado |
approved mas valor/moeda divergente | validacao_falhou | inalterado (alerta) |
approved válido — primeira execução | regularizada | pago |
approved válido — fatura já estava paga | ja_paga | inalterado |
| Erro de rede / timeout | erro | inalterado |
A confirmação usa lock otimista (UPDATE ... WHERE status_pagamento != 'pago'), então execuções concorrentes (cron + admin clicando o botão) não duplicam side-effects (notificação, e-mail, invalidação de preference).
Sequência diária (BRT)
02:00 reconciliar-pagamentos-diario (regulariza qualquer pago não-confirmado)
04:30 marcar-vencidas-diario (pendentes com vencimento já passado → vencido)
06:00 lembrete-d5-diario
09:00 reconciliar-pagamentos-diario (cobertura entre janelas)
09:00 régua-cobranca-diario
15:00 reconciliar-pagamentos-diario
21:00 reconciliar-pagamentos-diarioA reconciliação roda antes do marcar_vencidas, então qualquer pagamento aprovado fora de banda é capturado antes de a fatura ser marcada como vencida.
Telemetria
- Sucesso (cron):
pagamento.reconciliacao_executadacomsummary(regularizadas, sem_pagamento, ja_pagas, divergencias, erros). - Sucesso (admin manual): mesma ação,
modo: 'admin'. - HMAC do webhook quebrado (>3 falhas/h):
pagamento.webhook_hmac_falha_recorrente- push
ntfycom cooldown de 1h.
- push
- Filtros prontos no Admin → Logs com label "Reconciliar Pagamentos".
Quando o webhook está quebrado
A reconciliação mascara o sintoma mas não corrige a causa. Se você ver pagamento.webhook_hmac_falha_recorrente repetidamente:
- Acesse o portal Mercado Pago → Suas integrações → Webhooks.
- Confira / regenere o secret de
mercadopago-webhook. - Atualize o secret
MP_WEBHOOK_SECRETem Supabase → Project Settings → Edge Functions → Secrets. - Aguarde 5 min, dispare uma reconciliação manual e verifique os logs.
Limites
- Máximo 500 faturas/execução (cap defensivo em
buscarFaturasCandidatas). - Janela default 14 dias retroativos no cron; admin pode passar
fatura_idsespecíficos para fugir da janela. - Cada
payments/searché uma chamada à API MP — 4×/dia × ~N faturas pendentes. Sem rate-limit dedicado (volume atual << limites MP).
Como agendar/alterar o cron (procedimento correto)
NÃO colocar o
cron.schedule(...)em arquivo de migration: ocommandcontém o Bearer literal doCRON_SECRET(mesmo padrão dos outros 7 jobs do projeto, já quepg_cronroda no Postgres e não enxerga secrets de Edge Functions). Migrations vão para o repositório git → token vaza.Executar o SQL direto no banco (Supabase SQL Editor ou ferramenta de insert do agente). Snippet idempotente:
sqlDO $$ BEGIN PERFORM cron.unschedule('reconciliar-pagamentos-diario'); EXCEPTION WHEN OTHERS THEN NULL; END $$; SELECT cron.schedule( 'reconciliar-pagamentos-diario', '0 2,9,15,21 * * *', $job$ SELECT net.http_post( url := 'https://<ref>.supabase.co/functions/v1/faturamento-cron', headers := '{"Content-Type":"application/json","Authorization":"Bearer <CRON_SECRET>"}'::jsonb, body := '{"action":"reconciliar_pagamentos","triggered_by":"pg_cron"}'::jsonb, timeout_milliseconds := 120000 ) AS request_id; $job$ );Validação:
SELECT * FROM cron.job WHERE jobname = '…';eSELECT * FROM cron.job_run_details WHERE jobid = … ORDER BY start_time DESC LIMIT 5;.
Referências
- ADR-006 — Third-party integration standard
mem://security/payment-integration-security-standardsdocs/operations/CRON_JOBS.md