Add role-aware cash routing and backend improvements

Staff cash goes to worker payout ledger, admin/manager cash deletes
pending payout and reverses withholding. Add RoleID to myBusinesses
response. Various order and webhook improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John Pinkyfloyd 2026-03-04 20:01:32 -08:00
parent 9c09e18833
commit b9755a1e72
10 changed files with 197 additions and 79 deletions

View file

@ -136,8 +136,8 @@ try {
// Send via SMS // Send via SMS
try { try {
if (structKeyExists(application, "twilioObj")) { if (structKeyExists(application, "twilioObj")) {
smsMessage = "Your Payfrit code is: " & code; smsMessage = "Your Payfrit login code is: " & code & ". It expires in 10 minutes.";
application.twilioObj.sendSMS(to=qUser.ContactNumber, message=smsMessage); application.twilioObj.sendSMS(recipientNumber="+1" & qUser.ContactNumber, messageBody=smsMessage);
} }
} catch (any smsErr) { } catch (any smsErr) {
writeLog(file="otp_errors", text="SMS send failed for user #userId#: #smsErr.message#"); writeLog(file="otp_errors", text="SMS send failed for user #userId#: #smsErr.message#");

View file

@ -37,24 +37,26 @@
<cfset qOrder = queryTimed( <cfset qOrder = queryTimed(
" "
SELECT SELECT
ID, o.ID,
UUID, o.UUID,
UserID, o.UserID,
BusinessID, o.BusinessID,
DeliveryMultiplier, o.DeliveryMultiplier,
OrderTypeID, o.OrderTypeID,
DeliveryFee, o.DeliveryFee,
StatusID, o.StatusID,
AddressID, o.AddressID,
PaymentID, o.PaymentID,
PaymentStatus, o.PaymentStatus,
Remarks, o.Remarks,
AddedOn, o.AddedOn,
LastEditedOn, o.LastEditedOn,
SubmittedOn, o.SubmittedOn,
ServicePointID o.ServicePointID,
FROM Orders sp.Name AS ServicePointName
WHERE ID = ? FROM Orders o
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
WHERE o.ID = ?
LIMIT 1 LIMIT 1
", ",
[ { value = OrderID, cfsqltype = "cf_sql_integer" } ], [ { value = OrderID, cfsqltype = "cf_sql_integer" } ],
@ -161,7 +163,8 @@
"AddedOn": qOrder.AddedOn, "AddedOn": qOrder.AddedOn,
"LastEditedOn": qOrder.LastEditedOn, "LastEditedOn": qOrder.LastEditedOn,
"SubmittedOn": qOrder.SubmittedOn, "SubmittedOn": qOrder.SubmittedOn,
"ServicePointID": val(qOrder.ServicePointID) "ServicePointID": val(qOrder.ServicePointID),
"ServicePointName": qOrder.ServicePointName ?: ""
}, },
"ORDERLINEITEMS": rows "ORDERLINEITEMS": rows
})> })>

View file

@ -33,28 +33,30 @@
<cfset var qOrder = queryTimed( <cfset var qOrder = queryTimed(
" "
SELECT SELECT
ID, o.ID,
UUID, o.UUID,
UserID, o.UserID,
BusinessID, o.BusinessID,
DeliveryMultiplier, o.DeliveryMultiplier,
OrderTypeID, o.OrderTypeID,
DeliveryFee, o.DeliveryFee,
StatusID, o.StatusID,
AddressID, o.AddressID,
PaymentID, o.PaymentID,
Remarks, o.Remarks,
AddedOn, o.AddedOn,
LastEditedOn, o.LastEditedOn,
SubmittedOn, o.SubmittedOn,
ServicePointID, o.ServicePointID,
GrantID, sp.Name AS ServicePointName,
GrantOwnerBusinessID, o.GrantID,
GrantEconomicsType, o.GrantOwnerBusinessID,
GrantEconomicsValue, o.GrantEconomicsType,
TabID o.GrantEconomicsValue,
FROM Orders o.TabID
WHERE ID = ? FROM Orders o
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
WHERE o.ID = ?
LIMIT 1 LIMIT 1
", ",
[ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ], [ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ],
@ -94,6 +96,7 @@
"LastEditedOn": qOrder.LastEditedOn, "LastEditedOn": qOrder.LastEditedOn,
"SubmittedOn": qOrder.SubmittedOn, "SubmittedOn": qOrder.SubmittedOn,
"ServicePointID": val(qOrder.ServicePointID), "ServicePointID": val(qOrder.ServicePointID),
"ServicePointName": qOrder.ServicePointName ?: "",
"GrantID": val(qOrder.GrantID), "GrantID": val(qOrder.GrantID),
"GrantOwnerBusinessID": val(qOrder.GrantOwnerBusinessID), "GrantOwnerBusinessID": val(qOrder.GrantOwnerBusinessID),
"GrantEconomicsType": qOrder.GrantEconomicsType ?: "", "GrantEconomicsType": qOrder.GrantEconomicsType ?: "",
@ -172,6 +175,16 @@
})> })>
</cfif> </cfif>
<!--- Require ServicePointID for now (delivery/takeaway not yet supported) --->
<cfif ServicePointID LTE 0>
<cfset apiAbort({
"OK": false,
"ERROR": "missing_service_point",
"MESSAGE": "ServicePointID is required. Please scan a table beacon.",
"DETAIL": ""
})>
</cfif>
<!--- OrderTypeID can be 0 (undecided) for delivery/takeaway flow, or 1 for dine-in ---> <!--- OrderTypeID can be 0 (undecided) for delivery/takeaway flow, or 1 for dine-in --->
<cfif OrderTypeID LT 0 OR OrderTypeID GT 3> <cfif OrderTypeID LT 0 OR OrderTypeID GT 3>
<cfset apiAbort({ <cfset apiAbort({

View file

@ -191,9 +191,11 @@
o.LastEditedOn, o.LastEditedOn,
o.SubmittedOn, o.SubmittedOn,
o.ServicePointID, o.ServicePointID,
sp.Name AS ServicePointName,
COALESCE(b.DeliveryFlatFee, 0) AS BusinessDeliveryFee COALESCE(b.DeliveryFlatFee, 0) AS BusinessDeliveryFee
FROM Orders o FROM Orders o
LEFT JOIN Businesses b ON b.ID = o.BusinessID LEFT JOIN Businesses b ON b.ID = o.BusinessID
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
WHERE o.ID = ? WHERE o.ID = ?
LIMIT 1 LIMIT 1
", ",
@ -221,6 +223,7 @@
"LastEditedOn": qOrder.LastEditedOn, "LastEditedOn": qOrder.LastEditedOn,
"SubmittedOn": qOrder.SubmittedOn, "SubmittedOn": qOrder.SubmittedOn,
"ServicePointID": val(qOrder.ServicePointID), "ServicePointID": val(qOrder.ServicePointID),
"ServicePointName": qOrder.ServicePointName ?: "",
"BusinessDeliveryFee": val(qOrder.BusinessDeliveryFee) "BusinessDeliveryFee": val(qOrder.BusinessDeliveryFee)
}> }>

View file

@ -33,23 +33,25 @@
<cfset var qOrder = queryTimed( <cfset var qOrder = queryTimed(
" "
SELECT SELECT
ID, o.ID,
UUID, o.UUID,
UserID, o.UserID,
BusinessID, o.BusinessID,
DeliveryMultiplier, o.DeliveryMultiplier,
OrderTypeID, o.OrderTypeID,
DeliveryFee, o.DeliveryFee,
StatusID, o.StatusID,
AddressID, o.AddressID,
PaymentID, o.PaymentID,
Remarks, o.Remarks,
AddedOn, o.AddedOn,
LastEditedOn, o.LastEditedOn,
SubmittedOn, o.SubmittedOn,
ServicePointID o.ServicePointID,
FROM Orders sp.Name AS ServicePointName
WHERE ID = ? FROM Orders o
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
WHERE o.ID = ?
LIMIT 1 LIMIT 1
", ",
[ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ], [ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ],
@ -84,7 +86,8 @@
"AddedOn": qOrder.AddedOn, "AddedOn": qOrder.AddedOn,
"LastEditedOn": qOrder.LastEditedOn, "LastEditedOn": qOrder.LastEditedOn,
"SubmittedOn": qOrder.SubmittedOn, "SubmittedOn": qOrder.SubmittedOn,
"ServicePointID": val(qOrder.ServicePointID) "ServicePointID": val(qOrder.ServicePointID),
"ServicePointName": qOrder.ServicePointName ?: ""
}> }>
<cfset var qLI = queryTimed( <cfset var qLI = queryTimed(

View file

@ -112,7 +112,6 @@ try {
if (orderID > 0) { if (orderID > 0) {
// Update order status to paid/submitted (status 1) // Update order status to paid/submitted (status 1)
// Note: Task is created later when order status changes to Ready (3) in updateStatus.cfm
queryTimed(" queryTimed("
UPDATE Orders UPDATE Orders
SET PaymentStatus = 'paid', SET PaymentStatus = 'paid',
@ -134,6 +133,34 @@ try {
", { amount: val(qBalOrder.BalanceApplied), userID: val(qBalOrder.UserID) }); ", { amount: val(qBalOrder.BalanceApplied), userID: val(qBalOrder.UserID) });
writeLog(file="stripe_webhooks", text="Order #orderID# balance deducted: $#qBalOrder.BalanceApplied#"); writeLog(file="stripe_webhooks", text="Order #orderID# balance deducted: $#qBalOrder.BalanceApplied#");
} }
// Create kitchen task for the order
qOrder = queryTimed("
SELECT o.BusinessID, o.ServicePointID, sp.Name AS ServicePointName
FROM Orders o
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
WHERE o.ID = :orderID
", { orderID: orderID });
if (qOrder.recordCount > 0) {
tableName = len(trim(qOrder.ServicePointName)) ? qOrder.ServicePointName : "Table";
taskTitle = "Prepare Order ##" & orderID & " for " & tableName;
queryTimed("
INSERT INTO Tasks (
BusinessID, OrderID, ServicePointID, Title, CreatedOn, ClaimedByUserID
) VALUES (
:businessID, :orderID, :servicePointID, :title, NOW(), 0
)
", {
businessID: qOrder.BusinessID,
orderID: orderID,
servicePointID: val(qOrder.ServicePointID),
title: taskTitle
});
writeLog(file="stripe_webhooks", text="Kitchen task created for order #orderID#");
}
} }
// === WORKER PAYOUT TRANSFER === // === WORKER PAYOUT TRANSFER ===

View file

@ -36,6 +36,16 @@ try {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
} }
// If servicePointID not provided but orderID is, look it up from the order
if (servicePointID == 0 && orderID > 0) {
qOrderSP = queryExecute("
SELECT ServicePointID FROM Orders WHERE ID = :orderID
", { orderID: { value: orderID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (qOrderSP.recordCount && val(qOrderSP.ServicePointID) > 0) {
servicePointID = val(qOrderSP.ServicePointID);
}
}
if (servicePointID == 0) { if (servicePointID == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ServicePointID is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ServicePointID is required" });
} }

View file

@ -297,6 +297,26 @@
<!--- === CASH TRANSACTION PROCESSING === ---> <!--- === CASH TRANSACTION PROCESSING === --->
<cfset cashProcessed = false> <cfset cashProcessed = false>
<cfif isCashTask AND CashReceivedCents GT 0 AND qTask.OrderID GT 0 AND NOT CancelOrder> <cfif isCashTask AND CashReceivedCents GT 0 AND qTask.OrderID GT 0 AND NOT CancelOrder>
<!--- Look up worker's role for this business (1=Staff, 2=Manager, 3=Admin) --->
<cfset workerRoleID = 1>
<cfif workerUserID_for_payout GT 0>
<cfset qWorkerRole = queryTimed("
SELECT COALESCE(RoleID, 1) AS RoleID
FROM Employees
WHERE UserID = ? AND BusinessID = ? AND IsActive = b'1'
ORDER BY RoleID DESC
LIMIT 1
", [
{ value = workerUserID_for_payout, cfsqltype = "cf_sql_integer" },
{ value = qTask.BusinessID, cfsqltype = "cf_sql_integer" }
], { datasource = "payfrit" })>
<cfif qWorkerRole.recordCount GT 0>
<cfset workerRoleID = val(qWorkerRole.RoleID)>
</cfif>
</cfif>
<cfset isAdmin = (workerRoleID GTE 2)>
<!--- Credit customer change to their balance ---> <!--- Credit customer change to their balance --->
<cfif changeCents GT 0 AND customerUserID GT 0> <cfif changeCents GT 0 AND customerUserID GT 0>
<cfset queryTimed(" <cfset queryTimed("
@ -318,20 +338,46 @@
<!--- Debit for physical cash received ---> <!--- Debit for physical cash received --->
<!--- Staff: debit the worker (they pocketed the cash) ---> <!--- Staff: debit the worker (they pocketed the cash) --->
<!--- Admin/Manager: debit the business owner (restaurant has the cash) ---> <!--- Admin/Manager: cash goes to the business, delete worker's pending payout --->
<cfset cashDebitUserID = isAdminRole ? businessOwnerUserID : workerUserID_for_payout> <cfif isAdmin>
<cfif cashDebitUserID GT 0> <!--- Admin/Manager: cash goes to the business, delete worker's pending payout --->
<cfset queryTimed(" <!--- Remove the task earning ledger entry (admin hands cash to register) --->
INSERT INTO WorkPayoutLedgers <cfif workerUserID_for_payout GT 0>
(UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status) <cfset queryTimed("
VALUES DELETE FROM WorkPayoutLedgers
(:userID, :taskID, :gross, 0, :net, 'cash_debit') WHERE TaskID = :taskID AND UserID = :userID AND Status = 'pending_charge'
", { ", {
userID: cashDebitUserID, taskID: TaskID,
taskID: TaskID, userID: workerUserID_for_payout
gross: -CashReceivedCents, }, { datasource = "payfrit" })>
net: -CashReceivedCents
}, { datasource = "payfrit" })> <!--- Reverse activation withholding if any was applied --->
<cfif ledgerCreated AND activationWithhold GT 0>
<cfset queryTimed("
UPDATE Users
SET ActivationBalanceCents = GREATEST(0, ActivationBalanceCents - :withhold)
WHERE ID = :userID
", {
withhold: activationWithhold,
userID: workerUserID_for_payout
}, { datasource = "payfrit" })>
</cfif>
</cfif>
<cfelse>
<!--- Staff: cash stays in their pocket, debit their payout balance --->
<cfif workerUserID_for_payout GT 0>
<cfset queryTimed("
INSERT INTO WorkPayoutLedgers
(UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status)
VALUES
(:userID, :taskID, :gross, 0, :net, 'cash_debit')
", {
userID: workerUserID_for_payout,
taskID: TaskID,
gross: -CashReceivedCents,
net: -CashReceivedCents
}, { datasource = "payfrit" })>
</cfif>
</cfif> </cfif>
<!--- Log transaction in Payments table ---> <!--- Log transaction in Payments table --->
@ -345,7 +391,7 @@
:sentBy, :receivedBy, :orderID, :sentBy, :receivedBy, :orderID,
0, 0, :cashAmount, 0, 0, :cashAmount,
:payfritCut, 0, :bizFee, :payfritCut, 0, :bizFee,
'Cash payment', NOW() :remark, NOW()
) )
", { ", {
sentBy: customerUserID, sentBy: customerUserID,
@ -353,7 +399,8 @@
orderID: qTask.OrderID, orderID: qTask.OrderID,
cashAmount: CashReceivedCents / 100, cashAmount: CashReceivedCents / 100,
payfritCut: payfritRevenueCents / 100, payfritCut: payfritRevenueCents / 100,
bizFee: round(businessFeeDollars * 100) / 100 bizFee: round(businessFeeDollars * 100) / 100,
remark: isAdmin ? "Cash payment (collected by manager)" : "Cash payment"
}, { datasource = "payfrit" })> }, { datasource = "payfrit" })>
<cfset cashProcessed = true> <cfset cashProcessed = true>
@ -430,6 +477,8 @@
<cfset response["BalanceApplied"] = numberFormat(balanceAppliedCents / 100, "0.00")> <cfset response["BalanceApplied"] = numberFormat(balanceAppliedCents / 100, "0.00")>
<cfset response["CashOwed"] = numberFormat(cashOwedCents / 100, "0.00")> <cfset response["CashOwed"] = numberFormat(cashOwedCents / 100, "0.00")>
</cfif> </cfif>
<cfset response["CashRoutedTo"] = isAdmin ? "business" : "worker">
<cfset response["WorkerRoleID"] = workerRoleID>
</cfif> </cfif>
<cfset apiAbort(response)> <cfset apiAbort(response)>

View file

@ -35,6 +35,16 @@ try {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" }); apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID is required" });
} }
// If servicePointID not provided but orderID is, look it up from the order
if (servicePointID == 0 && orderID > 0) {
qOrderSP = queryTimed("
SELECT ServicePointID FROM Orders WHERE ID = :orderID
", { orderID: { value: orderID, cfsqltype: "cf_sql_integer" } }, { datasource: "payfrit" });
if (qOrderSP.recordCount && val(qOrderSP.ServicePointID) > 0) {
servicePointID = val(qOrderSP.ServicePointID);
}
}
// Look up "Chat With Staff" task type for this business // Look up "Chat With Staff" task type for this business
ttQuery = queryTimed(" ttQuery = queryTimed("
SELECT ID FROM tt_TaskTypes SELECT ID FROM tt_TaskTypes

View file

@ -40,7 +40,7 @@
e.BusinessID, e.BusinessID,
MIN(e.StatusID) AS StatusID, MIN(e.StatusID) AS StatusID,
MAX(e.IsActive) AS IsActive, MAX(e.IsActive) AS IsActive,
MAX(e.RoleID) AS RoleID, MAX(COALESCE(e.RoleID, 1)) AS RoleID,
b.Name AS Name, b.Name AS Name,
(SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = 0 AND t.CompletedOn IS NULL) AS PendingTaskCount, (SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = 0 AND t.CompletedOn IS NULL) AS PendingTaskCount,
(SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = ? AND t.CompletedOn IS NULL) AS ActiveTaskCount (SELECT COUNT(*) FROM Tasks t WHERE t.BusinessID = e.BusinessID AND t.ClaimedByUserID = ? AND t.CompletedOn IS NULL) AS ActiveTaskCount