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
try {
if (structKeyExists(application, "twilioObj")) {
smsMessage = "Your Payfrit code is: " & code;
application.twilioObj.sendSMS(to=qUser.ContactNumber, message=smsMessage);
smsMessage = "Your Payfrit login code is: " & code & ". It expires in 10 minutes.";
application.twilioObj.sendSMS(recipientNumber="+1" & qUser.ContactNumber, messageBody=smsMessage);
}
} catch (any smsErr) {
writeLog(file="otp_errors", text="SMS send failed for user #userId#: #smsErr.message#");

View file

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

View file

@ -33,28 +33,30 @@
<cfset var qOrder = queryTimed(
"
SELECT
ID,
UUID,
UserID,
BusinessID,
DeliveryMultiplier,
OrderTypeID,
DeliveryFee,
StatusID,
AddressID,
PaymentID,
Remarks,
AddedOn,
LastEditedOn,
SubmittedOn,
ServicePointID,
GrantID,
GrantOwnerBusinessID,
GrantEconomicsType,
GrantEconomicsValue,
TabID
FROM Orders
WHERE ID = ?
o.ID,
o.UUID,
o.UserID,
o.BusinessID,
o.DeliveryMultiplier,
o.OrderTypeID,
o.DeliveryFee,
o.StatusID,
o.AddressID,
o.PaymentID,
o.Remarks,
o.AddedOn,
o.LastEditedOn,
o.SubmittedOn,
o.ServicePointID,
sp.Name AS ServicePointName,
o.GrantID,
o.GrantOwnerBusinessID,
o.GrantEconomicsType,
o.GrantEconomicsValue,
o.TabID
FROM Orders o
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
WHERE o.ID = ?
LIMIT 1
",
[ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ],
@ -94,6 +96,7 @@
"LastEditedOn": qOrder.LastEditedOn,
"SubmittedOn": qOrder.SubmittedOn,
"ServicePointID": val(qOrder.ServicePointID),
"ServicePointName": qOrder.ServicePointName ?: "",
"GrantID": val(qOrder.GrantID),
"GrantOwnerBusinessID": val(qOrder.GrantOwnerBusinessID),
"GrantEconomicsType": qOrder.GrantEconomicsType ?: "",
@ -172,6 +175,16 @@
})>
</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 --->
<cfif OrderTypeID LT 0 OR OrderTypeID GT 3>
<cfset apiAbort({

View file

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

View file

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

View file

@ -112,7 +112,6 @@ try {
if (orderID > 0) {
// Update order status to paid/submitted (status 1)
// Note: Task is created later when order status changes to Ready (3) in updateStatus.cfm
queryTimed("
UPDATE Orders
SET PaymentStatus = 'paid',
@ -134,6 +133,34 @@ try {
", { amount: val(qBalOrder.BalanceApplied), userID: val(qBalOrder.UserID) });
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 ===

View file

@ -36,6 +36,16 @@ try {
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) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "ServicePointID is required" });
}

View file

@ -297,6 +297,26 @@
<!--- === CASH TRANSACTION PROCESSING === --->
<cfset cashProcessed = false>
<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 --->
<cfif changeCents GT 0 AND customerUserID GT 0>
<cfset queryTimed("
@ -318,20 +338,46 @@
<!--- Debit for physical cash received --->
<!--- Staff: debit the worker (they pocketed the cash) --->
<!--- Admin/Manager: debit the business owner (restaurant has the cash) --->
<cfset cashDebitUserID = isAdminRole ? businessOwnerUserID : workerUserID_for_payout>
<cfif cashDebitUserID GT 0>
<cfset queryTimed("
INSERT INTO WorkPayoutLedgers
(UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status)
VALUES
(:userID, :taskID, :gross, 0, :net, 'cash_debit')
", {
userID: cashDebitUserID,
taskID: TaskID,
gross: -CashReceivedCents,
net: -CashReceivedCents
}, { datasource = "payfrit" })>
<!--- Admin/Manager: cash goes to the business, delete worker's pending payout --->
<cfif isAdmin>
<!--- Admin/Manager: cash goes to the business, delete worker's pending payout --->
<!--- Remove the task earning ledger entry (admin hands cash to register) --->
<cfif workerUserID_for_payout GT 0>
<cfset queryTimed("
DELETE FROM WorkPayoutLedgers
WHERE TaskID = :taskID AND UserID = :userID AND Status = 'pending_charge'
", {
taskID: TaskID,
userID: workerUserID_for_payout
}, { 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>
<!--- Log transaction in Payments table --->
@ -345,7 +391,7 @@
:sentBy, :receivedBy, :orderID,
0, 0, :cashAmount,
:payfritCut, 0, :bizFee,
'Cash payment', NOW()
:remark, NOW()
)
", {
sentBy: customerUserID,
@ -353,7 +399,8 @@
orderID: qTask.OrderID,
cashAmount: CashReceivedCents / 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" })>
<cfset cashProcessed = true>
@ -430,6 +477,8 @@
<cfset response["BalanceApplied"] = numberFormat(balanceAppliedCents / 100, "0.00")>
<cfset response["CashOwed"] = numberFormat(cashOwedCents / 100, "0.00")>
</cfif>
<cfset response["CashRoutedTo"] = isAdmin ? "business" : "worker">
<cfset response["WorkerRoleID"] = workerRoleID>
</cfif>
<cfset apiAbort(response)>

View file

@ -35,6 +35,16 @@ try {
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
ttQuery = queryTimed("
SELECT ID FROM tt_TaskTypes

View file

@ -40,7 +40,7 @@
e.BusinessID,
MIN(e.StatusID) AS StatusID,
MAX(e.IsActive) AS IsActive,
MAX(e.RoleID) AS RoleID,
MAX(COALESCE(e.RoleID, 1)) AS RoleID,
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 = ? AND t.CompletedOn IS NULL) AS ActiveTaskCount