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" },
  });
}