diff --git a/api/Application.cfm b/api/Application.cfm index a8e0c2f..7d6131c 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -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; diff --git a/api/businesses/get.cfm b/api/businesses/get.cfm index 4123f3e..c8a80fc 100644 --- a/api/businesses/get.cfm +++ b/api/businesses/get.cfm @@ -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 diff --git a/api/businesses/updateTabs.cfm b/api/businesses/updateTabs.cfm index 612ad14..3a8cba6 100644 --- a/api/businesses/updateTabs.cfm +++ b/api/businesses/updateTabs.cfm @@ -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; @@ -48,7 +64,13 @@ if (SessionPaymentStrategy != "A" && SessionPaymentStrategy != "P") SessionPayme UPDATE Businesses SET SessionEnabled = , SessionLockMinutes = , - SessionPaymentStrategy = + SessionPaymentStrategy = , + TabMinAuthAmount = , + TabDefaultAuthAmount = , + TabMaxAuthAmount = , + TabAutoIncreaseThreshold = , + TabMaxMembers = , + TabApprovalRequired = WHERE ID = @@ -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 })# diff --git a/api/orders/getOrCreateCart.cfm b/api/orders/getOrCreateCart.cfm index 5fc81bd..aa8feeb 100644 --- a/api/orders/getOrCreateCart.cfm +++ b/api/orders/getOrCreateCart.cfm @@ -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) }> + + + + + + + @@ -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" } )> diff --git a/api/orders/submit.cfm b/api/orders/submit.cfm index 712eecc..6b129ad 100644 --- a/api/orders/submit.cfm +++ b/api/orders/submit.cfm @@ -261,6 +261,39 @@ + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + +/** + * 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 }); +} + diff --git a/api/stripe/webhook.cfm b/api/stripe/webhook.cfm index a16b673..e38be3c 100644 --- a/api/stripe/webhook.cfm +++ b/api/stripe/webhook.cfm @@ -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) diff --git a/api/tabs/addMember.cfm b/api/tabs/addMember.cfm new file mode 100644 index 0000000..8668814 --- /dev/null +++ b/api/tabs/addMember.cfm @@ -0,0 +1,83 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/addOrder.cfm b/api/tabs/addOrder.cfm new file mode 100644 index 0000000..3ebbe12 --- /dev/null +++ b/api/tabs/addOrder.cfm @@ -0,0 +1,147 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/approveOrder.cfm b/api/tabs/approveOrder.cfm new file mode 100644 index 0000000..8ef0ffe --- /dev/null +++ b/api/tabs/approveOrder.cfm @@ -0,0 +1,98 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/cancel.cfm b/api/tabs/cancel.cfm new file mode 100644 index 0000000..b90c24a --- /dev/null +++ b/api/tabs/cancel.cfm @@ -0,0 +1,63 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/close.cfm b/api/tabs/close.cfm new file mode 100644 index 0000000..e1281fa --- /dev/null +++ b/api/tabs/close.cfm @@ -0,0 +1,204 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/get.cfm b/api/tabs/get.cfm new file mode 100644 index 0000000..9df9b43 --- /dev/null +++ b/api/tabs/get.cfm @@ -0,0 +1,132 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/getActive.cfm b/api/tabs/getActive.cfm new file mode 100644 index 0000000..cbd1ff2 --- /dev/null +++ b/api/tabs/getActive.cfm @@ -0,0 +1,83 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/getPresence.cfm b/api/tabs/getPresence.cfm new file mode 100644 index 0000000..da292f6 --- /dev/null +++ b/api/tabs/getPresence.cfm @@ -0,0 +1,88 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/increaseAuth.cfm b/api/tabs/increaseAuth.cfm new file mode 100644 index 0000000..d73afd4 --- /dev/null +++ b/api/tabs/increaseAuth.cfm @@ -0,0 +1,74 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/open.cfm b/api/tabs/open.cfm new file mode 100644 index 0000000..5d1db6d --- /dev/null +++ b/api/tabs/open.cfm @@ -0,0 +1,183 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/pendingOrders.cfm b/api/tabs/pendingOrders.cfm new file mode 100644 index 0000000..35986cb --- /dev/null +++ b/api/tabs/pendingOrders.cfm @@ -0,0 +1,74 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/rejectOrder.cfm b/api/tabs/rejectOrder.cfm new file mode 100644 index 0000000..1f8cc8d --- /dev/null +++ b/api/tabs/rejectOrder.cfm @@ -0,0 +1,55 @@ + + + + + + +/** + * 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 }); +} + diff --git a/api/tabs/removeMember.cfm b/api/tabs/removeMember.cfm new file mode 100644 index 0000000..88aa69d --- /dev/null +++ b/api/tabs/removeMember.cfm @@ -0,0 +1,54 @@ + + + + + + +/** + * 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 }); +} + diff --git a/cron/expireTabs.cfm b/cron/expireTabs.cfm new file mode 100644 index 0000000..72469d9 --- /dev/null +++ b/cron/expireTabs.cfm @@ -0,0 +1,146 @@ + + + + + + +/** + * 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 + }); +} + diff --git a/portal/index.html b/portal/index.html index f290537..abc3724 100644 --- a/portal/index.html +++ b/portal/index.html @@ -678,13 +678,12 @@ -
diff --git a/portal/portal.js b/portal/portal.js index bc72b4e..80db7f8 100644 --- a/portal/portal.js +++ b/portal/portal.js @@ -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) {