Pix2DePix

cobranca.php

<?php
/**
 * PHP, só com o que vem na instalação padrão (curl e hash).
 *
 *     P2D_KEY='p2d_test_...' php cobranca.php
 *
 * 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.
 *
 * No fim do arquivo há um recebedor de webhook pronto, com as notas de
 * WooCommerce e Laravel.
 */

declare(strict_types=1);

final class ErroDaApi extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        /** O `code` é o que não muda — escreva seu if em cima dele. */
        public readonly string $code,
        /** Mande ao suporte: é por ele que a requisição é achada. */
        public readonly string $requestId,
        string $mensagem,
    ) {
        parent::__construct("[{$status} {$code}] {$mensagem} (requestId: {$requestId})");
    }
}

final class Pix2DePix
{
    public function __construct(
        private readonly string $chave,
        private readonly string $base = 'https://api.pix2depix.com',
    ) {
    }

    /** @param array<string,mixed>|null $corpo */
    public function chamar(string $caminho, ?array $corpo = null, array $cabecalhos = []): array
    {
        $ch = curl_init($this->base . $caminho);
        $headers = array_merge([
            'authorization: Bearer ' . $this->chave,
            'content-type: application/json',
        ], $cabecalhos);

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_CUSTOMREQUEST => $corpo === null ? 'GET' : 'POST',
            CURLOPT_POSTFIELDS => $corpo === null ? null : json_encode($corpo, JSON_UNESCAPED_UNICODE),
        ]);

        $resposta = curl_exec($ch);
        if ($resposta === false) {
            throw new RuntimeException('falha de rede: ' . curl_error($ch));
        }
        $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $dados = json_decode((string) $resposta, true, flags: JSON_THROW_ON_ERROR);

        if ($status >= 400) {
            throw new ErroDaApi(
                $status,
                $dados['error']['code'],
                $dados['error']['requestId'],
                $dados['error']['message'],
            );
        }
        return [$dados, $status];
    }
}

/**
 * Validação da assinatura do webhook. Este é o pedaço que vai para o seu
 * servidor.
 *
 * @param string $corpoBruto o corpo **como chegou** — nunca json_encode(json_decode(...))
 * @param array<string,string> $cabecalhos em minúsculas
 */
function assinaturaValida(string $segredo, string $corpoBruto, array $cabecalhos): bool
{
    $ts = (int) ($cabecalhos['x-p2d-timestamp'] ?? 0);
    // Fora da janela de 5 minutos é entrega antiga sendo reenviada por terceiro.
    if ($ts === 0 || abs(time() - $ts) > 300) {
        return false;
    }

    $esperado = 'v1=' . hash_hmac('sha256', $ts . '.' . $corpoBruto, $segredo);

    // hash_equals: `===` em string vaza o segredo pelo tempo de resposta.
    return hash_equals($esperado, $cabecalhos['x-p2d-signature'] ?? '');
}

// ── Recebedor de webhook, para copiar ───────────────────────────────────────
//
// $corpoBruto = file_get_contents('php://input');   // NUNCA $_POST
// $cabecalhos = array_change_key_case(getallheaders(), CASE_LOWER);
//
// if (!assinaturaValida(getenv('P2D_WEBHOOK_SECRET'), $corpoBruto, $cabecalhos)) {
//     http_response_code(401);
//     exit;
// }
//
// $evento = json_decode($corpoBruto, true);
//
// // A entrega é at-least-once: deduplique pelo id antes de processar.
// if (jaProcessamos($evento['id'])) { http_response_code(200); exit; }
// registraEvento($evento['id']);
//
// // Enfileire e responda rápido — o timeout é de 10 segundos.
// enfileira($evento);
// http_response_code(200);
//
// Laravel:      use $request->getContent(), não $request->all(), e exclua a
//               rota do VerifyCsrfToken.
// WooCommerce:  register_rest_route(...) e $request->get_body().

// ── Exemplo executável ──────────────────────────────────────────────────────

$chave = getenv('P2D_KEY');
if ($chave === false || $chave === '') {
    fwrite(STDERR, "defina P2D_KEY com sua chave de API\n");
    exit(1);
}

$api = new Pix2DePix($chave, getenv('P2D_URL') ?: 'https://api.pix2depix.com');

// 1. Conta e taxa vigente.
[$conta] = $api->chamar('/v1/account');
printf("ambiente: %s · taxa: %s%%\n", $conta['environment'], $conta['plan']['feeBps'] / 100);

// 2. Criar a cobrança. O externalId é o número do pedido no seu sistema.
$pedido = 'pedido-' . time();
$corpo = [
    'amountInCents' => 25000,
    'payerTaxNumber' => '529.982.247-25',
    'payerName' => 'Maria Silva',
    'externalId' => $pedido,
    'metadata' => ['origem' => 'exemplo'],
];

// Retry seguro de rede: repetir com a mesma chave devolve a cobrança original.
$idempotencia = 'idempotency-key: ' . bin2hex(random_bytes(16));
[$cobranca, $status] = $api->chamar('/v1/charges', $corpo, [$idempotencia]);

printf("cobrança %s criada (HTTP %d)\n", $cobranca['id'], $status);
printf("copia-e-cola: %s\n", $cobranca['pix']['qrCode']);
printf("líquido: %.2f\n", $cobranca['amount']['netInCents'] / 100);

// 3. A mesma requisição de novo não duplica.
[$repetida, $status] = $api->chamar('/v1/charges', $corpo);
printf("repetição: HTTP %d, mesma cobrança: %s\n", $status, var_export($repetida['id'] === $cobranca['id'], true));

// 4. Sandbox: mover a cobrança sem Pix.
if ($conta['environment'] === 'test') {
    foreach (['paid_pending_settlement', 'settled'] as $alvo) {
        [$movida] = $api->chamar("/v1/test/charges/{$cobranca['id']}/advance", ['status' => $alvo]);
        printf("→ %s %s\n", $movida['status'], $movida['settlement']['estimatedAt'] ?? '');
    }
}

// 5. A confirmação que decide se o produto sai.
[$atual] = $api->chamar("/v1/charges/{$cobranca['id']}");
echo $atual['status'] === 'settled'
    ? "DePix liquidado — pode liberar o produto.\n"
    : "ainda em {$atual['status']} — não libere.\n";

// 6. Prova de que a validação concorda com o algoritmo documentado.
$segredo = 'whsec_exemplo';
$corpoBruto = '{"id":"evt_1","type":"charge.settled"}';
$ts = time();
$cabecalhos = [
    'x-p2d-timestamp' => (string) $ts,
    'x-p2d-signature' => 'v1=' . hash_hmac('sha256', $ts . '.' . $corpoBruto, $segredo),
];

// `if` e não `assert()`: com zend.assertions=-1 os asserts viram nada, e um
// teste que some é um teste que passa sem testar.
if (!assinaturaValida($segredo, $corpoBruto, $cabecalhos)) {
    fwrite(STDERR, "a assinatura correta deveria validar\n");
    exit(1);
}
if (assinaturaValida($segredo, $corpoBruto . ' ', $cabecalhos)) {
    fwrite(STDERR, "corpo adulterado deveria falhar\n");
    exit(1);
}
echo "validação de assinatura: ok\n";