Files
HotLunches/pp-integration/lib/PayPalClient.php
T
2026-10-19 15:22:52 -07:00

262 lines
10 KiB
PHP

<?php
class PayPalClient
{
private string $clientId;
private string $clientSecret;
private string $env; // sandbox|live
private string $baseApi;
private ?string $caBundle; // optional CA bundle path for cURL TLS verification
public function __construct(array $config)
{
$this->clientId = $config['client_id'] ?? '';
$this->clientSecret = $config['client_secret'] ?? '';
$this->env = $config['env'] ?? 'sandbox';
$this->caBundle = !empty($config['curl_ca_bundle']) ? $config['curl_ca_bundle'] : null;
$this->baseApi = $this->env === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com';
if (!$this->clientId || !$this->clientSecret) {
throw new RuntimeException('Missing PayPal client_id/client_secret in config.php');
}
}
/**
* cURL TLS options. We always verify the peer/host (never disable it — that
* would be insecure and is rejected in production). When a CA bundle path is
* configured we point cURL at it, which fixes environments that lack a
* system-wide curl.cainfo (e.g. stock Windows PHP).
*/
private function tlsOpts(): array
{
$opts = [
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
];
if ($this->caBundle && is_file($this->caBundle)) {
$opts[CURLOPT_CAINFO] = $this->caBundle;
}
return $opts;
}
public function getAccessToken(): string
{
$url = $this->baseApi . '/v1/oauth2/token';
$headers = [
'Accept: application/json',
'Accept-Language: en_US',
];
$body = 'grant_type=client_credentials';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => $this->clientId . ':' . $this->clientSecret,
] + $this->tlsOpts());
$resp = curl_exec($ch);
if ($resp === false) {
throw new RuntimeException('OAuth token request failed: ' . curl_error($ch));
}
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($resp, true);
if ($code < 200 || $code >= 300 || !isset($data['access_token'])) {
throw new RuntimeException('OAuth token response error (' . $code . '): ' . $resp);
}
return $data['access_token'];
}
public function api(string $method, string $path, array $payload = null, array $headers = []): array
{
$url = $this->baseApi . $path;
$accessToken = $this->getAccessToken();
$defaultHeaders = [
'Content-Type: application/json',
'Authorization: Bearer ' . $accessToken,
'PayPal-Partner-Attribution-Id: EZSSOFTWAREINC_SP_PPCP',
];
$allHeaders = array_merge($defaultHeaders, $headers);
$debugId = null;
$ch = curl_init($url);
$opts = [
CURLOPT_HTTPHEADER => $allHeaders,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$debugId) {
if (stripos($header, 'paypal-debug-id:') === 0) {
$debugId = trim(explode(':', $header, 2)[1]);
}
return strlen($header);
},
];
if ($payload !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($payload);
}
curl_setopt_array($ch, $opts + $this->tlsOpts());
$resp = curl_exec($ch);
if ($resp === false) {
throw new RuntimeException('API request failed: ' . curl_error($ch));
}
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (function_exists('writeLog')) {
writeLog('PayPal API ' . strtoupper($method) . ' ' . $path . ' => HTTP ' . $code . ' debug-id=' . ($debugId ?? 'n/a'));
}
$data = json_decode($resp, true);
if ($data === null && $resp !== '' && $resp !== 'null') {
$data = ['raw' => $resp];
}
return ['status' => $code, 'body' => $data, 'debug_id' => $debugId];
}
public function buildAuthAssertionJwt(string $merchantId): string
{
$b64url = fn(string $v) => rtrim(strtr(base64_encode($v), '+/', '-_'), '=');
$header = $b64url(json_encode(['alg' => 'none']));
$payload = $b64url(json_encode(['iss' => $this->clientId, 'payer_id' => $merchantId]));
return $header . '.' . $payload . '.';
}
public function createOrder(string $merchantId, array $payload, string $fraudnetId = ''): array
{
$headers = [
'PayPal-Auth-Assertion: ' . $this->buildAuthAssertionJwt($merchantId),
'PayPal-Request-Id: ' . bin2hex(random_bytes(16)), // idempotency — prevents duplicate orders on network retry
];
if ($fraudnetId !== '') {
$headers[] = 'paypal-client-metadata-id: ' . $fraudnetId;
}
return $this->api('POST', '/v2/checkout/orders', $payload, $headers);
}
public function captureOrder(string $merchantId, string $orderId): array
{
return $this->api(
'POST',
'/v2/checkout/orders/' . urlencode($orderId) . '/capture',
null,
[
'PayPal-Auth-Assertion: ' . $this->buildAuthAssertionJwt($merchantId),
'Prefer: return=representation', // ensures capture ID is in the response body
]
);
}
/**
* Refund a captured payment (full or partial).
*
* @param array $refundData Optional. Keys: 'amount' (array with 'value'+'currency_code'),
* 'note_to_payer' (string), 'invoice_id' (string).
* Pass [] for a full refund.
* @param string $idempotencyKey Caller-supplied key so safe retries don't double-refund.
* Auto-generated if empty.
*/
public function refundCapture(
string $merchantId,
string $captureId,
array $refundData = [],
string $idempotencyKey = ''
): array {
if ($idempotencyKey === '') {
$idempotencyKey = bin2hex(random_bytes(16));
}
return $this->api(
'POST',
'/v2/payments/captures/' . urlencode($captureId) . '/refund',
empty($refundData) ? null : $refundData,
[
'PayPal-Auth-Assertion: ' . $this->buildAuthAssertionJwt($merchantId),
'PayPal-Request-Id: ' . $idempotencyKey,
]
);
}
public function getSellerStatus(string $partnerMerchantId, string $merchantId): array
{
$path = '/v1/customer/partners/' . urlencode($partnerMerchantId)
. '/merchant-integrations/' . urlencode($merchantId);
$resp = $this->api('GET', $path);
$body = $resp['body'] ?? [];
$emailConfirmed = (bool)($body['primary_email_confirmed'] ?? false);
$paymentsReceivable = (bool)($body['payments_receivable'] ?? false);
$oauthIntegrations = $body['oauth_integrations'] ?? [];
$oauthOk = !empty($oauthIntegrations);
$sellerEmail = $body['primary_email'] ?? null;
$products = $body['products'] ?? [];
$capabilities = $body['capabilities'] ?? [];
// ── ACDC (Advanced Card Processing) readiness ─────────────────────
// Guide: check PPCP_CUSTOM vetting + CUSTOM_CARD_PROCESSING capability
$acdcVettingStatus = null;
foreach ($products as $product) {
if (($product['name'] ?? '') === 'PPCP_CUSTOM') {
$acdcVettingStatus = strtoupper($product['vetting_status'] ?? '');
break;
}
}
$customCardCap = null;
foreach ($capabilities as $cap) {
if (($cap['name'] ?? '') === 'CUSTOM_CARD_PROCESSING') {
$customCardCap = $cap;
break;
}
}
$acdcCapActive = ($customCardCap['status'] ?? '') === 'ACTIVE';
$acdcNoLimits = !isset($customCardCap['limits']);
$acdcReady = $acdcVettingStatus === 'SUBSCRIBED' && $acdcCapActive && $acdcNoLimits;
$acdcDenied = $acdcVettingStatus === 'DENIED';
$acdcPending = $acdcVettingStatus !== null && !$acdcReady && !$acdcDenied;
$issues = [];
if (!$emailConfirmed) {
$issues[] = 'PRIMARY_EMAIL_CONFIRMED is false — seller must confirm their PayPal email before receiving payments.';
}
if (!$paymentsReceivable) {
$issues[] = 'PAYMENTS_RECEIVABLE is false — there may be a restriction on the seller\'s PayPal account.';
}
if (!$oauthOk) {
$issues[] = 'OAUTH_INTEGRATIONS is empty — seller has not granted third-party permissions.';
}
if ($acdcDenied) {
$issues[] = 'ACDC DENIED — seller can only use PayPal Wallet buttons, not Advanced Card Processing.';
}
if ($acdcPending) {
$issues[] = 'ACDC PENDING (vetting_status=' . $acdcVettingStatus . ') — waiting for PayPal to approve Advanced Card Processing.';
}
return [
'status' => $resp['status'],
'debug_id' => $resp['debug_id'],
'paypal_ready' => $emailConfirmed && $paymentsReceivable && $oauthOk,
'email_confirmed' => $emailConfirmed,
'payments_receivable' => $paymentsReceivable,
'oauth_ok' => $oauthOk,
'seller_email' => $sellerEmail,
'issues' => $issues,
// ACDC fields
'acdc_ready' => $acdcReady,
'acdc_vetting_status' => $acdcVettingStatus,
'acdc_denied' => $acdcDenied,
'acdc_pending' => $acdcPending,
];
}
public function getBaseApi(): string
{
return $this->baseApi;
}
}