Cardápio Baianah — functions/api/mp/pay.js (rota de criação de pagamento, Cloudflare Workers)
// Mercado Pago — processa o pagamento vindo do Payment Brick (checkout
// transparente: cartão ou Pix, sem sair do site).
// POST /api/mp/pay { formData, amount, description, payer }
// formData = exatamente o que o Brick devolve no onSubmit (token/method/etc.)
//
// Inerte por padrão: sem MP_ACCESS_TOKEN configurado (Cloudflare Pages
// secret), devolve 500 — mas o front nunca chega a chamar esta rota nesse
// caso, porque /api/mp/config já disse que não há chave pública.
//
// ⚠️ O valor cobrado (`amount`) vem do carrinho calculado no NAVEGADOR do
// cliente. Mitigações (2026-07-21): o servidor exige amount = produtos+frete,
// e o pedido criado no painel SEMPRE mostra o valor realmente pago no MP —
// com alerta 🚨 destacado se divergir do total declarado (ver _pedido.js).
// Fechar de vez exigiria recalcular o total no servidor a partir do menu do
// KV (frágil: labels compostos, kit festa, sazonais) — Carine confere.
//
// Pagamento aprovado (cartão, na hora) já cria o pedido sozinho no painel
// (baianah-pedidos) — ver _pedido.js. Pix pendente guarda o "snapshot" do
// pedido aqui no KV e cria depois, quando webhook.js ou status.js virem a
// confirmação chegar.
import { criarPedidoSeNecessario, registrarRecusaSeNecessario, enviarCapiPurchaseSeNecessario, alertaTecnico } from "./_pedido.js";
export async function onRequestPost({ request, env }) {
const token = env.MP_ACCESS_TOKEN;
if (!token) return json({ error: "Mercado Pago não configurado" }, 500);
// freio anti "card testing" (testar lotes de cartão roubado num checkout
// aberto pode derrubar a reputação/conta do MP): máx. 12 tentativas por
// IP por hora. KV é eventualmente consistente — é freio, não muro; o
// antifraude do próprio Mercado Pago continua atrás disso.
const ip = request.headers.get("CF-Connecting-IP") || "0";
if (env.BAIANAH_KV) {
try {
const rlKey = "rl:pay:" + ip + ":" + new Date().toISOString().slice(0, 13);
const n = parseInt(await env.BAIANAH_KV.get(rlKey), 10) || 0;
if (n >= 12) return json({ error: "Muitas tentativas de pagamento — espere um pouco e tente de novo." }, 429);
await env.BAIANAH_KV.put(rlKey, String(n + 1), { expirationTtl: 7200 });
} catch (_e) { /* KV indisponível não bloqueia pagamento legítimo */ }
}
let body = {};
try { body = await request.json(); } catch (_e) { body = {}; }
const { formData, description, payer } = body;
const amount = Number(body.amount);
// produtos/frete são só pra registro (o frete é repasse pro entregador, não
// entra na receita da padaria — precisa ficar separado pra quem lançar no
// painel depois); a cobrança em si sempre usa `amount`, que é a soma dos dois
const valorProdutos = Number.isFinite(Number(body.valorProdutos)) ? Number(body.valorProdutos) : null;
const valorFrete = Number.isFinite(Number(body.valorFrete)) ? Number(body.valorFrete) : null;
// snapshot do pedido (cliente + itens + agenda) — guardado no KV pra criar
// o pedido no painel, na hora (cartão) ou depois (Pix pendente)
const pedidoSnapshot = (body.pedido && typeof body.pedido === "object") ? body.pedido : null;
if (!formData || typeof formData !== "object") return json({ error: "dados de pagamento ausentes" }, 400);
if (!(amount > 0)) return json({ error: "valor inválido" }, 400);
// o front sempre manda amount = produtos + frete; um valor que não fecha só
// acontece em chamada forjada direto na API — recusa antes de cobrar
if (valorProdutos !== null && valorFrete !== null && Math.abs(amount - (valorProdutos + valorFrete)) > 0.01) {
return json({ error: "valores inconsistentes" }, 400);
}
// Monta o payload pro MP só com os campos CONHECIDOS do Brick (token,
// método, emissor, payer) — repassar `...formData` inteiro deixava um
// cliente malicioso injetar campos arbitrários da API de pagamentos
// (capture:false, parcelamento forjado etc.).
const payerIn = (formData.payer && typeof formData.payer === "object") ? formData.payer : {};
const payerOut = {};
if (payerIn.email) payerOut.email = String(payerIn.email).slice(0, 120);
if (payerIn.first_name) payerOut.first_name = String(payerIn.first_name).slice(0, 80);
if (payerIn.last_name) payerOut.last_name = String(payerIn.last_name).slice(0, 80);
if (payerIn.identification && typeof payerIn.identification === "object") {
payerOut.identification = {
type: String(payerIn.identification.type || "").slice(0, 10),
number: String(payerIn.identification.number || "").slice(0, 20),
};
}
if (payer && typeof payer === "object" && payer.email) payerOut.email = String(payer.email).slice(0, 120);
/* 🛑 07/08/2026 — o pedido inteiro viaja DENTRO do pagamento.
Até aqui o snapshot vivia só no nosso KV, e o KV era ponto único de
falha: ele é eventualmente consistente, então webhook e polling podiam
ler vazio segundos depois da gravação e sobrescrever o registro bom com
um esqueleto. Aconteceu com a Gislaine (#171656815195) e, na real,
também com a Cássia (#1106). Guardando aqui, o pagamento passa a ser a
fonte da verdade: mesmo que o KV perca tudo, dá pra reconstruir o pedido.
Vai como STRING numa chave só — metadata com objeto aninhado volta do MP
com as chaves mexidas. Se estourar o limite, some sozinho em vez de
derrubar o pagamento. */
const metadata = { origem: "cardapio-baianah" };
if (pedidoSnapshot) {
try {
const pacote = JSON.stringify({
c: pedidoSnapshot.cliente || null,
i: pedidoSnapshot.itens || [],
a: pedidoSnapshot.agenda || null,
p: pedidoSnapshot.pagamento || null,
vp: valorProdutos, vf: valorFrete,
});
if (pacote.length <= 9000) metadata.pedido = pacote;
} catch (_e) { /* pedido segue sem o espelho — o KV ainda é o caminho normal */ }
}
const payload = {
transaction_amount: amount,
description: String(description || "Pedido Baianah").slice(0, 250),
payer: payerOut,
metadata,
};
if (formData.token) payload.token = String(formData.token);
if (formData.payment_method_id) payload.payment_method_id = String(formData.payment_method_id).slice(0, 40);
if (formData.payment_type_id) payload.payment_type_id = String(formData.payment_type_id).slice(0, 40);
if (formData.issuer_id != null) payload.issuer_id = formData.issuer_id;
// parcelamento desligado por decisão (UI usa maxInstallments:1) — fixa 1x
// no servidor pra chamada direta não conseguir forjar 12x
if (formData.token) payload.installments = 1;
/* 🛑 3-D SECURE (08/08/2026) — a causa das recusas seguidas de cartão.
Sem `three_d_secure_mode` o Mercado Pago assume `not_supported`: a
transação NUNCA é autenticada no banco, e a que precisaria de
autenticação volta recusada com `cc_rejected_high_risk`. É por isso que o
MESMO cartão que o nosso site recusou passou no link de pagamento deles
(Checkout Pro autentica sozinho) e o Pix sempre aprovou (Pix não tem 3DS).
Só faz sentido em cartão — Pix não usa. Ver 3DS.md.
🛑 `optional`, NUNCA `mandatory` (testado em produção 08/08, 13h45–13h55):
com `mandatory` o MP devolveu **500 "internal_error" SEM CRIAR o
pagamento** (cliente real 13h47, R$74 — nenhum registro no KV, nenhuma
recusa no painel, nenhum rastro). Com `optional` o pagamento É criado
(Adriana 13h06: recusado, mas registrado e avisado). O `optional` não
desafia nesta conta (o antifraude recusa antes de decidir), mas não
quebra nada — quem resolve cartão de verdade é o plano B (pref.js,
página do MP). */
if (formData.token) {
payload.three_d_secure_mode = "optional";
payload.capture = true;
/* binary_mode precisa ficar FALSE: o desafio exige que o pagamento possa
ficar pendente enquanto a cliente confirma no banco. Com binary_mode
ligado o MP recusa em vez de esperar. */
payload.binary_mode = false;
}
// additional_info: recomendação do Mercado Pago pra reduzir recusa do
// antifraude — mandar o máximo de contexto real do comprador/produto que
// já temos (não é dado novo, só não estava sendo repassado no pagamento).
const cli = (pedidoSnapshot && pedidoSnapshot.cliente) || {};
const itensSnap = (pedidoSnapshot && Array.isArray(pedidoSnapshot.itens)) ? pedidoSnapshot.itens : [];
if (itensSnap.length) {
payload.additional_info = payload.additional_info || {};
/* 🛑 REVERTIDO EM 05/08/2026 — quantity/unit_price voltam a ser STRING.
Em 03/08 eu troquei pra número achando que additional_info malformado
estava custando aprovação de cartão. Efeito real: a partir daquele
deploy o Mercado Pago passou a recusar o payload, NENHUM pagamento foi
criado (o registro no KV parou em 29) e clientes reais reclamaram no
WhatsApp — Gabriella às 13h53 de 03/08 ("não estou conseguindo pagar
com pix / está dando erro"), Nadia ("escolhi pix, não recebi o código").
Quebrou o Pix, que era o ÚNICO meio que funcionava.
Lição: eu validei o formato pelo meu próprio critério e publiquei sem
UMA transação real confirmando que o MP aceita. Nunca mais mexer neste
payload sem um pagamento de R$1 de verdade passando antes. */
payload.additional_info.items = itensSnap.slice(0, 30).map((o, i) => ({
id: String(i), title: String(o.label || "item").slice(0, 250),
description: String(o.label || "item").slice(0, 250),
category_id: "food",
quantity: String(Math.max(1, parseInt(o.qty, 10) || 1)),
unit_price: String(Number(o.price) || 0),
}));
}
const nomePartes = String(cli.nome || "").trim().split(/\s+/).filter(Boolean);
const telDigits = String(cli.telefone || "").replace(/\D/g, "");
if (nomePartes.length || telDigits || cli.endereco) {
payload.additional_info = payload.additional_info || {};
payload.additional_info.payer = {};
if (nomePartes[0]) payload.additional_info.payer.first_name = nomePartes[0].slice(0, 80);
if (nomePartes.length > 1) payload.additional_info.payer.last_name = nomePartes.slice(1).join(" ").slice(0, 80);
if (telDigits) payload.additional_info.payer.phone = { area_code: telDigits.slice(0, 2), number: telDigits.slice(2, 11) };
if (cli.endereco) {
const addr = { street_name: String(cli.endereco).slice(0, 200) };
const zip = String(cli.cep || "").replace(/\D/g, "");
if (zip) addr.zip_code = zip.slice(0, 8);
const numInt = parseInt(cli.numero, 10);
if (Number.isFinite(numInt)) addr.street_number = numInt;
payload.additional_info.payer.address = addr;
/* 🛑 `express` NÃO existe aqui — é o campo que o MP recusa pelo nome e
que derrubou TODOS os pagamentos de 03 a 05/08 (HTTP 400, nenhum
pagamento criado). `local_pickup` é aceito. Validado campo a campo
contra a API real em 06/08 — ver testes/mp-comparar-payload.py. */
payload.additional_info.shipments = { receiver_address: addr, local_pickup: false };
// o MP pontua o `payer` do pagamento, não só o do additional_info
payerOut.address = addr;
if (telDigits) payerOut.phone = { area_code: telDigits.slice(0, 2), number: telDigits.slice(2, 11) };
}
// fallback: se o Brick não coletou nome (comum em cartão), usa o do cadastro
if (!payerOut.first_name && payload.additional_info.payer.first_name) payerOut.first_name = payload.additional_info.payer.first_name;
if (!payerOut.last_name && payload.additional_info.payer.last_name) payerOut.last_name = payload.additional_info.payer.last_name;
}
/* DADOS DE INDÚSTRIA — recolocados em 06/08/2026, agora com CADA CAMPO
validado contra a API real do Mercado Pago (testes/mp-comparar-payload.py).
Em 03/08 este bloco subiu junto com `shipments.express`, que o MP recusa
pelo NOME — o 400 derrubava o pagamento inteiro e ninguém conseguiu pagar
por 2 dias. O culpado era só aquele campo; estes aqui são aceitos.
Por que voltam: são exatamente o que o MP diz que aumenta aprovação de
cartão — e cartão é 0 de 6 nesta conta. Sem eles, toda compra chega como
"desconhecido gastando R$74 num site que nunca usou"; com eles, chega como
"cliente desde 23/06, 3ª compra, mesmo aparelho de sempre".
🛑 NUNCA adicionar campo novo aqui sem rodar o comparador antes. */
const uaCli = request.headers.get("User-Agent") || "";
if (telDigits.length >= 10) {
payload.additional_info = payload.additional_info || {};
payload.additional_info.payer = payload.additional_info.payer || {};
payload.additional_info.payer.authentication_type = /Mobi|Android|iPhone|iPad/i.test(uaCli) ? "MOBILE" : "WEB";
try {
const base = env.PEDIDOS_API_URL || "https://pedidos.baianahpadaria.com.br";
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 1200);
const r = await fetch(base + "/api/cliente-resumo", {
method: "POST", signal: ctrl.signal,
headers: {
"Content-Type": "application/json",
"X-Integration-Key": env.MP_INTEGRATION_KEY || "",
},
body: JSON.stringify({ whatsapp: telDigits }),
});
clearTimeout(t);
const h = await r.json();
// o MP espera ISO com fuso; -03:00 foi o formato validado no teste
const iso = (s) => {
if (!s) return null;
const d = new Date(String(s).includes("T") ? s : String(s).replace(" ", "T") + "Z");
return isNaN(d) ? null : d.toISOString().replace("Z", "-03:00");
};
const ap = payload.additional_info.payer;
ap.is_first_purchase_online = !(h && h.pedidos > 0);
if (h && h.primeiro) ap.registration_date = iso(h.primeiro);
if (h && h.ultimo) ap.last_purchase = iso(h.ultimo);
} catch (_e) { /* sem histórico é pior pro score, mas nunca impede de cobrar */ }
}
// aparece na fatura da cliente — reduz contestação e recusa por "não reconheço"
payload.statement_descriptor = "BAIANAH";
/* REFERÊNCIA EXTERNA — ação OBRIGATÓRIA na Qualidade da Integração do
Mercado Pago (vale 15 pontos, a maior pendência da medição de 06/08 que
deu 66/100, quando o mínimo é 73).
O MP pede "um código único que permita correlacionar o payment_id deles
com o ID interno do nosso sistema". O número do pedido só nasce DEPOIS
do pagamento (o painel é quem numera), então geramos aqui uma referência
própria e a guardamos junto no registro do KV — é ela que amarra
pagamento ↔ cliente ↔ pedido na hora de conferir. */
const refExterna = "bn-" + (telDigits || "s") + "-" + Date.now().toString(36);
payload.external_reference = refExterna.slice(0, 256);
// webhook por PAGAMENTO: o MP passa a avisar /api/mp/webhook sozinho quando
// o status mudar (essencial pro Pix pago depois do cliente fechar a aba) —
// sem depender de ninguém configurar webhook no painel do Mercado Pago
const origin = new URL(request.url).origin;
if (/^https:/.test(origin) && !/localhost|127\.0\.0\.1/.test(origin)) {
payload.notification_url = origin + "/api/mp/webhook";
}
const idemKey = (crypto.randomUUID && crypto.randomUUID()) || String(Date.now()) + Math.random();
const headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + token,
"X-Idempotency-Key": idemKey,
};
// Device ID (fingerprint do navegador, gerado pelo SDK do MP já carregado
// pro Brick) — recomendação Nº1 do Mercado Pago pra reduzir recusa do
// antifraude no Checkout Transparente. Sem valor real do cliente não manda
// nada (cabeçalho vazio é pior que ausente).
//
// 🛑 08/08/2026 — estava cortando em 200 chars. Medido ao vivo no domínio de
// produção: o MP_DEVICE_SESSION_ID real tem 231 chars — todo pagamento de
// cartão mandava um fingerprint truncado pro antifraude. Limite alto agora
// é só higiene contra corpo forjado numa chamada direta na API, não um valor
// ajustado ao tamanho observado (uma amostra só não define a faixa real).
const deviceId = typeof body.deviceId === "string" ? body.deviceId.trim().slice(0, 1000) : "";
if (deviceId) headers["X-Meli-Session-Id"] = deviceId;
/* AUDITORIA DO DEVICE ID (06/08/2026, pedido do suporte do Mercado Pago).
Até aqui não dava pra responder "o header chegou em TODAS as tentativas?"
— o registro no KV não guardava nada disso. Agora guarda presença e
tamanho (NUNCA o valor, que é fingerprint do aparelho da cliente).
Serve pra provar, com histórico, se alguma tentativa saiu sem Device ID. */
const deviceInfo = {
presente: !!deviceId,
tamanho: deviceId.length,
prefixo: deviceId ? deviceId.slice(0, 6) : null, // "armor." e afins
};
if (!deviceId) console.log("ALERTA: pagamento sem Device ID (X-meli-session-id vazio)");
let resp, data;
try {
resp = await fetch("https://api.mercadopago.com/v1/payments", {
method: "POST",
headers,
body: JSON.stringify(payload),
});
data = await resp.json();
} catch (e) {
return json({ error: "upstream_fetch_failed", detail: String(e) }, 502);
}
if (!resp.ok) {
return json({ error: data.message || "pagamento recusado", detail: data }, resp.status);
}
// guarda o registro no KV (auditoria + snapshot pro pedido poder ser criado
// agora ou depois — webhook/status atualizam o status por cima disso)
//
// 🛑 Incidente real (23/07, pedido #1106 — Cássia Sibele Benini): esta
// gravação falhou silenciosamente (erro passageiro de KV) e sem o
// snapshot NUNCA dava pra criar o pedido sozinho — nem o webhook nem o
// polling da tela do cliente conseguem inventar dados que nunca chegaram
// a ser salvos. Do lado da cliente correu tudo normal (ela viu o Pix e
// pagou); só o nosso lado ficou sem saber. Duas defesas agora:
// (1) tenta a gravação até 3x antes de desistir;
// (2) se mesmo assim falhar, avisa a Carine NA HORA (alertaTecnico) em vez
// de deixar o cliente descobrir sozinho horas depois com comprovante.
let pedido = null;
if (env.BAIANAH_KV) {
// fbp/fbc/external_id do navegador + IP/UA desta requisição: guardados pro
// Purchase server-side da CAPI (dispara quando o pagamento aprovar, mesmo
// com o navegador do cliente já fechado — ver _pedido.js)
const fb = (body.fb && typeof body.fb === "object") ? {
fbp: String(body.fb.fbp || "").slice(0, 80),
fbc: String(body.fb.fbc || "").slice(0, 120),
xid: String(body.fb.xid || "").slice(0, 60),
} : null;
const registro = JSON.stringify({
id: data.id, status: data.status, status_detail: data.status_detail,
amount, valorProdutos, valorFrete, description, payer: payload.payer,
snapshot: pedidoSnapshot, created_at: new Date().toISOString(),
device: deviceInfo, // presença/tamanho do Device ID — ver bloco acima
external_reference: refExterna, // amarra pagamento ↔ cliente ↔ pedido
ip, ua: request.headers.get("User-Agent") || "", fb,
});
let gravou = false;
for (let tentativa = 0; tentativa < 3 && !gravou; tentativa++) {
try {
await env.BAIANAH_KV.put("mp_payment:" + data.id, registro, { expirationTtl: 60 * 60 * 24 * 90 });
gravou = true;
} catch (_e) {
if (tentativa < 2) await new Promise((r) => setTimeout(r, 150 * (tentativa + 1)));
}
}
if (!gravou) {
// as 3 tentativas falharam — o pagamento em si JÁ ACONTECEU (resp.ok lá
// em cima), só o nosso registro que não gravou. Avisa na hora.
await alertaTecnico(env, `Pagamento #${data.id} (${data.status}, R$ ${amount.toFixed(2).replace(".", ",")}) processado no Mercado Pago, mas NÃO consegui salvar o registro pra criar o pedido sozinho (KV falhou 3x). Confira no Mercado Pago (Atividades) e crie o pedido na mão.`);
} else {
try {
// cartão aprova na hora — já cria o pedido no painel sem esperar nada
if (data.status === "approved") {
pedido = await criarPedidoSeNecessario(env, data.id);
await enviarCapiPurchaseSeNecessario(env, data.id);
}
// cartão recusado na hora — registra na lista de "pagamento recusado" da Carine
else if (data.status === "rejected") await registrarRecusaSeNecessario(env, data.id);
} catch (_e) { /* não bloqueia o pagamento se o painel falhar aqui — webhook/polling tentam de novo */ }
}
}
return json({
id: data.id,
status: data.status, // approved | pending | in_process | rejected
status_detail: data.status_detail,
pedido, // {id, numero} se já criado no painel, senão null
// Pix: QR code (imagem base64) + "copia e cola" pra exibir/deixar copiar
point_of_interaction: data.point_of_interaction || null,
/* Cartão com 3-D Secure: quando o banco pede confirmação, vem
status_detail "pending_challenge" e estes dois campos — a URL do banco e
o token `creq`, que o site posta num iframe pra cliente confirmar.
Sem repassar isto, o pagamento ficava pendente pra sempre. */
three_ds_info: data.three_ds_info || null,
});
}
function json(obj, status = 200) {
return new Response(JSON.stringify(obj), {
status,
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
});
}