This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/api/tabs/close.cfm
John Mizerek 4c0479db5c Add Open Tabs feature: tab APIs, presence tracking, shared tabs, cron, portal settings
- 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>
2026-02-24 20:56:07 -08:00

204 lines
8.7 KiB
Text

<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Close Tab
* Calculates aggregate fees, adds tip, captures the Stripe PaymentIntent.
*
* POST: {
* TabID: int,
* UserID: int (must be tab owner or business employee),
* TipPercent: number (optional, e.g. 0.18 for 18%),
* TipAmount: number (optional, in dollars - one of TipPercent or TipAmount)
* }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
userID = val(requestData.UserID ?: 0);
tipPercent = structKeyExists(requestData, "TipPercent") ? val(requestData.TipPercent) : -1;
tipAmount = structKeyExists(requestData, "TipAmount") ? val(requestData.TipAmount) : -1;
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
// Get tab + business info
qTab = queryTimed("
SELECT t.*, b.PayfritFee, b.StripeAccountID, b.StripeOnboardingComplete
FROM Tabs t
JOIN Businesses b ON b.ID = t.BusinessID
WHERE t.ID = :tabID LIMIT 1
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
if (qTab.recordCount == 0) apiAbort({ "OK": false, "ERROR": "tab_not_found" });
if (qTab.StatusID != 1) apiAbort({ "OK": false, "ERROR": "tab_not_open", "MESSAGE": "Tab is not open (status: #qTab.StatusID#)." });
// Verify: must be tab owner or business employee
isTabOwner = qTab.OwnerUserID == userID;
if (!isTabOwner) {
qEmp = queryTimed("
SELECT ID FROM Employees
WHERE BusinessID = :bizID AND UserID = :userID AND IsActive = 1 LIMIT 1
", {
bizID: { value: qTab.BusinessID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" }
});
if (qEmp.recordCount == 0) apiAbort({ "OK": false, "ERROR": "not_authorized", "MESSAGE": "Only the tab owner or a business employee can close the tab." });
}
// Reject any pending orders
queryTimed("
UPDATE TabOrders SET ApprovalStatus = 'rejected'
WHERE TabID = :tabID AND ApprovalStatus = 'pending'
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
// Calculate aggregate totals from approved orders
qTotals = queryTimed("
SELECT COALESCE(SUM(SubtotalCents), 0) AS TotalSubtotalCents,
COALESCE(SUM(TaxCents), 0) AS TotalTaxCents
FROM TabOrders
WHERE TabID = :tabID AND ApprovalStatus = 'approved'
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
totalSubtotalCents = val(qTotals.TotalSubtotalCents);
totalTaxCents = val(qTotals.TotalTaxCents);
// If no orders, just cancel
if (totalSubtotalCents == 0) {
// Cancel the PI
cfhttp(method="POST", url="https://api.stripe.com/v1/payment_intents/#qTab.StripePaymentIntentID#/cancel", result="cancelResp") {
cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#");
}
queryTimed("
UPDATE Tabs SET StatusID = 4, ClosedOn = NOW(), PaymentStatus = 'cancelled'
WHERE ID = :tabID
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
apiAbort({ "OK": true, "MESSAGE": "Tab cancelled (no orders).", "FINAL_CAPTURE_CENTS": 0 });
}
// Calculate tip
tipCents = 0;
tipPct = 0;
totalSubtotalDollars = totalSubtotalCents / 100;
if (tipPercent >= 0) {
tipPct = tipPercent;
tipCents = round(totalSubtotalCents * tipPercent);
} else if (tipAmount >= 0) {
tipCents = round(tipAmount * 100);
if (totalSubtotalCents > 0) tipPct = tipCents / totalSubtotalCents;
}
// Fee calculation (same formula as createPaymentIntent.cfm)
payfritFeeRate = val(qTab.PayfritFee);
if (payfritFeeRate <= 0) apiAbort({ "OK": false, "ERROR": "no_fee_configured", "MESSAGE": "Business PayfritFee not set." });
payfritFeeCents = round(totalSubtotalCents * payfritFeeRate);
totalBeforeCardFeeCents = totalSubtotalCents + totalTaxCents + tipCents + payfritFeeCents;
// Card fee: (total + $0.30) / (1 - 0.029) - total
cardFeeFixedCents = 30; // $0.30
cardFeePercent = 0.029;
totalWithCardFeeCents = ceiling((totalBeforeCardFeeCents + cardFeeFixedCents) / (1 - cardFeePercent));
cardFeeCents = totalWithCardFeeCents - totalBeforeCardFeeCents;
finalCaptureCents = totalWithCardFeeCents;
// Stripe Connect: application_fee_amount = 2x payfritFee (customer + business share)
applicationFeeCents = payfritFeeCents * 2;
// Ensure capture doesn't exceed authorization
if (finalCaptureCents > qTab.AuthAmountCents) {
// Cap at authorized amount - customer underpays slightly
// In practice this shouldn't happen if we enforce limits on addOrder
finalCaptureCents = qTab.AuthAmountCents;
// Recalculate application fee proportionally
if (totalWithCardFeeCents > 0) {
applicationFeeCents = round(applicationFeeCents * (finalCaptureCents / totalWithCardFeeCents));
}
}
// Mark tab as closing
queryTimed("UPDATE Tabs SET StatusID = 2 WHERE ID = :tabID", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
// Capture the PaymentIntent
cfhttp(method="POST", url="https://api.stripe.com/v1/payment_intents/#qTab.StripePaymentIntentID#/capture", result="captureResp") {
cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#");
cfhttpparam(type="formfield", name="amount_to_capture", value=finalCaptureCents);
if (len(trim(qTab.StripeAccountID)) && val(qTab.StripeOnboardingComplete) == 1) {
cfhttpparam(type="formfield", name="application_fee_amount", value=applicationFeeCents);
}
cfhttpparam(type="formfield", name="metadata[type]", value="tab_close");
cfhttpparam(type="formfield", name="metadata[tab_id]", value=tabID);
cfhttpparam(type="formfield", name="metadata[tip_cents]", value=tipCents);
}
captureData = deserializeJSON(captureResp.fileContent);
if (structKeyExists(captureData, "status") && captureData.status == "succeeded") {
// Success - update tab
queryTimed("
UPDATE Tabs SET
StatusID = 3, ClosedOn = NOW(), CapturedOn = NOW(),
TipAmountCents = :tipCents, FinalCaptureCents = :captureCents,
RunningTotalCents = :runningTotal,
PaymentStatus = 'captured'
WHERE ID = :tabID
", {
tipCents: { value: tipCents, cfsqltype: "cf_sql_integer" },
captureCents: { value: finalCaptureCents, cfsqltype: "cf_sql_integer" },
runningTotal: { value: totalSubtotalCents + totalTaxCents, cfsqltype: "cf_sql_integer" },
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
// Mark all approved orders as paid
queryTimed("
UPDATE Orders o
JOIN TabOrders tbo ON tbo.OrderID = o.ID
SET o.PaymentStatus = 'paid', o.PaymentCompletedOn = NOW()
WHERE tbo.TabID = :tabID AND tbo.ApprovalStatus = 'approved'
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
// Mark all tab members as left
queryTimed("
UPDATE TabMembers SET StatusID = 3, LeftOn = NOW()
WHERE TabID = :tabID AND StatusID = 1
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
apiAbort({
"OK": true,
"FINAL_CAPTURE_CENTS": finalCaptureCents,
"TAB_UUID": qTab.UUID,
"FEE_BREAKDOWN": {
"SUBTOTAL_CENTS": totalSubtotalCents,
"TAX_CENTS": totalTaxCents,
"TIP_CENTS": tipCents,
"TIP_PERCENT": tipPct,
"PAYFRIT_FEE_CENTS": payfritFeeCents,
"CARD_FEE_CENTS": cardFeeCents,
"TOTAL_CENTS": finalCaptureCents
}
});
} else {
// Capture failed
errMsg = structKeyExists(captureData, "error") && structKeyExists(captureData.error, "message") ? captureData.error.message : "Capture failed";
queryTimed("
UPDATE Tabs SET PaymentStatus = 'capture_failed', PaymentError = :err
WHERE ID = :tabID
", {
err: { value: errMsg, cfsqltype: "cf_sql_varchar" },
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
apiAbort({ "OK": false, "ERROR": "capture_failed", "MESSAGE": errMsg });
}
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>