282 lines
13 KiB
PHP
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']);
|