- 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>
204 lines
8.7 KiB
Text
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>
|