Pix2DePix

cobranca.mjs

/**
 * Node, sem dependência nenhuma (fetch e node:crypto já vêm no Node 20+).
 *
 *   P2D_KEY='p2d_test_...' node cobranca.mjs
 *
 * Cobre: criar, idempotência, consultar, e a validação de assinatura do
 * webhook — que é o pedaço que você vai copiar para o seu servidor.
 */
import { createHmac, timingSafeEqual } from 'node:crypto';

const CHAVE = process.env.P2D_KEY;
const BASE = process.env.P2D_URL ?? 'https://api.pix2depix.com';
if (!CHAVE) throw new Error('defina P2D_KEY com sua chave de API');

async function chamar(caminho, opcoes = {}) {
  const res = await fetch(`${BASE}${caminho}`, {
    ...opcoes,
    headers: {
      authorization: `Bearer ${CHAVE}`,
      'content-type': 'application/json',
      ...opcoes.headers,
    },
    body: opcoes.corpo ? JSON.stringify(opcoes.corpo) : undefined,
  });

  const dados = await res.json();
  if (!res.ok) {
    // O `code` é o que não muda — escreva seu if em cima dele, não da mensagem.
    // O `requestId` é o que o suporte usa para achar a requisição.
    const { code, message, requestId } = dados.error;
    throw new Error(`[${res.status} ${code}] ${message} (requestId: ${requestId})`);
  }
  return { dados, status: res.status };
}

// ── 1. Conta e taxa vigente ────────────────────────────────────────────────
const { dados: conta } = await chamar('/v1/account');
console.log('ambiente:', conta.environment, '· taxa:', conta.plan.feeBps / 100 + '%');

// ── 2. Criar a cobrança ────────────────────────────────────────────────────
const pedido = `pedido-${Date.now()}`;
const corpo = {
  amountInCents: 25000,
  payerTaxNumber: '529.982.247-25',
  payerName: 'Maria Silva',
  externalId: pedido,
  metadata: { origem: 'exemplo' },
};

const { dados: cobranca, status } = await chamar('/v1/charges', {
  method: 'POST',
  corpo,
  // Retry seguro de rede: se a resposta se perder, repetir com a mesma chave
  // devolve a cobrança original em vez de criar uma segunda.
  headers: { 'idempotency-key': crypto.randomUUID() },
});
console.log(`cobrança ${cobranca.id} criada (HTTP ${status})`);
console.log('copia-e-cola:', cobranca.pix.qrCode);
console.log('líquido:', (cobranca.amount.netInCents / 100).toFixed(2));

// ── 3. A mesma requisição de novo não duplica ──────────────────────────────
const repetida = await chamar('/v1/charges', { method: 'POST', corpo });
console.log(
  `repetição: HTTP ${repetida.status}, mesma cobrança:`,
  repetida.dados.id === cobranca.id,
);

// ── 4. Sandbox: mover a cobrança sem Pix ───────────────────────────────────
if (conta.environment === 'test') {
  for (const alvo of ['paid_pending_settlement', 'settled']) {
    const { dados } = await chamar(`/v1/test/charges/${cobranca.id}/advance`, {
      method: 'POST',
      corpo: { status: alvo },
    });
    console.log('→', dados.status, dados.settlement.estimatedAt ?? '');
  }
}

// ── 5. A confirmação que decide se o produto sai ───────────────────────────
const { dados: atual } = await chamar(`/v1/charges/${cobranca.id}`);
console.log(
  atual.status === 'settled'
    ? 'DePix liquidado — pode liberar o produto.'
    : `ainda em ${atual.status} — não libere.`,
);

// ── 6. Validação da assinatura do webhook ──────────────────────────────────
// Este é o pedaço que vai para o seu servidor.

export function assinaturaValida({ segredo, corpoBruto, cabecalhos }) {
  const ts = Number(cabecalhos['x-p2d-timestamp']);
  // Fora da janela de 5 minutos é entrega antiga sendo reenviada por terceiro.
  if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;

  // O corpo **bruto**, como chegou. Reserializar o JSON muda a assinatura.
  const esperado =
    'v1=' +
    createHmac('sha256', segredo).update(`${ts}.${corpoBruto}`).digest('hex');

  const a = Buffer.from(cabecalhos['x-p2d-signature'] ?? '');
  const b = Buffer.from(esperado);
  // Comparação em tempo constante: `===` vaza o segredo pelo tempo de resposta.
  return a.length === b.length && timingSafeEqual(a, b);
}

// Prova de que a função concorda com o algoritmo documentado.
{
  const segredo = 'whsec_exemplo';
  const corpoBruto = '{"id":"evt_1","type":"charge.settled"}';
  const ts = Math.floor(Date.now() / 1000);
  const assinatura =
    'v1=' + createHmac('sha256', segredo).update(`${ts}.${corpoBruto}`).digest('hex');

  const cabecalhos = { 'x-p2d-timestamp': String(ts), 'x-p2d-signature': assinatura };

  if (!assinaturaValida({ segredo, corpoBruto, cabecalhos })) {
    throw new Error('a assinatura correta deveria validar');
  }
  if (assinaturaValida({ segredo, corpoBruto: corpoBruto + ' ', cabecalhos })) {
    throw new Error('corpo adulterado deveria falhar');
  }
  console.log('validação de assinatura: ok');
}