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 = strtolower(str_replace('-', '', 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 = strtolower(str_replace('-', '', 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()]); }