$v) { if (strcasecmp($k, $name) === 0) return $v; } } $serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); return $_SERVER[$serverKey] ?? ''; } // ── Guard: webhook_id must be configured ──────────────────────────────────── $webhookId = $config['webhook_id'] ?? ''; if ($webhookId === '') { if (function_exists('writeLog')) { writeLog('webhook: webhook_id is not set in config.php — skipping signature check'); } echo json_encode(['status' => 'webhook_id_not_configured']); exit(); } // ── Collect PayPal signature headers ───────────────────────────────────────── $transmissionId = ppWebhookHeader('PAYPAL-TRANSMISSION-ID'); $transmissionTime = ppWebhookHeader('PAYPAL-TRANSMISSION-TIME'); $transmissionSig = ppWebhookHeader('PAYPAL-TRANSMISSION-SIG'); $certUrl = ppWebhookHeader('PAYPAL-CERT-URL'); $authAlgo = ppWebhookHeader('PAYPAL-AUTH-ALGO'); if (!$transmissionId || !$transmissionSig || !$certUrl) { if (function_exists('writeLog')) { writeLog('webhook: missing PayPal signature headers — request ignored'); } echo json_encode(['status' => 'missing_signature_headers']); exit(); } // ── Verify signature via PayPal API ────────────────────────────────────────── // This is the simplest and most reliable verification method. // PayPal docs: /v1/notifications/verify-webhook-signature try { $client = new PayPalClient($config); $verifyResp = $client->api('POST', '/v1/notifications/verify-webhook-signature', [ 'auth_algo' => $authAlgo, 'cert_url' => $certUrl, 'transmission_id' => $transmissionId, 'transmission_sig' => $transmissionSig, 'transmission_time' => $transmissionTime, 'webhook_id' => $webhookId, 'webhook_event' => json_decode($rawBody, true) ?: [], ]); $verificationStatus = $verifyResp['body']['verification_status'] ?? 'FAILURE'; if ($verificationStatus !== 'SUCCESS') { if (function_exists('writeLog')) { writeLog('webhook: signature FAILED verification_status=' . $verificationStatus . ' debug_id=' . ($verifyResp['debug_id'] ?? 'n/a')); } echo json_encode(['status' => 'signature_verification_failed']); exit(); } } catch (Throwable $e) { if (function_exists('writeLog')) { writeLog('webhook: signature verification threw — ' . $e->getMessage()); } echo json_encode(['status' => 'verification_error']); exit(); } // ── Parse event ─────────────────────────────────────────────────────────────── $event = json_decode($rawBody, true) ?: []; $eventType = $event['event_type'] ?? ''; $resource = $event['resource'] ?? []; if (function_exists('writeLog')) { writeLog('webhook: verified event_type=' . $eventType . ' event_id=' . ($event['id'] ?? '?')); } $dataDir = __DIR__ . '/data'; $merchantsFile = $dataDir . '/school_merchants.json'; // ── Helper: persist merchants JSON ─────────────────────────────────────────── function ppSaveMerchants(string $file, array $merchants): void { file_put_contents($file, json_encode($merchants, JSON_PRETTY_PRINT)); } // ── Helper: find school name by merchant_id ─────────────────────────────────── function ppFindSchoolByMerchant(array $merchants, string $merchantId): ?string { foreach ($merchants as $school => $data) { if (($data['merchant_id'] ?? '') === $merchantId) { return $school; } } return null; } // ════════════════════════════════════════════════════════════════════════════ // EVENT: MERCHANT.PARTNER-CONSENT.REVOKED // The seller has revoked your API permissions from their PayPal account. // Action: immediately disable PayPal for this school. // ════════════════════════════════════════════════════════════════════════════ if ($eventType === 'MERCHANT.PARTNER-CONSENT.REVOKED') { $merchantId = $resource['merchant_id'] ?? $resource['payer_id'] ?? null; if (!$merchantId) { if (function_exists('writeLog')) { writeLog('webhook: CONSENT.REVOKED — no merchant_id in resource, skipping'); } echo json_encode(['status' => 'ok']); exit(); } if (function_exists('writeLog')) { writeLog('webhook: CONSENT.REVOKED merchant_id=' . $merchantId); } // ── Update JSON ─────────────────────────────────────────────────────── if (file_exists($merchantsFile)) { $merchants = json_decode(file_get_contents($merchantsFile), true) ?: []; $schoolName = ppFindSchoolByMerchant($merchants, $merchantId); if ($schoolName) { $merchants[$schoolName]['paypal_ready'] = false; $merchants[$schoolName]['consent_revoked'] = true; $merchants[$schoolName]['consent_revoked_at'] = time(); $merchants[$schoolName]['status_issues'] = [ 'MERCHANT.PARTNER-CONSENT.REVOKED — the seller has revoked API permissions.' . ' They must reconnect their PayPal account before payments can resume.', ]; ppSaveMerchants($merchantsFile, $merchants); if (function_exists('writeLog')) { writeLog('webhook: CONSENT.REVOKED — disabled PayPal for school=' . $schoolName); } } else { if (function_exists('writeLog')) { writeLog('webhook: CONSENT.REVOKED — merchant_id=' . $merchantId . ' not found in school_merchants.json'); } } } // ── Update DB ───────────────────────────────────────────────────────── // We keep the paypal_merchant_id so re-onboarding can match the school, // but a NULL signals "not ready" in any direct DB check. try { $dbConfig = require __DIR__ . '/db_config.php'; $conn = new mysqli($dbConfig['host'], $dbConfig['username'], $dbConfig['password'], $dbConfig['database']); if (!$conn->connect_error) { $conn->set_charset($dbConfig['charset'] ?? 'utf8mb4'); // If your schema has a paypal_consent_revoked column, set it here. // Otherwise, null out paypal_merchant_id to block payments at the DB level. $stmt = $conn->prepare( "UPDATE schools SET paypal_merchant_id = NULL WHERE paypal_merchant_id = ?" ); $stmt->bind_param('s', $merchantId); $stmt->execute(); $affected = $stmt->affected_rows; $stmt->close(); $conn->close(); if (function_exists('writeLog')) { writeLog('webhook: CONSENT.REVOKED — nulled paypal_merchant_id for ' . $affected . ' row(s)'); } } } catch (Throwable $e) { if (function_exists('writeLog')) { writeLog('webhook: CONSENT.REVOKED — DB update failed: ' . $e->getMessage()); } } echo json_encode(['status' => 'ok']); exit(); } // ════════════════════════════════════════════════════════════════════════════ // EVENT: CUSTOMER.MERCHANT-INTEGRATION.PRODUCT-SUBSCRIPTION-UPDATED // A product subscription (e.g. PPCP_CUSTOM vetting) has changed. // Action: re-check seller status to pick up new ACDC / Apple Pay / Google Pay readiness. // ════════════════════════════════════════════════════════════════════════════ if ($eventType === 'CUSTOMER.MERCHANT-INTEGRATION.PRODUCT-SUBSCRIPTION-UPDATED') { $merchantId = $resource['merchant_id'] ?? null; if (!$merchantId) { if (function_exists('writeLog')) { writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED — no merchant_id in resource, skipping'); } echo json_encode(['status' => 'ok']); exit(); } if (function_exists('writeLog')) { writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED merchant_id=' . $merchantId); } if (file_exists($merchantsFile)) { $merchants = json_decode(file_get_contents($merchantsFile), true) ?: []; $schoolName = ppFindSchoolByMerchant($merchants, $merchantId); if ($schoolName) { try { $sellerStatus = $client->getSellerStatus($config['partner_merchant_id'], $merchantId); $merchants[$schoolName] = array_merge($merchants[$schoolName], [ 'paypal_ready' => $sellerStatus['paypal_ready'], 'email_confirmed' => $sellerStatus['email_confirmed'], 'payments_receivable' => $sellerStatus['payments_receivable'], 'oauth_ok' => $sellerStatus['oauth_ok'], 'seller_email' => $sellerStatus['seller_email'], 'status_issues' => $sellerStatus['issues'], 'status_checked_at' => time(), // ACDC fields — updated whenever subscription status changes 'acdc_ready' => $sellerStatus['acdc_ready'], 'acdc_vetting_status' => $sellerStatus['acdc_vetting_status'], 'acdc_denied' => $sellerStatus['acdc_denied'], 'acdc_pending' => $sellerStatus['acdc_pending'], ]); ppSaveMerchants($merchantsFile, $merchants); if (function_exists('writeLog')) { writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED — re-checked school=' . $schoolName . ' paypal_ready=' . ($sellerStatus['paypal_ready'] ? 'true' : 'false')); } } catch (Throwable $e) { if (function_exists('writeLog')) { writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED — getSellerStatus failed: ' . $e->getMessage()); } } } else { if (function_exists('writeLog')) { writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED — merchant_id=' . $merchantId . ' not found'); } } } echo json_encode(['status' => 'ok']); exit(); } // ── Unhandled event — still 200 so PayPal stops retrying ───────────────────── if (function_exists('writeLog')) { writeLog('webhook: unhandled event_type=' . $eventType . ' — no action taken'); } echo json_encode(['status' => 'ok', 'note' => 'event_type not handled by this integration']);