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 Pinkyfloyd df903a9c75 Allow completing orphaned cash tasks without payment processing
Skip cash validation and processing for tasks with no linked order.
These are likely test tasks that were created before order linking.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 12:26:24 -08:00

397 lines
16 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 )>
<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 = {}>
<!--- Skip cash processing for orphaned tasks (no order linked) --->
<cfif isCashTask AND qTask.OrderID GT 0>
<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
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 cashPlatformFee = cashSubtotal * 0.05>
<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" })>
<!--- If task has an associated order, mark it as Delivered/Complete (status 5) --->
<cfset orderUpdated = false>
<cfif qTask.OrderID GT 0>
<cfset queryTimed("
UPDATE Orders
SET StatusID = 5,
LastEditedOn = NOW()
WHERE ID = ?
", [ { value = qTask.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<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>
<!--- 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": "Task completed successfully.",
"TaskID": TaskID,
"OrderUpdated": orderUpdated,
"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>