First Commit
This commit is contained in:
1 parent
5eb9dd037d
commit
2e62c0e60b
11877 files changed
+2035312
No files matched your search
@@ -0,0 +1,281 @@
|
||||
<?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']);
|
||||
Reference in new issue
Block a user