payfrit-api/api/tasks/complete.php
John Mizerek 5761ed3e88 Standardize UUID format: generateUUID() now returns unhyphenated 32-char hex
- Remove vsprintf hyphenation from generateUUID() in helpers.php
- Remove redundant str_replace('-', '', ...) wrappers in callers
- Fix grants/create, tabs/open, orders/getOrCreateCart which were storing hyphenated UUIDs
- Cast prices to float in getForBuilder.php
- Uppercase auth response keys (TOKEN, USERID, FIRSTNAME)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:43:02 -07:00

365 lines
16 KiB
PHP

<?php
require_once __DIR__ . '/../helpers.php';
runAuth();
/**
* Complete a task (with cash processing, payout ledger, ratings)
* POST: { TaskID, UserID?, workerRating?, CashReceivedCents?, CancelOrder? }
*/
$data = readJsonBody();
$taskID = (int) ($data['TaskID'] ?? 0);
global $userId;
$userID = $userId > 0 ? $userId : (int) ($data['UserID'] ?? 0);
$workerRating = $data['workerRating'] ?? [];
$cashReceivedCents = (int) ($data['CashReceivedCents'] ?? 0);
$cancelOrder = !empty($data['CancelOrder']) && $data['CancelOrder'] === true;
if ($taskID <= 0) {
apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'TaskID is required.']);
}
try {
// Verify task exists
$qTask = queryOne("
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,
COALESCE(emp.RoleID, 1) AS WorkerRoleID
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
LEFT JOIN Employees emp ON emp.BusinessID = t.BusinessID AND emp.UserID = t.ClaimedByUserID AND emp.IsActive = 1
WHERE t.ID = ?
", [$taskID]);
if (!$qTask) {
apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Task not found.']);
}
$isChatTask = ((int) $qTask['TaskTypeID'] === 2);
$isCashTask = (!empty(trim($qTask['TaskTypeName'] ?? '')) && stripos($qTask['TaskTypeName'], 'Cash') !== false);
$isAdminRole = ((int) $qTask['WorkerRoleID'] >= 2);
if (!$isChatTask && (int) $qTask['ClaimedByUserID'] === 0) {
apiAbort(['OK' => false, 'ERROR' => 'not_claimed', 'MESSAGE' => 'Task has not been claimed yet.']);
}
if (!$isChatTask && $userID > 0 && (int) $qTask['ClaimedByUserID'] !== $userID) {
apiAbort(['OK' => false, 'ERROR' => 'not_yours', 'MESSAGE' => 'This task was claimed by someone else.']);
}
if (!empty(trim($qTask['CompletedOn'] ?? ''))) {
apiAbort(['OK' => false, 'ERROR' => 'already_completed', 'MESSAGE' => 'Task has already been completed.']);
}
$hasServicePoint = ((int) ($qTask['ServicePointID'] ?? 0) > 0);
$customerUserID = (int) ($qTask['CustomerUserID'] ?? 0);
$businessOwnerUserID = (int) ($qTask['BusinessOwnerUserID'] ?? 0);
$workerUserID = (int) $qTask['ClaimedByUserID'];
$ratingsCreated = [];
$orderCancelled = false;
// === CASH TASK VALIDATION ===
$cashResult = [];
$customerFeeDollars = 0;
$businessFeeDollars = 0;
$payfritRevenueCents = 0;
$orderTotalCents = 0;
$cashOwedCents = 0;
$changeCents = 0;
$businessReceivesCents = 0;
$balanceAppliedCents = 0;
$activationWithhold = 0;
if ($isCashTask && (int) $qTask['OrderID'] > 0 && !$cancelOrder) {
if ($cashReceivedCents <= 0) {
apiAbort(['OK' => false, 'ERROR' => 'cash_required', 'MESSAGE' => 'Cash amount is required for cash tasks.']);
}
if ($cashReceivedCents > 50000) {
apiAbort(['OK' => false, 'ERROR' => 'cash_limit', 'MESSAGE' => 'Cash transactions cannot exceed $500.']);
}
// Check 30-day rolling limit for customer
if ($customerUserID > 0) {
$q30Day = queryOne("
SELECT COALESCE(SUM(PaymentPaidInCash), 0) AS TotalCashDollars
FROM Payments
WHERE PaymentSentByUserID = ?
AND PaymentAddedOn >= DATE_SUB(NOW(), INTERVAL 30 DAY)
", [$customerUserID]);
if (((float) $q30Day['TotalCashDollars'] + ($cashReceivedCents / 100)) > 10000) {
apiAbort(['OK' => false, 'ERROR' => 'cash_30day_limit', 'MESSAGE' => 'Customer has exceeded the $10,000 rolling 30-day cash limit.']);
}
}
if ((int) $qTask['OrderID'] <= 0) {
apiAbort(['OK' => false, 'ERROR' => 'no_order', 'MESSAGE' => 'Cash tasks must be linked to an order.']);
}
// Calculate order total from line items
$qOrderTotal = queryOne("
SELECT SUM(oli.Price * oli.Quantity) AS Subtotal, o.TipAmount, o.DeliveryFee, o.OrderTypeID,
o.BalanceApplied, 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 = 0
WHERE o.ID = ?
GROUP BY o.ID
", [$qTask['OrderID']]);
if (!$qOrderTotal) {
apiAbort(['OK' => false, 'ERROR' => 'no_order', 'MESSAGE' => 'Order not found for this cash task.']);
}
$cashSubtotal = (float) $qOrderTotal['Subtotal'];
$cashTax = $cashSubtotal * (float) $qOrderTotal['TaxRate'];
$cashTip = (float) $qOrderTotal['TipAmount'];
$cashDeliveryFee = ((int) $qOrderTotal['OrderTypeID'] === 3) ? (float) $qOrderTotal['DeliveryFee'] : 0;
$cashPayfritFee = (is_numeric($qOrderTotal['PayfritFee']) && (float) $qOrderTotal['PayfritFee'] > 0)
? (float) $qOrderTotal['PayfritFee'] : 0.05;
$customerFeeDollars = $cashSubtotal * $cashPayfritFee;
$businessFeeDollars = $cashSubtotal * $cashPayfritFee;
$payfritRevenueDollars = $customerFeeDollars + $businessFeeDollars;
$orderTotalCents = (int) round(($cashSubtotal + $cashTax + $cashTip + $cashDeliveryFee + $customerFeeDollars) * 100);
$balanceAppliedCents = (int) round((float) ($qOrderTotal['BalanceApplied'] ?? 0) * 100);
$cashOwedCents = $orderTotalCents - $balanceAppliedCents;
if ($cashOwedCents < 0) $cashOwedCents = 0;
if ($cashReceivedCents < $cashOwedCents) {
apiAbort(['OK' => false, 'ERROR' => 'insufficient_cash', 'MESSAGE' => sprintf(
'Cash received ($%s) is less than cash owed ($%s).',
number_format($cashReceivedCents / 100, 2),
number_format($cashOwedCents / 100, 2)
)]);
}
$payfritRevenueCents = (int) round($payfritRevenueDollars * 100);
$changeCents = $cashReceivedCents - $cashOwedCents;
$businessReceivesCents = $orderTotalCents - $payfritRevenueCents;
}
// Mark task as completed
queryTimed("UPDATE Tasks SET CompletedOn = NOW() WHERE ID = ?", [$taskID]);
// Update order status based on task type
$orderUpdated = false;
if ((int) $qTask['OrderID'] > 0) {
if ($cancelOrder) {
// Cancel task only: leave order untouched
$orderCancelled = false;
} elseif ($isCashTask) {
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 = ?
", [$qTask['OrderID']]);
} else {
queryTimed("
UPDATE Orders
SET StatusID = 5, LastEditedOn = NOW()
WHERE ID = ?
", [$qTask['OrderID']]);
}
$orderUpdated = true;
}
// === PAYOUT LEDGER + ACTIVATION WITHHOLDING ===
$ledgerCreated = false;
$workerUserID_for_payout = (int) $qTask['ClaimedByUserID'];
if ($workerUserID_for_payout > 0 && !$isAdminRole) {
$qTaskPay = queryOne("SELECT PayCents FROM Tasks WHERE ID = ?", [$taskID]);
$grossCents = (int) ($qTaskPay['PayCents'] ?? 0);
if ($grossCents > 0) {
$qActivation = queryOne("
SELECT ActivationBalanceCents, ActivationCapCents
FROM Users WHERE ID = ?
", [$workerUserID_for_payout]);
$activationBalance = (int) ($qActivation['ActivationBalanceCents'] ?? 0);
$activationCap = (int) ($qActivation['ActivationCapCents'] ?? 0);
$remainingActivation = $activationCap - $activationBalance;
$activationWithhold = 0;
if ($remainingActivation > 0) {
$activationWithhold = min(100, min($remainingActivation, $grossCents));
}
$netCents = $grossCents - $activationWithhold;
queryTimed("
INSERT INTO WorkPayoutLedgers
(UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status)
VALUES (?, ?, ?, ?, ?, 'pending_charge')
", [$workerUserID_for_payout, $taskID, $grossCents, $activationWithhold, $netCents]);
if ($activationWithhold > 0) {
queryTimed("
UPDATE Users
SET ActivationBalanceCents = ActivationBalanceCents + ?
WHERE ID = ?
", [$activationWithhold, $workerUserID_for_payout]);
}
$ledgerCreated = true;
}
}
// === CASH TRANSACTION PROCESSING ===
$cashProcessed = false;
if ($isCashTask && $cashReceivedCents > 0 && (int) $qTask['OrderID'] > 0 && !$cancelOrder) {
// Look up worker's role for this business
$workerRoleID = 1;
if ($workerUserID_for_payout > 0) {
$qWorkerRole = queryOne("
SELECT COALESCE(RoleID, 1) AS RoleID
FROM Employees
WHERE UserID = ? AND BusinessID = ? AND IsActive = 1
ORDER BY RoleID DESC
LIMIT 1
", [$workerUserID_for_payout, $qTask['BusinessID']]);
if ($qWorkerRole) {
$workerRoleID = (int) $qWorkerRole['RoleID'];
}
}
$isAdmin = ($workerRoleID >= 2);
// Credit customer change to their balance
if ($changeCents > 0 && $customerUserID > 0) {
queryTimed("UPDATE Users SET Balance = Balance + ? WHERE ID = ?", [$changeCents / 100, $customerUserID]);
}
// Credit Payfrit revenue to User 0
if ($payfritRevenueCents > 0) {
queryTimed("UPDATE Users SET Balance = Balance + ? WHERE ID = 0", [$payfritRevenueCents / 100]);
}
// Debit for physical cash received
if ($isAdmin) {
// Admin/Manager: cash goes to the business, delete worker's pending payout
if ($workerUserID_for_payout > 0) {
queryTimed("
DELETE FROM WorkPayoutLedgers
WHERE TaskID = ? AND UserID = ? AND Status = 'pending_charge'
", [$taskID, $workerUserID_for_payout]);
if ($ledgerCreated && $activationWithhold > 0) {
queryTimed("
UPDATE Users
SET ActivationBalanceCents = GREATEST(0, ActivationBalanceCents - ?)
WHERE ID = ?
", [$activationWithhold, $workerUserID_for_payout]);
}
}
} else {
// Staff: cash stays in their pocket, debit their payout balance
if ($workerUserID_for_payout > 0) {
queryTimed("
INSERT INTO WorkPayoutLedgers
(UserID, TaskID, GrossEarningsCents, ActivationWithheldCents, NetTransferCents, Status)
VALUES (?, ?, ?, 0, ?, 'cash_debit')
", [$workerUserID_for_payout, $taskID, -$cashReceivedCents, -$cashReceivedCents]);
}
}
// Log transaction in Payments table
queryTimed("
INSERT INTO Payments (
PaymentSentByUserID, PaymentReceivedByUserID, PaymentOrderID,
PaymentFromCreditCard, PaymentFromPayfritBalance, PaymentPaidInCash,
PaymentPayfritsCut, PaymentCreditCardFees, PaymentPayfritNetworkFees,
PaymentRemark, PaymentAddedOn
) VALUES (?, ?, ?, 0, 0, ?, ?, 0, ?, ?, NOW())
", [
$customerUserID,
$businessOwnerUserID,
$qTask['OrderID'],
$cashReceivedCents / 100,
$payfritRevenueCents / 100,
round($businessFeeDollars * 100) / 100,
$isAdmin ? 'Cash payment (collected by manager)' : 'Cash payment',
]);
$cashProcessed = true;
}
// Create rating records for service point tasks
if ($hasServicePoint && $customerUserID > 0 && $workerUserID > 0) {
// 1. Customer rates Worker
$customerToken = generateUUID();
queryTimed("
INSERT INTO TaskRatings (
TaskID, ByUserID, ForUserID, Direction,
AccessToken, ExpiresOn
) VALUES (?, ?, ?, 'customer_rates_worker', ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))
", [$taskID, $customerUserID, $workerUserID, $customerToken]);
$ratingsCreated[] = ['direction' => 'customer_rates_worker', 'token' => $customerToken];
// 2. Worker rates Customer (if provided)
if (!empty($workerRating)) {
$workerToken = generateUUID();
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())
", [
$taskID,
$workerUserID,
$customerUserID,
isset($workerRating['prepared']) ? ($workerRating['prepared'] ? 1 : 0) : null,
isset($workerRating['completedScope']) ? ($workerRating['completedScope'] ? 1 : 0) : null,
isset($workerRating['respectful']) ? ($workerRating['respectful'] ? 1 : 0) : null,
isset($workerRating['wouldAutoAssign']) ? ($workerRating['wouldAutoAssign'] ? 1 : 0) : null,
$workerToken,
]);
$ratingsCreated[] = ['direction' => 'worker_rates_customer', 'submitted' => true];
}
}
$response = [
'OK' => true,
'ERROR' => '',
'MESSAGE' => $orderCancelled ? 'Order cancelled.' : 'Task completed successfully.',
'TaskID' => $taskID,
'OrderUpdated' => $orderUpdated,
'OrderCancelled' => $orderCancelled,
'RatingsCreated' => $ratingsCreated,
'LedgerCreated' => $ledgerCreated,
'CashProcessed' => $cashProcessed,
];
if ($cashProcessed) {
$response['CashReceived'] = number_format($cashReceivedCents / 100, 2, '.', '');
$response['OrderTotal'] = number_format($orderTotalCents / 100, 2, '.', '');
$response['Change'] = number_format($changeCents / 100, 2, '.', '');
$response['CustomerFee'] = number_format(round($customerFeeDollars * 100) / 100, 2, '.', '');
$response['BusinessFee'] = number_format(round($businessFeeDollars * 100) / 100, 2, '.', '');
$response['PayfritRevenue'] = number_format($payfritRevenueCents / 100, 2, '.', '');
$response['BusinessReceives'] = number_format($businessReceivesCents / 100, 2, '.', '');
if ($balanceAppliedCents > 0) {
$response['BalanceApplied'] = number_format($balanceAppliedCents / 100, 2, '.', '');
$response['CashOwed'] = number_format($cashOwedCents / 100, 2, '.', '');
}
$response['CashRoutedTo'] = $isAdmin ? 'business' : 'worker';
$response['WorkerRoleID'] = $workerRoleID;
}
jsonResponse($response);
} catch (Exception $e) {
jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'Error completing task', 'DETAIL' => $e->getMessage()]);
}