Complete port of all 163 API endpoints from Lucee/CFML to PHP 8.3. Shared helpers in api/helpers.php (DB, auth, request/response, security). PDO prepared statements throughout. Same JSON response shapes as CFML.
365 lines
16 KiB
PHP
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 = 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()]);
|
|
}
|