This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/api/tasks/complete.cfm
John Mizerek fc310c35cf Fix cancel task to not cancel order, standardize OTP messages
- Cancel Task now leaves order untouched (customer can pay another way)
- Standardized SMS text to "Your Payfrit code is:" across all endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 11:11:37 -08:00

419 lines
17 KiB
Text

<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cffunction name="apiAbort" access="public" returntype="void" output="true">
<cfargument name="payload" type="struct" required="true">
<cfcontent type="application/json; charset=utf-8">
<cfoutput>#serializeJSON(arguments.payload)#</cfoutput>
<cfabort>
</cffunction>
<cffunction name="readJsonBody" access="public" returntype="struct" output="false">
<cfset var raw = getHttpRequestData().content>
<cfif isNull(raw) OR len(trim(raw)) EQ 0>
<cfreturn {}>
</cfif>
<cftry>
<cfset var data = deserializeJSON(raw)>
<cfif isStruct(data)>
<cfreturn data>
<cfelse>
<cfreturn {}>
</cfif>
<cfcatch>
<cfreturn {}>
</cfcatch>
</cftry>
</cffunction>
<cffunction name="generateToken" access="public" returntype="string" output="false">
<cfreturn lcase(replace(createUUID(), "-", "", "all"))>
</cffunction>
<cfset data = readJsonBody()>
<cfset TaskID = val( structKeyExists(data,"TaskID") ? data.TaskID : 0 )>
<!--- Get UserID from request (auth header) or from JSON body as fallback --->
<cfset UserID = val( structKeyExists(request,"UserID") ? request.UserID : (structKeyExists(data,"UserID") ? data.UserID : 0) )>
<!--- Optional: Worker rating of customer (when required or voluntary) --->
<cfset workerRating = structKeyExists(data,"workerRating") ? data.workerRating : {}>
<cfset CashReceivedCents = val( structKeyExists(data,"CashReceivedCents") ? data.CashReceivedCents : 0 )>
<cfset CancelOrder = structKeyExists(data,"CancelOrder") AND data.CancelOrder EQ true>
<cfif TaskID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "TaskID is required." })>
</cfif>
<cftry>
<!--- Verify task exists --->
<cfset qTask = queryTimed("
SELECT t.ID, t.ClaimedByUserID, t.CompletedOn, t.OrderID, t.TaskTypeID, t.BusinessID,
o.UserID AS CustomerUserID, o.ServicePointID,
tt.Name AS TaskTypeName,
b.UserID AS BusinessOwnerUserID
FROM Tasks t
LEFT JOIN Orders o ON o.ID = t.OrderID
LEFT JOIN tt_TaskTypes tt ON tt.ID = t.TaskTypeID
LEFT JOIN Businesses b ON b.ID = t.BusinessID
WHERE t.ID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfif qTask.recordCount EQ 0>
<cfset apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Task not found." })>
</cfif>
<!--- Chat tasks (TaskTypeID = 2) can be closed without being claimed first --->
<cfset isChatTask = (qTask.TaskTypeID EQ 2)>
<cfset isCashTask = (len(trim(qTask.TaskTypeName)) GT 0 AND findNoCase("Cash", qTask.TaskTypeName) GT 0)>
<cfif (NOT isChatTask) AND (qTask.ClaimedByUserID EQ 0)>
<cfset apiAbort({ "OK": false, "ERROR": "not_claimed", "MESSAGE": "Task has not been claimed yet." })>
</cfif>
<cfif (NOT isChatTask) AND (UserID GT 0) AND (qTask.ClaimedByUserID NEQ UserID)>
<cfset apiAbort({ "OK": false, "ERROR": "not_yours", "MESSAGE": "This task was claimed by someone else." })>
</cfif>
<!--- Check if already completed - use len() to handle both NULL and empty string --->
<cfif len(trim(qTask.CompletedOn)) GT 0>
<cfset apiAbort({ "OK": false, "ERROR": "already_completed", "MESSAGE": "Task has already been completed." })>
</cfif>
<!--- Check if this is a service point task (customer-facing) --->
<cfset hasServicePoint = (val(qTask.ServicePointID) GT 0)>
<cfset customerUserID = val(qTask.CustomerUserID)>
<cfset businessOwnerUserID = val(qTask.BusinessOwnerUserID)>
<cfset workerUserID = val(qTask.ClaimedByUserID)>
<cfset ratingRequired = false>
<cfset ratingsCreated = []>
<!--- DISABLED: Worker rating of customer (10% sampling) --->
<!--- TODO: Re-enable when rating UI is ready in the app --->
<!---
<cfif hasServicePoint AND customerUserID GT 0 AND workerUserID GT 0>
<cfset ratingRequired = (TaskID MOD 10 EQ 0)>
<cfif ratingRequired AND structIsEmpty(workerRating)>
<cfset apiAbort({
"OK": false,
"ERROR": "rating_required",
"MESSAGE": "Please rate the customer before completing this task.",
"CustomerUserID": customerUserID,
"Questions": {
"prepared": "Was the customer prepared?",
"completedScope": "Was the scope clear?",
"respectful": "Was the customer respectful?",
"wouldAutoAssign": "Would you serve this customer again?"
}
})>
</cfif>
</cfif>
--->
<!--- === CASH TASK VALIDATION === --->
<cfset cashResult = {}>
<cfset orderCancelled = false>
<!--- Skip cash processing for orphaned tasks (no order linked) OR if cancelling --->
<cfif isCashTask AND qTask.OrderID GT 0 AND NOT CancelOrder>
<cfif CashReceivedCents LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "cash_required", "MESSAGE": "Cash amount is required for cash tasks." })>
</cfif>
<cfif CashReceivedCents GT 50000>
<cfset apiAbort({ "OK": false, "ERROR": "cash_limit", "MESSAGE": "Cash transactions cannot exceed $500." })>
</cfif>
<!--- Check 30-day rolling limit for customer --->
<cfif customerUserID GT 0>
<cfset q30Day = queryTimed("
SELECT COALESCE(SUM(PaymentPaidInCash), 0) AS TotalCashDollars
FROM Payments
WHERE PaymentSentByUserID = ?
AND PaymentAddedOn >= DATE_SUB(NOW(), INTERVAL 30 DAY)
", [{ value = customerUserID, cfsqltype = "cf_sql_integer" }], { datasource = "payfrit" })>
<cfif (val(q30Day.TotalCashDollars) + (CashReceivedCents / 100)) GT 10000>
<cfset apiAbort({ "OK": false, "ERROR": "cash_30day_limit", "MESSAGE": "Customer has exceeded the $10,000 rolling 30-day cash limit." })>
</cfif>
</cfif>
<!--- Calculate order total from line items --->
<cfif qTask.OrderID LTE 0>
<cfset apiAbort({ "OK": false, "ERROR": "no_order", "MESSAGE": "Cash tasks must be linked to an order." })>
</cfif>
<cfset qOrderTotal = queryTimed("
SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID, b.TaxRate, b.PayfritFee
FROM Orders o
INNER JOIN Businesses b ON b.ID = o.BusinessID
LEFT JOIN OrderLineItems oli ON oli.OrderID = o.ID AND oli.IsDeleted = b'0'
WHERE o.ID = ?
GROUP BY o.ID
", [{ value = qTask.OrderID, cfsqltype = "cf_sql_integer" }], { datasource = "payfrit" })>
<cfif qOrderTotal.recordCount EQ 0>
<cfset apiAbort({ "OK": false, "ERROR": "no_order", "MESSAGE": "Order not found for this cash task." })>
</cfif>
<cfset cashSubtotal = val(qOrderTotal.Subtotal)>
<cfset cashTax = cashSubtotal * val(qOrderTotal.TaxRate)>
<cfset cashTip = val(qOrderTotal.TipAmount)>
<cfset cashDeliveryFee = (val(qOrderTotal.OrderTypeID) EQ 3) ? val(qOrderTotal.DeliveryFee) : 0>
<cfset cashPayfritFee = (isNumeric(qOrderTotal.PayfritFee) AND val(qOrderTotal.PayfritFee) GT 0) ? val(qOrderTotal.PayfritFee) : 0.05>
<cfset cashPlatformFee = cashSubtotal * cashPayfritFee>
<cfset orderTotalCents = round((cashSubtotal + cashTax + cashTip + cashDeliveryFee + cashPlatformFee) * 100)>
<cfif CashReceivedCents LT orderTotalCents>
<cfset apiAbort({ "OK": false, "ERROR": "insufficient_cash", "MESSAGE": "Cash received ($#numberFormat(CashReceivedCents/100, '0.00')#) is less than order total ($#numberFormat(orderTotalCents/100, '0.00')#)." })>
</cfif>
<!--- Calculate cash transaction fee (on order total) --->
<cfif orderTotalCents LT 1000>
<cfset feeCents = round(orderTotalCents * 0.0225)>
<cfelse>
<cfset feeCents = 22 + round(orderTotalCents * 0.0005)>
</cfif>
<cfset changeCents = CashReceivedCents - orderTotalCents>
<cfset businessReceivesCents = orderTotalCents - feeCents>
<cfset cashResult = {
"orderTotalCents": orderTotalCents,
"cashReceivedCents": CashReceivedCents,
"changeCents": changeCents,
"feeCents": feeCents,
"businessReceivesCents": businessReceivesCents
}>
</cfif>
<!--- Mark task as completed --->
<cfset queryTimed("
UPDATE Tasks
SET CompletedOn = NOW()
WHERE ID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<!--- Update order status based on task type --->
<cfset orderUpdated = false>
<cfif qTask.OrderID GT 0>
<cfif CancelOrder>
<!--- Cancel task only: leave order untouched so customer can pay another way --->
<cfset orderCancelled = false>
<cfelseif isCashTask>
<!--- Cash task: Set to status 1 (Submitted/Paid) to trigger kitchen flow --->
<!--- Kitchen will then move to 2, then 3 (which creates delivery task) --->
<cfset queryTimed("
UPDATE Orders
SET StatusID = CASE WHEN StatusID = 0 THEN 1 ELSE StatusID END,
PaymentStatus = 'paid',
PaymentCompletedOn = NOW(),
SubmittedOn = CASE WHEN SubmittedOn IS NULL THEN NOW() ELSE SubmittedOn END,
LastEditedOn = NOW()
WHERE ID = ?
", [ { value = qTask.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfelse>
<!--- Regular task (delivery, pickup, etc.): Mark order as Delivered/Complete (status 5) --->
<cfset queryTimed("
UPDATE Orders
SET StatusID = 5,
LastEditedOn = NOW()
WHERE ID = ?
", [ { value = qTask.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
</cfif>
<cfset orderUpdated = true>
</cfif>
<!--- === PAYOUT LEDGER + ACTIVATION WITHHOLDING === --->
<cfset ledgerCreated = false>
<cfset workerUserID_for_payout = val(qTask.ClaimedByUserID)>
<cfif workerUserID_for_payout GT 0>
<!--- Get PayCents from the task --->
<cfset qTaskPay = queryTimed("
SELECT PayCents FROM Tasks WHERE ID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfset grossCents = val(qTaskPay.PayCents)>
<cfif grossCents GT 0>
<!--- Get worker's activation state --->
<cfset qActivation = queryTimed("
SELECT ActivationBalanceCents, ActivationCapCents
FROM Users WHERE ID = ?
", [ { value = workerUserID_for_payout, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfset activationBalance = val(qActivation.ActivationBalanceCents)>
<cfset activationCap = val(qActivation.ActivationCapCents)>
<cfset remainingActivation = activationCap - activationBalance>
<!--- Withholding: min($1, remaining, gross). Never negative. --->
<cfset activationWithhold = 0>
<cfif remainingActivation GT 0>
<cfset activationWithhold = min(100, min(remainingActivation, grossCents))>
</cfif>
<cfset netCents = grossCents - activationWithhold>
<!--- Insert ledger row --->
<cfset queryTimed("
INSERT INTO WorkPayoutLedgers
(UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status)
VALUES
(:userID, :taskID, :gross, :withheld, :net, 'pending_charge')
", {
userID: workerUserID_for_payout,
taskID: TaskID,
gross: grossCents,
withheld: activationWithhold,
net: netCents
}, { datasource = "payfrit" })>
<!--- Increment activation balance --->
<cfif activationWithhold GT 0>
<cfset queryTimed("
UPDATE Users
SET ActivationBalanceCents = ActivationBalanceCents + :withhold
WHERE ID = :userID
", {
withhold: activationWithhold,
userID: workerUserID_for_payout
}, { datasource = "payfrit" })>
</cfif>
<cfset ledgerCreated = true>
</cfif>
</cfif>
<!--- === CASH TRANSACTION PROCESSING === --->
<cfset cashProcessed = false>
<cfif isCashTask AND CashReceivedCents GT 0 AND qTask.OrderID GT 0 AND NOT CancelOrder>
<!--- Credit customer change to their balance --->
<cfif changeCents GT 0 AND customerUserID GT 0>
<cfset queryTimed("
UPDATE Users SET Balance = Balance + :change WHERE ID = :userID
", {
change: changeCents / 100,
userID: customerUserID
}, { datasource = "payfrit" })>
</cfif>
<!--- Credit Payfrit fee to User 0 (Payfrit Network) --->
<cfif feeCents GT 0>
<cfset queryTimed("
UPDATE Users SET Balance = Balance + :fee WHERE ID = 0
", {
fee: feeCents / 100
}, { datasource = "payfrit" })>
</cfif>
<!--- Debit worker (received physical cash, debit digitally) --->
<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>
<!--- Log transaction in Payments table --->
<cfset queryTimed("
INSERT INTO Payments (
PaymentSentByUserID, PaymentReceivedByUserID, PaymentOrderID,
PaymentFromCreditCard, PaymentFromPayfritBalance, PaymentPaidInCash,
PaymentPayfritsCut, PaymentCreditCardFees, PaymentPayfritNetworkFees,
PaymentRemark, PaymentAddedOn
) VALUES (
:sentBy, :receivedBy, :orderID,
0, 0, :cashAmount,
:payfritCut, 0, 0,
'Cash payment', NOW()
)
", {
sentBy: customerUserID,
receivedBy: businessOwnerUserID,
orderID: qTask.OrderID,
cashAmount: CashReceivedCents / 100,
payfritCut: feeCents / 100
}, { datasource = "payfrit" })>
<cfset cashProcessed = true>
</cfif>
<!--- Create rating records for service point tasks --->
<cfif hasServicePoint AND customerUserID GT 0 AND workerUserID GT 0>
<!--- 1. Customer rates Worker (always created, submitted via receipt link) --->
<cfset customerToken = generateToken()>
<cfset queryTimed("
INSERT INTO TaskRatings (
TaskID, ByUserID, ForUserID, Direction,
AccessToken, ExpiresOn
) VALUES (
?, ?, ?, 'customer_rates_worker',
?, DATE_ADD(NOW(), INTERVAL 24 HOUR)
)
", [
{ value = TaskID, cfsqltype = "cf_sql_integer" },
{ value = customerUserID, cfsqltype = "cf_sql_integer" },
{ value = workerUserID, cfsqltype = "cf_sql_integer" },
{ value = customerToken, cfsqltype = "cf_sql_varchar" }
], { datasource = "payfrit" })>
<cfset arrayAppend(ratingsCreated, { "direction": "customer_rates_worker", "token": customerToken })>
<!--- 2. Worker rates Customer (if provided or required) --->
<cfif NOT structIsEmpty(workerRating)>
<cfset workerToken = generateToken()>
<cfset queryTimed("
INSERT INTO TaskRatings (
TaskID, ByUserID, ForUserID, Direction,
Prepared, CompletedScope, Respectful, WouldAutoAssign,
AccessToken, ExpiresOn, CompletedOn
) VALUES (
?, ?, ?, 'worker_rates_customer',
?, ?, ?, ?,
?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NOW()
)
", [
{ value = TaskID, cfsqltype = "cf_sql_integer" },
{ value = workerUserID, cfsqltype = "cf_sql_integer" },
{ value = customerUserID, cfsqltype = "cf_sql_integer" },
{ value = structKeyExists(workerRating,"prepared") ? (workerRating.prepared ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"prepared") },
{ value = structKeyExists(workerRating,"completedScope") ? (workerRating.completedScope ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"completedScope") },
{ value = structKeyExists(workerRating,"respectful") ? (workerRating.respectful ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"respectful") },
{ value = structKeyExists(workerRating,"wouldAutoAssign") ? (workerRating.wouldAutoAssign ? 1 : 0) : javaCast("null",""), cfsqltype = "cf_sql_tinyint", null = !structKeyExists(workerRating,"wouldAutoAssign") },
{ value = workerToken, cfsqltype = "cf_sql_varchar" }
], { datasource = "payfrit" })>
<cfset arrayAppend(ratingsCreated, { "direction": "worker_rates_customer", "submitted": true })>
</cfif>
</cfif>
<cfset response = {
"OK": true,
"ERROR": "",
"MESSAGE": orderCancelled ? "Order cancelled." : "Task completed successfully.",
"TaskID": TaskID,
"OrderUpdated": orderUpdated,
"OrderCancelled": orderCancelled,
"RatingsCreated": ratingsCreated,
"LedgerCreated": ledgerCreated,
"CashProcessed": cashProcessed
}>
<cfif cashProcessed>
<cfset response["CashReceived"] = numberFormat(CashReceivedCents / 100, "0.00")>
<cfset response["OrderTotal"] = numberFormat(orderTotalCents / 100, "0.00")>
<cfset response["Change"] = numberFormat(changeCents / 100, "0.00")>
<cfset response["Fee"] = numberFormat(feeCents / 100, "0.00")>
<cfset response["BusinessReceives"] = numberFormat(businessReceivesCents / 100, "0.00")>
</cfif>
<cfset apiAbort(response)>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "Error completing task",
"DETAIL": cfcatch.message
})>
</cfcatch>
</cftry>