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
193 lines
9.1 KiB
PHP
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()]);
|
|
}
|