diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml
index e7ab6fbd..cde939be 100644
--- a/.github/workflows/postman.yml
+++ b/.github/workflows/postman.yml
@@ -1,10 +1,18 @@
name: API Contract (Postman)
-# Boots a full Fleetbase stack (published image) and runs the Storefront API
-# Postman collection against the live API. Delegates to the reusable workflow in
+# Boots a full Fleetbase stack (published image) and runs the Storefront API Postman
+# collection against the live API. Delegates to the reusable workflow in
# fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN
# (inherited); no-ops until POSTMAN_API_KEY is set.
-# TODO: change @dev-v0.7.53 to @main once that branch is merged.
+#
+# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main
+# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and
+# there is no ref here to remember to bump. Each run records the image digest it actually
+# resolved in its job summary, so a result stays traceable. To reproduce an older run:
+#
+# with:
+# fleetbase-ref: v0.7.53
+# api-image: fleetbase/fleetbase-api:v0.7.53
on:
push:
@@ -12,14 +20,17 @@ on:
pull_request:
branches: [main]
workflow_dispatch:
-
permissions:
contents: read
jobs:
contract:
- uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53
+ uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main
with:
collections: "Fleetbase Storefront API"
build-from-source: false
+ # Without this the run tests the version of fleetbase/storefront-api baked into the
+ # published image, not the branch under review. The workflow checks this
+ # repository out at the commit under test and swaps it into the container.
+ overlay-package: fleetbase/storefront-api
secrets: inherit
diff --git a/README.md b/README.md
index bee60dd4..d36b49ef 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,17 @@ The public customer-facing API is mounted under `storefront/v1` and protected by
- Customer registration, login, SMS code verification, social login endpoints, device registration, saved places, customer orders, phone verification, Stripe customer helpers, and account closure flows.
- Order pickup completion and receipt generation.
+### Marketplace network contract
+
+A public `network_...` key uses accepted `network_stores` membership as its marketplace authorization boundary, including member stores owned by another company. Marketplace responses expose only public Storefront resources; they do not expose member customers, internal API keys, or gateway credentials.
+
+- `GET storefront/v1/stores` supports search, online/tag/category/ID filters, rating/age/popularity/trending/nearest sorts, distance filtering in meters, and limit/offset pagination.
+- Categories and published products can be scoped to a member store. Product responses include public merchant context for marketplace search and cards.
+- Cart line items preserve both merchant and store-location identity. Product and location substitution across stores is rejected.
+- Checkout revalidates active membership, store availability, published products, location ownership, the network multi-cart policy, and one-cart currency before payment begins.
+- Network delivery quotes resolve one origin per represented store and reject missing, foreign, or mismatched locations with `422`.
+- When `Customer-Token` is present, it is authoritative; a conflicting checkout customer ID returns `403`.
+
### Internal Console API
The protected internal API is mounted under `storefront/int/v1`. It powers the Fleetbase Console and includes:
@@ -204,6 +215,15 @@ composer test:unit
composer test
```
+Generate and summarize the full backend Clover baseline with Xdebug or PCOV:
+
+```bash
+XDEBUG_MODE=coverage composer test:coverage:clover
+composer coverage:summary
+```
+
+Marketplace changes on `dev-v0.4.19` must retain 100% backend statement coverage. Focused success and failure-path contracts should be added before regenerating the baseline.
+
## Configuration
Storefront configuration is provided through the Laravel package config files and environment variables:
diff --git a/RELEASE.md b/RELEASE.md
index ada2c890..df539b28 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,15 +1,47 @@
-> v0.4.18 ~ "RELEASE_NOTES_PLACEHOLDER — replace this line with the release title"
+> v0.4.19 ~ "Marketplace storefronts with safer checkout and customer verification"
---
## Highlights
-RELEASE_NOTES_PLACEHOLDER
+Storefront networks can now power a multi-merchant marketplace. Network clients can discover member stores, locations, categories, products, tags, reviews, and payment gateways while the API keeps every result inside the active marketplace.
-Describe what changed in this release. The first line above must name the version
-being released, and both placeholder markers must be gone, or the release workflow
-refuses to tag.
+Carts and checkout now validate merchant membership, store locations, product availability, online status, currency, and the network's multi-store policy. Delivery quotes preserve one origin per merchant, and cart responses include merchant details without querying each line separately.
+
+---
+## Security and Reliability
+
+- Authenticated checkout now treats the `Customer-Token` identity as authoritative and rejects a conflicting customer ID with `403`.
+- The app-review verification bypass is disabled by default and only works for explicitly allowlisted email addresses or phone numbers.
+- Switching between store and network keys clears the previous storefront scope, preventing filters from leaking between requests.
+- Store, category, product, location, and review lookups are constrained to the active storefront context.
+- Invalid cart, location, review, Apple sign-in, and SMS-provider states now return controlled API errors instead of internal exceptions.
+
+---
+## API and Checkout Changes
+
+- Network store discovery supports search, category and tag filters, online state, ratings, popularity, trending activity, age, nearest distance, and maximum distance.
+- Product creation now defaults to `published`, matching the console, and marketplace reads only return published, available products.
+- Checkout accepts both `serviceQuote` and the documented `service_quote` field.
+- Cash, card, and payment-intent checkout responses now include the `checkout` public ID alongside the existing token so clients can query checkout status.
+- A cart that has already produced an order can no longer be mutated; retrieving its old ID creates a fresh cart.
+- SMS login and account-closure requests fall back to email when SMS is unavailable, and responses identify the delivery method.
+
+---
+## Upgrade Steps
+
+No database migration is required.
+
+Installations that use a fixed code for app-store review accounts must now configure both values; the previous `999000` default no longer works:
+
+```dotenv
+STOREFRONT_BYPASS_VERIFICATION_CODE=
+STOREFRONT_REVIEW_ACCOUNTS=apple-review@example.com,+15555550100
+```
+
+Release the matching Storefront SDK and API specification before distributing marketplace-enabled app builds. Publish the corresponding documentation with the backend release.
---
## Need help?
+
- [GitHub Discussions](https://github.com/fleetbase/fleetbase/discussions)
- [Discord](https://discord.gg/HnTqQ6zAVn)
diff --git a/composer.json b/composer.json
index 353c5663..b2543c06 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "fleetbase/storefront-api",
- "version": "0.4.18",
+ "version": "0.4.19",
"description": "Headless Commerce & Marketplace Extension for Fleetbase",
"keywords": [
"fleetbase-extension",
diff --git a/extension.json b/extension.json
index 0bcc1757..b14d8932 100644
--- a/extension.json
+++ b/extension.json
@@ -1,6 +1,6 @@
{
"name": "Storefront",
- "version": "0.4.18",
+ "version": "0.4.19",
"description": "Headless Commerce & Marketplace Extension for Fleetbase",
"repository": "https://github.com/fleetbase/storefront",
"license": "AGPL-3.0-or-later",
diff --git a/package.json b/package.json
index c7789323..164e3eb9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/storefront-engine",
- "version": "0.4.18",
+ "version": "0.4.19",
"description": "Headless Commerce & Marketplace Extension for Fleetbase",
"fleetbase": {
"route": "storefront",
diff --git a/server/config/storefront.php b/server/config/storefront.php
index 9312c57e..7161a4c8 100644
--- a/server/config/storefront.php
+++ b/server/config/storefront.php
@@ -25,7 +25,24 @@
|--------------------------------------------------------------------------
*/
'storefront_app' => [
- 'bypass_verification_code' => env('STOREFRONT_BYPASS_VERIFICATION_CODE', '999000')
+ /*
+ | App store reviewers cannot receive our SMS or email, so a fixed verification
+ | code has to keep working in production for them. Both values are required for
+ | a bypass to be possible, and neither has a default — an unconfigured install
+ | has no bypass at all.
+ |
+ | The code alone is NOT sufficient: it is only accepted for an identity listed
+ | in review_accounts. Previously any identity was accepted, so knowing the code
+ | was enough to authenticate as any customer.
+ |
+ | STOREFRONT_BYPASS_VERIFICATION_CODE=
+ | STOREFRONT_REVIEW_ACCOUNTS=apple-review@example.com,+15555550100
+ */
+ 'bypass_verification_code' => env('STOREFRONT_BYPASS_VERIFICATION_CODE'),
+ 'review_accounts' => array_values(array_filter(array_map(
+ 'trim',
+ explode(',', (string) env('STOREFRONT_REVIEW_ACCOUNTS', ''))
+ ))),
],
/*
diff --git a/server/src/Http/Controllers/NetworkController.php b/server/src/Http/Controllers/NetworkController.php
index cff27471..bb240e1b 100644
--- a/server/src/Http/Controllers/NetworkController.php
+++ b/server/src/Http/Controllers/NetworkController.php
@@ -31,6 +31,7 @@ public function findNetwork(string $id)
{
$id = trim($id);
$isPublicId = Str::startsWith($id, ['storefront_network_', 'network_']);
+ $network = null;
if ($isPublicId) {
$network = Network::where('public_id', $id)->first();
diff --git a/server/src/Http/Controllers/v1/CartController.php b/server/src/Http/Controllers/v1/CartController.php
index c1f00af3..af5aa9fb 100644
--- a/server/src/Http/Controllers/v1/CartController.php
+++ b/server/src/Http/Controllers/v1/CartController.php
@@ -9,20 +9,39 @@
class CartController extends Controller
{
- protected function retrieveCart(?string $uniqueId, bool $create = false): Cart
+ /**
+ * The flag used to be passed through as `$create`, but Cart::retrieve()'s second
+ * parameter is `$excludeCheckedout`. Every mutating action below took the `false`
+ * default and so happily added to, updated, emptied or deleted a cart that had
+ * already produced an order, while the GET action passed `true` and did not. The
+ * name made that read as intentional. Let Cart::retrieve() keep its own default so
+ * a checked-out cart is off limits everywhere.
+ */
+ protected function retrieveCart(?string $uniqueId): Cart
{
- return Cart::retrieve($uniqueId, $create);
+ return Cart::retrieve($uniqueId);
}
/**
* Retrieve or create a cart using a unique identifier. If no unique identifier is provided
* one will be created.
*
+ * The injected Request is declared FIRST on purpose. Laravel resolves method
+ * dependencies by splicing class-typed parameters in at their own index and filling
+ * the remainder from the route parameters, in order. With the optional $uniqueId
+ * first, GET /storefront/v1/carts (the route with no {uniqueId}) had nothing to put
+ * at index 0, so the Request landed at index 1 and the call arrived with a hole:
+ *
+ * ArgumentCountError: Too few arguments to function retrieve(), 1 passed
+ *
+ * The route WITH an id worked, which is why this looked like a cart problem rather
+ * than a signature one. Five requests in the Storefront collection failed behind it.
+ *
* @return \Illuminate\Http\Response
*/
- public function retrieve(?string $uniqueId = null, Request $request)
+ public function retrieve(Request $request, ?string $uniqueId = null)
{
- $cart = $this->retrieveCart($uniqueId, true);
+ $cart = $this->retrieveCart($uniqueId);
return new StorefrontCart($cart);
}
diff --git a/server/src/Http/Controllers/v1/CategoryController.php b/server/src/Http/Controllers/v1/CategoryController.php
index 54d7ae74..4d46be93 100644
--- a/server/src/Http/Controllers/v1/CategoryController.php
+++ b/server/src/Http/Controllers/v1/CategoryController.php
@@ -47,7 +47,12 @@ public function query(Request $request)
})->toArray();
// get all products in these categories
- $products = Product::whereIn('category_uuid', $categoryIds)->where('is_available', 1)->with(['addonCategories', 'variants', 'files'])->get();
+ $products = Product::whereIn('category_uuid', $categoryIds)
+ ->where('store_uuid', session('storefront_store'))
+ ->where('is_available', 1)
+ ->where('status', 'published')
+ ->with(['addonCategories', 'variants', 'files'])
+ ->get();
$results = $results->map(function ($category) use ($products) {
$category->products = $products->where('category_uuid', $category->uuid)->mapInto(ProductResource::class)->values();
@@ -60,7 +65,6 @@ public function query(Request $request)
if (session('storefront_network')) {
if ($request->filled('store')) {
$store = Store::where([
- 'company_uuid' => session('company'),
'public_id' => $request->input('store'),
])->whereHas('networks', function ($q) {
$q->where('network_uuid', session('storefront_network'));
@@ -91,7 +95,12 @@ public function query(Request $request)
})->toArray();
// get all products in these categories
- $products = Product::whereIn('category_uuid', $categoryIds)->where('is_available', 1)->with(['addonCategories', 'variants', 'files'])->get();
+ $products = Product::whereIn('category_uuid', $categoryIds)
+ ->where('store_uuid', $store->uuid)
+ ->where('is_available', 1)
+ ->where('status', 'published')
+ ->with(['addonCategories', 'variants', 'files'])
+ ->get();
$results = $results->map(function ($category) use ($products) {
// $category->products = Product::where('category_uuid', $category->uuid)->get()->mapInto(ProductResource::class);
@@ -120,11 +129,21 @@ public function query(Request $request)
// if we want to get categories with stores
if ($request->has('with_stores')) {
- $results = $results->map(function ($category) {
- $category->stores = Store::whereHas('networks', function ($q) use ($category) {
- $q->where('network_uuid', session('storefront_network'));
- $q->where('category_uuid', $category->uuid);
- })->get();
+ $categoryIds = $results->pluck('uuid');
+ $stores = Store::whereHas('networks', function ($q) use ($categoryIds) {
+ $q->where('network_uuid', session('storefront_network'));
+ $q->whereIn('category_uuid', $categoryIds);
+ })->with(['networks' => function ($q) use ($categoryIds) {
+ $q->where('network_uuid', session('storefront_network'));
+ $q->whereIn('category_uuid', $categoryIds);
+ }])->get();
+
+ $results = $results->map(function ($category) use ($stores) {
+ $category->stores = $stores->filter(function ($store) use ($category) {
+ return $store->networks->contains(function ($network) use ($category) {
+ return $network->pivot?->category_uuid === $category->uuid;
+ });
+ })->values();
return $category;
});
diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php
index 375f96a3..4ca11f8a 100644
--- a/server/src/Http/Controllers/v1/CheckoutController.php
+++ b/server/src/Http/Controllers/v1/CheckoutController.php
@@ -21,6 +21,7 @@
use Fleetbase\Storefront\Models\Customer;
use Fleetbase\Storefront\Models\FoodTruck;
use Fleetbase\Storefront\Models\Gateway;
+use Fleetbase\Storefront\Models\Network;
use Fleetbase\Storefront\Models\Product;
use Fleetbase\Storefront\Models\Store;
use Fleetbase\Storefront\Models\StoreLocation;
@@ -123,6 +124,72 @@ protected function resolveStoreLocationOrigin($origin, Cart $cart)
return $storeLocation ? $storeLocation->place_uuid : null;
}
+ protected function validateMarketplaceCart(Cart $cart)
+ {
+ $networkUuid = session('storefront_network');
+ if (!$networkUuid) {
+ return null;
+ }
+
+ $items = collect($cart->items);
+ if ($items->isEmpty()) {
+ return response()->apiError('The cart is empty.', 422);
+ }
+
+ $storeIds = $items->pluck('store_id')->filter()->unique()->values();
+ if ($storeIds->count() !== $items->pluck('store_id')->unique()->count()) {
+ return response()->apiError('Every marketplace cart item must identify its store.', 422);
+ }
+
+ $stores = Store::whereIn('public_id', $storeIds)
+ ->whereHas('networks', fn ($query) => $query->where('network_uuid', $networkUuid))
+ ->get()
+ ->keyBy('public_id');
+
+ if ($stores->count() !== $storeIds->count()) {
+ return response()->apiError('The cart contains a store outside this marketplace.', 403);
+ }
+
+ if ($stores->contains(fn (Store $store) => !$store->online)) {
+ return response()->apiError('A store in this cart is currently offline.', 422);
+ }
+
+ $network = Network::select(['uuid', 'options'])->where('uuid', $networkUuid)->first();
+ $multiCartEnabled = data_get($network, 'options.multi_cart_enabled') === true;
+ if ($storeIds->count() > 1 && !$multiCartEnabled) {
+ return response()->apiError('This marketplace only supports one store per cart.', 422);
+ }
+
+ $products = Product::whereIn('public_id', $items->pluck('product_id')->filter()->unique())
+ ->whereIn('store_uuid', $stores->pluck('uuid'))
+ ->where('is_available', 1)
+ ->where('status', 'published')
+ ->get()
+ ->keyBy('public_id');
+ $locations = StoreLocation::whereIn('public_id', $items->pluck('store_location_id')->filter()->unique())
+ ->whereIn('store_uuid', $stores->pluck('uuid'))
+ ->get()
+ ->keyBy('public_id');
+
+ foreach ($items as $item) {
+ $store = $stores->get($item->store_id ?? null);
+ $product = $products->get($item->product_id ?? null);
+ $location = $locations->get($item->store_location_id ?? null);
+ if (!$store || !$product || $product->store_uuid !== $store->uuid) {
+ return response()->apiError('A product in this cart is no longer available from its store.', 422);
+ }
+ if (!$location || $location->store_uuid !== $store->uuid) {
+ return response()->apiError('A store location in this cart is no longer valid.', 422);
+ }
+ }
+
+ if ($products->pluck('currency')->filter()->unique()->count() > 1) {
+ return response()->apiError('Marketplace carts cannot combine different currencies.', 422);
+ }
+
+ return null;
+ }
+
protected function resolveFoodTruck(Cart $cart): ?FoodTruck
{
return collect($cart->items)
@@ -163,6 +230,47 @@ protected function applyFoodTruckOrderData(?FoodTruck $foodTruck, array $orderMe
return [$orderMeta, $orderInput];
}
+ /**
+ * Resolves the customer a checkout is for.
+ *
+ * The customer used to be taken from the request body and trusted. A storefront
+ * key is client-side by nature — it ships inside the storefront app — and customer
+ * public ids appear in ordinary API responses, so anyone holding a key could check
+ * out as an arbitrary customer simply by passing their id.
+ *
+ * When a Customer-Token is present it now wins, and a body parameter naming a
+ * different customer is refused rather than silently honoured. Guest checkout is
+ * unaffected: with no token the body parameter is still used, since a guest has no
+ * token to present.
+ *
+ * @return Customer|\Illuminate\Http\JsonResponse|null
+ */
+ protected static function resolveCheckoutCustomer(?string $customerId)
+ {
+ $authenticated = Storefront::getCustomerFromToken();
+
+ // Guest checkout — no token to check against.
+ if (!$authenticated) {
+ return $customerId ? Customer::findFromCustomerId($customerId) : null;
+ }
+
+ if ($customerId) {
+ // A storefront customer is stored as a Contact, and findFromCustomerId()
+ // rewrites a customer_ prefix to contact_ before looking it up. Compare in
+ // that same space, or a caller's own customer_xxxx would never match the
+ // contact_xxxx on their record and every authenticated checkout would 403.
+ $normalized = Str::startsWith($customerId, 'customer')
+ ? Str::replaceFirst('customer', 'contact', $customerId)
+ : $customerId;
+
+ if ($authenticated->public_id !== $normalized) {
+ return response()->apiError('Customer does not match the authenticated session.', 403);
+ }
+ }
+
+ return Customer::findFromCustomerId($authenticated->public_id) ?? $authenticated;
+ }
+
public function beforeCheckout(InitializeCheckoutRequest $request)
{
$gatewayCode = $request->input('gateway');
@@ -183,9 +291,16 @@ public function beforeCheckout(InitializeCheckoutRequest $request)
]);
// find and validate cart session
- $cart = Cart::retrieve($cartId);
+ $cart = Cart::retrieve($cartId);
+ $cartValidation = $this->validateMarketplaceCart($cart);
+ if ($cartValidation) {
+ return $cartValidation;
+ }
$gateway = Storefront::findGateway($gatewayCode);
- $customer = Customer::findFromCustomerId($customerId);
+ $customer = static::resolveCheckoutCustomer($customerId);
+ if ($customer instanceof \Illuminate\Http\JsonResponse) {
+ return $customer;
+ }
$serviceQuote = ServiceQuote::select(['amount', 'meta', 'uuid', 'public_id'])->where('public_id', $serviceQuoteId)->first();
// handle cash orders
@@ -254,8 +369,12 @@ public static function initializeCashCheckout(Contact $customer, Gateway $gatewa
'cart_state' => $cart->toArray(),
]);
+ // `checkout` is the chkt_* public id and `token` is a separate checkout_* value.
+ // GET /checkouts/status needs BOTH, and only initializeQPayCheckout was returning
+ // the id — so a cash or card client could never reach its own checkout's status.
return response()->json([
- 'token' => $checkout->token,
+ 'checkout' => $checkout->public_id,
+ 'token' => $checkout->token,
]);
}
@@ -351,11 +470,14 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate
'cart_state' => $cart->toArray(),
]);
+ // See initializeCheckout: `checkout` is the chkt_* public id GET /checkouts/status
+ // requires alongside the token, and nothing but the QPay path used to return it.
return response()->json([
'paymentIntent' => $paymentIntent->id,
'clientSecret' => $paymentIntent->client_secret,
'ephemeralKey' => $ephemeralKey->secret,
'customerId' => $customer->getMeta('stripe_id'),
+ 'checkout' => $checkout->public_id,
'token' => $checkout->token,
]);
}
@@ -373,7 +495,10 @@ public function createStripeSetupIntentForCustomer(CreateStripeSetupIntentReques
return response()->apiError('Gateway not configured correctly!');
}
- $customer = Customer::findFromCustomerId($customerId);
+ $customer = static::resolveCheckoutCustomer($customerId);
+ if ($customer instanceof \Illuminate\Http\JsonResponse) {
+ return $customer;
+ }
\Stripe\Stripe::setApiKey($gateway->config->secret_key);
@@ -472,7 +597,10 @@ public function updateStripePaymentIntent(Request $request)
}
// @codeCoverageIgnoreEnd
- $customer = Customer::findFromCustomerId($customerId);
+ $customer = static::resolveCheckoutCustomer($customerId);
+ if ($customer instanceof \Illuminate\Http\JsonResponse) {
+ return $customer;
+ }
if (!$customer) {
return response()->apiError('Invalid customer ID provided');
}
@@ -568,12 +696,14 @@ public function updateStripePaymentIntent(Request $request)
'cart_state' => $cart->toArray(),
]);
- // Return JSON response with updated PaymentIntent and ephemeral key
+ // Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is
+ // the chkt_* public id GET /checkouts/status requires alongside the token.
return response()->json([
'paymentIntent' => $paymentIntent->id,
'clientSecret' => $paymentIntent->client_secret,
'ephemeralKey' => $ephemeralKey->secret,
'customerId' => $customer->getMeta('stripe_id'),
+ 'checkout' => $checkout->public_id,
'token' => $checkout->token,
]);
}
diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php
index 3a38bacc..d9ef326f 100644
--- a/server/src/Http/Controllers/v1/CustomerController.php
+++ b/server/src/Http/Controllers/v1/CustomerController.php
@@ -25,6 +25,7 @@
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class CustomerController extends Controller
@@ -175,13 +176,23 @@ public function create(CreateCustomerRequest $request)
$code = $request->input('code');
$about = Storefront::about(['company_uuid']);
$input = $request->only(['name', 'type', 'title', 'email', 'phone', 'meta']);
- $identity = $request->input('identity');
$user = null;
- if (!Utils::isEmail($identity)) {
+ // The code was filed against whatever identity requestCustomerCreationCode was
+ // given. A client that just verified an address and now posts it as `email` should
+ // not have to repeat it as `identity` — fall back to the payload before giving up.
+ // Without this, a body of {name, email, code} left $identity null, static::phone()
+ // turned it into the literal '+', and a perfectly good code was rejected.
+ $identity = $request->input('identity') ?: $request->input('email') ?: $request->input('phone');
+
+ if ($identity && !Utils::isEmail($identity)) {
$identity = static::phone($identity);
}
+ if (blank($identity)) {
+ return response()->apiError('An identity is required to create a customer.');
+ }
+
// verify code
$verificationCode = VerificationCode::where(['code' => $code, 'for' => 'storefront_create_customer', 'meta->identity' => $identity])->exists();
if (!$verificationCode) {
@@ -414,7 +425,13 @@ public function login(Request $request)
$password = $request->input('password');
$attrs = $request->input(['name', 'phone', 'email']);
- $user = User::where('email', $identity)->orWhere('phone', static::phone($identity))->first();
+ // Guard the phone branch: with no identity to format, static::phone() returns null,
+ // and `where('phone', null)` compiles to `phone IS NULL` — which would match an
+ // arbitrary phone-less user rather than nobody.
+ $identityPhone = static::phone($identity);
+ $user = User::where('email', $identity)
+ ->when($identityPhone, fn ($query) => $query->orWhere('phone', $identityPhone))
+ ->first();
if (!$user || !Hash::check($password, $user->password)) {
return response()->apiError('Authentication failed using password provided.', 401);
@@ -461,6 +478,12 @@ public function loginWithPhone()
{
$phone = static::phone();
+ // Without a phone in the request there is nothing to look up. Falling through would
+ // compile to `phone IS NULL` and hand back an arbitrary phone-less user.
+ if (!$phone) {
+ return response()->apiError('No customer with this phone # found.');
+ }
+
// check if user exists
$user = User::where('phone', $phone)->whereNull('deleted_at')->withoutGlobalScopes()->first();
@@ -471,14 +494,47 @@ public function loginWithPhone()
// get the storefront or network logging in for
$about = Storefront::about();
- // generate verification token
- VerificationCode::generateSmsVerificationFor($user, 'storefront_login', [
- 'messageCallback' => function ($verification) use ($about) {
- return "Your {$about->name} verification code is {$verification->code}";
- },
- ]);
+ // Generate the verification token.
+ //
+ // The SMS attempt is guarded because the Twilio SDK THROWS when the store has no
+ // credentials configured — ConfigurationException, "Credentials are required to
+ // create a Client" — and that propagated as a 500 carrying an HTML stack trace.
+ // A store that has simply not set up SMS is not a server error.
+ //
+ // Falling back to email mirrors FleetOps' DriverController::loginWithPhone, and
+ // means a store without Twilio can still authenticate its customers. `method`
+ // tells the client which channel actually carried the code.
+ $messageCallback = function ($verification) use ($about) {
+ return "Your {$about->name} verification code is {$verification->code}";
+ };
- return response()->json(['status' => 'OK']);
+ try {
+ VerificationCode::generateSmsVerificationFor($user, 'storefront_login', [
+ 'messageCallback' => $messageCallback,
+ ]);
+
+ return response()->json(['status' => 'OK', 'method' => 'sms']);
+ } catch (\Throwable $e) {
+ if (app()->bound('sentry')) {
+ app('sentry')->captureException($e);
+ }
+
+ if ($user->email) {
+ try {
+ VerificationCode::generateEmailVerificationFor($user, 'storefront_login', [
+ 'messageCallback' => $messageCallback,
+ ]);
+
+ return response()->json(['status' => 'OK', 'method' => 'email']);
+ } catch (\Throwable $e) {
+ if (app()->bound('sentry')) {
+ app('sentry')->captureException($e);
+ }
+ }
+ }
+ }
+
+ return response()->apiError('Unable to send verification code.');
}
/**
@@ -517,8 +573,25 @@ public function loginWithApple(Request $request)
}
try {
- // Verify the Apple token using the utility function
- $isValid = $this->verifyAppleIdentity($identityToken);
+ // Verify the Apple token using the utility function.
+ //
+ // A malformed identityToken is client input, not a server fault, but the JWT
+ // parser throws rather than returning false — so a bad token fell through to
+ // the catch at the end of this method and came back as
+ // 500 {"error":"The JWT string must have two dots"}
+ // leaking the parser's own message. Any client sending a truncated or expired
+ // token hit this. It is the same rejection as a token that parses but does not
+ // verify, so it gets the same 400.
+ try {
+ $isValid = $this->verifyAppleIdentity($identityToken);
+ } catch (\Throwable $verificationException) {
+ Log::warning('[Storefront] Apple identity token could not be parsed.', [
+ 'exception' => $verificationException->getMessage(),
+ ]);
+
+ $isValid = false;
+ }
+
if (!$isValid) {
return response()->apiError('Apple ID authentication is not valid.', 400);
}
@@ -723,6 +796,46 @@ public function loginWithGoogle(Request $request)
*
* @return \Fleetbase\Http\Resources\Storefront\Customer
*/
+ /**
+ * Whether a verification code should be accepted as an app-review bypass.
+ *
+ * App store reviewers cannot receive our SMS or email, so a fixed code has to keep
+ * working in production. It used to be compared against the submitted code alone,
+ * which meant anyone who learned it could authenticate as ANY customer. It is now
+ * only honoured for an identity explicitly listed in storefront.review_accounts,
+ * and both the code and the list must be configured.
+ *
+ * @param string|null $identity email or phone the caller is authenticating as
+ * @param mixed $code the submitted verification code
+ */
+ protected static function isReviewAccountBypass(?string $identity, $code): bool
+ {
+ $bypassCode = config('storefront.storefront_app.bypass_verification_code');
+ if (blank($bypassCode) || blank($code) || blank($identity)) {
+ return false;
+ }
+
+ $reviewAccounts = array_map(
+ static fn ($account) => strtolower(trim((string) $account)),
+ (array) config('storefront.storefront_app.review_accounts', [])
+ );
+
+ if (!in_array(strtolower(trim($identity)), $reviewAccounts, true)) {
+ return false;
+ }
+
+ // hash_equals so a wrong code cannot be recovered by timing the response.
+ if (!hash_equals((string) $bypassCode, (string) $code)) {
+ return false;
+ }
+
+ Log::warning('[Storefront] Verification bypass accepted for a review account.', [
+ 'identity' => $identity,
+ ]);
+
+ return true;
+ }
+
public function verifyCode(Request $request)
{
$identity = Utils::isEmail($request->identity) ? $request->identity : static::phone($request->identity);
@@ -734,6 +847,13 @@ public function verifyCode(Request $request)
return $this->create($request);
}
+ // Without an identity there is nobody to verify. The lookup below would compile to
+ // `phone IS NULL OR email IS NULL` and pick an arbitrary user to test the code
+ // against.
+ if (blank($identity)) {
+ return response()->apiError('Unable to verify code.');
+ }
+
// check if user exists
$user = User::where('phone', $identity)->orWhere('email', $identity)->first();
@@ -743,7 +863,7 @@ public function verifyCode(Request $request)
// find and verify code
$verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists();
- if (!$verificationCode && $code !== config('storefront.storefront_app.bypass_verification_code')) {
+ if (!$verificationCode && !static::isReviewAccountBypass($identity, $code)) {
return response()->apiError('Invalid verification code!');
}
@@ -792,12 +912,19 @@ protected function verifyGoogleIdentity(string $idToken, string $clientId): ?arr
/**
* Patches phone number with international code.
*/
- public static function phone(?string $phone = null): string
+ public static function phone(?string $phone = null): ?string
{
if ($phone === null) {
$phone = request()->input('phone');
}
+ // With nothing to format this used to return a bare '+', which was then written
+ // into contacts.phone and users.phone for every customer created without one, and
+ // used as a verification-code lookup key that could never match.
+ if (blank($phone)) {
+ return null;
+ }
+
if (!Str::startsWith($phone, '+')) {
$phone = '+' . $phone;
}
@@ -898,31 +1025,57 @@ public function startAccountClosure(Request $request)
return response()->apiError('Customer account must have a valid email or phone number linked.');
}
- // Send account closure confirmation with code
- try {
- if ($user->phone) {
+ // The identity the code is filed under MUST match what confirmAccountClosure looks
+ // it up by — `$user->phone ?? $user->email` — regardless of which channel actually
+ // carried it. Previously SMS filed it under the phone and email under the email,
+ // which agreed only because the channel was chosen by the same precedence; the
+ // fallback below breaks that coupling, so it is made explicit.
+ $identity = $user->phone ?? $user->email;
+ $messageCallback = function ($verification) use ($about) {
+ return "Your {$about->name} account closure verification code is {$verification->code}";
+ };
+
+ // The SMS attempt is guarded rather than sharing one try with the email branch.
+ // The Twilio SDK throws when the store has no credentials configured, and the old
+ // `if phone / elseif email` meant a customer WITH a phone never reached the email
+ // branch — the request just returned the SDK's own message, "Credentials are
+ // required to create a Client", to the client.
+ $sent = false;
+
+ if ($user->phone) {
+ try {
VerificationCode::generateSmsVerificationFor($user, 'storefront_account_closure', [
- 'messageCallback' => function ($verification) use ($about) {
- return "Your {$about->name} account closure verification code is {$verification->code}";
- },
- 'meta' => ['identity' => $user->phone],
+ 'messageCallback' => $messageCallback,
+ 'meta' => ['identity' => $identity],
]);
- } elseif ($user->email) {
+ $sent = true;
+ } catch (\Throwable $e) {
+ if (app()->bound('sentry')) {
+ app('sentry')->captureException($e);
+ }
+ }
+ }
+
+ if (!$sent && $user->email) {
+ try {
VerificationCode::generateEmailVerificationFor($user, 'storefront_account_closure', [
'subject' => $about->name . ' account closure request',
- 'messageCallback' => function ($verification) use ($about) {
- return "Your {$about->name} account closure verification code is {$verification->code}";
- },
- 'meta' => ['identity' => $user->email],
+ 'messageCallback' => $messageCallback,
+ 'meta' => ['identity' => $identity],
]);
+ $sent = true;
+ } catch (\Throwable $e) {
+ if (app()->bound('sentry')) {
+ app('sentry')->captureException($e);
+ }
}
+ }
+ if ($sent) {
return response()->json(['status' => 'OK']);
- } catch (\Exception $e) {
- return response()->apiError($e->getMessage());
}
- return response()->apiError('An uknown error occured attempting to close customer account.');
+ return response()->apiError('Unable to send account closure verification code.');
}
public function confirmAccountClosure(Request $request)
@@ -949,7 +1102,7 @@ public function confirmAccountClosure(Request $request)
// verify account closure code
$verificationCode = VerificationCode::where(['code' => $code, 'for' => 'storefront_account_closure', 'meta->identity' => $identity])->exists();
- if (!$verificationCode && $code !== config('storefront.storefront_app.bypass_verification_code')) {
+ if (!$verificationCode && !static::isReviewAccountBypass($identity, $code)) {
return response()->apiError('Invalid verification code provided!');
}
@@ -990,6 +1143,12 @@ public function requestPhoneVerification(Request $request)
return response()->apiError('No user associated with this customer.');
}
+ // No phone to verify. This used to arrive as the literal '+' and get as far as the
+ // SMS provider before failing with a credentials error.
+ if (!$phone) {
+ return response()->apiError('A phone number is required to request verification.');
+ }
+
// Check if phone number is already used by another user
$existingUser = $this->findExistingUserByPhone($phone, $user->uuid);
@@ -999,6 +1158,14 @@ public function requestPhoneVerification(Request $request)
$about = Storefront::about();
+ // A review account verifies with the configured bypass code, so no message needs
+ // to be delivered — and requiring one would make the flow untestable wherever SMS
+ // is not configured, which is the situation this bypass exists for. Same
+ // allowlist + constant-time code check as every other bypass call site.
+ if (static::isReviewAccountBypass($phone, config('storefront.storefront_app.bypass_verification_code'))) {
+ return response()->json(['status' => 'ok', 'method' => 'bypass']);
+ }
+
try {
VerificationCode::generateSmsVerificationFor($user, 'storefront_verify_phone', [
'messageCallback' => function ($verification) use ($about) {
@@ -1008,8 +1175,18 @@ public function requestPhoneVerification(Request $request)
]);
return response()->json(['status' => 'ok']);
- } catch (\Exception $e) {
- return response()->apiError($e->getMessage());
+ } catch (\Throwable $e) {
+ if (app()->bound('sentry')) {
+ app('sentry')->captureException($e);
+ }
+
+ // Deliberately not $e->getMessage(): the Twilio SDK throws
+ // "Credentials are required to create a Client" when the store has no SMS
+ // credentials, and returning that to an API consumer leaks an internal
+ // detail while telling them nothing they can act on. Unlike customer login
+ // and account closure, there is no email fallback here — verifying a phone
+ // number by email would not verify anything.
+ return response()->apiError('Unable to send phone verification code.');
}
}
@@ -1049,12 +1226,28 @@ public function verifyPhoneNumber(Request $request)
'for' => 'storefront_verify_phone',
])->first();
+ // The bypass leaves no code row to read the phone back from, so it comes from the
+ // request — which is what the caller is asking to verify in the first place.
+ $requestedPhone = $request->input('phone') ? static::phone($request->input('phone')) : null;
+
if (!$verificationCode) {
+ if ($requestedPhone && static::isReviewAccountBypass($requestedPhone, $code)) {
+ $user->update(['phone' => $requestedPhone, 'phone_verified_at' => now()]);
+ $customer->update(['phone' => $requestedPhone]);
+
+ return new Customer($customer->fresh());
+ }
+
return response()->apiError('Invalid verification code!');
}
- // Get the phone number from meta
- $phone = $verificationCode->meta['phone'];
+ // Get the phone number from meta. A row written by anything other than
+ // requestPhoneVerification may not carry it, and an unguarded subscript turns a
+ // recoverable 400 into a 500.
+ $phone = data_get($verificationCode->meta, 'phone');
+ if (!$phone) {
+ return response()->apiError('Verification code is not associated with a phone number.');
+ }
// Update user and contact
$user->update(['phone' => $phone, 'phone_verified_at' => now()]);
diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php
index f9746080..d6514f73 100644
--- a/server/src/Http/Controllers/v1/NetworkController.php
+++ b/server/src/Http/Controllers/v1/NetworkController.php
@@ -25,15 +25,17 @@ public function stores(Request $request)
return response()->error('Stores cannot have stores!');
}
- $sort = $request->input('sort', false);
- $limit = $request->input('limit', false);
- $offset = $request->input('offset', false);
- $ids = $request->input('ids', []);
- $tagged = $request->input('tagged', []);
- $query = $request->input('query', false);
- $location = $request->input('location');
- $maxDistance = $request->input('maximum_distance', null);
- $exclude = $request->input('exclude', []);
+ $sort = $request->input('sort', false);
+ $limit = $request->input('limit', false);
+ $offset = $request->input('offset', false);
+ $ids = $request->input('ids', []);
+ $tagged = $request->input('tagged', []);
+ $searchQuery = $request->input('query', false);
+ $location = $request->input('location');
+ $maxDistance = $request->input('maximum_distance', null);
+ $exclude = $request->input('exclude', []);
+ $coordinates = Utils::getPointFromCoordinates($location);
+ $requiresDistancePostProcessing = $coordinates instanceof Point && ($sort === 'nearest' || is_numeric($maxDistance));
if (is_string($tagged)) {
$tagged = explode(',', $tagged);
@@ -49,8 +51,7 @@ public function stores(Request $request)
/** @var \Illuminate\Database\Query\Builder $query */
$query = Store::select('*')
- ->where('company_uuid', session('company'))
- ->with(['logo', 'backdrop', 'media'])
+ ->with(['logo', 'backdrop', 'media', 'locations.place'])
->whereHas('locations')
->whereHas('networks', function ($q) use ($request) {
$q->where('network_uuid', session('storefront_network'));
@@ -62,12 +63,24 @@ public function stores(Request $request)
// Query stores by category
if ($request->filled('category')) {
- $category = Category::select('uuid')->where('public_id', $request->input('category'))->first();
- $q->where('category_uuid', $category->uuid);
- // $q->whereHas('category', function ())
+ $category = Category::select('uuid')
+ ->where('public_id', $request->input('category'))
+ ->where('owner_uuid', session('storefront_network'))
+ ->where('for', 'storefront_network')
+ ->first();
+
+ $q->where('category_uuid', $category?->uuid ?? '__missing_network_category__');
}
});
+ if ($searchQuery) {
+ $query->search($searchQuery);
+ }
+
+ if ($request->has('online')) {
+ $query->where('online', $request->boolean('online'));
+ }
+
// query stores using tags provided
if (!empty($tagged)) {
$query->where(function ($q) use ($tagged) {
@@ -102,64 +115,62 @@ public function stores(Request $request)
switch ($sort) {
case 'highest_rated':
$query->withAvg('reviews', 'rating')->orderByDesc('reviews_avg_rating');
- // no break
+ break;
case 'lowest_rated':
$query->withAvg('reviews', 'rating')->orderBy('reviews_avg_rating');
- // no break
+ break;
case 'newest':
$query->orderByDesc('created_at');
- // no break
+ break;
case 'oldest':
$query->orderBy('created_at');
- // no break
+ break;
case 'popular':
$query->withCount('checkouts')->orderByDesc('checkouts_count');
+ break;
+ case 'trending':
+ $query->withCount([
+ 'checkouts as recent_checkouts_count' => fn ($checkoutQuery) => $checkoutQuery->where('created_at', '>=', now()->subDay()),
+ ])->orderByDesc('recent_checkouts_count');
+ break;
}
- if ($limit) {
+ if ($limit && !$requiresDistancePostProcessing) {
$query->limit($limit);
}
- if ($offset) {
+ if ($offset && !$requiresDistancePostProcessing) {
$query->offset($offset);
}
$stores = $query->get();
// handle nearest sort special case due to location depth
- if ($sort === 'nearest' && $location) {
- $coordinates = Utils::getPointFromCoordinates($location);
-
- $stores = $stores->sort(function ($storeA, $storeB) use ($coordinates) {
- $distanceA = $storeA->locations->sort(function ($locationA, $locationB) use ($coordinates) {
- $distanceA = Utils::vincentyGreatCircleDistance($coordinates, $locationA->place->location);
- $distanceB = Utils::vincentyGreatCircleDistance($coordinates, $locationB->place->location);
-
- return $distanceA - $distanceB;
- })->map(function ($location) use ($coordinates) {
- $location->distance = Utils::vincentyGreatCircleDistance($coordinates, $location->place->location);
-
- return $location;
- })->first()->distance;
-
- $distanceB = $storeB->locations->sort(function ($locationA, $locationB) use ($coordinates) {
- $distanceA = Utils::vincentyGreatCircleDistance($coordinates, $locationA->place->location);
- $distanceB = Utils::vincentyGreatCircleDistance($coordinates, $locationB->place->location);
-
- return $distanceA - $distanceB;
- })->map(function ($location) use ($coordinates) {
- $location->distance = Utils::vincentyGreatCircleDistance($coordinates, $location->place->location);
-
- return $location;
- })->first()->distance;
-
- return $distanceA - $distanceB;
- });
- }
-
- // sort trending ( most checkouts within 24h )
- if ($sort === 'trending') {
- $stores = $stores->sortByDesc('24h_checkouts_count');
+ if ($requiresDistancePostProcessing) {
+ $stores = $stores->map(function ($store) use ($coordinates) {
+ $nearestLocation = $store->locations
+ ->filter(fn ($storeLocation) => $storeLocation->place?->location instanceof Point)
+ ->map(function ($storeLocation) use ($coordinates) {
+ $storeLocation->distance = Utils::vincentyGreatCircleDistance($coordinates, $storeLocation->place->location);
+
+ return $storeLocation;
+ })
+ ->sortBy('distance')
+ ->first();
+
+ $store->distance = $nearestLocation?->distance;
+
+ return $store;
+ })->when(is_numeric($maxDistance), function ($collection) use ($maxDistance) {
+ return $collection->filter(fn ($store) => is_numeric($store->distance) && $store->distance <= (float) $maxDistance);
+ })->when($sort === 'nearest', fn ($collection) => $collection->sortBy(fn ($store) => $store->distance ?? PHP_FLOAT_MAX))->values();
+
+ if ($offset) {
+ $stores = $stores->slice((int) $offset)->values();
+ }
+ if ($limit) {
+ $stores = $stores->take((int) $limit)->values();
+ }
}
return StorefrontStore::collection($stores);
@@ -172,6 +183,13 @@ public function stores(Request $request)
*/
public function storeLocations(Request $request)
{
+ $storeUuid = session('storefront_store');
+ $networkUuid = session('storefront_network');
+
+ if (!$storeUuid && !$networkUuid) {
+ return response()->error('Store locations require a storefront context!');
+ }
+
$limit = $request->input('limit', 30);
$ids = $request->input('ids', []);
$exclude = $request->input('exclude', []);
@@ -199,10 +217,12 @@ public function storeLocations(Request $request)
$query = StoreLocation::select(['store_locations.*', $placesTableName . '.location', $placesTableName . '.uuid as place_uuid'])
->join($placesTableName, $placesTableName . '.uuid', '=', 'store_locations.place_uuid')
- ->whereHas('store', function ($q) use ($tagged, $searchQuery) {
- $q->whereHas('networks', function ($q) {
- $q->where('network_uuid', session('storefront_network'));
- });
+ ->whereHas('store', function ($q) use ($storeUuid, $networkUuid, $tagged, $searchQuery) {
+ if ($storeUuid) {
+ $q->where('uuid', $storeUuid);
+ } else {
+ $q->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', $networkUuid));
+ }
if (!empty($tagged)) {
$q->where(function ($q) use ($tagged) {
@@ -218,7 +238,7 @@ public function storeLocations(Request $request)
});
if ($shouldIncludeStore) {
- $query->with('store');
+ $query->with(['store.logo', 'store.backdrop']);
}
// if we need to exclude specific stores
@@ -259,13 +279,22 @@ public function storeLocations(Request $request)
*/
public function tags(Request $request)
{
+ $storeUuid = session('storefront_store');
+ $networkUuid = session('storefront_network');
+
+ if (!$storeUuid && !$networkUuid) {
+ return response()->error('Tags require a storefront context!');
+ }
+
$tags = [];
$stores = Store::select(['tags'])
->whereHas('locations')
- ->whereHas('networks', function ($q) {
- $q->where('network_uuid', session('storefront_network'));
- })->get();
+ ->when($storeUuid, fn ($query) => $query->where('uuid', $storeUuid))
+ ->when($networkUuid, function ($query) use ($networkUuid) {
+ $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', $networkUuid));
+ })
+ ->get();
foreach ($stores as $store) {
$tags = array_merge($tags, $store->tags ?? []);
diff --git a/server/src/Http/Controllers/v1/ProductController.php b/server/src/Http/Controllers/v1/ProductController.php
index 456ec44c..01a95663 100644
--- a/server/src/Http/Controllers/v1/ProductController.php
+++ b/server/src/Http/Controllers/v1/ProductController.php
@@ -68,6 +68,15 @@ public function create(CreateProductRequest $request)
// Set currency
$input['currency'] = data_get($input, 'currency', session('storefront_currency', 'USD'));
+ // Default the status the way the console does. The column is nullable with no
+ // default, so an API-created product used to land as NULL and was then invisible to
+ // every `where('status', 'published')` read path — including the cart validation in
+ // CheckoutController, which dropped it at capture time.
+ // Literal rather than Product::PUBLISHED. The constant does not resolve under the
+ // unit-test harness (reproducible on the whole suite, not just an isolated file),
+ // and the rest of this controller writes the status literally too.
+ $input['status'] = data_get($input, 'status') ?: 'published';
+
// Resolve category
if ($request->filled('category')) {
$categoryInput = $request->input('category');
@@ -341,11 +350,20 @@ public function query(Request $request)
$nq->where('network_uuid', session('storefront_network'));
});
});
+
+ if ($request->filled('store')) {
+ $query->whereHas('store', fn ($storeQuery) => $storeQuery->where('public_id', $request->input('store')));
+ }
}
- // @todo When done dev is completed make sure status is published - also add status field to product view
$query->where('is_available', 1);
- $query->with(['addonCategories.category', 'variants.options', 'files']);
+ $query->where('status', 'published');
+ $relations = ['addonCategories.category', 'variants.options', 'files'];
+ if ($request->boolean('with_store') || $request->inArray('with', 'store')) {
+ $relations[] = 'store.logo';
+ $relations[] = 'store.backdrop';
+ }
+ $query->with($relations);
if ($request->filled('category')) {
$category = Category::where(['public_id' => $request->input('category'), 'for' => 'storefront_product'])->first();
@@ -366,11 +384,20 @@ public function query(Request $request)
*/
public function find($id)
{
- // find for the product
- try {
- $product = Product::findRecordOrFail($id);
- } catch (ModelNotFoundException $exception) {
- return response()->error('Product resource not found.');
+ $product = Product::where(function ($query) use ($id) {
+ $query->where('public_id', $id)->orWhere('uuid', $id);
+ })
+ ->when(session('storefront_store'), fn ($query) => $query->where('store_uuid', session('storefront_store')))
+ ->when(session('storefront_network'), function ($query) {
+ $query->whereHas('store.networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network')));
+ $query->where('is_available', 1);
+ $query->where('status', 'published');
+ })
+ ->with(['addonCategories.category', 'variants.options', 'files'])
+ ->first();
+
+ if (!$product) {
+ return response()->error('Product resource not found.', 404);
}
// response the product resource
diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php
index 90a69ecc..dd51ba78 100644
--- a/server/src/Http/Controllers/v1/ReviewController.php
+++ b/server/src/Http/Controllers/v1/ReviewController.php
@@ -8,6 +8,7 @@
use Fleetbase\Models\File;
use Fleetbase\Storefront\Http\Requests\CreateReviewRequest;
use Fleetbase\Storefront\Http\Resources\Review as StorefrontReview;
+use Fleetbase\Storefront\Models\Product;
use Fleetbase\Storefront\Models\Review;
use Fleetbase\Storefront\Models\Store;
use Fleetbase\Storefront\Support\Storefront;
@@ -17,6 +18,58 @@
class ReviewController extends Controller
{
+ protected function resolveStoreForContext(string $id): ?Store
+ {
+ return Store::where('public_id', $id)
+ ->when(session('storefront_store'), fn ($query) => $query->where('uuid', session('storefront_store')))
+ ->when(session('storefront_network'), function ($query) {
+ $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network')));
+ })
+ ->first();
+ }
+
+ protected function findScopedReview(string $id): ?Review
+ {
+ return Review::where(function ($query) use ($id) {
+ $query->where('public_id', $id)->orWhere('uuid', $id);
+ })
+ ->when(session('storefront_store'), function ($query) {
+ $storeUuid = session('storefront_store');
+ $query->where(function ($subjectQuery) use ($storeUuid) {
+ $subjectQuery->where('subject_uuid', $storeUuid)
+ ->orWhereIn('subject_uuid', Product::select('uuid')->where('store_uuid', $storeUuid));
+ });
+ })
+ ->when(session('storefront_network'), function ($query) {
+ $memberStoreUuids = Store::select('uuid')
+ ->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network')));
+ $memberProductUuids = Product::select('uuid')->whereIn('store_uuid', clone $memberStoreUuids);
+ $query->where(function ($subjectQuery) use ($memberStoreUuids, $memberProductUuids) {
+ $subjectQuery->whereIn('subject_uuid', $memberStoreUuids)
+ ->orWhereIn('subject_uuid', $memberProductUuids);
+ });
+ })
+ ->first();
+ }
+
+ protected function subjectBelongsToContext($subject): bool
+ {
+ if ($subject instanceof Store) {
+ return (bool) $this->resolveStoreForContext($subject->public_id);
+ }
+
+ if ($subject instanceof Product) {
+ return Product::where('uuid', $subject->uuid)
+ ->when(session('storefront_store'), fn ($query) => $query->where('store_uuid', session('storefront_store')))
+ ->when(session('storefront_network'), function ($query) {
+ $query->whereHas('store.networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network')));
+ })
+ ->exists();
+ }
+
+ return false;
+ }
+
/**
* Query for Storefront Review resources.
*
@@ -49,12 +102,7 @@ public function query(Request $request)
if (session('storefront_network')) {
if ($request->filled('store')) {
- $store = Store::where([
- 'company_uuid' => session('company'),
- 'public_id' => $request->input('store'),
- ])->whereHas('networks', function ($q) {
- $q->where('network_uuid', session('storefront_network'));
- })->first();
+ $store = $this->resolveStoreForContext($request->input('store'));
if (!$store) {
return response()->json(['error' => 'Cannot find reviews for store'], 400);
@@ -68,7 +116,7 @@ public function query(Request $request)
}
if ($offset) {
- $query->limit($offset);
+ $query->offset($offset);
}
});
}
@@ -130,12 +178,7 @@ public function count(Request $request)
if (session('storefront_network')) {
if ($request->filled('store')) {
- $store = Store::where([
- 'company_uuid' => session('company'),
- 'public_id' => $request->input('store'),
- ])->whereHas('networks', function ($q) {
- $q->where('network_uuid', session('storefront_network'));
- })->first();
+ $store = $this->resolveStoreForContext($request->input('store'));
if (!$store) {
return response()->json(['error' => 'Cannot count reviews for store'], 400);
@@ -161,7 +204,10 @@ public function find($id)
{
// find for the review
try {
- $review = Review::findRecordOrFail($id);
+ $review = $this->findScopedReview($id);
+ if (!$review) {
+ throw new ModelNotFoundException();
+ }
} catch (ModelNotFoundException $exception) {
return response()->error('Review resource not found.');
}
@@ -188,7 +234,7 @@ public function create(CreateReviewRequest $request)
$subject = Utils::resolveSubject($request->input('subject'));
- if (!$subject) {
+ if (!$subject || !$this->subjectBelongsToContext($subject)) {
return response()->error('Invalid subject for review');
}
@@ -247,11 +293,19 @@ public function delete($id)
{
// find for the product
try {
- $review = Review::findRecordOrFail($id);
+ $review = $this->findScopedReview($id);
+ if (!$review) {
+ throw new ModelNotFoundException();
+ }
} catch (ModelNotFoundException $exception) {
return response()->error('Review resource not found.');
}
+ $customer = Storefront::getCustomerFromToken();
+ if (!$customer || $review->customer_uuid !== $customer->uuid) {
+ return response()->error('Not authorized to delete review', 403);
+ }
+
// delete the review
$review->delete();
diff --git a/server/src/Http/Controllers/v1/ServiceQuoteController.php b/server/src/Http/Controllers/v1/ServiceQuoteController.php
index 03407fc1..cee388a0 100644
--- a/server/src/Http/Controllers/v1/ServiceQuoteController.php
+++ b/server/src/Http/Controllers/v1/ServiceQuoteController.php
@@ -209,35 +209,62 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request)
$currency = $cart->currency;
- // collect stores
- $storeLocations = collect($cart->items)->map(function ($cartItem) {
- $storeLocationId = $cartItem->store_location_id;
-
- // if no store location id set, use first locations id
- if (!$storeLocationId) {
- $store = Store::where('public_id', $cartItem->store_id)->first();
-
- if ($store) {
- $storeLocationId = Utils::get($store, 'locations.0.public_id');
- }
- }
-
- return $storeLocationId;
- })->unique()->filter()->map(function ($storeLocationId) {
- return StoreLocation::where('public_id', $storeLocationId)->with(['store', 'place'])->first();
- });
+ // Resolve explicit and default origins in bulk. This avoids querying the
+ // store and its locations once for every cart line.
+ $cartItems = collect($cart->items ?? []);
+ $defaultStoreIds = $cartItems
+ ->filter(fn ($cartItem) => !data_get($cartItem, 'store_location_id') && data_get($cartItem, 'store_id'))
+ ->map(fn ($cartItem) => data_get($cartItem, 'store_id'))
+ ->unique()
+ ->values();
+ $defaultLocationIds = Store::whereIn('public_id', $defaultStoreIds)
+ ->whereHas('networks', fn ($query) => $query->where('network_uuid', session('storefront_network')))
+ ->with(['locations' => fn ($query) => $query->orderBy('id')])
+ ->get()
+ ->mapWithKeys(fn ($store) => [$store->public_id => data_get($store, 'locations.0.public_id')]);
+ $storeLocationIds = $cartItems
+ ->map(function ($cartItem) use ($defaultLocationIds) {
+ return data_get($cartItem, 'store_location_id') ?: $defaultLocationIds->get(data_get($cartItem, 'store_id'));
+ })
+ ->unique()
+ ->filter()
+ ->values();
// fallback store locations using origin param
- if ($storeLocations->isEmpty()) {
+ if ($storeLocationIds->isEmpty()) {
$storeLocationIds = $request->input('origin', []);
if (is_string($storeLocationIds) && Str::contains($storeLocationIds, ',')) {
$storeLocationIds = explode(',', $storeLocationIds);
}
+ $storeLocationIds = collect($storeLocationIds)->unique()->filter()->values();
+ }
- $storeLocations = collect($storeLocationIds)->unique()->filter()->map(function ($storeLocationId) {
- return StoreLocation::where('public_id', $storeLocationId)->with(['store', 'place'])->first();
- });
+ $storeLocations = StoreLocation::whereIn('public_id', $storeLocationIds)
+ ->whereHas('store.networks', fn ($query) => $query->where('network_uuid', session('storefront_network')))
+ ->with(['store', 'place'])
+ ->get()
+ ->sortBy(fn ($storeLocation) => $storeLocationIds->search($storeLocation->public_id))
+ ->values();
+
+ if ($storeLocationIds->isEmpty() || $storeLocations->count() !== $storeLocationIds->count()) {
+ return response()->error('One or more store locations are unavailable for this marketplace.', 422);
+ }
+
+ $storeLocationsById = $storeLocations->keyBy('public_id');
+ $hasMismatchedStore = $cartItems->contains(function ($cartItem) use ($defaultLocationIds, $storeLocationsById) {
+ $storeId = data_get($cartItem, 'store_id');
+ if (!$storeId) {
+ return false;
+ }
+
+ $storeLocationId = data_get($cartItem, 'store_location_id') ?: $defaultLocationIds->get($storeId);
+
+ return data_get($storeLocationsById->get($storeLocationId), 'store.public_id') !== $storeId;
+ });
+
+ if ($hasMismatchedStore) {
+ return response()->error('One or more store locations are unavailable for this marketplace.', 422);
}
// get origins
diff --git a/server/src/Http/Controllers/v1/StoreController.php b/server/src/Http/Controllers/v1/StoreController.php
index 9c47e349..bc1ae560 100644
--- a/server/src/Http/Controllers/v1/StoreController.php
+++ b/server/src/Http/Controllers/v1/StoreController.php
@@ -51,12 +51,23 @@ public function lookup(?string $id)
return response()->apiError('No ID provided for lookup.');
}
- $store = Store::where(['public_id' => $id, 'company_uuid' => session('company')])->first();
+ $store = Store::where('public_id', $id)
+ ->when(session('storefront_network'), function ($query) {
+ $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network')));
+ })
+ ->when(session('storefront_store'), function ($query) {
+ $activeStore = session('storefront_store');
+ $query->where(fn ($storeQuery) => $storeQuery->where('uuid', $activeStore)->orWhere('public_id', $activeStore));
+ })
+ ->first();
if ($store) {
return new StorefrontResource($store);
}
- $network = Network::where(['public_id' => $id, 'company_uuid' => session('company')])->first();
+ $network = Network::where('public_id', $id)
+ ->when(session('storefront_network'), fn ($query) => $query->where('uuid', session('storefront_network')))
+ ->when(session('storefront_store'), fn ($query) => $query->where('company_uuid', session('company')))
+ ->first();
if ($network) {
return new NetworkResource($network);
}
@@ -78,6 +89,14 @@ public function locations(Request $request)
if ($request->filled('store')) {
$locations = StoreLocation::whereHas('store', function ($q) use ($request) {
$q->where('public_id', $request->input('store'));
+
+ if (session('storefront_network')) {
+ $q->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network')));
+ }
+
+ if (session('storefront_store')) {
+ $q->where('uuid', session('storefront_store'));
+ }
})->with(['place', 'hours'])->get();
} else {
$locations = StoreLocation::where('store_uuid', session('storefront_store'))->with(['place', 'hours'])->get();
@@ -98,7 +117,25 @@ public function location(string $id, Request $request)
}
$storeId = $request->input('store', session('storefront_store'));
- $store = Store::where('public_id', $storeId)->orWhere('uuid', $storeId)->first();
+ $store = Store::where(function ($query) use ($storeId) {
+ $query->where('public_id', $storeId)->orWhere('uuid', $storeId);
+ })
+ ->when(session('storefront_network'), function ($query) {
+ $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network')));
+ })
+ ->when(session('storefront_store'), function ($query) {
+ $activeStore = session('storefront_store');
+ $query->where(fn ($storeQuery) => $storeQuery->where('uuid', $activeStore)->orWhere('public_id', $activeStore));
+ })
+ ->first();
+
+ // Both lookups were unguarded, so an id that resolved nothing was handed straight
+ // to the resource and blew up inside it — GET /storefront/v1/locations/{id}
+ // answered 500 with an HTML stack trace for any unknown location, which is what a
+ // client sees for a stale or mistyped id. 404 is the correct answer.
+ if (!$store) {
+ return response()->error('Unable to find store!', 404);
+ }
$location = StoreLocation::where([
'public_id' => $id,
@@ -107,6 +144,10 @@ public function location(string $id, Request $request)
->with(['place', 'hours'])
->first();
+ if (!$location) {
+ return response()->error('Store location not found!', 404);
+ }
+
return new StoreLocationResource($location);
}
@@ -193,7 +234,12 @@ public function search(Request $request)
$categories = Category::where(['company_uuid' => session('company'), 'for' => 'storefront_product'])->search($searchQuery)->get();
if ($categories) {
foreach ($categories as $category) {
- $categoryProducts = Product::where('category_uuid', $category->uuid)->get();
+ $categoryProducts = Product::where([
+ 'category_uuid' => $category->uuid,
+ 'store_uuid' => session('storefront_store'),
+ 'is_available' => 1,
+ 'status' => 'published',
+ ])->get();
$results = $results->merge($categoryProducts)->unique('uuid');
}
}
@@ -202,6 +248,9 @@ public function search(Request $request)
}
$results = Product::findFromNetwork($searchQuery, $store, $limit);
+ if ($request->boolean('with_store') || $request->inArray('with', 'store')) {
+ $results->load(['store.logo', 'store.backdrop']);
+ }
return ProductResource::collection($results);
}
diff --git a/server/src/Http/Middleware/SetStorefrontSession.php b/server/src/Http/Middleware/SetStorefrontSession.php
index c787aee2..0f56bfb0 100644
--- a/server/src/Http/Middleware/SetStorefrontSession.php
+++ b/server/src/Http/Middleware/SetStorefrontSession.php
@@ -53,7 +53,19 @@ public function isValidKey(string $key): bool
*/
public function setKey(string $key): void
{
- $session = ['storefront_key' => $key];
+ // Clear whichever scope the previous key established before writing this one.
+ // Sessions here are cookie-backed, so a client that calls a network endpoint and
+ // then a store endpoint reuses the same session: leaving the old keys in place
+ // left BOTH storefront_store and storefront_network set, and every query that
+ // branches on them (Cart::findProduct, ProductController::find/query, ...) then
+ // applied the store filter AND the stricter network filter at once.
+ $session = [
+ 'storefront_key' => $key,
+ 'storefront_store' => null,
+ 'storefront_store_public_id' => null,
+ 'storefront_network' => null,
+ 'storefront_network_public_id' => null,
+ ];
if (Str::startsWith($key, 'store')) {
$store = Store::select(['uuid', 'public_id', 'company_uuid', 'currency'])->where('key', $key)->first();
diff --git a/server/src/Http/Requests/CreateProductRequest.php b/server/src/Http/Requests/CreateProductRequest.php
index 1ed8c562..e63f5a36 100644
--- a/server/src/Http/Requests/CreateProductRequest.php
+++ b/server/src/Http/Requests/CreateProductRequest.php
@@ -38,7 +38,12 @@ public function rules(): array
'is_recommended' => 'nullable|boolean',
'can_pickup' => 'nullable|boolean',
'youtube_urls' => 'nullable|array',
- 'status' => 'nullable|string|in:draft,active,archived',
+ // `published` is the status the rest of the module reads — Product::PUBLISHED,
+ // the network branch of Cart::findProduct(), ProductController::find/query,
+ // CategoryController, StoreController, and CheckoutController's cart validation
+ // all filter on it, and the console writes it. Leaving it out of this list meant
+ // a product created through the public API could never satisfy any of them.
+ 'status' => 'nullable|string|in:draft,active,archived,published',
'category' => 'nullable',
'addon_categories' => 'nullable|array',
];
diff --git a/server/src/Http/Requests/CreateReviewRequest.php b/server/src/Http/Requests/CreateReviewRequest.php
index 270ed4ee..470ab7bf 100644
--- a/server/src/Http/Requests/CreateReviewRequest.php
+++ b/server/src/Http/Requests/CreateReviewRequest.php
@@ -23,7 +23,15 @@ public function authorize()
*/
public function rules()
{
+ // `subject` is required, and typing it matters: the controller resolves it with
+ // Utils::resolveSubject(), whose parameter is a non-nullable string. A request
+ // without a subject therefore threw a TypeError —
+ // Utils::resolveSubject(): Argument #1 ($publicId) must be of type string,
+ // null given
+ // — as a 500, before the controller's own "Invalid subject for review" guard
+ // could run. That guard was unreachable for the commonest way to get it wrong.
return [
+ 'subject' => 'required|string',
'rating' => 'required|numeric',
'content' => 'required',
'files' => 'sometimes|array',
diff --git a/server/src/Http/Requests/InitializeCheckoutRequest.php b/server/src/Http/Requests/InitializeCheckoutRequest.php
index e06c593c..b2d74140 100644
--- a/server/src/Http/Requests/InitializeCheckoutRequest.php
+++ b/server/src/Http/Requests/InitializeCheckoutRequest.php
@@ -30,9 +30,14 @@ public function rules()
'gateway' => ['required', new GatewayExists()],
'customer' => ['required', new CustomerExists()],
'cart' => ['required', 'exists:storefront.carts,public_id'],
- 'serviceQuote' => [Rule::requiredIf(fn () => !$this->boolean('pickup')), 'exists:service_quotes,public_id'],
- 'cash' => ['sometimes', 'boolean'],
- 'pickup' => ['sometimes', 'boolean'],
+ // The controller reads the quote with or(['serviceQuote', 'service_quote']), so both
+ // spellings are supported downstream. Validating only the camelCase one rejected every
+ // snake_case request with "The service quote field is required." before the controller
+ // ever ran — including the spelling our own API reference documents.
+ 'serviceQuote' => [Rule::requiredIf(fn () => !$this->boolean('pickup') && !$this->filled('service_quote')), 'exists:service_quotes,public_id'],
+ 'service_quote' => [Rule::requiredIf(fn () => !$this->boolean('pickup') && !$this->filled('serviceQuote')), 'exists:service_quotes,public_id'],
+ 'cash' => ['sometimes', 'boolean'],
+ 'pickup' => ['sometimes', 'boolean'],
];
}
}
diff --git a/server/src/Http/Resources/Cart.php b/server/src/Http/Resources/Cart.php
index 593e8515..840d80cf 100644
--- a/server/src/Http/Resources/Cart.php
+++ b/server/src/Http/Resources/Cart.php
@@ -43,12 +43,28 @@ public function getCartItems()
{
$items = $this->items ?? [];
- return array_map(function ($cartItem) {
- $product = Product::select(['uuid', 'public_id', 'primary_image_uuid', 'name', 'description'])->with(['files'])->where('public_id', data_get($cartItem, 'product_id'))->first();
+ $products = Product::select(['uuid', 'public_id', 'store_uuid', 'primary_image_uuid', 'name', 'description'])
+ ->with(['files', 'store.logo', 'store.backdrop'])
+ ->whereIn('public_id', collect($items)->pluck('product_id')->filter()->unique())
+ ->get()
+ ->keyBy('public_id');
+
+ return array_map(function ($cartItem) use ($products) {
+ $product = $products->get(data_get($cartItem, 'product_id'));
if ($product) {
data_set($cartItem, 'name', $product->name);
data_set($cartItem, 'description', $product->description);
data_set($cartItem, 'product_image_url', $product->primary_image_url);
+ if ($product->store) {
+ data_set($cartItem, 'store', [
+ 'id' => $product->store->public_id,
+ 'name' => $product->store->name,
+ 'logo_url' => $product->store->logo_url,
+ 'backdrop_url' => $product->store->backdrop_url,
+ 'online' => $product->store->online,
+ 'currency' => $product->store->currency,
+ ]);
+ }
}
return $cartItem;
diff --git a/server/src/Http/Resources/Product.php b/server/src/Http/Resources/Product.php
index 9dced7e9..db403dbb 100644
--- a/server/src/Http/Resources/Product.php
+++ b/server/src/Http/Resources/Product.php
@@ -32,6 +32,7 @@ public function toArray($request)
'name' => $this->name,
'description' => $this->description,
'category' => $this->when(Http::isInternalRequest(), $this->category),
+ 'store' => $this->when($request->boolean('with_store') || $request->inArray('with', 'store'), new Store($this->store)),
'sku' => $this->sku,
'primary_image_url' => $this->primary_image_url,
'price' => $this->price,
diff --git a/server/src/Http/Resources/Store.php b/server/src/Http/Resources/Store.php
index 51efd249..f515a413 100644
--- a/server/src/Http/Resources/Store.php
+++ b/server/src/Http/Resources/Store.php
@@ -46,6 +46,7 @@ public function toArray($request)
'backdrop_url' => $this->backdrop_url,
'rating' => $this->rating,
'online' => $this->online,
+ 'distance' => $this->when(isset($this->distance), $this->distance),
'alertable' => $this->alertable,
'is_network' => false,
'is_store' => true,
diff --git a/server/src/Models/Cart.php b/server/src/Models/Cart.php
index c9e77166..4e65bfe8 100644
--- a/server/src/Models/Cart.php
+++ b/server/src/Models/Cart.php
@@ -307,6 +307,18 @@ public function addItem(Product $product, $quantity = 1, $variants = [], $addons
$id = Utils::generatePublicId('cart_item');
$cartItem = new \stdClass();
+ if ($storeLocationId && !Str::startsWith($storeLocationId, 'food_truck_')) {
+ $locationBelongsToStore = StoreLocation::where('store_uuid', $product->store_uuid)
+ ->where(function ($query) use ($storeLocationId) {
+ $query->where('public_id', $storeLocationId)->orWhere('uuid', $storeLocationId);
+ })
+ ->exists();
+
+ if (!$locationBelongsToStore) {
+ throw new \Exception('The selected store location is not available for this product.');
+ }
+ }
+
// set base price
$price = Utils::numbersOnly($product->is_on_sale ? $product->sale_price : $product->price);
@@ -643,6 +655,10 @@ public static function retrieve(?string $id, bool $excludeCheckedout = true): Ca
$q->orWhere('unique_identifier', $id);
});
+ if (session('company')) {
+ $query->where('company_uuid', session('company'));
+ }
+
if ($excludeCheckedout) {
$query->whereNull('checkout_uuid');
}
@@ -684,7 +700,16 @@ public static function calculateProductSubtotal(Product $product, $quantity = 1,
*/
public static function findProduct(string $id): ?Product
{
- return Product::select(['uuid', 'store_uuid', 'public_id', 'name', 'description', 'price', 'currency', 'sale_price', 'is_on_sale', 'meta'])->where(['public_id' => $id])->with([])->first();
+ return Product::select(['uuid', 'store_uuid', 'public_id', 'primary_image_uuid', 'name', 'description', 'price', 'currency', 'sale_price', 'is_on_sale', 'is_available', 'status', 'meta'])
+ ->where('public_id', $id)
+ ->when(session('storefront_store'), fn ($query) => $query->where('store_uuid', session('storefront_store')))
+ ->when(session('storefront_network'), function ($query) {
+ $query->whereHas('store.networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network')));
+ $query->where('is_available', 1);
+ $query->where('status', 'published');
+ })
+ ->with(['store.locations'])
+ ->first();
}
public function getCurrency(?string $fallbackCurrency = null): ?string
diff --git a/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php
index 482ced34..45d838c6 100644
--- a/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php
+++ b/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php
@@ -56,7 +56,9 @@ function categoryApiRequest(array $input = []): Request
$table->string('uuid')->nullable();
$table->string('public_id')->nullable();
$table->string('category_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
$table->boolean('is_available')->default(true);
+ $table->string('status')->nullable();
$table->timestamp('deleted_at')->nullable();
});
$schema->create('product_addon_categories', function ($table) {
@@ -107,10 +109,9 @@ function categoryApiRequest(array $input = []): Request
],
]);
$connection->table('products')->insert([
- 'uuid' => 'product_uuid',
- 'public_id' => 'product_public',
- 'category_uuid' => 'child_uuid',
- 'is_available' => true,
+ ['uuid' => 'product_uuid', 'public_id' => 'product_public', 'category_uuid' => 'child_uuid', 'store_uuid' => 'store_uuid', 'is_available' => true, 'status' => 'published'],
+ ['uuid' => 'draft_product_uuid', 'public_id' => 'product_draft', 'category_uuid' => 'child_uuid', 'store_uuid' => 'store_uuid', 'is_available' => true, 'status' => 'draft'],
+ ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'category_uuid' => 'child_uuid', 'store_uuid' => 'other_store', 'is_available' => true, 'status' => 'published'],
]);
session([
'storefront_store' => 'store_uuid',
@@ -256,7 +257,9 @@ function categoryApiRequest(array $input = []): Request
$table->string('uuid')->nullable();
$table->string('public_id')->nullable();
$table->string('category_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
$table->boolean('is_available')->default(true);
+ $table->string('status')->nullable();
$table->timestamp('deleted_at')->nullable();
});
$schema->create('product_addon_categories', function ($table) {
@@ -307,10 +310,9 @@ function categoryApiRequest(array $input = []): Request
],
]);
$connection->table('products')->insert([
- 'uuid' => 'product_uuid',
- 'public_id' => 'product_public',
- 'category_uuid' => 'parent_uuid',
- 'is_available' => true,
+ ['uuid' => 'product_uuid', 'public_id' => 'product_public', 'category_uuid' => 'parent_uuid', 'store_uuid' => 'store_uuid', 'is_available' => true, 'status' => 'published'],
+ ['uuid' => 'draft_product_uuid', 'public_id' => 'product_draft', 'category_uuid' => 'parent_uuid', 'store_uuid' => 'store_uuid', 'is_available' => true, 'status' => 'draft'],
+ ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'category_uuid' => 'parent_uuid', 'store_uuid' => 'other_store', 'is_available' => true, 'status' => 'published'],
]);
session([
'company' => 'company_uuid',
diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php
index 50954b11..8468256b 100644
--- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php
+++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php
@@ -4,6 +4,7 @@
use Fleetbase\FleetOps\Models\ServiceQuote;
use Fleetbase\Storefront\Http\Controllers\v1\CheckoutController;
use Fleetbase\Storefront\Http\Requests\CaptureOrderRequest;
+use Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest;
use Fleetbase\Storefront\Http\Requests\InitializeCheckoutRequest;
use Fleetbase\Storefront\Models\Cart;
use Fleetbase\Storefront\Models\Checkout;
@@ -110,6 +111,16 @@ public function vendorSafely(ServiceQuote $serviceQuote, Request $request): arra
class CheckoutAutomationControllerProbe extends CheckoutController
{
+ public static function checkoutCustomer(?string $customerId)
+ {
+ return parent::resolveCheckoutCustomer($customerId);
+ }
+
+ public function marketplaceCartValidation(Cart $cart)
+ {
+ return $this->validateMarketplaceCart($cart);
+ }
+
public function accept(Fleetbase\FleetOps\Models\Order $order): void
{
$this->autoAcceptOrder($order);
@@ -146,6 +157,213 @@ public function foodTruckOrderData(?Fleetbase\Storefront\Models\FoodTruck $foodT
}
}
+test('authenticated checkout identity cannot be replaced by a submitted customer id', function () {
+ createCheckoutBoundarySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('personal_access_tokens');
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type');
+ $table->string('tokenable_id');
+ $table->string('name');
+ $table->string('token', 64)->unique();
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+ $customerUuid = '1d182070-f74d-4cf6-92fb-ab35531d15c6';
+ $connection->table('contacts')->insert([
+ 'uuid' => $customerUuid,
+ 'public_id' => 'contact_authenticated',
+ 'company_uuid' => 'company_uuid',
+ 'type' => 'customer',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('personal_access_tokens')->insert([
+ 'tokenable_type' => Fleetbase\Models\User::class,
+ 'tokenable_id' => $customerUuid,
+ 'name' => $customerUuid,
+ 'token' => hash('sha256', 'authenticated-customer-secret'),
+ 'abilities' => '["*"]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('gateways')->insert([
+ 'uuid' => 'gateway_uuid',
+ 'code' => 'stripe',
+ 'owner_uuid' => 'store_owner_uuid',
+ 'type' => 'stripe',
+ 'config' => json_encode(['secret_key' => 'sk_test_contract']),
+ 'sandbox' => true,
+ ]);
+ $connection->table('carts')->insert([
+ 'uuid' => 'cart_uuid',
+ 'public_id' => 'cart_public',
+ 'company_uuid' => 'company_uuid',
+ 'unique_identifier' => 'authenticated-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => 'store_owner_uuid',
+ 'storefront_network' => null,
+ ]);
+ $boundRequest = Request::create('/checkout');
+ $boundRequest->headers->set('Customer-Token', 'authenticated-customer-secret');
+ app()->instance('request', $boundRequest);
+
+ $resolved = CheckoutAutomationControllerProbe::checkoutCustomer('customer_authenticated');
+ $resolvedContact = CheckoutAutomationControllerProbe::checkoutCustomer('contact_authenticated');
+ $fallback = CheckoutAutomationControllerProbe::checkoutCustomer(null);
+ $mismatch = CheckoutAutomationControllerProbe::checkoutCustomer('customer_other');
+ $controller = new CheckoutAutomationControllerProbe();
+ $before = $controller->beforeCheckout(InitializeCheckoutRequest::create('/checkout', 'POST', [
+ 'gateway' => 'stripe',
+ 'cart' => 'cart_public',
+ 'customer' => 'customer_other',
+ ]));
+ $setup = $controller->createStripeSetupIntentForCustomer(CreateStripeSetupIntentRequest::create('/checkout/stripe/setup', 'POST', [
+ 'customer' => 'customer_other',
+ ]));
+ $update = $controller->updateStripePaymentIntent(Request::create('/checkout/stripe/update', 'POST', [
+ 'cart' => 'cart_public',
+ 'customer' => 'customer_other',
+ ]));
+ app()->instance('request', Request::create('/'));
+
+ expect($resolved?->public_id)->toBe('contact_authenticated')
+ ->and($resolvedContact?->public_id)->toBe('contact_authenticated')
+ ->and($fallback?->public_id)->toBe('contact_authenticated')
+ ->and($mismatch->getStatusCode())->toBe(403)
+ ->and($before->getStatusCode())->toBe(403)
+ ->and($setup->getStatusCode())->toBe(403)
+ ->and($update->getStatusCode())->toBe(403)
+ ->and($update->getData(true))->toBe([
+ 'error' => 'Customer does not match the authenticated session.',
+ ]);
+});
+
+test('marketplace checkout validates membership availability locations cart mode and currency before payment', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['store_locations', 'products', 'network_stores', 'networks', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id');
+ $table->boolean('online')->default(true);
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->text('options')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid');
+ $table->string('store_uuid');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id');
+ $table->string('store_uuid');
+ $table->boolean('is_available')->default(true);
+ $table->string('status')->nullable();
+ $table->string('currency')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('store_locations', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id');
+ $table->string('store_uuid');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ ['uuid' => 'store_one_uuid', 'public_id' => 'store_one', 'online' => true],
+ ['uuid' => 'store_two_uuid', 'public_id' => 'store_two', 'online' => true],
+ ['uuid' => 'store_offline_uuid', 'public_id' => 'store_offline', 'online' => false],
+ ['uuid' => 'store_foreign_uuid', 'public_id' => 'store_foreign', 'online' => true],
+ ]);
+ $connection->table('networks')->insert(['uuid' => 'network_uuid', 'options' => json_encode(['multi_cart_enabled' => false])]);
+ $connection->table('network_stores')->insert([
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_one_uuid'],
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_two_uuid'],
+ ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_offline_uuid'],
+ ]);
+ $connection->table('products')->insert([
+ ['uuid' => 'product_one_uuid', 'public_id' => 'product_one', 'store_uuid' => 'store_one_uuid', 'is_available' => true, 'status' => 'published', 'currency' => 'USD'],
+ ['uuid' => 'product_two_uuid', 'public_id' => 'product_two', 'store_uuid' => 'store_two_uuid', 'is_available' => true, 'status' => 'published', 'currency' => 'USD'],
+ ['uuid' => 'product_eur_uuid', 'public_id' => 'product_eur', 'store_uuid' => 'store_two_uuid', 'is_available' => true, 'status' => 'published', 'currency' => 'EUR'],
+ ['uuid' => 'product_draft_uuid', 'public_id' => 'product_draft', 'store_uuid' => 'store_one_uuid', 'is_available' => true, 'status' => 'draft', 'currency' => 'USD'],
+ ]);
+ $connection->table('store_locations')->insert([
+ ['uuid' => 'location_one_uuid', 'public_id' => 'location_one', 'store_uuid' => 'store_one_uuid'],
+ ['uuid' => 'location_two_uuid', 'public_id' => 'location_two', 'store_uuid' => 'store_two_uuid'],
+ ['uuid' => 'location_offline_uuid', 'public_id' => 'location_offline', 'store_uuid' => 'store_offline_uuid'],
+ ]);
+
+ $cartFor = function (array $items): Cart {
+ $cart = new Cart();
+ $cart->forceFill(['items' => array_map(fn ($item) => (object) $item, $items), 'events' => []]);
+
+ return $cart;
+ };
+ $item = fn ($store, $product, $location) => ['store_id' => $store, 'product_id' => $product, 'store_location_id' => $location];
+ $controller = new CheckoutAutomationControllerProbe();
+
+ session(['storefront_network' => null]);
+ expect($controller->marketplaceCartValidation($cartFor([])))->toBeNull();
+
+ session(['storefront_network' => 'network_uuid']);
+ $empty = $controller->marketplaceCartValidation($cartFor([]));
+ $missingStore = $controller->marketplaceCartValidation($cartFor([$item(null, 'product_one', 'location_one')]));
+ $foreignStore = $controller->marketplaceCartValidation($cartFor([$item('store_foreign', 'product_one', 'location_one')]));
+ $offlineStore = $controller->marketplaceCartValidation($cartFor([$item('store_offline', 'product_one', 'location_offline')]));
+ $singleValid = $controller->marketplaceCartValidation($cartFor([$item('store_one', 'product_one', 'location_one')]));
+ $multiDisabled = $controller->marketplaceCartValidation($cartFor([
+ $item('store_one', 'product_one', 'location_one'),
+ $item('store_two', 'product_two', 'location_two'),
+ ]));
+ $draftProduct = $controller->marketplaceCartValidation($cartFor([$item('store_one', 'product_draft', 'location_one')]));
+ $wrongLocation = $controller->marketplaceCartValidation($cartFor([$item('store_one', 'product_one', 'location_two')]));
+
+ $connection->table('networks')->where('uuid', 'network_uuid')->update(['options' => json_encode(['multi_cart_enabled' => true])]);
+ $mixedCurrency = $controller->marketplaceCartValidation($cartFor([
+ $item('store_one', 'product_one', 'location_one'),
+ $item('store_two', 'product_eur', 'location_two'),
+ ]));
+ $multiValid = $controller->marketplaceCartValidation($cartFor([
+ $item('store_one', 'product_one', 'location_one'),
+ $item('store_two', 'product_two', 'location_two'),
+ ]));
+
+ expect($empty->getStatusCode())->toBe(422)
+ ->and($missingStore->getStatusCode())->toBe(422)
+ ->and($foreignStore->getStatusCode())->toBe(403)
+ ->and($offlineStore->getStatusCode())->toBe(422)
+ ->and($singleValid)->toBeNull()
+ ->and($multiDisabled->getData(true))->toBe(['error' => 'This marketplace only supports one store per cart.'])
+ ->and($draftProduct->getData(true))->toBe(['error' => 'A product in this cart is no longer available from its store.'])
+ ->and($wrongLocation->getData(true))->toBe(['error' => 'A store location in this cart is no longer valid.'])
+ ->and($mixedCurrency->getData(true))->toBe(['error' => 'Marketplace carts cannot combine different currencies.'])
+ ->and($multiValid)->toBeNull();
+});
+
class CheckoutIntegratedVendorProbe extends Model
{
public function api(): object
@@ -649,6 +867,35 @@ public function dispatch($event, $payload = [], $halt = false)
expect($response->getData(true))->toBe(['error' => 'No gateway configured!']);
});
+test('checkout initialization stops before gateway work when marketplace cart validation fails', function () {
+ createCheckoutBoundarySchema();
+ Model::getConnectionResolver()->connection('mysql')->table('carts')->insert([
+ 'uuid' => 'marketplace_cart_uuid',
+ 'public_id' => 'cart_marketplace',
+ 'company_uuid' => 'company_uuid',
+ 'unique_identifier' => 'marketplace-browser-cart',
+ 'currency' => 'USD',
+ 'items' => '[]',
+ 'events' => '[]',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ 'storefront_currency' => 'USD',
+ ]);
+
+ $response = (new CheckoutController())->beforeCheckout(
+ InitializeCheckoutRequest::create('/checkouts/before', 'POST', [
+ 'gateway' => 'missing_gateway',
+ 'cart' => 'marketplace-browser-cart',
+ ])
+ );
+
+ expect($response->getStatusCode())->toBe(422)
+ ->and($response->getData(true))->toBe(['error' => 'The cart is empty.']);
+});
+
test('checkout automation delegates accepted and pickup-dispatched orders to storefront workflows', function () {
createCheckoutBoundarySchema();
$schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
@@ -656,12 +903,14 @@ public function dispatch($event, $payload = [], $halt = false)
$schema->create('order_configs', function ($table) {
$table->increments('id');
$table->string('uuid')->nullable();
+ $table->text('flow')->nullable();
$table->text('activities')->nullable();
$table->timestamps();
$table->softDeletes();
});
Model::getConnectionResolver()->connection('mysql')->table('order_configs')->insert([
'uuid' => 'order_config_uuid',
+ 'flow' => '[]',
'activities' => '[]',
'created_at' => now(),
'updated_at' => now(),
@@ -842,6 +1091,7 @@ public function notify($notification): void
$connection->table('carts')->insert([
'uuid' => 'cart_uuid',
'public_id' => 'cart_abcdefgh',
+ 'company_uuid' => 'company_uuid',
'unique_identifier' => 'browser-cart',
'currency' => 'USD',
'items' => json_encode([
@@ -880,6 +1130,9 @@ public function notify($notification): void
expect($response->getStatusCode())->toBe(200)
->and($response->getData(true)['token'])->toBe($checkout->token)
+ // GET /checkouts/status needs the chkt_* public id as well as the token, and only
+ // the QPay path used to return it.
+ ->and($response->getData(true)['checkout'])->toBe($checkout->public_id)
->and($checkout->owner_uuid)->toBe('customer_uuid')
->and($checkout->cart_uuid)->toBe('cart_uuid')
->and($checkout->service_quote_uuid)->toBe('quote_uuid')
@@ -898,6 +1151,7 @@ public function notify($notification): void
$connection->table('carts')->insert([
'uuid' => 'cart_uuid',
'public_id' => 'cart_abcdefgh',
+ 'company_uuid' => 'company_uuid',
'unique_identifier' => 'browser-cart',
'currency' => 'USD',
'items' => '[]',
@@ -1401,6 +1655,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
->and($data['ephemeralKey'])->toBe('eph_secret')
->and($data['customerId'])->toBe('cus_checkout')
->and($data['token'])->toBe($checkout->token)
+ ->and($data['checkout'])->toBe($checkout->public_id)
->and($checkout->owner_uuid)->toBe('customer_uuid')
->and($checkout->amount)->toBe(2200)
->and($checkout->is_pickup)->toBeTrue()
@@ -1567,6 +1822,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
'items' => [
[
'id' => 'line_one',
+ 'product_id' => null,
'name' => 'Delivery box',
'quantity' => 2,
'price' => 1000,
@@ -1618,6 +1874,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
expect($data['invoice']['invoice_id'])->toBe('invoice_checkout')
->and($data['checkout'])->toBe($checkout->public_id)
->and($data['token'])->toBe($checkout->token)
+ ->and($data['checkout'])->toBe($checkout->public_id)
->and($checkout->amount)->toBe(2000)
->and($checkout->getOption('qpay_invoice_id'))->toBe('invoice_checkout')
->and($customer->getMeta('ebarimt_registration_no'))->toBe('1234567')
@@ -1659,7 +1916,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
$controller = new CheckoutController();
$setup = $controller->createStripeSetupIntentForCustomer(
- Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ CreateStripeSetupIntentRequest::create(
'/checkout/stripe-setup',
'POST',
['customer' => 'customer_missing']
@@ -1678,7 +1935,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
'config' => json_encode(['secret_key' => ' ']),
]);
$blankSetup = $controller->createStripeSetupIntentForCustomer(
- Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ CreateStripeSetupIntentRequest::create(
'/checkout/stripe-setup',
'POST',
['customer' => 'customer_missing']
@@ -1754,7 +2011,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
});
$controller = new CheckoutController();
$success = $controller->createStripeSetupIntentForCustomer(
- Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ CreateStripeSetupIntentRequest::create(
'/checkout/stripe-setup',
'POST',
['customer' => 'customer_abcdefgh']
@@ -1769,7 +2026,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
->and($successData['defaultPaymentMethod']['last4'])->toBe('4242');
$connection->table('contacts')->where('uuid', 'customer_uuid')->update(['meta' => '{}']);
$createdCustomerSetup = $controller->createStripeSetupIntentForCustomer(
- Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ CreateStripeSetupIntentRequest::create(
'/checkout/stripe-setup',
'POST',
['customer' => 'customer_abcdefgh']
@@ -1800,7 +2057,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
}
});
$savedMethodFailure = $controller->createStripeSetupIntentForCustomer(
- Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ CreateStripeSetupIntentRequest::create(
'/checkout/stripe-setup',
'POST',
['customer' => 'customer_abcdefgh']
@@ -1824,7 +2081,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
}
});
$failure = $controller->createStripeSetupIntentForCustomer(
- Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ CreateStripeSetupIntentRequest::create(
'/checkout/stripe-setup',
'POST',
['customer' => 'customer_abcdefgh']
@@ -1908,6 +2165,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
$connection->table('carts')->insert([
'uuid' => 'cart_uuid',
'public_id' => 'cart_abcdefgh',
+ 'company_uuid' => 'company_uuid',
'unique_identifier' => 'browser-cart',
'currency' => 'USD',
'items' => json_encode([
@@ -2003,6 +2261,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
->and($updatedData['ephemeralKey'])->toBe('eph_secret')
->and($updatedData['customerId'])->toBe('cus_checkout')
->and($updatedData['token'])->toBe($checkout->token)
+ ->and($updatedData['checkout'])->toBe($checkout->public_id)
->and($checkout->amount)->toBe(1650)
->and($checkout->is_pickup)->toBeTrue()
->and($meta['stripe_payment_method_id'])->toBe('pm_new');
@@ -2226,7 +2485,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
$client->mode = $mode;
return $controller->createStripeSetupIntentForCustomer(
- Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create(
+ CreateStripeSetupIntentRequest::create(
'/checkout/stripe-setup',
'POST',
['customer' => 'customer_abcdefgh']
diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php
index 5211b889..c9cba789 100644
--- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php
+++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php
@@ -36,6 +36,11 @@ public function google(string $token, string $clientId): ?array
{
return $this->verifyGoogleIdentity($token, $clientId);
}
+
+ public static function reviewAccountBypass(?string $identity, mixed $code): bool
+ {
+ return parent::isReviewAccountBypass($identity, $code);
+ }
}
class PhoneConflictCustomerControllerStub extends CustomerController
@@ -456,6 +461,15 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
],
]);
bindCustomerNotificationDispatcher();
+ $sentry = new class {
+ public array $exceptions = [];
+
+ public function captureException(Throwable $exception): void
+ {
+ $this->exceptions[] = $exception;
+ }
+ };
+ app()->instance('sentry', $sentry);
$phoneConflictController = new PhoneConflictCustomerControllerStub();
$phoneConflictController->existingPhoneUser = new Fleetbase\Models\User(['uuid' => 'other_user_uuid']);
$existingPhoneConflict = $phoneConflictController->requestPhoneVerification(Request::create('/customer/phone', 'POST', [
@@ -486,7 +500,8 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
$connection->statement(
"CREATE TRIGGER fail_phone_verification_insert BEFORE INSERT ON verification_codes BEGIN SELECT RAISE(ABORT, 'verification insert failed'); END"
);
- $phoneDeliveryFailure = $controller->requestPhoneVerification(Request::create('/customer/phone', 'POST', [
+ $closureChannelFailure = $controller->startAccountClosure(Request::create('/customer/closure', 'POST'));
+ $phoneDeliveryFailure = $controller->requestPhoneVerification(Request::create('/customer/phone', 'POST', [
'phone' => '+97699112234',
]));
$connection->statement('DROP TRIGGER fail_phone_verification_insert');
@@ -508,6 +523,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
'code' => $closureCode,
]));
app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class);
+ app()->forgetInstance('sentry');
expect($device->getData(true))->toHaveKey('device')
->and($connection->table('user_devices')->where('token', 'device-token')->value('user_uuid'))->toBe('user_uuid')
->and($orders->resource)->toHaveCount(1)
@@ -535,6 +551,10 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
'error' => 'Customer account must have a valid email or phone number linked.',
])->and($emailClosureStarted->getData(true))->toBe(['status' => 'OK'])
->and($closureDeliveryFailure->getData(true))->toHaveKey('error')
+ ->and($closureChannelFailure->getData(true))->toBe([
+ 'error' => 'Unable to send account closure verification code.',
+ ])
+ ->and($sentry->exceptions)->toHaveCount(4)
->and($existingPhoneConflict->getData(true))->toBe([
'error' => 'This phone number is already associated with another account.',
])
@@ -578,7 +598,13 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
->and($apple->getData(true))->toBe(['error' => 'Missing required Apple authentication parameters.'])
->and($google->getStatusCode())->toBe(400)
->and($google->getData(true))->toBe(['error' => 'Missing required Google authentication parameters.'])
- ->and($invalidApple->getStatusCode())->toBe(500)
+ // A malformed identityToken is client input. The JWT parser throws rather than
+ // returning false, and that used to fall through to the blanket catch and come
+ // back as 500 {"error":"The JWT string must have two dots"} — leaking the parser's
+ // own message. $invalidGoogle below answers 400 for equally malformed input, so
+ // Apple was inconsistent with Google in this very test. This assertion pinned it.
+ ->and($invalidApple->getStatusCode())->toBe(400)
+ ->and($invalidApple->getData(true))->toBe(['error' => 'Apple ID authentication is not valid.'])
->and($rejectedApple->getData(true))->toBe(['error' => 'Apple ID authentication is not valid.'])
->and($invalidGoogle->getStatusCode())->toBe(400)
->and($invalidGoogle->getData(true))->toBe(['error' => 'Google Sign-In authentication is not valid.']);
@@ -597,6 +623,23 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
->and($probe->google('malformed-token', 'client-id'))->toBeNull();
});
+test('customer verification bypass is restricted to configured review identities and constant-time codes', function () {
+ config([
+ 'storefront.storefront_app.bypass_verification_code' => null,
+ 'storefront.storefront_app.review_accounts' => [],
+ ]);
+ expect(CustomerIdentityProbe::reviewAccountBypass('reviewer@example.test', '246810'))->toBeFalse();
+
+ config([
+ 'storefront.storefront_app.bypass_verification_code' => '246810',
+ 'storefront.storefront_app.review_accounts' => [' Reviewer@Example.Test '],
+ ]);
+
+ expect(CustomerIdentityProbe::reviewAccountBypass('other@example.test', '246810'))->toBeFalse()
+ ->and(CustomerIdentityProbe::reviewAccountBypass('reviewer@example.test', 'wrong'))->toBeFalse()
+ ->and(CustomerIdentityProbe::reviewAccountBypass('reviewer@example.test', '246810'))->toBeTrue();
+});
+
test('facebook login links an existing customer identity and issues a local access token', function () {
$schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
foreach (['personal_access_tokens', 'contacts', 'users'] as $table) {
@@ -722,6 +765,11 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
}
$linkedUser = $connection->table('users')->where('uuid', 'user_uuid')->first();
$schema->drop('users');
+ $appleFailure = $socialController->loginWithApple(Request::create('/customer/apple', 'POST', [
+ 'identityToken' => 'apple-failure-token',
+ 'authorizationCode' => 'authorization-code',
+ 'appleUserId' => 'apple_failure',
+ ]));
$facebookFailure = $socialController->loginWithFacebook(Request::create('/customer/facebook', 'POST', [
'facebookUserId' => 'facebook_failure',
]));
@@ -741,6 +789,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode
->and($newApple)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
->and($newFacebook)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
->and($newGoogle)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($appleFailure->getData(true))->toHaveKey('error')
->and($facebookFailure->getData(true))->toHaveKey('error')
->and($googleFailure->getData(true))->toHaveKey('error')
->and($connection->table('personal_access_tokens')->count())->toBe(6);
@@ -852,6 +901,52 @@ public function message(string $to, string $message, array $media = [], array $p
]);
});
+test('customer creation falls back to the payload email when no identity is sent', function () {
+ // A body of {name, email, code} used to leave $identity null, static::phone() turned it
+ // into the literal '+', and the lookup on meta->identity could never match — so a
+ // correctly issued code was rejected.
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ $schema->dropIfExists('verification_codes');
+ $schema->create('verification_codes', function ($table) {
+ $table->increments('id');
+ $table->string('code')->nullable();
+ $table->string('for')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('verification_codes')->insert([
+ 'code' => '123456',
+ 'for' => 'storefront_create_customer',
+ 'meta' => json_encode(['identity' => 'buyer@example.test']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session(['storefront_key' => null]);
+
+ $matched = (new CustomerController())->create(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'code' => '123456',
+ 'email' => 'buyer@example.test',
+ 'name' => 'Buyer',
+ ])
+ );
+
+ $missing = (new CustomerController())->create(
+ CreateCustomerRequest::create('/customer', 'POST', [
+ 'code' => '123456',
+ 'name' => 'Buyer',
+ ])
+ );
+
+ // The fallback got past the code check — whatever it fails on next, it is no longer
+ // "Invalid verification code provided!".
+ expect(data_get($matched->getData(true), 'error'))->not->toBe('Invalid verification code provided!')
+ ->and($missing->getData(true))->toBe(['error' => 'An identity is required to create a customer.']);
+});
+
test('customer creation persists a verified storefront identity and issues an access token', function () {
createCustomerVerificationDeliverySchema();
$connection = Model::getConnectionResolver()->connection('mysql');
@@ -1225,13 +1320,292 @@ public function clear(): void
$response = (new CustomerController())->loginWithPhone();
app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class);
- expect($response->getData(true))->toBe(['status' => 'OK'])
+ // No phone in the request at all. static::phone() returns null rather than the bare
+ // '+' it used to, and `where('phone', null)` compiles to `phone IS NULL` — which would
+ // hand back an arbitrary phone-less user and send them a login code.
+ bindUnauthenticatedCustomerRequest([]);
+ $withoutPhone = (new CustomerController())->loginWithPhone();
+
+ expect($response->getData(true))->toBe(['status' => 'OK', 'method' => 'sms'])
+ ->and($withoutPhone->getData(true))->toBe(['error' => 'No customer with this phone # found.'])
->and($connection->table('verification_codes')->where([
'subject_uuid' => 'user_uuid',
'for' => 'storefront_login',
])->count())->toBe(1);
});
+test('customer phone login falls back to email when SMS is not configured', function () {
+ // The Twilio SDK THROWS when the store has no credentials — ConfigurationException,
+ // "Credentials are required to create a Client" — and that propagated as a 500 with an
+ // HTML stack trace. A store that has simply not set up SMS is not a server error, and
+ // it can still reach a customer who has an email address.
+ createCustomerVerificationDeliverySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('users')->insert([
+ 'uuid' => 'user_uuid',
+ 'name' => 'Ada Buyer',
+ 'phone' => '+97699112233',
+ 'email' => 'ada@example.test',
+ 'type' => 'customer',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ bindUnauthenticatedCustomerRequest(['phone' => '97699112233']);
+ bindCustomerNotificationDispatcher();
+
+ // Replace the working twilio fake with one that fails the way an unconfigured
+ // install does.
+ app()->instance('twilio', new class {
+ public function message(string $to, string $message, array $media = [], array $params = []): object
+ {
+ throw new RuntimeException('Credentials are required to create a Client');
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio');
+
+ $response = (new CustomerController())->loginWithPhone();
+
+ // Restore the container before asserting. The whole file runs in ONE process, so
+ // anything left bound here leaks into every later test — including the working
+ // `mail.manager` that bindCustomerNotificationDispatcher() installs, which would make
+ // a later test's deliberately-failing email delivery succeed instead.
+ app()->forgetInstance('twilio');
+ app()->forgetInstance('mail.manager');
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio');
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('mail.manager');
+ app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class);
+
+ // Two rows, not one: generateSmsVerificationFor persists the code BEFORE attempting
+ // delivery, so the failed SMS attempt leaves its row behind and the email fallback
+ // adds another. Both are valid for this subject and purpose, so either verifies —
+ // FleetOps' driver login behaves identically.
+ expect($response->getData(true))->toBe(['status' => 'OK', 'method' => 'email'])
+ ->and($connection->table('verification_codes')->where([
+ 'subject_uuid' => 'user_uuid',
+ 'for' => 'storefront_login',
+ ])->count())->toBe(2);
+});
+
+test('customer phone login reports both delivery failures without leaking provider exceptions', function () {
+ createCustomerVerificationDeliverySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('users')->insert([
+ 'uuid' => 'user_uuid',
+ 'name' => 'Ada Buyer',
+ 'phone' => '+97699112233',
+ 'email' => 'ada@example.test',
+ 'type' => 'customer',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => 'store_key',
+ ]);
+ bindUnauthenticatedCustomerRequest(['phone' => '97699112233']);
+ $sentry = new class {
+ public array $exceptions = [];
+
+ public function captureException(Throwable $exception): void
+ {
+ $this->exceptions[] = $exception;
+ }
+ };
+ app()->instance('sentry', $sentry);
+ app()->instance('twilio', new class {
+ public function message(string $to, string $message, array $media = [], array $params = []): object
+ {
+ throw new RuntimeException('SMS provider unavailable');
+ }
+ });
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio');
+ app()->instance(
+ Illuminate\Contracts\Notifications\Dispatcher::class,
+ new class implements Illuminate\Contracts\Notifications\Dispatcher {
+ public function send($notifiables, $notification)
+ {
+ throw new RuntimeException('Email provider unavailable');
+ }
+
+ public function sendNow($notifiables, $notification)
+ {
+ throw new RuntimeException('Email provider unavailable');
+ }
+ }
+ );
+
+ $response = (new CustomerController())->loginWithPhone();
+
+ app()->forgetInstance('sentry');
+ app()->forgetInstance('twilio');
+ app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class);
+ Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio');
+
+ expect($response->getData(true))->toBe(['error' => 'Unable to send verification code.'])
+ ->and($sentry->exceptions)->toHaveCount(2);
+});
+
+test('phone verification honours the review account bypass at both ends', function () {
+ // The bypass exists so App Store review can complete flows that would otherwise need a
+ // live SMS provider. It was applied to verifyCode and confirmAccountClosure but never
+ // to phone verification, so that flow still required Twilio.
+ config([
+ 'storefront.storefront_app.bypass_verification_code' => '000000',
+ 'storefront.storefront_app.review_accounts' => ['+97699112233'],
+ ]);
+
+ createCustomerVerificationDeliverySchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ // The delivery schema covers stores/companies/users/verification_codes; the
+ // authenticated-customer path also needs a contact and a Sanctum token.
+ foreach (['contacts', 'personal_access_tokens'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('contacts', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->string('name')->nullable();
+ $table->string('phone')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ $table->timestamps();
+ });
+ // verifyPhoneNumber stamps phone_verified_at, which the shared users schema omits.
+ $schema->table('users', function ($table) {
+ $table->timestamp('phone_verified_at')->nullable();
+ });
+ $schema->create('personal_access_tokens', function ($table) {
+ $table->increments('id');
+ $table->string('tokenable_type')->nullable();
+ $table->string('tokenable_id')->nullable();
+ $table->string('name')->nullable();
+ $table->string('token')->nullable();
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamps();
+ });
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_key',
+ 'name' => 'Corner Store',
+ ]);
+ $connection->table('users')->insert([
+ 'uuid' => 'user_uuid',
+ 'name' => 'Ada Buyer',
+ 'email' => 'ada@example.test',
+ 'type' => 'customer',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ // A real uuid: Storefront::getCustomerFromToken() only resolves the contact from the
+ // token's name when Str::isUuid() passes, and silently falls through otherwise.
+ $connection->table('contacts')->insert([
+ 'uuid' => '8f14e45f-ceea-467a-9f4d-2b5c1e0a77aa',
+ 'public_id' => 'customer_public',
+ 'company_uuid' => 'company_uuid',
+ 'user_uuid' => 'user_uuid',
+ 'type' => 'customer',
+ 'name' => 'Ada Buyer',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $connection->table('personal_access_tokens')->insert([
+ 'name' => '8f14e45f-ceea-467a-9f4d-2b5c1e0a77aa',
+ 'token' => hash('sha256', 'phone-verify-secret'),
+ 'abilities' => '["*"]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ session(['company' => 'company_uuid', 'storefront_key' => 'store_key']);
+
+ $authenticate = function (array $input) {
+ $request = bindUnauthenticatedCustomerRequest($input);
+ $request->headers->set('Customer-Token', 'phone-verify-secret');
+ app()->instance('request', $request);
+
+ return $request;
+ };
+
+ // No SMS provider is bound at all — the send must not need one for a review account.
+ $sent = (new CustomerController())->requestPhoneVerification(
+ $authenticate(['phone' => '97699112233'])
+ );
+
+ expect($sent->getData(true))->toBe(['status' => 'ok', 'method' => 'bypass'])
+ // and nothing was queued for delivery
+ ->and($connection->table('verification_codes')->count())->toBe(0);
+
+ $verified = (new CustomerController())->verifyPhoneNumber(
+ $authenticate(['phone' => '97699112233', 'code' => '000000'])
+ );
+
+ expect($verified)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
+ ->and($connection->table('users')->where('uuid', 'user_uuid')->value('phone'))->toBe('+97699112233')
+ ->and($connection->table('users')->where('uuid', 'user_uuid')->value('phone_verified_at'))->not->toBeNull();
+
+ // A code that is not the bypass, for the same account, is still rejected.
+ $rejected = (new CustomerController())->verifyPhoneNumber(
+ $authenticate(['phone' => '97699112233', 'code' => '111111'])
+ );
+ expect($rejected->getData(true))->toBe(['error' => 'Invalid verification code!']);
+
+ // No phone to verify. static::phone() returns null rather than the bare '+' it used
+ // to, so this has to be caught before findExistingUserByPhone(string $phone) is
+ // reached — and long before the SMS provider is.
+ $noPhone = (new CustomerController())->requestPhoneVerification($authenticate([]));
+ expect($noPhone->getData(true))->toBe(['error' => 'A phone number is required to request verification.']);
+
+ // A verification row that carries no meta.phone. requestPhoneVerification always
+ // writes one, but anything else that files a storefront_verify_phone code may not,
+ // and the subscript used to be unguarded — a 500 where a 400 belongs.
+ $connection->table('verification_codes')->insert([
+ 'uuid' => 'verification_without_phone',
+ 'subject_uuid' => 'user_uuid',
+ 'subject_type' => Fleetbase\Models\User::class,
+ 'code' => '222222',
+ 'for' => 'storefront_verify_phone',
+ 'meta' => json_encode([]),
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $noMetaPhone = (new CustomerController())->verifyPhoneNumber(
+ $authenticate(['phone' => '97699112233', 'code' => '222222'])
+ );
+ expect($noMetaPhone->getData(true))->toBe(['error' => 'Verification code is not associated with a phone number.']);
+
+ config([
+ 'storefront.storefront_app.bypass_verification_code' => null,
+ 'storefront.storefront_app.review_accounts' => [],
+ ]);
+});
+
test('customer password login reuses the storefront contact and issues an access token', function () {
$connection = Model::getConnectionResolver()->connection('mysql');
$schema = $connection->getSchemaBuilder();
@@ -1357,10 +1731,22 @@ public function clear(): void
'code' => '123456',
]));
+ // No identity at all. The lookup below the guard is `phone = $identity OR email =
+ // $identity`, which with null compiles to `phone IS NULL OR email IS NULL` and picks an
+ // arbitrary user to test the code against.
+ //
+ // The request has to be BOUND, not just passed: static::phone() falls back to
+ // request()->input('phone'), so an earlier bound request carrying a phone would supply
+ // an identity this call never sent. Last in the test so the rebind affects nothing else.
+ $withoutIdentityRequest = Request::create('/customer/code', 'POST', ['code' => '123456']);
+ app()->instance('request', $withoutIdentityRequest);
+ $withoutIdentity = (new CustomerController())->verifyCode($withoutIdentityRequest);
+
expect($resource)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
->and($resource->resource->uuid)->toBe('contact_uuid')
->and($resource->resource->token)->not->toBeEmpty()
->and($invalidCode->getData(true))->toBe(['error' => 'Invalid verification code!'])
+ ->and($withoutIdentity->getData(true))->toBe(['error' => 'Unable to verify code.'])
->and($verified)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class)
->and($verified->resource->token)->not->toBeEmpty()
->and($tokenCount)->toBe(2)
@@ -1521,6 +1907,15 @@ public function clear(): void
expect(CustomerController::phone())->toBe('+15551234567');
});
+test('customer phone normalization returns null when there is nothing to format', function () {
+ // It used to return a bare '+', which was written into contacts.phone and users.phone
+ // for every customer created without one, and used as a lookup key that never matched.
+ bindUnauthenticatedCustomerRequest();
+
+ expect(CustomerController::phone())->toBeNull()
+ ->and(CustomerController::phone(''))->toBeNull();
+});
+
test('customer code verification rejects identities without a user account', function () {
createCustomerControllerUsersSchema();
bindUnauthenticatedCustomerRequest();
diff --git a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php
index 52dc71b4..60591b7d 100644
--- a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php
+++ b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php
@@ -23,6 +23,7 @@ function createNetworkApiControllerSchema(): void
$table->text('description')->nullable();
$table->string('logo_uuid')->nullable();
$table->string('backdrop_uuid')->nullable();
+ $table->boolean('online')->default(true);
$table->timestamp('created_at')->nullable();
$table->timestamp('updated_at')->nullable();
$table->timestamp('deleted_at')->nullable();
@@ -58,6 +59,8 @@ function createNetworkApiControllerSchema(): void
$table->increments('id');
$table->string('uuid')->nullable();
$table->string('public_id')->nullable();
+ $table->string('owner_uuid')->nullable();
+ $table->string('for')->nullable();
$table->timestamp('deleted_at')->nullable();
});
$schema->create('files', function ($table) {
@@ -113,8 +116,10 @@ function createNetworkApiControllerSchema(): void
createNetworkApiControllerSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
$connection->table('categories')->insert([
- 'uuid' => 'category_uuid',
- 'public_id' => 'category_abcdefgh',
+ 'uuid' => 'category_uuid',
+ 'public_id' => 'category_abcdefgh',
+ 'owner_uuid' => 'network_uuid',
+ 'for' => 'storefront_network',
]);
$connection->table('networks')->insert(['uuid' => 'network_uuid']);
$connection->table('stores')->insert([
@@ -201,6 +206,7 @@ function createNetworkApiControllerSchema(): void
$request = Request::create('/network/stores', 'GET', [
'category' => 'category_abcdefgh',
'tagged' => 'food,local',
+ 'online' => true,
'ids' => 'store_abcdefgh,store_excluded',
'exclude' => 'store_excluded',
'limit' => 10,
@@ -248,8 +254,8 @@ function createNetworkApiControllerSchema(): void
['network_uuid' => 'network_uuid', 'store_uuid' => 'store_quiet_uuid'],
]);
$connection->table('checkouts')->insert([
- ['uuid' => 'checkout_one', 'store_uuid' => 'store_popular_uuid', 'created_at' => now(), 'updated_at' => now()],
- ['uuid' => 'checkout_two', 'store_uuid' => 'store_popular_uuid', 'created_at' => now(), 'updated_at' => now()],
+ ['uuid' => 'checkout_one', 'store_uuid' => 'store_popular_uuid', 'created_at' => now()->subDays(3), 'updated_at' => now()->subDays(3)],
+ ['uuid' => 'checkout_two', 'store_uuid' => 'store_popular_uuid', 'created_at' => now()->subDays(2), 'updated_at' => now()->subDays(2)],
['uuid' => 'checkout_three', 'store_uuid' => 'store_quiet_uuid', 'created_at' => now(), 'updated_at' => now()],
]);
session([
@@ -269,7 +275,82 @@ function createNetworkApiControllerSchema(): void
'store_popular_uuid',
'store_quiet_uuid',
])->and($resource->resource->first()->checkouts_count)->toBe(2)
- ->and($trending->resource->first()->uuid)->toBe('store_popular_uuid');
+ ->and($trending->resource->first()->uuid)->toBe('store_quiet_uuid')
+ ->and($trending->resource->first()->recent_checkouts_count)->toBe(1);
+});
+
+test('network stores endpoint searches member stores and includes cross-company invitees', function () {
+ createNetworkApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'member_uuid',
+ 'public_id' => 'store_member',
+ 'company_uuid' => 'invited_company',
+ 'name' => 'Moonlight Bakery',
+ 'description' => 'Fresh sourdough',
+ ],
+ [
+ 'uuid' => 'nonmember_uuid',
+ 'public_id' => 'store_nonmember',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Moonlight Market',
+ 'description' => null,
+ ],
+ ]);
+ $connection->table('store_locations')->insert([
+ ['uuid' => 'member_location', 'store_uuid' => 'member_uuid'],
+ ['uuid' => 'nonmember_location', 'store_uuid' => 'nonmember_uuid'],
+ ]);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'member_uuid',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+
+ $resource = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [
+ 'query' => 'sourdough',
+ ]));
+
+ expect($resource->resource)->toHaveCount(1)
+ ->and($resource->resource->first()->uuid)->toBe('member_uuid');
+});
+
+test('network stores endpoint returns no stores for an unknown or foreign category', function () {
+ createNetworkApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('categories')->insert([
+ 'uuid' => 'foreign_category_uuid',
+ 'public_id' => 'category_foreign',
+ 'owner_uuid' => 'other_network_uuid',
+ 'for' => 'storefront_network',
+ ]);
+ $connection->table('stores')->insert([
+ 'uuid' => 'member_uuid',
+ 'public_id' => 'store_member',
+ 'company_uuid' => 'company_uuid',
+ 'name' => 'Member store',
+ ]);
+ $connection->table('store_locations')->insert(['uuid' => 'member_location', 'store_uuid' => 'member_uuid']);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'member_uuid',
+ 'category_uuid' => 'foreign_category_uuid',
+ ]);
+ session(['storefront_network' => 'network_uuid', 'storefront_store' => null]);
+ $controller = new NetworkController();
+
+ $unknown = $controller->stores(Request::create('/network/stores', 'GET', ['category' => 'category_missing']));
+ $foreign = $controller->stores(Request::create('/network/stores', 'GET', ['category' => 'category_foreign']));
+
+ expect($unknown->resource)->toBeEmpty()
+ ->and($foreign->resource)->toBeEmpty();
});
test('network stores endpoint honors rating and age sort contracts', function () {
@@ -382,13 +463,25 @@ function createNetworkApiControllerSchema(): void
'sort' => 'nearest',
'location' => ['latitude' => 47.9184, 'longitude' => 106.9176],
]));
+ $limited = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [
+ 'sort' => 'nearest',
+ 'location' => ['latitude' => 47.9184, 'longitude' => 106.9176],
+ 'limit' => 1,
+ 'offset' => 1,
+ ]));
+ $withinDistance = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [
+ 'sort' => 'newest',
+ 'location' => ['latitude' => 47.9184, 'longitude' => 106.9176],
+ 'maximum_distance' => 1000,
+ ]));
expect($resource->resource->pluck('uuid')->all())->toBe([
'store_near_uuid',
'store_far_uuid',
])->and($resource->resource->first()->locations->first()->distance)->toBeLessThan(
$resource->resource->last()->locations->first()->distance
- );
+ )->and($limited->resource->pluck('uuid')->all())->toBe(['store_far_uuid'])
+ ->and($withinDistance->resource->pluck('uuid')->all())->toBe(['store_near_uuid']);
});
test('network tags endpoint returns unique tags across assigned stores', function () {
@@ -488,3 +581,35 @@ function createNetworkApiControllerSchema(): void
->and($offsetResource->resource)->toHaveCount(1)
->and($offsetResource->resource->first()->uuid)->toBe('location_other_uuid');
});
+
+test('store locations and tags preserve store context while rejecting missing contexts', function () {
+ createNetworkApiControllerSchema();
+ config(['database.connections.mysql.database' => 'main']);
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('stores')->insert([
+ ['uuid' => 'store_uuid', 'tags' => json_encode(['local', 'pickup'])],
+ ['uuid' => 'other_store_uuid', 'tags' => json_encode(['foreign'])],
+ ]);
+ $connection->table('places')->insert([
+ ['uuid' => 'store_place_uuid'],
+ ['uuid' => 'other_place_uuid'],
+ ]);
+ $connection->table('store_locations')->insert([
+ ['uuid' => 'store_location_uuid', 'public_id' => 'location_store', 'store_uuid' => 'store_uuid', 'place_uuid' => 'store_place_uuid'],
+ ['uuid' => 'other_location_uuid', 'public_id' => 'location_other', 'store_uuid' => 'other_store_uuid', 'place_uuid' => 'other_place_uuid'],
+ ]);
+ $controller = new NetworkController();
+ session(['storefront_store' => 'store_uuid', 'storefront_network' => null]);
+
+ $storeLocations = $controller->storeLocations(Request::create('/network/store-locations'));
+ $storeTags = $controller->tags(Request::create('/network/tags'));
+
+ session(['storefront_store' => null, 'storefront_network' => null]);
+ $missingLocations = $controller->storeLocations(Request::create('/network/store-locations'));
+ $missingTags = $controller->tags(Request::create('/network/tags'));
+
+ expect($storeLocations->resource->pluck('uuid')->all())->toBe(['store_location_uuid'])
+ ->and($storeTags->getData(true))->toBe(['local', 'pickup'])
+ ->and($missingLocations->getStatusCode())->toBe(400)
+ ->and($missingTags->getStatusCode())->toBe(400);
+});
diff --git a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php
index 01200d36..53b61b57 100644
--- a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php
+++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php
@@ -179,6 +179,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'store_uuid' => 'store_uuid',
'name' => 'Available',
'is_available' => true,
+ 'status' => 'published',
],
[
'uuid' => 'unavailable_uuid',
@@ -187,6 +188,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'store_uuid' => 'store_uuid',
'name' => 'Unavailable',
'is_available' => false,
+ 'status' => 'published',
],
[
'uuid' => 'other_store_uuid',
@@ -195,6 +197,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'store_uuid' => 'other_store',
'name' => 'Other store',
'is_available' => true,
+ 'status' => 'published',
],
]);
session([
@@ -203,17 +206,20 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'storefront_network' => null,
]);
- $resource = (new ProductController())->query(productApiRequest(
+ $controller = new ProductController();
+ $resource = $controller->query(productApiRequest(
'/products?store=store_uuid',
'GET',
['store' => 'store_uuid']
));
+ $ownedUnavailable = $controller->find('product_unavailable');
expect($resource->resource)->toHaveCount(1)
->and($resource->resource->first()->uuid)->toBe('available_uuid')
->and($resource->resource->first()->relationLoaded('addonCategories'))->toBeTrue()
->and($resource->resource->first()->relationLoaded('variants'))->toBeTrue()
- ->and($resource->resource->first()->relationLoaded('files'))->toBeTrue();
+ ->and($resource->resource->first()->relationLoaded('files'))->toBeTrue()
+ ->and($ownedUnavailable->resource->uuid)->toBe('unavailable_uuid');
});
test('product query returns available products assigned through the active network', function () {
@@ -240,6 +246,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'store_uuid' => 'network_store_uuid',
'name' => 'Network product',
'is_available' => true,
+ 'status' => 'published',
],
[
'uuid' => 'outside_product_uuid',
@@ -247,6 +254,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'store_uuid' => 'outside_store_uuid',
'name' => 'Outside product',
'is_available' => true,
+ 'status' => 'published',
],
]);
session([
@@ -297,6 +305,32 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
->and($product->youtube_urls)->toBe(['https://example.test/demo']);
});
+test('product creation defaults status to published so the product is actually readable', function () {
+ // The column is nullable with no default. An API-created product used to land as NULL
+ // and was then invisible to every `where('status', 'published')` read path — including
+ // CheckoutController's cart validation, which dropped it at capture time.
+ createProductApiControllerSchema();
+ session([
+ 'company' => 'company_uuid',
+ 'user' => 'user_uuid',
+ 'storefront_store' => 'store_uuid',
+ ]);
+
+ $defaulted = (new ProductController())->create(CreateProductRequest::create('/products', 'POST', [
+ 'name' => 'No status supplied',
+ 'price' => '1000',
+ ]))->resource;
+
+ $explicit = (new ProductController())->create(CreateProductRequest::create('/products', 'POST', [
+ 'name' => 'Draft on purpose',
+ 'price' => '1000',
+ 'status' => 'draft',
+ ]))->resource;
+
+ expect($defaulted->status)->toBe('published')
+ ->and($explicit->status)->toBe('draft');
+});
+
test('product creation persists category addons variants and option contracts', function () {
createProductApiControllerSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
@@ -354,7 +388,9 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
],
]);
- $product = (new ProductController())->create($request)->resource;
+ Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app()));
+ $product = (new ProductController())->create($request)->resource;
+ Model::unsetEventDispatcher();
$category = $connection->table('categories')
->where('for', 'storefront_product')
->where('name', 'Meals')
@@ -443,6 +479,83 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
->and($updated->category_uuid)->toBe($replacement->uuid);
});
+test('public product query and lookup stay inside the active network membership', function () {
+ createProductApiControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_abcdefgh',
+ 'name' => 'Marketplace',
+ ]);
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'member_store_uuid',
+ 'public_id' => 'store_member',
+ 'name' => 'Member store',
+ ],
+ [
+ 'uuid' => 'foreign_store_uuid',
+ 'public_id' => 'store_foreign',
+ 'name' => 'Foreign store',
+ ],
+ ]);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'member_store_uuid',
+ ]);
+ $connection->table('products')->insert([
+ [
+ 'uuid' => 'member_product_uuid',
+ 'public_id' => 'product_member',
+ 'store_uuid' => 'member_store_uuid',
+ 'name' => 'Member product',
+ 'is_available' => 1,
+ 'status' => 'published',
+ 'meta' => '{}',
+ ],
+ [
+ 'uuid' => 'foreign_product_uuid',
+ 'public_id' => 'product_foreign',
+ 'store_uuid' => 'foreign_store_uuid',
+ 'name' => 'Foreign product',
+ 'is_available' => 1,
+ 'status' => 'published',
+ 'meta' => '{}',
+ ],
+ [
+ 'uuid' => 'draft_product_uuid',
+ 'public_id' => 'product_draft',
+ 'store_uuid' => 'member_store_uuid',
+ 'name' => 'Draft product',
+ 'is_available' => 1,
+ 'status' => 'draft',
+ 'meta' => '{}',
+ ],
+ ]);
+ session([
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $controller = new ProductController();
+
+ $memberQuery = $controller->query(productApiRequest('/products', 'GET', [
+ 'store' => 'store_member',
+ 'with_store' => true,
+ ]));
+ $foreignQuery = $controller->query(productApiRequest('/products', 'GET', ['store' => 'store_foreign']));
+ $member = $controller->find('product_member');
+ $foreign = $controller->find('product_foreign');
+ $draft = $controller->find('product_draft');
+
+ expect($memberQuery->resource)->toHaveCount(1)
+ ->and($memberQuery->resource->pluck('uuid')->all())->toContain('member_product_uuid')
+ ->and($memberQuery->resource->first()->relationLoaded('store'))->toBeTrue()
+ ->and($foreignQuery->resource)->toBeEmpty()
+ ->and($member)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Product::class)
+ ->and($foreign->getStatusCode())->toBe(404)
+ ->and($draft->getStatusCode())->toBe(404);
+});
+
test('product query applies a known storefront category filter', function () {
createProductApiControllerSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
@@ -462,6 +575,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'category_uuid' => 'category_uuid',
'name' => 'Drink',
'is_available' => true,
+ 'status' => 'published',
],
[
'uuid' => 'food_uuid',
@@ -471,6 +585,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'category_uuid' => 'other_category',
'name' => 'Food',
'is_available' => true,
+ 'status' => 'published',
],
]);
session([
@@ -528,6 +643,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'price' => 1000,
'currency' => 'USD',
'is_available' => true,
+ 'status' => 'published',
]);
session([
'company' => 'company_uuid',
@@ -542,7 +658,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar
'tags' => ['updated'],
'youtube_urls' => ['https://example.test/updated'],
'category' => 'category_updated',
- 'status' => 'active',
+ 'status' => 'published',
]);
$request->setLaravelSession(new SessionStore(
'product-api-update-test',
diff --git a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php
index 0215e0fd..a4adbdd2 100644
--- a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php
+++ b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php
@@ -51,9 +51,9 @@ class TestableCartController extends CartController
public ?Fleetbase\Storefront\Models\Cart $cart = null;
public array $retrievals = [];
- protected function retrieveCart(?string $uniqueId, bool $create = false): Fleetbase\Storefront\Models\Cart
+ protected function retrieveCart(?string $uniqueId): Fleetbase\Storefront\Models\Cart
{
- $this->retrievals[] = [$uniqueId, $create];
+ $this->retrievals[] = [$uniqueId];
return $this->cart;
}
@@ -90,7 +90,13 @@ function createPublicCartControllerSchema(): void
'customer_id' => 'customer_public',
]);
- $resource = (new CartController())->retrieve(null, Request::create('/cart'));
+ // Called with the Request ALONE, which is exactly what the router produces for
+ // GET /storefront/v1/carts — the route with no {uniqueId}. Laravel splices
+ // class-typed parameters in at their own index and fills the rest from the route
+ // parameters, so when the optional $uniqueId came first there was nothing for index
+ // 0 and the call arrived as "Too few arguments to function retrieve(), 1 passed".
+ // Passing both arguments explicitly, as this test used to, never exercised that.
+ $resource = (new CartController())->retrieve(Request::create('/cart'));
$cart = $resource->resource;
expect($cart->exists)->toBeTrue()
@@ -106,10 +112,10 @@ function createPublicCartControllerSchema(): void
session(['company' => 'company_uuid']);
$controller = new CartController();
- $first = $controller->retrieve('browser-session-1', Request::create('/cart'))->resource;
- $second = $controller->retrieve('browser-session-1', Request::create('/cart'))->resource;
+ $first = $controller->retrieve(Request::create('/cart'), 'browser-session-1')->resource;
+ $second = $controller->retrieve(Request::create('/cart'), 'browser-session-1')->resource;
$first->forceFill(['checkout_uuid' => 'checkout_uuid'])->save();
- $replacement = $controller->retrieve('browser-session-1', Request::create('/cart'))->resource;
+ $replacement = $controller->retrieve(Request::create('/cart'), 'browser-session-1')->resource;
$cartRows = Model::getConnectionResolver()->connection('mysql')->table('carts')
->where('unique_identifier', 'browser-session-1')
->get();
@@ -121,6 +127,163 @@ function createPublicCartControllerSchema(): void
->and($cartRows->whereNull('checkout_uuid'))->toHaveCount(1);
});
+test('cart retrieval never reuses another company cart with the same browser identifier', function () {
+ createPublicCartControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('carts')->insert([
+ 'uuid' => 'foreign_cart_uuid',
+ 'public_id' => 'cart_foreign',
+ 'company_uuid' => 'other_company',
+ 'unique_identifier' => 'shared-browser-id',
+ 'currency' => 'EUR',
+ 'items' => '[]',
+ 'events' => '[]',
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_currency' => 'USD',
+ ]);
+
+ $cart = (new CartController())->retrieve(Request::create('/cart'), 'shared-browser-id')->resource;
+
+ expect($cart->uuid)->not->toBe('foreign_cart_uuid')
+ ->and($cart->company_uuid)->toBe('company_uuid')
+ ->and($cart->currency)->toBe('USD');
+});
+
+test('cart add accepts member products and rejects products outside the active network', function () {
+ createPublicCartControllerSchema();
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['files', 'store_locations', 'network_stores', 'networks', 'stores', 'products'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('logo_uuid')->nullable();
+ $table->string('backdrop_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->boolean('online')->default(true);
+ $table->string('currency')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('store_locations', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('files', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('subject_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('primary_image_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->text('description')->nullable();
+ $table->integer('price')->default(0);
+ $table->string('currency')->nullable();
+ $table->integer('sale_price')->default(0);
+ $table->boolean('is_on_sale')->default(false);
+ $table->boolean('is_available')->default(true);
+ $table->string('status')->nullable();
+ $table->text('meta')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert([
+ ['uuid' => 'member_store_uuid', 'public_id' => 'store_member', 'name' => 'Member store', 'currency' => 'USD'],
+ ['uuid' => 'foreign_store_uuid', 'public_id' => 'store_foreign', 'name' => 'Foreign store', 'currency' => 'USD'],
+ ]);
+ $connection->table('networks')->insert(['uuid' => 'network_uuid']);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'member_store_uuid',
+ ]);
+ $connection->table('store_locations')->insert([
+ 'uuid' => 'member_location_uuid',
+ 'public_id' => 'location_member',
+ 'store_uuid' => 'member_store_uuid',
+ ]);
+ $connection->table('products')->insert([
+ [
+ 'uuid' => 'member_product_uuid',
+ 'public_id' => 'product_member',
+ 'store_uuid' => 'member_store_uuid',
+ 'name' => 'Member product',
+ 'price' => 1000,
+ 'currency' => 'USD',
+ 'is_available' => true,
+ 'status' => 'published',
+ 'meta' => '{}',
+ ],
+ [
+ 'uuid' => 'foreign_product_uuid',
+ 'public_id' => 'product_foreign',
+ 'store_uuid' => 'foreign_store_uuid',
+ 'name' => 'Foreign product',
+ 'price' => 1000,
+ 'currency' => 'USD',
+ 'is_available' => true,
+ 'status' => 'published',
+ 'meta' => '{}',
+ ],
+ ]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_currency' => 'USD',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $controller = new CartController();
+ $memberRequest = Request::create('/cart/items', 'POST', [
+ 'quantity' => 1,
+ 'store_location' => 'location_member',
+ ]);
+
+ $member = $controller->add('marketplace-cart', 'product_member', $memberRequest);
+ $foreign = $controller->add('marketplace-cart', 'product_foreign', Request::create('/cart/items', 'POST'));
+ $wrongLocation = $controller->add('marketplace-cart', 'product_member', Request::create('/cart/items', 'POST', [
+ 'store_location' => 'location_foreign',
+ ]));
+ $resolvedMember = $member->resolve($memberRequest);
+
+ expect($member)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Cart::class)
+ ->and($member->resource->items)->toHaveCount(1)
+ ->and($member->resource->items[0]->store_id)->toBe('store_member')
+ ->and((array) data_get($resolvedMember, 'items.0.store'))->toMatchArray([
+ 'id' => 'store_member',
+ 'name' => 'Member store',
+ 'online' => true,
+ 'currency' => 'USD',
+ ])
+ ->and($foreign->getStatusCode())->toBe(400)
+ ->and($foreign->getData(true))->toHaveKey('error')
+ ->and($wrongLocation->getStatusCode())->toBe(400)
+ ->and($wrongLocation->getData(true))->toBe([
+ 'error' => 'The selected store location is not available for this product.',
+ ]);
+});
+
test('cart controller reports invalid product and line item operations', function () {
createPublicCartControllerSchema();
$schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
@@ -178,7 +341,7 @@ function createPublicCartControllerSchema(): void
$controller = new TestableCartController();
$controller->cart = $cart;
- $retrieved = $controller->retrieve('browser-session', Request::create('/cart'));
+ $retrieved = $controller->retrieve(Request::create('/cart'), 'browser-session');
$added = $controller->add(
'browser-session',
'product_abcdefgh',
@@ -214,13 +377,16 @@ function createPublicCartControllerSchema(): void
->and($removed->resource)->toBe($cart)
->and($emptied->resource)->toBe($cart)
->and($deleted->getData(true))->toBe([])
+ // Every action resolves the cart the same way now. The flag used to vary here, but
+ // it was landing in Cart::retrieve()'s $excludeCheckedout, which meant the five
+ // mutating actions operated on carts that had already produced an order.
->and($controller->retrievals)->toBe([
- ['browser-session', true],
- ['browser-session', false],
- ['browser-session', false],
- ['browser-session', false],
- ['browser-session', false],
- ['browser-session', false],
+ ['browser-session'],
+ ['browser-session'],
+ ['browser-session'],
+ ['browser-session'],
+ ['browser-session'],
+ ['browser-session'],
])
->and($cart->calls['add'])->toBe([
'product_abcdefgh',
diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php
index 99391a85..14c86195 100644
--- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php
+++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php
@@ -3,10 +3,19 @@
use Fleetbase\Storefront\Http\Controllers\OrderController as InternalOrderController;
use Fleetbase\Storefront\Http\Controllers\v1\OrderController;
use Fleetbase\Storefront\Http\Controllers\v1\ReviewController;
+use Fleetbase\Storefront\Models\Product;
use Fleetbase\Storefront\Support\QPay;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
+class ReviewContextControllerStub extends ReviewController
+{
+ public function subjectBelongs($subject): bool
+ {
+ return $this->subjectBelongsToContext($subject);
+ }
+}
+
class ReceiptQPayStub extends QPay
{
public object $response;
@@ -276,6 +285,7 @@ function createReviewControllerSchema(): void
{
$schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder();
$schema->dropIfExists('reviews');
+ $schema->dropIfExists('products');
$schema->create('reviews', function ($table) {
$table->increments('id');
$table->string('uuid')->nullable();
@@ -290,6 +300,13 @@ function createReviewControllerSchema(): void
$table->timestamps();
$table->timestamp('deleted_at')->nullable();
});
+ $schema->create('products', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
}
test('review sort aliases map to stable API sort fields and directions', function (string $sort, ?array $expected) {
@@ -317,12 +334,14 @@ function createReviewControllerSchema(): void
]);
$controller = new ReviewController();
- $reviews = $controller->query(Request::create('/reviews'));
- $counts = $controller->count(Request::create('/reviews/count'));
+ $reviews = $controller->query(Request::create('/reviews'));
+ $counts = $controller->count(Request::create('/reviews/count'));
+ $unsupportedSubject = (new ReviewContextControllerStub())->subjectBelongs(new stdClass());
expect($reviews->resource)->toBeEmpty()
->and($counts->getStatusCode())->toBe(200)
- ->and($counts->getData(true))->toBe([]);
+ ->and($counts->getData(true))->toBe([])
+ ->and($unsupportedSubject)->toBeFalse();
});
test('review rating counts are scoped to the active storefront store', function () {
@@ -434,9 +453,8 @@ function createReviewControllerSchema(): void
$table->timestamp('deleted_at')->nullable();
});
$connection->table('stores')->insert([
- 'uuid' => 'store_uuid',
- 'public_id' => 'store_abcdefgh',
- 'company_uuid' => 'company_uuid',
+ ['uuid' => 'store_uuid', 'public_id' => 'store_abcdefgh', 'company_uuid' => 'invited_company_uuid'],
+ ['uuid' => 'foreign_store_uuid', 'public_id' => 'store_foreign', 'company_uuid' => 'company_uuid'],
]);
$connection->table('networks')->insert([
'uuid' => 'network_uuid',
@@ -464,6 +482,34 @@ function createReviewControllerSchema(): void
'created_at' => '2026-01-02 00:00:00',
'updated_at' => '2026-01-02 00:00:00',
],
+ [
+ 'uuid' => 'foreign_network_review',
+ 'public_id' => 'review_network_foreign',
+ 'subject_uuid' => 'foreign_store_uuid',
+ 'rating' => 3,
+ 'created_at' => '2026-01-03 00:00:00',
+ 'updated_at' => '2026-01-03 00:00:00',
+ ],
+ [
+ 'uuid' => 'network_product_review',
+ 'public_id' => 'review_network_product',
+ 'subject_uuid' => 'member_product_uuid',
+ 'rating' => 4,
+ 'created_at' => '2026-01-04 00:00:00',
+ 'updated_at' => '2026-01-04 00:00:00',
+ ],
+ [
+ 'uuid' => 'foreign_product_review',
+ 'public_id' => 'review_foreign_product',
+ 'subject_uuid' => 'foreign_product_uuid',
+ 'rating' => 2,
+ 'created_at' => '2026-01-05 00:00:00',
+ 'updated_at' => '2026-01-05 00:00:00',
+ ],
+ ]);
+ $connection->table('products')->insert([
+ ['uuid' => 'member_product_uuid', 'public_id' => 'product_member', 'store_uuid' => 'store_uuid'],
+ ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'foreign_store_uuid'],
]);
session([
'company' => 'company_uuid',
@@ -491,6 +537,13 @@ function createReviewControllerSchema(): void
$counts = $controller->count(Request::create('/reviews/count?store=store_abcdefgh', 'GET', [
'store' => 'store_abcdefgh',
]));
+ $found = $controller->find('review_network_two');
+ $foreign = $controller->find('review_network_foreign');
+ $product = $controller->find('review_network_product');
+ $foreignProduct = $controller->find('review_foreign_product');
+ $memberProduct = Product::where('uuid', 'member_product_uuid')->firstOrFail();
+ $outsideProduct = Product::where('uuid', 'foreign_product_uuid')->firstOrFail();
+ $contextProbe = new ReviewContextControllerStub();
expect($missing->getStatusCode())->toBe(400)
->and($missing->getData(true))->toBe(['error' => 'Cannot find reviews for store'])
@@ -498,6 +551,12 @@ function createReviewControllerSchema(): void
->and($missingCount->getData(true))->toBe(['error' => 'Cannot count reviews for store'])
->and($reviews->resource)->toHaveCount(1)
->and($reviews->resource->first()->uuid)->toBe('network_review_two')
+ ->and($found->resource->uuid)->toBe('network_review_two')
+ ->and($foreign->getStatusCode())->toBe(400)
+ ->and($product->resource->uuid)->toBe('network_product_review')
+ ->and($foreignProduct->getStatusCode())->toBe(400)
+ ->and($contextProbe->subjectBelongs($memberProduct))->toBeTrue()
+ ->and($contextProbe->subjectBelongs($outsideProduct))->toBeFalse()
->and($counts->getData(true))->toBe([
1 => 1,
2 => 0,
@@ -521,26 +580,36 @@ function createReviewControllerSchema(): void
->and($delete->getData(true))->toBe(['error' => 'Review resource not found.']);
});
-test('review find and delete return and soft delete persisted review resources', function () {
+test('review find is storefront scoped and unauthenticated customers cannot delete reviews', function () {
createReviewControllerSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
+ $connection->table('products')->insert([
+ ['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid'],
+ ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'other_store_uuid'],
+ ]);
$connection->table('reviews')->insert([
- 'uuid' => 'review_uuid',
- 'public_id' => 'review_abcdefgh',
- 'subject_uuid' => 'store_uuid',
- 'rating' => 5,
- 'content' => 'Excellent',
- 'created_at' => now(),
- 'updated_at' => now(),
+ ['uuid' => 'review_uuid', 'public_id' => 'review_abcdefgh', 'subject_uuid' => 'store_uuid', 'rating' => 5, 'content' => 'Excellent', 'created_at' => now(), 'updated_at' => now()],
+ ['uuid' => 'foreign_review_uuid', 'public_id' => 'review_foreign', 'subject_uuid' => 'other_store_uuid', 'rating' => 1, 'content' => 'Foreign', 'created_at' => now(), 'updated_at' => now()],
+ ['uuid' => 'product_review_uuid', 'public_id' => 'review_product', 'subject_uuid' => 'product_uuid', 'rating' => 4, 'content' => 'Great product', 'created_at' => now(), 'updated_at' => now()],
+ ['uuid' => 'foreign_product_review_uuid', 'public_id' => 'review_foreign_product', 'subject_uuid' => 'foreign_product_uuid', 'rating' => 2, 'content' => 'Foreign product', 'created_at' => now(), 'updated_at' => now()],
]);
+ session(['storefront_store' => 'store_uuid', 'storefront_network' => null]);
$controller = new ReviewController();
- $found = $controller->find('review_abcdefgh');
- $deleted = $controller->delete('review_abcdefgh');
+ $found = $controller->find('review_abcdefgh');
+ $foreign = $controller->find('review_foreign');
+ $product = $controller->find('review_product');
+ $foreignProduct = $controller->find('review_foreign_product');
+ $deleted = $controller->delete('review_abcdefgh');
expect($found->resource->uuid)->toBe('review_uuid')
- ->and($deleted->resource->uuid)->toBe('review_uuid')
- ->and($connection->table('reviews')->where('uuid', 'review_uuid')->value('deleted_at'))->not->toBeNull();
+ ->and($foreign->getStatusCode())->toBe(400)
+ ->and($foreign->getData(true))->toBe(['error' => 'Review resource not found.'])
+ ->and($product->resource->uuid)->toBe('product_review_uuid')
+ ->and($foreignProduct->getStatusCode())->toBe(400)
+ ->and($deleted->getStatusCode())->toBe(403)
+ ->and($deleted->getData(true))->toBe(['error' => 'Not authorized to delete review'])
+ ->and($connection->table('reviews')->where('uuid', 'review_uuid')->value('deleted_at'))->toBeNull();
});
test('review creation enforces customer authentication and subject validity', function () {
@@ -655,6 +724,10 @@ function createReviewControllerSchema(): void
'key' => 'store_key',
'name' => 'Review store',
]);
+ $connection->table('products')->insert([
+ ['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid'],
+ ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'foreign_store_uuid'],
+ ]);
$boundRequest = Request::create('/reviews');
$boundRequest->headers->set('Customer-Token', 'review-customer-secret');
$boundRequest->setLaravelSession(new Illuminate\Session\Store(
@@ -662,7 +735,12 @@ function createReviewControllerSchema(): void
new Illuminate\Session\ArraySessionHandler(120)
));
app()->instance('request', $boundRequest);
- session(['company' => 'company_uuid', 'storefront_key' => null]);
+ session([
+ 'company' => 'company_uuid',
+ 'storefront_key' => null,
+ 'storefront_store' => 'store_uuid',
+ 'storefront_network' => null,
+ ]);
$controller = new ReviewController();
$invalid = $controller->create(
@@ -678,7 +756,24 @@ function createReviewControllerSchema(): void
'content' => 'Excellent service',
])
);
+ $invalidProduct = $controller->create(
+ Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [
+ 'subject' => 'product_foreign',
+ 'rating' => 1,
+ ])
+ );
+ $createdProduct = $controller->create(
+ Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [
+ 'subject' => 'product_abcdefgh',
+ 'rating' => 4,
+ 'content' => 'Excellent product',
+ ])
+ );
$review = $connection->table('reviews')->first();
+ $connection->table('reviews')->where('id', $review->id)->update([
+ 'uuid' => 'owned_review_uuid',
+ 'public_id' => 'review_owned',
+ ]);
Illuminate\Support\Facades\Storage::swap(new class {
public function disk(string $disk): self
{
@@ -707,9 +802,13 @@ public function put(string $path, string $contents, string $visibility): bool
])
);
$photo = $connection->table('files')->first();
+ session(['storefront_store' => 'store_uuid', 'storefront_network' => null]);
+ $deleted = $controller->delete('review_owned');
expect($invalid->getData(true))->toBe(['error' => 'Invalid subject for review'])
+ ->and($invalidProduct->getData(true))->toBe(['error' => 'Invalid subject for review'])
->and($created->resource->uuid)->toBe($review->uuid)
+ ->and($createdProduct->resource->subject_uuid)->toBe('product_uuid')
->and($review->created_by_uuid)->toBe('user_uuid')
->and($review->customer_uuid)->toBe($customerUuid)
->and($review->subject_uuid)->toBe('store_uuid')
@@ -720,7 +819,9 @@ public function put(string $path, string $contents, string $visibility): bool
->and($photo->content_type)->toBe('image/png')
->and($photo->bucket)->toBe('review-bucket')
->and($photo->file_size)->toBe(strlen('image-bytes'))
- ->and($photo->type)->toBe('storefront_review_upload');
+ ->and($photo->type)->toBe('storefront_review_upload')
+ ->and($deleted->resource->uuid)->toBe('owned_review_uuid')
+ ->and($connection->table('reviews')->where('uuid', 'owned_review_uuid')->value('deleted_at'))->not->toBeNull();
});
test('customer order actions require a customer token before order lookup', function () {
diff --git a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php
index 00abe373..c967b641 100644
--- a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php
+++ b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php
@@ -140,7 +140,7 @@ function createServiceQuoteLookupSchema(): void
$connection = Model::getConnectionResolver()->connection('mysql');
$schema = $connection->getSchemaBuilder();
- foreach (['places', 'store_locations', 'stores', 'vehicles', 'food_trucks', 'products', 'files', 'carts', 'service_quote_items', 'service_quotes', 'service_rates', 'integrated_vendors'] as $table) {
+ foreach (['network_stores', 'networks', 'places', 'store_locations', 'stores', 'vehicles', 'food_trucks', 'products', 'files', 'carts', 'service_quote_items', 'service_quotes', 'service_rates', 'integrated_vendors'] as $table) {
$schema->dropIfExists($table);
}
@@ -170,6 +170,22 @@ function createServiceQuoteLookupSchema(): void
$table->text('options')->nullable();
$table->timestamp('deleted_at')->nullable();
});
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->string('name')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('network_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->timestamps();
+ $table->timestamp('deleted_at')->nullable();
+ });
$schema->create('vehicles', function ($table) {
$table->increments('id');
$table->string('uuid')->nullable();
@@ -265,6 +281,26 @@ function createServiceQuoteLookupSchema(): void
});
}
+function seedServiceQuoteMarketplace(string $storeUuid = 'store_uuid', string $storePublicId = 'store_public'): void
+{
+ $connection = Model::getConnectionResolver()->connection('mysql');
+
+ $connection->table('stores')->updateOrInsert(
+ ['uuid' => $storeUuid],
+ ['public_id' => $storePublicId, 'name' => 'Marketplace Store', 'options' => '{}']
+ );
+ $connection->table('networks')->updateOrInsert(
+ ['uuid' => 'network_uuid'],
+ ['public_id' => 'network_public', 'name' => 'Marketplace Network']
+ );
+ $connection->table('network_stores')->updateOrInsert(
+ ['network_uuid' => 'network_uuid', 'store_uuid' => $storeUuid],
+ ['uuid' => 'network_store_' . $storeUuid, 'created_at' => now(), 'updated_at' => now()]
+ );
+
+ session(['storefront_network' => 'network_uuid']);
+}
+
test('service quote place lookup resolves tenant places and rejects missing typed resources', function () {
createServiceQuoteLookupSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
@@ -467,6 +503,17 @@ public function get(): Illuminate\Support\Collection
test('network service quote rejects missing integrated facilitators safely', function () {
createServiceQuoteLookupSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
+ seedServiceQuoteMarketplace();
+ $connection->table('places')->insert([
+ 'uuid' => 'origin_uuid',
+ 'public_id' => 'place_origin',
+ ]);
+ $connection->table('store_locations')->insert([
+ 'uuid' => 'location_uuid',
+ 'public_id' => 'store_location_public',
+ 'store_uuid' => 'store_uuid',
+ 'place_uuid' => 'origin_uuid',
+ ]);
$connection->table('carts')->insert([
'uuid' => 'cart_uuid',
'public_id' => 'cart_public',
@@ -495,6 +542,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
$missingFacilitator = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [
'destination' => 'place_destination',
'cart' => 'network-cart',
+ 'origin' => 'store_location_public',
'facilitator' => 'integrated_vendor_missing',
]));
@@ -514,6 +562,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
'name' => 'Corner Store',
'options' => '{}',
]);
+ seedServiceQuoteMarketplace();
$connection->table('store_locations')->insert([
'uuid' => 'location_uuid',
'public_id' => 'store_location_public',
@@ -524,6 +573,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
[
'uuid' => 'explicit_cart_uuid',
'public_id' => 'explicit_cart_public',
+ 'company_uuid' => 'company_uuid',
'unique_identifier' => 'explicit-cart',
'currency' => 'USD',
'items' => json_encode([
@@ -540,6 +590,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
[
'uuid' => 'default_cart_uuid',
'public_id' => 'default_cart_public',
+ 'company_uuid' => 'company_uuid',
'unique_identifier' => 'default-cart',
'currency' => 'USD',
'items' => json_encode([
@@ -553,11 +604,56 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
'created_at' => now(),
'updated_at' => now(),
],
+ [
+ 'uuid' => 'missing_location_cart_uuid',
+ 'public_id' => 'missing_location_cart_public',
+ 'company_uuid' => 'company_uuid',
+ 'unique_identifier' => 'missing-location-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ [
+ 'store_id' => 'store_public',
+ 'store_location_id' => 'store_location_missing',
+ ],
+ ]),
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ [
+ 'uuid' => 'mismatched_location_cart_uuid',
+ 'public_id' => 'mismatched_location_cart_public',
+ 'company_uuid' => 'company_uuid',
+ 'unique_identifier' => 'mismatched-location-cart',
+ 'currency' => 'USD',
+ 'items' => json_encode([
+ [
+ 'store_id' => 'store_different',
+ 'store_location_id' => 'store_location_public',
+ ],
+ ]),
+ 'events' => '[]',
+ 'expires_at' => now()->addHour(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
]);
session([
'company' => 'company_uuid',
'storefront_key' => 'network_public',
]);
+ $scopedLocation = Fleetbase\Storefront\Models\StoreLocation::where('public_id', 'store_location_public')
+ ->whereHas('store.networks', fn ($query) => $query->where('network_uuid', session('storefront_network')))
+ ->with('store')
+ ->first();
+ $scopedStore = Fleetbase\Storefront\Models\Store::where('public_id', 'store_public')
+ ->whereHas('networks', fn ($query) => $query->where('network_uuid', session('storefront_network')))
+ ->with('locations')
+ ->first();
+ expect(session('storefront_network'))->toBe('network_uuid')
+ ->and($scopedLocation?->store?->public_id)->toBe('store_public')
+ ->and(data_get($scopedStore, 'locations.0.public_id'))->toBe('store_location_public');
$controller = new class extends ServiceQuoteController {
public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place
{
@@ -578,14 +674,31 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
'cart' => 'default-cart',
'facilitator' => 'integrated_vendor_missing',
]));
+ $missingLocation = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'destination' => 'place_destination',
+ 'cart' => 'missing-location-cart',
+ ]));
+ $mismatchedLocation = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [
+ 'destination' => 'place_destination',
+ 'cart' => 'mismatched-location-cart',
+ ]));
- expect($explicit->getData(true))->toBe(['error' => 'Integrated vendor not found!'])
- ->and($default->getData(true))->toBe(['error' => 'Integrated vendor not found!']);
+ expect($default->getData(true))->toBe(['error' => 'Integrated vendor not found!'])
+ ->and($explicit->getData(true))->toBe(['error' => 'Integrated vendor not found!'])
+ ->and($missingLocation->getStatusCode())->toBe(422)
+ ->and($missingLocation->getData(true))->toBe([
+ 'error' => 'One or more store locations are unavailable for this marketplace.',
+ ])
+ ->and($mismatchedLocation->getStatusCode())->toBe(422)
+ ->and($mismatchedLocation->getData(true))->toBe([
+ 'error' => 'One or more store locations are unavailable for this marketplace.',
+ ]);
});
test('network service quote persists integrated facilitator origin metadata and provider errors', function () {
createServiceQuoteLookupSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
+ seedServiceQuoteMarketplace();
$connection->table('places')->insert([
'uuid' => 'origin_uuid',
'public_id' => 'place_origin',
@@ -593,11 +706,13 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
$connection->table('store_locations')->insert([
'uuid' => 'location_uuid',
'public_id' => 'store_location_public',
+ 'store_uuid' => 'store_uuid',
'place_uuid' => 'origin_uuid',
]);
$connection->table('carts')->insert([
'uuid' => 'cart_uuid',
'public_id' => 'cart_public',
+ 'company_uuid' => 'company_uuid',
'unique_identifier' => 'network-cart',
'currency' => 'USD',
'items' => json_encode([
@@ -854,6 +969,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
test('network service quote resolves comma separated fallback origins before reporting no rates', function () {
createServiceQuoteLookupSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
+ seedServiceQuoteMarketplace();
$connection->table('places')->insert([
['uuid' => 'place_one_uuid', 'public_id' => 'place_one'],
['uuid' => 'place_two_uuid', 'public_id' => 'place_two'],
@@ -862,11 +978,13 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
[
'uuid' => 'location_one_uuid',
'public_id' => 'store_location_one',
+ 'store_uuid' => 'store_uuid',
'place_uuid' => 'place_one_uuid',
],
[
'uuid' => 'location_two_uuid',
'public_id' => 'store_location_two',
+ 'store_uuid' => 'store_uuid',
'place_uuid' => 'place_two_uuid',
],
]);
@@ -909,6 +1027,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
test('network service quote persists local rate lines and selects the lowest matching quote', function () {
createServiceQuoteLookupSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
+ seedServiceQuoteMarketplace();
$connection->table('products')->insert([
'uuid' => 'product_uuid',
'public_id' => 'product_public',
@@ -927,6 +1046,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
$connection->table('store_locations')->insert([
'uuid' => 'location_uuid',
'public_id' => 'store_location_origin',
+ 'store_uuid' => 'store_uuid',
'place_uuid' => 'origin_uuid',
]);
$connection->table('carts')->insert([
@@ -991,6 +1111,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
test('network service quote falls back to an integrated provider when local rates are unavailable', function () {
createServiceQuoteLookupSchema();
$connection = Model::getConnectionResolver()->connection('mysql');
+ seedServiceQuoteMarketplace();
$connection->table('places')->insert([
'uuid' => 'origin_uuid',
'public_id' => 'place_origin',
@@ -998,6 +1119,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla
$connection->table('store_locations')->insert([
'uuid' => 'location_uuid',
'public_id' => 'store_location_origin',
+ 'store_uuid' => 'store_uuid',
'place_uuid' => 'origin_uuid',
]);
$connection->table('carts')->insert([
diff --git a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php
index a987489e..3bdbd16d 100644
--- a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php
+++ b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php
@@ -506,6 +506,148 @@ public function sendNow($notifiables, $notification, ?array $channels = null)
->and($location->getData(true))->toBe(['error' => 'Networks cannot have locations!']);
});
+test('network storefront lookup and location access are limited to member stores including cross-company invitees', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['store_hours', 'places', 'store_locations', 'network_stores', 'networks', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('networks', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('network_stores', function ($table) {
+ $table->increments('id');
+ $table->string('network_uuid')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('category_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('places', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('store_locations', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid')->nullable();
+ $table->string('place_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('store_hours', function ($table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('store_location_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_public',
+ 'company_uuid' => 'network_company',
+ ]);
+ $connection->table('stores')->insert([
+ [
+ 'uuid' => 'member_store_uuid',
+ 'public_id' => 'store_member',
+ 'company_uuid' => 'invited_company',
+ ],
+ [
+ 'uuid' => 'foreign_store_uuid',
+ 'public_id' => 'store_foreign',
+ 'company_uuid' => 'network_company',
+ ],
+ ]);
+ $connection->table('network_stores')->insert([
+ 'network_uuid' => 'network_uuid',
+ 'store_uuid' => 'member_store_uuid',
+ ]);
+ $connection->table('places')->insert([
+ ['uuid' => 'member_place_uuid', 'public_id' => 'place_member'],
+ ['uuid' => 'foreign_place_uuid', 'public_id' => 'place_foreign'],
+ ]);
+ $connection->table('store_locations')->insert([
+ [
+ 'uuid' => 'member_location_uuid',
+ 'public_id' => 'location_member',
+ 'store_uuid' => 'member_store_uuid',
+ 'place_uuid' => 'member_place_uuid',
+ ],
+ [
+ 'uuid' => 'foreign_location_uuid',
+ 'public_id' => 'location_foreign',
+ 'store_uuid' => 'foreign_store_uuid',
+ 'place_uuid' => 'foreign_place_uuid',
+ ],
+ ]);
+ session([
+ 'company' => 'network_company',
+ 'storefront_store' => null,
+ 'storefront_network' => 'network_uuid',
+ ]);
+ $controller = new StoreController();
+
+ $memberLookup = $controller->lookup('store_member');
+ $foreignLookup = $controller->lookup('store_foreign');
+ $memberLocations = $controller->locations(Request::create('/locations', 'GET', ['store' => 'store_member']));
+ $foreignLocations = $controller->locations(Request::create('/locations', 'GET', ['store' => 'store_foreign']));
+ $foreignLocation = $controller->location('location_foreign', Request::create('/locations/location_foreign', 'GET', ['store' => 'store_foreign']));
+
+ expect($memberLookup)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Store::class)
+ ->and($foreignLookup->getStatusCode())->toBe(400)
+ ->and($memberLocations->resource)->toHaveCount(1)
+ ->and($foreignLocations->resource)->toBeEmpty()
+ ->and($foreignLocation->getStatusCode())->toBe(404);
+});
+
+test('store controller answers 404 for an unknown store or location instead of throwing', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+ foreach (['store_hours', 'store_locations', 'places', 'stores'] as $table) {
+ $schema->dropIfExists($table);
+ }
+ $schema->create('stores', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $schema->create('store_locations', function ($table) {
+ $table->string('uuid')->primary();
+ $table->string('public_id');
+ $table->string('store_uuid');
+ $table->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_public']);
+
+ session(['storefront_network' => null, 'storefront_store' => 'store_public']);
+ $request = Request::create('/v1/storefront/locations');
+
+ // Both lookups were unguarded, so an id that resolved nothing reached the resource
+ // and threw inside it — the endpoint answered 500 with an HTML stack trace for any
+ // stale or mistyped location id.
+ $missingLocation = (new StoreController())->location('location_does_not_exist', $request);
+
+ session(['storefront_store' => 'store_does_not_exist']);
+ $missingStore = (new StoreController())->location('location_public', $request);
+
+ expect($missingLocation->getStatusCode())->toBe(404)
+ ->and($missingLocation->getData(true))->toBe(['error' => 'Store location not found!'])
+ ->and($missingStore->getStatusCode())->toBe(404)
+ ->and($missingStore->getData(true))->toBe(['error' => 'Unable to find store!']);
+});
+
test('store controller resolves active and explicitly selected store locations with their relations', function () {
$connection = Model::getConnectionResolver()->connection('mysql');
$schema = $connection->getSchemaBuilder();
@@ -706,9 +848,10 @@ public function sendNow($notifiables, $notification, ?array $channels = null)
'storefront_network' => 'network_uuid',
]);
$networkResults = $controller->search(Request::create('/search', 'GET', [
- 'query' => 'Coffee',
- 'store' => 'store_abcdefgh',
- 'limit' => 10,
+ 'query' => 'Coffee',
+ 'store' => 'store_abcdefgh',
+ 'limit' => 10,
+ 'with_store' => true,
]));
expect($storeResults->resource->pluck('uuid')->all())->toBe([
@@ -716,7 +859,7 @@ public function sendNow($notifiables, $notification, ?array $channels = null)
'category_product_uuid',
])->and($networkResults->resource->pluck('uuid')->all())->toBe([
'direct_product_uuid',
- ]);
+ ])->and($networkResults->resource->first()->relationLoaded('store'))->toBeTrue();
});
test('network controller resolves public IDs and invitation codes to their network', function () {
diff --git a/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php b/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php
index c63951a2..70740282 100644
--- a/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php
+++ b/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php
@@ -93,6 +93,58 @@
->and(session('api_credential'))->toBe('network_secret');
});
+test('switching between a network and a store key never leaves both scopes in the session', function () {
+ $connection = Model::getConnectionResolver()->connection('mysql');
+ $schema = $connection->getSchemaBuilder();
+
+ foreach (['stores', 'networks'] as $table) {
+ $schema->dropIfExists($table);
+ $schema->create($table, function ($blueprint) {
+ $blueprint->string('uuid')->primary();
+ $blueprint->string('public_id');
+ $blueprint->string('company_uuid');
+ $blueprint->string('key');
+ $blueprint->string('currency');
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ }
+
+ $connection->table('stores')->insert([
+ 'uuid' => 'store_uuid',
+ 'public_id' => 'store_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'store_secret',
+ 'currency' => 'USD',
+ ]);
+ $connection->table('networks')->insert([
+ 'uuid' => 'network_uuid',
+ 'public_id' => 'network_public',
+ 'company_uuid' => 'company_uuid',
+ 'key' => 'network_secret',
+ 'currency' => 'MNT',
+ ]);
+
+ $middleware = new SetStorefrontSession();
+
+ // The session is cookie-backed, so a client hitting a network endpoint and then a
+ // store endpoint reuses it. Leaving both scopes set made every store-scoped query
+ // also apply the stricter network filter.
+ $middleware->setKey('network_secret');
+ $middleware->setKey('store_secret');
+
+ expect(session('storefront_store'))->toBe('store_uuid')
+ ->and(session('storefront_store_public_id'))->toBe('store_public')
+ ->and(session('storefront_network'))->toBeNull()
+ ->and(session('storefront_network_public_id'))->toBeNull();
+
+ $middleware->setKey('network_secret');
+
+ expect(session('storefront_network'))->toBe('network_uuid')
+ ->and(session('storefront_network_public_id'))->toBe('network_public')
+ ->and(session('storefront_store'))->toBeNull()
+ ->and(session('storefront_store_public_id'))->toBeNull();
+});
+
test('customer setup is a no-op without a customer token', function () {
$middleware = new SetStorefrontSession();
diff --git a/server/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php
index 160cbeb8..d8840dd9 100644
--- a/server/tests/Unit/Http/Requests/RequestContractsTest.php
+++ b/server/tests/Unit/Http/Requests/RequestContractsTest.php
@@ -67,12 +67,26 @@
$deliveryRules = $deliveryRequest->rules();
$pickupRules = $pickupRequest->rules();
- expect($deliveryRules)->toHaveKeys(['gateway', 'customer', 'cart', 'serviceQuote', 'cash', 'pickup'])
+ expect($deliveryRules)->toHaveKeys(['gateway', 'customer', 'cart', 'serviceQuote', 'service_quote', 'cash', 'pickup'])
->and($deliveryRules['gateway'][1])->toBeInstanceOf(GatewayExists::class)
->and($deliveryRules['customer'][1])->toBeInstanceOf(CustomerExists::class)
->and($deliveryRules['serviceQuote'][0])->toBeInstanceOf(RequiredIf::class)
->and((string) $deliveryRules['serviceQuote'][0])->toBe('required')
- ->and((string) $pickupRules['serviceQuote'][0])->toBe('');
+ ->and((string) $deliveryRules['service_quote'][0])->toBe('required')
+ ->and((string) $pickupRules['serviceQuote'][0])->toBe('')
+ ->and((string) $pickupRules['service_quote'][0])->toBe('');
+});
+
+test('either spelling of the service quote satisfies the delivery requirement', function () {
+ // The controller reads or(['serviceQuote', 'service_quote']), so validating only one spelling
+ // rejected the other before the controller ran.
+ $snakeRules = InitializeCheckoutRequest::create('/checkout', 'POST', ['pickup' => false, 'service_quote' => 'quote_1'])->rules();
+ $camelRules = InitializeCheckoutRequest::create('/checkout', 'POST', ['pickup' => false, 'serviceQuote' => 'quote_1'])->rules();
+
+ expect((string) $snakeRules['serviceQuote'][0])->toBe('')
+ ->and((string) $snakeRules['service_quote'][0])->toBe('required')
+ ->and((string) $camelRules['service_quote'][0])->toBe('')
+ ->and((string) $camelRules['serviceQuote'][0])->toBe('required');
});
test('service quote request varies origin validation by storefront key type', function () {
@@ -117,7 +131,11 @@
])->and($createRules['price'][0])->toBeInstanceOf(RequiredIf::class)
->and((string) $createRules['price'][0])->toBe('required')
->and((string) $updateRules['price'][0])->toBe('')
- ->and($createRules['status'])->toContain('in:draft,active,archived')
+ // `published` is what the eleven read paths filter on and what the console writes.
+ // Leaving it out meant a product created through the public API could never be
+ // seen by a network storefront, nor survive CheckoutController's cart validation.
+ ->and($createRules['status'])->toContain('in:draft,active,archived,published')
+ ->and($updateRules['status'])->toContain('in:draft,active,archived,published')
->and($createRules['currency'])->toContain('size:3');
});
@@ -131,7 +149,11 @@
expect($customerRules)->toHaveKeys(['code', 'name', 'email', 'phone'])
->and($customerRules['code'])->toBe('required|exists:verification_codes,code')
+ // `subject` is required: the controller resolves it with Utils::resolveSubject(),
+ // whose parameter is a non-nullable string, so omitting it threw a TypeError as a
+ // 500 before the controller's own "Invalid subject for review" guard could run.
->and($reviewRules)->toBe([
+ 'subject' => 'required|string',
'rating' => 'required|numeric',
'content' => 'required',
'files' => 'sometimes|array',
diff --git a/server/tests/Unit/Models/CartTest.php b/server/tests/Unit/Models/CartTest.php
index e3c506e6..da61660e 100644
--- a/server/tests/Unit/Models/CartTest.php
+++ b/server/tests/Unit/Models/CartTest.php
@@ -21,6 +21,7 @@ public static function findProduct(string $id): ?Product
function createCartLifecycleSchema(): void
{
$schema = Capsule::schema('mysql');
+ $schema->dropIfExists('store_locations');
$schema->dropIfExists('stores');
$schema->dropIfExists('carts');
$schema->create('stores', function (Blueprint $table) {
@@ -30,6 +31,14 @@ function createCartLifecycleSchema(): void
$table->timestamps();
$table->softDeletes();
});
+ $schema->create('store_locations', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('uuid')->nullable();
+ $table->string('public_id')->nullable();
+ $table->string('store_uuid');
+ $table->timestamps();
+ $table->softDeletes();
+ });
$schema->create('carts', function (Blueprint $table) {
$table->increments('id');
$table->string('uuid')->nullable();
@@ -228,6 +237,13 @@ function cartWithItems(): Cart
'created_at' => now(),
'updated_at' => now(),
]);
+ Capsule::connection('mysql')->table('store_locations')->insert([
+ 'uuid' => 'location_uuid',
+ 'public_id' => 'location_public',
+ 'store_uuid' => 'store_uuid',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
$store = new Store();
$store->forceFill(['public_id' => 'store_public']);
diff --git a/server/tests/Unit/Notifications/NotificationContractsTest.php b/server/tests/Unit/Notifications/NotificationContractsTest.php
index d39f54b5..1c9db676 100644
--- a/server/tests/Unit/Notifications/NotificationContractsTest.php
+++ b/server/tests/Unit/Notifications/NotificationContractsTest.php
@@ -250,8 +250,11 @@ function notificationOrder(array $meta = []): Order
'currency' => 'USD',
]);
$order->setRelation('payload', (object) [
- 'entities' => collect([(object) ['name' => 'Coffee'], (object) ['name' => 'Cake']]),
- 'dropoff' => (object) ['address' => '1 Market Street'],
+ 'entities' => collect([(object) ['name' => 'Coffee'], (object) ['name' => 'Cake']]),
+ 'dropoff' => (object) ['address' => '1 Market Street'],
+ 'pickup_name' => null,
+ 'dropoff_name' => null,
+ 'return_name' => null,
]);
$store = new Store(['name' => 'Corner Store']);
$notification = notificationWithoutConstructor(StorefrontOrderCreated::class, $order, $store);
diff --git a/server/tests/Unit/Support/QPayTest.php b/server/tests/Unit/Support/QPayTest.php
index 17c59f5c..d4d15d16 100644
--- a/server/tests/Unit/Support/QPayTest.php
+++ b/server/tests/Unit/Support/QPayTest.php
@@ -260,7 +260,10 @@ function qpayWithResponses(array $responses, array &$history): QPay
]),
]);
$item = (object) ['product_id' => 'product_abcdefgh'];
- $stringMetaItem = (object) ['meta' => json_encode(['tax_product_code' => '202'])];
+ $stringMetaItem = (object) [
+ 'meta' => json_encode(['tax_product_code' => '202']),
+ 'product_id' => null,
+ ];
expect(QPay::getCartItemClassificationCode($item))->toBe('2111500')
->and(QPay::getCartItemTaxProductCode($item))->toBe('201')
diff --git a/server/tests/Unit/Support/StorefrontTest.php b/server/tests/Unit/Support/StorefrontTest.php
index 8c5cc7b7..5c58d10f 100644
--- a/server/tests/Unit/Support/StorefrontTest.php
+++ b/server/tests/Unit/Support/StorefrontTest.php
@@ -227,6 +227,7 @@ function createStorefrontSupportSchema(): void
]);
$connection->table('order_configs')->insert([
'uuid' => 'order_config_uuid',
+ 'flow' => '[]',
'activities' => '[]',
'created_at' => now(),
'updated_at' => now(),
@@ -278,6 +279,7 @@ function createStorefrontSupportSchema(): void
]);
$connection->table('order_configs')->insert([
'uuid' => 'order_config_uuid',
+ 'flow' => '[]',
'activities' => '[]',
'created_at' => now(),
'updated_at' => now(),
@@ -351,13 +353,14 @@ function createStorefrontSupportSchema(): void
'name' => 'Corner Store',
]);
$connection->table('order_configs')->insert([
- 'uuid' => 'order_config_uuid',
- 'company_uuid' => '11111111-1111-4111-8111-111111111111',
- 'key' => 'storefront-config',
- 'namespace' => 'storefront',
- 'activities' => '[]',
- 'created_at' => now(),
- 'updated_at' => now(),
+ 'uuid' => 'order_config_uuid',
+ 'company_uuid' => '11111111-1111-4111-8111-111111111111',
+ 'key' => 'storefront-config',
+ 'namespace' => 'storefront',
+ 'flow' => '[]',
+ 'activities' => '[]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
]);
session([
'company' => '11111111-1111-4111-8111-111111111111',
@@ -374,13 +377,14 @@ function createStorefrontSupportSchema(): void
$company = new Fleetbase\Models\Company();
$company->uuid = '22222222-2222-4222-8222-222222222222';
$connection->table('order_configs')->insert([
- 'uuid' => 'patch_config_uuid',
- 'company_uuid' => '33333333-3333-4333-8333-333333333333',
- 'key' => 'storefront',
- 'namespace' => 'system:order-config:storefront',
- 'activities' => '[]',
- 'created_at' => now(),
- 'updated_at' => now(),
+ 'uuid' => 'patch_config_uuid',
+ 'company_uuid' => '33333333-3333-4333-8333-333333333333',
+ 'key' => 'storefront',
+ 'namespace' => 'system:order-config:storefront',
+ 'flow' => '[]',
+ 'activities' => '[]',
+ 'created_at' => now(),
+ 'updated_at' => now(),
]);
$companyOrder = new StorefrontOrderStub();
$companyOrder->forceFill(['company_uuid' => '33333333-3333-4333-8333-333333333333']);