payfrit-api/api/stripe/webhook.php
John Mizerek 1f81d98c52 Initial PHP API migration from CFML
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.
2026-03-14 14:26:59 -07:00

293 lines
14 KiB
PHP

<?php
require_once __DIR__ . '/../helpers.php';
require_once __DIR__ . '/../config/stripe.php';
// NO runAuth() — webhook is called by Stripe, not a user
try {
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$config = getStripeConfig();
$webhookSecret = $config['webhookSecret'];
// Verify webhook signature (Stripe HMAC-SHA256)
if (!empty($webhookSecret) && !empty($sigHeader)) {
$sigParts = [];
foreach (explode(',', $sigHeader) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) === 2) $sigParts[$kv[0]] = $kv[1];
}
$sigTimestamp = $sigParts['t'] ?? '';
$sigV1 = $sigParts['v1'] ?? '';
if (empty($sigTimestamp) || empty($sigV1)) {
jsonResponse(['OK' => 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()]);
}