false, 'ERROR' => 'BusinessID is required']); if ($orderID === 0) apiAbort(['OK' => false, 'ERROR' => 'OrderID is required']); if ($subtotal <= 0) apiAbort(['OK' => false, 'ERROR' => 'Invalid subtotal']); $config = getStripeConfig(); if (empty($config['secretKey'])) apiAbort(['OK' => false, 'ERROR' => 'Stripe is not configured']); // Get business Stripe account and fee settings $qBusiness = queryOne("SELECT StripeAccountID, StripeOnboardingComplete, Name, PayfritFee FROM Businesses WHERE ID = ?", [$businessID]); if (!$qBusiness) apiAbort(['OK' => false, 'ERROR' => 'Business not found']); // Get order details $qOrder = queryOne(" SELECT o.DeliveryFee, o.OrderTypeID, o.GrantID, o.GrantOwnerBusinessID, o.GrantEconomicsType, o.GrantEconomicsValue, o.StripePaymentIntentID, o.UserID, o.BalanceApplied AS PrevBalanceApplied, u.StripeCustomerId, u.EmailAddress, u.FirstName, u.LastName, u.Balance FROM Orders o LEFT JOIN Users u ON u.ID = o.UserID WHERE o.ID = ? ", [$orderID]); $deliveryFee = 0; if ($qOrder && (int) $qOrder['OrderTypeID'] === 3) { $deliveryFee = (float) ($qOrder['DeliveryFee'] ?? 0); } // SP-SM: Resolve grant economics $grantOwnerFeeCents = 0; $grantOwnerBusinessID = 0; $grantID = 0; if ($qOrder && (int) ($qOrder['GrantID'] ?? 0) > 0) { $grantID = (int) $qOrder['GrantID']; $grantOwnerBusinessID = (int) ($qOrder['GrantOwnerBusinessID'] ?? 0); $grantEconType = $qOrder['GrantEconomicsType'] ?? 'none'; $grantEconValue = (float) ($qOrder['GrantEconomicsValue'] ?? 0); if ($grantEconType === 'flat_fee' && $grantEconValue > 0) { $grantOwnerFeeCents = round($grantEconValue * 100); } elseif ($grantEconType === 'percent_of_orders' && $grantEconValue > 0) { $grantOwnerFeeCents = round($subtotal * ($grantEconValue / 100) * 100); } } $hasStripeConnect = (int) ($qBusiness['StripeOnboardingComplete'] ?? 0) === 1 && !empty(trim($qBusiness['StripeAccountID'] ?? '')); // ============================================================ // FEE CALCULATION (from Businesses.PayfritFee) // ============================================================ $payfritFee = $qBusiness['PayfritFee'] ?? 0; if (!is_numeric($payfritFee) || $payfritFee <= 0) { apiAbort(['OK' => false, 'ERROR' => 'Business PayfritFee not configured']); } $customerFeePercent = (float) $payfritFee; $businessFeePercent = $customerFeePercent; $cardFeePercent = 0.029; $cardFeeFixed = 0.30; $payfritCustomerFee = $subtotal * $customerFeePercent; $payfritBusinessFee = $subtotal * $businessFeePercent; $totalBeforeCardFee = $subtotal + $tax + $tip + $deliveryFee + $payfritCustomerFee; // ============================================================ // AUTO-APPLY USER BALANCE // ============================================================ $balanceApplied = 0; $userBalance = (float) ($qOrder['Balance'] ?? 0); $orderUserID = (int) ($qOrder['UserID'] ?? 0); if ($userBalance > 0 && $orderUserID > 0) { $balanceApplied = round(min($userBalance, $totalBeforeCardFee) * 100) / 100; $adjustedTest = (($totalBeforeCardFee - $balanceApplied) + $cardFeeFixed) / (1 - $cardFeePercent); if ($adjustedTest < 0.50) { $maxBalance = $totalBeforeCardFee - ((0.50 * (1 - $cardFeePercent)) - $cardFeeFixed); $balanceApplied = round(max(0, min($userBalance, $maxBalance)) * 100) / 100; } if ($balanceApplied > 0) { queryTimed("UPDATE Orders SET BalanceApplied = ? WHERE ID = ?", [$balanceApplied, $orderID]); } } $adjustedBeforeCardFee = $totalBeforeCardFee - $balanceApplied; $totalCustomerPays = ($adjustedBeforeCardFee + $cardFeeFixed) / (1 - $cardFeePercent); $cardFee = $totalCustomerPays - $adjustedBeforeCardFee; $totalAmountCents = round($totalCustomerPays * 100); $totalPlatformFeeCents = round(($payfritCustomerFee + $payfritBusinessFee) * 100); // ============================================================ // CHECK FOR EXISTING PAYMENTINTENT // ============================================================ $existingPiId = $qOrder['StripePaymentIntentID'] ?? ''; if (!empty(trim($existingPiId))) { $existingPi = stripeRequest('GET', "https://api.stripe.com/v1/payment_intents/$existingPiId"); if (!isset($existingPi['error'])) { $piStatus = $existingPi['status'] ?? ''; $reusableStates = ['requires_payment_method', 'requires_confirmation', 'requires_action']; if (in_array($piStatus, $reusableStates)) { if ((int) $existingPi['amount'] !== $totalAmountCents) { $existingPi = stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/$existingPiId", [ 'amount' => $totalAmountCents, ]); } jsonResponse([ 'OK' => true, 'CLIENT_SECRET' => $existingPi['client_secret'], 'PAYMENT_INTENT_ID' => $existingPi['id'], 'PUBLISHABLE_KEY' => $config['publishableKey'], 'REUSED' => true, 'FEE_BREAKDOWN' => [ 'SUBTOTAL' => $subtotal, 'TAX' => $tax, 'TIP' => $tip, 'DELIVERY_FEE' => $deliveryFee, 'PAYFRIT_FEE' => $payfritCustomerFee, 'CARD_FEE' => $cardFee, 'BALANCE_APPLIED' => $balanceApplied, 'TOTAL' => $totalCustomerPays, 'TOTAL_BEFORE_BALANCE' => ($balanceApplied > 0) ? $totalCustomerPays + $balanceApplied : 0, 'GRANT_OWNER_FEE_CENTS' => $grantOwnerFeeCents, ], ]); } elseif ($piStatus === 'succeeded') { apiAbort(['OK' => false, 'ERROR' => 'already_paid', 'MESSAGE' => 'This order has already been paid.']); } } // Terminal or not found — clear and create new queryTimed("UPDATE Orders SET StripePaymentIntentID = NULL WHERE ID = ?", [$orderID]); } // ============================================================ // STRIPE CUSTOMER (for saving payment methods) // ============================================================ $stripeCustomerId = ''; $orderUserID = (int) ($qOrder['UserID'] ?? 0); if ($orderUserID > 0) { $stripeCustomerId = $qOrder['StripeCustomerId'] ?? ''; // Validate existing customer $needNewCustomer = empty(trim($stripeCustomerId)); if (!$needNewCustomer) { $validateData = stripeRequest('GET', "https://api.stripe.com/v1/customers/$stripeCustomerId"); if (isset($validateData['error']) || !isset($validateData['id'])) { $needNewCustomer = true; } } if ($needNewCustomer) { $customerParams = ['metadata[user_id]' => $orderUserID]; $customerName = trim(($qOrder['FirstName'] ?? '') . ' ' . ($qOrder['LastName'] ?? '')); if (!empty($customerName)) $customerParams['name'] = $customerName; if (!empty(trim($qOrder['EmailAddress'] ?? ''))) $customerParams['email'] = $qOrder['EmailAddress']; $customerData = stripeRequest('POST', 'https://api.stripe.com/v1/customers', $customerParams); if (!isset($customerData['error']) && isset($customerData['id'])) { $stripeCustomerId = $customerData['id']; queryTimed("UPDATE Users SET StripeCustomerId = ? WHERE ID = ?", [$stripeCustomerId, $orderUserID]); } } } // ============================================================ // CREATE PAYMENTINTENT // ============================================================ $piParams = [ 'amount' => $totalAmountCents, 'currency' => 'usd', 'automatic_payment_methods[enabled]' => 'true', 'metadata[order_id]' => $orderID, 'metadata[business_id]' => $businessID, 'description' => "Order #$orderID at " . $qBusiness['Name'], ]; if (!empty(trim($stripeCustomerId))) { $piParams['customer'] = $stripeCustomerId; $piParams['setup_future_usage'] = 'off_session'; } if ($hasStripeConnect) { $effectivePlatformFeeCents = $totalPlatformFeeCents + $grantOwnerFeeCents; $piParams['application_fee_amount'] = $effectivePlatformFeeCents; $piParams['transfer_data[destination]'] = $qBusiness['StripeAccountID']; } if ($grantOwnerFeeCents > 0) { $piParams['metadata[grant_id]'] = $grantID; $piParams['metadata[grant_owner_business_id]'] = $grantOwnerBusinessID; $piParams['metadata[grant_owner_fee_cents]'] = $grantOwnerFeeCents; } if (!empty($customerEmail)) { $piParams['receipt_email'] = $customerEmail; } $piHeaders = ['Idempotency-Key' => "pi-order-$orderID"]; $piData = stripeRequest('POST', 'https://api.stripe.com/v1/payment_intents', $piParams, $piHeaders); if (isset($piData['error'])) { apiAbort(['OK' => false, 'ERROR' => $piData['error']['message']]); } // Store PaymentIntent ID on order queryTimed("UPDATE Orders SET StripePaymentIntentID = ? WHERE ID = ?", [$piData['id'], $orderID]); // Link PaymentIntent to worker payout ledger try { queryTimed(" UPDATE WorkPayoutLedgers wpl INNER JOIN Tasks t ON t.ID = wpl.TaskID AND t.OrderID = ? SET wpl.StripePaymentIntentID = ? WHERE wpl.Status = 'pending_charge' AND wpl.StripePaymentIntentID IS NULL ", [$orderID, $piData['id']]); } catch (Exception $e) { // Non-fatal } // ============================================================ // CREATE CHECKOUT SESSION (for iOS) // ============================================================ $checkoutParams = [ 'mode' => 'payment', 'line_items[0][price_data][currency]' => 'usd', 'line_items[0][price_data][product_data][name]' => "Order #$orderID at " . $qBusiness['Name'], 'line_items[0][price_data][unit_amount]' => $totalAmountCents, 'line_items[0][quantity]' => '1', 'success_url' => "payfrit://stripe-redirect?success=true&order_id=$orderID", 'cancel_url' => "payfrit://stripe-redirect?success=false&error=cancelled&order_id=$orderID", 'metadata[order_id]' => $orderID, 'metadata[business_id]' => $businessID, ]; if ($hasStripeConnect) { $effectivePlatformFeeCents = $totalPlatformFeeCents + $grantOwnerFeeCents; $checkoutParams['payment_intent_data[application_fee_amount]'] = $effectivePlatformFeeCents; $checkoutParams['payment_intent_data[transfer_data][destination]'] = $qBusiness['StripeAccountID']; } $checkoutData = stripeRequest('POST', 'https://api.stripe.com/v1/checkout/sessions', $checkoutParams); $checkoutUrl = $checkoutData['url'] ?? ''; jsonResponse([ 'OK' => true, 'CLIENT_SECRET' => $piData['client_secret'], 'PAYMENT_INTENT_ID' => $piData['id'], 'PUBLISHABLE_KEY' => $config['publishableKey'], 'CHECKOUT_URL' => $checkoutUrl, 'FEE_BREAKDOWN' => [ 'SUBTOTAL' => $subtotal, 'TAX' => $tax, 'TIP' => $tip, 'DELIVERY_FEE' => $deliveryFee, 'PAYFRIT_FEE' => $payfritCustomerFee, 'CARD_FEE' => $cardFee, 'BALANCE_APPLIED' => $balanceApplied, 'TOTAL' => $totalCustomerPays, 'TOTAL_BEFORE_BALANCE' => ($balanceApplied > 0) ? $totalCustomerPays + $balanceApplied : 0, 'GRANT_OWNER_FEE_CENTS' => $grantOwnerFeeCents, ], 'STRIPE_CONNECT_ENABLED' => $hasStripeConnect, ]); } catch (Exception $e) { jsonResponse(['OK' => false, 'ERROR' => $e->getMessage(), 'DETAIL' => '']); }