diff --git a/cron/expireTabs.cfm b/cron/expireTabs.cfm index b1f708d..e68b599 100644 --- a/cron/expireTabs.cfm +++ b/cron/expireTabs.cfm @@ -10,8 +10,7 @@ * 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) + * 2. Clean up stale presence records (>30 min old) */ function apiAbort(required struct payload) { @@ -28,13 +27,15 @@ try { 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.SessionLockMinutes, b.PayfritFee, b.StripeAccountID, b.StripeOnboardingComplete FROM Tabs t JOIN Businesses b ON b.ID = t.BusinessID WHERE t.StatusID = 1 @@ -43,107 +44,186 @@ try { 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' + // 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; + } - if (qOrders.OrderCount == 0) { - // No orders - cancel the tab and release hold + // 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(""); - cancelResult = httpCancel.send().getPrefix(); - cancelData = deserializeJSON(cancelResult.fileContent); + httpCancel.send(); - 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; - } + 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 { - // 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; + // PI is in requires_capture - proceed with capture or cancel - // Cap at authorized amount - if (captureCents > tab.AuthAmountCents) { - captureCents = tab.AuthAmountCents; - } + // 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" }); - // Mark as closing - queryExecute("UPDATE Tabs SET StatusID = 2 WHERE ID = :tabID", - { 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); - 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(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; - 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" }); + // Stripe Connect: application_fee_amount = 2x payfritFee + applicationFeeCents = platformFee * 2; - // 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') + // 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" }); - 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; + // 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 + // 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 @@ -156,7 +236,6 @@ try { ", { 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#"); } @@ -174,6 +253,7 @@ try { "MESSAGE": "Tab cron complete", "EXPIRED_TABS": expiredCount, "CAPTURED_TABS": capturedCount, + "CANCELLED_TABS": cancelledCount, "PRESENCE_CLEANED": presenceCleaned });