@fleetbase/storefront-engine 0.4.12 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/storefront-api",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "Headless Commerce & Marketplace Extension for Fleetbase",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Storefront",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "Headless Commerce & Marketplace Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/storefront",
6
6
  "license": "AGPL-3.0-or-later",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/storefront-engine",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "Headless Commerce & Marketplace Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "storefront",
@@ -30,6 +30,7 @@ use Fleetbase\Storefront\Support\StripeUtils;
30
30
  use Fleetbase\Support\SocketCluster\SocketClusterService;
31
31
  use Illuminate\Http\Request;
32
32
  use Illuminate\Support\Arr;
33
+ use Illuminate\Support\Facades\Cache;
33
34
  use Illuminate\Support\Facades\Log;
34
35
  use Illuminate\Support\Str;
35
36
  use Stripe\Exception\InvalidRequestException;
@@ -641,7 +642,6 @@ class CheckoutController extends Controller
641
642
  }
642
643
 
643
644
  $paymentCheck = $qpay->paymentCheck($invoiceId);
644
-
645
645
  if (!$paymentCheck || empty($paymentCheck->count) || $paymentCheck->count < 1) {
646
646
  return response()->json([
647
647
  'error' => 'PAYMENT_NOTFOUND',
@@ -651,7 +651,18 @@ class CheckoutController extends Controller
651
651
  }
652
652
 
653
653
  $payment = data_get($paymentCheck, 'rows.0');
654
+
654
655
  if ($payment) {
656
+ // Create order from payment using reusable gateway-agnostic method
657
+ $transactionDetails = [
658
+ 'transaction_id' => $payment->payment_id,
659
+ 'payment_status' => 'PAID',
660
+ 'payment_wallet' => $payment->payment_wallet ?? 'QPay',
661
+ ];
662
+
663
+ $this->createOrderFromCheckout($checkout, $transactionDetails);
664
+ $checkout->refresh();
665
+
655
666
  $data = [
656
667
  'checkout' => $checkout->public_id,
657
668
  'payment' => (array) $payment,
@@ -673,9 +684,116 @@ class CheckoutController extends Controller
673
684
  }
674
685
 
675
686
  /**
676
- * Process a cart item and create/save an entity.
687
+ * Create order from checkout session.
688
+ *
689
+ * Gateway-agnostic reusable method to create an order from a checkout session.
690
+ * Works with any payment gateway (QPay, Stripe, etc.) by delegating to captureOrder().
691
+ * Handles idempotency by checking if order already exists.
692
+ *
693
+ * @param Checkout $checkout The checkout session
694
+ * @param array $transactionDetails Payment gateway transaction details
695
+ * @param string|null $notes Optional notes for the order
696
+ *
697
+ * @return Order|null The created order or null if already exists/error
698
+ */
699
+ private function createOrderFromCheckout($checkout, $transactionDetails, $notes = null)
700
+ {
701
+ // Define a unique lock key for this specific checkout
702
+ $lockKey = 'create-order-checkout-' . $checkout->uuid;
703
+
704
+ // Attempt to acquire a lock for 10 seconds
705
+ $lock = Cache::lock($lockKey, 10);
706
+
707
+ if ($lock->get()) {
708
+ try {
709
+ // Re-fetch checkout to ensure we have the latest data after acquiring lock
710
+ $checkout->refresh();
711
+
712
+ // Check if order already exists for this checkout
713
+ if ($checkout->order_uuid) {
714
+ Log::info('[ORDER CREATION]: Order already exists for checkout after acquiring lock', [
715
+ 'checkout_id' => $checkout->public_id,
716
+ 'order_id' => $checkout->order_uuid,
717
+ ]);
718
+
719
+ return $checkout->order;
720
+ }
721
+
722
+ Log::info('[ORDER CREATION]: Creating order from payment', [
723
+ 'checkout_id' => $checkout->public_id,
724
+ 'transaction_id' => $transactionDetails['transaction_id'] ?? null,
725
+ ]);
726
+
727
+ // Create CaptureOrderRequest with payment details
728
+ $captureRequest = CaptureOrderRequest::create('', 'POST', [
729
+ 'token' => $checkout->token,
730
+ 'transactionDetails' => $transactionDetails,
731
+ 'notes' => $notes,
732
+ ]);
733
+
734
+ // Call captureOrder to create the order
735
+ $this->captureOrder($captureRequest);
736
+
737
+ // Reload checkout to get the created order
738
+ $checkout->refresh();
739
+
740
+ if ($checkout->order) {
741
+ Log::info('[ORDER CREATION]: Order created successfully', [
742
+ 'checkout_id' => $checkout->public_id,
743
+ 'order_id' => $checkout->order->public_id,
744
+ ]);
745
+
746
+ return $checkout->order;
747
+ }
748
+
749
+ Log::warning('[ORDER CREATION]: captureOrder completed but no order found on checkout', [
750
+ 'checkout_id' => $checkout->public_id,
751
+ ]);
752
+
753
+ return null;
754
+ } catch (\Exception $e) {
755
+ Log::error('[ORDER CREATION ERROR]: ' . $e->getMessage(), [
756
+ 'checkout_id' => $checkout->public_id,
757
+ 'transaction_details' => $transactionDetails,
758
+ 'exception' => $e->getTraceAsString(),
759
+ ]);
760
+
761
+ return null;
762
+ } finally {
763
+ // Always release the lock
764
+ $lock->release();
765
+ }
766
+ } else {
767
+ // Could not acquire lock - another process is creating the order
768
+ Log::info('[ORDER CREATION]: Could not acquire lock, another process is creating order', [
769
+ 'checkout_id' => $checkout->public_id,
770
+ ]);
771
+
772
+ // Wait briefly and return the order that should be created by the other process
773
+ sleep(2);
774
+ $checkout->refresh();
775
+
776
+ if ($checkout->order) {
777
+ Log::info('[ORDER CREATION]: Order found after waiting for lock', [
778
+ 'checkout_id' => $checkout->public_id,
779
+ 'order_id' => $checkout->order->public_id,
780
+ ]);
781
+
782
+ return $checkout->order;
783
+ }
784
+
785
+ Log::warning('[ORDER CREATION]: Lock wait completed but no order found', [
786
+ 'checkout_id' => $checkout->public_id,
787
+ ]);
788
+
789
+ return null;
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Process a cart item.
677
795
  *
678
- * @param mixed $cartItem the cart item to process
796
+ * @param mixed $cartItem the cart item
679
797
  * @param mixed $payload the payload
680
798
  * @param mixed $customer the customer
681
799
  *
@@ -727,6 +845,14 @@ class CheckoutController extends Controller
727
845
  $destination = $serviceQuote ? $serviceQuote->getMeta('destination') : null;
728
846
  $cart = $checkout->cart;
729
847
 
848
+ // If the checkout already has an order created
849
+ if ($checkout->order_uuid) {
850
+ $completedOrder = Order::where('uuid', $checkout->order_uuid)->first();
851
+ if ($completedOrder) {
852
+ return new OrderResource($completedOrder);
853
+ }
854
+ }
855
+
730
856
  // if cart is null then cart has either been deleted or expired
731
857
  if (!$cart) {
732
858
  return response()->apiError('Cart expired');
@@ -1046,6 +1172,14 @@ class CheckoutController extends Controller
1046
1172
  $amount = static::calculateCheckoutAmount($cart, $serviceQuote, $checkout->options);
1047
1173
  $currency = $checkout->currency ?? $cart->getCurrency();
1048
1174
 
1175
+ // If the checkout already has an order created
1176
+ if ($checkout->order_uuid) {
1177
+ $completedOrder = Order::where('uuid', $checkout->order_uuid)->first();
1178
+ if ($completedOrder) {
1179
+ return new OrderResource($completedOrder);
1180
+ }
1181
+ }
1182
+
1049
1183
  if (!$about) {
1050
1184
  return response()->apiError('No network in request to capture order!');
1051
1185
  }
@@ -1340,6 +1474,145 @@ class CheckoutController extends Controller
1340
1474
  {
1341
1475
  }
1342
1476
 
1477
+ /**
1478
+ * Get checkout status including payment and order details.
1479
+ *
1480
+ * This endpoint allows the app to query the current status of a checkout session,
1481
+ * including payment confirmation and order details. If payment is confirmed but
1482
+ * order doesn't exist (callback failed), it will create the order as a fallback.
1483
+ *
1484
+ * @return \Illuminate\Http\JsonResponse
1485
+ */
1486
+ public function getCheckoutStatus(Request $request)
1487
+ {
1488
+ $checkoutId = $request->input('checkout');
1489
+ $token = $request->input('token');
1490
+
1491
+ // Validate required parameters
1492
+ if (!$checkoutId || !$token) {
1493
+ return response()->json([
1494
+ 'error' => 'Missing required parameters: checkout and token',
1495
+ ], 400);
1496
+ }
1497
+
1498
+ try {
1499
+ // Find checkout by ID and token
1500
+ $checkout = Checkout::where('public_id', $checkoutId)
1501
+ ->where('token', $token)
1502
+ ->with(['order'])
1503
+ ->first();
1504
+
1505
+ if (!$checkout) {
1506
+ return response()->json([
1507
+ 'error' => 'Checkout not found',
1508
+ ], 404);
1509
+ }
1510
+
1511
+ // Initialize response (gateway-agnostic)
1512
+ $response = [
1513
+ 'status' => $checkout->captured ? 'completed' : 'pending',
1514
+ 'checkout' => $checkout->public_id,
1515
+ 'payment' => null,
1516
+ 'order' => $checkout->order ? new OrderResource($checkout->order) : null,
1517
+ ];
1518
+
1519
+ // Check if this is a QPay checkout
1520
+ if ($checkout->gateway_uuid) {
1521
+ $gateway = Gateway::where('uuid', $checkout->gateway_uuid)->first();
1522
+
1523
+ if ($gateway && $gateway->code === 'qpay') {
1524
+ // Get QPay invoice ID from checkout options
1525
+ $qpayInvoiceId = $checkout->getOption('qpay_invoice_id');
1526
+ $payment = null;
1527
+
1528
+ if ($qpayInvoiceId) {
1529
+ // Create QPay instance with correct credentials
1530
+ $qpay = QPay::instance(
1531
+ $gateway->config->username,
1532
+ $gateway->config->password,
1533
+ $gateway->callback_url
1534
+ );
1535
+
1536
+ if ($gateway->sandbox) {
1537
+ $qpay->useSandbox();
1538
+ }
1539
+
1540
+ $qpay->setAuthToken();
1541
+
1542
+ // Verify payment status with QPay
1543
+ $paymentCheck = $qpay->paymentCheck($qpayInvoiceId);
1544
+ $payment = data_get($paymentCheck, 'rows.0');
1545
+ }
1546
+
1547
+ if ($payment && $payment->payment_status === 'PAID') {
1548
+ $response['status'] = 'paid';
1549
+ $response['payment'] = [
1550
+ 'payment_id' => $payment->payment_id,
1551
+ 'payment_status' => $payment->payment_status,
1552
+ 'payment_amount' => $payment->payment_amount,
1553
+ 'payment_date' => $payment->payment_date ?? null,
1554
+ 'payment_wallet' => $payment->payment_wallet ?? 'QPay',
1555
+ ];
1556
+
1557
+ // FALLBACK: If payment confirmed but order doesn't exist, create it
1558
+ if (!$checkout->order_uuid) {
1559
+ Log::info('[CHECKOUT STATUS FALLBACK]: Payment confirmed but no order exists, attempting to create', [
1560
+ 'checkout_id' => $checkout->public_id,
1561
+ 'payment_id' => $payment->payment_id,
1562
+ ]);
1563
+
1564
+ $transactionDetails = [
1565
+ 'transaction_id' => $payment->payment_id,
1566
+ 'payment_status' => 'PAID',
1567
+ 'payment_wallet' => $payment->payment_wallet ?? 'QPay',
1568
+ ];
1569
+
1570
+ try {
1571
+ // Use the reusable gateway-agnostic method to create order
1572
+ // createOrderFromCheckout has built-in idempotency checks
1573
+ $order = $this->createOrderFromCheckout($checkout, $transactionDetails);
1574
+
1575
+ if ($order) {
1576
+ $response['status'] = 'completed';
1577
+ $response['order'] = new OrderResource($order);
1578
+ }
1579
+ } catch (\Exception $e) {
1580
+ // If order creation fails (e.g., race condition), refresh and check again
1581
+ Log::warning('[CHECKOUT STATUS FALLBACK]: Order creation failed, checking if order was created by another request', [
1582
+ 'checkout_id' => $checkout->public_id,
1583
+ 'error' => $e->getMessage(),
1584
+ ]);
1585
+
1586
+ $checkout->refresh();
1587
+ if ($checkout->order_uuid) {
1588
+ // Order was created by another request
1589
+ $response['status'] = 'completed';
1590
+ $response['order'] = new OrderResource($checkout->order);
1591
+ }
1592
+ }
1593
+ } else {
1594
+ // Order already exists
1595
+ $response['status'] = 'completed';
1596
+ $response['order'] = new OrderResource($checkout->order);
1597
+ }
1598
+ }
1599
+ }
1600
+ }
1601
+
1602
+ return response()->json($response);
1603
+ } catch (\Exception $e) {
1604
+ Log::error('[CHECKOUT STATUS ERROR]: ' . $e->getMessage(), [
1605
+ 'checkout_id' => $checkoutId,
1606
+ 'exception' => $e->getTraceAsString(),
1607
+ ]);
1608
+
1609
+ return response()->json([
1610
+ 'error' => 'Failed to retrieve checkout status',
1611
+ 'message' => $e->getMessage(),
1612
+ ], 500);
1613
+ }
1614
+ }
1615
+
1343
1616
  /**
1344
1617
  * Calculates the total checkout amount.
1345
1618
  *
@@ -84,7 +84,7 @@ class CustomerController extends Controller
84
84
  $q->orWhere('meta', 'not like', '%related_orders%');
85
85
  });
86
86
  }
87
- });
87
+ }, true);
88
88
 
89
89
  return OrderResource::collection($results);
90
90
  }
@@ -104,7 +104,7 @@ class CustomerController extends Controller
104
104
 
105
105
  $results = Place::queryWithRequest($request, function (&$query) use ($customer) {
106
106
  $query->where('owner_uuid', $customer->uuid);
107
- });
107
+ }, true);
108
108
 
109
109
  return PlaceResource::collection($results);
110
110
  }
@@ -951,4 +951,95 @@ class CustomerController extends Controller
951
951
 
952
952
  return response()->apiError('An uknown error occured attempting to close customer account.');
953
953
  }
954
+
955
+ /**
956
+ * Sends a verification code to the customer's phone for verification.
957
+ *
958
+ * @return \Illuminate\Http\JsonResponse
959
+ */
960
+ public function requestPhoneVerification(Request $request)
961
+ {
962
+ $customer = Storefront::getCustomerFromToken();
963
+ $phone = static::phone($request->input('phone'));
964
+
965
+ if (!$customer) {
966
+ return response()->apiError('Not authorized to request phone verification.');
967
+ }
968
+
969
+ // Use the user associated with the contact
970
+ $user = User::where(['uuid' => $customer->user_uuid])->first();
971
+ if (!$user) {
972
+ return response()->apiError('No user associated with this customer.');
973
+ }
974
+
975
+ // Check if phone number is already used by another user
976
+ $existingUser = User::where('phone', $phone)
977
+ ->where('uuid', '!=', $user->uuid)
978
+ ->whereNull('deleted_at')
979
+ ->withoutGlobalScopes()
980
+ ->first();
981
+
982
+ if ($existingUser) {
983
+ return response()->apiError('This phone number is already associated with another account.');
984
+ }
985
+
986
+ $about = Storefront::about();
987
+
988
+ try {
989
+ VerificationCode::generateSmsVerificationFor($user, 'storefront_verify_phone', [
990
+ 'messageCallback' => function ($verification) use ($about) {
991
+ return "Your {$about->name} verification code is {$verification->code}";
992
+ },
993
+ 'meta' => ['phone' => $phone], // Store the phone number in meta
994
+ ]);
995
+
996
+ return response()->json(['status' => 'ok']);
997
+ } catch (\Exception $e) {
998
+ return response()->apiError($e->getMessage());
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * Verifies the phone number using the provided code.
1004
+ *
1005
+ * @return Customer
1006
+ */
1007
+ public function verifyPhoneNumber(Request $request)
1008
+ {
1009
+ $customer = Storefront::getCustomerFromToken();
1010
+ $code = $request->input('code');
1011
+
1012
+ if (!$customer) {
1013
+ return response()->apiError('Not authorized to verify phone number.');
1014
+ }
1015
+
1016
+ $user = $customer->user;
1017
+
1018
+ if (!$user) {
1019
+ return response()->apiError('No user associated with this customer.');
1020
+ }
1021
+
1022
+ // Find the verification code
1023
+ $verificationCode = VerificationCode::where([
1024
+ 'subject_uuid' => $user->uuid,
1025
+ 'code' => $code,
1026
+ 'for' => 'storefront_verify_phone',
1027
+ ])->first();
1028
+
1029
+ if (!$verificationCode) {
1030
+ return response()->apiError('Invalid verification code!');
1031
+ }
1032
+
1033
+ // Get the phone number from meta
1034
+ $phone = $verificationCode->meta['phone'];
1035
+
1036
+ // Update user and contact
1037
+ $user->update(['phone' => $phone, 'phone_verified_at' => now()]);
1038
+ $customer->update(['phone' => $phone]);
1039
+
1040
+ // Invalidate the verification code
1041
+ $verificationCode->delete();
1042
+
1043
+ return new Customer($customer->fresh());
1044
+ }
954
1045
  }
@@ -41,7 +41,6 @@ class ServiceQuoteController extends Controller
41
41
  $serviceType = $request->input('service_type');
42
42
  $cart = Cart::retrieve($request->input('cart'));
43
43
  $currency = $cart->currency;
44
- $config = $request->input('config', 'storefront');
45
44
  $all = $request->boolean('all');
46
45
  $isRouteOptimized = $request->boolean('is_route_optimized', true);
47
46
  $isNetwork = Str::startsWith(session('storefront_key'), 'network_');
@@ -106,7 +105,13 @@ class ServiceQuoteController extends Controller
106
105
  $orderConfigKey = data_get($orderConfig, 'key', 'storefront');
107
106
 
108
107
  // get service rates for config type
109
- $serviceRates = ServiceRate::where(['company_uuid' => session('company'), 'service_type' => $orderConfigKey])->get();
108
+ // $serviceRates = ServiceRate::where(['company_uuid' => session('company'), 'service_type' => $orderConfigKey])->get();
109
+ $serviceRates = ServiceRate::getServicableForPlaces([$destination], $orderConfigKey, $currency, function ($q) {
110
+ $q->where('company_uuid', session('company'));
111
+ });
112
+
113
+ // Convert to collection
114
+ $serviceRates = collect($serviceRates);
110
115
 
111
116
  // if no service rates send an empty quote
112
117
  if ($serviceRates->isEmpty()) {
@@ -200,7 +205,6 @@ class ServiceQuoteController extends Controller
200
205
  $serviceType = $request->input('service_type');
201
206
  $cart = Cart::retrieve($request->input('cart'));
202
207
  $currency = $cart->currency;
203
- $config = $request->input('config', 'storefront');
204
208
  $all = $request->boolean('all');
205
209
  $isRouteOptimized = $request->boolean('is_route_optimized', true);
206
210
 
@@ -294,7 +298,13 @@ class ServiceQuoteController extends Controller
294
298
  $orderConfigKey = data_get($orderConfig, 'key', 'storefront');
295
299
 
296
300
  // get service rates for config type
297
- $serviceRates = ServiceRate::where(['company_uuid' => session('company'), 'service_type' => $orderConfigKey])->get();
301
+ // $serviceRates = ServiceRate::where(['company_uuid' => session('company'), 'service_type' => $orderConfigKey])->get();
302
+ $serviceRates = ServiceRate::getServicableForPlaces([$destination], $orderConfigKey, $currency, function ($q) {
303
+ $q->where('company_uuid', session('company'));
304
+ });
305
+
306
+ // Convert to collection
307
+ $serviceRates = collect($serviceRates);
298
308
 
299
309
  // if no service rates send an empty quote
300
310
  if ($serviceRates->isEmpty()) {
@@ -3,6 +3,7 @@
3
3
  namespace Fleetbase\Storefront\Models;
4
4
 
5
5
  use Fleetbase\Casts\Json;
6
+ use Fleetbase\FleetOps\Models\Order;
6
7
  use Fleetbase\FleetOps\Models\ServiceQuote;
7
8
  use Fleetbase\Models\Company;
8
9
  use Fleetbase\Support\Utils;
@@ -54,6 +54,7 @@ Route::prefix(config('storefront.api.routing.prefix', 'storefront'))->namespace(
54
54
  // storefront/v1/checkouts
55
55
  $router->group(['prefix' => 'checkouts'], function () use ($router) {
56
56
  $router->get('before', 'CheckoutController@beforeCheckout');
57
+ $router->get('status', 'CheckoutController@getCheckoutStatus');
57
58
  $router->post('capture', 'CheckoutController@captureOrder');
58
59
  $router->post('stripe-setup-intent', 'CheckoutController@createStripeSetupIntentForCustomer');
59
60
  $router->put('stripe-update-payment-intent', 'CheckoutController@updateStripePaymentIntent');
@@ -118,6 +119,8 @@ Route::prefix(config('storefront.api.routing.prefix', 'storefront'))->namespace(
118
119
  $router->post('stripe-setup-intent', 'CustomerController@getStripeSetupIntent');
119
120
  $router->post('account-closure', 'CustomerController@startAccountClosure');
120
121
  $router->post('confirm-account-closure', 'CustomerController@confirmAccountClosure');
122
+ $router->post('request-phone-verification', 'CustomerController@requestPhoneVerification');
123
+ $router->post('verify-phone-number', 'CustomerController@verifyPhoneNumber');
121
124
  });
122
125
 
123
126
  // hotfix! storefront-app sending customer update to /contacts/ route