Pix2DePix

split.mjs

/**
 * Split de pagamento em Node, sem dependência nenhuma (Node 20+).
 *
 *   P2D_KEY='p2d_test_...' node split.mjs
 *
 * Cobre: criar a cobrança com split, conferir a taxa (plano + 1pp), empurrar
 * no sandbox até `settled` e ler o que caiu em cada carteira — o mesmo que
 * chega no evento `charge.split.completed`.
 */
const CHAVE = process.env.P2D_KEY;
const BASE = process.env.P2D_URL ?? 'https://apiv2.pix2depix.com';
if (!CHAVE) throw new Error('defina P2D_KEY com sua chave de API');

// Troque pelas suas carteiras Liquid. Estas passam na validação, mas não são
// de ninguém — use só no sandbox.
const CARTEIRA_A =
  'lq1qqf8er278e6nyvuwtgf39e6ewvdcnjupn9a86rzpx655y5lhkt0walu3djf9cklkxd3ryld97hu8h3xepw7sh2rlu7q45dcew5';
const CARTEIRA_B = 'ex1qw508d6qejxtdg4y5r3zarvary0c5xw7kxw5fx4';

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) {
    const { code, message, details, requestId } = dados.error;
    // Em `invalid_request`, `details` diz qual destino ou percentual falhou.
    throw new Error(
      `[${res.status} ${code}] ${message} ${JSON.stringify(details ?? [])} (requestId: ${requestId})`,
    );
  }
  return dados;
}

const reais = (centavos) => `R$ ${(centavos / 100).toFixed(2)}`;

// ── 1. Criar a cobrança com split ──────────────────────────────────────────
const cobranca = await chamar('/v1/split-charges', {
  method: 'POST',
  headers: { 'idempotency-key': crypto.randomUUID() },
  corpo: {
    amountInCents: 100000,
    payerTaxNumber: '529.982.247-25',
    payerName: 'Maria Silva',
    externalId: `pedido-${Date.now()}`,
    split: {
      // Até duas casas, somando exatamente 100.
      destinations: [
        { address: CARTEIRA_A, percent: 60 },
        { address: CARTEIRA_B, percent: 40 },
      ],
    },
  },
});

const { amount, split } = cobranca;
console.log(`cobrança ${cobranca.id} → ${cobranca.status}`);
console.log('copia-e-cola:', cobranca.pix.qrCode);
console.log(
  `taxa: plano ${split.planFeeBps / 100}% + 1% = ${amount.feeBps / 100}%`,
  `+ fixa ${reais(amount.feeFixedInCents)} → líquido ${reais(amount.netInCents)}`,
);

// ── 2. Sandbox: pagar e liquidar sem Pix ───────────────────────────────────
// Em produção quem faz isso é o seu cliente pagando, e a retenção de 24h.
if (cobranca.environment === 'test') {
  for (const status of ['paid_pending_settlement', 'settled']) {
    await chamar(`/v1/test/charges/${cobranca.id}/advance`, {
      method: 'POST',
      corpo: { status },
    });
  }
}

// ── 3. Ler o resultado ─────────────────────────────────────────────────────
// Em produção, reaja ao webhook `charge.split.completed` em vez de consultar:
// o corpo dele traz esta mesma cobrança em `data.charge`.
const atual = await chamar(`/v1/charges/${cobranca.id}`);

// Quem libera o produto é o status da cobrança, não o do split.
console.log(
  atual.status === 'settled'
    ? 'cobrança liquidada — pode liberar o produto.'
    : `cobrança em ${atual.status} — não libere.`,
);

console.log(`split: ${atual.split.status}`, atual.split.txId ?? '');
for (const destino of atual.split.destinations) {
  console.log(
    `  ${destino.percent}% → ${destino.address.slice(0, 16)}…`,
    destino.amountInCents === undefined ? '(ainda não enviado)' : reais(destino.amountInCents),
  );
}