262 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|