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()]); }