First Commit
This commit is contained in:
1 parent
5eb9dd037d
commit
2e62c0e60b
11877 files changed
+2035312
No files matched your search
@@ -0,0 +1,261 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
class Storage
|
||||
{
|
||||
private string $dir;
|
||||
|
||||
public function __construct(string $dir)
|
||||
{
|
||||
$this->dir = rtrim($dir, DIRECTORY_SEPARATOR);
|
||||
if (!is_dir($this->dir)) {
|
||||
mkdir($this->dir, 0777, true);
|
||||
}
|
||||
if (!is_writable($this->dir)) {
|
||||
throw new RuntimeException('Storage path not writable: ' . $this->dir);
|
||||
}
|
||||
}
|
||||
|
||||
private function path(string $name): string
|
||||
{
|
||||
return $this->dir . DIRECTORY_SEPARATOR . $name . '.json';
|
||||
}
|
||||
|
||||
public function get(string $name, $default = [])
|
||||
{
|
||||
$p = $this->path($name);
|
||||
if (!file_exists($p)) return $default;
|
||||
$data = json_decode((string)file_get_contents($p), true);
|
||||
return $data ?? $default;
|
||||
}
|
||||
|
||||
public function set(string $name, $value): void
|
||||
{
|
||||
$p = $this->path($name);
|
||||
file_put_contents($p, json_encode($value, JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
class TenantResolver
|
||||
{
|
||||
/**
|
||||
* Returns the current subdomain from HTTP_HOST (e.g., school1 for school1.example.com).
|
||||
*/
|
||||
public static function currentSubdomain(): ?string
|
||||
{
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if (!$host) return null;
|
||||
// Strip port
|
||||
$host = preg_replace('/:\\d+$/', '', $host);
|
||||
$parts = explode('.', $host);
|
||||
if (count($parts) < 3) {
|
||||
// No subdomain (like localhost or example.com)
|
||||
return null;
|
||||
}
|
||||
// Return first label (handles multi-level like school1.dev.example.com -> school1)
|
||||
return strtolower($parts[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map subdomain to a school display name using data/tenants.json if present.
|
||||
* tenants.json structure: { "subdomain-key": "School Display Name" }
|
||||
*/
|
||||
public static function schoolFromSubdomain(Storage $storage): ?string
|
||||
{
|
||||
$sub = self::currentSubdomain();
|
||||
if (!$sub) return null;
|
||||
try {
|
||||
$tenants = $storage->get('tenants', []);
|
||||
if (!empty($tenants[$sub]) && is_string($tenants[$sub])) {
|
||||
return $tenants[$sub];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
// Fallback heuristic: humanize the subdomain (school-one -> School One)
|
||||
$guess = trim($sub);
|
||||
if ($guess === '') return null;
|
||||
$guess = str_replace(['-', '_'], ' ', $guess);
|
||||
return ucwords($guess);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine a school candidate for the current request:
|
||||
* - Prefer explicit $provided (POST/GET)
|
||||
* - Then subdomain mapping
|
||||
*/
|
||||
public static function resolveSchool(Storage $storage, ?string $provided = null): ?string
|
||||
{
|
||||
$provided = is_string($provided) ? trim($provided) : '';
|
||||
if ($provided !== '') return $provided;
|
||||
return self::schoolFromSubdomain($storage);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user