/** * Scheduled task to handle tab expiry and cleanup. * Run every 5 minutes. * * 1. Expire idle tabs (no activity beyond business SessionLockMinutes) * 2. Auto-close tabs nearing 7-day auth expiry (Stripe hold limit) * 3. Clean up stale presence records (>30 min old) */ function apiAbort(required struct payload) { writeOutput(serializeJSON(payload)); abort; } try { expiredCount = 0; capturedCount = 0; presenceCleaned = 0; // 1. Find open tabs that have been idle beyond their business lock duration qIdleTabs = queryExecute(" SELECT t.ID, t.StripePaymentIntentID, t.AuthAmountCents, t.RunningTotalCents, t.OwnerUserID, t.BusinessID, b.SessionLockMinutes, b.PayfritFee 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) ", {}, { datasource: "payfrit" }); for (tab in qIdleTabs) { try { // Check if there are any approved orders qOrders = queryExecute(" SELECT COALESCE(SUM(SubtotalCents), 0) AS TotalSubtotal, COALESCE(SUM(TaxCents), 0) AS TotalTax, COUNT(*) AS OrderCount FROM TabOrders WHERE TabID = :tabID AND ApprovalStatus = 'approved' ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); if (qOrders.OrderCount == 0) { // No orders - cancel the tab and release hold stripeSecretKey = application.stripeSecretKey ?: ""; httpCancel = new http(); httpCancel.setMethod("POST"); httpCancel.setUrl("https://api.stripe.com/v1/payment_intents/#tab.StripePaymentIntentID#/cancel"); httpCancel.setUsername(stripeSecretKey); httpCancel.setPassword(""); httpCancel.send(); queryExecute(" UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled' WHERE ID = :tabID ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); } else { // Has orders - capture with 0% tip (auto-close) payfritFee = isNumeric(tab.PayfritFee) ? tab.PayfritFee : 0.05; totalSubtotal = qOrders.TotalSubtotal; totalTax = qOrders.TotalTax; platformFee = round(totalSubtotal * payfritFee); totalBeforeCard = totalSubtotal + totalTax + platformFee; cardFeeCents = round((totalBeforeCard + 30) / (1 - 0.029)) - totalBeforeCard; captureCents = totalBeforeCard + cardFeeCents; appFeeCents = round(platformFee * 2); if (captureCents > 0) { stripeSecretKey = application.stripeSecretKey ?: ""; httpCapture = new http(); httpCapture.setMethod("POST"); httpCapture.setUrl("https://api.stripe.com/v1/payment_intents/#tab.StripePaymentIntentID#/capture"); httpCapture.setUsername(stripeSecretKey); httpCapture.setPassword(""); httpCapture.addParam(type="formfield", name="amount_to_capture", value=captureCents); httpCapture.addParam(type="formfield", name="application_fee_amount", value=appFeeCents); httpCapture.send(); } queryExecute(" UPDATE Tabs SET StatusID = 3, ClosedOn = NOW(), PaymentStatus = 'captured', CapturedOn = NOW(), FinalCaptureCents = :captureCents, TipAmountCents = 0 WHERE ID = :tabID ", { captureCents: { value: captureCents, cfsqltype: "cf_sql_integer" }, tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); // Mark approved orders as paid queryExecute(" UPDATE Orders SET PaymentStatus = 'paid', PaymentCompletedOn = NOW() WHERE ID IN (SELECT OrderID FROM TabOrders WHERE TabID = :tabID AND ApprovalStatus = 'approved') ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); capturedCount++; } // Release all members queryExecute(" UPDATE TabMembers SET StatusID = 3, LeftOn = NOW() WHERE TabID = :tabID AND StatusID = 1 ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); // Reject pending orders queryExecute(" UPDATE TabOrders SET ApprovalStatus = 'rejected' WHERE TabID = :tabID AND ApprovalStatus = 'pending' ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); expiredCount++; writeLog(file="tab_cron", text="Tab ##tab.ID## expired (idle). Orders=#qOrders.OrderCount#"); } catch (any tabErr) { writeLog(file="tab_cron", text="Error expiring tab ##tab.ID##: #tabErr.message#"); } } // 2. Clean stale presence records (>30 min) qPresence = queryExecute(" DELETE FROM UserPresence WHERE LastSeenOn < DATE_SUB(NOW(), INTERVAL 30 MINUTE) ", {}, { datasource: "payfrit" }); presenceCleaned = qPresence.recordCount ?: 0; apiAbort({ "OK": true, "MESSAGE": "Tab cron complete", "EXPIRED_TABS": expiredCount, "CAPTURED_TABS": capturedCount, "PRESENCE_CLEANED": presenceCleaned }); } catch (any e) { writeLog(file="tab_cron", text="Cron error: #e.message#"); apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message }); }