Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
11a75c5
Fix cart based service quotes, fix qpay checkout flow
roncodes Feb 3, 2026
35e0ccc
fix: create order in QPay callback when payment is confirmed
Feb 3, 2026
d8c4dd1
fix: use correct CaptureOrderRequest type and remove unused variable
Feb 3, 2026
4a27a6d
feat: Add reusable QPay order creation method with fallback
Feb 4, 2026
a988229
refactor: Rename createOrderFromQPayPayment to createOrderFromCheckout
Feb 4, 2026
b3ba3dc
ran linter & added Order model import to Checkout model
roncodes Feb 4, 2026
51d9b9f
fix: Use correct QPay method paymentCheck instead of checkPayment
Feb 4, 2026
dc91b01
fix: Validate QPay invoice ID exists before checking payment status
Feb 4, 2026
66a6063
feat: Make checkout status endpoint gateway-agnostic
Feb 4, 2026
9b3d8c3
fix: Use getOption() to retrieve QPay invoice ID from options column
Feb 4, 2026
666b597
fix: CRITICAL - Correct QPay instantiation in getCheckoutStatus
Feb 4, 2026
fea2683
fix: Prevent duplicate orders with database locking in fallback
Feb 4, 2026
a9da0bf
fix: Remove lockForUpdate to prevent request timeouts in fallback
Feb 4, 2026
8cefbf2
fix: implement atomic cache lock to prevent duplicate order creation …
Feb 4, 2026
fe847b1
feat: add phone verification endpoints for authenticated customers
Feb 4, 2026
5c882a6
fix: add phone number uniqueness validation in requestPhoneVerification
Feb 4, 2026
6e99e5d
ran linter & fix user resolution in `requestPhoneVerification`
roncodes Feb 4, 2026
23c908e
disable cache on customer resource endpoints
roncodes Feb 4, 2026
e47249e
prevent duplicate order from capture endpoint by checking if order cr…
roncodes Feb 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/storefront-api",
"version": "0.4.12",
"version": "0.4.13",
"description": "Headless Commerce & Marketplace Extension for Fleetbase",
"keywords": [
"fleetbase-extension",
Expand Down
2 changes: 1 addition & 1 deletion extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Storefront",
"version": "0.4.12",
"version": "0.4.13",
"description": "Headless Commerce & Marketplace Extension for Fleetbase",
"repository": "https://github.com/fleetbase/storefront",
"license": "AGPL-3.0-or-later",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/storefront-engine",
"version": "0.4.12",
"version": "0.4.13",
"description": "Headless Commerce & Marketplace Extension for Fleetbase",
"fleetbase": {
"route": "storefront",
Expand Down
279 changes: 276 additions & 3 deletions server/src/Http/Controllers/v1/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use Fleetbase\Support\SocketCluster\SocketClusterService;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Stripe\Exception\InvalidRequestException;
Expand Down Expand Up @@ -641,7 +642,6 @@ public function captureQPayCallback(Request $request)
}

$paymentCheck = $qpay->paymentCheck($invoiceId);

if (!$paymentCheck || empty($paymentCheck->count) || $paymentCheck->count < 1) {
return response()->json([
'error' => 'PAYMENT_NOTFOUND',
Expand All @@ -651,7 +651,18 @@ public function captureQPayCallback(Request $request)
}

$payment = data_get($paymentCheck, 'rows.0');

if ($payment) {
// Create order from payment using reusable gateway-agnostic method
$transactionDetails = [
'transaction_id' => $payment->payment_id,
'payment_status' => 'PAID',
'payment_wallet' => $payment->payment_wallet ?? 'QPay',
];

$this->createOrderFromCheckout($checkout, $transactionDetails);
$checkout->refresh();

$data = [
'checkout' => $checkout->public_id,
'payment' => (array) $payment,
Expand All @@ -673,9 +684,116 @@ public function captureQPayCallback(Request $request)
}

/**
* Process a cart item and create/save an entity.
* Create order from checkout session.
*
* Gateway-agnostic reusable method to create an order from a checkout session.
* Works with any payment gateway (QPay, Stripe, etc.) by delegating to captureOrder().
* Handles idempotency by checking if order already exists.
*
* @param Checkout $checkout The checkout session
* @param array $transactionDetails Payment gateway transaction details
* @param string|null $notes Optional notes for the order
*
* @return Order|null The created order or null if already exists/error
*/
private function createOrderFromCheckout($checkout, $transactionDetails, $notes = null)
{
// Define a unique lock key for this specific checkout
$lockKey = 'create-order-checkout-' . $checkout->uuid;

// Attempt to acquire a lock for 10 seconds
$lock = Cache::lock($lockKey, 10);

if ($lock->get()) {
try {
// Re-fetch checkout to ensure we have the latest data after acquiring lock
$checkout->refresh();

// Check if order already exists for this checkout
if ($checkout->order_uuid) {
Log::info('[ORDER CREATION]: Order already exists for checkout after acquiring lock', [
'checkout_id' => $checkout->public_id,
'order_id' => $checkout->order_uuid,
]);

return $checkout->order;
}

Log::info('[ORDER CREATION]: Creating order from payment', [
'checkout_id' => $checkout->public_id,
'transaction_id' => $transactionDetails['transaction_id'] ?? null,
]);

// Create CaptureOrderRequest with payment details
$captureRequest = CaptureOrderRequest::create('', 'POST', [
'token' => $checkout->token,
'transactionDetails' => $transactionDetails,
'notes' => $notes,
]);

// Call captureOrder to create the order
$this->captureOrder($captureRequest);

// Reload checkout to get the created order
$checkout->refresh();

if ($checkout->order) {
Log::info('[ORDER CREATION]: Order created successfully', [
'checkout_id' => $checkout->public_id,
'order_id' => $checkout->order->public_id,
]);

return $checkout->order;
}

Log::warning('[ORDER CREATION]: captureOrder completed but no order found on checkout', [
'checkout_id' => $checkout->public_id,
]);

return null;
} catch (\Exception $e) {
Log::error('[ORDER CREATION ERROR]: ' . $e->getMessage(), [
'checkout_id' => $checkout->public_id,
'transaction_details' => $transactionDetails,
'exception' => $e->getTraceAsString(),
]);

return null;
} finally {
// Always release the lock
$lock->release();
}
} else {
// Could not acquire lock - another process is creating the order
Log::info('[ORDER CREATION]: Could not acquire lock, another process is creating order', [
'checkout_id' => $checkout->public_id,
]);

// Wait briefly and return the order that should be created by the other process
sleep(2);
$checkout->refresh();

if ($checkout->order) {
Log::info('[ORDER CREATION]: Order found after waiting for lock', [
'checkout_id' => $checkout->public_id,
'order_id' => $checkout->order->public_id,
]);

return $checkout->order;
}

Log::warning('[ORDER CREATION]: Lock wait completed but no order found', [
'checkout_id' => $checkout->public_id,
]);

return null;
}
}

/**
* Process a cart item.
*
* @param mixed $cartItem the cart item to process
* @param mixed $cartItem the cart item
* @param mixed $payload the payload
* @param mixed $customer the customer
*
Expand Down Expand Up @@ -727,6 +845,14 @@ public function captureOrder(CaptureOrderRequest $request)
$destination = $serviceQuote ? $serviceQuote->getMeta('destination') : null;
$cart = $checkout->cart;

// If the checkout already has an order created
if ($checkout->order_uuid) {
$completedOrder = Order::where('uuid', $checkout->order_uuid)->first();
if ($completedOrder) {
return new OrderResource($completedOrder);
}
}

// if cart is null then cart has either been deleted or expired
if (!$cart) {
return response()->apiError('Cart expired');
Expand Down Expand Up @@ -1046,6 +1172,14 @@ public function captureMultipleOrders(CaptureOrderRequest $request)
$amount = static::calculateCheckoutAmount($cart, $serviceQuote, $checkout->options);
$currency = $checkout->currency ?? $cart->getCurrency();

// If the checkout already has an order created
if ($checkout->order_uuid) {
$completedOrder = Order::where('uuid', $checkout->order_uuid)->first();
if ($completedOrder) {
return new OrderResource($completedOrder);
}
}

if (!$about) {
return response()->apiError('No network in request to capture order!');
}
Expand Down Expand Up @@ -1340,6 +1474,145 @@ public function afterCheckout(Request $request)
{
}

/**
* Get checkout status including payment and order details.
*
* This endpoint allows the app to query the current status of a checkout session,
* including payment confirmation and order details. If payment is confirmed but
* order doesn't exist (callback failed), it will create the order as a fallback.
*
* @return \Illuminate\Http\JsonResponse
*/
public function getCheckoutStatus(Request $request)
{
$checkoutId = $request->input('checkout');
$token = $request->input('token');

// Validate required parameters
if (!$checkoutId || !$token) {
return response()->json([
'error' => 'Missing required parameters: checkout and token',
], 400);
}

try {
// Find checkout by ID and token
$checkout = Checkout::where('public_id', $checkoutId)
->where('token', $token)
->with(['order'])
->first();

if (!$checkout) {
return response()->json([
'error' => 'Checkout not found',
], 404);
}

// Initialize response (gateway-agnostic)
$response = [
'status' => $checkout->captured ? 'completed' : 'pending',
'checkout' => $checkout->public_id,
'payment' => null,
'order' => $checkout->order ? new OrderResource($checkout->order) : null,
];

// Check if this is a QPay checkout
if ($checkout->gateway_uuid) {
$gateway = Gateway::where('uuid', $checkout->gateway_uuid)->first();

if ($gateway && $gateway->code === 'qpay') {
// Get QPay invoice ID from checkout options
$qpayInvoiceId = $checkout->getOption('qpay_invoice_id');
$payment = null;

if ($qpayInvoiceId) {
// Create QPay instance with correct credentials
$qpay = QPay::instance(
$gateway->config->username,
$gateway->config->password,
$gateway->callback_url
);

if ($gateway->sandbox) {
$qpay->useSandbox();
}

$qpay->setAuthToken();

// Verify payment status with QPay
$paymentCheck = $qpay->paymentCheck($qpayInvoiceId);
$payment = data_get($paymentCheck, 'rows.0');
}

if ($payment && $payment->payment_status === 'PAID') {
$response['status'] = 'paid';
$response['payment'] = [
'payment_id' => $payment->payment_id,
'payment_status' => $payment->payment_status,
'payment_amount' => $payment->payment_amount,
'payment_date' => $payment->payment_date ?? null,
'payment_wallet' => $payment->payment_wallet ?? 'QPay',
];

// FALLBACK: If payment confirmed but order doesn't exist, create it
if (!$checkout->order_uuid) {
Log::info('[CHECKOUT STATUS FALLBACK]: Payment confirmed but no order exists, attempting to create', [
'checkout_id' => $checkout->public_id,
'payment_id' => $payment->payment_id,
]);

$transactionDetails = [
'transaction_id' => $payment->payment_id,
'payment_status' => 'PAID',
'payment_wallet' => $payment->payment_wallet ?? 'QPay',
];

try {
// Use the reusable gateway-agnostic method to create order
// createOrderFromCheckout has built-in idempotency checks
$order = $this->createOrderFromCheckout($checkout, $transactionDetails);

if ($order) {
$response['status'] = 'completed';
$response['order'] = new OrderResource($order);
}
} catch (\Exception $e) {
// If order creation fails (e.g., race condition), refresh and check again
Log::warning('[CHECKOUT STATUS FALLBACK]: Order creation failed, checking if order was created by another request', [
'checkout_id' => $checkout->public_id,
'error' => $e->getMessage(),
]);

$checkout->refresh();
if ($checkout->order_uuid) {
// Order was created by another request
$response['status'] = 'completed';
$response['order'] = new OrderResource($checkout->order);
}
}
} else {
// Order already exists
$response['status'] = 'completed';
$response['order'] = new OrderResource($checkout->order);
}
}
}
}

return response()->json($response);
} catch (\Exception $e) {
Log::error('[CHECKOUT STATUS ERROR]: ' . $e->getMessage(), [
'checkout_id' => $checkoutId,
'exception' => $e->getTraceAsString(),
]);

return response()->json([
'error' => 'Failed to retrieve checkout status',
'message' => $e->getMessage(),
], 500);
}
}

/**
* Calculates the total checkout amount.
*
Expand Down
Loading
Loading