createPayment([ * 'amount' => '1500.00', 'merchant_ref' => 'INV-1042', * 'return_url' => 'https://shop.example.com/thanks', 'cancel_url' => 'https://shop.example.com/cart', * ], 'INV-1042-1500.00'); * header('Location: ' . $p['checkout_url']); */ class PaynexException extends RuntimeException { public int $httpStatus; public string $errorCode; public function __construct(int $httpStatus, string $code, string $message) { parent::__construct("paynex: $code ($httpStatus): $message"); $this->httpStatus = $httpStatus; $this->errorCode = $code; } } class Paynex { private string $baseUrl; private string $keyId; private string $secret; public int $timeout = 30; public function __construct(string $baseUrl, string $keyId, string $secret) { $this->baseUrl = rtrim($baseUrl, '/'); $this->keyId = $keyId; $this->secret = $secret; } /** hex(HMAC-SHA256(secret, ts\nnonce\nMETHOD\npath\nhex(sha256(body)))) */ public static function signature(string $secret, string $ts, string $nonce, string $method, string $path, string $body): string { $msg = implode("\n", [$ts, $nonce, strtoupper($method), $path, hash('sha256', $body)]); return hash_hmac('sha256', $msg, $secret); } /** @return array headers; $path includes /v1 and excludes the query string */ public function sign(string $method, string $path, string $body): array { $ts = (string) time(); $nonce = bin2hex(random_bytes(16)); return [ 'Authorization' => 'Paynex-HMAC key_id=' . $this->keyId, 'X-PG-Timestamp' => $ts, 'X-PG-Nonce' => $nonce, 'X-PG-Signature' => self::signature($this->secret, $ts, $nonce, $method, $path, $body), ]; } /** @return array decoded JSON */ public function request(string $method, string $pathWithQuery, ?array $payload = null, ?string $idempotencyKey = null): array { $body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $path = explode('?', $pathWithQuery, 2)[0]; $headers = $this->sign($method, $path, $body) + ['Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'paynex-php/1.0']; if ($idempotencyKey !== null) { $headers['Idempotency-Key'] = $idempotencyKey; } $h = []; foreach ($headers as $k => $v) { $h[] = "$k: $v"; } $ch = curl_init($this->baseUrl . $pathWithQuery); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeout, ]); if ($body !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $text = curl_exec($ch); if ($text === false) { $err = curl_error($ch); curl_close($ch); throw new PaynexException(0, 'network', $err); } $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $data = json_decode($text, true); if ($status >= 300) { $e = is_array($data) && isset($data['error']) ? $data['error'] : []; throw new PaynexException($status, $e['code'] ?? "http_$status", $e['message'] ?? trim((string) $text)); } return is_array($data) ? $data : []; } // ---- payments ---- public function createPayment(array $params, string $idempotencyKey): array { if ($idempotencyKey === '') { throw new InvalidArgumentException('paynex: idempotency key is required (e.g. "-")'); } return $this->request('POST', '/v1/payments', $params, $idempotencyKey); } public function getPayment(string $id): array { return $this->request('GET', '/v1/payments/' . rawurlencode($id)); } public function listPayments(array $q = []): array { $qs = http_build_query(array_filter($q, fn($v) => $v !== null && $v !== '')); return $this->request('GET', '/v1/payments' . ($qs ? "?$qs" : '')); } public function cancelPayment(string $id): array { return $this->request('POST', '/v1/payments/' . rawurlencode($id) . '/cancel'); } public function verifyPayment(string $id): array { return $this->request('POST', '/v1/payments/' . rawurlencode($id) . '/verify'); } // ---- refunds / store ---- public function createRefund(string $paymentId, ?string $amount = null, ?string $reason = null, ?string $idempotencyKey = null): array { $p = ['payment_id' => $paymentId]; if ($amount !== null) { $p['amount'] = $amount; } if ($reason !== null) { $p['reason'] = $reason; } return $this->request('POST', '/v1/refunds', $p, $idempotencyKey); } public function getRefund(string $id): array { return $this->request('GET', '/v1/refunds/' . rawurlencode($id)); } public function getStore(): array { return $this->request('GET', '/v1/store'); } // ---- inbound ---- /** * Verify a webhook and return the decoded event. Pass the raw body (file_get_contents('php://input')) * and the X-PG-Signature header. Throws PaynexException(401) on a bad signature. */ public static function verifyWebhook(string $webhookSecret, ?string $signatureHeader, string $rawBody, int $toleranceSec = 300): array { $t = $v1 = null; foreach (explode(',', (string) $signatureHeader) as $part) { [$k, $v] = explode('=', trim($part), 2) + [null, null]; if ($k === 't') { $t = $v; } elseif ($k === 'v1') { $v1 = $v; } } if (!$t || !$v1) { throw new PaynexException(401, 'bad_signature', 'missing t/v1'); } if ($toleranceSec > 0 && abs(time() - (int) $t) > $toleranceSec) { throw new PaynexException(401, 'bad_signature', 'timestamp outside tolerance'); } if (!hash_equals(hash_hmac('sha256', $t . '.' . $rawBody, $webhookSecret), strtolower($v1))) { throw new PaynexException(401, 'bad_signature', 'signature mismatch'); } $ev = json_decode($rawBody, true); if (!is_array($ev) || !isset($ev['event'])) { throw new PaynexException(400, 'bad_payload', 'not a Paynex event'); } return $ev; } /** Verify the signed return redirect (?pg_ref&pg_status&pg_ts&pg_sig). UX only. Returns null if invalid. */ public static function verifyReturn(string $webhookSecret, array $query, int $toleranceSec = 300): ?array { $ref = $query['pg_ref'] ?? null; $st = $query['pg_status'] ?? null; $ts = $query['pg_ts'] ?? null; $sig = $query['pg_sig'] ?? null; if (!$ref || !$st || !$ts || !$sig) { return null; } if ($toleranceSec > 0 && abs(time() - (int) $ts) > $toleranceSec) { return null; } if (!hash_equals(hash_hmac('sha256', "$ref.$st.$ts", $webhookSecret), strtolower($sig))) { return null; } return ['payment_id' => $ref, 'status' => $st, 'at' => (int) $ts]; } }