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>
This commit is contained in:
John Mizerek 2026-02-24 20:56:07 -08:00
parent 0e603b6cc9
commit 4c0479db5c
23 changed files with 1787 additions and 60 deletions

View file

@ -331,6 +331,10 @@ if (len(request._api_path)) {
// Beacon sharding endpoints
if (findNoCase("/api/beacon-sharding/get_shard_pool.cfm", request._api_path)) request._api_isPublic = true;
// Tab endpoints
if (findNoCase("/api/tabs/", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/presence/", request._api_path)) request._api_isPublic = true;
// Stripe endpoints
if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/stripe/status.cfm", request._api_path)) request._api_isPublic = true;

View file

@ -46,7 +46,13 @@ try {
BrandColor,
SessionEnabled,
SessionLockMinutes,
SessionPaymentStrategy
SessionPaymentStrategy,
TabMinAuthAmount,
TabDefaultAuthAmount,
TabMaxAuthAmount,
TabAutoIncreaseThreshold,
TabMaxMembers,
TabApprovalRequired
FROM Businesses
WHERE ID = :businessID
", { businessID: businessID }, { datasource: "payfrit" });
@ -146,7 +152,13 @@ try {
"BrandColor": len(q.BrandColor) ? (left(q.BrandColor, 1) == chr(35) ? q.BrandColor : chr(35) & q.BrandColor) : "",
"SessionEnabled": isNumeric(q.SessionEnabled) ? q.SessionEnabled : 0,
"SessionLockMinutes": isNumeric(q.SessionLockMinutes) ? q.SessionLockMinutes : 30,
"SessionPaymentStrategy": len(q.SessionPaymentStrategy) ? q.SessionPaymentStrategy : "A"
"SessionPaymentStrategy": len(q.SessionPaymentStrategy) ? q.SessionPaymentStrategy : "A",
"TabMinAuthAmount": isNumeric(q.TabMinAuthAmount) ? q.TabMinAuthAmount : 50.00,
"TabDefaultAuthAmount": isNumeric(q.TabDefaultAuthAmount) ? q.TabDefaultAuthAmount : 150.00,
"TabMaxAuthAmount": isNumeric(q.TabMaxAuthAmount) ? q.TabMaxAuthAmount : 1000.00,
"TabAutoIncreaseThreshold": isNumeric(q.TabAutoIncreaseThreshold) ? q.TabAutoIncreaseThreshold : 0.80,
"TabMaxMembers": isNumeric(q.TabMaxMembers) ? q.TabMaxMembers : 10,
"TabApprovalRequired": isNumeric(q.TabApprovalRequired) ? q.TabApprovalRequired : 1
};
// Add header image URL if extension exists

View file

@ -36,11 +36,27 @@ BusinessID = int(data.BusinessID);
SessionEnabled = structKeyExists(data, "SessionEnabled") && isNumeric(data.SessionEnabled) ? int(data.SessionEnabled) : 0;
SessionLockMinutes = structKeyExists(data, "SessionLockMinutes") && isNumeric(data.SessionLockMinutes) ? int(data.SessionLockMinutes) : 30;
SessionPaymentStrategy = structKeyExists(data, "SessionPaymentStrategy") ? left(trim(data.SessionPaymentStrategy), 1) : "A";
TabMinAuthAmount = structKeyExists(data, "TabMinAuthAmount") && isNumeric(data.TabMinAuthAmount) ? val(data.TabMinAuthAmount) : 50.00;
TabDefaultAuthAmount = structKeyExists(data, "TabDefaultAuthAmount") && isNumeric(data.TabDefaultAuthAmount) ? val(data.TabDefaultAuthAmount) : 150.00;
TabMaxAuthAmount = structKeyExists(data, "TabMaxAuthAmount") && isNumeric(data.TabMaxAuthAmount) ? val(data.TabMaxAuthAmount) : 1000.00;
TabAutoIncreaseThreshold = structKeyExists(data, "TabAutoIncreaseThreshold") && isNumeric(data.TabAutoIncreaseThreshold) ? val(data.TabAutoIncreaseThreshold) : 0.80;
TabMaxMembers = structKeyExists(data, "TabMaxMembers") && isNumeric(data.TabMaxMembers) ? int(data.TabMaxMembers) : 10;
TabApprovalRequired = structKeyExists(data, "TabApprovalRequired") && isNumeric(data.TabApprovalRequired) ? int(data.TabApprovalRequired) : 1;
// Validate
if (SessionLockMinutes < 5) SessionLockMinutes = 5;
if (SessionLockMinutes > 480) SessionLockMinutes = 480;
if (SessionPaymentStrategy != "A" && SessionPaymentStrategy != "P") SessionPaymentStrategy = "A";
if (TabMinAuthAmount < 10) TabMinAuthAmount = 10;
if (TabMinAuthAmount > 10000) TabMinAuthAmount = 10000;
if (TabDefaultAuthAmount < TabMinAuthAmount) TabDefaultAuthAmount = TabMinAuthAmount;
if (TabDefaultAuthAmount > TabMaxAuthAmount) TabDefaultAuthAmount = TabMaxAuthAmount;
if (TabMaxAuthAmount < TabMinAuthAmount) TabMaxAuthAmount = TabMinAuthAmount;
if (TabMaxAuthAmount > 10000) TabMaxAuthAmount = 10000;
if (TabAutoIncreaseThreshold < 0.5) TabAutoIncreaseThreshold = 0.5;
if (TabAutoIncreaseThreshold > 1.0) TabAutoIncreaseThreshold = 1.0;
if (TabMaxMembers < 1) TabMaxMembers = 1;
if (TabMaxMembers > 50) TabMaxMembers = 50;
</cfscript>
<cftry>
@ -48,7 +64,13 @@ if (SessionPaymentStrategy != "A" && SessionPaymentStrategy != "P") SessionPayme
UPDATE Businesses
SET SessionEnabled = <cfqueryparam cfsqltype="cf_sql_tinyint" value="#SessionEnabled#">,
SessionLockMinutes = <cfqueryparam cfsqltype="cf_sql_integer" value="#SessionLockMinutes#">,
SessionPaymentStrategy = <cfqueryparam cfsqltype="cf_sql_char" value="#SessionPaymentStrategy#">
SessionPaymentStrategy = <cfqueryparam cfsqltype="cf_sql_char" value="#SessionPaymentStrategy#">,
TabMinAuthAmount = <cfqueryparam cfsqltype="cf_sql_decimal" value="#TabMinAuthAmount#">,
TabDefaultAuthAmount = <cfqueryparam cfsqltype="cf_sql_decimal" value="#TabDefaultAuthAmount#">,
TabMaxAuthAmount = <cfqueryparam cfsqltype="cf_sql_decimal" value="#TabMaxAuthAmount#">,
TabAutoIncreaseThreshold = <cfqueryparam cfsqltype="cf_sql_decimal" value="#TabAutoIncreaseThreshold#">,
TabMaxMembers = <cfqueryparam cfsqltype="cf_sql_integer" value="#TabMaxMembers#">,
TabApprovalRequired = <cfqueryparam cfsqltype="cf_sql_tinyint" value="#TabApprovalRequired#">
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#BusinessID#">
</cfquery>
@ -58,7 +80,13 @@ if (SessionPaymentStrategy != "A" && SessionPaymentStrategy != "P") SessionPayme
"BusinessID" = BusinessID,
"SessionEnabled" = SessionEnabled,
"SessionLockMinutes" = SessionLockMinutes,
"SessionPaymentStrategy" = SessionPaymentStrategy
"SessionPaymentStrategy" = SessionPaymentStrategy,
"TabMinAuthAmount" = TabMinAuthAmount,
"TabDefaultAuthAmount" = TabDefaultAuthAmount,
"TabMaxAuthAmount" = TabMaxAuthAmount,
"TabAutoIncreaseThreshold" = TabAutoIncreaseThreshold,
"TabMaxMembers" = TabMaxMembers,
"TabApprovalRequired" = TabApprovalRequired
})#</cfoutput>
<cfcatch>

View file

@ -51,7 +51,8 @@
GrantID,
GrantOwnerBusinessID,
GrantEconomicsType,
GrantEconomicsValue
GrantEconomicsValue,
TabID
FROM Orders
WHERE ID = ?
LIMIT 1
@ -96,7 +97,8 @@
"GrantID": val(qOrder.GrantID),
"GrantOwnerBusinessID": val(qOrder.GrantOwnerBusinessID),
"GrantEconomicsType": qOrder.GrantEconomicsType ?: "",
"GrantEconomicsValue": val(qOrder.GrantEconomicsValue)
"GrantEconomicsValue": val(qOrder.GrantEconomicsValue),
"TabID": val(qOrder.TabID)
}>
<cfset var qLI = queryTimed(
@ -252,6 +254,29 @@
</cfif>
</cfif>
<!--- Check if user is on an active tab at this business --->
<cfset tabID = 0>
<cfset qUserTab = queryTimed(
"
SELECT t.ID
FROM TabMembers tm
JOIN Tabs t ON t.ID = tm.TabID
WHERE tm.UserID = ?
AND tm.StatusID = 1
AND t.BusinessID = ?
AND t.StatusID = 1
LIMIT 1
",
[
{ value = UserID, cfsqltype = "cf_sql_integer" },
{ value = BusinessID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
<cfif qUserTab.recordCount GT 0>
<cfset tabID = val(qUserTab.ID)>
</cfif>
<cfset nowDt = now()>
<cfset newUUID = createObject("java", "java.util.UUID").randomUUID().toString()>
@ -290,7 +315,8 @@
GrantID,
GrantOwnerBusinessID,
GrantEconomicsType,
GrantEconomicsValue
GrantEconomicsValue,
TabID
) VALUES (
?,
?,
@ -310,6 +336,7 @@
?,
?,
?,
?,
?
)
",
@ -327,7 +354,8 @@
{ value = grantID, cfsqltype = "cf_sql_integer", null = (grantID EQ 0) },
{ value = grantOwnerBusinessID, cfsqltype = "cf_sql_integer", null = (grantOwnerBusinessID EQ 0) },
{ value = grantEconomicsType, cfsqltype = "cf_sql_varchar", null = (len(grantEconomicsType) EQ 0) },
{ value = grantEconomicsValue, cfsqltype = "cf_sql_decimal", null = (grantEconomicsType EQ "" OR grantEconomicsType EQ "none") }
{ value = grantEconomicsValue, cfsqltype = "cf_sql_decimal", null = (grantEconomicsType EQ "" OR grantEconomicsType EQ "none") },
{ value = tabID, cfsqltype = "cf_sql_integer", null = (tabID EQ 0) }
],
{ datasource = "payfrit" }
)>

View file

@ -261,6 +261,39 @@
</cfif>
</cfloop>
<!--- Tab-aware submit: if order is on a tab and user is a member, check approval --->
<cfset tabID = 0>
<cfset qOrderTab = queryTimed(
"SELECT TabID FROM Orders WHERE ID = ? LIMIT 1",
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif val(qOrderTab.TabID) GT 0>
<cfset tabID = val(qOrderTab.TabID)>
<!--- Verify tab is open --->
<cfset qTabCheck = queryTimed(
"SELECT StatusID, OwnerUserID FROM Tabs WHERE ID = ? AND StatusID = 1 LIMIT 1",
[ { value = tabID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qTabCheck.recordCount EQ 0>
<cfset apiAbort({ "OK": false, "ERROR": "tab_not_open", "MESSAGE": "The tab associated with this order is no longer open." })>
</cfif>
<!--- If order user is NOT the tab owner, check approval status --->
<cfif qOrder.UserID NEQ qTabCheck.OwnerUserID>
<cfset qApproval = queryTimed(
"SELECT ApprovalStatus FROM TabOrders WHERE TabID = ? AND OrderID = ? LIMIT 1",
[ { value = tabID, cfsqltype = "cf_sql_integer" }, { value = OrderID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qApproval.recordCount EQ 0 OR qApproval.ApprovalStatus NEQ "approved">
<cfset apiAbort({ "OK": false, "ERROR": "not_approved", "MESSAGE": "This order needs tab owner approval before submitting." })>
</cfif>
</cfif>
</cfif>
<!--- Submit: mark submitted + status 1 --->
<cfset queryTimed(
"
@ -279,7 +312,28 @@
{ datasource = "payfrit" }
)>
<cfset apiAbort({ "OK": true, "ERROR": "", "OrderID": OrderID, "MESSAGE": "submitted" })>
<!--- If on a tab, update running total and last activity --->
<cfif tabID GT 0>
<cfset qOrderTotals = queryTimed(
"SELECT COALESCE(SUM(Price * Quantity), 0) AS Subtotal FROM OrderLineItems WHERE OrderID = ? AND IsDeleted = 0",
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset qBizTax = queryTimed(
"SELECT TaxRate FROM Businesses WHERE ID = ?",
[ { value = qOrder.BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset subtotalCents = round(val(qOrderTotals.Subtotal) * 100)>
<cfset taxCents = round(subtotalCents * val(qBizTax.TaxRate))>
<cfset queryTimed(
"UPDATE Tabs SET LastActivityOn = NOW() WHERE ID = ?",
[ { value = tabID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
</cfif>
<cfset apiAbort({ "OK": true, "ERROR": "", "OrderID": OrderID, "MESSAGE": "submitted", "TAB_ID": tabID })>
<cfcatch>
<cfset apiAbort({

View file

@ -0,0 +1,46 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Presence Heartbeat
* Records/updates that a user is at a specific business + service point.
* Called on beacon scan + periodic heartbeat from the app.
*
* POST: { UserID: int, BusinessID: int, ServicePointID: int (optional) }
*/
response = { "OK": false };
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
userID = val(requestData.UserID ?: 0);
businessID = val(requestData.BusinessID ?: 0);
servicePointID = val(requestData.ServicePointID ?: 0);
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
if (businessID == 0) apiAbort({ "OK": false, "ERROR": "missing_BusinessID" });
// Upsert presence (UNIQUE on UserID means one row per user)
queryTimed("
INSERT INTO UserPresence (UserID, BusinessID, ServicePointID, LastSeenOn)
VALUES (:userID, :businessID, :spID, NOW())
ON DUPLICATE KEY UPDATE
BusinessID = VALUES(BusinessID),
ServicePointID = VALUES(ServicePointID),
LastSeenOn = NOW()
", {
userID: { value: userID, cfsqltype: "cf_sql_integer" },
businessID: { value: businessID, cfsqltype: "cf_sql_integer" },
spID: { value: servicePointID > 0 ? servicePointID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: servicePointID <= 0 }
});
apiAbort({ "OK": true });
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

View file

@ -74,6 +74,41 @@ try {
// Payment was successful
paymentIntentID = eventData.id;
orderID = val(eventData.metadata.order_id ?: 0);
metaType = eventData.metadata.type ?: "";
// === TAB CLOSE CAPTURE ===
if (metaType == "tab_close") {
tabID = val(eventData.metadata.tab_id ?: 0);
if (tabID > 0) {
// Mark tab as closed
queryTimed("
UPDATE Tabs
SET StatusID = 3,
PaymentStatus = 'captured',
CapturedOn = NOW(),
ClosedOn = NOW()
WHERE ID = :tabID AND StatusID = 2
", { tabID: tabID });
// Mark all approved tab orders as paid
qTabOrders = queryTimed("
SELECT OrderID FROM TabOrders
WHERE TabID = :tabID AND ApprovalStatus = 'approved'
", { tabID: tabID });
for (row in qTabOrders) {
queryTimed("
UPDATE Orders
SET PaymentStatus = 'paid',
PaymentCompletedOn = NOW()
WHERE ID = :orderID
", { orderID: row.OrderID });
}
writeLog(file="stripe_webhooks", text="Tab #tabID# capture confirmed via PI #paymentIntentID#");
}
break;
}
if (orderID > 0) {
// Update order status to paid/submitted (status 1)

83
api/tabs/addMember.cfm Normal file
View file

@ -0,0 +1,83 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Add Member to Tab
* Tab owner adds a user. Validates target user not already on a tab.
*
* POST: { TabID: int, OwnerUserID: int, TargetUserID: int }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
ownerUserID = val(requestData.OwnerUserID ?: 0);
targetUserID = val(requestData.TargetUserID ?: 0);
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (ownerUserID == 0) apiAbort({ "OK": false, "ERROR": "missing_OwnerUserID" });
if (targetUserID == 0) apiAbort({ "OK": false, "ERROR": "missing_TargetUserID" });
// Verify tab exists, is open, and requester is owner
qTab = queryTimed("
SELECT t.ID, t.OwnerUserID, t.StatusID, t.BusinessID, b.TabMaxMembers
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" });
if (qTab.OwnerUserID != ownerUserID) apiAbort({ "OK": false, "ERROR": "not_owner" });
// Check member limit
qCount = queryTimed("SELECT COUNT(*) AS Cnt FROM TabMembers WHERE TabID = :tabID AND StatusID = 1", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
if (qCount.Cnt >= val(qTab.TabMaxMembers)) {
apiAbort({ "OK": false, "ERROR": "max_members", "MESSAGE": "Tab has reached the maximum number of members." });
}
// Check target user not already on any tab
qExisting = queryTimed("
SELECT t.ID, b.Name AS BusinessName
FROM TabMembers tm JOIN Tabs t ON t.ID = tm.TabID JOIN Businesses b ON b.ID = t.BusinessID
WHERE tm.UserID = :uid AND tm.StatusID = 1 AND t.StatusID = 1 LIMIT 1
", { uid: { value: targetUserID, cfsqltype: "cf_sql_integer" } });
if (qExisting.recordCount > 0) {
apiAbort({ "OK": false, "ERROR": "user_already_on_tab", "MESSAGE": "This user is already on a tab." });
}
// Check target user exists
qTarget = queryTimed("SELECT FirstName, LastName FROM Users WHERE ID = :uid LIMIT 1", {
uid: { value: targetUserID, cfsqltype: "cf_sql_integer" }
});
if (qTarget.recordCount == 0) apiAbort({ "OK": false, "ERROR": "user_not_found" });
// Add member
queryTimed("
INSERT INTO TabMembers (TabID, UserID, RoleID, StatusID, JoinedOn)
VALUES (:tabID, :userID, 2, 1, NOW())
ON DUPLICATE KEY UPDATE StatusID = 1, LeftOn = NULL, JoinedOn = NOW()
", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
userID: { value: targetUserID, cfsqltype: "cf_sql_integer" }
});
apiAbort({
"OK": true,
"MEMBER": {
"UserID": targetUserID,
"FirstName": qTarget.FirstName,
"LastName": qTarget.LastName,
"RoleID": 2
}
});
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

147
api/tabs/addOrder.cfm Normal file
View file

@ -0,0 +1,147 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Add Order to Tab
* Links an order to the tab. Auto-approves for owner, pending for members.
*
* POST: { TabID: int, OrderID: int, UserID: int }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
orderID = val(requestData.OrderID ?: 0);
userID = val(requestData.UserID ?: 0);
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (orderID == 0) apiAbort({ "OK": false, "ERROR": "missing_OrderID" });
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
// Get tab
qTab = queryTimed("
SELECT t.ID, t.StatusID, t.BusinessID, t.OwnerUserID, t.AuthAmountCents, t.RunningTotalCents,
b.TabApprovalRequired, b.TabAutoIncreaseThreshold
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" });
// Verify user is a member
qMember = queryTimed("
SELECT RoleID FROM TabMembers
WHERE TabID = :tabID AND UserID = :userID AND StatusID = 1 LIMIT 1
", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" }
});
if (qMember.recordCount == 0) apiAbort({ "OK": false, "ERROR": "not_a_member" });
isOwner = qMember.RoleID == 1;
// Verify order belongs to same business and is in cart state
qOrder = queryTimed("
SELECT ID, BusinessID, StatusID, UserID FROM Orders
WHERE ID = :orderID LIMIT 1
", { orderID: { value: orderID, cfsqltype: "cf_sql_integer" } });
if (qOrder.recordCount == 0) apiAbort({ "OK": false, "ERROR": "order_not_found" });
if (qOrder.BusinessID != qTab.BusinessID) apiAbort({ "OK": false, "ERROR": "wrong_business" });
if (qOrder.StatusID != 0) apiAbort({ "OK": false, "ERROR": "order_not_in_cart", "MESSAGE": "Order must be in cart state." });
// Calculate order subtotal + tax
qTotals = queryTimed("
SELECT COALESCE(SUM(oli.Price * oli.Quantity), 0) AS Subtotal
FROM OrderLineItems oli
WHERE oli.OrderID = :orderID AND oli.IsDeleted = 0
", { orderID: { value: orderID, cfsqltype: "cf_sql_integer" } });
subtotal = val(qTotals.Subtotal);
subtotalCents = round(subtotal * 100);
// Get tax rate from business
qBizTax = queryTimed("SELECT TaxRate FROM Businesses WHERE ID = :bizID", {
bizID: { value: qTab.BusinessID, cfsqltype: "cf_sql_integer" }
});
taxRate = val(qBizTax.TaxRate);
taxCents = round(subtotalCents * taxRate);
// Determine approval status
approvalStatus = "approved";
if (!isOwner && qTab.TabApprovalRequired == 1) {
approvalStatus = "pending";
}
// Check if adding this would exceed authorization (only for auto-approved orders)
newRunning = qTab.RunningTotalCents;
if (approvalStatus == "approved") {
newRunning = qTab.RunningTotalCents + subtotalCents + taxCents;
if (newRunning > qTab.AuthAmountCents) {
apiAbort({
"OK": false, "ERROR": "exceeds_authorization",
"MESSAGE": "This order would exceed your tab authorization. Please increase your authorization first.",
"RUNNING_TOTAL_CENTS": qTab.RunningTotalCents,
"ORDER_CENTS": subtotalCents + taxCents,
"AUTH_AMOUNT_CENTS": qTab.AuthAmountCents
});
}
}
// Link order to tab
queryTimed("UPDATE Orders SET TabID = :tabID WHERE ID = :orderID", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID, cfsqltype: "cf_sql_integer" }
});
// Insert into TabOrders
queryTimed("
INSERT INTO TabOrders (TabID, OrderID, UserID, ApprovalStatus, SubtotalCents, TaxCents, AddedOn)
VALUES (:tabID, :orderID, :userID, :status, :subtotalCents, :taxCents, NOW())
ON DUPLICATE KEY UPDATE ApprovalStatus = VALUES(ApprovalStatus), SubtotalCents = VALUES(SubtotalCents), TaxCents = VALUES(TaxCents)
", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" },
status: { value: approvalStatus, cfsqltype: "cf_sql_varchar" },
subtotalCents: { value: subtotalCents, cfsqltype: "cf_sql_integer" },
taxCents: { value: taxCents, cfsqltype: "cf_sql_integer" }
});
// If auto-approved, update running total
if (approvalStatus == "approved") {
queryTimed("
UPDATE Tabs SET RunningTotalCents = :newRunning, LastActivityOn = NOW()
WHERE ID = :tabID
", {
newRunning: { value: newRunning, cfsqltype: "cf_sql_integer" },
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
}
// Check if auto-increase threshold reached
needsIncrease = false;
threshold = val(qTab.TabAutoIncreaseThreshold);
if (threshold > 0 && qTab.AuthAmountCents > 0) {
needsIncrease = (newRunning / qTab.AuthAmountCents) >= threshold;
}
apiAbort({
"OK": true,
"APPROVAL_STATUS": approvalStatus,
"RUNNING_TOTAL_CENTS": newRunning,
"AUTH_REMAINING_CENTS": qTab.AuthAmountCents - newRunning,
"NEEDS_INCREASE": needsIncrease,
"ORDER_SUBTOTAL_CENTS": subtotalCents,
"ORDER_TAX_CENTS": taxCents
});
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

98
api/tabs/approveOrder.cfm Normal file
View file

@ -0,0 +1,98 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Approve Tab Order
* Tab owner approves a pending member order. Order is then submitted to kitchen.
*
* POST: { TabID: int, OrderID: int, UserID: int (tab owner) }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
orderID = val(requestData.OrderID ?: 0);
userID = val(requestData.UserID ?: 0);
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (orderID == 0) apiAbort({ "OK": false, "ERROR": "missing_OrderID" });
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
// Verify tab owner
qTab = queryTimed("
SELECT ID, OwnerUserID, StatusID, AuthAmountCents, RunningTotalCents
FROM Tabs WHERE 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" });
if (qTab.OwnerUserID != userID) apiAbort({ "OK": false, "ERROR": "not_owner" });
// Get the pending order
qTabOrder = queryTimed("
SELECT ID, SubtotalCents, TaxCents, ApprovalStatus
FROM TabOrders WHERE TabID = :tabID AND OrderID = :orderID LIMIT 1
", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID, cfsqltype: "cf_sql_integer" }
});
if (qTabOrder.recordCount == 0) apiAbort({ "OK": false, "ERROR": "order_not_on_tab" });
if (qTabOrder.ApprovalStatus != "pending") apiAbort({ "OK": false, "ERROR": "not_pending", "MESSAGE": "Order is #qTabOrder.ApprovalStatus#, not pending." });
// Check authorization limit
orderTotal = qTabOrder.SubtotalCents + qTabOrder.TaxCents;
newRunning = qTab.RunningTotalCents + orderTotal;
if (newRunning > qTab.AuthAmountCents) {
apiAbort({
"OK": false, "ERROR": "exceeds_authorization",
"MESSAGE": "Approving this order would exceed your tab authorization. Increase your authorization first.",
"RUNNING_TOTAL_CENTS": qTab.RunningTotalCents,
"ORDER_CENTS": orderTotal,
"AUTH_AMOUNT_CENTS": qTab.AuthAmountCents
});
}
// Approve
queryTimed("
UPDATE TabOrders SET ApprovalStatus = 'approved', ApprovedByUserID = :approverID, ApprovedOn = NOW()
WHERE TabID = :tabID AND OrderID = :orderID
", {
approverID: { value: userID, cfsqltype: "cf_sql_integer" },
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID, cfsqltype: "cf_sql_integer" }
});
// Update running total
queryTimed("
UPDATE Tabs SET RunningTotalCents = :newRunning, LastActivityOn = NOW()
WHERE ID = :tabID
", {
newRunning: { value: newRunning, cfsqltype: "cf_sql_integer" },
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
// Auto-submit order to kitchen (StatusID 0 → 1)
qOrder = queryTimed("SELECT StatusID FROM Orders WHERE ID = :orderID LIMIT 1", {
orderID: { value: orderID, cfsqltype: "cf_sql_integer" }
});
if (qOrder.StatusID == 0) {
queryTimed("
UPDATE Orders SET StatusID = 1, SubmittedOn = NOW(), LastEditedOn = NOW()
WHERE ID = :orderID
", { orderID: { value: orderID, cfsqltype: "cf_sql_integer" } });
}
apiAbort({
"OK": true,
"RUNNING_TOTAL_CENTS": newRunning,
"AUTH_REMAINING_CENTS": qTab.AuthAmountCents - newRunning
});
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

63
api/tabs/cancel.cfm Normal file
View file

@ -0,0 +1,63 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Cancel Tab
* Only allowed if no orders have been submitted to kitchen (StatusID >= 1).
* Releases the Stripe hold.
*
* POST: { TabID: int, UserID: int }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
userID = val(requestData.UserID ?: 0);
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
qTab = queryTimed("
SELECT ID, OwnerUserID, StatusID, StripePaymentIntentID, BusinessID
FROM Tabs WHERE 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" });
if (qTab.OwnerUserID != userID) apiAbort({ "OK": false, "ERROR": "not_owner" });
// Check for submitted orders
qSubmitted = queryTimed("
SELECT COUNT(*) AS Cnt FROM TabOrders tbo
JOIN Orders o ON o.ID = tbo.OrderID
WHERE tbo.TabID = :tabID AND tbo.ApprovalStatus = 'approved' AND o.StatusID >= 1
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
if (qSubmitted.Cnt > 0) {
apiAbort({ "OK": false, "ERROR": "has_submitted_orders", "MESSAGE": "Tab has orders in progress. Close the tab instead of cancelling." });
}
// Cancel Stripe PI
if (len(trim(qTab.StripePaymentIntentID))) {
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#");
}
}
// Mark tab cancelled, release members
queryTimed("UPDATE Tabs SET StatusID = 4, ClosedOn = NOW(), PaymentStatus = 'cancelled' WHERE ID = :tabID", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
queryTimed("UPDATE TabMembers SET StatusID = 3, LeftOn = NOW() WHERE TabID = :tabID AND StatusID = 1", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
apiAbort({ "OK": true, "MESSAGE": "Tab cancelled. Card hold released." });
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

204
api/tabs/close.cfm Normal file
View file

@ -0,0 +1,204 @@
<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>

132
api/tabs/get.cfm Normal file
View file

@ -0,0 +1,132 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Get Tab Details
* Returns full tab info with orders and members.
*
* POST: { TabID: int, UserID: int }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
userID = val(requestData.UserID ?: 0);
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
// Verify user is a member of this tab
qMember = queryTimed("
SELECT RoleID FROM TabMembers
WHERE TabID = :tabID AND UserID = :userID AND StatusID = 1
LIMIT 1
", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" }
});
if (qMember.recordCount == 0) apiAbort({ "OK": false, "ERROR": "not_a_member" });
isOwner = qMember.RoleID == 1;
// Get tab
qTab = queryTimed("
SELECT t.*, b.Name AS BusinessName, b.PayfritFee, b.TaxRate,
sp.Name AS ServicePointName,
u.FirstName AS OwnerFirstName, u.LastName AS OwnerLastName
FROM Tabs t
JOIN Businesses b ON b.ID = t.BusinessID
LEFT JOIN ServicePoints sp ON sp.ID = t.ServicePointID
JOIN Users u ON u.ID = t.OwnerUserID
WHERE t.ID = :tabID LIMIT 1
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
if (qTab.recordCount == 0) apiAbort({ "OK": false, "ERROR": "tab_not_found" });
// Get members
qMembers = queryTimed("
SELECT tm.ID, tm.UserID, tm.RoleID, tm.StatusID, tm.JoinedOn,
u.FirstName, u.LastName, u.ImageExtension
FROM TabMembers tm
JOIN Users u ON u.ID = tm.UserID
WHERE tm.TabID = :tabID AND tm.StatusID = 1
ORDER BY tm.RoleID, tm.JoinedOn
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
members = [];
for (row in qMembers) {
arrayAppend(members, {
"UserID": row.UserID,
"FirstName": row.FirstName,
"LastName": row.LastName,
"RoleID": row.RoleID,
"IsOwner": row.RoleID == 1,
"ImageExtension": row.ImageExtension ?: "",
"JoinedOn": toISO8601(row.JoinedOn)
});
}
// Get orders on this tab
qOrders = queryTimed("
SELECT tbo.OrderID, tbo.UserID, tbo.ApprovalStatus, tbo.SubtotalCents, tbo.TaxCents, tbo.AddedOn,
o.StatusID AS OrderStatusID, o.UUID AS OrderUUID,
u.FirstName, u.LastName
FROM TabOrders tbo
JOIN Orders o ON o.ID = tbo.OrderID
JOIN Users u ON u.ID = tbo.UserID
WHERE tbo.TabID = :tabID
ORDER BY tbo.AddedOn DESC
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
orders = [];
for (row in qOrders) {
// If member (not owner), only show own orders
if (!isOwner && row.UserID != userID) continue;
arrayAppend(orders, {
"OrderID": row.OrderID,
"OrderUUID": row.OrderUUID,
"UserID": row.UserID,
"UserName": "#row.FirstName# #row.LastName#",
"ApprovalStatus": row.ApprovalStatus,
"SubtotalCents": row.SubtotalCents,
"TaxCents": row.TaxCents,
"OrderStatusID": row.OrderStatusID,
"AddedOn": toISO8601(row.AddedOn)
});
}
apiAbort({
"OK": true,
"TAB": {
"ID": qTab.ID,
"UUID": qTab.UUID,
"BusinessID": qTab.BusinessID,
"BusinessName": qTab.BusinessName,
"OwnerUserID": qTab.OwnerUserID,
"OwnerName": "#qTab.OwnerFirstName# #qTab.OwnerLastName#",
"ServicePointID": val(qTab.ServicePointID),
"ServicePointName": qTab.ServicePointName ?: "",
"StatusID": qTab.StatusID,
"AuthAmountCents": qTab.AuthAmountCents,
"RunningTotalCents": qTab.RunningTotalCents,
"RemainingCents": qTab.AuthAmountCents - qTab.RunningTotalCents,
"TipAmountCents": val(qTab.TipAmountCents),
"FinalCaptureCents": val(qTab.FinalCaptureCents),
"PaymentStatus": qTab.PaymentStatus,
"OpenedOn": toISO8601(qTab.OpenedOn),
"ClosedOn": isDate(qTab.ClosedOn) ? toISO8601(qTab.ClosedOn) : "",
"IsOwner": isOwner,
"PayfritFee": val(qTab.PayfritFee),
"TaxRate": val(qTab.TaxRate)
},
"MEMBERS": members,
"ORDERS": orders
});
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

83
api/tabs/getActive.cfm Normal file
View file

@ -0,0 +1,83 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Get Active Tab
* Checks if user has/is on an active tab (globally).
*
* POST: { UserID: int }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
userID = val(requestData.UserID ?: 0);
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
qTab = queryTimed("
SELECT t.ID, t.UUID, t.BusinessID, t.OwnerUserID, t.ServicePointID,
t.StatusID, t.AuthAmountCents, t.RunningTotalCents,
t.OpenedOn, t.LastActivityOn, t.PaymentStatus,
b.Name AS BusinessName, b.TabApprovalRequired,
tm.RoleID,
sp.Name AS ServicePointName,
u.FirstName AS OwnerFirstName, u.LastName AS OwnerLastName
FROM TabMembers tm
JOIN Tabs t ON t.ID = tm.TabID
JOIN Businesses b ON b.ID = t.BusinessID
LEFT JOIN ServicePoints sp ON sp.ID = t.ServicePointID
JOIN Users u ON u.ID = t.OwnerUserID
WHERE tm.UserID = :userID AND tm.StatusID = 1 AND t.StatusID = 1
LIMIT 1
", { userID: { value: userID, cfsqltype: "cf_sql_integer" } });
if (qTab.recordCount == 0) {
apiAbort({ "OK": true, "HAS_TAB": false });
}
// Get member count
qMembers = queryTimed("
SELECT COUNT(*) AS MemberCount FROM TabMembers
WHERE TabID = :tabID AND StatusID = 1
", { tabID: { value: qTab.ID, cfsqltype: "cf_sql_integer" } });
// Get pending order count (for tab owner)
pendingCount = 0;
if (qTab.RoleID == 1) {
qPending = queryTimed("
SELECT COUNT(*) AS PendingCount FROM TabOrders
WHERE TabID = :tabID AND ApprovalStatus = 'pending'
", { tabID: { value: qTab.ID, cfsqltype: "cf_sql_integer" } });
pendingCount = qPending.PendingCount;
}
apiAbort({
"OK": true,
"HAS_TAB": true,
"TAB": {
"ID": qTab.ID,
"UUID": qTab.UUID,
"BusinessID": qTab.BusinessID,
"BusinessName": qTab.BusinessName,
"OwnerUserID": qTab.OwnerUserID,
"OwnerName": "#qTab.OwnerFirstName# #qTab.OwnerLastName#",
"ServicePointID": val(qTab.ServicePointID),
"ServicePointName": qTab.ServicePointName ?: "",
"StatusID": qTab.StatusID,
"AuthAmountCents": qTab.AuthAmountCents,
"RunningTotalCents": qTab.RunningTotalCents,
"RemainingCents": qTab.AuthAmountCents - qTab.RunningTotalCents,
"OpenedOn": toISO8601(qTab.OpenedOn),
"MemberCount": qMembers.MemberCount,
"PendingOrderCount": pendingCount,
"IsOwner": qTab.RoleID == 1,
"ApprovalRequired": qTab.TabApprovalRequired == 1
}
});
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

88
api/tabs/getPresence.cfm Normal file
View file

@ -0,0 +1,88 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Get Presence at Service Point
* Returns users currently at the same business/service point.
* Used by tab owner to see who they can add.
*
* POST: { BusinessID: int, ServicePointID: int (optional), UserID: int (requesting user, excluded from results) }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
businessID = val(requestData.BusinessID ?: 0);
servicePointID = val(requestData.ServicePointID ?: 0);
userID = val(requestData.UserID ?: 0);
if (businessID == 0) apiAbort({ "OK": false, "ERROR": "missing_BusinessID" });
// Get users present at this business within last 30 minutes
// If servicePointID specified, filter to that SP; otherwise show all at the business
if (servicePointID > 0) {
qPresence = queryTimed("
SELECT up.UserID, up.ServicePointID, up.LastSeenOn,
u.FirstName, u.LastName, u.ImageExtension,
sp.Name AS ServicePointName
FROM UserPresence up
JOIN Users u ON u.ID = up.UserID
LEFT JOIN ServicePoints sp ON sp.ID = up.ServicePointID
WHERE up.BusinessID = :bizID
AND up.ServicePointID = :spID
AND up.LastSeenOn >= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
AND up.UserID != :excludeUserID
ORDER BY up.LastSeenOn DESC
", {
bizID: { value: businessID, cfsqltype: "cf_sql_integer" },
spID: { value: servicePointID, cfsqltype: "cf_sql_integer" },
excludeUserID: { value: userID, cfsqltype: "cf_sql_integer" }
});
} else {
qPresence = queryTimed("
SELECT up.UserID, up.ServicePointID, up.LastSeenOn,
u.FirstName, u.LastName, u.ImageExtension,
sp.Name AS ServicePointName
FROM UserPresence up
JOIN Users u ON u.ID = up.UserID
LEFT JOIN ServicePoints sp ON sp.ID = up.ServicePointID
WHERE up.BusinessID = :bizID
AND up.LastSeenOn >= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
AND up.UserID != :excludeUserID
ORDER BY up.LastSeenOn DESC
", {
bizID: { value: businessID, cfsqltype: "cf_sql_integer" },
excludeUserID: { value: userID, cfsqltype: "cf_sql_integer" }
});
}
users = [];
for (row in qPresence) {
// Check if user is already on a tab
qOnTab = queryTimed("
SELECT t.ID AS TabID FROM TabMembers tm
JOIN Tabs t ON t.ID = tm.TabID
WHERE tm.UserID = :uid AND tm.StatusID = 1 AND t.StatusID = 1
LIMIT 1
", { uid: { value: row.UserID, cfsqltype: "cf_sql_integer" } });
arrayAppend(users, {
"UserID": row.UserID,
"FirstName": row.FirstName,
"LastName": row.LastName,
"ImageExtension": row.ImageExtension ?: "",
"ServicePointID": val(row.ServicePointID),
"ServicePointName": row.ServicePointName ?: "",
"LastSeenOn": toISO8601(row.LastSeenOn),
"IsOnTab": qOnTab.recordCount > 0
});
}
apiAbort({ "OK": true, "USERS": users });
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

74
api/tabs/increaseAuth.cfm Normal file
View file

@ -0,0 +1,74 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Increase Tab Authorization
* Updates the Stripe PaymentIntent amount for a higher hold.
*
* POST: { TabID: int, UserID: int, NewAuthAmount: number (dollars) }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
userID = val(requestData.UserID ?: 0);
newAuthAmount = val(requestData.NewAuthAmount ?: 0);
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
if (newAuthAmount <= 0) apiAbort({ "OK": false, "ERROR": "missing_NewAuthAmount" });
qTab = queryTimed("
SELECT t.ID, t.OwnerUserID, t.StatusID, t.AuthAmountCents, t.StripePaymentIntentID,
b.TabMaxAuthAmount
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" });
if (qTab.OwnerUserID != userID) apiAbort({ "OK": false, "ERROR": "not_owner" });
newAuthCents = round(newAuthAmount * 100);
maxCents = round(val(qTab.TabMaxAuthAmount) * 100);
if (newAuthCents <= qTab.AuthAmountCents) apiAbort({ "OK": false, "ERROR": "not_an_increase", "MESSAGE": "New amount must be higher than current authorization." });
if (maxCents > 0 && newAuthCents > maxCents) apiAbort({ "OK": false, "ERROR": "exceeds_max", "MESSAGE": "Maximum authorization is $#numberFormat(qTab.TabMaxAuthAmount, '0.00')#." });
// Update Stripe PI amount
cfhttp(method="POST", url="https://api.stripe.com/v1/payment_intents/#qTab.StripePaymentIntentID#", result="updateResp") {
cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#");
cfhttpparam(type="formfield", name="amount", value=newAuthCents);
}
updateData = deserializeJSON(updateResp.fileContent);
if (structKeyExists(updateData, "id") && structKeyExists(updateData, "amount") && updateData.amount == newAuthCents) {
// Success
queryTimed("UPDATE Tabs SET AuthAmountCents = :newAuth WHERE ID = :tabID", {
newAuth: { value: newAuthCents, cfsqltype: "cf_sql_integer" },
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
apiAbort({
"OK": true,
"AUTH_AMOUNT_CENTS": newAuthCents,
"PREVIOUS_AUTH_CENTS": qTab.AuthAmountCents
});
} else {
// Increase declined
errMsg = structKeyExists(updateData, "error") && structKeyExists(updateData.error, "message") ? updateData.error.message : "Authorization increase declined";
apiAbort({
"OK": false, "ERROR": "increase_declined", "MESSAGE": errMsg,
"CURRENT_AUTH_CENTS": qTab.AuthAmountCents
});
}
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

183
api/tabs/open.cfm Normal file
View file

@ -0,0 +1,183 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Open a Tab
*
* Creates a Stripe PaymentIntent with capture_method=manual (card hold).
* Returns client_secret for the Android PaymentSheet to confirm the hold.
*
* POST: {
* UserID: int,
* BusinessID: int,
* AuthAmount: number (dollars, customer-chosen),
* ServicePointID: int (optional)
* }
*/
response = { "OK": false };
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
userID = val(requestData.UserID ?: 0);
businessID = val(requestData.BusinessID ?: 0);
authAmount = val(requestData.AuthAmount ?: 0);
servicePointID = val(requestData.ServicePointID ?: 0);
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
if (businessID == 0) apiAbort({ "OK": false, "ERROR": "missing_BusinessID" });
// Get business config
qBiz = queryTimed("
SELECT SessionEnabled, TabMinAuthAmount, TabDefaultAuthAmount, TabMaxAuthAmount,
StripeAccountID, StripeOnboardingComplete
FROM Businesses WHERE ID = :bizID LIMIT 1
", { bizID: { value: businessID, cfsqltype: "cf_sql_integer" } });
if (qBiz.recordCount == 0) apiAbort({ "OK": false, "ERROR": "business_not_found" });
if (!qBiz.SessionEnabled) apiAbort({ "OK": false, "ERROR": "tabs_not_enabled", "MESSAGE": "This business does not accept tabs." });
// Validate auth amount
minAuth = val(qBiz.TabMinAuthAmount);
maxAuth = val(qBiz.TabMaxAuthAmount);
if (authAmount <= 0) authAmount = val(qBiz.TabDefaultAuthAmount);
if (authAmount < minAuth) apiAbort({ "OK": false, "ERROR": "auth_too_low", "MESSAGE": "Minimum authorization is $#numberFormat(minAuth, '0.00')#", "MIN": minAuth });
if (authAmount > maxAuth) apiAbort({ "OK": false, "ERROR": "auth_too_high", "MESSAGE": "Maximum authorization is $#numberFormat(maxAuth, '0.00')#", "MAX": maxAuth });
// Check user not already on a tab (globally)
qExisting = queryTimed("
SELECT t.ID, t.BusinessID, b.Name AS BusinessName
FROM TabMembers tm
JOIN Tabs t ON t.ID = tm.TabID
JOIN Businesses b ON b.ID = t.BusinessID
WHERE tm.UserID = :userID AND tm.StatusID = 1 AND t.StatusID = 1
LIMIT 1
", { userID: { value: userID, cfsqltype: "cf_sql_integer" } });
if (qExisting.recordCount > 0) {
apiAbort({
"OK": false, "ERROR": "already_on_tab",
"MESSAGE": "You're already on a tab at #qExisting.BusinessName#.",
"EXISTING_TAB_ID": qExisting.ID,
"EXISTING_BUSINESS_NAME": qExisting.BusinessName
});
}
// Get or create Stripe Customer
qUser = queryTimed("
SELECT StripeCustomerId, EmailAddress, FirstName, LastName
FROM Users WHERE ID = :userID LIMIT 1
", { userID: { value: userID, cfsqltype: "cf_sql_integer" } });
if (qUser.recordCount == 0) apiAbort({ "OK": false, "ERROR": "user_not_found" });
stripeCustomerId = qUser.StripeCustomerId;
if (!len(trim(stripeCustomerId))) {
// Create Stripe Customer
cfhttp(method="POST", url="https://api.stripe.com/v1/customers", result="custResp") {
cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#");
cfhttpparam(type="formfield", name="name", value="#qUser.FirstName# #qUser.LastName#");
if (len(trim(qUser.EmailAddress))) {
cfhttpparam(type="formfield", name="email", value=qUser.EmailAddress);
}
cfhttpparam(type="formfield", name="metadata[payfrit_user_id]", value=userID);
}
custData = deserializeJSON(custResp.fileContent);
if (structKeyExists(custData, "id")) {
stripeCustomerId = custData.id;
queryTimed("UPDATE Users SET StripeCustomerId = :cid WHERE ID = :uid", {
cid: { value: stripeCustomerId, cfsqltype: "cf_sql_varchar" },
uid: { value: userID, cfsqltype: "cf_sql_integer" }
});
} else {
apiAbort({ "OK": false, "ERROR": "stripe_customer_failed", "MESSAGE": "Could not create Stripe customer." });
}
}
// Generate tab UUID
tabUUID = createObject("java", "java.util.UUID").randomUUID().toString();
authAmountCents = round(authAmount * 100);
// Create PaymentIntent with manual capture
piParams = {
"amount": authAmountCents,
"currency": "usd",
"capture_method": "manual",
"customer": stripeCustomerId,
"setup_future_usage": "off_session",
"automatic_payment_methods[enabled]": "true",
"metadata[type]": "tab_authorization",
"metadata[tab_uuid]": tabUUID,
"metadata[business_id]": businessID,
"metadata[user_id]": userID
};
// Stripe Connect: if business has a connected account, set transfer_data
if (len(trim(qBiz.StripeAccountID)) && val(qBiz.StripeOnboardingComplete) == 1) {
piParams["transfer_data[destination]"] = qBiz.StripeAccountID;
// application_fee_amount set at capture time, not authorization
}
// Idempotency key to prevent duplicate tab creation on retry
idempotencyKey = "tab-open-#userID#-#businessID#-#dateFormat(now(), 'yyyymmdd')#-#timeFormat(now(), 'HHmmss')#";
cfhttp(method="POST", url="https://api.stripe.com/v1/payment_intents", result="piResp") {
cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#");
cfhttpparam(type="header", name="Idempotency-Key", value=idempotencyKey);
for (key in piParams) {
cfhttpparam(type="formfield", name=key, value=piParams[key]);
}
}
piData = deserializeJSON(piResp.fileContent);
if (!structKeyExists(piData, "id")) {
errMsg = structKeyExists(piData, "error") && structKeyExists(piData.error, "message") ? piData.error.message : "Stripe error";
apiAbort({ "OK": false, "ERROR": "stripe_pi_failed", "MESSAGE": errMsg });
}
// Insert tab
queryTimed("
INSERT INTO Tabs (UUID, BusinessID, OwnerUserID, ServicePointID, StatusID,
AuthAmountCents, StripePaymentIntentID, StripeCustomerID, OpenedOn, LastActivityOn)
VALUES (:uuid, :bizID, :userID, :spID, 1, :authCents, :piID, :custID, NOW(), NOW())
", {
uuid: { value: tabUUID, cfsqltype: "cf_sql_varchar" },
bizID: { value: businessID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" },
spID: { value: servicePointID > 0 ? servicePointID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: servicePointID <= 0 },
authCents: { value: authAmountCents, cfsqltype: "cf_sql_integer" },
piID: { value: piData.id, cfsqltype: "cf_sql_varchar" },
custID: { value: stripeCustomerId, cfsqltype: "cf_sql_varchar" }
});
// Get the tab ID
qTab = queryTimed("SELECT LAST_INSERT_ID() AS TabID");
tabID = qTab.TabID;
// Add owner as TabMember with RoleID=1
queryTimed("
INSERT INTO TabMembers (TabID, UserID, RoleID, StatusID, JoinedOn)
VALUES (:tabID, :userID, 1, 1, NOW())
", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
userID: { value: userID, cfsqltype: "cf_sql_integer" }
});
apiAbort({
"OK": true,
"TAB_ID": tabID,
"TAB_UUID": tabUUID,
"CLIENT_SECRET": piData.client_secret,
"PAYMENT_INTENT_ID": piData.id,
"AUTH_AMOUNT_CENTS": authAmountCents,
"PUBLISHABLE_KEY": application.stripePublishableKey
});
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

View file

@ -0,0 +1,74 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Get Pending Tab Orders
* Returns orders awaiting tab owner approval, with line item details.
*
* POST: { TabID: int, UserID: int (tab owner) }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
userID = val(requestData.UserID ?: 0);
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
qTab = queryTimed("SELECT OwnerUserID FROM Tabs WHERE ID = :tabID LIMIT 1", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
if (qTab.recordCount == 0) apiAbort({ "OK": false, "ERROR": "tab_not_found" });
if (qTab.OwnerUserID != userID) apiAbort({ "OK": false, "ERROR": "not_owner" });
qPending = queryTimed("
SELECT tbo.OrderID, tbo.UserID, tbo.SubtotalCents, tbo.TaxCents, tbo.AddedOn,
u.FirstName, u.LastName
FROM TabOrders tbo
JOIN Users u ON u.ID = tbo.UserID
WHERE tbo.TabID = :tabID AND tbo.ApprovalStatus = 'pending'
ORDER BY tbo.AddedOn
", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } });
orders = [];
for (row in qPending) {
// Get line items for this order
qItems = queryTimed("
SELECT oli.ID, oli.ItemID, oli.Price, oli.Quantity, oli.Remark,
i.Name AS ItemName
FROM OrderLineItems oli
JOIN Items i ON i.ID = oli.ItemID
WHERE oli.OrderID = :orderID AND oli.IsDeleted = 0 AND oli.ParentOrderLineItemID = 0
", { orderID: { value: row.OrderID, cfsqltype: "cf_sql_integer" } });
items = [];
for (item in qItems) {
arrayAppend(items, {
"Name": item.ItemName,
"Price": item.Price,
"Quantity": item.Quantity,
"Remark": item.Remark ?: ""
});
}
arrayAppend(orders, {
"OrderID": row.OrderID,
"UserID": row.UserID,
"UserName": "#row.FirstName# #row.LastName#",
"SubtotalCents": row.SubtotalCents,
"TaxCents": row.TaxCents,
"AddedOn": toISO8601(row.AddedOn),
"Items": items
});
}
apiAbort({ "OK": true, "PENDING_ORDERS": orders });
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

55
api/tabs/rejectOrder.cfm Normal file
View file

@ -0,0 +1,55 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Reject Tab Order
* Tab owner rejects a pending member order.
*
* POST: { TabID: int, OrderID: int, UserID: int (tab owner) }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
orderID = val(requestData.OrderID ?: 0);
userID = val(requestData.UserID ?: 0);
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (orderID == 0) apiAbort({ "OK": false, "ERROR": "missing_OrderID" });
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
qTab = queryTimed("SELECT OwnerUserID, StatusID FROM Tabs WHERE ID = :tabID LIMIT 1", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" }
});
if (qTab.recordCount == 0) apiAbort({ "OK": false, "ERROR": "tab_not_found" });
if (qTab.OwnerUserID != userID) apiAbort({ "OK": false, "ERROR": "not_owner" });
qTabOrder = queryTimed("SELECT ApprovalStatus FROM TabOrders WHERE TabID = :tabID AND OrderID = :orderID LIMIT 1", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID, cfsqltype: "cf_sql_integer" }
});
if (qTabOrder.recordCount == 0) apiAbort({ "OK": false, "ERROR": "order_not_on_tab" });
if (qTabOrder.ApprovalStatus != "pending") apiAbort({ "OK": false, "ERROR": "not_pending" });
queryTimed("
UPDATE TabOrders SET ApprovalStatus = 'rejected'
WHERE TabID = :tabID AND OrderID = :orderID
", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
orderID: { value: orderID, cfsqltype: "cf_sql_integer" }
});
// Also unlink the order from the tab
queryTimed("UPDATE Orders SET TabID = NULL WHERE ID = :orderID", {
orderID: { value: orderID, cfsqltype: "cf_sql_integer" }
});
apiAbort({ "OK": true });
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

54
api/tabs/removeMember.cfm Normal file
View file

@ -0,0 +1,54 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Remove Member from Tab
* Tab owner removes a member. Can't remove yourself (use close/cancel instead).
*
* POST: { TabID: int, OwnerUserID: int, TargetUserID: int }
*/
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
tabID = val(requestData.TabID ?: 0);
ownerUserID = val(requestData.OwnerUserID ?: 0);
targetUserID = val(requestData.TargetUserID ?: 0);
if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" });
if (ownerUserID == 0) apiAbort({ "OK": false, "ERROR": "missing_OwnerUserID" });
if (targetUserID == 0) apiAbort({ "OK": false, "ERROR": "missing_TargetUserID" });
qTab = queryTimed("SELECT OwnerUserID, StatusID FROM Tabs WHERE 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" });
if (qTab.OwnerUserID != ownerUserID) apiAbort({ "OK": false, "ERROR": "not_owner" });
if (targetUserID == ownerUserID) apiAbort({ "OK": false, "ERROR": "cannot_remove_self" });
// Reject any pending orders from this member
queryTimed("
UPDATE TabOrders SET ApprovalStatus = 'rejected'
WHERE TabID = :tabID AND UserID = :uid AND ApprovalStatus = 'pending'
", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
uid: { value: targetUserID, cfsqltype: "cf_sql_integer" }
});
queryTimed("
UPDATE TabMembers SET StatusID = 2, LeftOn = NOW()
WHERE TabID = :tabID AND UserID = :uid AND StatusID = 1
", {
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
uid: { value: targetUserID, cfsqltype: "cf_sql_integer" }
});
apiAbort({ "OK": true });
} catch (any e) {
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
}
</cfscript>

146
cron/expireTabs.cfm Normal file
View file

@ -0,0 +1,146 @@
<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>

View file

@ -678,13 +678,12 @@
</div>
</div>
<!-- COMMENTED OUT FOR LAUNCH - Tabs/Running Checks feature (coming soon)
<div class="card">
<div class="card-header">
<h3>Tabs / Running Checks</h3>
</div>
<div class="card-body">
<p style="color: #666; font-size: 13px; margin-bottom: 16px;">Allow customers to keep a tab open and order multiple rounds before closing out.</p>
<p style="color: #666; font-size: 13px; margin-bottom: 16px;">Allow customers to open a tab, authorize their card, and order multiple rounds before closing out with a single charge.</p>
<div class="form-group" style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
<label class="toggle">
<input type="checkbox" id="tabsEnabled" onchange="Portal.saveTabSettings()">
@ -696,20 +695,44 @@
<div class="form-group">
<label>Tab Lock Duration (minutes)</label>
<input type="number" id="tabLockMinutes" class="form-input" value="30" min="5" max="480" style="width: 120px;">
<small style="color:#666;font-size:12px;display:block;margin-top:4px;">How long a tab stays open without activity</small>
<small style="color:#666;font-size:12px;display:block;margin-top:4px;">How long a tab stays open without activity before auto-closing</small>
</div>
<div class="form-group" style="margin-top: 12px;">
<label>Payment Strategy</label>
<select id="tabPaymentStrategy" class="form-input" style="width: 200px;">
<option value="A">Pay at end (single charge)</option>
<option value="P">Pre-authorize card</option>
</select>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-top: 12px;">
<div class="form-group">
<label>Min Auth Amount ($)</label>
<input type="number" id="tabMinAuthAmount" class="form-input" value="50" min="10" max="10000" step="10">
</div>
<div class="form-group">
<label>Default Auth Amount ($)</label>
<input type="number" id="tabDefaultAuthAmount" class="form-input" value="150" min="10" max="10000" step="10">
</div>
<div class="form-group">
<label>Max Auth Amount ($)</label>
<input type="number" id="tabMaxAuthAmount" class="form-input" value="1000" min="10" max="10000" step="10">
</div>
</div>
<button class="btn btn-primary" onclick="Portal.saveTabSettings()" style="margin-top: 12px;">Save Tab Settings</button>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 12px;">
<div class="form-group">
<label>Max Members Per Tab</label>
<input type="number" id="tabMaxMembers" class="form-input" value="10" min="1" max="50">
</div>
<div class="form-group">
<label>Auto-Increase Threshold</label>
<input type="number" id="tabAutoIncreaseThreshold" class="form-input" value="0.80" min="0.5" max="1.0" step="0.05">
<small style="color:#666;font-size:12px;display:block;margin-top:4px;">Suggest auth increase when this % of limit is reached</small>
</div>
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 12px; margin-top: 12px;">
<label class="toggle">
<input type="checkbox" id="tabApprovalRequired">
<span class="toggle-slider"></span>
</label>
<span>Require owner approval for member orders</span>
</div>
<button class="btn btn-primary" onclick="Portal.saveTabSettings()" style="margin-top: 16px;">Save Tab Settings</button>
</div>
</div>
</div>
END COMMENTED OUT -->
<div class="card">
<div class="card-header">

View file

@ -834,52 +834,65 @@ const Portal = {
// Render hours editor
this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || biz.HoursDetail || []);
// COMMENTED OUT FOR LAUNCH - Tabs/Running Checks (coming soon)
// const tabsEnabled = biz.SESSIONENABLED || biz.SessionEnabled || 0;
// const tabLockMinutes = biz.SESSIONLOCKMINUTES || biz.SessionLockMinutes || 30;
// const tabPaymentStrategy = biz.SESSIONPAYMENTSTRATEGY || biz.SessionPaymentStrategy || 'A';
// const tabsCheckbox = document.getElementById('tabsEnabled');
// const tabDetails = document.getElementById('tabSettingsDetails');
// if (tabsCheckbox) {
// tabsCheckbox.checked = tabsEnabled == 1;
// if (tabDetails) tabDetails.style.display = tabsEnabled == 1 ? 'block' : 'none';
// }
// const lockInput = document.getElementById('tabLockMinutes');
// if (lockInput) lockInput.value = tabLockMinutes;
// const strategySelect = document.getElementById('tabPaymentStrategy');
// if (strategySelect) strategySelect.value = tabPaymentStrategy;
// Tab settings
const tabsEnabled = biz.SESSIONENABLED || biz.SessionEnabled || 0;
const tabLockMinutes = biz.SESSIONLOCKMINUTES || biz.SessionLockMinutes || 30;
const tabsCheckbox = document.getElementById('tabsEnabled');
const tabDetails = document.getElementById('tabSettingsDetails');
if (tabsCheckbox) {
tabsCheckbox.checked = tabsEnabled == 1;
if (tabDetails) tabDetails.style.display = tabsEnabled == 1 ? 'block' : 'none';
}
const lockInput = document.getElementById('tabLockMinutes');
if (lockInput) lockInput.value = tabLockMinutes;
const minAuthInput = document.getElementById('tabMinAuthAmount');
if (minAuthInput) minAuthInput.value = biz.TABMINAUTHAMOUNT || biz.TabMinAuthAmount || 50;
const defaultAuthInput = document.getElementById('tabDefaultAuthAmount');
if (defaultAuthInput) defaultAuthInput.value = biz.TABDEFAULTAUTHAMOUNT || biz.TabDefaultAuthAmount || 150;
const maxAuthInput = document.getElementById('tabMaxAuthAmount');
if (maxAuthInput) maxAuthInput.value = biz.TABMAXAUTHAMOUNT || biz.TabMaxAuthAmount || 1000;
const maxMembersInput = document.getElementById('tabMaxMembers');
if (maxMembersInput) maxMembersInput.value = biz.TABMAXMEMBERS || biz.TabMaxMembers || 10;
const thresholdInput = document.getElementById('tabAutoIncreaseThreshold');
if (thresholdInput) thresholdInput.value = biz.TABAUTOINCREASETHRESHOLD || biz.TabAutoIncreaseThreshold || 0.80;
const approvalCheckbox = document.getElementById('tabApprovalRequired');
if (approvalCheckbox) approvalCheckbox.checked = (biz.TABAPPROVALREQUIRED || biz.TabApprovalRequired || 1) == 1;
}
} catch (err) {
console.error('[Portal] Error loading business info:', err);
}
},
// COMMENTED OUT FOR LAUNCH - Tabs/Running Checks (coming soon)
// async saveTabSettings() {
// const tabsEnabled = document.getElementById('tabsEnabled').checked ? 1 : 0;
// const tabLockMinutes = parseInt(document.getElementById('tabLockMinutes').value) || 30;
// const tabPaymentStrategy = document.getElementById('tabPaymentStrategy').value || 'A';
// const tabDetails = document.getElementById('tabSettingsDetails');
// if (tabDetails) tabDetails.style.display = tabsEnabled ? 'block' : 'none';
// try {
// const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateTabs.cfm`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// BusinessID: this.config.businessId,
// SessionEnabled: tabsEnabled,
// SessionLockMinutes: tabLockMinutes,
// SessionPaymentStrategy: tabPaymentStrategy
// })
// });
// const data = await response.json();
// if (data.OK) { this.showToast('Tab settings saved!', 'success'); }
// else { this.showToast(data.ERROR || 'Failed to save tab settings', 'error'); }
// } catch (err) {
// console.error('[Portal] Error saving tab settings:', err);
// this.showToast('Error saving tab settings', 'error');
// }
// },
async saveTabSettings() {
const tabsEnabled = document.getElementById('tabsEnabled').checked ? 1 : 0;
const tabLockMinutes = parseInt(document.getElementById('tabLockMinutes').value) || 30;
const tabDetails = document.getElementById('tabSettingsDetails');
if (tabDetails) tabDetails.style.display = tabsEnabled ? 'block' : 'none';
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateTabs.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
SessionEnabled: tabsEnabled,
SessionLockMinutes: tabLockMinutes,
SessionPaymentStrategy: 'A',
TabMinAuthAmount: parseFloat(document.getElementById('tabMinAuthAmount').value) || 50,
TabDefaultAuthAmount: parseFloat(document.getElementById('tabDefaultAuthAmount').value) || 150,
TabMaxAuthAmount: parseFloat(document.getElementById('tabMaxAuthAmount').value) || 1000,
TabMaxMembers: parseInt(document.getElementById('tabMaxMembers').value) || 10,
TabAutoIncreaseThreshold: parseFloat(document.getElementById('tabAutoIncreaseThreshold').value) || 0.80,
TabApprovalRequired: document.getElementById('tabApprovalRequired').checked ? 1 : 0
})
});
const data = await response.json();
if (data.OK) { this.showToast('Tab settings saved!', 'success'); }
else { this.showToast(data.ERROR || 'Failed to save tab settings', 'error'); }
} catch (err) {
console.error('[Portal] Error saving tab settings:', err);
this.showToast('Error saving tab settings', 'error');
}
},
// Render hours editor
renderHoursEditor(hours) {