fix: harden tab expiry cron against silent capture failures

- Check PI state on Stripe before capture/cancel (requires_capture,
  canceled, requires_payment_method, etc.)
- Add application_fee_amount for Stripe Connect (was missing — all
  money went to connected account on auto-close)
- Re-verify tab StatusID=1 before processing (prevents race with
  user-initiated close)
- Add WHERE StatusID=1 to closing UPDATE (prevents overwriting
  concurrent user close)
- Log full Stripe response status and HTTP code for debugging
- Handle already-cancelled PIs gracefully (mark tab expired)
- Handle unconfirmed PIs (cancel and mark expired)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-03-03 13:00:39 -08:00
parent 496ef74c4c
commit 03c675336e

View file

@ -10,8 +10,7 @@
* Run every 5 minutes. * Run every 5 minutes.
* *
* 1. Expire idle tabs (no activity beyond business SessionLockMinutes) * 1. Expire idle tabs (no activity beyond business SessionLockMinutes)
* 2. Auto-close tabs nearing 7-day auth expiry (Stripe hold limit) * 2. Clean up stale presence records (>30 min old)
* 3. Clean up stale presence records (>30 min old)
*/ */
function apiAbort(required struct payload) { function apiAbort(required struct payload) {
@ -28,13 +27,15 @@ try {
expiredCount = 0; expiredCount = 0;
capturedCount = 0; capturedCount = 0;
cancelledCount = 0;
presenceCleaned = 0; presenceCleaned = 0;
// 1. Find open tabs that have been idle beyond their business lock duration // 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(" qIdleTabs = queryExecute("
SELECT t.ID, t.StripePaymentIntentID, t.AuthAmountCents, t.RunningTotalCents, SELECT t.ID, t.StripePaymentIntentID, t.AuthAmountCents, t.RunningTotalCents,
t.OwnerUserID, t.BusinessID, t.OwnerUserID, t.BusinessID,
b.SessionLockMinutes, b.PayfritFee b.SessionLockMinutes, b.PayfritFee, b.StripeAccountID, b.StripeOnboardingComplete
FROM Tabs t FROM Tabs t
JOIN Businesses b ON b.ID = t.BusinessID JOIN Businesses b ON b.ID = t.BusinessID
WHERE t.StatusID = 1 WHERE t.StatusID = 1
@ -43,107 +44,186 @@ try {
for (tab in qIdleTabs) { for (tab in qIdleTabs) {
try { try {
// Check if there are any approved orders // Re-verify tab is still open (prevents race with user close)
qOrders = queryExecute(" qRecheck = queryExecute("
SELECT COALESCE(SUM(SubtotalCents), 0) AS TotalSubtotal, SELECT StatusID FROM Tabs WHERE ID = :tabID LIMIT 1
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" }); ", { 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) { // Check PI state on Stripe before attempting capture/cancel
// No orders - cancel the tab and release hold 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 = new http();
httpCancel.setMethod("POST"); httpCancel.setMethod("POST");
httpCancel.setUrl("https://api.stripe.com/v1/payment_intents/#tab.StripePaymentIntentID#/cancel"); httpCancel.setUrl("https://api.stripe.com/v1/payment_intents/#tab.StripePaymentIntentID#/cancel");
httpCancel.setUsername(stripeSecretKey); httpCancel.setUsername(stripeSecretKey);
httpCancel.setPassword(""); httpCancel.setPassword("");
cancelResult = httpCancel.send().getPrefix(); httpCancel.send();
cancelData = deserializeJSON(cancelResult.fileContent);
if (structKeyExists(cancelData, "status") && cancelData.status == "canceled") { queryExecute("
queryExecute(" UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled',
UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled' PaymentError = 'PI never confirmed (status: #piStatus#)'
WHERE ID = :tabID WHERE ID = :tabID AND StatusID = 1
", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
writeLog(file="tab_cron", text="Tab ##tab.ID## cancelled (no orders, hold released)."); cancelledCount++;
} else { // Fall through to member/order cleanup below
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 { } else {
// Has orders - capture with 0% tip (auto-close) // PI is in requires_capture - proceed with capture or cancel
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 // Check if there are any approved orders
if (captureCents > tab.AuthAmountCents) { qOrders = queryExecute("
captureCents = tab.AuthAmountCents; 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 if (qOrders.OrderCount == 0 || val(qOrders.TotalSubtotal) == 0) {
queryExecute("UPDATE Tabs SET StatusID = 2 WHERE ID = :tabID", // No orders - cancel the PI and release hold
{ tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); 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(); if (structKeyExists(cancelData, "status") && cancelData.status == "canceled") {
httpCapture.setMethod("POST"); queryExecute("
httpCapture.setUrl("https://api.stripe.com/v1/payment_intents/#tab.StripePaymentIntentID#/capture"); UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled'
httpCapture.setUsername(stripeSecretKey); WHERE ID = :tabID AND StatusID = 1
httpCapture.setPassword(""); ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
httpCapture.addParam(type="formfield", name="amount_to_capture", value=captureCents); cancelledCount++;
httpCapture.addParam(type="formfield", name="metadata[type]", value="tab_auto_close"); writeLog(file="tab_cron", text="Tab ##tab.ID## cancelled (no orders, hold released).");
httpCapture.addParam(type="formfield", name="metadata[tab_id]", value=tab.ID); } else {
captureResult = httpCapture.send().getPrefix(); errMsg = structKeyExists(cancelData, "error") && structKeyExists(cancelData.error, "message") ? cancelData.error.message : "Cancel failed";
captureData = deserializeJSON(captureResult.fileContent); 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") { // Stripe Connect: application_fee_amount = 2x payfritFee
queryExecute(" applicationFeeCents = platformFee * 2;
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 // Cap at authorized amount
queryExecute(" if (captureCents > tab.AuthAmountCents) {
UPDATE Orders SET PaymentStatus = 'paid', PaymentCompletedOn = NOW() captureCents = tab.AuthAmountCents;
WHERE ID IN (SELECT OrderID FROM TabOrders WHERE TabID = :tabID AND ApprovalStatus = 'approved') 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" }); ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
capturedCount++; // Check if we actually updated (another process might have closed it)
writeLog(file="tab_cron", text="Tab ##tab.ID## auto-closed. Captured #captureCents# cents."); if (val(qClosing.recordCount ?: 0) == 0) {
} else { writeLog(file="tab_cron", text="Tab ##tab.ID## already being closed by another process. Skipping.");
errMsg = structKeyExists(captureData, "error") && structKeyExists(captureData.error, "message") ? captureData.error.message : "Capture failed"; continue;
queryExecute(" }
UPDATE Tabs SET StatusID = 1, PaymentStatus = 'capture_failed', PaymentError = :err
WHERE ID = :tabID httpCapture = new http();
", { httpCapture.setMethod("POST");
err: { value: errMsg, cfsqltype: "cf_sql_varchar" }, httpCapture.setUrl("https://api.stripe.com/v1/payment_intents/#tab.StripePaymentIntentID#/capture");
tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } httpCapture.setUsername(stripeSecretKey);
}, { datasource: "payfrit" }); httpCapture.setPassword("");
writeLog(file="tab_cron", text="Tab ##tab.ID## capture FAILED: #errMsg#. Tab reverted to open."); httpCapture.addParam(type="formfield", name="amount_to_capture", value=captureCents);
continue; // 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(" queryExecute("
UPDATE TabMembers SET StatusID = 3, LeftOn = NOW() UPDATE TabMembers SET StatusID = 3, LeftOn = NOW()
WHERE TabID = :tabID AND StatusID = 1 WHERE TabID = :tabID AND StatusID = 1
@ -156,7 +236,6 @@ try {
", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" }); ", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
expiredCount++; expiredCount++;
writeLog(file="tab_cron", text="Tab ##tab.ID## expired (idle). Orders=#qOrders.OrderCount#");
} catch (any tabErr) { } catch (any tabErr) {
writeLog(file="tab_cron", text="Error expiring tab ##tab.ID##: #tabErr.message#"); writeLog(file="tab_cron", text="Error expiring tab ##tab.ID##: #tabErr.message#");
} }
@ -174,6 +253,7 @@ try {
"MESSAGE": "Tab cron complete", "MESSAGE": "Tab cron complete",
"EXPIRED_TABS": expiredCount, "EXPIRED_TABS": expiredCount,
"CAPTURED_TABS": capturedCount, "CAPTURED_TABS": capturedCount,
"CANCELLED_TABS": cancelledCount,
"PRESENCE_CLEANED": presenceCleaned "PRESENCE_CLEANED": presenceCleaned
}); });