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

131 lines
6.2 KiB
PHP

<?php
/**
* Square Webhook Handler (Phase 2)
* ────────────────────────────────
* Setup (do once):
* 1. Deploy this file and note its public URL, e.g. https://app.hotlunches.net/square/webhook.php
* 2. Square Developer Dashboard → your App → Webhooks → Add Subscription:
* • Notification URL: the exact URL above (must match config 'webhook_url')
* • Events: oauth.authorization.revoked (Phase 5 will add payment.updated, refund.updated)
* 3. Copy the subscription's Signature Key → set 'webhook_signature_key' in config.php
* 4. Set 'webhook_url' in config.php to the exact Notification URL you entered.
*
* Square retries on any non-2xx for ~72h, so on transient problems we respond
* 200 (work is logged); on a FAILED signature we respond 403 (never process an
* unverified body). The raw body is read first, before anything can corrupt it.
*/
$rawBody = file_get_contents('php://input');
// Optional app logging.
ob_start();
if (file_exists(__DIR__ . '/../include/logging-functions.php')) {
require_once __DIR__ . '/../include/logging-functions.php';
if (function_exists('startLog')) startLog('square-webhook');
}
ob_end_clean();
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/MerchantStore.php';
function sqLog(string $msg): void {
if (function_exists('writeLog')) writeLog($msg);
}
// ── Header helper (works on Apache + Nginx + PHP-FPM) ────────────────────────
function sqWebhookHeader(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] ?? '';
}
// ── Resolve the notification URL exactly as registered (signature depends on it).
function sqNotificationUrl(array $config): string {
$u = trim($config['webhook_url'] ?? '');
if ($u !== '') return $u;
// Fallback: reconstruct from the request (works when not behind a rewriting proxy).
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https'
: ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https');
$host = $_SERVER['HTTP_HOST'] ?? '';
$uri = $_SERVER['REQUEST_URI'] ?? '';
return $scheme . '://' . $host . $uri;
}
// ── Guard: signature key must be configured ──────────────────────────────────
$signatureKey = $config['webhook_signature_key'] ?? '';
if ($signatureKey === '') {
sqLog('square-webhook: webhook_signature_key not set — refusing to process unverified events');
http_response_code(500);
echo json_encode(['status' => 'signature_key_not_configured']);
exit();
}
// ── Verify HMAC-SHA256 signature ─────────────────────────────────────────────
// signature = base64( HMAC_SHA256( key, notificationUrl + rawBody ) )
$providedSig = sqWebhookHeader('x-square-hmacsha256-signature');
if ($providedSig === '') {
sqLog('square-webhook: missing x-square-hmacsha256-signature header — ignored');
http_response_code(403);
echo json_encode(['status' => 'missing_signature']);
exit();
}
$notificationUrl = sqNotificationUrl($config);
$expectedSig = base64_encode(hash_hmac('sha256', $notificationUrl . $rawBody, $signatureKey, true));
if (!hash_equals($expectedSig, $providedSig)) {
sqLog('square-webhook: signature MISMATCH (notification_url=' . $notificationUrl . ') — rejected');
http_response_code(403);
echo json_encode(['status' => 'signature_verification_failed']);
exit();
}
// ── Parse the verified event ─────────────────────────────────────────────────
$event = json_decode($rawBody, true) ?: [];
$eventType = $event['type'] ?? '';
$merchantId = $event['merchant_id'] ?? ($event['data']['object']['merchant_id'] ?? '');
sqLog('square-webhook: verified type=' . $eventType . ' event_id=' . ($event['event_id'] ?? '?') . ' merchant_id=' . ($merchantId ?: '?'));
http_response_code(200);
try {
$store = new MerchantStore($config);
// ════════════════════════════════════════════════════════════════════════
// EVENT: oauth.authorization.revoked
// The seller removed our application from THEIR Square account. The tokens
// are already dead at Square — clear ours and flag the school so payments
// stop and staff know a reconnect is required.
// ════════════════════════════════════════════════════════════════════════
if ($eventType === 'oauth.authorization.revoked') {
if ($merchantId === '') {
sqLog('square-webhook: revoked event without merchant_id — nothing to do');
echo json_encode(['status' => 'ok', 'note' => 'no merchant_id']);
exit();
}
$school = $store->markAuthorizationRevoked($merchantId);
sqLog('square-webhook: authorization revoked for merchant_id=' . $merchantId
. ($school ? (' (school=' . $school . ')') : ' (no matching school)'));
echo json_encode(['status' => 'ok', 'school' => $school]);
exit();
}
// ── Unhandled (but verified) event — 200 so Square stops retrying ─────────
sqLog('square-webhook: unhandled event type=' . $eventType);
echo json_encode(['status' => 'ok', 'note' => 'event type not handled']);
exit();
} catch (Throwable $e) {
// Respond 200 so Square doesn't hammer us; the failure is logged for review.
sqLog('square-webhook: handler error — ' . $e->getMessage());
echo json_encode(['status' => 'ok', 'note' => 'handler error logged']);
exit();
}