Skip to content

Mercado Pago — Reconciliação de Pagamentos

Quando ler: webhook MP falhou (HMAC 401), boleto compensado fora de banda, fatura paga ainda aparecendo como vencido ou pendente, ou suspeita de drift entre status MP × status na plataforma.

TL;DR

  1. Cron automático: roda 4×/dia (0 2,9,15,21 * * * UTC) e regulariza qualquer fatura pendente/vencido cujo Mercado Pago já tenha pagamento approved.
  2. Atalho manual (admin): ícone RefreshCw em
    • Admin Financeiro → Faturas vencidas (1 fatura)
    • Detalhes da escola → Ciclo Contratual (todas as pendentes/vencidas do ano)
  3. Trigger manual completo: aba CRON → "Reconciliar Pagamentos" (cobre todas as faturas elegíveis dos últimos 14 dias).
  4. 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

CamadaArquivoFunção
Núcleosupabase/functions/_shared/mp-reconcile-helper.tsbuscarFaturasCandidatas, reconciliarFaturas
Confirmaçãosupabase/functions/_shared/mp-confirm-payment.tsconfirmarPagamentoFatura (lock otimista, idempotente)
Cronsupabase/functions/faturamento-cron/index.ts (actionReconciliarPagamentos)Action reconciliar_pagamentos
Endpoint admin/manualsupabase/functions/mercadopago-reconciliar/index.tsAceita admin (cookie) ou X-Internal-Cron-Secret
UI hooksrc/hooks/useReconciliarPagamento.tsMostra resultado por fatura via olpToast
UI atalho 1src/components/admin-financeiro/vencidas-table.tsxBotão RefreshCw por linha
UI atalho 2src/components/ciclo-contratual-dashboard.tsxBotão "Reconciliar com MP" no header do ciclo
Registry CRONsrc/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 RefreshCw na 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)

bash
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 MPResultadoStatus fatura
Sem pagamentosem_pagamento_aprovadoinalterado
Pagamento pending, rejected, in_process, etc.sem_pagamento_aprovadoinalterado
approved mas valor/moeda divergentevalidacao_falhouinalterado (alerta)
approved válido — primeira execuçãoregularizadapago
approved válido — fatura já estava pagaja_pagainalterado
Erro de rede / timeouterroinalterado

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-diario

A 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_executada com summary (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 ntfy com cooldown de 1h.
  • 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:

  1. Acesse o portal Mercado Pago → Suas integrações → Webhooks.
  2. Confira / regenere o secret de mercadopago-webhook.
  3. Atualize o secret MP_WEBHOOK_SECRET em Supabase → Project Settings → Edge Functions → Secrets.
  4. 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_ids especí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: o command contém o Bearer literal do CRON_SECRET (mesmo padrão dos outros 7 jobs do projeto, já que pg_cron roda 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:

sql
DO $$ 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 = '…'; e SELECT * 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-standards
  • docs/operations/CRON_JOBS.md