payfrit-api/cron/expireTabs.php
Schwifty 601245d969 fix: harden auth middleware — exact route matching, remove admin bypass, add cron secret
1. Switch str_contains() to exact match ($path === $route) in PUBLIC_ROUTES check
   to prevent substring-based route bypass attacks.

2. Remove blanket /api/admin/ bypass that was letting all admin endpoints through
   without authentication.

3. Add requireCronSecret() — cron/scheduled task endpoints now require a valid
   X-Cron-Secret header matching the PAYFRIT_CRON_SECRET env var. Uses
   hash_equals() for timing-safe comparison. Applied to:
   - cron/expireStaleChats.php
   - cron/expireTabs.php
   - api/admin/scheduledTasks/runDue.php
2026-03-23 01:43:43 +00:00

193 lines
9.1 KiB
PHP

<?php
require_once __DIR__ . '/../api/helpers.php';
require_once __DIR__ . '/../api/config/stripe.php';
requireCronSecret();
/**
* Scheduled task to handle tab expiry and cleanup.
* Run every 5 minutes.
*
* 1. Expire idle tabs (no activity beyond business SessionLockMinutes)
* 2. Clean up stale presence records (>30 min old)
*/
try {
$config = getStripeConfig();
$stripeSecretKey = $config['secretKey'] ?? '';
if ($stripeSecretKey === '') {
error_log('[tab_cron] FATAL: stripeSecretKey not available. Aborting.');
jsonResponse(['OK' => false, 'ERROR' => 'no_stripe_key']);
}
$expiredCount = 0;
$capturedCount = 0;
$cancelledCount = 0;
$presenceCleaned = 0;
// 1. Find open tabs that have been idle beyond their business lock duration
$idleTabs = queryTimed("
SELECT t.ID, t.StripePaymentIntentID, t.AuthAmountCents, t.RunningTotalCents,
t.OwnerUserID, t.BusinessID,
b.SessionLockMinutes, b.PayfritFee, b.StripeAccountID, b.StripeOnboardingComplete
FROM Tabs t
JOIN Businesses b ON b.ID = t.BusinessID
WHERE t.StatusID = 1
AND t.LastActivityOn < DATE_SUB(NOW(), INTERVAL COALESCE(b.SessionLockMinutes, 30) MINUTE)
");
foreach ($idleTabs as $tab) {
try {
// Re-verify tab is still open
$recheck = queryOne("SELECT StatusID FROM Tabs WHERE ID = ? LIMIT 1", [$tab['ID']]);
if (!$recheck || (int) $recheck['StatusID'] !== 1) {
error_log("[tab_cron] Tab #{$tab['ID']} no longer open, skipping.");
continue;
}
// Check PI state on Stripe
$piData = stripeRequest('GET', "https://api.stripe.com/v1/payment_intents/{$tab['StripePaymentIntentID']}");
if (empty($piData['status'])) {
error_log("[tab_cron] Tab #{$tab['ID']} cannot read PI status. Skipping.");
continue;
}
$piStatus = $piData['status'];
error_log("[tab_cron] Tab #{$tab['ID']} PI status: {$piStatus}");
if ($piStatus === 'canceled') {
queryTimed("
UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled',
PaymentError = 'PI already cancelled on Stripe'
WHERE ID = ? AND StatusID = 1
", [$tab['ID']]);
$cancelledCount++;
} elseif ($piStatus !== 'requires_capture') {
// Cancel the PI since it's not in a capturable state
stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$tab['StripePaymentIntentID']}/cancel");
queryTimed("
UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled',
PaymentError = ?
WHERE ID = ? AND StatusID = 1
", ["PI never confirmed (status: {$piStatus})", $tab['ID']]);
$cancelledCount++;
} else {
// PI is requires_capture — capture or cancel
$qOrders = queryOne("
SELECT COALESCE(SUM(SubtotalCents), 0) AS TotalSubtotal,
COALESCE(SUM(TaxCents), 0) AS TotalTax,
COUNT(*) AS OrderCount
FROM TabOrders
WHERE TabID = ? AND ApprovalStatus = 'approved'
", [$tab['ID']]);
if ((int) $qOrders['OrderCount'] === 0 || (int) $qOrders['TotalSubtotal'] === 0) {
// No orders — cancel PI and release hold
$cancelData = stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$tab['StripePaymentIntentID']}/cancel");
if (($cancelData['status'] ?? '') === 'canceled') {
queryTimed("
UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled'
WHERE ID = ? AND StatusID = 1
", [$tab['ID']]);
$cancelledCount++;
} else {
$errMsg = $cancelData['error']['message'] ?? 'Cancel failed';
queryTimed("UPDATE Tabs SET PaymentStatus = 'cancel_failed', PaymentError = ? WHERE ID = ?", [$errMsg, $tab['ID']]);
error_log("[tab_cron] Tab #{$tab['ID']} cancel FAILED: {$errMsg}");
continue;
}
} else {
// Has orders — capture with 0% tip (auto-close)
$payfritFee = is_numeric($tab['PayfritFee']) ? (float) $tab['PayfritFee'] : 0.05;
$totalSubtotal = (int) $qOrders['TotalSubtotal'];
$totalTax = (int) $qOrders['TotalTax'];
$platformFee = (int) round($totalSubtotal * $payfritFee);
$totalBeforeCard = $totalSubtotal + $totalTax + $platformFee;
$cardFeeCents = (int) round(($totalBeforeCard + 30) / (1 - 0.029)) - $totalBeforeCard;
$captureCents = $totalBeforeCard + $cardFeeCents;
$applicationFeeCents = $platformFee * 2;
// Cap at authorized amount
if ($captureCents > (int) $tab['AuthAmountCents']) {
$captureCents = (int) $tab['AuthAmountCents'];
if (($totalBeforeCard + $cardFeeCents) > 0) {
$applicationFeeCents = (int) round($applicationFeeCents * ($captureCents / ($totalBeforeCard + $cardFeeCents)));
}
}
// Mark as closing (prevents race)
$closing = queryTimed("UPDATE Tabs SET StatusID = 2 WHERE ID = ? AND StatusID = 1", [$tab['ID']]);
if ($closing->rowCount() === 0) {
error_log("[tab_cron] Tab #{$tab['ID']} already being closed. Skipping.");
continue;
}
$captureParams = [
'amount_to_capture' => $captureCents,
'metadata[type]' => 'tab_auto_close',
'metadata[tab_id]' => $tab['ID'],
];
if (!empty(trim($tab['StripeAccountID'] ?? '')) && (int) ($tab['StripeOnboardingComplete'] ?? 0) === 1) {
$captureParams['application_fee_amount'] = $applicationFeeCents;
}
$captureData = stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$tab['StripePaymentIntentID']}/capture", $captureParams);
if (($captureData['status'] ?? '') === 'succeeded') {
queryTimed("
UPDATE Tabs SET StatusID = 3, ClosedOn = NOW(), PaymentStatus = 'captured',
CapturedOn = NOW(), FinalCaptureCents = ?, TipAmountCents = 0
WHERE ID = ?
", [$captureCents, $tab['ID']]);
queryTimed("
UPDATE Orders SET PaymentStatus = 'paid', PaymentCompletedOn = NOW()
WHERE ID IN (SELECT OrderID FROM TabOrders WHERE TabID = ? AND ApprovalStatus = 'approved')
", [$tab['ID']]);
$capturedCount++;
error_log("[tab_cron] Tab #{$tab['ID']} auto-closed. Captured {$captureCents} cents (fee={$applicationFeeCents}).");
} else {
$errMsg = $captureData['error']['message'] ?? 'Capture failed';
queryTimed("UPDATE Tabs SET StatusID = 1, PaymentStatus = 'capture_failed', PaymentError = ? WHERE ID = ?", [$errMsg, $tab['ID']]);
error_log("[tab_cron] Tab #{$tab['ID']} capture FAILED: {$errMsg}. Tab reverted to open.");
continue;
}
}
}
// Release all members
queryTimed("UPDATE TabMembers SET StatusID = 3, LeftOn = NOW() WHERE TabID = ? AND StatusID = 1", [$tab['ID']]);
// Reject pending orders
queryTimed("UPDATE TabOrders SET ApprovalStatus = 'rejected' WHERE TabID = ? AND ApprovalStatus = 'pending'", [$tab['ID']]);
$expiredCount++;
} catch (Exception $tabErr) {
error_log("[tab_cron] Error expiring tab #{$tab['ID']}: {$tabErr->getMessage()}");
}
}
// 2. Clean stale presence records (>30 min)
$cleanStmt = queryTimed("DELETE FROM UserPresence WHERE LastSeenOn < DATE_SUB(NOW(), INTERVAL 30 MINUTE)");
$presenceCleaned = $cleanStmt->rowCount();
jsonResponse([
'OK' => true,
'MESSAGE' => 'Tab cron complete',
'EXPIRED_TABS' => $expiredCount,
'CAPTURED_TABS' => $capturedCount,
'CANCELLED_TABS' => $cancelledCount,
'PRESENCE_CLEANED' => $presenceCleaned,
]);
} catch (Exception $e) {
error_log("[tab_cron] Cron error: {$e->getMessage()}");
jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]);
}