Files
2026-10-19 15:22:52 -07:00

282 lines
13 KiB
PHP

<?php
/**
* PayPal Webhook Handler
* ──────────────────────
* Setup (do once):
* 1. Deploy this file and note its public URL, e.g. https://app.hotlunches.net/sm/webhook.php
* 2. In PayPal Developer Dashboard → Your App → Webhooks → Add Webhook
* Paste the URL and subscribe to:
* • MERCHANT.PARTNER-CONSENT.REVOKED
* • CUSTOMER.MERCHANT-INTEGRATION.PRODUCT-SUBSCRIPTION-UPDATED
* 3. Copy the Webhook ID that PayPal generates and set 'webhook_id' in config.php
*
* PayPal retries on any non-200 response, so we always respond 200 — errors are logged.
*/
// Read raw body immediately before any output can corrupt it
$rawBody = file_get_contents('php://input');
// Pull in app logging if available
ob_start();
if (file_exists('../include/logging-functions.php')) {
require_once '../include/logging-functions.php';
startLog('webhook');
}
ob_end_clean();
// Always respond 200 to stop PayPal from retrying
http_response_code(200);
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/PayPalClient.php';
// ── Header helper (works on Apache + Nginx + PHP-FPM) ────────────────────────
function ppWebhookHeader(string $name): string
{
if (function_exists('getallheaders')) {
foreach (getallheaders() as $k => $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']);