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:
parent
0e603b6cc9
commit
4c0479db5c
23 changed files with 1787 additions and 60 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
)>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
46
api/presence/heartbeat.cfm
Normal file
46
api/presence/heartbeat.cfm
Normal 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>
|
||||
|
|
@ -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
83
api/tabs/addMember.cfm
Normal 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
147
api/tabs/addOrder.cfm
Normal 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
98
api/tabs/approveOrder.cfm
Normal 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
63
api/tabs/cancel.cfm
Normal 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
204
api/tabs/close.cfm
Normal 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
132
api/tabs/get.cfm
Normal 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
83
api/tabs/getActive.cfm
Normal 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
88
api/tabs/getPresence.cfm
Normal 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
74
api/tabs/increaseAuth.cfm
Normal 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
183
api/tabs/open.cfm
Normal 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>
|
||||
74
api/tabs/pendingOrders.cfm
Normal file
74
api/tabs/pendingOrders.cfm
Normal 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
55
api/tabs/rejectOrder.cfm
Normal 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
54
api/tabs/removeMember.cfm
Normal 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
146
cron/expireTabs.cfm
Normal 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>
|
||||
|
|
@ -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>
|
||||
<button class="btn btn-primary" onclick="Portal.saveTabSettings()" style="margin-top: 12px;">Save Tab Settings</button>
|
||||
<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>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Reference in a new issue