- New api/tabs/ directory with 13 endpoints: open, close, cancel, get, getActive, addOrder, increaseAuth, addMember, removeMember, getPresence, approveOrder, rejectOrder, pendingOrders - New api/presence/heartbeat.cfm for beacon-based user presence tracking - New cron/expireTabs.cfm for idle tab expiry and presence cleanup - Modified submit.cfm for tab-aware order submission (skip payment, update running total) - Modified getOrCreateCart.cfm to auto-detect active tab and set TabID on new carts - Modified webhook.cfm to handle tab capture events (metadata type=tab_close) - Modified businesses/get.cfm and updateTabs.cfm with new tab config columns - Updated portal tab settings UI with auth amounts, max members, approval toggle - Added tab and presence endpoints to Application.cfm public allowlist Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
146 lines
6.2 KiB
Text
146 lines
6.2 KiB
Text
<cfsetting showdebugoutput="false" requesttimeout="60">
|
|
<cfset request._api_isPublic = true>
|
|
<cfsetting enablecfoutputonly="true">
|
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
|
|
|
<cfscript>
|
|
/**
|
|
* 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 {
|
|
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
|
|
stripeSecretKey = application.stripeSecretKey ?: "";
|
|
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'
|
|
WHERE ID = :tabID
|
|
", { tabID: { value: tab.ID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
|
|
} 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;
|
|
appFeeCents = round(platformFee * 2);
|
|
|
|
if (captureCents > 0) {
|
|
stripeSecretKey = application.stripeSecretKey ?: "";
|
|
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="application_fee_amount", value=appFeeCents);
|
|
httpCapture.send();
|
|
}
|
|
|
|
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++;
|
|
}
|
|
|
|
// 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
|
|
});
|
|
}
|
|
</cfscript>
|