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.
280 lines
12 KiB
PHP
280 lines
12 KiB
PHP
<?php
|
|
require_once __DIR__ . '/../helpers.php';
|
|
require_once __DIR__ . '/../config/stripe.php';
|
|
runAuth();
|
|
|
|
try {
|
|
$data = readJsonBody();
|
|
|
|
$businessID = (int) ($data['BusinessID'] ?? 0);
|
|
$orderID = (int) ($data['OrderID'] ?? 0);
|
|
$subtotal = (float) ($data['Subtotal'] ?? 0);
|
|
$tax = (float) ($data['Tax'] ?? 0);
|
|
$tip = (float) ($data['Tip'] ?? 0);
|
|
$customerEmail = $data['CustomerEmail'] ?? '';
|
|
|
|
if ($businessID === 0) apiAbort(['OK' => 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' => '']);
|
|
}
|