First Commit

This commit is contained in:
mark committed 2026-10-19 15:22:52 -07:00
1 parent 5eb9dd037d
commit 2e62c0e60b
11877 files changed
+2035312

No files matched your search

+261
View File
@@ -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;
}
}
+36
View File
@@ -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));
}
}
+57
View File
@@ -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);
}
}