/** * 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 { stripeSecretKey = application.stripeSecretKey ?: ""; if (stripeSecretKey == "") { writeLog(file="tab_cron", text="FATAL: stripeSecretKey not available. Aborting."); apiAbort({ "OK": false, "ERROR": "no_stripe_key" }); } 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 httpCancel = new http(); httpCancel.setMethod("POST"); httpCancel.setUrl("https://api.stripe.com/v1/payment_intents/#tab.StripePaymentIntentID#/cancel"); httpCancel.setUsername(stripeSecretKey); httpCancel.setPassword(""); cancelResult = httpCancel.send().getPrefix(); cancelData = deserializeJSON(cancelResult.fileContent); if (structKeyExists(cancelData, "status") && cancelData.status == "canceled") { queryExecute(" UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled' WHERE ID = :tabID ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); writeLog(file="tab_cron", text="Tab ##tab.ID## cancelled (no orders, hold released)."); } else { errMsg = structKeyExists(cancelData, "error") && structKeyExists(cancelData.error, "message") ? cancelData.error.message : "Cancel failed"; queryExecute(" UPDATE Tabs SET PaymentStatus = 'cancel_failed', PaymentError = :err WHERE ID = :tabID ", { err: { value: errMsg, cfsqltype: "cf_sql_varchar" }, tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); writeLog(file="tab_cron", text="Tab ##tab.ID## cancel FAILED: #errMsg#"); continue; } } 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; // Cap at authorized amount if (captureCents > tab.AuthAmountCents) { captureCents = tab.AuthAmountCents; } // Mark as closing queryExecute("UPDATE Tabs SET StatusID = 2 WHERE ID = :tabID", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); 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="metadata[type]", value="tab_auto_close"); httpCapture.addParam(type="formfield", name="metadata[tab_id]", value=tab.ID); captureResult = httpCapture.send().getPrefix(); captureData = deserializeJSON(captureResult.fileContent); if (structKeyExists(captureData, "status") && captureData.status == "succeeded") { 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++; writeLog(file="tab_cron", text="Tab ##tab.ID## auto-closed. Captured #captureCents# cents."); } else { errMsg = structKeyExists(captureData, "error") && structKeyExists(captureData.error, "message") ? captureData.error.message : "Capture failed"; queryExecute(" UPDATE Tabs SET StatusID = 1, PaymentStatus = 'capture_failed', PaymentError = :err WHERE ID = :tabID ", { err: { value: errMsg, cfsqltype: "cf_sql_varchar" }, tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); writeLog(file="tab_cron", text="Tab ##tab.ID## capture FAILED: #errMsg#. Tab reverted to open."); continue; } } // 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 }); }