false, 'ERROR' => 'invalid_signature']); } // Check timestamp tolerance (5 minutes) if (abs(time() - (int) $sigTimestamp) > 300) { jsonResponse(['OK' => false, 'ERROR' => 'timestamp_expired']); } $signedPayload = $sigTimestamp . '.' . $payload; $expectedSig = hash_hmac('sha256', $signedPayload, $webhookSecret); if (!hash_equals($expectedSig, strtolower($sigV1))) { jsonResponse(['OK' => false, 'ERROR' => 'signature_mismatch']); } } $event = json_decode($payload, true); $eventType = $event['type'] ?? ''; $eventData = $event['data']['object'] ?? []; switch ($eventType) { case 'payment_intent.succeeded': $paymentIntentID = $eventData['id']; $orderID = (int) ($eventData['metadata']['order_id'] ?? 0); $metaType = $eventData['metadata']['type'] ?? ''; // === TAB CLOSE CAPTURE === if ($metaType === 'tab_close') { $tabID = (int) ($eventData['metadata']['tab_id'] ?? 0); if ($tabID > 0) { queryTimed(" UPDATE Tabs SET StatusID = 3, PaymentStatus = 'captured', CapturedOn = NOW(), ClosedOn = NOW() WHERE ID = ? AND StatusID = 2 ", [$tabID]); $qTabOrders = queryTimed("SELECT OrderID FROM TabOrders WHERE TabID = ? AND ApprovalStatus = 'approved'", [$tabID]); foreach ($qTabOrders as $row) { queryTimed("UPDATE Orders SET PaymentStatus = 'paid', PaymentCompletedOn = NOW() WHERE ID = ?", [$row['OrderID']]); } } break; } if ($orderID > 0) { // Mark order as paid queryTimed(" UPDATE Orders SET PaymentStatus = 'paid', PaymentCompletedOn = NOW(), SubmittedOn = COALESCE(SubmittedOn, NOW()), StatusID = CASE WHEN StatusID = 0 THEN 1 ELSE StatusID END WHERE ID = ? ", [$orderID]); // Deduct user balance if applied at checkout $qBalOrder = queryOne("SELECT BalanceApplied, UserID FROM Orders WHERE ID = ?", [$orderID]); if ($qBalOrder && (float) ($qBalOrder['BalanceApplied'] ?? 0) > 0) { queryTimed("UPDATE Users SET Balance = GREATEST(Balance - ?, 0) WHERE ID = ?", [(float) $qBalOrder['BalanceApplied'], (int) $qBalOrder['UserID']]); } // Create kitchen task $qOrder = queryOne(" SELECT o.BusinessID, o.ServicePointID, sp.Name AS ServicePointName FROM Orders o LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID WHERE o.ID = ? ", [$orderID]); if ($qOrder) { $tableName = !empty(trim($qOrder['ServicePointName'] ?? '')) ? $qOrder['ServicePointName'] : 'Table'; $taskTitle = "Prepare Order #$orderID for $tableName"; queryTimed(" INSERT INTO Tasks (BusinessID, OrderID, ServicePointID, Title, CreatedOn, ClaimedByUserID) VALUES (?, ?, ?, ?, NOW(), 0) ", [$qOrder['BusinessID'], $orderID, (int) ($qOrder['ServicePointID'] ?? 0), $taskTitle]); } } // === WORKER PAYOUT TRANSFER === try { $qLedger = queryOne(" SELECT ID, UserID, TaskID, NetTransferCents, StripeTransferID, Status FROM WorkPayoutLedgers WHERE StripePaymentIntentID = ? LIMIT 1 ", [$paymentIntentID]); if ($qLedger && empty($qLedger['StripeTransferID']) && $qLedger['Status'] === 'pending_charge') { queryTimed("UPDATE WorkPayoutLedgers SET Status = 'charged', UpdatedAt = NOW() WHERE ID = ?", [$qLedger['ID']]); $qWorker = queryOne("SELECT StripeConnectedAccountID FROM Users WHERE ID = ?", [$qLedger['UserID']]); $workerAccountID = $qWorker['StripeConnectedAccountID'] ?? ''; if (!empty(trim($workerAccountID)) && (int) $qLedger['NetTransferCents'] > 0) { $transferData = stripeRequest('POST', 'https://api.stripe.com/v1/transfers', [ 'amount' => $qLedger['NetTransferCents'], 'currency' => 'usd', 'destination' => $workerAccountID, 'metadata[user_id]' => $qLedger['UserID'], 'metadata[task_id]' => $qLedger['TaskID'], 'metadata[ledger_id]' => $qLedger['ID'], 'metadata[activation_withheld_cents]' => 0, ], ['Idempotency-Key' => 'transfer-ledger-' . $qLedger['ID']]); if (isset($transferData['id'])) { queryTimed("UPDATE WorkPayoutLedgers SET StripeTransferID = ?, Status = 'transferred', UpdatedAt = NOW() WHERE ID = ?", [$transferData['id'], $qLedger['ID']]); } } elseif ((int) $qLedger['NetTransferCents'] === 0) { queryTimed("UPDATE WorkPayoutLedgers SET Status = 'transferred', UpdatedAt = NOW() WHERE ID = ?", [$qLedger['ID']]); } } } catch (Exception $e) { // Non-fatal } // === SP-SM: GRANT OWNER TRANSFER === try { $grantOwnerFeeCents = (int) ($eventData['metadata']['grant_owner_fee_cents'] ?? 0); $grantOwnerBizID = (int) ($eventData['metadata']['grant_owner_business_id'] ?? 0); $grantMetaID = (int) ($eventData['metadata']['grant_id'] ?? 0); if ($grantOwnerFeeCents > 0 && $grantOwnerBizID > 0) { $qOwnerBiz = queryOne("SELECT StripeAccountID FROM Businesses WHERE ID = ?", [$grantOwnerBizID]); $ownerStripeAcct = $qOwnerBiz['StripeAccountID'] ?? ''; if (!empty(trim($ownerStripeAcct))) { stripeRequest('POST', 'https://api.stripe.com/v1/transfers', [ 'amount' => $grantOwnerFeeCents, 'currency' => 'usd', 'destination' => $ownerStripeAcct, 'metadata[grant_id]' => $grantMetaID, 'metadata[order_id]' => $orderID, 'metadata[type]' => 'grant_owner_fee', ], ['Idempotency-Key' => "grant-transfer-$orderID-$grantMetaID"]); } } } catch (Exception $e) { // Non-fatal } break; case 'payment_intent.payment_failed': $orderID = (int) ($eventData['metadata']['order_id'] ?? 0); $failureMessage = $eventData['last_payment_error']['message'] ?? 'Payment failed'; if ($orderID > 0) { queryTimed("UPDATE Orders SET PaymentStatus = 'failed', PaymentError = ? WHERE ID = ?", [$failureMessage, $orderID]); } break; case 'charge.refunded': $paymentIntentID = $eventData['payment_intent'] ?? ''; $refundAmount = ($eventData['amount_refunded'] ?? 0) / 100; if (!empty($paymentIntentID)) { queryTimed(" UPDATE Orders SET PaymentStatus = 'refunded', RefundAmount = ?, RefundedOn = NOW() WHERE StripePaymentIntentID = ? ", [$refundAmount, $paymentIntentID]); } break; case 'charge.dispute.created': $paymentIntentID = $eventData['payment_intent'] ?? ''; if (!empty($paymentIntentID)) { $qOrder = queryOne("SELECT ID, BusinessID FROM Orders WHERE StripePaymentIntentID = ?", [$paymentIntentID]); if ($qOrder) { queryTimed("UPDATE Orders SET PaymentStatus = 'disputed' WHERE StripePaymentIntentID = ?", [$paymentIntentID]); queryTimed(" INSERT INTO Tasks (BusinessID, CategoryID, Title, Details, CreatedOn, StatusID, SourceType, SourceID) VALUES (?, 4, 'Payment Dispute', ?, NOW(), 0, 'dispute', ?) ", [ $qOrder['BusinessID'], 'Order #' . $qOrder['ID'] . ' has been disputed. Review immediately.', $qOrder['ID'], ]); } } break; case 'account.updated': $accountID = $eventData['id']; $chargesEnabled = $eventData['charges_enabled'] ?? false; $payoutsEnabled = $eventData['payouts_enabled'] ?? false; // Business accounts if ($chargesEnabled && $payoutsEnabled) { queryTimed("UPDATE Businesses SET StripeOnboardingComplete = 1 WHERE StripeAccountID = ?", [$accountID]); } // Worker accounts try { $qWorkerAcct = queryOne("SELECT ID FROM Users WHERE StripeConnectedAccountID = ? LIMIT 1", [$accountID]); if ($qWorkerAcct) { queryTimed("UPDATE Users SET StripePayoutsEnabled = ? WHERE StripeConnectedAccountID = ?", [$payoutsEnabled ? 1 : 0, $accountID]); } } catch (Exception $e) { // Non-fatal } break; case 'checkout.session.completed': try { $sessionMetadata = $eventData['metadata'] ?? []; $metaType = $sessionMetadata['type'] ?? ''; $metaUserID = (int) ($sessionMetadata['user_id'] ?? 0); // Activation early unlock if ($metaType === 'activation_unlock' && $metaUserID > 0) { queryTimed("UPDATE Users SET ActivationBalanceCents = ActivationCapCents WHERE ID = ?", [$metaUserID]); } // Tip payment if ($metaType === 'tip') { $metaTipID = (int) ($sessionMetadata['tip_id'] ?? 0); $metaWorkerID = (int) ($sessionMetadata['worker_user_id'] ?? 0); $tipPaymentIntent = $eventData['payment_intent'] ?? ''; if ($metaTipID > 0) { queryTimed("UPDATE Tips SET Status = 'paid', PaidOn = NOW(), StripePaymentIntentID = ? WHERE ID = ? AND Status = 'pending'", [$tipPaymentIntent, $metaTipID]); // Transfer tip to worker if ($metaWorkerID > 0) { $qTipWorker = queryOne("SELECT StripeConnectedAccountID FROM Users WHERE ID = ?", [$metaWorkerID]); $tipWorkerAcct = $qTipWorker['StripeConnectedAccountID'] ?? ''; if (!empty(trim($tipWorkerAcct))) { $qTipAmt = queryOne("SELECT AmountCents FROM Tips WHERE ID = ?", [$metaTipID]); if ($qTipAmt && (int) $qTipAmt['AmountCents'] > 0) { $tipTransferData = stripeRequest('POST', 'https://api.stripe.com/v1/transfers', [ 'amount' => $qTipAmt['AmountCents'], 'currency' => 'usd', 'destination' => $tipWorkerAcct, 'metadata[type]' => 'tip', 'metadata[tip_id]' => $metaTipID, 'metadata[worker_user_id]' => $metaWorkerID, ], ['Idempotency-Key' => "tip-transfer-$metaTipID"]); if (isset($tipTransferData['id'])) { queryTimed("UPDATE Tips SET Status = 'transferred', StripeTransferID = ? WHERE ID = ?", [$tipTransferData['id'], $metaTipID]); } } } } } } } catch (Exception $e) { // Non-fatal } break; } jsonResponse(['OK' => true, 'RECEIVED' => true]); } catch (Exception $e) { jsonResponse(['OK' => false, 'ERROR' => $e->getMessage()]); }