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:
parent
496ef74c4c
commit
03c675336e
1 changed files with 166 additions and 86 deletions
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Reference in a new issue