/** * 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) */ 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; cancelledCount = 0; presenceCleaned = 0; // 1. Find open tabs that have been idle beyond their business lock duration // Re-check StatusID = 1 to prevent race with user-initiated close qIdleTabs = queryExecute(" 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) ", {}, { datasource: "payfrit" }); for (tab in qIdleTabs) { try { // Re-verify tab is still open (prevents race with user close) qRecheck = queryExecute(" SELECT StatusID FROM Tabs WHERE ID = :tabID LIMIT 1 ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); if (qRecheck.recordCount == 0 || qRecheck.StatusID != 1) { writeLog(file="tab_cron", text="Tab ##tab.ID## no longer open (status=#qRecheck.StatusID#), skipping."); continue; } // Check PI state on Stripe before attempting capture/cancel httpCheck = new http(); httpCheck.setMethod("GET"); httpCheck.setUrl("https://api.stripe.com/v1/payment_intents/#tab.StripePaymentIntentID#"); httpCheck.setUsername(stripeSecretKey); httpCheck.setPassword(""); checkResult = httpCheck.send().getPrefix(); piData = deserializeJSON(checkResult.fileContent); if (!structKeyExists(piData, "status")) { writeLog(file="tab_cron", text="Tab ##tab.ID## cannot read PI status. Skipping."); continue; } piStatus = piData.status; writeLog(file="tab_cron", text="Tab ##tab.ID## PI status: #piStatus#"); // If PI is already canceled/succeeded/processing, handle accordingly if (piStatus == "canceled") { // PI was already cancelled (e.g., by Stripe, webhook, or manual action) queryExecute(" UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled', PaymentError = 'PI already cancelled on Stripe' WHERE ID = :tabID AND StatusID = 1 ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); cancelledCount++; writeLog(file="tab_cron", text="Tab ##tab.ID## PI already cancelled. Marked expired."); // Fall through to member/order cleanup below } else if (piStatus != "requires_capture") { // PI not in capturable state (requires_payment_method, requires_confirmation, etc.) writeLog(file="tab_cron", text="Tab ##tab.ID## PI not capturable (status=#piStatus#). Cancelling."); // Cancel the PI since it was never confirmed 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', PaymentError = 'PI never confirmed (status: #piStatus#)' WHERE ID = :tabID AND StatusID = 1 ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); cancelledCount++; // Fall through to member/order cleanup below } else { // PI is in requires_capture - proceed with capture or cancel // 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 || val(qOrders.TotalSubtotal) == 0) { // No orders - cancel the PI 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 AND StatusID = 1 ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); cancelledCount++; 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; // Stripe Connect: application_fee_amount = 2x payfritFee applicationFeeCents = platformFee * 2; // Cap at authorized amount if (captureCents > tab.AuthAmountCents) { captureCents = tab.AuthAmountCents; if ((totalBeforeCard + cardFeeCents) > 0) { applicationFeeCents = round(applicationFeeCents * (captureCents / (totalBeforeCard + cardFeeCents))); } } // Mark as closing (prevents race with user close) qClosing = queryExecute(" UPDATE Tabs SET StatusID = 2 WHERE ID = :tabID AND StatusID = 1 ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); // Check if we actually updated (another process might have closed it) if (val(qClosing.recordCount ?: 0) == 0) { writeLog(file="tab_cron", text="Tab ##tab.ID## already being closed by another process. Skipping."); continue; } 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); // Add application_fee_amount for Stripe Connect if (len(trim(tab.StripeAccountID)) && val(tab.StripeOnboardingComplete) == 1) { httpCapture.addParam(type="formfield", name="application_fee_amount", value=applicationFeeCents); } 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); writeLog(file="tab_cron", text="Tab ##tab.ID## capture response: status=#structKeyExists(captureData, 'status') ? captureData.status : 'N/A'# http=#captureResult.statusCode#"); 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 (fee=#applicationFeeCents#)."); } else { errMsg = structKeyExists(captureData, "error") && structKeyExists(captureData.error, "message") ? captureData.error.message : "Capture failed (http #captureResult.statusCode#)"; 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 (runs for all successful close/cancel/expire paths) 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++; } 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, "CANCELLED_TABS": cancelledCount, "PRESENCE_CLEANED": presenceCleaned }); } catch (any e) { writeLog(file="tab_cron", text="Cron error: #e.message#"); apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message }); }