From 5518e674c0845614ee57af97eaf8f1cee1339c2d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 13:06:09 +0800 Subject: [PATCH 01/50] fix(reviews): upload review photos without a public ACL The S3 media bucket enforces bucket-owner object ownership and rejects PUTs that carry an ACL, so put(..., 'public') returned false and no photo was stored. --- .../src/Http/Controllers/v1/ReviewController.php | 4 ++-- .../ReviewAndOrderControllerContractsTest.php | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index dd51ba78..7a8ce79a 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -258,8 +258,8 @@ public function create(CreateReviewRequest $request) $extension = File::getExtensionFromMimeType($mimeType); $bucketPath = 'hyperstore/' . $about->public_id . '/review-photos/' . $review->uuid . '/' . File::randomFileName($extension); - // upload file to path - $upload = Storage::disk($disk)->put($bucketPath, base64_decode($data), 'public'); + // upload file to path; no 'public' ACL, the S3 media bucket is private and rejects ACLs + $upload = Storage::disk($disk)->put($bucketPath, base64_decode($data)); // create the file $uploadedFiles->push(File::create([ diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 14c86195..611b28c8 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -774,17 +774,22 @@ function createReviewControllerSchema(): void 'uuid' => 'owned_review_uuid', 'public_id' => 'review_owned', ]); - Illuminate\Support\Facades\Storage::swap(new class { + $reviewStorage = new class { public function disk(string $disk): self { return $this; } - public function put(string $path, string $contents, string $visibility): bool + public array $writes = []; + + public function put(string $path, string $contents, mixed $options = []): bool { + $this->writes[] = [$path, $options]; + return true; } - }); + }; + Illuminate\Support\Facades\Storage::swap($reviewStorage); session(['storefront_key' => 'store_key']); $withPhoto = $controller->create( Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [ @@ -820,6 +825,9 @@ public function put(string $path, string $contents, string $visibility): bool ->and($photo->bucket)->toBe('review-bucket') ->and($photo->file_size)->toBe(strlen('image-bytes')) ->and($photo->type)->toBe('storefront_review_upload') + // review photos are written without a 'public' visibility/ACL + ->and($reviewStorage->writes)->toHaveCount(1) + ->and($reviewStorage->writes[0][1])->toBe([]) ->and($deleted->resource->uuid)->toBe('owned_review_uuid') ->and($connection->table('reviews')->where('uuid', 'owned_review_uuid')->value('deleted_at'))->not->toBeNull(); }); From b5a436eed07696f119c7ef2ca8c003e8ba8b6d1d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:50:41 +0800 Subject: [PATCH 02/50] feat(socket-auth): storefront customer and checkout socket tokens, channel resolvers - POST storefront/v1/customers/socket-token mints a customer principal (store key + Customer-Token); 404 while socket auth is disabled, 401 without a customer of the storefront's company. - Checkout initialization responses carry socket_token (checkout kind, scp limited to checkout.{public_id}) when socket auth is enabled; absent otherwise. - Register storefront and checkout channel resolvers with core-api's SocketChannelRegistry. - QPay capture publishes {checkout, status, order, error} on checkout.{public_id} instead of the raw payment row; a publish failure no longer fails the callback. - Require fleetbase/core-api ^1.6.69. --- composer.json | 2 +- .../Controllers/v1/CheckoutController.php | 83 ++++- .../Controllers/v1/CustomerController.php | 32 ++ .../Providers/StorefrontServiceProvider.php | 12 + server/src/Support/StorefrontSocket.php | 180 +++++++++ server/src/routes.php | 1 + .../CheckoutBoundaryContractsTest.php | 269 +++++++++++++- .../StorefrontServiceProviderTest.php | 17 + .../Unit/Routes/StorefrontRoutesTest.php | 1 + .../Unit/Support/StorefrontSocketTest.php | 350 ++++++++++++++++++ 10 files changed, 937 insertions(+), 10 deletions(-) create mode 100644 server/src/Support/StorefrontSocket.php create mode 100644 server/tests/Unit/Support/StorefrontSocketTest.php diff --git a/composer.json b/composer.json index 267085d0..f386e3db 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ ], "require": { "php": "^8.0", - "fleetbase/core-api": "*", + "fleetbase/core-api": "^1.6.69", "fleetbase/fleetops-api": "*", "geocoder-php/google-maps-places-provider": "^1.4", "laravel-notification-channels/apn": "^5.0", diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index df5aeddb..1aa9443c 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -32,6 +32,7 @@ use Fleetbase\Storefront\Promotions\PromotionUnavailableException; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; use Fleetbase\Storefront\Support\StripeUtils; use Fleetbase\Support\SocketCluster\SocketClusterService; use Illuminate\Http\JsonResponse; @@ -385,7 +386,7 @@ public static function initializeCashCheckout(Contact $customer, Gateway $gatewa // 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. // The checkout is discarded instead if one of its promotions ran out meanwhile. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'checkout' => $checkout->public_id, 'token' => $checkout->token, ]); @@ -486,7 +487,7 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate // 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 static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -718,7 +719,7 @@ public function updateStripePaymentIntent(Request $request) // Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is // the chkt_* public id GET /checkouts/status requires alongside the token. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -842,7 +843,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa // Update checkout with invoice id $checkout->updateOption('qpay_invoice_id', data_get($invoice, 'invoice_id')); - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'invoice' => $invoice, 'checkout' => $checkout->public_id, 'token' => $checkout->token, @@ -861,7 +862,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa * response for either success or error scenarios. * - Initializes a QPay instance with the gateway configuration and sets the authentication token. * - Retrieves the invoice ID from the checkout options and performs a payment check using QPay's API. - * - Publishes the payment data or error response to the SocketCluster channel. + * - Publishes the checkout status, its order and any error to the checkout's realtime channel. * * Depending on the 'respond' flag from the request, the method returns a JSON response * or completes the processing without returning data. @@ -925,7 +926,7 @@ public function captureQPayCallback(Request $request) ]; } - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, $testScenario === 'success', $data['error']); return $shouldRespond ? response()->json($data) : response()->json(); } @@ -978,7 +979,7 @@ public function captureQPayCallback(Request $request) 'error' => null, ]; - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, true); return $shouldRespond ? response()->json($data) : response()->json(); } @@ -2143,6 +2144,74 @@ protected static function promotionCodesFor(Cart $cart, Request $request): array return array_values(array_unique(array_filter(array_merge((array) $codes, $cart->getPromotionCodes()), 'is_string'))); } + /** + * The JSON response for an initialized checkout. + * + * When realtime socket authentication is enabled it carries `socket_token`: a + * `checkout` token whose scope is exactly this checkout's channel, so a client + * (a guest included) can listen for its own payment confirmation. The field is + * absent while socket authentication is disabled. + */ + protected static function checkoutResponse(Checkout $checkout, array $data): JsonResponse + { + $socketToken = StorefrontSocket::checkoutToken($checkout); + if ($socketToken) { + $data['socket_token'] = $socketToken; + } + + return response()->json($data); + } + + /** + * Publishes a checkout's progress on its realtime channel. + * + * The payload is what a storefront client acts on — the checkout, its status, the + * order once one exists (serialized exactly as GET checkouts/status returns it) and + * any error — never the raw gateway payment record. A publish failure is logged and + * swallowed: by now the payment is recorded, and clients still recover the outcome + * through GET checkouts/status. + * + * @return array|null the published payload, or null when publishing failed + */ + protected static function publishCheckoutUpdate(Checkout $checkout, bool $paid, ?array $error = null): ?array + { + try { + // A failed payment carries no order, so a client never completes on an error event. + $order = !$error && $checkout->order_uuid ? Order::where('uuid', $checkout->order_uuid)->first() : null; + $status = 'pending'; + if ($error) { + $status = 'failed'; + } elseif ($order) { + $status = 'completed'; + } elseif ($paid) { + $status = 'paid'; + } + + $data = [ + 'checkout' => $checkout->public_id, + 'status' => $status, + 'order' => $order ? static::checkoutChannelOrder($order) : null, + 'error' => $error, + ]; + + SocketClusterService::publish(StorefrontSocket::checkoutChannel($checkout), $data); + + return $data; + } catch (\Throwable $e) { + Log::warning('[CHECKOUT SOCKET PUBLISH FAILED]: ' . $e->getMessage(), ['checkout' => $checkout->public_id]); + + return null; + } + } + + /** + * Serializes a checkout's order for its realtime channel, as GET checkouts/status does. + */ + protected static function checkoutChannelOrder(Order $order): array + { + return json_decode(json_encode(new OrderResource($order)), true); + } + /** * Reserve a new checkout's promotions, discarding the checkout if one ran out meanwhile. */ diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 1aa827e4..8e47c447 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -22,6 +22,8 @@ use Fleetbase\Storefront\Http\Resources\Customer; use Fleetbase\Storefront\Push\StorefrontPushChannel; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketToken; use Fleetbase\Support\Utils; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -127,6 +129,36 @@ public function unregisterDevice(Request $request) ]); } + /** + * Mints a realtime socket token for the signed-in customer. + * + * POST storefront/v1/customers/socket-token — authenticated like every other + * customer endpoint: the storefront key plus a Customer-Token header. The token + * is a `customer` principal scoped to the store or network the key belongs to; + * the socket server only lets it subscribe to channels the customer owns. + * Returns 404 while socket authentication is not configured on this instance. + */ + public function socketToken(Request $request) + { + if (!SocketToken::enabled()) { + return response()->apiError('Not found.', 404); + } + + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + // A customer's token is only honoured by the storefront whose company the + // customer belongs to, so a token minted against another company's key is refused. + $storefront = Storefront::about(); + if (!$storefront || $storefront->company_uuid !== $customer->company_uuid) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + return response()->json(SocketToken::issue(StorefrontSocket::customerPrincipal($customer, $storefront))); + } + /** * Newer core-api versions add push metadata columns to user_devices. * diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 8fd668d9..79b25d48 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -4,6 +4,8 @@ use Fleetbase\FleetOps\Providers\FleetOpsServiceProvider; use Fleetbase\Providers\CoreServiceProvider; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketChannelRegistry; // These dependency guards are only reachable before Composer can load this provider. // The test runtime necessarily has both parent providers loaded, so the throw paths cannot execute. @@ -111,5 +113,15 @@ public function boot() $this->mergeConfigFrom(__DIR__ . '/../../config/database.connections.php', 'database.connections'); $this->mergeConfigFrom(__DIR__ . '/../../config/storefront.php', 'storefront'); $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'storefront.api'); + $this->registerStorefrontSocketChannels(); + } + + /** + * Registers the realtime channel prefixes storefront owns (`storefront`, `checkout`) + * with core-api's socket channel registry, so the socket server can authorize them. + */ + public function registerStorefrontSocketChannels(): void + { + StorefrontSocket::registerChannels($this->app->make(SocketChannelRegistry::class)); } } diff --git a/server/src/Support/StorefrontSocket.php b/server/src/Support/StorefrontSocket.php new file mode 100644 index 00000000..f8d1b66a --- /dev/null +++ b/server/src/Support/StorefrontSocket.php @@ -0,0 +1,180 @@ + 'customer', + 'sub' => $customer->uuid, + 'cid' => $storefront->company_uuid, + 'cpid' => static::companyPublicId($storefront->company_uuid), + 'env' => static::ENV, + 'ids' => [$customer->uuid, $customer->public_id], + 'adm' => false, + 'scp' => null, + 'sid' => $storefront->uuid, + ]); + } + + /** + * Builds the `checkout` principal: it may only ever subscribe to its own checkout channel. + */ + public static function checkoutPrincipal(Checkout $checkout): SocketPrincipal + { + return SocketPrincipal::fromClaims([ + 'kind' => 'checkout', + 'sub' => $checkout->uuid, + 'cid' => $checkout->company_uuid, + 'cpid' => static::companyPublicId($checkout->company_uuid), + 'env' => static::ENV, + 'ids' => [$checkout->uuid, $checkout->public_id], + 'adm' => false, + 'scp' => [static::checkoutChannel($checkout)], + 'sid' => $checkout->store_uuid ?? $checkout->network_uuid, + ]); + } + + /** + * Mints the socket token returned with an initialized checkout, or null while socket auth is disabled. + */ + public static function checkoutToken(Checkout $checkout): ?array + { + if (!SocketToken::enabled()) { + return null; + } + + return SocketToken::issue(static::checkoutPrincipal($checkout)); + } + + /** + * The channel checkout progress is published on. + */ + public static function checkoutChannel(Checkout $checkout): string + { + return 'checkout.' . $checkout->public_id; + } + + /** + * Registers storefront's channel resolvers with core-api's socket channel registry. + * + * @param \Fleetbase\Support\SocketCluster\SocketChannelRegistry $registry + */ + public static function registerChannels($registry): void + { + $registry->register('storefront', \Closure::fromCallable([static::class, 'authorizeStorefront'])); + $registry->register('checkout', \Closure::fromCallable([static::class, 'authorizeCheckout'])); + } + + /** + * `storefront.{id}` — id is a store or network uuid, public id or key. + * + * Console users and API credentials may subscribe to their own company's + * storefronts; a customer only to the storefront their token was minted for. + */ + public static function authorizeStorefront(SocketPrincipal $principal, string $id, string $channel): bool + { + $storefront = static::findStorefront($id); + + if (!$storefront || !$storefront->company_uuid || $storefront->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $principal->sid !== null && $storefront->uuid === $principal->sid; + } + + return false; + } + + /** + * `checkout.{id}` — id is a checkout uuid or public id. + * + * Console users and API credentials may subscribe to their own company's + * checkouts; a customer only to checkouts they own. A `checkout` principal never + * reaches here: its `scp` already limits it to its own channel. + */ + public static function authorizeCheckout(SocketPrincipal $principal, string $id, string $channel): bool + { + $checkout = Checkout::select(['uuid', 'public_id', 'company_uuid', 'owner_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + if (!$checkout || !$checkout->company_uuid || $checkout->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $checkout->owner_uuid !== null && $checkout->owner_uuid === $principal->sub; + } + + return false; + } + + /** + * Finds a store, then a network, by uuid, public id or key. + */ + protected static function findStorefront(string $id): Store|Network|null + { + foreach ([Store::class, Network::class] as $model) { + $storefront = $model::select(['uuid', 'company_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id)->orWhere('key', $id); + }) + ->first(); + + if ($storefront) { + return $storefront; + } + } + + return null; + } + + protected static function companyPublicId(?string $companyUuid): ?string + { + if (!$companyUuid) { + return null; + } + + return Company::where('uuid', $companyUuid)->value('public_id'); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7099a53f..5cec3919 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -135,6 +135,7 @@ function ($router) { $router->get('/', 'CustomerController@query'); $router->post('register-device', 'CustomerController@registerDevice'); $router->post('unregister-device', 'CustomerController@unregisterDevice'); + $router->post('socket-token', 'CustomerController@socketToken'); $router->get('places', 'CustomerController@places'); $router->get('orders', 'CustomerController@orders'); $router->get('{id}', 'CustomerController@find'); diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 9113ec35..7d02eee8 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -468,6 +468,34 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note } } +class CheckoutChannelPayloadStub extends TestableCheckoutController +{ + public static bool $failSerialization = false; + + protected static function checkoutChannelOrder(Fleetbase\FleetOps\Models\Order $order): array + { + if (static::$failSerialization) { + throw new RuntimeException('Order serialization failed'); + } + + return ['id' => $order->public_id]; + } + + public static function initializedCheckoutResponse(Checkout $checkout, array $data) + { + return static::checkoutResponse($checkout, $data); + } +} + +function enableCheckoutSocketAuth(bool $enabled = true): void +{ + config(['broadcasting.connections.socketcluster.auth_key' => $enabled ? 'checkout-socket-test-key-0123456789abcdef' : null]); +} + +afterEach(function () { + enableCheckoutSocketAuth(false); +}); + function createCheckoutBoundarySchema(): void { $connection = Model::getConnectionResolver()->connection('mysql'); @@ -2794,7 +2822,8 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode CheckoutQPayStub::$sandboxUsed = false; CheckoutQPayStub::$authenticated = false; Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; - $controller = new TestableCheckoutController(); + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); $missingInvoice = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ 'checkout' => 'checkout_abcdefgh', @@ -2857,7 +2886,243 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($sandboxError->getData(true)['error']['error'])->toBe('PAYMENT_NOT_PAID') ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue() ->and(CheckoutQPayStub::$authenticated)->toBeTrue() - ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toHaveCount(3); + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + // The realtime payload carries what a storefront client acts on — never the raw payment row. + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'failed', + 'order' => null, + 'error' => [ + 'error' => 'PAYMENT_NOT_PAID', + 'message' => 'Payment has not been paid!', + ], + ]], + ]); +}); + +test('qpay callback publishes paid before an order exists and survives a failed publish', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('gateways')->insert([ + 'uuid' => 'qpay_gateway_uuid', + 'code' => 'qpay', + 'owner_uuid' => 'store_uuid', + 'type' => 'qpay', + 'sandbox' => true, + 'callback_url' => 'https://storefront.test/qpay', + 'config' => json_encode(['username' => 'merchant', 'password' => 'secret']), + ]); + $connection->table('orders')->insert([ + 'uuid' => 'order_uuid', + 'public_id' => 'order_abcdefgh', + ]); + $connection->table('checkouts')->insert([ + [ + 'uuid' => 'checkout_pending_uuid', + 'public_id' => 'checkout_pending', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => null, + 'options' => '{}', + 'token' => 'checkout-token-pending', + ], + [ + 'uuid' => 'checkout_ordered_uuid', + 'public_id' => 'checkout_ordered', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'order_uuid', + 'options' => '{}', + 'token' => 'checkout-token-ordered', + ], + ]); + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); + + $paidWithoutOrder = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_pending', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = true; + $publishFailed = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_ordered', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = false; + + expect($paidWithoutOrder->getData(true)['payment']['payment_status'])->toBe('PAID') + // A failed publish never turns a recorded payment into an error response. + ->and($publishFailed->getStatusCode())->toBe(200) + ->and($publishFailed->getData(true)['payment']['payment_status'])->toBe('PAID') + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + ['checkout.checkout_pending', [ + 'checkout' => 'checkout_pending', + 'status' => 'paid', + 'order' => null, + 'error' => null, + ]], + ]); +}); + +test('qpay callback publishes the order serialized as checkout status returns it', function () { + createCheckoutCaptureExecutionSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('gateways')->insert([ + 'uuid' => 'qpay_gateway_uuid', + 'code' => 'qpay', + 'owner_uuid' => 'store_uuid', + 'type' => 'qpay', + 'sandbox' => true, + 'callback_url' => 'https://storefront.test/qpay', + 'config' => json_encode(['username' => 'merchant', 'password' => 'secret']), + ]); + $connection->table('orders')->insert([ + 'uuid' => 'status_order_uuid', + 'public_id' => 'order_status', + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_abcdefgh', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'status_order_uuid', + 'options' => json_encode(['qpay_invoice_id' => 'invoice_checkout']), + 'token' => 'checkout-token', + 'captured' => true, + ]); + session(['storefront_key' => null]); + CheckoutQPayStub::$failure = null; + CheckoutQPayStub::$paymentCheckResult = (object) [ + 'count' => 1, + 'rows' => [ + (object) [ + 'payment_id' => 'payment_checkout', + 'payment_status' => 'PAID', + 'payment_amount' => 2500, + 'payment_wallet' => 'QPay', + ], + ], + ]; + TestableCheckoutController::$statusFallbackOrder = null; + TestableCheckoutController::$statusFallbackFailure = null; + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + + (new TestableCheckoutController())->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_abcdefgh', + ])); + $published = Fleetbase\Support\SocketCluster\SocketClusterService::$published; + + expect($published)->toHaveCount(1) + ->and($published[0][0])->toBe('checkout.checkout_abcdefgh') + ->and(array_keys($published[0][1]))->toBe(['checkout', 'status', 'order', 'error']) + ->and($published[0][1]['status'])->toBe('completed') + ->and($published[0][1]['order']['id'])->toBe('order_status') + ->and($published[0][1]['error'])->toBeNull(); +}); + +test('initialized checkouts carry a checkout-scoped socket token only while socket auth is enabled', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('companies')->insert([ + 'uuid' => 'company_uuid', + 'public_id' => 'company_public', + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => 'store_uuid', + 'storefront_network' => null, + ]); + $initialize = function () { + $cart = new Cart(); + $cart->forceFill([ + 'uuid' => 'cart_uuid', + 'currency' => 'USD', + 'items' => [['id' => 'line_one', 'quantity' => 1, 'subtotal' => 1000]], + 'events' => [], + ]); + $customer = new Fleetbase\Storefront\Models\Customer(); + $customer->forceFill(['uuid' => 'customer_uuid']); + $gateway = Gateway::cash(); + $gateway->forceFill(['uuid' => 'gateway_uuid']); + + return CheckoutController::initializeCashCheckout( + $customer, + $gateway, + null, + $cart, + (object) ['is_pickup' => true, 'tip' => false, 'delivery_tip' => false], + Request::create('/checkout') + ); + }; + + $disabled = $initialize(); + enableCheckoutSocketAuth(); + $enabled = $initialize(); + $checkout = Checkout::where('public_id', $enabled->getData(true)['checkout'])->firstOrFail(); + $socket = $enabled->getData(true)['socket_token']; + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($socket['token']); + + expect(array_keys($disabled->getData(true)))->toBe(['checkout', 'token']) + ->and(array_keys($enabled->getData(true)))->toBe(['checkout', 'token', 'socket_token']) + ->and(array_keys($socket))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe($checkout->uuid) + ->and($claims->ids)->toBe([$checkout->uuid, $checkout->public_id]) + ->and($claims->scp)->toBe(['checkout.' . $checkout->public_id]) + ->and($claims->cid)->toBe('company_uuid') + ->and($claims->cpid)->toBe('company_public') + ->and($claims->sid)->toBe('store_uuid') + ->and($claims->env)->toBe('live'); +}); + +test('checkout response helper adds the socket token beside the existing fields', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_network', + 'company_uuid' => 'company_uuid', + 'network_uuid' => 'network_uuid', + 'token' => 'checkout-token', + ]); + $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail(); + enableCheckoutSocketAuth(); + + $data = CheckoutChannelPayloadStub::initializedCheckoutResponse($checkout, ['invoice' => ['invoice_id' => 'inv'], 'checkout' => 'checkout_network', 'token' => 'checkout-token'])->getData(true); + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($data['socket_token']['token']); + + expect($data['invoice'])->toBe(['invoice_id' => 'inv']) + ->and($claims->scp)->toBe(['checkout.checkout_network']) + ->and($claims->sid)->toBe('network_uuid') + ->and($claims->cpid)->toBeNull(); }); test('single and multiple order capture reject invalid checkout tokens safely', function () { diff --git a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php index d71c6aac..485d25c6 100644 --- a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php +++ b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php @@ -100,6 +100,11 @@ protected function mergeConfigFrom($path, $key) { $this->calls[] = $key; } + + public function registerStorefrontSocketChannels(): void + { + $this->calls[] = 'socket-channels'; + } }; $provider->boot(); @@ -119,5 +124,17 @@ protected function mergeConfigFrom($path, $key) 'database.connections', 'storefront', 'storefront.api', + 'socket-channels', ]); }); + +test('storefront provider registers its socket channel resolvers with the core registry', function () { + $app = new Fleetbase\TestSupport\ApplicationContainer(); + $registry = new Fleetbase\Support\SocketCluster\SocketChannelRegistry(); + $app->instance(Fleetbase\Support\SocketCluster\SocketChannelRegistry::class, $registry); + + (new StorefrontServiceProvider($app))->registerStorefrontSocketChannels(); + + expect($registry->resolve('storefront'))->toBeInstanceOf(Closure::class) + ->and($registry->resolve('checkout'))->toBeInstanceOf(Closure::class); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 8b8f4452..b6a0dc2f 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -92,6 +92,7 @@ private function record(string $method, string $uri, mixed $action): self ['GET', 'about', 'StoreController@about'], ['POST', '/', 'ProductController@create'], ['POST', 'receipt', 'OrderController@getReceipt'], + ['POST', 'socket-token', 'CustomerController@socketToken'], ['POST', 'send-push-notification', 'ActionController@sendPushNotification'], ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], diff --git a/server/tests/Unit/Support/StorefrontSocketTest.php b/server/tests/Unit/Support/StorefrontSocketTest.php new file mode 100644 index 00000000..aa7ec375 --- /dev/null +++ b/server/tests/Unit/Support/StorefrontSocketTest.php @@ -0,0 +1,350 @@ + $enabled ? 'storefront-socket-test-key-0123456789abcdef' : null]); +} + +function storefrontSocketSchema(): void +{ + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['companies', 'contacts', 'personal_access_tokens', 'stores', 'networks', 'checkouts'] as $table) { + $schema->dropIfExists($table); + } + + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $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->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_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'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + foreach (['stores', 'networks'] as $storefrontTable) { + $schema->create($storefrontTable, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('backdrop_uuid')->nullable(); + $table->string('logo_uuid')->nullable(); + $table->string('order_config_uuid')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->text('translations')->nullable(); + $table->string('website')->nullable(); + $table->string('facebook')->nullable(); + $table->string('instagram')->nullable(); + $table->string('twitter')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->text('tags')->nullable(); + $table->string('currency')->nullable(); + $table->string('timezone')->nullable(); + $table->string('pod_method')->nullable(); + $table->text('options')->nullable(); + $table->text('alertable')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $schema->create('checkouts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('store_uuid')->nullable(); + $table->string('network_uuid')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('owner_type')->nullable(); + $table->text('options')->nullable(); + $table->string('token')->nullable(); + $table->string('order_uuid')->nullable(); + $table->boolean('captured')->default(false); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $connection->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_aaaaaaa'], + ['uuid' => 'company-b', 'public_id' => 'company_bbbbbbb'], + ]); + $connection->table('stores')->insert([ + ['uuid' => 'store-a', 'public_id' => 'store_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'store_key_a'], + ['uuid' => 'store-a2', 'public_id' => 'store_aaaaaa2', 'company_uuid' => 'company-a', 'key' => 'store_key_a2'], + ['uuid' => 'store-b', 'public_id' => 'store_bbbbbbb', 'company_uuid' => 'company-b', 'key' => 'store_key_b'], + ['uuid' => 'store-orphan', 'public_id' => 'store_orphan1', 'company_uuid' => null, 'key' => 'store_key_orphan'], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network-a', 'public_id' => 'network_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'network_key_a'], + ]); + $connection->table('contacts')->insert([ + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'type' => 'customer'], + ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'type' => 'customer'], + ]); + $connection->table('checkouts')->insert([ + ['uuid' => 'checkout-a', 'public_id' => 'chkt_aaaaaaa', 'company_uuid' => 'company-a', 'store_uuid' => 'store-a', 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_a'], + ['uuid' => 'checkout-guest', 'public_id' => 'chkt_guest01', 'company_uuid' => 'company-a', 'store_uuid' => null, 'network_uuid' => 'network-a', 'owner_uuid' => null, 'token' => 'checkout_g'], + ['uuid' => 'checkout-b', 'public_id' => 'chkt_bbbbbbb', 'company_uuid' => 'company-b', 'store_uuid' => 'store-b', 'network_uuid' => null, 'owner_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'token' => 'checkout_b'], + ['uuid' => 'checkout-orphan', 'public_id' => 'chkt_orphan1', 'company_uuid' => null, 'store_uuid' => null, 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_o'], + ]); + + foreach (['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' => 'customer-secret-a', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' => 'customer-secret-b'] as $contact => $secret) { + $connection->table('personal_access_tokens')->insert([ + 'name' => $contact, + 'token' => hash('sha256', $secret), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} + +function storefrontSocketTokenRequest(?string $storefrontKey, ?string $customerToken): Request +{ + $request = Request::create('/storefront/v1/customers/socket-token', 'POST'); + $request->setLaravelSession(new SessionStore('storefront-socket-test', new ArraySessionHandler(120))); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + app()->instance('request', $request); + session(['storefront_key' => $storefrontKey]); + + return $request; +} + +function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrincipal +{ + return SocketPrincipal::fromClaims(array_merge(['kind' => $kind, 'sub' => $kind . '-subject', 'cid' => 'company-a'], $claims)); +} + +beforeEach(function () { + storefrontSocketSchema(); + storefrontSocketEnable(false); +}); + +afterEach(function () { + storefrontSocketEnable(false); + session(['storefront_key' => null]); +}); + +test('customer socket token is not found while socket auth is disabled', function () { + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true))->toBe(['error' => 'Not found.']); +}); + +test('customer socket token requires an authenticated customer of the storefront company', function () { + storefrontSocketEnable(); + $controller = new CustomerController(); + + $noCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', null)); + $unknownCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', 'not-a-real-token')); + $noStorefront = $controller->socketToken(storefrontSocketTokenRequest(null, 'customer-secret-a')); + $otherCompany = $controller->socketToken(storefrontSocketTokenRequest('store_key_b', 'customer-secret-a')); + + foreach ([$noCustomer, $unknownCustomer, $noStorefront, $otherCompany] as $response) { + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'Not authorized to create a socket token for customer.']); + } +}); + +test('customer socket token mints a customer principal scoped to the store', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + $body = $response->getData(true); + $claims = SocketToken::verify($body['token']); + + expect($response->getStatusCode())->toBe(200) + ->and(array_keys($body))->toBe(['token', 'expires_in', 'expires_at']) + ->and($body['expires_in'])->toBe(900) + ->and($claims)->toBeInstanceOf(SocketPrincipal::class) + ->and($claims->kind)->toBe('customer') + ->and($claims->sub)->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa']) + ->and($claims->cid)->toBe('company-a') + ->and($claims->cpid)->toBe('company_aaaaaaa') + ->and($claims->sid)->toBe('store-a') + ->and($claims->env)->toBe('live') + ->and($claims->adm)->toBeFalse() + ->and($claims->scp)->toBeNull(); +}); + +test('customer socket token minted with a network key is scoped to the network', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('network_key_a', 'customer-secret-a')); + $claims = SocketToken::verify($response->getData(true)['token']); + + expect($claims->kind)->toBe('customer') + ->and($claims->sid)->toBe('network-a') + ->and($claims->cid)->toBe('company-a'); +}); + +test('checkout principal is limited by scope to its own checkout channel', function () { + $storeCheckout = Checkout::where('uuid', 'checkout-a')->firstOrFail(); + $networkCheckout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + $orphanCheckout = Checkout::where('uuid', 'checkout-orphan')->firstOrFail(); + + $store = StorefrontSocket::checkoutPrincipal($storeCheckout); + $network = StorefrontSocket::checkoutPrincipal($networkCheckout); + $orphan = StorefrontSocket::checkoutPrincipal($orphanCheckout); + + expect($store->kind)->toBe('checkout') + ->and($store->sub)->toBe('checkout-a') + ->and($store->ids)->toBe(['checkout-a', 'chkt_aaaaaaa']) + ->and($store->cid)->toBe('company-a') + ->and($store->cpid)->toBe('company_aaaaaaa') + ->and($store->scp)->toBe(['checkout.chkt_aaaaaaa']) + ->and($store->sid)->toBe('store-a') + ->and($store->env)->toBe('live') + ->and($network->sid)->toBe('network-a') + ->and($network->scp)->toBe(['checkout.chkt_guest01']) + ->and($orphan->cid)->toBeNull() + ->and($orphan->cpid)->toBeNull() + ->and(StorefrontSocket::checkoutChannel($storeCheckout))->toBe('checkout.chkt_aaaaaaa'); +}); + +test('checkout socket token is only minted while socket auth is enabled', function () { + $checkout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + + $disabled = StorefrontSocket::checkoutToken($checkout); + storefrontSocketEnable(); + $enabled = StorefrontSocket::checkoutToken($checkout); + $claims = SocketToken::verify($enabled['token']); + + expect($disabled)->toBeNull() + ->and(array_keys($enabled))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe('checkout-guest') + ->and($claims->scp)->toBe(['checkout.chkt_guest01']) + ->and($claims->cid)->toBe('company-a'); +}); + +test('storefront channels resolve stores and networks by uuid public id or key within the company', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + + expect(StorefrontSocket::authorizeStorefront($user, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_key_a', 'storefront.store_key_a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($api, 'network_aaaaaaa', 'storefront.network_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_bbbbbbb', 'storefront.store_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_orphan1', 'storefront.store_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_missing', 'storefront.store_missing'))->toBeFalse(); +}); + +test('storefront channels let a customer subscribe only to the storefront their token names', function () { + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $networkMember = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'network-a']); + $unscoped = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'cid' => 'company-b', 'sid' => 'store-a']); + $driver = storefrontSocketPrincipal('driver', ['sid' => 'store-a']); + + expect(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaa2', 'storefront.store_aaaaaa2'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'network-a', 'storefront.network-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($unscoped, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($foreign, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($driver, 'store-a', 'storefront.store-a'))->toBeFalse(); +}); + +test('checkout channels allow the company and the owning customer only', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + $owner = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $stranger = storefrontSocketPrincipal('customer', ['sub' => 'contact-other', 'sid' => 'store-a']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'cid' => 'company-b', 'sid' => 'store-b']); + $checkout = storefrontSocketPrincipal('checkout', ['sub' => 'checkout-a', 'scp' => ['checkout.chkt_aaaaaaa']]); + $driver = storefrontSocketPrincipal('driver'); + + expect(StorefrontSocket::authorizeCheckout($user, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($api, 'checkout-a', 'checkout.checkout-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_orphan1', 'checkout.chkt_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_missing', 'checkout.chkt_missing'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_guest01', 'checkout.chkt_guest01'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($stranger, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($foreign, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($checkout, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($driver, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse(); +}); + +test('storefront registers its storefront and checkout channel resolvers', function () { + $registry = new SocketChannelRegistry(); + + StorefrontSocket::registerChannels($registry); + + $storefront = $registry->resolve('storefront'); + $checkout = $registry->resolve('checkout'); + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + + expect($storefront)->toBeInstanceOf(Closure::class) + ->and($checkout)->toBeInstanceOf(Closure::class) + ->and($storefront($customer, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and($checkout($customer, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and($checkout($customer, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse(); +}); + +test('customer principal is built from the customer and the storefront of the key', function () { + $customer = Contact::where('uuid', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')->firstOrFail(); + $store = Store::where('uuid', 'store-a')->firstOrFail(); + $network = Network::where('uuid', 'network-a')->firstOrFail(); + + $forStore = StorefrontSocket::customerPrincipal($customer, $store); + $forNetwork = StorefrontSocket::customerPrincipal($customer, $network); + + expect($forStore->toClaims())->toBe([ + 'kind' => 'customer', + 'sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'cid' => 'company-a', + 'cpid' => 'company_aaaaaaa', + 'env' => 'live', + 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa'], + 'adm' => false, + 'sid' => 'store-a', + ])->and($forNetwork->sid)->toBe('network-a'); +}); From a38e23428cd759371f598344a68e0e65c10dcf1f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 16:57:41 +0800 Subject: [PATCH 03/50] perf: speed up network and console store listings Listing a network's stores ran several queries per store and loaded the whole network for distance sorting, so a page of stores could take minutes. - Store resource: build category, networks, locations and media only when the field is requested (when() evaluated its value argument for every store) - Preload the review average with withAvg() on the public and console store queries; the rating accessor uses it instead of one AVG query per store - Console store list eager-loads logo and backdrop, and networks when the network category is requested - Network category lookup reuses eager-loaded networks and resolves each network id once instead of once per store - Ignore missing or unparseable locations instead of measuring from (0, 0): no in-memory sort of every store, and maximum_distance no longer filters out every store when no location is sent - Index reviews.subject_uuid --- ...dd_subject_uuid_index_to_reviews_table.php | 47 ++++++++++++ .../src/Http/Controllers/StoreController.php | 14 ++++ .../Http/Controllers/v1/NetworkController.php | 29 ++++++- server/src/Http/Resources/Store.php | 9 ++- server/src/Models/Store.php | 41 ++++++++-- .../NetworkApiControllerContractsTest.php | 75 +++++++++++++++++++ .../Controllers/StoreQueryContractsTest.php | 22 ++++++ .../tests/Unit/Models/StoreBehaviorTest.php | 62 +++++++++++++++ 8 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php create mode 100644 server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php diff --git a/server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php b/server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php new file mode 100644 index 00000000..3be53b54 --- /dev/null +++ b/server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php @@ -0,0 +1,47 @@ +indexExists('reviews', 'reviews_subject_uuid_index')) { + return; + } + + Schema::connection(config('storefront.connection.db'))->table('reviews', function (Blueprint $table) { + $table->index('subject_uuid'); + }); + } + + public function down(): void + { + if (!$this->indexExists('reviews', 'reviews_subject_uuid_index')) { + return; + } + + Schema::connection(config('storefront.connection.db'))->table('reviews', function (Blueprint $table) { + $table->dropIndex(['subject_uuid']); + }); + } + + protected function indexExists(string $table, string $index): bool + { + try { + $indexes = Schema::connection(config('storefront.connection.db')) + ->getConnection() + ->getDoctrineSchemaManager() + ->listTableIndexes($table); + + return isset($indexes[$index]); + } catch (Throwable $e) { + return false; + } + } +}; diff --git a/server/src/Http/Controllers/StoreController.php b/server/src/Http/Controllers/StoreController.php index 0b67731e..91c65728 100644 --- a/server/src/Http/Controllers/StoreController.php +++ b/server/src/Http/Controllers/StoreController.php @@ -3,6 +3,7 @@ namespace Fleetbase\Storefront\Http\Controllers; use Fleetbase\Storefront\Models\Store; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; class StoreController extends StorefrontController @@ -14,6 +15,19 @@ class StoreController extends StorefrontController */ public $resource = 'store'; + /** + * Eager-load what the store resource always serializes, so listing stores + * does not run logo, backdrop and rating queries for every store. + */ + public function onQueryRecord(Builder $builder, Request $request) + { + $builder->with(['logo', 'backdrop'])->withAvg('reviews', 'rating'); + + if ($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category'))) { + $builder->with('networks'); + } + } + public function allStores(Request $request) { $stores = Store::select(['uuid', 'name', 'description', 'created_at']) diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php index d6514f73..8db88e6e 100644 --- a/server/src/Http/Controllers/v1/NetworkController.php +++ b/server/src/Http/Controllers/v1/NetworkController.php @@ -14,6 +14,28 @@ class NetworkController extends Controller { + /** + * The point to measure store distances from, or null when the request has no usable location. + * + * The coordinate parser falls back to (0, 0) for missing or invalid input. Treating that as a + * location made every store's distance be measured from the Gulf of Guinea, filtered out every + * store when maximum_distance was set, and loaded the whole network for in-memory sorting. + */ + protected static function resolveReferencePoint($location): ?Point + { + if (blank($location)) { + return null; + } + + $point = Utils::getPointFromCoordinates($location); + + if ((float) $point->getLat() === 0.0 && (float) $point->getLng() === 0.0) { + return null; + } + + return $point; + } + /** * Returns all stores within the network. * @@ -34,7 +56,7 @@ public function stores(Request $request) $location = $request->input('location'); $maxDistance = $request->input('maximum_distance', null); $exclude = $request->input('exclude', []); - $coordinates = Utils::getPointFromCoordinates($location); + $coordinates = static::resolveReferencePoint($location); $requiresDistancePostProcessing = $coordinates instanceof Point && ($sort === 'nearest' || is_numeric($maxDistance)); if (is_string($tagged)) { @@ -52,6 +74,7 @@ public function stores(Request $request) /** @var \Illuminate\Database\Query\Builder $query */ $query = Store::select('*') ->with(['logo', 'backdrop', 'media', 'locations.place']) + ->withAvg('reviews', 'rating') ->whereHas('locations') ->whereHas('networks', function ($q) use ($request) { $q->where('network_uuid', session('storefront_network')); @@ -114,10 +137,10 @@ public function stores(Request $request) switch ($sort) { case 'highest_rated': - $query->withAvg('reviews', 'rating')->orderByDesc('reviews_avg_rating'); + $query->orderByDesc('reviews_avg_rating'); break; case 'lowest_rated': - $query->withAvg('reviews', 'rating')->orderBy('reviews_avg_rating'); + $query->orderBy('reviews_avg_rating'); break; case 'newest': $query->orderByDesc('created_at'); diff --git a/server/src/Http/Resources/Store.php b/server/src/Http/Resources/Store.php index f515a413..8faac538 100644 --- a/server/src/Http/Resources/Store.php +++ b/server/src/Http/Resources/Store.php @@ -50,10 +50,11 @@ public function toArray($request) 'alertable' => $this->alertable, 'is_network' => false, 'is_store' => true, - 'category' => $this->when($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category')), new Category($this->getNetworkCategoryUsingId($request->input('network')))), - 'networks' => $this->when($request->boolean('with_networks') || $request->inArray('with', 'networks'), Network::collection($this->networks)), - 'locations' => $this->when($request->boolean('with_locations') || $request->inArray('with', 'locations'), $this->locations->mapInto(StoreLocation::class)), - 'media' => $this->when($request->boolean('with_media') || $request->inArray('with', 'media'), Media::collection($this->media)), + // Closures, so relations and lookups only run when the field is requested. + 'category' => $this->when($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category')), fn () => new Category($this->getNetworkCategoryUsingId($request->input('network')))), + 'networks' => $this->when($request->boolean('with_networks') || $request->inArray('with', 'networks'), fn () => Network::collection($this->networks)), + 'locations' => $this->when($request->boolean('with_locations') || $request->inArray('with', 'locations'), fn () => $this->locations->mapInto(StoreLocation::class)), + 'media' => $this->when($request->boolean('with_media') || $request->inArray('with', 'media'), fn () => Media::collection($this->media)), 'slug' => $this->slug, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, diff --git a/server/src/Models/Store.php b/server/src/Models/Store.php index 4c5a3c2d..1dce9319 100644 --- a/server/src/Models/Store.php +++ b/server/src/Models/Store.php @@ -340,6 +340,14 @@ public function getBackdropUrlAttribute() */ public function getRatingAttribute() { + // Use the average preloaded with withAvg('reviews', 'rating') when present, so + // listing stores does not run one aggregate query per store. + if (array_key_exists('reviews_avg_rating', $this->attributes)) { + $average = $this->attributes['reviews_avg_rating']; + + return is_numeric($average) ? $average + 0 : 0; + } + return $this->reviews()->avg('rating') ?? 0; } @@ -356,13 +364,33 @@ public function getNetworkCategoryUsingId(?string $id) return null; } - $network = Network::where('uuid', $id)->orWhere('public_id', $id)->first(); + // A network's uuid never changes, so resolve each id once instead of once per store. + if (!array_key_exists($id, static::$networkUuidsById)) { + static::$networkUuidsById[$id] = Network::where('uuid', $id)->orWhere('public_id', $id)->value('uuid'); + } - if (!$network instanceof Network) { + $networkUuid = static::$networkUuidsById[$id]; + + if (!$networkUuid) { return null; } - return $this->getNetworkCategory($network); + return $this->getNetworkCategory((new Network())->forceFill(['uuid' => $networkUuid])); + } + + /** + * Network uuids resolved by uuid or public id. + * + * @var array + */ + protected static array $networkUuidsById = []; + + /** + * Forget network ids resolved by getNetworkCategoryUsingId(). + */ + public static function flushResolvedNetworkIds(): void + { + static::$networkUuidsById = []; } /** @@ -374,8 +402,11 @@ public function getNetworkCategoryUsingId(?string $id) */ public function getNetworkCategory(Network $network) { - // Find the relationship between this store and the given network - $networkRelation = $this->networks()->where('networks.uuid', $network->uuid)->first(); + // Find the relationship between this store and the given network, using the + // eager-loaded networks when available. + $networkRelation = $this->relationLoaded('networks') + ? $this->networks->firstWhere('uuid', $network->uuid) + : $this->networks()->where('networks.uuid', $network->uuid)->first(); // Check if the relationship exists if ($networkRelation) { diff --git a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php index 60591b7d..c147c9c4 100644 --- a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php @@ -613,3 +613,78 @@ function createNetworkApiControllerSchema(): void ->and($missingLocations->getStatusCode())->toBe(400) ->and($missingTags->getStatusCode())->toBe(400); }); + +test('network stores endpoint ignores distance filters without a usable location', function () { + createNetworkApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('stores')->insert([ + ['uuid' => 'store_one_uuid', 'public_id' => 'store_one', 'company_uuid' => 'company_uuid', 'name' => 'One', 'created_at' => '2026-01-01 00:00:00'], + ['uuid' => 'store_two_uuid', 'public_id' => 'store_two', 'company_uuid' => 'company_uuid', 'name' => 'Two', 'created_at' => '2026-02-01 00:00:00'], + ]); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_one_uuid'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_two_uuid'], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'location_one_uuid', 'store_uuid' => 'store_one_uuid'], + ['uuid' => 'location_two_uuid', 'store_uuid' => 'store_two_uuid'], + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + $controller = new NetworkController(); + + // Without a location there is nothing to measure from, so the distance filter no longer + // removes every store and limit/offset stay in SQL. + $withoutLocation = $controller->stores(Request::create('/network/stores', 'GET', [ + 'sort' => 'oldest', + 'maximum_distance' => 1000, + 'limit' => 1, + 'offset' => 1, + ])); + // The coordinate parser's (0, 0) fallback is treated the same as no location. + $nullIsland = $controller->stores(Request::create('/network/stores', 'GET', [ + 'sort' => 'nearest', + 'location' => 'not-a-location', + ])); + + expect($withoutLocation->resource->pluck('uuid')->all())->toBe(['store_two_uuid']) + ->and($nullIsland->resource->pluck('uuid')->sort()->values()->all())->toBe(['store_one_uuid', 'store_two_uuid']) + ->and($nullIsland->resource->first()->distance)->toBeNull(); +}); + +test('network stores endpoint preloads each store rating in the store query', function () { + createNetworkApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('stores')->insert([ + ['uuid' => 'store_rated_uuid', 'public_id' => 'store_rated', 'company_uuid' => 'company_uuid', 'name' => 'Rated'], + ['uuid' => 'store_new_uuid', 'public_id' => 'store_new', 'company_uuid' => 'company_uuid', 'name' => 'New'], + ]); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_rated_uuid'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_new_uuid'], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'location_rated_uuid', 'store_uuid' => 'store_rated_uuid'], + ['uuid' => 'location_new_uuid', 'store_uuid' => 'store_new_uuid'], + ]); + $connection->table('reviews')->insert([ + ['uuid' => 'review_a', 'subject_uuid' => 'store_rated_uuid', 'rating' => 5], + ['uuid' => 'review_b', 'subject_uuid' => 'store_rated_uuid', 'rating' => 4], + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + + $stores = (new NetworkController())->stores(Request::create('/network/stores', 'GET'))->resource->keyBy('uuid'); + + expect(array_key_exists('reviews_avg_rating', $stores['store_rated_uuid']->getAttributes()))->toBeTrue() + ->and($stores['store_rated_uuid']->rating)->toBe(4.5) + ->and($stores['store_new_uuid']->rating)->toBe(0); +}); diff --git a/server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php b/server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php new file mode 100644 index 00000000..92da6136 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php @@ -0,0 +1,22 @@ +onQueryRecord($plain, Request::create('/int/v1/stores', 'GET')); + + $withCategory = Store::query(); + $controller->onQueryRecord($withCategory, Request::create('/int/v1/stores', 'GET', [ + 'network' => 'network_uuid', + 'with_category' => 1, + ])); + + expect(array_keys($plain->getEagerLoads()))->toBe(['logo', 'backdrop']) + ->and($plain->toSql())->toContain('reviews_avg_rating') + ->and(array_keys($withCategory->getEagerLoads()))->toBe(['logo', 'backdrop', 'networks']); +}); diff --git a/server/tests/Unit/Models/StoreBehaviorTest.php b/server/tests/Unit/Models/StoreBehaviorTest.php index b8e473a8..d329f1b9 100644 --- a/server/tests/Unit/Models/StoreBehaviorTest.php +++ b/server/tests/Unit/Models/StoreBehaviorTest.php @@ -423,3 +423,65 @@ function createStoreBehaviorSchema(): void expect(fn () => $missing->getOrderConfig()) ->toThrow(RuntimeException::class, 'No default OrderConfig is configured.'); }); + +test('store rating uses a preloaded review average without querying reviews', function () { + // No reviews table: any aggregate query would throw. + Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder()->dropIfExists('reviews'); + + $decimal = new Store(); + $decimal->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => '4.5000']); + $whole = new Store(); + $whole->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => 4]); + $unrated = new Store(); + $unrated->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => null]); + + expect($decimal->rating)->toBe(4.5) + ->and($whole->rating)->toBe(4) + ->and($unrated->rating)->toBe(0); +}); + +test('store network category uses eager loaded networks', function () { + createStoreBehaviorSchema(); + $connection = Capsule::connection('mysql'); + $connection->table('categories')->insert(['uuid' => 'category_uuid', 'name' => 'Electronics']); + + $member = new Network(); + $member->forceFill(['uuid' => 'network_uuid']); + $member->setRelation('pivot', (object) ['category_uuid' => 'category_uuid']); + $other = new Network(); + $other->forceFill(['uuid' => 'other_network_uuid']); + + $store = new Store(); + $store->forceFill(['uuid' => 'store_uuid']); + $store->setRelation('networks', new Illuminate\Database\Eloquent\Collection([$member])); + + // network_stores is empty, so a match can only come from the loaded relation. + expect($store->getNetworkCategory($member)?->uuid)->toBe('category_uuid') + ->and($store->getNetworkCategory($other))->toBeNull(); +}); + +test('store resolves each network id once', function () { + createStoreBehaviorSchema(); + Store::flushResolvedNetworkIds(); + $connection = Capsule::connection('mysql'); + $connection->table('stores')->insert(['uuid' => 'store_uuid', 'name' => 'Store']); + $connection->table('networks')->insert(['uuid' => 'network_uuid', 'public_id' => 'network_public', 'name' => 'Network']); + $connection->table('categories')->insert(['uuid' => 'category_uuid', 'name' => 'Flowers']); + $connection->table('network_stores')->insert([ + 'network_uuid' => 'network_uuid', + 'store_uuid' => 'store_uuid', + 'category_uuid' => 'category_uuid', + ]); + $store = Store::where('uuid', 'store_uuid')->firstOrFail(); + + expect($store->getNetworkCategoryUsingId('network_public')?->uuid)->toBe('category_uuid'); + + // The id is not looked up again: it still resolves after the public id changes. + $connection->table('networks')->update(['public_id' => 'network_renamed']); + expect($store->getNetworkCategoryUsingId('network_public')?->uuid)->toBe('category_uuid'); + + Store::flushResolvedNetworkIds(); + expect($store->getNetworkCategoryUsingId('network_public'))->toBeNull(); + + Store::flushResolvedNetworkIds(); +}); From 6b02d96cde298477a07bf4760999a5bd3938b0b4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 19:48:17 +0800 Subject: [PATCH 04/50] feat(reviews): verified-purchase reviews, eligibility endpoint and own-review delete - Customers can review a store or product only for a completed order placed with that store or containing that product, once per order per subject. Reviews record the order (new reviews.order_uuid). - GET reviews/eligibility tells the app whether the signed-in customer can review a subject, and why not. - DELETE reviews/{id} now routes to delete() instead of find(), so customers can remove their own reviews. - Ratings must be whole stars from 1 to 5; content is limited to 2000 characters and uploads to four image or video files. - The review resource names the subject by public id instead of the internal row id, and adds subject_type, verified and is_mine. --- ...000000_add_order_uuid_to_reviews_table.php | 41 +++ .../Http/Controllers/v1/ReviewController.php | 30 ++ .../src/Http/Requests/CreateReviewRequest.php | 13 +- server/src/Http/Resources/Review.php | 44 ++- server/src/Models/Review.php | 14 +- server/src/Support/ReviewEligibility.php | 136 +++++++++ server/src/routes.php | 3 +- .../ReviewAndOrderControllerContractsTest.php | 56 +++- .../Http/Requests/RequestContractsTest.php | 15 +- .../Resources/SimpleResourceContractsTest.php | 22 +- .../Unit/Routes/StorefrontRoutesTest.php | 4 +- .../Unit/Support/ReviewEligibilityTest.php | 289 ++++++++++++++++++ 12 files changed, 633 insertions(+), 34 deletions(-) create mode 100644 server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php create mode 100644 server/src/Support/ReviewEligibility.php create mode 100644 server/tests/Unit/Support/ReviewEligibilityTest.php diff --git a/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php b/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php new file mode 100644 index 00000000..6306a00f --- /dev/null +++ b/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php @@ -0,0 +1,41 @@ +hasColumn('reviews', 'order_uuid')) { + return; + } + + Schema::connection($connection)->table('reviews', function (Blueprint $table) { + $table->char('order_uuid', 36)->nullable()->after('customer_uuid'); + $table->index(['customer_uuid', 'subject_uuid', 'order_uuid'], 'reviews_customer_subject_order_index'); + }); + } + + public function down(): void + { + $connection = config('storefront.connection.db'); + + if (!Schema::connection($connection)->hasColumn('reviews', 'order_uuid')) { + return; + } + + Schema::connection($connection)->table('reviews', function (Blueprint $table) { + $table->dropIndex('reviews_customer_subject_order_index'); + $table->dropColumn('order_uuid'); + }); + } +}; diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index dd51ba78..8c242db8 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -11,6 +11,7 @@ use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; +use Fleetbase\Storefront\Support\ReviewEligibility; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -216,6 +217,29 @@ public function find($id) return new StorefrontReview($review); } + /** + * Whether the signed-in customer can review a store or product, and why not. + * + * The app calls this before showing "Write a review", so a customer who has not completed + * an order, or has already reviewed every completed one, sees an explanation instead. + */ + public function eligibility(Request $request) + { + $subjectId = $request->input('subject'); + if (!is_string($subjectId) || $subjectId === '') { + return response()->error('A subject is required.'); + } + + $subject = Utils::resolveSubject($subjectId); + if (!$subject || !$this->subjectBelongsToContext($subject)) { + return response()->error('Invalid subject for review'); + } + + $eligibility = ReviewEligibility::check(Storefront::getCustomerFromToken(), $subject, $request->input('order')); + + return response()->json($eligibility->toArray()); + } + /** * Create a review. * @@ -238,9 +262,15 @@ public function create(CreateReviewRequest $request) return response()->error('Invalid subject for review'); } + $eligibility = ReviewEligibility::check($customer, $subject, $request->input('order')); + if (!$eligibility->allowed) { + return response()->json(['error' => $eligibility->message(), 'reason' => $eligibility->reason], 403); + } + $review = Review::create([ 'created_by_uuid' => $customer->user_uuid, 'customer_uuid' => $customer->uuid, + 'order_uuid' => $eligibility->order->uuid, 'subject_uuid' => $subject->uuid, 'subject_type' => Utils::getMutationType($subject), 'rating' => $request->input('rating'), diff --git a/server/src/Http/Requests/CreateReviewRequest.php b/server/src/Http/Requests/CreateReviewRequest.php index 470ab7bf..596a09c7 100644 --- a/server/src/Http/Requests/CreateReviewRequest.php +++ b/server/src/Http/Requests/CreateReviewRequest.php @@ -31,11 +31,14 @@ public function rules() // — 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', - 'rejected' => 'sometimes|boolean', + 'subject' => 'required|string', + 'order' => 'sometimes|nullable|string', + 'rating' => 'required|integer|between:1,5', + 'content' => 'required|string|max:2000', + 'files' => 'sometimes|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/,video/', + 'rejected' => 'sometimes|boolean', ]; } } diff --git a/server/src/Http/Resources/Review.php b/server/src/Http/Resources/Review.php index 240b212b..734e50b6 100644 --- a/server/src/Http/Resources/Review.php +++ b/server/src/Http/Resources/Review.php @@ -3,6 +3,8 @@ namespace Fleetbase\Storefront\Http\Resources; use Fleetbase\Http\Resources\FleetbaseResource; +use Fleetbase\Storefront\Models\Product; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Http; class Review extends FleetbaseResource @@ -17,17 +19,21 @@ class Review extends FleetbaseResource public function toArray($request) { return [ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'subject_id' => $this->subject->id, - 'rating' => $this->rating, - 'content' => $this->content, - 'customer' => new ReviewCustomer($this->customer), - 'slug' => $this->slug, - 'photos' => $this->mapPhotos($this->photos), - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'subject_id' => $this->when(Http::isInternalRequest(), $this->subject?->id, $this->subject?->public_id), + 'subject_type' => $this->subject instanceof Product ? 'product' : 'store', + 'order_uuid' => $this->when(Http::isInternalRequest(), $this->order_uuid), + 'verified' => !empty($this->order_uuid), + 'is_mine' => $this->customer_uuid !== null && $this->customer_uuid === static::viewerCustomerUuid(), + 'rating' => $this->rating, + 'content' => $this->content, + 'customer' => new ReviewCustomer($this->customer), + 'slug' => $this->slug, + 'photos' => $this->mapPhotos($this->photos), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ]; } @@ -43,4 +49,20 @@ public function mapPhotos($photos = []) ]; }); } + + /** + * The uuid of the customer making the request, looked up once per request so that a + * page of reviews does not resolve the customer token for every review. + */ + public static function viewerCustomerUuid(): ?string + { + $request = request(); + $key = 'storefront.review_viewer_uuid'; + + if (!$request->attributes->has($key)) { + $request->attributes->set($key, Http::isInternalRequest() ? null : Storefront::getCustomerFromToken()?->uuid); + } + + return $request->attributes->get($key); + } } diff --git a/server/src/Models/Review.php b/server/src/Models/Review.php index 178efbd9..7ec470cd 100644 --- a/server/src/Models/Review.php +++ b/server/src/Models/Review.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Models; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\Models\File; use Fleetbase\Models\User; use Fleetbase\Traits\HasApiModelBehavior; @@ -45,6 +46,7 @@ class Review extends StorefrontModel protected $fillable = [ 'created_by_uuid', 'customer_uuid', + 'order_uuid', 'subject_uuid', 'subject_type', 'rating', @@ -57,7 +59,9 @@ class Review extends StorefrontModel * * @var array */ - protected $casts = []; + protected $casts = [ + 'rating' => 'integer', + ]; /** * Dynamic attributes that are appended to object. @@ -89,6 +93,14 @@ public function customer() return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(Customer::class); } + /** + * The completed order this review was written for. + */ + public function order() + { + return $this->belongsTo(Order::class, 'order_uuid', 'uuid'); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ diff --git a/server/src/Support/ReviewEligibility.php b/server/src/Support/ReviewEligibility.php new file mode 100644 index 00000000..7c7fd966 --- /dev/null +++ b/server/src/Support/ReviewEligibility.php @@ -0,0 +1,136 @@ + + */ + public const MESSAGES = [ + self::SIGN_IN_REQUIRED => 'Sign in to write a review.', + self::UNSUPPORTED_SUBJECT => 'Only stores and products can be reviewed.', + self::NO_COMPLETED_ORDER => 'You can review this after an order with it is completed.', + self::ALREADY_REVIEWED => 'You have already reviewed this for your completed orders.', + ]; + + public function __construct( + public bool $allowed, + public ?string $reason = null, + public ?Order $order = null, + public ?Review $review = null, + ) { + } + + /** + * Check whether the customer can review the subject, optionally for one specific order. + * + * When no order is given, the most recent completed order that has not been reviewed for + * this subject is used. + */ + public static function check(?Contact $customer, mixed $subject, ?string $orderId = null): self + { + if (!$customer) { + return new self(false, self::SIGN_IN_REQUIRED); + } + + $query = static::completedOrdersFor($customer, $subject); + if (!$query) { + return new self(false, self::UNSUPPORTED_SUBJECT); + } + + if ($orderId) { + $query->where(fn ($orderQuery) => $orderQuery->where('public_id', $orderId)->orWhere('uuid', $orderId)); + } + + $orders = $query->orderByDesc('created_at')->limit(static::ORDER_LOOKBACK)->get(); + if ($orders->isEmpty()) { + return new self(false, self::NO_COMPLETED_ORDER); + } + + $reviews = Review::where('customer_uuid', $customer->uuid) + ->where('subject_uuid', $subject->uuid) + ->whereIn('order_uuid', $orders->pluck('uuid')) + ->get() + ->keyBy('order_uuid'); + + $unreviewed = $orders->first(fn ($order) => !$reviews->has($order->uuid)); + if (!$unreviewed) { + $latest = $orders->first(); + + return new self(false, self::ALREADY_REVIEWED, $latest, $reviews->get($latest->uuid)); + } + + return new self(true, null, $unreviewed); + } + + /** + * The customer's completed orders that include the subject, or null when the subject + * cannot be reviewed. + */ + public static function completedOrdersFor(Contact $customer, mixed $subject): ?Builder + { + $query = Order::query() + ->where('customer_uuid', $customer->uuid) + ->whereIn('status', static::COMPLETED_STATUSES); + + if ($subject instanceof Store) { + return $query->where('meta->storefront_id', $subject->public_id); + } + + if ($subject instanceof Product) { + return $query->whereIn( + 'payload_uuid', + Entity::query()->select('payload_uuid')->where('internal_id', $subject->public_id)->whereNotNull('payload_uuid') + ); + } + + return null; + } + + public function message(): ?string + { + return $this->reason ? static::MESSAGES[$this->reason] : null; + } + + /** + * @return array{can_review: bool, reason: ?string, message: ?string, order: ?string, review: ?string} + */ + public function toArray(): array + { + return [ + 'can_review' => $this->allowed, + 'reason' => $this->reason, + 'message' => $this->message(), + 'order' => $this->order?->public_id, + 'review' => $this->review?->public_id, + ]; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7099a53f..65685ecd 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -88,9 +88,10 @@ function ($router) { $router->group(['prefix' => 'reviews'], function () use ($router) { $router->get('/', 'ReviewController@query'); $router->get('count', 'ReviewController@count'); + $router->get('eligibility', 'ReviewController@eligibility'); $router->get('{id}', 'ReviewController@find'); $router->post('/', 'ReviewController@create'); - $router->delete('{id}', 'ReviewController@find'); + $router->delete('{id}', 'ReviewController@delete'); }); // storefront/v1/orders diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 14c86195..d823acaf 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -281,6 +281,33 @@ public function unassignCurrentOrder(): void } } +function createReviewOrderSchema(): void +{ + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('orders'); + $schema->dropIfExists('entities'); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('entities', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('internal_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); +} + function createReviewControllerSchema(): void { $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); @@ -292,6 +319,7 @@ function createReviewControllerSchema(): void $table->string('public_id')->nullable(); $table->string('created_by_uuid')->nullable(); $table->string('customer_uuid')->nullable(); + $table->string('order_uuid')->nullable(); $table->string('subject_uuid')->nullable(); $table->string('subject_type')->nullable(); $table->integer('rating')->nullable(); @@ -637,9 +665,10 @@ function createReviewControllerSchema(): void createReviewControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['personal_access_tokens', 'files', 'contacts', 'stores'] as $table) { + foreach (['personal_access_tokens', 'files', 'contacts', 'stores', 'orders', 'entities'] as $table) { $schema->dropIfExists($table); } + createReviewOrderSchema(); $schema->create('personal_access_tokens', function ($table) { $table->increments('id'); $table->string('tokenable_type')->nullable(); @@ -728,6 +757,14 @@ function createReviewControllerSchema(): void ['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid'], ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'foreign_store_uuid'], ]); + // Two completed orders from the store and one containing the product: each order can be + // reviewed once per subject, newest first. + $connection->table('orders')->insert([ + ['uuid' => 'older_store_order_uuid', 'public_id' => 'order_older', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDays(3)], + ['uuid' => 'newer_store_order_uuid', 'public_id' => 'order_newer', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDay()], + ['uuid' => 'product_order_uuid', 'public_id' => 'order_product', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => 'payload_uuid', 'meta' => json_encode(['storefront_id' => 'other_store']), 'created_at' => now()->subDays(2)], + ]); + $connection->table('entities')->insert(['uuid' => 'entity_uuid', 'payload_uuid' => 'payload_uuid', 'internal_id' => 'product_abcdefgh']); $boundRequest = Request::create('/reviews'); $boundRequest->headers->set('Customer-Token', 'review-customer-secret'); $boundRequest->setLaravelSession(new Illuminate\Session\Store( @@ -801,7 +838,14 @@ public function put(string $path, string $contents, string $visibility): bool ], ]) ); - $photo = $connection->table('files')->first(); + $photo = $connection->table('files')->first(); + $alreadyReviewed = $controller->create( + Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'store_abcdefgh', + 'rating' => 3, + 'content' => 'Third review', + ]) + ); session(['storefront_store' => 'store_uuid', 'storefront_network' => null]); $deleted = $controller->delete('review_owned'); @@ -814,6 +858,14 @@ public function put(string $path, string $contents, string $visibility): bool ->and($review->subject_uuid)->toBe('store_uuid') ->and($review->rating)->toBe(5) ->and($review->content)->toBe('Excellent service') + ->and($review->order_uuid)->toBe('newer_store_order_uuid') + ->and($createdProduct->resource->order_uuid)->toBe('product_order_uuid') + ->and($withPhoto->resource->order_uuid)->toBe('older_store_order_uuid') + ->and($alreadyReviewed->getStatusCode())->toBe(403) + ->and($alreadyReviewed->getData(true))->toBe([ + 'error' => 'You have already reviewed this for your completed orders.', + 'reason' => 'already_reviewed', + ]) ->and($withPhoto->resource->files)->toHaveCount(1) ->and($photo->subject_uuid)->toBe($withPhoto->resource->uuid) ->and($photo->content_type)->toBe('image/png') diff --git a/server/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php index d8840dd9..c9ad3752 100644 --- a/server/tests/Unit/Http/Requests/RequestContractsTest.php +++ b/server/tests/Unit/Http/Requests/RequestContractsTest.php @@ -152,12 +152,17 @@ // `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. + // Ratings are whole stars from 1 to 5, and a review carries at most four photos or + // videos. `order` optionally names the completed order being reviewed. ->and($reviewRules)->toBe([ - 'subject' => 'required|string', - 'rating' => 'required|numeric', - 'content' => 'required', - 'files' => 'sometimes|array', - 'rejected' => 'sometimes|boolean', + 'subject' => 'required|string', + 'order' => 'sometimes|nullable|string', + 'rating' => 'required|integer|between:1,5', + 'content' => 'required|string|max:2000', + 'files' => 'sometimes|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/,video/', + 'rejected' => 'sometimes|boolean', ])->and($verificationRules)->toBe([ 'mode' => 'required|in:email,sms', 'identity' => 'required', diff --git a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php index 3d55ff3a..749f9cf2 100644 --- a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php @@ -158,27 +158,33 @@ public function uri(): string 'name' => 'Ada Lovelace', ]); $review = storefrontResourceModel([ - 'id' => 4, - 'uuid' => 'review_uuid', - 'public_id' => 'review_123', + 'id' => 4, + 'uuid' => 'review_uuid', + 'public_id' => 'review_123', + 'customer_uuid' => 'customer_uuid', + 'order_uuid' => 'order_uuid', 'rating' => 5, 'content' => 'Excellent service', 'slug' => 'excellent-service', 'created_at' => '2026-01-01', 'updated_at' => '2026-01-02', ], [ - 'subject' => storefrontResourceModel(['id' => 99]), + 'subject' => storefrontResourceModel(['id' => 99, 'public_id' => 'store_123']), 'customer' => $customer, 'photos' => collect([$photo]), ]); $reviewData = (new ReviewResource($review))->toArray(setSimpleStorefrontResourceRoute('v1/storefront/reviews')); + // Public responses name the subject by its public id, never the internal row id. expect($reviewData)->toMatchArray([ - 'id' => 'review_123', - 'subject_id' => 99, - 'rating' => 5, - 'content' => 'Excellent service', + 'id' => 'review_123', + 'subject_id' => 'store_123', + 'subject_type' => 'store', + 'verified' => true, + 'is_mine' => false, + 'rating' => 5, + 'content' => 'Excellent service', ])->and($reviewData['photos'][0])->toBe([ 'id' => 'file_123', 'filename' => 'receipt.jpg', diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 8b8f4452..46cc19cf 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -96,5 +96,7 @@ private function record(string $method, string $uri, mixed $action): self ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], ['GET', '/', 'MetricsController@all'], - )->and(count($router->routes))->toBeGreaterThan(50); + ['GET', 'eligibility', 'ReviewController@eligibility'], + ['DELETE', '{id}', 'ReviewController@delete'], + )->and($router->routes)->not->toContain(['DELETE', '{id}', 'ReviewController@find'])->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/server/tests/Unit/Support/ReviewEligibilityTest.php b/server/tests/Unit/Support/ReviewEligibilityTest.php new file mode 100644 index 00000000..7f365af4 --- /dev/null +++ b/server/tests/Unit/Support/ReviewEligibilityTest.php @@ -0,0 +1,289 @@ +connection('mysql')->getSchemaBuilder(); + foreach (['reviews', 'orders', 'entities', 'products', 'stores', 'contacts', 'personal_access_tokens'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('reviews', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('order_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->integer('rating')->nullable(); + $table->text('content')->nullable(); + $table->boolean('rejected')->default(false); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('entities', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('internal_id')->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->timestamp('deleted_at')->nullable(); + }); + $schema->create('stores', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->text('translations')->nullable(); + $table->text('tags')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('type')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->integer('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token'); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_abcdefgh', 'company_uuid' => 'company_uuid', 'name' => 'Bloom']); + $connection->table('products')->insert(['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid']); + $connection->table('contacts')->insert(['uuid' => ELIGIBILITY_CUSTOMER_UUID, 'public_id' => 'contact_eligible', 'user_uuid' => 'user_uuid', 'type' => 'customer']); + $connection->table('orders')->insert([ + // Completed orders for the store, newest last so ordering is exercised. + ['uuid' => 'store_order_old', 'public_id' => 'order_old', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => 'payload_old', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDays(5)], + ['uuid' => 'store_order_new', 'public_id' => 'order_new', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => 'payload_new', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDay()], + // Not eligible: still in progress, another store, another customer. + ['uuid' => 'store_order_active', 'public_id' => 'order_active', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'dispatched', 'payload_uuid' => 'payload_active', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()], + ['uuid' => 'other_store_order', 'public_id' => 'order_other_store', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_other']), 'created_at' => now()], + ['uuid' => 'other_customer_order', 'public_id' => 'order_other_customer', 'customer_uuid' => 'someone_else', 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()], + ]); + // Only the newest store order contains the product. + $connection->table('entities')->insert([ + ['uuid' => 'entity_new', 'payload_uuid' => 'payload_new', 'internal_id' => 'product_abcdefgh'], + ['uuid' => 'entity_active', 'payload_uuid' => 'payload_active', 'internal_id' => 'product_abcdefgh'], + ['uuid' => 'entity_orphan', 'payload_uuid' => null, 'internal_id' => 'product_abcdefgh'], + ]); +} + +function eligibilityCustomer(): Contact +{ + return Contact::where('uuid', ELIGIBILITY_CUSTOMER_UUID)->first(); +} + +function bindEligibilityRequest(?string $customerToken = null, array $query = []): Request +{ + $request = Request::create('/reviews/eligibility', 'GET', $query); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + $request->setLaravelSession(new Illuminate\Session\Store('review-eligibility', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $request); + session(['company' => 'company_uuid', 'storefront_key' => 'store_key', 'storefront_store' => 'store_uuid', 'storefront_network' => null]); + + return $request; +} + +function issueEligibilityToken(): string +{ + Model::getConnectionResolver()->connection('mysql')->table('personal_access_tokens')->insert([ + 'name' => ELIGIBILITY_CUSTOMER_UUID, + 'token' => hash('sha256', 'eligibility-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return 'eligibility-secret'; +} + +test('review eligibility requires a signed-in customer and a store or product subject', function () { + createReviewEligibilitySchema(); + $store = Store::where('uuid', 'store_uuid')->first(); + + $signedOut = ReviewEligibility::check(null, $store); + $unsupported = ReviewEligibility::check(eligibilityCustomer(), new stdClass()); + + expect($signedOut->allowed)->toBeFalse() + ->and($signedOut->toArray())->toBe([ + 'can_review' => false, + 'reason' => 'sign_in_required', + 'message' => 'Sign in to write a review.', + 'order' => null, + 'review' => null, + ]) + ->and($unsupported->reason)->toBe('unsupported_subject') + ->and($unsupported->message())->toBe('Only stores and products can be reviewed.') + ->and(ReviewEligibility::completedOrdersFor(eligibilityCustomer(), new stdClass()))->toBeNull(); +}); + +test('store reviews use the newest completed order from that store that has not been reviewed', function () { + createReviewEligibilitySchema(); + $store = Store::where('uuid', 'store_uuid')->first(); + $customer = eligibilityCustomer(); + + $first = ReviewEligibility::check($customer, $store); + Review::create(['customer_uuid' => $customer->uuid, 'subject_uuid' => $store->uuid, 'order_uuid' => 'store_order_new', 'rating' => 5, 'content' => 'Great']); + $second = ReviewEligibility::check($customer, $store); + Review::create(['customer_uuid' => $customer->uuid, 'subject_uuid' => $store->uuid, 'order_uuid' => 'store_order_old', 'rating' => 4, 'content' => 'Good']); + $done = ReviewEligibility::check($customer, $store); + + expect($first->allowed)->toBeTrue() + ->and($first->reason)->toBeNull() + ->and($first->message())->toBeNull() + ->and($first->order->uuid)->toBe('store_order_new') + ->and($second->order->uuid)->toBe('store_order_old') + ->and($done->allowed)->toBeFalse() + ->and($done->reason)->toBe('already_reviewed') + ->and($done->order->public_id)->toBe('order_new') + ->and($done->review->order_uuid)->toBe('store_order_new') + ->and($done->toArray()['order'])->toBe('order_new'); +}); + +test('product reviews need a completed order containing the product and can target one order', function () { + createReviewEligibilitySchema(); + $product = Product::where('uuid', 'product_uuid')->first(); + $store = Store::where('uuid', 'store_uuid')->first(); + $customer = eligibilityCustomer(); + + $product_ = ReviewEligibility::check($customer, $product); + $byPublic = ReviewEligibility::check($customer, $store, 'order_old'); + $byUuid = ReviewEligibility::check($customer, $store, 'store_order_old'); + $notMine = ReviewEligibility::check($customer, $store, 'order_other_customer'); + $missing = ReviewEligibility::check($customer, $product, 'order_old'); + + expect($product_->allowed)->toBeTrue() + ->and($product_->order->uuid)->toBe('store_order_new') + ->and($byPublic->order->uuid)->toBe('store_order_old') + ->and($byUuid->order->uuid)->toBe('store_order_old') + ->and($notMine->reason)->toBe('no_completed_order') + ->and($missing->allowed)->toBeFalse() + ->and($missing->message())->toBe('You can review this after an order with it is completed.'); +}); + +test('review eligibility endpoint validates the subject and reports the signed-in customer state', function () { + createReviewEligibilitySchema(); + $controller = new ReviewController(); + + bindEligibilityRequest(); + $missing = $controller->eligibility(request()); + bindEligibilityRequest(null, ['subject' => 'store_missing']); + $invalid = $controller->eligibility(request()); + bindEligibilityRequest(null, ['subject' => 'store_abcdefgh']); + $signedOut = $controller->eligibility(request()); + $token = issueEligibilityToken(); + bindEligibilityRequest($token, ['subject' => 'store_abcdefgh']); + $eligible = $controller->eligibility(request()); + bindEligibilityRequest($token, ['subject' => 'product_abcdefgh', 'order' => 'order_old']); + $wrongOrder = $controller->eligibility(request()); + + expect($missing->getData(true))->toBe(['error' => 'A subject is required.']) + ->and($invalid->getData(true))->toBe(['error' => 'Invalid subject for review']) + ->and($signedOut->getData(true)['reason'])->toBe('sign_in_required') + ->and($eligible->getData(true))->toBe([ + 'can_review' => true, + 'reason' => null, + 'message' => null, + 'order' => 'order_new', + 'review' => null, + ]) + ->and($wrongOrder->getData(true)['reason'])->toBe('no_completed_order'); +}); + +test('creating a review without a completed order is refused with the reason', function () { + createReviewEligibilitySchema(); + Model::getConnectionResolver()->connection('mysql')->table('orders')->update(['status' => 'canceled']); + bindEligibilityRequest(issueEligibilityToken()); + + $response = (new ReviewController())->create(CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'store_abcdefgh', + 'rating' => 5, + 'content' => 'Never received it', + ])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true))->toBe([ + 'error' => 'You can review this after an order with it is completed.', + 'reason' => 'no_completed_order', + ]) + ->and(Review::query()->count())->toBe(0); +}); + +test('review resources mark the viewer own verified product reviews and keep internal ids internal', function () { + createReviewEligibilitySchema(); + $product = Product::where('uuid', 'product_uuid')->first(); + $mine = new Review(['customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'subject_uuid' => 'product_uuid', 'rating' => 4, 'content' => 'Nice']); + $mine->setRelation('subject', $product)->setRelation('customer', null)->setRelation('photos', collect()); + $other = new Review(['customer_uuid' => null, 'order_uuid' => null, 'rating' => 2, 'content' => 'Meh']); + $other->setRelation('subject', null)->setRelation('customer', null)->setRelation('photos', collect()); + + bindEligibilityRequest(issueEligibilityToken()); + $publicMine = (new ReviewResource($mine))->toArray(request()); + $publicOther = (new ReviewResource($other))->toArray(request()); + // The viewer is resolved once per request. + Model::getConnectionResolver()->connection('mysql')->table('personal_access_tokens')->delete(); + $cached = ReviewResource::viewerCustomerUuid(); + + expect($publicMine['subject_id'])->toBe('product_abcdefgh') + ->and($publicMine['subject_type'])->toBe('product') + ->and($publicMine['verified'])->toBeFalse() + ->and($publicMine['is_mine'])->toBeTrue() + ->and($publicOther['subject_id'])->toBeNull() + ->and($publicOther['is_mine'])->toBeFalse() + ->and($cached)->toBe(ELIGIBILITY_CUSTOMER_UUID); +}); + +test('reviews belong to the completed order they were written for', function () { + $relation = (new Review())->order(); + + expect($relation)->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\BelongsTo::class) + ->and($relation->getRelated())->toBeInstanceOf(Fleetbase\FleetOps\Models\Order::class) + ->and($relation->getForeignKeyName())->toBe('order_uuid') + ->and($relation->getOwnerKeyName())->toBe('uuid'); +}); From a406dd26411017f04aca6ae0a954b4b89cefbb4e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 19:53:48 +0800 Subject: [PATCH 05/50] feat(promotions): store attribution, shareable code and availability on public promotions - Public promotions name the store or network running them (owner: type, id, name, logo_url) and list applies_to targets by public id instead of uuid. - Code promotions show their code publicly when they have one reusable, unassigned, unexpired code; single-use batch codes are never exposed. - Every promotion reports availability (live, scheduled, ended) and, when scheduled, next_starts_at from its date range and weekly hours. - GET promotions?include=scheduled also lists promotions outside their weekly hours or before their start; GET promotions/{id} opens scheduled and ended promotions so links from notifications keep working. --- docs/promotions.md | 7 + .../Controllers/v1/PromotionController.php | 27 ++- server/src/Http/Resources/Promotion.php | 6 +- server/src/Models/Promotion.php | 155 ++++++++++++++++ .../Promotions/PublicPromotionDetailsTest.php | 171 ++++++++++++++++++ 5 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 server/tests/Unit/Promotions/PublicPromotionDetailsTest.php diff --git a/docs/promotions.md b/docs/promotions.md index c2264460..738b8a17 100644 --- a/docs/promotions.md +++ b/docs/promotions.md @@ -16,6 +16,13 @@ specific code disables the batch count, prefix, and length fields because they d not apply to that operation. The code list shows loading and failure states, with a retry action if it cannot load. +The customer app shows the code of a public code promotion only when the promotion +has a reusable code that is active, not assigned to a customer and not expired +(for example one specific code entered with **Manage codes**). Batches of +single-use codes are never shown; send them to customers with a campaign instead. +Public promotions with weekly hours stay visible in the app outside those hours, +marked with when they next start. + ## Campaigns Enter an internal campaign name, notification title, and message. Choose an diff --git a/server/src/Http/Controllers/v1/PromotionController.php b/server/src/Http/Controllers/v1/PromotionController.php index d9d106f0..0b4ef1fe 100644 --- a/server/src/Http/Controllers/v1/PromotionController.php +++ b/server/src/Http/Controllers/v1/PromotionController.php @@ -18,25 +18,41 @@ class PromotionController extends Controller /** * Live, public promotions of the storefront, and in a network of its member stores. * - * Query params: `store` (a store public id, to only list that store's promotions in a network). + * Query params: + * - `store`: a store public id, to only list that store's promotions in a network. + * - `include=scheduled`: also list active promotions that are outside their weekly hours or + * have not started yet, so the app can show "starts again at 2 pm". Each promotion's + * `availability` says which it is. */ public function query(Request $request) { + $listed = $request->input('include') === 'scheduled' + ? [Promotion::AVAILABILITY_LIVE, Promotion::AVAILABILITY_SCHEDULED] + : [Promotion::AVAILABILITY_LIVE]; + $promotions = $this->publicPromotions($request->input('store')) + ->where('status', Promotion::STATUS_ACTIVE) ->orderByDesc('priority') ->orderBy('ends_at') ->get() - ->filter(fn (Promotion $promotion) => $promotion->isLiveAt()) + ->filter(fn (Promotion $promotion) => in_array($promotion->availabilityAt(), $listed, true)) ->values(); return PromotionResource::collection($promotions); } + /** + * One public promotion. Scheduled and ended promotions are returned too, so a link from a + * notification still opens and can say when the offer runs or that it is over. + */ public function find(string $id) { - $promotion = $this->publicPromotions()->where('public_id', $id)->first(); + $promotion = $this->publicPromotions() + ->whereIn('status', [Promotion::STATUS_ACTIVE, Promotion::STATUS_ENDED]) + ->where('public_id', $id) + ->first(); - if (!$promotion || !$promotion->isLiveAt()) { + if (!$promotion || $promotion->availabilityAt() === Promotion::AVAILABILITY_INACTIVE) { return response()->apiError('Promotion not found.', 404); } @@ -58,8 +74,7 @@ protected function publicPromotions(?string $storeId = null): Builder return Promotion::query() ->whereIn('owner_uuid', array_filter($owners)) - ->where('status', Promotion::STATUS_ACTIVE) ->where('is_public', true) - ->with('image'); + ->with(['image', 'owner', 'codes']); } } diff --git a/server/src/Http/Resources/Promotion.php b/server/src/Http/Resources/Promotion.php index e3d44d69..2fd8f71f 100644 --- a/server/src/Http/Resources/Promotion.php +++ b/server/src/Http/Resources/Promotion.php @@ -36,7 +36,11 @@ public function toArray($request) 'currency' => $this->currency, 'min_subtotal' => $this->min_subtotal, 'min_items' => $this->min_items, - 'applies_to' => $this->applies_to, + 'owner' => $this->when(!$internal, fn () => $this->ownerSummary()), + 'code' => $this->when(!$internal, fn () => $this->shareableCode()), + 'availability' => $this->availabilityAt(), + 'next_starts_at' => $this->nextLiveAt(), + 'applies_to' => $internal ? $this->applies_to : $this->publicAppliesTo(), 'bogo_config' => $this->bogo_config, 'first_order_only' => $this->first_order_only, 'usage_limit' => $this->when($internal, $this->usage_limit), diff --git a/server/src/Models/Promotion.php b/server/src/Models/Promotion.php index 996d4b03..15335c13 100644 --- a/server/src/Models/Promotion.php +++ b/server/src/Models/Promotion.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; use Fleetbase\FleetOps\Support\Utils; +use Fleetbase\Models\Category; use Fleetbase\Models\File; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; @@ -36,6 +37,15 @@ class Promotion extends StorefrontModel public const STATUSES = [self::STATUS_DRAFT, self::STATUS_ACTIVE, self::STATUS_PAUSED, self::STATUS_ENDED]; + /** + * Customer-facing availability: running now, running later (outside its weekly hours or + * before its start date), over, or not offered at all (draft or paused). + */ + public const AVAILABILITY_LIVE = 'live'; + public const AVAILABILITY_SCHEDULED = 'scheduled'; + public const AVAILABILITY_ENDED = 'ended'; + public const AVAILABILITY_INACTIVE = 'inactive'; + protected $publicIdType = 'promotion'; protected $table = 'promotions'; @@ -165,6 +175,151 @@ public function isLiveAt(?Carbon $moment = null): bool return false; } + /** + * Customer-facing availability at the given moment. + */ + public function availabilityAt(?Carbon $moment = null): string + { + $moment ??= Carbon::now(); + + if ($this->status === self::STATUS_ENDED || ($this->ends_at && $moment->gte($this->ends_at))) { + return self::AVAILABILITY_ENDED; + } + + if ($this->status !== self::STATUS_ACTIVE) { + return self::AVAILABILITY_INACTIVE; + } + + return $this->isLiveAt($moment) ? self::AVAILABILITY_LIVE : self::AVAILABILITY_SCHEDULED; + } + + /** + * When a scheduled promotion next starts applying, looking up to a week ahead; null when it + * is live now, has ended, is not active, or has no start inside that week. + */ + public function nextLiveAt(?Carbon $moment = null): ?Carbon + { + $moment ??= Carbon::now(); + + if ($this->availabilityAt($moment) !== self::AVAILABILITY_SCHEDULED) { + return null; + } + + $timezone = $this->timezone ?: config('app.timezone', 'UTC'); + $from = ($this->starts_at && $this->starts_at->gt($moment) ? $this->starts_at : $moment)->copy()->setTimezone($timezone); + $windows = array_values(array_filter((array) ($this->schedule ?? []), 'is_array')); + if (empty($windows)) { + $windows = [['days' => [1, 2, 3, 4, 5, 6, 7], 'start' => '00:00', 'end' => '24:00']]; + } + + $next = null; + for ($offset = -1; $offset <= 7; $offset++) { + $day = $from->copy()->startOfDay()->addDays($offset); + foreach ($windows as $window) { + if (!in_array($day->dayOfWeekIso, array_map('intval', (array) ($window['days'] ?? [1, 2, 3, 4, 5, 6, 7])), true)) { + continue; + } + + $start = static::minutesOfDay($window['start'] ?? '00:00'); + $end = static::minutesOfDay($window['end'] ?? '24:00'); + $opens = $day->copy()->addMinutes($start); + $shuts = $day->copy()->addMinutes($end <= $start ? $end + 1440 : $end); + + $candidate = $opens->lt($from) ? $from->copy() : $opens; + if ($candidate->gte($shuts) || ($this->ends_at && $candidate->gte($this->ends_at))) { + continue; + } + + if (!$next || $candidate->lt($next)) { + $next = $candidate; + } + } + } + + return $next?->setTimezone(config('app.timezone', 'UTC')); + } + + /** + * The code a customer can type for a code-triggered promotion: an active code that is not + * assigned to one customer, has not expired and can be used more than once. Batches of + * single-use codes are handed out individually (for example by a campaign), so they are + * never shown publicly. + */ + public function shareableCode(?Carbon $moment = null): ?string + { + if ($this->trigger !== self::TRIGGER_CODE) { + return null; + } + + $moment ??= Carbon::now(); + $codes = $this->relationLoaded('codes') ? $this->codes : $this->codes()->get(); + + $code = $codes + ->filter(fn (PromotionCode $code) => $code->status === PromotionCode::STATUS_ACTIVE + && empty($code->customer_uuid) + && (!$code->expires_at || $moment->lt($code->expires_at)) + && ($code->usage_limit === null || $code->usage_limit > 1)) + ->sortByDesc('created_at') + ->first(); + + return $code?->code; + } + + /** + * The store or network running the promotion, as customers see it. + * + * @return array{type: string, id: ?string, name: ?string, logo_url: ?string}|null + */ + public function ownerSummary(): ?array + { + $owner = $this->owner; + if (!$owner instanceof Store && !$owner instanceof Network) { + return null; + } + + return [ + 'type' => $owner instanceof Store ? 'store' : 'network', + 'id' => $owner->public_id, + 'name' => $owner->name, + 'logo_url' => data_get($owner, 'logo.url'), + ]; + } + + /** + * `applies_to` with every product, category and store named by its public id, so the app + * can link "Shop the offer" to them. Ids that no longer resolve are dropped. + * + * @return array> + */ + public function publicAppliesTo(): array + { + $appliesTo = (array) ($this->applies_to ?? []); + $models = [ + 'products' => Product::class, + 'exclude_products' => Product::class, + 'stores' => Store::class, + 'categories' => Category::class, + 'exclude_categories' => Category::class, + ]; + + $public = []; + foreach ($models as $key => $model) { + $ids = array_values(array_filter((array) ($appliesTo[$key] ?? []), 'is_string')); + if (empty($ids)) { + continue; + } + + $public[$key] = $model::query() + ->where(fn ($query) => $query->whereIn('uuid', $ids)->orWhereIn('public_id', $ids)) + ->pluck('public_id') + ->filter() + ->values() + ->all(); + } + + return $public; + } + protected static function minutesOfDay(string $time): int { [$hours, $minutes] = array_pad(array_map('intval', explode(':', $time)), 2, 0); diff --git a/server/tests/Unit/Promotions/PublicPromotionDetailsTest.php b/server/tests/Unit/Promotions/PublicPromotionDetailsTest.php new file mode 100644 index 00000000..4dfefbb4 --- /dev/null +++ b/server/tests/Unit/Promotions/PublicPromotionDetailsTest.php @@ -0,0 +1,171 @@ + [1, 2, 3, 4, 5], 'start' => '14:00', 'end' => '17:00']]; + +function publicPromotionRequest(array $input = [], bool $internal = false): Request +{ + $request = Request::create('/', 'GET', $input); + $request->setLaravelSession(new Illuminate\Session\Store('promotion-details', new Illuminate\Session\ArraySessionHandler(120))); + if ($internal) { + $request->setRouteResolver(fn () => new class { + public array $action = []; + + public function uri(): string + { + return 'int/v1/storefront/promotions'; + } + }); + } + app()->instance('request', $request); + + return $request; +} + +beforeEach(function () { + createPromotionSchema(); + seedPromotionStores(); + session(['storefront_key' => 'store_key_a', 'storefront_store' => 'store_a_uuid', 'storefront_network' => null, 'company' => 'company_uuid']); + Carbon::setTestNow(Carbon::parse(PROMO_MONDAY_10AM, 'UTC')); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +test('promotion availability distinguishes live, scheduled, ended and inactive promotions', function () { + expect(makePromotion()->availabilityAt())->toBe('live') + ->and(makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC'])->availabilityAt())->toBe('scheduled') + ->and(makePromotion(['starts_at' => now()->addDay()])->availabilityAt())->toBe('scheduled') + ->and(makePromotion(['status' => Promotion::STATUS_ENDED])->availabilityAt())->toBe('ended') + ->and(makePromotion(['ends_at' => now()->subMinute()])->availabilityAt())->toBe('ended') + ->and(makePromotion(['status' => Promotion::STATUS_DRAFT])->availabilityAt())->toBe('inactive') + ->and(makePromotion(['status' => Promotion::STATUS_PAUSED])->availabilityAt(now()))->toBe('inactive'); +}); + +test('scheduled promotions report when they next start', function () { + $afternoons = makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC']); + $friday = Carbon::parse('2026-10-09 18:00:00', 'UTC'); + $starting = makePromotion(['starts_at' => Carbon::parse('2026-10-20 09:00:00', 'UTC')]); + $overnight = makePromotion([ + 'schedule' => [['days' => [1], 'start' => '22:00', 'end' => '02:00']], + 'timezone' => 'UTC', + 'starts_at' => Carbon::parse('2026-10-05 23:00:00', 'UTC'), + ]); + // Singapore is UTC+8: 14:00 local on Monday is 06:00 UTC, already passed at 10:00 UTC, + // so the next start is Tuesday 06:00 UTC. + $singapore = makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'Asia/Singapore']); + $endsFirst = makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC', 'ends_at' => Carbon::parse('2026-10-05 12:00:00', 'UTC')]); + $noDays = makePromotion(['schedule' => [['days' => [], 'start' => '14:00', 'end' => '17:00']], 'timezone' => 'UTC']); + + expect(makePromotion()->nextLiveAt())->toBeNull() + ->and($afternoons->nextLiveAt()->toDateTimeString())->toBe('2026-10-05 14:00:00') + ->and($afternoons->nextLiveAt($friday)->toDateTimeString())->toBe('2026-10-12 14:00:00') + ->and($starting->nextLiveAt()->toDateTimeString())->toBe('2026-10-20 09:00:00') + ->and($overnight->nextLiveAt()->toDateTimeString())->toBe('2026-10-05 23:00:00') + ->and($singapore->nextLiveAt()->toDateTimeString())->toBe('2026-10-06 06:00:00') + ->and($endsFirst->nextLiveAt())->toBeNull() + ->and($noDays->nextLiveAt())->toBeNull(); +}); + +test('only one active, unassigned, unexpired, reusable code is shared publicly', function () { + $automatic = makePromotion(); + $coded = makePromotion(['trigger' => Promotion::TRIGGER_CODE]); + makePromotionCode($coded, 'SINGLE', ['usage_limit' => 1]); + makePromotionCode($coded, 'MINE', ['customer_uuid' => 'customer_uuid']); + makePromotionCode($coded, 'OLDCODE', ['expires_at' => now()->subDay()]); + makePromotionCode($coded, 'OFF', ['status' => 'disabled']); + makePromotionCode($coded, 'BLOOM10', ['usage_limit' => 100, 'created_at' => now()->subDay()]); + makePromotionCode($coded, 'BLOOMNEW', ['usage_limit' => null, 'created_at' => now()]); + $batchOnly = makePromotion(['trigger' => Promotion::TRIGGER_CODE]); + makePromotionCode($batchOnly, 'BATCH1', ['usage_limit' => 1]); + + expect($automatic->shareableCode())->toBeNull() + ->and($coded->shareableCode())->toBe('BLOOMNEW') + ->and($coded->load('codes')->shareableCode())->toBe('BLOOMNEW') + ->and($batchOnly->shareableCode())->toBeNull(); +}); + +test('promotions describe their owner and name their targets by public id', function () { + $schema = promotionDb()->getSchemaBuilder(); + $schema->dropIfExists('categories'); + $schema->create('categories', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + promotionDb()->table('categories')->insert(['uuid' => 'category_uuid', 'public_id' => 'category_flowers']); + promotionDb()->table('products')->insert(['uuid' => 'product_1_uuid', 'public_id' => 'product_1', 'store_uuid' => 'store_a_uuid']); + + $storeOwned = makePromotion(['applies_to' => [ + 'products' => ['product_1_uuid', 'product_gone'], + 'exclude_products' => [], + 'stores' => ['store_b'], + 'categories' => ['category_uuid', 42], + 'exclude_categories' => ['category_missing'], + ]]); + $networkOwned = makePromotion(['owner_uuid' => 'network_uuid', 'owner_type' => Network::class]); + $orphaned = makePromotion(['owner_uuid' => 'missing_store']); + + expect($storeOwned->ownerSummary())->toBe(['type' => 'store', 'id' => 'store_a', 'name' => 'Store A', 'logo_url' => null]) + ->and($networkOwned->ownerSummary())->toBe(['type' => 'network', 'id' => 'network_m', 'name' => 'Market', 'logo_url' => null]) + ->and($orphaned->ownerSummary())->toBeNull() + ->and($storeOwned->publicAppliesTo())->toBe([ + 'products' => ['product_1'], + 'stores' => ['store_b'], + 'categories' => ['category_flowers'], + 'exclude_categories' => [], + ]) + ->and($networkOwned->publicAppliesTo())->toBe([]); +}); + +test('public promotion listings can include scheduled promotions and details open for scheduled and ended ones', function () { + $live = makePromotion(['name' => 'Live']); + $scheduled = makePromotion(['name' => 'Afternoons', 'schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC']); + $ended = makePromotion(['name' => 'Ended', 'status' => Promotion::STATUS_ENDED]); + $paused = makePromotion(['name' => 'Paused', 'status' => Promotion::STATUS_PAUSED]); + $draft = makePromotion(['name' => 'Draft', 'status' => Promotion::STATUS_DRAFT]); + $controller = new PromotionController(); + + $default = collect($controller->query(publicPromotionRequest())->resolve())->pluck('name')->all(); + $withLater = collect($controller->query(publicPromotionRequest(['include' => 'scheduled']))->resolve()); + + expect($default)->toBe(['Live']) + ->and($withLater->pluck('name')->all())->toBe(['Live', 'Afternoons']) + ->and($withLater->firstWhere('name', 'Afternoons')['availability'])->toBe('scheduled') + ->and($withLater->firstWhere('name', 'Afternoons')['next_starts_at']->toDateTimeString())->toBe('2026-10-05 14:00:00') + ->and($controller->find($scheduled->public_id)->resolve()['availability'])->toBe('scheduled') + ->and($controller->find($ended->public_id)->resolve()['availability'])->toBe('ended') + ->and($controller->find($live->public_id)->resolve()['availability'])->toBe('live') + ->and($controller->find($paused->public_id)->getStatusCode())->toBe(404) + ->and($controller->find($draft->public_id)->getStatusCode())->toBe(404); +}); + +test('public promotion resources add owner and shareable code, internal ones keep raw targets', function () { + $promotion = makePromotion(['trigger' => Promotion::TRIGGER_CODE, 'applies_to' => ['stores' => ['store_a_uuid']]]); + makePromotionCode($promotion, 'SHARE', ['usage_limit' => null]); + + $public = (new PromotionResource($promotion))->resolve(publicPromotionRequest()); + $internal = (new PromotionResource($promotion))->resolve(publicPromotionRequest([], true)); + + expect($public['owner'])->toBe(['type' => 'store', 'id' => 'store_a', 'name' => 'Store A', 'logo_url' => null]) + ->and($public['code'])->toBe('SHARE') + ->and($public['availability'])->toBe('live') + ->and($public['next_starts_at'])->toBeNull() + ->and($public['applies_to'])->toBe(['stores' => ['store_a']]) + ->and($internal)->not->toHaveKey('owner') + ->and($internal)->not->toHaveKey('code') + ->and($internal['applies_to'])->toBe(['stores' => ['store_a_uuid']]); +}); From fb6ecf7c8c7df2c7252f6eacf08a5838476d1492 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 20:09:15 +0800 Subject: [PATCH 06/50] feat(chat): customer chat with the driver delivering their order - storefront/v1/orders/{id}/chat: show (starts the chat), messages (cursor pagination), send (text and up to four photos) and read (receipts), for the signed-in customer's own orders in this storefront. - The chat is a core chat channel tagged with the order in its meta, so the driver sees it in Navigator; participants are kept to the customer and the currently assigned driver, and it is started when a driver is assigned. - Customers can read but not send once the order is completed, canceled or expired. - Driver messages are pushed to the customer through storefront push, the inbox and the customer's broadcast channel. - Customer socket tokens include the customer's user uuid so they can subscribe to chat_channel.{uuid}, which core authorizes by participant. --- .../Controllers/v1/OrderChatController.php | 245 ++++++ .../Requests/SendOrderChatMessageRequest.php | 33 + server/src/Http/Resources/OrderChat.php | 56 ++ .../src/Http/Resources/OrderChatMessage.php | 67 ++ .../Listeners/HandleOrderDriverAssigned.php | 9 + .../StorefrontOrderChatMessage.php | 96 +++ server/src/Observers/ChatMessageObserver.php | 37 + .../Providers/StorefrontServiceProvider.php | 1 + server/src/Support/OrderChat.php | 137 +++ server/src/Support/StorefrontSocket.php | 4 +- server/src/routes.php | 6 +- .../Controllers/OrderChatControllerTest.php | 795 ++++++++++++++++++ .../Unit/Routes/StorefrontRoutesTest.php | 4 + .../Unit/Support/StorefrontSocketTest.php | 15 +- 14 files changed, 1498 insertions(+), 7 deletions(-) create mode 100644 server/src/Http/Controllers/v1/OrderChatController.php create mode 100644 server/src/Http/Requests/SendOrderChatMessageRequest.php create mode 100644 server/src/Http/Resources/OrderChat.php create mode 100644 server/src/Http/Resources/OrderChatMessage.php create mode 100644 server/src/Notifications/StorefrontOrderChatMessage.php create mode 100644 server/src/Observers/ChatMessageObserver.php create mode 100644 server/src/Support/OrderChat.php create mode 100644 server/tests/Unit/Http/Controllers/OrderChatControllerTest.php diff --git a/server/src/Http/Controllers/v1/OrderChatController.php b/server/src/Http/Controllers/v1/OrderChatController.php new file mode 100644 index 00000000..e306c93b --- /dev/null +++ b/server/src/Http/Controllers/v1/OrderChatController.php @@ -0,0 +1,245 @@ +resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [$order, $customer, $channel] = $context; + + $customerUserUuid = $customer->user_uuid; + $participant = OrderChat::participantFor($channel, $customerUserUuid); + $messages = $this->messageQuery($channel)->limit(static::PAGE_SIZE)->get()->reverse()->values(); + $unread = $participant ? $channel->getUnreadMessagesForParticipant($participant)->count() : 0; + + $channel->load(['participants.user']); + + return new OrderChatResource($channel, $order, $customerUserUuid, $messages, $unread); + } + + /** + * Older messages, newest first in the query and returned oldest first. + * + * Query params: `before` (a message id) and `limit` (up to 100). + */ + public function messages(Request $request, string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [, $customer, $channel] = $context; + + $limit = min(max((int) $request->input('limit', static::PAGE_SIZE), 1), static::MAX_PAGE_SIZE); + $query = $this->messageQuery($channel); + + if ($request->filled('before')) { + $before = ChatMessage::where('chat_channel_uuid', $channel->uuid)->where('public_id', $request->input('before'))->first(); + if (!$before) { + return response()->apiError('Message not found.', 404); + } + $query->where(fn ($older) => $older->where('created_at', '<', $before->created_at) + ->orWhere(fn ($sameTime) => $sameTime->where('created_at', $before->created_at)->where('id', '<', $before->id))); + } + + $messages = $query->limit($limit)->get()->reverse()->values(); + + return response()->json(['messages' => OrderChatMessage::list($messages, $customer->user_uuid)]); + } + + /** + * Send a message as the customer. Closed once the order is completed or canceled. + */ + public function send(SendOrderChatMessageRequest $request, string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [$order, $customer, $channel] = $context; + + if (OrderChat::isClosed($order)) { + return response()->json(['error' => 'This chat closed when the order finished.', 'reason' => 'chat_closed'], 423); + } + + $sender = OrderChat::participantFor($channel, $customer->user_uuid); + if (!$sender) { + return response()->apiError('You are not part of this chat.', 403); + } + + $message = ChatMessage::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_channel_uuid' => $channel->uuid, + 'sender_uuid' => $sender->uuid, + 'content' => (string) $request->input('content', ''), + ]); + + foreach ((array) $request->input('files', []) as $upload) { + $this->attachPhoto($message, $sender, $customer, $upload); + } + + $message->load(['sender.user', 'attachments.file', 'receipts']); + $message->notifyParticipants(); + + return response()->json(['message' => (new OrderChatMessage($message, $customer->user_uuid))->resolve()]); + } + + /** + * Mark every message from the driver as read by the customer. + */ + public function read(string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [, $customer, $channel] = $context; + + $participant = OrderChat::participantFor($channel, $customer->user_uuid); + if (!$participant) { + return response()->apiError('You are not part of this chat.', 403); + } + + $unread = $channel->getUnreadMessagesForParticipant($participant); + foreach ($unread as $message) { + ChatReceipt::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_message_uuid' => $message->uuid, + 'participant_uuid' => $participant->uuid, + ]); + } + + return response()->json(['read' => $unread->count()]); + } + + /** + * Resolve the signed-in customer, their order in this storefront and its chat. + * + * @return array{0: Order, 1: Contact, 2: ChatChannel}|JsonResponse + */ + protected function resolveContext(string $id): array|JsonResponse + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.', 401); + } + + $order = Order::where('public_id', $id)->with(['customer', 'driverAssigned'])->first(); + if (!$order || !$this->belongsToStorefront($order)) { + return response()->apiError('Order not found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + // Once the order is finished the chat stays readable, but is not started any more. + $channel = OrderChat::isClosed($order) ? OrderChat::find($order) : OrderChat::open($order); + if (!$channel) { + return response()->json([ + 'error' => 'You can message your driver once one is assigned to this order.', + 'reason' => 'driver_not_assigned', + ], 409); + } + + return [$order, $customer, $channel]; + } + + /** + * Whether the order was placed through the storefront the request is made with. + */ + protected function belongsToStorefront(Order $order): bool + { + $about = Storefront::about(); + if (!$about) { + return false; + } + + $storefrontId = $about->is_network ? $order->getMeta('storefront_network_id') : $order->getMeta('storefront_id'); + + return $storefrontId === $about->public_id; + } + + protected function messageQuery(ChatChannel $channel): Builder + { + return ChatMessage::where('chat_channel_uuid', $channel->uuid) + ->with(['sender.user', 'attachments.file', 'receipts']) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + /** + * Store a base64 photo and attach it to the message. Written without a public ACL: the + * media bucket is private and file URLs are served through the File model. + * + * @param array{data?: string, type?: string} $upload + */ + protected function attachPhoto(ChatMessage $message, ChatParticipant $sender, Contact $customer, array $upload): void + { + $disk = config('filesystems.default'); + $bucket = config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket')); + $data = base64_decode((string) ($upload['data'] ?? '')); + $mimeType = (string) ($upload['type'] ?? 'image/jpeg'); + $extension = File::getExtensionFromMimeType($mimeType); + $path = 'hyperstore/' . data_get(Storefront::about(), 'public_id') . '/order-chat/' . $message->chat_channel_uuid . '/' . File::randomFileName($extension); + + Storage::disk($disk)->put($path, $data); + + $file = File::create([ + 'company_uuid' => $message->company_uuid, + 'uploader_uuid' => $customer->user_uuid, + 'subject_uuid' => $message->uuid, + 'subject_type' => ChatMessage::class, + 'disk' => $disk, + 'original_filename' => basename($path), + 'content_type' => $mimeType, + 'path' => $path, + 'bucket' => $bucket, + 'type' => 'storefront_chat_upload', + 'file_size' => strlen($data), + ]); + + ChatAttachment::create([ + 'company_uuid' => $message->company_uuid, + 'chat_channel_uuid' => $message->chat_channel_uuid, + 'chat_message_uuid' => $message->uuid, + 'sender_uuid' => $sender->uuid, + 'file_uuid' => $file->uuid, + ]); + } +} diff --git a/server/src/Http/Requests/SendOrderChatMessageRequest.php b/server/src/Http/Requests/SendOrderChatMessageRequest.php new file mode 100644 index 00000000..edc53322 --- /dev/null +++ b/server/src/Http/Requests/SendOrderChatMessageRequest.php @@ -0,0 +1,33 @@ + 'required_without:files|nullable|string|max:2000', + 'files' => 'required_without:content|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/', + ]; + } +} diff --git a/server/src/Http/Resources/OrderChat.php b/server/src/Http/Resources/OrderChat.php new file mode 100644 index 00000000..16171f4e --- /dev/null +++ b/server/src/Http/Resources/OrderChat.php @@ -0,0 +1,56 @@ + $messages latest messages, oldest first + */ + public function __construct(ChatChannel $channel, public Order $order, public ?string $customerUserUuid, public iterable $messages = [], public int $unreadCount = 0) + { + parent::__construct($channel); + } + + /** + * Transform the resource into an array. + * + * @param \Illuminate\Http\Request $request + * + * @return array + */ + public function toArray($request) + { + $closed = OrderChatSupport::isClosed($this->order); + $me = $this->participants->firstWhere('user_uuid', $this->customerUserUuid); + + return [ + 'id' => $this->public_id, + 'channel' => 'chat_channel.' . $this->uuid, + 'order' => $this->order->public_id, + 'status' => $closed ? 'closed' : 'open', + 'me' => data_get($me, 'public_id'), + 'participants' => $this->participants->map(fn ($participant) => [ + 'id' => $participant->public_id, + 'role' => $participant->user_uuid === $this->customerUserUuid ? 'customer' : 'driver', + 'name' => data_get($participant, 'user.name'), + // Null without an uploaded avatar, so the app shows initials instead of a placeholder. + 'avatar_url' => data_get($participant, 'user.avatar_uuid') ? data_get($participant, 'user.avatar_url') : null, + 'is_online' => (bool) $participant->is_online, + ])->values()->all(), + 'messages' => OrderChatMessage::list($this->messages, $this->customerUserUuid), + 'unread_count' => $this->unreadCount, + ]; + } +} diff --git a/server/src/Http/Resources/OrderChatMessage.php b/server/src/Http/Resources/OrderChatMessage.php new file mode 100644 index 00000000..7c0e5e44 --- /dev/null +++ b/server/src/Http/Resources/OrderChatMessage.php @@ -0,0 +1,67 @@ +sender; + $senderUser = data_get($sender, 'user_uuid'); + $isMine = $senderUser !== null && $senderUser === $this->customerUserUuid; + + return [ + 'id' => $this->public_id, + 'content' => $this->content, + 'sender' => [ + 'id' => data_get($sender, 'public_id'), + 'role' => $isMine ? 'customer' : 'driver', + 'name' => data_get($sender, 'user.name'), + ], + 'is_mine' => $isMine, + 'attachments' => $this->attachments->map(fn ($attachment) => [ + 'id' => data_get($attachment, 'file.public_id'), + 'url' => data_get($attachment, 'file.url'), + 'type' => data_get($attachment, 'file.content_type'), + ])->values()->all(), + // Read by someone other than the sender. + 'read' => $this->receipts->contains(fn ($receipt) => $receipt->participant_uuid !== $this->sender_uuid), + 'created_at' => $this->created_at, + ]; + } + + /** + * @param iterable $messages + * + * @return array + */ + public static function list(iterable $messages, ?string $customerUserUuid): array + { + $items = []; + foreach ($messages as $message) { + $items[] = (new static($message, $customerUserUuid))->resolve(); + } + + return $items; + } +} diff --git a/server/src/Listeners/HandleOrderDriverAssigned.php b/server/src/Listeners/HandleOrderDriverAssigned.php index 6585e0f7..7687978d 100644 --- a/server/src/Listeners/HandleOrderDriverAssigned.php +++ b/server/src/Listeners/HandleOrderDriverAssigned.php @@ -5,6 +5,8 @@ use Fleetbase\FleetOps\Events\OrderDriverAssigned; use Fleetbase\FleetOps\Models\Order; use Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned; +use Fleetbase\Storefront\Support\OrderChat; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; @@ -36,6 +38,13 @@ public function handle(OrderDriverAssigned $event) if ($order->customer) { $order->customer->notify(new StorefrontOrderDriverAssigned($order)); } + + // Start the order chat so it is waiting in the driver's chat list. + try { + OrderChat::open($order); + } catch (\Throwable $e) { + app(ExceptionHandler::class)->report($e); + } } } } diff --git a/server/src/Notifications/StorefrontOrderChatMessage.php b/server/src/Notifications/StorefrontOrderChatMessage.php new file mode 100644 index 00000000..753060a1 --- /dev/null +++ b/server/src/Notifications/StorefrontOrderChatMessage.php @@ -0,0 +1,96 @@ +message, 'sender.user.name') ?: 'Your driver'; + + return $name . ' sent a message'; + } + + public function body(): string + { + $content = trim((string) $this->message->content); + + return $content !== '' ? $content : 'Sent a photo'; + } + + public function toPush($notifiable): ?PushMessage + { + return PushMessage::create($this->title(), $this->body(), $this->payload())->analyticsLabel('storefront_order_chat'); + } + + public function pushStorefronts(): array + { + return PushCredentialResolver::storefrontsForOrder($this->order); + } + + public function toArray($notifiable): array + { + return [ + 'title' => $this->title(), + 'body' => $this->body(), + 'subject' => $this->title(), + 'message' => $this->body(), + ...$this->payload(), + ]; + } + + public function toBroadcast($notifiable): BroadcastMessage + { + return new BroadcastMessage(CustomerNotificationPresenter::present($this->toArray($notifiable), static::class)); + } + + public function broadcastType(): string + { + return 'order_chat_message'; + } + + /** + * @return array + */ + protected function payload(): array + { + return array_filter([ + 'type' => 'order_chat_message', + 'order' => $this->order->uuid, + 'order_id' => $this->order->public_id, + 'chat_id' => data_get($this->message, 'chatChannel.public_id'), + 'message_id' => $this->message->public_id, + 'store_id' => $this->order->getMeta('storefront_id'), + 'network_id' => $this->order->getMeta('storefront_network_id'), + ], fn ($value) => $value !== null); + } +} diff --git a/server/src/Observers/ChatMessageObserver.php b/server/src/Observers/ChatMessageObserver.php new file mode 100644 index 00000000..024bab77 --- /dev/null +++ b/server/src/Observers/ChatMessageObserver.php @@ -0,0 +1,37 @@ +chatChannel; + if (!$channel || !data_get($channel->meta, OrderChat::META_ORDER_UUID)) { + return; + } + + $order = OrderChat::orderFor($channel); + if (!$order) { + return; + } + + $customer = $order->customer; + $sender = $message->sender; + if (!$customer || !$sender || $sender->user_uuid === data_get($customer, 'user_uuid')) { + return; + } + + $customer->notify(new StorefrontOrderChatMessage($message, $order)); + } +} diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 79b25d48..b8b23369 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -35,6 +35,7 @@ class StorefrontServiceProvider extends CoreServiceProvider \Fleetbase\Storefront\Models\Catalog::class => \Fleetbase\Storefront\Observers\CatalogObserver::class, \Fleetbase\Storefront\Models\FoodTruck::class => \Fleetbase\Storefront\Observers\FoodTruckObserver::class, \Fleetbase\Models\Company::class => \Fleetbase\Storefront\Observers\CompanyObserver::class, + \Fleetbase\Models\ChatMessage::class => \Fleetbase\Storefront\Observers\ChatMessageObserver::class, ]; /** diff --git a/server/src/Support/OrderChat.php b/server/src/Support/OrderChat.php new file mode 100644 index 00000000..c204b090 --- /dev/null +++ b/server/src/Support/OrderChat.php @@ -0,0 +1,137 @@ +company_uuid) + ->where('meta->' . static::META_ORDER_UUID, $order->uuid) + ->first(); + } + + /** + * The order's chat, started when needed, with the customer and the assigned driver as its + * only participants. Null when either side has no user account yet, typically because no + * driver has been assigned. + */ + public static function open(Order $order): ?ChatChannel + { + $customerUserUuid = static::customerUserUuid($order); + $driverUserUuid = static::driverUserUuid($order); + if (!$customerUserUuid || !$driverUserUuid) { + return null; + } + + $channel = static::find($order); + if (!$channel) { + // Creating the channel adds its creator, the customer, as the first participant. + $channel = ChatChannel::create([ + 'company_uuid' => $order->company_uuid, + 'created_by_uuid' => $customerUserUuid, + 'name' => 'Order ' . $order->public_id, + 'meta' => [ + static::META_ORDER_UUID => $order->uuid, + static::META_ORDER_ID => $order->public_id, + ], + ]); + } + + static::syncParticipants($channel, [$customerUserUuid, $driverUserUuid]); + + return $channel; + } + + /** + * Whether the customer can still send messages. + */ + public static function isClosed(Order $order): bool + { + return in_array($order->status, static::CLOSED_STATUSES, true); + } + + /** + * The channel participant for a user, if they take part. + */ + public static function participantFor(ChatChannel $channel, ?string $userUuid): ?ChatParticipant + { + if (!$userUuid) { + return null; + } + + return ChatParticipant::where('chat_channel_uuid', $channel->uuid)->where('user_uuid', $userUuid)->first(); + } + + /** + * The order a chat channel belongs to, when it is an order chat. + */ + public static function orderFor(ChatChannel $channel): ?Order + { + $orderUuid = data_get($channel->meta, static::META_ORDER_UUID); + + return $orderUuid ? Order::where('uuid', $orderUuid)->first() : null; + } + + public static function customerUserUuid(Order $order): ?string + { + return data_get($order->customer, 'user_uuid'); + } + + public static function driverUserUuid(Order $order): ?string + { + return data_get($order->driverAssigned, 'user_uuid'); + } + + /** + * Add missing participants and remove anyone else, such as a driver the order was taken + * away from, so a reassigned order's chat moves to the new driver. + * + * @param array $userUuids + */ + protected static function syncParticipants(ChatChannel $channel, array $userUuids): void + { + $existing = ChatParticipant::where('chat_channel_uuid', $channel->uuid)->get(); + + foreach ($existing as $participant) { + if (!in_array($participant->user_uuid, $userUuids, true)) { + $participant->delete(); + } + } + + $present = $existing->pluck('user_uuid')->all(); + foreach ($userUuids as $userUuid) { + if (!in_array($userUuid, $present, true)) { + ChatParticipant::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_channel_uuid' => $channel->uuid, + 'user_uuid' => $userUuid, + ]); + } + } + } +} diff --git a/server/src/Support/StorefrontSocket.php b/server/src/Support/StorefrontSocket.php index f8d1b66a..288b9c74 100644 --- a/server/src/Support/StorefrontSocket.php +++ b/server/src/Support/StorefrontSocket.php @@ -38,7 +38,9 @@ public static function customerPrincipal(Contact $customer, Store|Network $store 'cid' => $storefront->company_uuid, 'cpid' => static::companyPublicId($storefront->company_uuid), 'env' => static::ENV, - 'ids' => [$customer->uuid, $customer->public_id], + // The user uuid lets the customer join chats they take part in, such as their + // order chat with the driver: core authorizes chat channels by participant user. + 'ids' => array_values(array_filter([$customer->uuid, $customer->public_id, $customer->user_uuid])), 'adm' => false, 'scp' => null, 'sid' => $storefront->uuid, diff --git a/server/src/routes.php b/server/src/routes.php index 5cec3919..379ffc0e 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -97,6 +97,10 @@ function ($router) { $router->group(['prefix' => 'orders'], function () use ($router) { $router->put('picked-up', 'OrderController@completeOrderPickup'); $router->post('receipt', 'OrderController@getReceipt'); + $router->get('{id}/chat', 'OrderChatController@show'); + $router->get('{id}/chat/messages', 'OrderChatController@messages'); + $router->post('{id}/chat/messages', 'OrderChatController@send'); + $router->post('{id}/chat/read', 'OrderChatController@read'); }); // storefront/v1/promotions @@ -104,7 +108,7 @@ function ($router) { $router->get('/', 'PromotionController@query'); $router->get('{id}', 'PromotionController@find'); }); - + // storefront/v1/notifications $router->group(['prefix' => 'notifications'], function () use ($router) { $router->get('/', 'NotificationController@query'); diff --git a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php new file mode 100644 index 00000000..3db3b4b8 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php @@ -0,0 +1,795 @@ +writes[] = [$path, $contents, $options]; + + return true; + } + + public function url($path) + { + return 'https://cdn.test/' . $path; + } + + public function exists($path) + { + return false; + } + + public function get($path) + { + return null; + } + + public function readStream($path) + { + return null; + } + + public function writeStream($path, $resource, array $options = []) + { + return false; + } + + public function getVisibility($path) + { + return 'private'; + } + + public function setVisibility($path, $visibility) + { + return false; + } + + public function prepend($path, $data) + { + return false; + } + + public function append($path, $data) + { + return false; + } + + public function delete($paths) + { + return false; + } + + public function copy($from, $to) + { + return false; + } + + public function move($from, $to) + { + return false; + } + + public function size($path) + { + return 0; + } + + public function lastModified($path) + { + return 0; + } + + public function files($directory = null, $recursive = false) + { + return []; + } + + public function allFiles($directory = null) + { + return []; + } + + public function directories($directory = null, $recursive = false) + { + return []; + } + + public function allDirectories($directory = null) + { + return []; + } + + public function makeDirectory($path) + { + return false; + } + + public function deleteDirectory($directory) + { + return false; + } +} + +function orderChatDb(): Illuminate\Database\Connection +{ + return Model::getConnectionResolver()->connection('mysql'); +} + +function createOrderChatSchema(): void +{ + $schema = orderChatDb()->getSchemaBuilder(); + foreach (['stores', 'networks', 'contacts', 'personal_access_tokens', 'users', 'drivers', 'orders', 'chat_channels', 'chat_participants', 'chat_messages', 'chat_attachments', 'chat_receipts', 'chat_logs', 'files'] as $table) { + $schema->dropIfExists($table); + } + foreach (['stores', 'networks'] as $storefrontTable) { + $schema->create($storefrontTable, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->text('translations')->nullable(); + $table->text('tags')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $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->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_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'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + $schema->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('avatar_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('type')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('last_login')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('drivers', 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->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('customer_type')->nullable(); + $table->string('driver_assigned_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_channels', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_participants', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_messages', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('sender_uuid')->nullable(); + $table->text('content')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_attachments', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('chat_message_uuid')->nullable(); + $table->string('sender_uuid')->nullable(); + $table->string('file_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_receipts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_message_uuid')->nullable(); + $table->string('participant_uuid')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_logs', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('initiator_uuid')->nullable(); + $table->string('event_type')->nullable(); + $table->text('content')->nullable(); + $table->text('subjects')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('files', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('uploader_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('name')->nullable(); + $table->string('original_filename')->nullable(); + $table->string('slug')->nullable(); + $table->string('disk')->nullable(); + $table->string('extension')->nullable(); + $table->string('content_type')->nullable(); + $table->string('path')->nullable(); + $table->string('bucket')->nullable(); + $table->string('type')->nullable(); + $table->integer('file_size')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $db = orderChatDb(); + $db->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_bloom', 'company_uuid' => 'company_uuid', 'key' => 'store_key', 'name' => 'Bloom']); + $db->table('networks')->insert(['uuid' => 'network_uuid', 'public_id' => 'network_market', 'company_uuid' => 'company_uuid', 'key' => 'network_key', 'name' => 'Market']); + $db->table('users')->insert([ + ['uuid' => 'customer_user_uuid', 'public_id' => 'user_customer', 'company_uuid' => 'company_uuid', 'name' => 'Mei Tan'], + ['uuid' => 'driver_user_uuid', 'public_id' => 'user_driver', 'company_uuid' => 'company_uuid', 'name' => 'Ravi K.'], + ['uuid' => 'second_driver_user_uuid', 'public_id' => 'user_driver_2', 'company_uuid' => 'company_uuid', 'name' => 'Arun S.'], + ]); + $db->table('contacts')->insert([ + ['uuid' => CHAT_CUSTOMER_UUID, 'public_id' => 'contact_mei', 'company_uuid' => 'company_uuid', 'user_uuid' => 'customer_user_uuid', 'type' => 'customer', 'name' => 'Mei Tan'], + ['uuid' => 'other_customer_uuid', 'public_id' => 'contact_other', 'company_uuid' => 'company_uuid', 'user_uuid' => 'other_user_uuid', 'type' => 'customer', 'name' => 'Other'], + ]); + $db->table('drivers')->insert([ + ['uuid' => 'driver_uuid', 'public_id' => 'driver_ravi', 'company_uuid' => 'company_uuid', 'user_uuid' => 'driver_user_uuid'], + ['uuid' => 'second_driver_uuid', 'public_id' => 'driver_arun', 'company_uuid' => 'company_uuid', 'user_uuid' => 'second_driver_user_uuid'], + ]); + $order = fn (string $publicId, array $overrides = []) => array_merge([ + 'uuid' => $publicId . '_uuid', + 'public_id' => $publicId, + 'company_uuid' => 'company_uuid', + 'customer_uuid' => CHAT_CUSTOMER_UUID, + 'customer_type' => Contact::class, + 'driver_assigned_uuid' => 'driver_uuid', + 'status' => 'dispatched', + 'meta' => json_encode(['storefront_id' => 'store_bloom']), + ], $overrides); + $db->table('orders')->insert([ + $order('order_active'), + $order('order_waiting', ['driver_assigned_uuid' => null]), + $order('order_done', ['status' => 'completed']), + $order('order_done_never_chatted', ['status' => 'completed']), + $order('order_other_customer', ['customer_uuid' => 'other_customer_uuid']), + $order('order_other_store', ['meta' => json_encode(['storefront_id' => 'store_elsewhere'])]), + $order('order_network', ['meta' => json_encode(['storefront_id' => 'store_bloom', 'storefront_network_id' => 'network_market'])]), + ]); + $db->table('personal_access_tokens')->insert([ + 'name' => CHAT_CUSTOMER_UUID, + 'token' => hash('sha256', 'chat-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function bindOrderChatRequest(?string $token = 'chat-secret', string $storefrontKey = 'store_key', array $input = [], string $method = 'GET', string $requestClass = Request::class): Request +{ + $request = $requestClass::create('/orders/chat', $method, $input); + if ($token) { + $request->headers->set('Customer-Token', $token); + } + $request->setLaravelSession(new Illuminate\Session\Store('order-chat', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $request); + session(['company' => 'company_uuid', 'storefront_key' => $storefrontKey, 'storefront_store' => null, 'storefront_network' => null]); + + return $request; +} + +function orderChatData($response): array +{ + return $response->resolve(request()); +} + +function driverSays(ChatChannel $channel, string $content, string $at, string $driverUserUuid = 'driver_user_uuid'): ChatMessage +{ + $sender = ChatParticipant::where('chat_channel_uuid', $channel->uuid)->where('user_uuid', $driverUserUuid)->first(); + + $message = ChatMessage::create(['company_uuid' => 'company_uuid', 'chat_channel_uuid' => $channel->uuid, 'sender_uuid' => $sender->uuid, 'content' => $content]); + orderChatDb()->table('chat_messages')->where('uuid', $message->uuid)->update(['created_at' => $at]); + + return $message->fresh(); +} + +/** + * Records notifications instead of sending them. + */ +function fakeChatNotifications(): object +{ + $dispatcher = new class implements Illuminate\Contracts\Notifications\Dispatcher { + public array $sent = []; + + public function send($notifiables, $notification) + { + $this->sendNow($notifiables, $notification); + } + + public function sendNow($notifiables, $notification, ?array $channels = null) + { + foreach (is_iterable($notifiables) && !$notifiables instanceof Model ? $notifiables : [$notifiables] as $notifiable) { + $this->sent[] = [$notifiable, $notification]; + } + } + + public function sentTo(string $uuid, string $class): array + { + return array_values(array_map(fn ($pair) => $pair[1], array_filter($this->sent, fn ($pair) => data_get($pair[0], 'uuid') === $uuid && $pair[1] instanceof $class))); + } + }; + app()->instance(Illuminate\Contracts\Notifications\Dispatcher::class, $dispatcher); + Illuminate\Support\Facades\Notification::clearResolvedInstance(Illuminate\Contracts\Notifications\Dispatcher::class); + + return $dispatcher; +} + +beforeEach(function () { + createOrderChatSchema(); + // Model events generate uuids and public ids, and run ChatChannel's creator hook. + Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app())); + Model::clearBootedModels(); + app()->instance('responsecache', new class { + public function clear(): void + { + } + }); + Spatie\ResponseCache\Facades\ResponseCache::clearResolvedInstance('responsecache'); + // Core macros and a disabled activity log, as the framework would provide them. + if (!Illuminate\Support\Str::hasMacro('humanize')) { + Illuminate\Support\Str::macro('humanize', (new Fleetbase\Expansions\Str())->humanize()); + } + app()->instance(Spatie\Activitylog\ActivityLogStatus::class, new Spatie\Activitylog\ActivityLogStatus(new Illuminate\Config\Repository(['activitylog' => ['enabled' => false]]))); + $this->notifications = fakeChatNotifications(); +}); + +afterEach(function () { + Model::unsetEventDispatcher(); + Model::clearBootedModels(); + app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); +}); + +test('order chat requires a signed-in customer and their own order in this storefront', function () { + $controller = new OrderChatController(); + + bindOrderChatRequest(null); + $signedOut = $controller->show('order_active'); + bindOrderChatRequest(); + $missing = $controller->show('order_missing'); + $otherStore = $controller->show('order_other_store'); + $notMine = $controller->show('order_other_customer'); + bindOrderChatRequest('chat-secret', 'unknown_key'); + $noStorefront = $controller->show('order_active'); + + expect($signedOut->getStatusCode())->toBe(401) + ->and($missing->getStatusCode())->toBe(404) + ->and($otherStore->getStatusCode())->toBe(404) + ->and($notMine->getStatusCode())->toBe(403) + ->and($noStorefront->getStatusCode())->toBe(404); +}); + +test('order chat waits for a driver and stays closed to new chats after the order finishes', function () { + bindOrderChatRequest(); + $controller = new OrderChatController(); + + $waiting = $controller->show('order_waiting'); + $finished = $controller->show('order_done_never_chatted'); + + expect($waiting->getStatusCode())->toBe(409) + ->and($waiting->getData(true))->toBe([ + 'error' => 'You can message your driver once one is assigned to this order.', + 'reason' => 'driver_not_assigned', + ]) + ->and($finished->getStatusCode())->toBe(409) + ->and(ChatChannel::query()->count())->toBe(0); +}); + +test('opening an order chat creates one channel with the customer and driver', function () { + bindOrderChatRequest(); + $controller = new OrderChatController(); + + $first = orderChatData($controller->show('order_active')); + $second = orderChatData($controller->show('order_active')); + $channel = ChatChannel::query()->first(); + + expect(ChatChannel::query()->count())->toBe(1) + ->and($channel->name)->toBe('Order order_active') + ->and(data_get($channel->meta, 'storefront_order_uuid'))->toBe('order_active_uuid') + ->and(data_get($channel->meta, 'storefront_order_id'))->toBe('order_active') + ->and($first['id'])->toBe($channel->public_id) + ->and($first['channel'])->toBe('chat_channel.' . $channel->uuid) + ->and($first['order'])->toBe('order_active') + ->and($first['status'])->toBe('open') + ->and($first['me'])->toBe(OrderChat::participantFor($channel, 'customer_user_uuid')->public_id) + ->and(collect($first['participants'])->pluck('role', 'name')->all())->toBe(['Mei Tan' => 'customer', 'Ravi K.' => 'driver']) + ->and($first['participants'][0]['is_online'])->toBeFalse() + ->and($first['participants'][0]['avatar_url'])->toBeNull() + ->and($first['messages'])->toBe([]) + ->and($first['unread_count'])->toBe(0) + ->and($second['id'])->toBe($first['id']); +}); + +test('network apps open chats for orders placed through the network', function () { + bindOrderChatRequest('chat-secret', 'network_key'); + + $response = orderChatData((new OrderChatController())->show('order_network')); + + expect($response['order'])->toBe('order_network'); +}); + +test('customers send text and photos, and only driver messages are pushed to the customer', function () { + $storage = new OrderChatFakeDisk(); + Illuminate\Support\Facades\Storage::swap($storage); + config(['filesystems.default' => 'public', 'filesystems.disks.public.bucket' => 'media-bucket']); + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + + $request = bindOrderChatRequest('chat-secret', 'store_key', [ + 'content' => 'Tower B, unit 08-112', + 'files' => [['data' => base64_encode('photo-bytes'), 'type' => 'image/png']], + ], 'POST', SendOrderChatMessageRequest::class); + $sent = $controller->send($request, 'order_active')->getData(true)['message']; + $file = orderChatDb()->table('files')->first(); + + $driverMessage = driverSays($channel, '', now()->toDateTimeString()); + + expect($sent['content'])->toBe('Tower B, unit 08-112') + ->and($sent['is_mine'])->toBeTrue() + ->and($sent['sender']['role'])->toBe('customer') + ->and($sent['sender']['name'])->toBe('Mei Tan') + ->and($sent['read'])->toBeFalse() + ->and($sent['attachments'])->toHaveCount(1) + ->and($sent['attachments'][0]['type'])->toBe('image/png') + ->and($sent['attachments'][0]['url'])->toStartWith('https://cdn.test/hyperstore/store_bloom/order-chat/' . $channel->uuid . '/') + ->and($file->disk)->toBe('public') + ->and($file->bucket)->toBe('media-bucket') + ->and($file->type)->toBe('storefront_chat_upload') + ->and($file->file_size)->toBe(strlen('photo-bytes')) + ->and($file->subject_type)->toBe(ChatMessage::class) + ->and($storage->writes[0][1])->toBe('photo-bytes') + // chat photos are written without a public ACL + ->and($storage->writes[0][2])->toBe([]); + + // The customer's own message is not pushed back to them; the driver's is (here the + // observer is called directly, since this harness has no model event dispatcher). + (new ChatMessageObserver())->created($driverMessage); + $pushed = $this->notifications->sentTo(CHAT_CUSTOMER_UUID, StorefrontOrderChatMessage::class); + $driverNotified = $this->notifications->sentTo('driver_user_uuid', Fleetbase\Notifications\ChatMessageReceived::class); + + expect($pushed)->toHaveCount(1) + ->and($pushed[0]->message->uuid)->toBe($driverMessage->uuid) + ->and($driverNotified)->toHaveCount(1); +}); + +test('messages page backwards with a stable cursor and read receipts clear the unread count', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + $first = driverSays($channel, 'Picked up your flowers', '2026-10-06 13:51:00'); + $second = driverSays($channel, 'At the lobby', '2026-10-06 14:00:00'); + $third = driverSays($channel, 'Which tower?', '2026-10-06 14:00:00'); + + $opened = orderChatData($controller->show('order_active')); + $page = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => $third->public_id, 'limit' => 1]), 'order_active')->getData(true); + $earlier = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => $second->public_id]), 'order_active')->getData(true); + $unknown = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => 'chat_message_missing']), 'order_active'); + $all = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['limit' => 500]), 'order_active')->getData(true); + bindOrderChatRequest(); + $read = $controller->read('order_active')->getData(true); + $after = orderChatData($controller->show('order_active')); + $again = $controller->read('order_active')->getData(true); + + expect(collect($opened['messages'])->pluck('content')->all())->toBe(['Picked up your flowers', 'At the lobby', 'Which tower?']) + ->and($opened['messages'][0]['is_mine'])->toBeFalse() + ->and($opened['messages'][0]['sender']['role'])->toBe('driver') + ->and($opened['unread_count'])->toBe(3) + ->and(collect($page['messages'])->pluck('id')->all())->toBe([$second->public_id]) + ->and(collect($earlier['messages'])->pluck('id')->all())->toBe([$first->public_id]) + ->and($unknown->getStatusCode())->toBe(404) + ->and($all['messages'])->toHaveCount(3) + ->and($read)->toBe(['read' => 3]) + ->and($after['unread_count'])->toBe(0) + ->and($after['messages'][2]['read'])->toBeTrue() + ->and($again)->toBe(['read' => 0]); +}); + +test('reassigning the order moves the chat to the new driver', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['driver_assigned_uuid' => 'second_driver_uuid']); + + $response = orderChatData($controller->show('order_active')); + + expect(collect($response['participants'])->pluck('name')->sort()->values()->all())->toBe(['Arun S.', 'Mei Tan']) + ->and(ChatChannel::query()->count())->toBe(1); +}); + +test('finished orders keep their chat readable but refuse new messages', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'completed']); + + $shown = orderChatData($controller->show('order_active')); + $request = bindOrderChatRequest('chat-secret', 'store_key', ['content' => 'Thanks!'], 'POST', SendOrderChatMessageRequest::class); + $sent = $controller->send($request, 'order_active'); + + expect($shown['status'])->toBe('closed') + ->and($sent->getStatusCode())->toBe(423) + ->and($sent->getData(true))->toBe(['error' => 'This chat closed when the order finished.', 'reason' => 'chat_closed']) + ->and(ChatMessage::query()->count())->toBe(0); +}); + +test('customers removed from a finished order chat cannot mark messages read until it is reopened', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + // A channel whose customer participant is gone (for example removed by an operator) and + // that is not re-synced because the order has finished. + OrderChat::participantFor($channel, 'customer_user_uuid')->delete(); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'canceled']); + $read = $controller->read('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'dispatched']); + $channel->refresh(); + + expect($read->getStatusCode())->toBe(403) + ->and(OrderChat::participantFor($channel, null))->toBeNull(); + + // Re-opening the chat for an active order restores the customer. + bindOrderChatRequest(); + $controller->show('order_active'); + expect(OrderChat::participantFor($channel, 'customer_user_uuid'))->not->toBeNull(); +}); + +test('a customer missing from an open chat cannot send messages', function () { + $controller = new class extends OrderChatController { + protected function resolveContext(string $id): array|Illuminate\Http\JsonResponse + { + $context = parent::resolveContext($id); + OrderChat::participantFor($context[2], 'customer_user_uuid')?->delete(); + + return $context; + } + }; + $request = bindOrderChatRequest('chat-secret', 'store_key', ['content' => 'Hi'], 'POST', SendOrderChatMessageRequest::class); + + $sent = $controller->send($request, 'order_active'); + $shown = orderChatData($controller->show('order_active')); + + expect($sent->getStatusCode())->toBe(403) + ->and($shown['unread_count'])->toBe(0) + ->and($shown['me'])->toBeNull(); +}); + +test('every chat action reports a missing context the same way', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(null); + + expect($controller->messages(request(), 'order_active')->getStatusCode())->toBe(401) + ->and($controller->read('order_active')->getStatusCode())->toBe(401) + ->and($controller->send(SendOrderChatMessageRequest::create('/', 'POST', ['content' => 'x']), 'order_active')->getStatusCode())->toBe(401); +}); + +test('the chat observer ignores non-order chats, missing orders and customers, and customer messages', function () { + $observer = new ChatMessageObserver(); + $plain = ChatChannel::create(['company_uuid' => 'company_uuid', 'created_by_uuid' => 'driver_user_uuid', 'name' => 'Dispatch']); + $orphan = ChatChannel::create(['company_uuid' => 'company_uuid', 'created_by_uuid' => 'driver_user_uuid', 'name' => 'Old order', 'meta' => ['storefront_order_uuid' => 'gone_uuid']]); + $sender = ChatParticipant::where('chat_channel_uuid', $plain->uuid)->first(); + + $observer->created(new ChatMessage(['chat_channel_uuid' => 'missing_channel'])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $plain->uuid, 'sender_uuid' => $sender->uuid])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $orphan->uuid, 'sender_uuid' => $sender->uuid])); + + $order = Order::where('public_id', 'order_active')->first(); + $chat = OrderChat::open($order); + $customerParticipant = OrderChat::participantFor($chat, 'customer_user_uuid'); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => $customerParticipant->uuid])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => 'missing_participant'])); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['customer_uuid' => 'nobody']); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => $customerParticipant->uuid])); + + expect($this->notifications->sent)->toBe([]); +}); + +test('order chat helpers find the order and refuse to open without accounts', function () { + $order = Order::where('public_id', 'order_active')->first(); + $chat = OrderChat::open($order); + orderChatDb()->table('contacts')->where('uuid', CHAT_CUSTOMER_UUID)->update(['user_uuid' => null]); + $guest = Order::where('public_id', 'order_active')->first(); + + expect(OrderChat::orderFor($chat)->uuid)->toBe('order_active_uuid') + ->and(OrderChat::orderFor(new ChatChannel()))->toBeNull() + ->and(OrderChat::find($order)->uuid)->toBe($chat->uuid) + ->and(OrderChat::open($guest))->toBeNull() + ->and(OrderChat::isClosed($order))->toBeFalse(); +}); + +test('chat notifications describe the driver message for push, inbox and sockets', function () { + $order = Order::where('public_id', 'order_network')->first(); + $chat = OrderChat::open($order); + $message = driverSays($chat, 'At the lobby', now()->toDateTimeString()); + $photo = driverSays($chat, ' ', now()->toDateTimeString()); + $anonymous = new ChatMessage(['content' => 'Hi']); + + $notification = new StorefrontOrderChatMessage($message->load(['sender.user', 'chatChannel']), $order); + $array = $notification->toArray(null); + $push = $notification->toPush(null); + + expect($notification->via(null))->toBe([Fleetbase\Storefront\Push\StorefrontPushChannel::class, 'database', Fleetbase\Storefront\Notifications\Channels\SafeBroadcastChannel::class]) + ->and($array)->toMatchArray([ + 'title' => 'Ravi K. sent a message', + 'body' => 'At the lobby', + 'type' => 'order_chat_message', + 'order_id' => 'order_network', + 'chat_id' => $chat->public_id, + 'message_id' => $message->public_id, + 'store_id' => 'store_bloom', + 'network_id' => 'network_market', + ]) + ->and($push)->toBeInstanceOf(Fleetbase\Storefront\Push\PushMessage::class) + ->and($notification->broadcastType())->toBe('order_chat_message') + ->and($notification->toBroadcast(null))->toBeInstanceOf(Illuminate\Notifications\Messages\BroadcastMessage::class) + ->and($notification->pushStorefronts())->toBeArray() + ->and((new StorefrontOrderChatMessage($photo, $order))->body())->toBe('Sent a photo') + ->and((new StorefrontOrderChatMessage($anonymous, $order))->title())->toBe('Your driver sent a message'); +}); + +test('send message requests need text or photos', function () { + session(['storefront_key' => 'store_key']); + $rules = (new SendOrderChatMessageRequest())->rules(); + session(['storefront_key' => null]); + + expect($rules)->toBe([ + 'content' => 'required_without:files|nullable|string|max:2000', + 'files' => 'required_without:content|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/', + ]) + ->and((new SendOrderChatMessageRequest())->authorize())->toBeFalse(); +}); + +test('assigning a driver to a storefront order starts its chat', function () { + $event = (new ReflectionClass(Fleetbase\FleetOps\Events\OrderDriverAssigned::class))->newInstanceWithoutConstructor(); + $event->modelUuid = 'order_active_uuid'; + $event->modelClassNamespace = Order::class; + + (new Fleetbase\Storefront\Listeners\HandleOrderDriverAssigned())->handle($event); + + expect(ChatChannel::query()->count())->toBe(1) + ->and($this->notifications->sentTo(CHAT_CUSTOMER_UUID, Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned::class))->toHaveCount(1); +}); + +test('a failure to start the chat does not stop the driver assignment notification', function () { + orderChatDb()->getSchemaBuilder()->drop('chat_channels'); + $reported = []; + app()->instance(Illuminate\Contracts\Debug\ExceptionHandler::class, new class($reported) implements Illuminate\Contracts\Debug\ExceptionHandler { + public function __construct(public array &$reported) + { + } + + public function report(Throwable $e) + { + $this->reported[] = $e; + } + + public function shouldReport(Throwable $e) + { + return true; + } + + public function render($request, Throwable $e) + { + throw $e; + } + + public function renderForConsole($output, Throwable $e) + { + } + }); + $event = (new ReflectionClass(Fleetbase\FleetOps\Events\OrderDriverAssigned::class))->newInstanceWithoutConstructor(); + $event->modelUuid = 'order_active_uuid'; + $event->modelClassNamespace = Order::class; + + (new Fleetbase\Storefront\Listeners\HandleOrderDriverAssigned())->handle($event); + app()->offsetUnset(Illuminate\Contracts\Debug\ExceptionHandler::class); + + expect($reported)->toHaveCount(1) + ->and($this->notifications->sentTo(CHAT_CUSTOMER_UUID, Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned::class))->toHaveCount(1); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index b6a0dc2f..1fe112a3 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -97,5 +97,9 @@ private function record(string $method, string $uri, mixed $action): self ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], ['GET', '/', 'MetricsController@all'], + ['GET', '{id}/chat', 'OrderChatController@show'], + ['GET', '{id}/chat/messages', 'OrderChatController@messages'], + ['POST', '{id}/chat/messages', 'OrderChatController@send'], + ['POST', '{id}/chat/read', 'OrderChatController@read'], )->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/server/tests/Unit/Support/StorefrontSocketTest.php b/server/tests/Unit/Support/StorefrontSocketTest.php index aa7ec375..e0e606a8 100644 --- a/server/tests/Unit/Support/StorefrontSocketTest.php +++ b/server/tests/Unit/Support/StorefrontSocketTest.php @@ -118,8 +118,8 @@ function storefrontSocketSchema(): void ['uuid' => 'network-a', 'public_id' => 'network_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'network_key_a'], ]); $connection->table('contacts')->insert([ - ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'type' => 'customer'], - ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'type' => 'customer'], + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a', 'type' => 'customer'], + ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'user_uuid' => null, 'type' => 'customer'], ]); $connection->table('checkouts')->insert([ ['uuid' => 'checkout-a', 'public_id' => 'chkt_aaaaaaa', 'company_uuid' => 'company-a', 'store_uuid' => 'store-a', 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_a'], @@ -202,7 +202,9 @@ function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrin ->and($claims)->toBeInstanceOf(SocketPrincipal::class) ->and($claims->kind)->toBe('customer') ->and($claims->sub)->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa']) + // The user uuid lets the customer join chats they take part in (core authorizes + // chat channels by participant user). + ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa', 'user-a']) ->and($claims->cid)->toBe('company-a') ->and($claims->cpid)->toBe('company_aaaaaaa') ->and($claims->sid)->toBe('store-a') @@ -343,8 +345,11 @@ function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrin 'cid' => 'company-a', 'cpid' => 'company_aaaaaaa', 'env' => 'live', - 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa'], + 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa', 'user-a'], 'adm' => false, 'sid' => 'store-a', - ])->and($forNetwork->sid)->toBe('network-a'); + ])->and($forNetwork->sid)->toBe('network-a') + // A customer without a user account has only its contact ids. + ->and(StorefrontSocket::customerPrincipal(Contact::where('uuid', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')->firstOrFail(), $store)->ids) + ->toBe(['bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'contact_bbbbbbb']); }); From 4f5ba15ab77e7694cbf831524255e8358b194106 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 21:48:25 +0800 Subject: [PATCH 07/50] feat(products): expose add-on category is_required and max_selectable on public product payloads --- server/src/Http/Resources/Product.php | 4 +++ .../Http/Resources/ProductResourceTest.php | 32 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/server/src/Http/Resources/Product.php b/server/src/Http/Resources/Product.php index db403dbb..efc0a2d9 100644 --- a/server/src/Http/Resources/Product.php +++ b/server/src/Http/Resources/Product.php @@ -110,6 +110,8 @@ public function mapAddonCategories(Collection|array $addonCategories = []) 'id' => $addonCategory->id, 'name' => $addonCategory->name, 'excluded_addons' => $addonCategory->excluded_addons, + 'is_required' => (bool) $addonCategory->is_required, + 'max_selectable' => $addonCategory->max_selectable, 'category' => $addonCategory->category, 'created_at' => $addonCategory->created_at, 'updated_at' => $addonCategory->updated_at, @@ -121,6 +123,8 @@ public function mapAddonCategories(Collection|array $addonCategories = []) 'name' => data_get($addonCategory, 'name'), 'description' => data_get($addonCategory, 'category.description'), 'excluded_addons' => $addonCategory->excluded_addons, + 'is_required' => (bool) $addonCategory->is_required, + 'max_selectable' => $addonCategory->max_selectable ? (int) $addonCategory->max_selectable : null, 'addons' => $this->mapProductAddons($addons, $addonCategory->excluded_addons), ]; diff --git a/server/tests/Unit/Http/Resources/ProductResourceTest.php b/server/tests/Unit/Http/Resources/ProductResourceTest.php index 8d87e8cc..68d576f6 100644 --- a/server/tests/Unit/Http/Resources/ProductResourceTest.php +++ b/server/tests/Unit/Http/Resources/ProductResourceTest.php @@ -53,6 +53,8 @@ function storefrontProductResourceFixture(): ProductModel 'category_uuid' => 'addon_category_uuid', 'name' => 'Packaging', 'excluded_addons' => [], + 'is_required' => 1, + 'max_selectable' => '2', 'category' => (object) [ 'public_id' => 'addon_category_123', 'description'=> 'Packaging choices', @@ -155,9 +157,11 @@ function storefrontProductResourceFixture(): ProductModel 'files', 'type', ])->and($data['addon_categories'][0])->toMatchArray([ - 'id' => 'addon_category_123', - 'name' => 'Packaging', - 'description' => 'Packaging choices', + 'id' => 'addon_category_123', + 'name' => 'Packaging', + 'description' => 'Packaging choices', + 'is_required' => true, + 'max_selectable' => 2, ])->and($data['addon_categories'][0]['addons'][0])->toMatchArray([ 'id' => 'addon_123', 'name' => 'Gift wrap', @@ -192,7 +196,9 @@ function storefrontProductResourceFixture(): ProductModel 'uuid' => 'product_addon_category_uuid', 'product_uuid' => 'product_uuid', 'category_uuid' => 'addon_category_uuid', - 'public_id' => 'addon_category_123', + 'public_id' => 'addon_category_123', + 'is_required' => true, + 'max_selectable' => '2', ])->and($data['addon_categories'][0])->not->toHaveKey('addons') ->and($data['variants'][0])->toMatchArray([ 'id' => 40, @@ -244,3 +250,21 @@ function storefrontProductResourceFixture(): ProductModel 'name' => 'Gift wrap', ]); }); + +test('public add-on categories report optional unlimited choices when limits are unset', function () { + setStorefrontResourceRoute('v1/storefront/products'); + + $category = (object) [ + 'name' => 'Extras', + 'excluded_addons' => null, + 'is_required' => null, + 'max_selectable' => null, + 'category' => (object) ['public_id' => 'addon_category_456', 'description' => null, 'addons' => collect()], + ]; + + expect((new ProductResource((object) []))->mapAddonCategories([$category])->first())->toMatchArray([ + 'id' => 'addon_category_456', + 'is_required' => false, + 'max_selectable' => null, + ]); +}); From 2aeffa60360384d475c2f957eb55cf2212d5a4b4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 21:48:55 +0800 Subject: [PATCH 08/50] style: align test fixture keys --- server/tests/Unit/Http/Resources/ProductResourceTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/tests/Unit/Http/Resources/ProductResourceTest.php b/server/tests/Unit/Http/Resources/ProductResourceTest.php index 68d576f6..29300c13 100644 --- a/server/tests/Unit/Http/Resources/ProductResourceTest.php +++ b/server/tests/Unit/Http/Resources/ProductResourceTest.php @@ -192,10 +192,10 @@ function storefrontProductResourceFixture(): ProductModel ])->and($data)->toHaveKey('files') ->and($data)->not->toHaveKeys(['images', 'videos']) ->and($data['addon_categories'][0])->toMatchArray([ - 'id' => 20, - 'uuid' => 'product_addon_category_uuid', - 'product_uuid' => 'product_uuid', - 'category_uuid' => 'addon_category_uuid', + 'id' => 20, + 'uuid' => 'product_addon_category_uuid', + 'product_uuid' => 'product_uuid', + 'category_uuid' => 'addon_category_uuid', 'public_id' => 'addon_category_123', 'is_required' => true, 'max_selectable' => '2', From 868dfa29aec7acf7c998764f684c9fc1298aba8d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 00:06:30 +0800 Subject: [PATCH 09/50] fix(reviews): show reviewers as first name and last initial, without contact details --- server/src/Http/Resources/ReviewCustomer.php | 27 ++++++++++++++++--- .../RemainingResourceContractsTest.php | 21 +++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/server/src/Http/Resources/ReviewCustomer.php b/server/src/Http/Resources/ReviewCustomer.php index 590cd748..b8dc6143 100644 --- a/server/src/Http/Resources/ReviewCustomer.php +++ b/server/src/Http/Resources/ReviewCustomer.php @@ -17,13 +17,17 @@ class ReviewCustomer extends FleetbaseResource */ public function toArray($request) { + $internal = Http::isInternalRequest(); + + // Reviews are public: customers see each other's first name and last initial only, + // never their contact details. return [ 'id' => $this->when(Http::isInternalRequest(), $this->id, Str::replaceFirst('contact', 'customer', $this->public_id)), 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'name' => $this->name, - 'email' => $this->email, - 'phone' => $this->phone, + 'name' => $internal ? $this->name : static::publicName($this->name), + 'email' => $this->when($internal, $this->email), + 'phone' => $this->when($internal, $this->phone), 'photo_url' => $this->photo_url, 'reviews_count' => $this->resource->reviews()->count(), 'uploads_count' => $this->resource->reviewUploads()->count(), @@ -32,4 +36,21 @@ public function toArray($request) 'updated_at' => $this->updated_at, ]; } + + /** + * "Ada B." from "Ada Buyer"; a single name is shown as is. + */ + public static function publicName(?string $name): ?string + { + $parts = preg_split('/\s+/u', trim((string) $name), -1, PREG_SPLIT_NO_EMPTY); + if (!$parts) { + return null; + } + + if (count($parts) === 1) { + return $parts[0]; + } + + return $parts[0] . ' ' . mb_strtoupper(mb_substr(end($parts), 0, 1)) . '.'; + } } diff --git a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php index f81cfcd8..d83b7258 100644 --- a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php @@ -374,10 +374,27 @@ public function count(): int expect($data)->toMatchArray([ 'id' => 'customer_public', - 'name' => 'Ada Buyer', + 'name' => 'Ada B.', 'reviews_count' => 4, 'uploads_count' => 6, - ])->and($data)->not->toHaveKeys(['uuid', 'public_id']); + ])->and($data)->not->toHaveKeys(['uuid', 'public_id', 'email', 'phone']); + + $internal = (new ReviewCustomerResource($customer)) + ->resolve(setSimpleStorefrontResourceRoute('int/v1/storefront/review-customers')); + + expect($internal)->toMatchArray([ + 'name' => 'Ada Buyer', + 'email' => 'ada@example.test', + 'phone' => '+15550100', + ]); +}); + +test('review customers are named by first name and last initial in public', function () { + expect(ReviewCustomerResource::publicName(' ada van buyer '))->toBe('ada B.') + ->and(ReviewCustomerResource::publicName('Ada'))->toBe('Ada') + ->and(ReviewCustomerResource::publicName('Ölga ölund'))->toBe('Ölga Ö.') + ->and(ReviewCustomerResource::publicName(' '))->toBeNull() + ->and(ReviewCustomerResource::publicName(null))->toBeNull(); }); test('store hour and location resources preserve customer-facing scheduling shapes', function () { From da400f274d3274acabd736d1c58a35095ff34d2d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 01:05:48 +0800 Subject: [PATCH 10/50] fix(chat): advertise the chat.{id} socket channel core broadcasts messages on --- server/src/Http/Resources/OrderChat.php | 3 ++- server/tests/Unit/Http/Controllers/OrderChatControllerTest.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Resources/OrderChat.php b/server/src/Http/Resources/OrderChat.php index 16171f4e..e04d164e 100644 --- a/server/src/Http/Resources/OrderChat.php +++ b/server/src/Http/Resources/OrderChat.php @@ -37,7 +37,8 @@ public function toArray($request) return [ 'id' => $this->public_id, - 'channel' => 'chat_channel.' . $this->uuid, + // Core broadcasts chat messages on `chat.{public_id}` (and `chat.{uuid}`). + 'channel' => 'chat.' . $this->public_id, 'order' => $this->order->public_id, 'status' => $closed ? 'closed' : 'open', 'me' => data_get($me, 'public_id'), diff --git a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php index 3db3b4b8..9b206316 100644 --- a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php +++ b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php @@ -489,7 +489,7 @@ public function clear(): void ->and(data_get($channel->meta, 'storefront_order_uuid'))->toBe('order_active_uuid') ->and(data_get($channel->meta, 'storefront_order_id'))->toBe('order_active') ->and($first['id'])->toBe($channel->public_id) - ->and($first['channel'])->toBe('chat_channel.' . $channel->uuid) + ->and($first['channel'])->toBe('chat.' . $channel->public_id) ->and($first['order'])->toBe('order_active') ->and($first['status'])->toBe('open') ->and($first['me'])->toBe(OrderChat::participantFor($channel, 'customer_user_uuid')->public_id) From 919c310b109d70ae74f4dd863cb7e33ca44b4bfe Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 03:12:35 +0800 Subject: [PATCH 11/50] chore(release): v0.4.25 --- RELEASE.md | 15 +++++++++------ composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index d938d0d4..7b7bcf30 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,16 +1,19 @@ -> v0.4.24 ~ "Extensions can add columns, actions and buttons to storefront tables and panels" +> v0.4.25 ~ "Verified reviews, order chat with the driver, richer public promotions and faster store listings" --- ## Highlights -- **Resource view registries.** Extensions can add the following through `storefront::table:` and `storefront::details:`: - - columns, row actions, bulk actions and toolbar buttons on the orders, customers, promotions, campaigns, segments and network tables; - - buttons and menu items on the order and customer panels. -- **Orders use the standard table layout.** +- **Verified-purchase reviews.** Reviews can be tied to a delivered order and are marked verified. A new eligibility endpoint tells the app whether a customer can review a store, and customers can delete their own reviews. Reviewer email and phone are no longer exposed publicly; public reviews show a short name such as "Ada B.". +- **Chat with the driver.** Customers can chat with the driver delivering their order: get the chat, page through messages, send text and photos, and mark messages read. New messages are broadcast on the chat's `chat.{id}` channel. +- **Public promotions.** Public promotion payloads now include the owning store, the shareable code, availability and the next start time. +- **Product options.** Public product payloads now include each add-on category's `is_required` and `max_selectable`. +- **Faster store listings.** Network and Console store listings are quicker. +- **Private review photos.** Review photos upload without a public ACL, so private buckets work. --- ## Upgrading -Needs fleetbase/ember-core v0.3.25 and fleetbase/ember-ui v0.4.5. +- Run migrations. This release adds an index on `reviews.subject_uuid` and an `order_uuid` column on `reviews`. +- Order chat needs storefront socket authentication and fleetbase/core-api v1.6.69 or later. --- ## Need help? diff --git a/composer.json b/composer.json index 267085d0..8dd05a4f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/storefront-api", - "version": "0.4.24", + "version": "0.4.25", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index ab0097b4..592c0c87 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Storefront", - "version": "0.4.24", + "version": "0.4.25", "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 275c0ec6..43ea033e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/storefront-engine", - "version": "0.4.24", + "version": "0.4.25", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "fleetbase": { "route": "storefront", From af474596f7b17d0832768207be25b2e1efc810a7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 13:27:58 +0800 Subject: [PATCH 12/50] feat(seeders): service stores, promotions, segments and campaigns in the testing seeders - Network seeder: a "Home & Wellness" category and two service stores tagged `services` (Lion City Cleaning, Tiong Bahru Wellness) with bookable products that carry a duration (`meta.duration`, 45 to 360 minutes), options, add-ons and a sale price. - Promotions for the network and individual stores, covering every type (percentage, fixed amount, free delivery, buy 2 get 1) and state (live, weekly schedule, weekends only, code only, first order only, upcoming, ended, draft), with codes WELCOME15, FIRSTCLEAN, MARKET5 and VIPPREVIEW. They target categories, products or stores. - Customer segments (loyal, recent, lapsed, never ordered, push enabled) and sent, scheduled and draft campaigns that deep-link to a promotion, store or product. Scheduled campaigns are due days ahead, so the scheduler doesn't send them straight away. - Single-store seeder: a smaller set of promotions, segments and campaigns. - Re-running purges seeded campaigns, promotions with their codes and redemptions, and segments. - Fix: re-running failed with "A customer with this email already exists". Both seeders used the same customer emails and phones in one company, and Fleet-Ops allows one customer profile per account there. The store seeder's customers now use `+market` emails and `+65 9101` phones. --- .../Concerns/SeedsStorefrontFixtures.php | 214 +++++++++++++++++- server/seeders/Testing/NetworkSeeder.php | 132 ++++++++++- server/seeders/Testing/StoreSeeder.php | 43 +++- 3 files changed, 378 insertions(+), 11 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index f4b5abe1..5272338c 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -15,11 +15,13 @@ use Fleetbase\Models\Transaction; use Fleetbase\Models\TransactionItem; use Fleetbase\Storefront\Models\AddonCategory; +use Fleetbase\Storefront\Models\Campaign; use Fleetbase\Storefront\Models\Cart; use Fleetbase\Storefront\Models\Catalog; use Fleetbase\Storefront\Models\CatalogCategory; use Fleetbase\Storefront\Models\CatalogProduct; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Models\CustomerSegment; use Fleetbase\Storefront\Models\Gateway; use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\NetworkStore; @@ -29,6 +31,8 @@ use Fleetbase\Storefront\Models\ProductStatus; use Fleetbase\Storefront\Models\ProductVariant; use Fleetbase\Storefront\Models\ProductVariantOption; +use Fleetbase\Storefront\Models\Promotion; +use Fleetbase\Storefront\Models\PromotionCode; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Models\StoreHour; @@ -62,6 +66,7 @@ * 'categories' => ['produce' => ['name' => 'Fresh Produce', 'description' => '...']], * 'addon_categories' => ['gift' => ['name' => ..., 'description' => ..., 'max_selectable' => 2, 'is_required' => false, 'addons' => [['Gift Wrap', 'desc', 350]]]], * 'products' => ['orchard-box' => ['name' => ..., 'description' => ..., 'price' => 2850, 'category' => 'produce', 'tags' => [], 'recommended' => true, + * 'is_service' => false, 'is_bookable' => false, 'meta' => ['duration' => 60], // services: duration in minutes * 'variants' => [['name' => 'Box Size', 'required' => true, 'multiselect' => false, 'options' => [['Small', 0], ['Family', 1200]]]], * 'addon_categories' => ['gift']]], * 'catalog' => ['name' => ..., 'description' => ..., 'categories' => ['Fresh Picks' => ['orchard-box', 'market-veg']]], @@ -97,10 +102,18 @@ protected function purgeStorefrontFixtures(): void $orderUuids = $this->seededUuids(Order::class); $transactionUuids = $this->seededUuids(Transaction::class); $customerUuids = $this->seededUuids(Contact::class); + $promotionUuids = $this->seededUuids(Promotion::class); $storeLocationUuids = Schema::connection($this->storefrontConnection())->hasTable('store_locations') ? DB::connection($this->storefrontConnection())->table('store_locations')->whereIn('store_uuid', $storeUuids)->pluck('uuid')->all() : []; + // Marketing: campaigns point at segments and promotions; codes and redemptions at promotions + $this->purgeModel(Campaign::class); + $this->deleteFrom($this->storefrontConnection(), 'promotion_redemptions', fn ($query) => $query->whereIn('promotion_uuid', $promotionUuids)); + $this->deleteFrom($this->storefrontConnection(), 'promotion_codes', fn ($query) => $query->whereIn('promotion_uuid', $promotionUuids)->orWhereIn('meta->seed', $this->seedNames())); + $this->purgeModel(Promotion::class); + $this->purgeModel(CustomerSegment::class); + // Orders, payments and logistics records $this->purgeSeededLedgerJournals($orderUuids); $this->deleteFrom($this->fleetbaseConnection(), 'transaction_items', fn ($query) => $query->whereIn('transaction_uuid', $transactionUuids)->orWhereIn('meta->seed', $this->seedNames())); @@ -177,7 +190,7 @@ protected function purgeSeededLedgerJournals(array $orderUuids): void * a location with opening hours, product categories, products with variants and * addons, a published catalog and (optionally) a Stripe gateway. * - * @return array{store: Store, location: StoreLocation|null, place: Place|null, products: array, gateway: Gateway|null} + * @return array{store: Store, location: StoreLocation|null, place: Place|null, products: array, categories: array, gateway: Gateway|null} */ protected function seedStore(Company $company, array $definition): array { @@ -248,11 +261,12 @@ protected function seedStore(Company $company, array $definition): array } return [ - 'store' => $store, - 'location' => $location, - 'place' => $place, - 'products' => $products, - 'gateway' => $gateway, + 'store' => $store, + 'location' => $location, + 'place' => $place, + 'products' => $products, + 'categories' => $categories, + 'gateway' => $gateway, ]; } @@ -369,7 +383,7 @@ protected function createProduct(Company $company, Store $store, string $storeKe 'name' => $product['name'], 'description' => $product['description'] ?? null, 'tags' => $product['tags'] ?? [], - 'meta' => $this->meta('product:' . $storeKey . ':' . $productKey), + 'meta' => $this->meta('product:' . $storeKey . ':' . $productKey, $product['meta'] ?? []), 'sku' => $product['sku'] ?? 'SF-' . Str::upper(Str::slug($storeKey . '-' . $productKey)), 'price' => (int) $product['price'], 'currency' => $product['currency'] ?? $store->currency, @@ -493,6 +507,178 @@ protected function createCatalog(Company $company, Store $store, string $storeKe return $model; } + /* + |-------------------------------------------------------------------------- + | Marketing + |-------------------------------------------------------------------------- + */ + + /** + * Seed promotions (with their codes), customer segments and campaigns for a store or + * network. Product and category references are ":" + * against the seeded store bundles; dates are relative to now: + * + * [ + * 'promotions' => ['welcome' => ['name' => ..., 'type' => 'percentage', 'value' => 15, 'trigger' => 'code', 'code' => 'WELCOME15', + * 'starts_in_days' => -30, 'ends_in_days' => 30, 'applies_to' => ['products' => ['store:product']], ...]], + * 'segments' => ['loyal' => ['name' => ..., 'description' => ..., 'rules' => ['min_orders' => 3]]], + * 'campaigns' => ['launch' => ['name' => ..., 'title' => ..., 'body' => ..., 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'welcome', + * 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2]], + * ] + * + * @param array, categories: array}> $bundles + * + * @return array{promotions: array, segments: array, campaigns: array} + */ + protected function seedMarketing(Company $company, Store|Network $owner, string $ownerKey, array $definition, array $bundles): array + { + $ownerType = $owner instanceof Network ? 'storefront:network' : 'storefront:store'; + $refs = $this->marketingReferences($bundles); + + $promotions = []; + foreach ($definition['promotions'] ?? [] as $key => $promotion) { + $promotions[$key] = $this->createPromotion($company, $owner, $ownerType, $ownerKey, $key, $promotion, $refs); + } + + $segments = []; + foreach ($definition['segments'] ?? [] as $key => $segment) { + $segments[$key] = $this->createRecord(CustomerSegment::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'name' => $segment['name'], + 'description' => $segment['description'] ?? null, + 'rules' => $segment['rules'] ?? [], + 'meta' => $this->meta('segment:' . $ownerKey . ':' . $key), + ]); + } + + $campaigns = []; + foreach ($definition['campaigns'] ?? [] as $key => $campaign) { + $campaigns[$key] = $this->createCampaign($company, $owner, $ownerType, $ownerKey, $key, $campaign, $promotions, $segments, $refs); + } + + return ['promotions' => $promotions, 'segments' => $segments, 'campaigns' => $campaigns]; + } + + /** + * Seeded stores, products and categories by reference ("" and ":"). + * + * @return array{stores: array, products: array, categories: array} + */ + protected function marketingReferences(array $bundles): array + { + $refs = ['stores' => [], 'products' => [], 'categories' => []]; + foreach ($bundles as $storeKey => $bundle) { + $refs['stores'][$storeKey] = $bundle['store']; + foreach ($bundle['products'] ?? [] as $key => $product) { + $refs['products'][$storeKey . ':' . $key] = $product; + } + foreach ($bundle['categories'] ?? [] as $key => $category) { + $refs['categories'][$storeKey . ':' . $key] = $category; + } + } + + return $refs; + } + + protected function createPromotion(Company $company, Store|Network $owner, string $ownerType, string $ownerKey, string $key, array $promotion, array $refs): Promotion + { + $uuids = fn (string $kind, array $keys) => array_values(array_filter(array_map(fn ($ref) => ($refs[$kind][$ref] ?? null)?->uuid, $keys))); + + $appliesTo = []; + foreach (['products' => 'products', 'categories' => 'categories', 'stores' => 'stores', 'exclude_products' => 'products', 'exclude_categories' => 'categories'] as $field => $kind) { + if (!empty($promotion['applies_to'][$field])) { + $appliesTo[$field] = $uuids($kind, (array) $promotion['applies_to'][$field]); + } + } + + $record = $this->createRecord(Promotion::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'name' => $promotion['name'], + 'description' => $promotion['description'] ?? null, + 'status' => $promotion['status'] ?? Promotion::STATUS_ACTIVE, + 'trigger' => isset($promotion['code']) ? Promotion::TRIGGER_CODE : Promotion::TRIGGER_AUTOMATIC, + 'type' => $promotion['type'], + 'value' => $promotion['value'] ?? null, + 'max_discount_amount' => $promotion['max_discount_amount'] ?? null, + 'currency' => $promotion['currency'] ?? $owner->currency ?? 'USD', + 'min_subtotal' => $promotion['min_subtotal'] ?? null, + 'min_items' => $promotion['min_items'] ?? null, + 'applies_to' => $appliesTo ?: null, + 'bogo_config' => $promotion['bogo_config'] ?? null, + 'first_order_only' => $promotion['first_order_only'] ?? false, + 'usage_limit' => $promotion['usage_limit'] ?? null, + 'usage_limit_per_customer' => $promotion['usage_limit_per_customer'] ?? null, + 'budget_amount' => $promotion['budget_amount'] ?? null, + 'stackable' => $promotion['stackable'] ?? false, + 'priority' => $promotion['priority'] ?? 0, + 'is_public' => $promotion['is_public'] ?? true, + 'starts_at' => isset($promotion['starts_in_days']) ? now()->addDays($promotion['starts_in_days'])->startOfDay() : null, + 'ends_at' => isset($promotion['ends_in_days']) ? now()->addDays($promotion['ends_in_days'])->endOfDay() : null, + 'schedule' => $promotion['schedule'] ?? null, + 'timezone' => $promotion['timezone'] ?? $owner->timezone ?? null, + 'meta' => $this->meta('promotion:' . $ownerKey . ':' . $key), + ]); + + if (isset($promotion['code'])) { + $this->createRecord(PromotionCode::class, [ + 'company_uuid' => $company->uuid, + 'promotion_uuid' => $record->uuid, + 'code' => $promotion['code'], + 'status' => 'active', + 'meta' => $this->meta('promotion-code:' . $ownerKey . ':' . $key), + ]); + } + + return $record; + } + + /** + * A campaign in any state. Sent campaigns get `sent_at` and stats; scheduled ones are + * due `send_in_days` from now, far enough out that the scheduler leaves them alone. + */ + protected function createCampaign(Company $company, Store|Network $owner, string $ownerType, string $ownerKey, string $key, array $campaign, array $promotions, array $segments, array $refs): Campaign + { + $status = $campaign['status'] ?? Campaign::STATUS_DRAFT; + $promotion = isset($campaign['promotion']) ? ($promotions[$campaign['promotion']] ?? null) : null; + $action = $campaign['action'] ?? null; + if ($action) { + $target = match ($action['type'] ?? null) { + 'promotion' => $promotion, + 'store' => $refs['stores'][$action['ref'] ?? ''] ?? null, + 'product' => $refs['products'][$action['ref'] ?? ''] ?? null, + default => null, + }; + $action = array_filter(['type' => $action['type'], 'id' => $target?->public_id, 'url' => $action['url'] ?? null]); + } + $sentAt = $status === Campaign::STATUS_SENT ? now()->subDays($campaign['sent_days_ago'] ?? 1)->setTime(10, 0) : null; + + return $this->createRecord(Campaign::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'segment_uuid' => isset($campaign['segment']) ? ($segments[$campaign['segment']] ?? null)?->uuid : null, + 'promotion_uuid' => $promotion?->uuid, + 'name' => $campaign['name'], + 'status' => $status, + 'channels' => $campaign['channels'] ?? [Campaign::CHANNEL_PUSH, Campaign::CHANNEL_INBOX], + 'title' => $campaign['title'], + 'body' => $campaign['body'], + 'action' => $action ?: null, + 'send_at' => $status === Campaign::STATUS_SCHEDULED ? now()->addDays($campaign['send_in_days'] ?? 7)->setTime(10, 0) : $sentAt, + 'started_at' => $sentAt, + 'sent_at' => $sentAt, + 'stats' => $status === Campaign::STATUS_SENT ? ($campaign['stats'] ?? ['targeted' => 0, 'batches' => 0]) : null, + 'meta' => $this->meta('campaign:' . $ownerKey . ':' . $key), + ]); + } + /* |-------------------------------------------------------------------------- | Networks @@ -561,7 +747,19 @@ protected function addStoreToNetwork(Network $network, Store $store, ?Category $ */ protected function seedCustomers(Company $company, array $fixtures): array { - return array_values(array_map(fn (array $fixture) => $this->createCustomer($company, ...$fixture), $fixtures)); + return array_values(array_map(fn (array $fixture) => $this->createCustomer($company, ...$this->customerIdentity($fixture)), $fixtures)); + } + + /** + * The [name, email, phone] a seeder gives one of the shared customer fixtures. + * + * Fleet-Ops allows one customer profile per account (matched by email or phone) in a + * company, and both seeders run in the same company, so a seeder sharing the fixtures + * must give its customers identities of their own. + */ + protected function customerIdentity(array $fixture): array + { + return $fixture; } protected function createCustomer(Company $company, string $name, string $email, string $phone): Contact diff --git a/server/seeders/Testing/NetworkSeeder.php b/server/seeders/Testing/NetworkSeeder.php index 88d0516f..fefdba66 100644 --- a/server/seeders/Testing/NetworkSeeder.php +++ b/server/seeders/Testing/NetworkSeeder.php @@ -13,10 +13,16 @@ * Produces a network with an order config and a sandbox Stripe gateway, network * store categories, and several member stores each with their own location, product * categories, products, catalog and (for some) their own Stripe gateway. One store is - * left without a network category to exercise the `without_category` filters. Shared + * left without a network category to exercise the `without_category` filters. Two + * service stores (tagged `services`) sell bookable services with durations. Shared * customers place orders across every store so network-level dashboards, order * listings and analytics have data. * + * Marketing: network and store promotions covering every type (percentage, fixed + * amount, free delivery, buy X get Y) and state (live, happy-hour schedule, code only, + * first order only, upcoming, ended, draft), customer segments, and sent, scheduled and + * draft campaigns. + * * Re-running the seeder purges and recreates its own fixtures only. * * php artisan db:seed --class="Fleetbase\\Storefront\\Seeders\\Testing\\NetworkSeeder" @@ -65,10 +71,21 @@ public function run(): void $orders = $this->seedStoreActivity($company, $bundle, $customers, $network, $definition['order_count'] ?? 12); $orderCount += count($orders); - $bundles[] = $bundle; + $bundles[$definition['key']] = $bundle; + } + + $marketing = $this->seedMarketing($company, $network, static::NETWORK_KEY, $this->marketingDefinition(), $bundles); + foreach ($this->storeMarketingDefinitions() as $storeKey => $definition) { + if (isset($bundles[$storeKey])) { + $storeMarketing = $this->seedMarketing($company, $bundles[$storeKey]['store'], $storeKey, $definition, $bundles); + foreach ($storeMarketing as $kind => $records) { + $marketing[$kind] = array_merge($marketing[$kind], array_values($records)); + } + } } $this->command?->info(sprintf('Seeded Storefront testing network for company %s with %d stores and %d orders.', $company->public_id, count($bundles), $orderCount)); + $this->command?->info(sprintf(' Marketing: %d promotions, %d segments, %d campaigns.', count($marketing['promotions']), count($marketing['segments']), count($marketing['campaigns']))); $this->reportStorefront($network, $gateway, ' '); foreach ($bundles as $bundle) { $this->reportStorefront($bundle['store'], $bundle['gateway'], ' '); @@ -107,6 +124,7 @@ protected function networkCategories(): array 'groceries' => ['name' => 'Groceries', 'description' => 'Supermarkets and fresh food.', 'icon' => 'basket-shopping', 'icon_color' => '#16a34a', 'tags' => ['food', 'fresh']], 'restaurants' => ['name' => 'Restaurants', 'description' => 'Prepared meals for delivery and pickup.', 'icon' => 'utensils', 'icon_color' => '#ea580c', 'tags' => ['food', 'meals']], 'health' => ['name' => 'Health & Beauty', 'description' => 'Pharmacies and personal care.', 'icon' => 'heart-pulse', 'icon_color' => '#2563eb', 'tags' => ['pharmacy']], + 'services' => ['name' => 'Home & Wellness', 'description' => 'Book cleaners, stylists and therapists.', 'icon' => 'calendar-check', 'icon_color' => '#7c3aed', 'tags' => ['services']], ]; } @@ -247,6 +265,116 @@ protected function storeDefinitions(): array ], 'catalog' => ['name' => 'Marina Flowers Catalog', 'categories' => ['Bouquets' => ['rose-bouquet', 'mixed-bouquet']]], ], + [ + 'key' => 'lion-city-cleaning', + 'network_category' => 'services', + 'order_count' => 5, + 'name' => 'Lion City Cleaning', + 'description' => 'Home cleaning by vetted cleaners. Book a date and start time.', + 'email' => 'cleaning@example.test', + 'phone' => '+65 6100 0306', + 'tags' => ['services', 'cleaning', 'home'], + 'gateway' => 'stripe', + 'location' => ['name' => 'Lion City Cleaning', 'street1' => '7 Raffles Place', 'city' => 'Singapore', 'postal_code' => '048616', 'country' => 'SG', 'lat' => 1.2840, 'lng' => 103.8515], + 'hours' => ['start' => '08:00', 'end' => '20:00', 'closed' => ['sunday']], + 'categories' => [ + 'cleaning' => ['name' => 'Home Cleaning', 'description' => 'Regular and deep cleans.'], + 'specialty' => ['name' => 'Specialty', 'description' => 'Appliances, windows and upholstery.'], + ], + 'addon_categories' => [ + 'extras' => ['name' => 'Extras', 'description' => 'Add to your clean.', 'max_selectable' => 3, 'is_required' => false, 'addons' => [['Inside the Fridge', 'Empty, wipe and restock.', 1500], ['Inside the Oven', 'Degrease racks and walls.', 2000], ['Ironing (1 hour)', 'Shirts and linens.', 2500]]], + ], + 'products' => [ + 'standard-clean' => ['name' => 'Standard Home Clean', 'description' => 'Dusting, floors, kitchen and bathrooms.', 'price' => 6500, 'category' => 'cleaning', 'tags' => ['cleaning'], 'recommended' => true, 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 180], 'variants' => [['name' => 'Home Size', 'required' => true, 'multiselect' => false, 'options' => [['Studio / 1 bedroom', 0], ['2 bedrooms', 2000], ['3+ bedrooms', 4000]]]], 'addon_categories' => ['extras']], + 'deep-clean' => ['name' => 'Deep Clean', 'description' => 'Top to bottom, including inside cabinets.', 'price' => 14500, 'category' => 'cleaning', 'tags' => ['cleaning'], 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 360], 'addon_categories' => ['extras']], + 'aircon-service' => ['name' => 'Aircon Servicing', 'description' => 'Filter wash and coil check, per unit.', 'price' => 3500, 'category' => 'specialty', 'tags' => ['aircon'], 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 45]], + 'sofa-clean' => ['name' => 'Sofa Shampoo', 'description' => 'Fabric sofa, up to 3 seats.', 'price' => 8900, 'sale_price' => 7500, 'category' => 'specialty', 'tags' => ['upholstery'], 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 90]], + ], + 'catalog' => ['name' => 'Lion City Cleaning Services', 'categories' => ['Home Cleaning' => ['standard-clean', 'deep-clean'], 'Specialty' => ['aircon-service', 'sofa-clean']]], + ], + [ + 'key' => 'tiong-bahru-wellness', + 'network_category' => 'services', + 'order_count' => 5, + 'name' => 'Tiong Bahru Wellness', + 'description' => 'Hair, nails and massage. Book a time at the studio.', + 'email' => 'wellness@example.test', + 'phone' => '+65 6100 0307', + 'tags' => ['services', 'salon', 'spa'], + 'gateway' => null, + 'location' => ['name' => 'Tiong Bahru Wellness', 'street1' => '55 Tiong Bahru Road', 'city' => 'Singapore', 'postal_code' => '160055', 'country' => 'SG', 'lat' => 1.2860, 'lng' => 103.8302], + 'hours' => ['start' => '10:00', 'end' => '21:00', 'closed' => ['monday']], + 'categories' => [ + 'hair' => ['name' => 'Hair', 'description' => 'Cuts and colour.'], + 'massage' => ['name' => 'Massage', 'description' => 'Relaxation and deep tissue.'], + 'nails' => ['name' => 'Nails', 'description' => 'Manicures and pedicures.'], + ], + 'products' => [ + 'haircut' => ['name' => 'Haircut & Blow Dry', 'description' => 'Consultation, wash, cut and style.', 'price' => 4800, 'category' => 'hair', 'tags' => ['hair'], 'recommended' => true, 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60], 'variants' => [['name' => 'Stylist', 'required' => true, 'multiselect' => false, 'options' => [['Stylist', 0], ['Senior Stylist', 1500]]]]], + 'hair-colour' => ['name' => 'Full Colour', 'description' => 'Single process colour with gloss.', 'price' => 12000, 'category' => 'hair', 'tags' => ['colour'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 120]], + 'swedish-60' => ['name' => 'Swedish Massage 60 min', 'description' => 'Full body, light to medium pressure.', 'price' => 9800, 'category' => 'massage', 'tags' => ['massage'], 'recommended' => true, 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60]], + 'deep-tissue-90' => ['name' => 'Deep Tissue Massage 90 min', 'description' => 'Firm pressure for tight muscles.', 'price' => 14800, 'category' => 'massage', 'tags' => ['massage'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 90]], + 'gel-manicure' => ['name' => 'Gel Manicure', 'description' => 'Shape, cuticle care and gel colour.', 'price' => 4500, 'category' => 'nails', 'tags' => ['nails'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 45]], + ], + 'catalog' => ['name' => 'Tiong Bahru Wellness Menu', 'categories' => ['Hair' => ['haircut', 'hair-colour'], 'Massage' => ['swedish-60', 'deep-tissue-90'], 'Nails' => ['gel-manicure']]], + ], + ]; + } + + /** + * Network-wide promotions, segments and campaigns (owned by the network). + */ + protected function marketingDefinition(): array + { + return [ + 'promotions' => [ + 'welcome' => ['name' => 'Welcome to Fleetbase Marketplace', 'description' => '15% off your first order, up to $10.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 1000, 'min_subtotal' => 2000, 'first_order_only' => true, 'code' => 'WELCOME15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], + 'free-delivery' => ['name' => 'Free delivery over $40', 'description' => 'Delivery is on us when your basket is $40 or more.', 'type' => 'free_delivery', 'min_subtotal' => 4000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], + 'weekend-five' => ['name' => '$5 off weekend orders', 'description' => 'Every Saturday and Sunday, $5 off orders of $25 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 2500, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], + 'services-launch' => ['name' => 'Home & Wellness launch', 'description' => '20% off any booked service.', 'type' => 'percentage', 'value' => 20, 'max_discount_amount' => 3000, 'applies_to' => ['stores' => ['lion-city-cleaning', 'tiong-bahru-wellness']], 'starts_in_days' => 3, 'ends_in_days' => 33], + 'spring-sale' => ['name' => 'Spring sale', 'description' => '10% off everything (ended).', 'type' => 'percentage', 'value' => 10, 'starts_in_days' => -40, 'ends_in_days' => -10], + 'vip-preview' => ['name' => 'VIP preview (draft)', 'description' => 'Not published yet.', 'type' => 'percentage', 'value' => 25, 'status' => 'draft', 'is_public' => false, 'code' => 'VIPPREVIEW'], + ], + 'segments' => [ + 'loyal' => ['name' => 'Loyal customers', 'description' => 'Three or more orders.', 'rules' => ['min_orders' => 3]], + 'recent' => ['name' => 'Ordered this month', 'description' => 'At least one order in the last 30 days.', 'rules' => ['ordered_within_days' => 30]], + 'lapsed' => ['name' => 'Lapsed customers', 'description' => 'Ordered before, but not in the last 14 days.', 'rules' => ['not_ordered_within_days' => 14]], + 'never' => ['name' => 'Never ordered', 'description' => 'Signed up but no orders yet.', 'rules' => ['max_orders' => 0]], + 'push-ready' => ['name' => 'Push enabled', 'description' => 'Customers with a registered push device.', 'rules' => ['has_push_device' => true]], + ], + 'campaigns' => [ + 'welcome-sent' => ['name' => 'Welcome offer', 'title' => '15% off your first order', 'body' => 'Use WELCOME15 at checkout. Ends soon.', 'status' => 'sent', 'segment' => 'never', 'promotion' => 'welcome', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 3, 'stats' => ['targeted' => 0, 'batches' => 0]], + 'delivery-sent' => ['name' => 'Free delivery week', 'title' => 'Free delivery over $40', 'body' => 'Stock up this week, delivery is on us.', 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'free-delivery', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 1, 'stats' => ['targeted' => 12, 'batches' => 1]], + 'services-soon' => ['name' => 'Home & Wellness launch', 'title' => 'Book a clean or a massage', 'body' => 'New on Fleetbase Marketplace: 20% off booked services.', 'status' => 'scheduled', 'segment' => 'recent', 'promotion' => 'services-launch', 'action' => ['type' => 'store', 'ref' => 'lion-city-cleaning'], 'send_in_days' => 14], + 'winback-draft' => ['name' => 'We miss you (draft)', 'title' => 'Come back for $5 off', 'body' => 'Here is $5 off your next weekend order.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'weekend-five'], + ], + ]; + } + + /** + * Promotions and campaigns owned by individual member stores, by store key. + */ + protected function storeMarketingDefinitions(): array + { + return [ + 'orchard-grocers' => [ + 'promotions' => [ + 'bakery-happy-hour' => ['name' => 'Bakery happy hour', 'description' => '30% off bakery, weekdays 5 to 8 pm.', 'type' => 'percentage', 'value' => 30, 'applies_to' => ['categories' => ['orchard-grocers:bakery']], 'schedule' => [['days' => [1, 2, 3, 4, 5], 'start' => '17:00', 'end' => '20:00']], 'starts_in_days' => -10, 'ends_in_days' => 50], + ], + 'campaigns' => [ + 'bakery-sent' => ['name' => 'Bakery happy hour', 'title' => '30% off fresh bread after 5', 'body' => 'Sourdough and croissants, weekdays 5 to 8 pm.', 'status' => 'sent', 'promotion' => 'bakery-happy-hour', 'action' => ['type' => 'product', 'ref' => 'orchard-grocers:sourdough'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 8, 'batches' => 1]], + ], + ], + 'rochor-noodle-house' => [ + 'promotions' => [ + 'dumpling-bogo' => ['name' => 'Buy 2 dumplings, get 1 free', 'description' => 'On any dumpling plate.', 'type' => 'bogo', 'bogo_config' => ['buy_quantity' => 2, 'get_quantity' => 1, 'discount_percent' => 100], 'applies_to' => ['products' => ['rochor-noodle-house:pork-dumplings', 'rochor-noodle-house:veg-dumplings']], 'starts_in_days' => -5, 'ends_in_days' => 25], + ], + ], + 'lion-city-cleaning' => [ + 'promotions' => [ + 'first-clean' => ['name' => '$20 off your first clean', 'description' => 'For new customers, on any home clean.', 'type' => 'fixed_amount', 'value' => 2000, 'min_subtotal' => 6000, 'first_order_only' => true, 'code' => 'FIRSTCLEAN', 'applies_to' => ['categories' => ['lion-city-cleaning:cleaning']], 'starts_in_days' => -20, 'ends_in_days' => 70], + ], + ], ]; } } diff --git a/server/seeders/Testing/StoreSeeder.php b/server/seeders/Testing/StoreSeeder.php index f2f18fff..3433cd84 100644 --- a/server/seeders/Testing/StoreSeeder.php +++ b/server/seeders/Testing/StoreSeeder.php @@ -12,7 +12,8 @@ * Produces a store with an order config, a store location with opening hours, * a sandbox Stripe gateway, product categories, products with variants and addons, * a published catalog, customers, an open cart, a pending checkout, a month of - * captured orders (cash and Stripe, delivery and pickup) and reviews. + * captured orders (cash and Stripe, delivery and pickup), reviews, and promotions, + * customer segments and campaigns in several states. * * Re-running the seeder purges and recreates its own fixtures only. * @@ -45,8 +46,10 @@ public function run(): void $bundle = $this->seedStore($company, $this->storeDefinition()); $customers = $this->seedCustomers($company, $this->customerFixtures()); $orders = $this->seedStoreActivity($company, $bundle, $customers, null, 30); + $marketing = $this->seedMarketing($company, $bundle['store'], static::STORE_KEY, $this->marketingDefinition(), [static::STORE_KEY => $bundle]); $this->command?->info(sprintf('Seeded Storefront testing store for company %s with %d products and %d orders.', $company->public_id, count($bundle['products']), count($orders))); + $this->command?->info(sprintf(' Marketing: %d promotions, %d segments, %d campaigns.', count($marketing['promotions']), count($marketing['segments']), count($marketing['campaigns']))); $this->reportStorefront($bundle['store'], $bundle['gateway'], ' '); } @@ -55,6 +58,18 @@ public function purgeSeedData(): void $this->purgeStorefrontFixtures(); } + /** + * The network seeder uses the same customer fixtures in the same company, so this + * store's customers get their own emails (`ava.chen+market@example.test`) and phones + * (`+65 9101 …`). + */ + protected function customerIdentity(array $fixture): array + { + [$name, $email, $phone] = $fixture; + + return [$name, str_replace('@', '+market@', $email), str_replace('+65 9100 ', '+65 9101 ', $phone)]; + } + protected function storeDefinition(): array { return [ @@ -214,4 +229,30 @@ protected function storeDefinition(): array ], ]; } + + /** + * The store's own promotions, segments and campaigns. + */ + protected function marketingDefinition(): array + { + $key = static::STORE_KEY; + + return [ + 'promotions' => [ + 'coffee-hour' => ['name' => 'Coffee hour', 'description' => '20% off beverages, every morning 7 to 10.', 'type' => 'percentage', 'value' => 20, 'applies_to' => ['categories' => [$key . ':beverages']], 'schedule' => [['days' => [1, 2, 3, 4, 5, 6, 7], 'start' => '07:00', 'end' => '10:00']], 'starts_in_days' => -14, 'ends_in_days' => 60], + 'market-five' => ['name' => '$5 off $30', 'description' => 'Use MARKET5 on orders of $30 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 3000, 'code' => 'MARKET5', 'usage_limit_per_customer' => 3, 'starts_in_days' => -7, 'ends_in_days' => 30], + 'free-delivery' => ['name' => 'Free delivery for first orders', 'description' => 'Your first delivery is free.', 'type' => 'free_delivery', 'first_order_only' => true, 'starts_in_days' => -30, 'ends_in_days' => 90], + 'harvest' => ['name' => 'Harvest week', 'description' => '15% off fresh produce (coming soon).', 'type' => 'percentage', 'value' => 15, 'applies_to' => ['categories' => [$key . ':produce']], 'starts_in_days' => 5, 'ends_in_days' => 12], + ], + 'segments' => [ + 'regulars' => ['name' => 'Regulars', 'description' => 'Five or more orders.', 'rules' => ['min_orders' => 5]], + 'lapsed' => ['name' => 'Lapsed', 'description' => 'No order in the last 21 days.', 'rules' => ['not_ordered_within_days' => 21]], + ], + 'campaigns' => [ + 'coffee-sent' => ['name' => 'Coffee hour', 'title' => '20% off coffee before 10', 'body' => 'Cold brew and lattes, every morning.', 'status' => 'sent', 'segment' => 'regulars', 'promotion' => 'coffee-hour', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 6, 'batches' => 1]], + 'harvest-soon' => ['name' => 'Harvest week', 'title' => 'Harvest week starts soon', 'body' => '15% off fresh produce all week.', 'status' => 'scheduled', 'promotion' => 'harvest', 'action' => ['type' => 'promotion'], 'send_in_days' => 5], + 'winback-draft' => ['name' => 'Win back (draft)', 'title' => '$5 off your next order', 'body' => 'Use MARKET5 on orders of $30 or more.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'market-five'], + ], + ]; + } } From aa0dda6df442fc35764b7638c02d8f0b72ce973d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 15:30:08 +0800 Subject: [PATCH 13/50] fix(reviews, stripe): review sort direction, Stripe key errors - Reviews sorted ascending whatever was asked: applySort set sort_direction, which core's query builder never reads. It now sends the columns core understands ("-rating", "-created_at"), with the newest review first on equal ratings, so "highest" and "newest" come back in the right order. - Payment methods: creating the Stripe customer ran outside the try/catch, so a gateway whose key Stripe refuses (such as the seeded placeholder key) threw and came back as a bare 401 "Unauthenticated.". It now returns the Stripe error as a 400, like the other Stripe failures there. --- .../Controllers/v1/CustomerController.php | 22 ++++++++++--------- .../Http/Controllers/v1/ReviewController.php | 10 +++++---- .../CustomerControllerContractsTest.php | 8 +++++++ .../ReviewAndOrderControllerContractsTest.php | 16 +++++++------- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 8e47c447..6cc9cf7c 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -1062,12 +1062,13 @@ public function getStripeEphemeralKey(Request $request) \Stripe\Stripe::setApiKey($gateway->config->secret_key); - // Ensure customer has a stripe_id - if ($customer->missingMeta('stripe_id')) { - Storefront::createStripeCustomerForContact($customer); - } - try { + // Ensure customer has a stripe_id. Inside the try: Stripe refusing the gateway's key + // must come back as an error, not an uncaught exception rendered as 401. + if ($customer->missingMeta('stripe_id')) { + Storefront::createStripeCustomerForContact($customer); + } + // Create Ephemeral Key $ephemeralKey = \Stripe\EphemeralKey::create( ['customer' => $customer->getMeta('stripe_id')], @@ -1097,12 +1098,13 @@ public function getStripeSetupIntent(Request $request) \Stripe\Stripe::setApiKey($gateway->config->secret_key); - // Ensure customer has a stripe_id - if ($customer->missingMeta('stripe_id')) { - Storefront::createStripeCustomerForContact($customer); - } - try { + // Ensure customer has a stripe_id. Inside the try: Stripe refusing the gateway's key + // must come back as an error, not an uncaught exception rendered as 401. + if ($customer->missingMeta('stripe_id')) { + Storefront::createStripeCustomerForContact($customer); + } + // Create SetupIntent $setupIntent = \Stripe\SetupIntent::create([ 'customer' => $customer->getMeta('stripe_id'), diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index 94beab9b..e33b7ebd 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -129,28 +129,30 @@ public function query(Request $request) public function applySort($request, $sort) { if ($sort) { + // Core's query builder reads the direction from a "-" prefix on each column (it has no + // sort_direction parameter). Equal ratings list the newest review first. switch ($sort) { case 'highest': case 'highest rated': - $request->merge(['sort' => 'rating', 'sort_direction' => 'desc']); + $request->merge(['sort' => ['-rating', '-created_at']]); break; case 'lowest': case 'lowest rated': - $request->merge(['sort' => 'rating', 'sort_direction' => 'asc']); + $request->merge(['sort' => ['rating', '-created_at']]); break; case 'newest': case 'newest first': - $request->merge(['sort' => 'created_at', 'sort_direction' => 'desc']); + $request->merge(['sort' => ['-created_at']]); break; case 'oldest': case 'oldest first': - $request->merge(['sort' => 'created_at', 'sort_direction' => 'asc']); + $request->merge(['sort' => ['created_at']]); break; diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 4fa4ff91..f8f19d7e 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -452,6 +452,10 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode }); $failedEphemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key')); $failedSetupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup')); + // Creating the Stripe customer fails too (e.g. the gateway's key is refused): an error, not an exception. + $connection->table('contacts')->where('uuid', $customerUuid)->update(['meta' => '{}']); + $failedCreateEphemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key')); + $failedCreateSetupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup')); $connection->table('gateways')->delete(); $missingEphemeralGateway = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key')); $missingSetupGateway = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup')); @@ -559,6 +563,10 @@ public function captureException(Throwable $exception): void ->and($createdSetupIntent->getData(true)['customerId'])->toBe('cus_created_customer') ->and($failedEphemeralKey->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) ->and($failedSetupIntent->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) + ->and($failedCreateEphemeralKey->getStatusCode())->toBe(400) + ->and($failedCreateEphemeralKey->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) + ->and($failedCreateSetupIntent->getStatusCode())->toBe(400) + ->and($failedCreateSetupIntent->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) ->and($missingEphemeralGateway->getData(true))->toBe(['error' => 'Stripe not setup.']) ->and($missingSetupGateway->getData(true))->toBe(['error' => 'Stripe not setup.']) ->and($closureStart->getData(true))->toBe(['error' => 'Customer user account not found.']) diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 0f55ec0e..8d513886 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -337,7 +337,7 @@ function createReviewControllerSchema(): void }); } -test('review sort aliases map to stable API sort fields and directions', function (string $sort, ?array $expected) { +test('review sort aliases map to the sort columns core understands, with "-" for descending', function (string $sort, ?array $expected) { $request = Request::create('/reviews'); (new ReviewController())->applySort($request, $sort); @@ -345,13 +345,14 @@ function createReviewControllerSchema(): void if ($expected === null) { expect($request->has('sort'))->toBeFalse(); } else { - expect($request->only(['sort', 'sort_direction']))->toBe($expected); + expect($request->input('sort'))->toBe($expected) + ->and($request->has('sort_direction'))->toBeFalse(); } })->with([ - 'highest' => ['highest rated', ['sort' => 'rating', 'sort_direction' => 'desc']], - 'lowest' => ['lowest', ['sort' => 'rating', 'sort_direction' => 'asc']], - 'newest' => ['newest first', ['sort' => 'created_at', 'sort_direction' => 'desc']], - 'oldest' => ['oldest', ['sort' => 'created_at', 'sort_direction' => 'asc']], + 'highest' => ['highest rated', ['-rating', '-created_at']], + 'lowest' => ['lowest', ['rating', '-created_at']], + 'newest' => ['newest first', ['-created_at']], + 'oldest' => ['oldest', ['created_at']], 'unknown' => ['featured', null], ]); @@ -446,8 +447,7 @@ function createReviewControllerSchema(): void $resource = (new ReviewController())->query($request); - expect($request->input('sort'))->toBe('rating') - ->and($request->input('sort_direction'))->toBe('desc') + expect($request->input('sort'))->toBe(['-rating', '-created_at']) ->and($resource->resource)->toHaveCount(1) ->and($resource->resource->first()->uuid)->toBe('review_two_uuid'); }); From 986843a96795c887b5042900ebda60cab6c9b3ad Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 15:30:09 +0800 Subject: [PATCH 14/50] fix(seeders): keep store and network keys when re-seeding Re-seeding purged and recreated the network and stores, and the models generate a new key on every create, so each run changed the storefront key and forced an app rebuild. The seeder now remembers the uuid, public id and key of every store and network it created before purging, recreates them with the same uuid, and writes the old public id and key back after the insert. New ones get new keys as before. --- .../Concerns/SeedsStorefrontFixtures.php | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index 5272338c..5239ee97 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -39,6 +39,7 @@ use Fleetbase\Storefront\Models\StoreLocation; use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Utils; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -79,6 +80,15 @@ trait SeedsStorefrontFixtures /** @var array dropoff place per seeded customer uuid */ protected array $customerPlaces = []; + /** + * uuid, public id and key of the stores and networks this seeder created before, by + * class and seed id, so re-seeding keeps them. Apps are built against a storefront key, + * so a new key would need an app rebuild. + * + * @var array> + */ + protected array $seededIdentities = []; + /* |-------------------------------------------------------------------------- | Purging @@ -91,6 +101,8 @@ trait SeedsStorefrontFixtures */ protected function purgeStorefrontFixtures(): void { + $this->rememberSeededIdentities(); + $storeUuids = $this->seededUuids(Store::class); $networkUuids = $this->seededUuids(Network::class); $productUuids = $this->seededUuids(Product::class); @@ -160,6 +172,55 @@ protected function purgeStorefrontFixtures(): void $this->customerPlaces = []; } + /** + * Record the identity of every store and network this seeder created, before purging. + */ + protected function rememberSeededIdentities(): void + { + $this->seededIdentities = []; + + foreach ([Store::class => 'meta', Network::class => 'options'] as $modelClass => $column) { + foreach ($this->seededQuery($modelClass)->get() as $model) { + $tag = (array) $model->{$column}; + $seedId = $tag['seed_id'] ?? null; + if (!$seedId || ($tag['seed'] ?? null) !== $this->seedName()) { + continue; + } + + $this->seededIdentities[$modelClass][$seedId] = [ + 'uuid' => $model->uuid, + 'public_id' => $model->public_id, + 'key' => $model->key, + ]; + } + } + } + + /** + * Create a store or network, keeping the uuid, public id and key it had when this + * seeder last created it. The models generate a new key on every create, so the + * remembered key and public id are written back straight after the insert. + */ + protected function createWithSeededIdentity(string $modelClass, string $seedId, array $attributes): Model + { + $previous = $this->seededIdentities[$modelClass][$seedId] ?? null; + if ($previous) { + $attributes['uuid'] = $previous['uuid']; + } + + $model = $this->createRecord($modelClass, $attributes); + + if ($previous) { + $identity = array_filter(['public_id' => $previous['public_id'], 'key' => $previous['key']]); + if ($identity) { + $model->getConnection()->table($model->getTable())->where('uuid', $model->uuid)->update($identity); + $model->forceFill($identity)->syncOriginal(); + } + } + + return $model; + } + protected function purgeSeededLedgerJournals(array $orderUuids): void { if (!Schema::connection($this->fleetbaseConnection())->hasTable('ledger_journals')) { @@ -197,7 +258,7 @@ protected function seedStore(Company $company, array $definition): array $storeKey = $definition['key']; $seedId = 'store:' . $storeKey; - $store = $this->createRecord(Store::class, [ + $store = $this->createWithSeededIdentity(Store::class, $seedId, [ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'order_config_uuid' => Storefront::getOrderConfig($company)->uuid, @@ -687,7 +748,7 @@ protected function createCampaign(Company $company, Store|Network $owner, string protected function createNetwork(Company $company, array $definition): Network { - $network = $this->createRecord(Network::class, [ + $network = $this->createWithSeededIdentity(Network::class, 'network:' . $definition['key'], [ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'order_config_uuid' => Storefront::getOrderConfig($company)->uuid, From 78f4017ffda9131e4712cd3ed25f6fa9c156243f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 15:44:03 +0800 Subject: [PATCH 15/50] fix(seeders): seed a storefront delivery rate and keep real Stripe keys - No service rate had the storefront service type, so every delivery quote failed with "No service rates available!" and checkout showed delivery as unavailable for any address. Both seeders now create a "Storefront Delivery" rate (base fee plus a per-km fee, no service area) for the storefront order config, purged and recreated like their other fixtures. - Re-seeding replaced the seeded Stripe gateways' keys with placeholders. A gateway now keeps the real keys it already had (set in the console), unless SEED_STRIPE_SECRET_KEY / SEED_STRIPE_PUBLISHABLE_KEY are given. --- .../Concerns/SeedsStorefrontFixtures.php | 71 +++++++++++++++++-- server/seeders/Testing/NetworkSeeder.php | 1 + server/seeders/Testing/StoreSeeder.php | 1 + 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index 5239ee97..b0cc09dd 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Models\ServiceQuote; +use Fleetbase\FleetOps\Models\ServiceRate; use Fleetbase\FleetOps\Support\Utils as FleetOpsUtils; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Category; @@ -89,6 +90,14 @@ trait SeedsStorefrontFixtures */ protected array $seededIdentities = []; + /** + * Config (Stripe keys) of the gateways this seeder created before, by seed id, so real + * keys set on a seeded gateway survive re-seeding. + * + * @var array + */ + protected array $seededGatewayConfigs = []; + /* |-------------------------------------------------------------------------- | Purging @@ -158,8 +167,9 @@ protected function purgeStorefrontFixtures(): void $this->purgeModel(AddonCategory::class); $this->purgeModel(Category::class); - // Payments + // Payments and delivery pricing $this->purgeModel(Gateway::class); + $this->purgeModel(ServiceRate::class); $this->deleteFrom($this->storefrontConnection(), 'payment_methods', fn ($query) => $query->whereIn('owner_uuid', $customerUuids)); // Stores, locations and networks @@ -194,6 +204,14 @@ protected function rememberSeededIdentities(): void ]; } } + + $this->seededGatewayConfigs = []; + foreach ($this->seededQuery(Gateway::class)->get() as $gateway) { + $tag = (array) $gateway->meta; + if (($tag['seed'] ?? null) === $this->seedName() && !empty($tag['seed_id'])) { + $this->seededGatewayConfigs[$tag['seed_id']] = (array) $gateway->config; + } + } } /** @@ -389,6 +407,52 @@ protected function createStoreLocation(Company $company, Store $store, string $s * are stored so the gateway is selectable and the "missing secret" checkout path * can be exercised. */ + /** + * Stripe keys for a seeded gateway: SEED_STRIPE_* when set, else the real keys already on + * this gateway from an earlier run (set in the console), else placeholders. + */ + protected function stripeGatewayConfig(string $seedId): array + { + $placeholder = ['secret_key' => 'sk_test_storefront_seed_placeholder', 'publishable_key' => 'pk_test_storefront_seed_placeholder']; + if (env('SEED_STRIPE_SECRET_KEY')) { + return ['secret_key' => env('SEED_STRIPE_SECRET_KEY'), 'publishable_key' => env('SEED_STRIPE_PUBLISHABLE_KEY') ?: $placeholder['publishable_key']]; + } + + $previous = $this->seededGatewayConfigs[$seedId] ?? []; + $secret = $previous['secret_key'] ?? null; + if (is_string($secret) && $secret !== '' && $secret !== $placeholder['secret_key']) { + return $previous; + } + + return $placeholder; + } + + /** + * A storefront delivery rate for the company, without a service area so every address + * gets a quote: a base fee plus a fee per kilometre, in the seeded stores' currency. + */ + protected function createDeliveryServiceRate(Company $company, string $seedId, string $currency = 'USD'): ServiceRate + { + $orderConfig = Storefront::getOrderConfig($company); + + return $this->createRecord(ServiceRate::class, [ + '_key' => $this->fixtureKey($seedId), + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'order_config_uuid' => $orderConfig?->uuid, + 'service_name' => 'Storefront Delivery', + 'service_type' => data_get($orderConfig, 'key', 'storefront'), + 'rate_calculation_method' => 'per_meter', + 'base_fee' => 299, + 'per_meter_flat_rate_fee' => 60, + 'per_meter_unit' => 'km', + 'currency' => $currency, + 'duration_terms' => 'Delivered within the hour', + 'estimated_days' => 0, + 'has_cod_fee' => false, + ]); + } + protected function createStripeGateway(Company $company, Store|Network $owner, string $ownerType, string $seedId): Gateway { return $this->createRecord(Gateway::class, [ @@ -401,10 +465,7 @@ protected function createStripeGateway(Company $company, Store|Network $owner, s 'code' => 'stripe', 'type' => 'stripe', 'sandbox' => true, - 'config' => [ - 'secret_key' => env('SEED_STRIPE_SECRET_KEY') ?: 'sk_test_storefront_seed_placeholder', - 'publishable_key' => env('SEED_STRIPE_PUBLISHABLE_KEY') ?: 'pk_test_storefront_seed_placeholder', - ], + 'config' => $this->stripeGatewayConfig($seedId), 'return_url' => null, 'callback_url' => null, 'meta' => $this->meta($seedId), diff --git a/server/seeders/Testing/NetworkSeeder.php b/server/seeders/Testing/NetworkSeeder.php index fefdba66..9a025e40 100644 --- a/server/seeders/Testing/NetworkSeeder.php +++ b/server/seeders/Testing/NetworkSeeder.php @@ -52,6 +52,7 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $network = $this->createNetwork($company, $this->networkDefinition()); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::NETWORK_KEY); $gateway = $this->createStripeGateway($company, $network, 'storefront:network', 'gateway:' . static::NETWORK_KEY . ':stripe'); $categories = []; diff --git a/server/seeders/Testing/StoreSeeder.php b/server/seeders/Testing/StoreSeeder.php index 3433cd84..ab3710c6 100644 --- a/server/seeders/Testing/StoreSeeder.php +++ b/server/seeders/Testing/StoreSeeder.php @@ -44,6 +44,7 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $bundle = $this->seedStore($company, $this->storeDefinition()); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::STORE_KEY); $customers = $this->seedCustomers($company, $this->customerFixtures()); $orders = $this->seedStoreActivity($company, $bundle, $customers, null, 30); $marketing = $this->seedMarketing($company, $bundle['store'], static::STORE_KEY, $this->marketingDefinition(), [static::STORE_KEY => $bundle]); From 7df89a92415af9bca949f38e4b99dd1fe0984ed8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 16:27:34 +0800 Subject: [PATCH 16/50] fix(networks): save network payment gateway changes through the gateway modal --- .../networks/index/network/index.js | 21 +++++---- .../networks/index/network/index.hbs | 45 ++++++++++++++----- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/addon/controllers/networks/index/network/index.js b/addon/controllers/networks/index/network/index.js index 5902ddd9..e44bed45 100644 --- a/addon/controllers/networks/index/network/index.js +++ b/addon/controllers/networks/index/network/index.js @@ -152,13 +152,17 @@ export default class NetworksIndexNetworkIndexController extends Controller { this.editGateway(gateway, { title: this.intl.t('storefront.networks.index.network.index.create-new-payment-gateway'), acceptButtonText: this.intl.t('storefront.networks.index.network.index.save-gateway'), - confirm: (modal) => { + confirm: async (modal) => { modal.startLoading(); - return gateway.save().then((gateway) => { + try { + await gateway.save(); this.notifications.success(this.intl.t('storefront.networks.index.network.index.new-gateway-add-network')); this.gateways.pushObject(gateway); - }); + } catch (error) { + modal.stopLoading(); + this.notifications.serverError(error); + } }, decline: (modal) => { gateway.destroyRecord(); @@ -180,14 +184,9 @@ export default class NetworksIndexNetworkIndexController extends Controller { options = {}; } - if (!options.confirm) { - options.confirm = (modal) => { - modal.startLoading(); - - return gateway.save().then(() => { - this.notifications.success(this.intl.t('storefront.networks.index.network.index.payment-gateway-changes-success')); - }); - }; + // The settings controller's modal saves the gateway and reports errors; only the message differs. + if (!options.successNotification) { + options.successNotification = this.intl.t('storefront.networks.index.network.index.payment-gateway-changes-success'); } return this.gatewaysController.editGateway(gateway, options); diff --git a/addon/templates/networks/index/network/index.hbs b/addon/templates/networks/index/network/index.hbs index b5997801..925f9791 100644 --- a/addon/templates/networks/index/network/index.hbs +++ b/addon/templates/networks/index/network/index.hbs @@ -334,9 +334,11 @@ {{#each this.gateways as |gateway|}} + {{! Read-only summary; changes are made in the gateway modal, which saves them. }}
@@ -367,23 +371,40 @@ {{#each-in gateway.config as |key value|}} {{#if (is-bool-value value)}}
- +
{{else}} - - + + {{/if}} {{/each-in}}
-
-
{{/each}} From 174589e3fa1ec68b05e58c2f754f8b25fd81d2c4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 17:22:19 +0800 Subject: [PATCH 17/50] fix(seeders): seed the Singapore test stores and network in SGD The test stores, network, products, promotions, seeded orders and the seeded delivery rate now use Singapore dollars. Service rates only quote carts in their own currency, so the delivery rate follows the seeded owner's currency. --- .../Concerns/SeedsStorefrontFixtures.php | 20 ++++++++++++++----- server/seeders/Testing/NetworkSeeder.php | 4 ++-- server/seeders/Testing/StoreSeeder.php | 4 ++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index b0cc09dd..0c3e4e82 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -60,7 +60,7 @@ * 'description' => '...', * 'email' => '...', 'phone' => '...', 'website' => '...', * 'tags' => ['groceries'], - * 'currency' => 'USD', 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', + * 'currency' => 'SGD', 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', * 'options' => ['auto_accept_orders' => false, ...], * 'gateway' => 'stripe' | null, * 'location' => ['name' => ..., 'street1' => ..., 'city' => ..., 'country' => 'SG', 'postal_code' => ..., 'lat' => 1.28, 'lng' => 103.85], @@ -287,7 +287,7 @@ protected function seedStore(Company $company, array $definition): array 'phone' => $definition['phone'] ?? null, 'website' => $definition['website'] ?? null, 'tags' => $definition['tags'] ?? [], - 'currency' => $definition['currency'] ?? 'USD', + 'currency' => $definition['currency'] ?? $this->seedCurrency(), 'timezone' => $definition['timezone'] ?? 'Asia/Singapore', 'pod_method' => $definition['pod_method'] ?? 'scan', 'options' => array_merge([ @@ -427,12 +427,22 @@ protected function stripeGatewayConfig(string $seedId): array return $placeholder; } + /** + * The test stores and network are in Singapore, so everything they sell, quote and + * charge is in Singapore dollars. Rates only quote carts in their own currency. + */ + protected function seedCurrency(): string + { + return 'SGD'; + } + /** * A storefront delivery rate for the company, without a service area so every address * gets a quote: a base fee plus a fee per kilometre, in the seeded stores' currency. */ - protected function createDeliveryServiceRate(Company $company, string $seedId, string $currency = 'USD'): ServiceRate + protected function createDeliveryServiceRate(Company $company, string $seedId, ?string $currency = null): ServiceRate { + $currency ??= $this->seedCurrency(); $orderConfig = Storefront::getOrderConfig($company); return $this->createRecord(ServiceRate::class, [ @@ -728,7 +738,7 @@ protected function createPromotion(Company $company, Store|Network $owner, strin 'type' => $promotion['type'], 'value' => $promotion['value'] ?? null, 'max_discount_amount' => $promotion['max_discount_amount'] ?? null, - 'currency' => $promotion['currency'] ?? $owner->currency ?? 'USD', + 'currency' => $promotion['currency'] ?? $owner->currency ?? $this->seedCurrency(), 'min_subtotal' => $promotion['min_subtotal'] ?? null, 'min_items' => $promotion['min_items'] ?? null, 'applies_to' => $appliesTo ?: null, @@ -820,7 +830,7 @@ protected function createNetwork(Company $company, array $definition): Network 'phone' => $definition['phone'] ?? null, 'website' => $definition['website'] ?? null, 'tags' => $definition['tags'] ?? [], - 'currency' => $definition['currency'] ?? 'USD', + 'currency' => $definition['currency'] ?? $this->seedCurrency(), 'timezone' => $definition['timezone'] ?? 'Asia/Singapore', 'pod_method' => $definition['pod_method'] ?? 'scan', 'options' => array_merge([ diff --git a/server/seeders/Testing/NetworkSeeder.php b/server/seeders/Testing/NetworkSeeder.php index 9a025e40..b70e4518 100644 --- a/server/seeders/Testing/NetworkSeeder.php +++ b/server/seeders/Testing/NetworkSeeder.php @@ -52,7 +52,7 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $network = $this->createNetwork($company, $this->networkDefinition()); - $this->createDeliveryServiceRate($company, 'service-rate:' . static::NETWORK_KEY); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::NETWORK_KEY, $network->currency); $gateway = $this->createStripeGateway($company, $network, 'storefront:network', 'gateway:' . static::NETWORK_KEY . ':stripe'); $categories = []; @@ -108,7 +108,7 @@ protected function networkDefinition(): array 'phone' => '+65 6100 0200', 'website' => 'https://example.test/fleetbase-marketplace', 'tags' => ['marketplace', 'testing'], - 'currency' => 'USD', + 'currency' => $this->seedCurrency(), 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', 'options' => [ diff --git a/server/seeders/Testing/StoreSeeder.php b/server/seeders/Testing/StoreSeeder.php index ab3710c6..cb9dfe9a 100644 --- a/server/seeders/Testing/StoreSeeder.php +++ b/server/seeders/Testing/StoreSeeder.php @@ -44,7 +44,7 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $bundle = $this->seedStore($company, $this->storeDefinition()); - $this->createDeliveryServiceRate($company, 'service-rate:' . static::STORE_KEY); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::STORE_KEY, $bundle['store']->currency); $customers = $this->seedCustomers($company, $this->customerFixtures()); $orders = $this->seedStoreActivity($company, $bundle, $customers, null, 30); $marketing = $this->seedMarketing($company, $bundle['store'], static::STORE_KEY, $this->marketingDefinition(), [static::STORE_KEY => $bundle]); @@ -81,7 +81,7 @@ protected function storeDefinition(): array 'phone' => '+65 6100 0100', 'website' => 'https://example.test/fleetbase-market', 'tags' => ['groceries', 'local', 'testing'], - 'currency' => 'USD', + 'currency' => $this->seedCurrency(), 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', 'gateway' => 'stripe', From 244364879eb9377f4a0d63b0740a7fdf8730a964 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 18:27:20 +0800 Subject: [PATCH 18/50] fix(checkout): keep the checkout linked to its Stripe PaymentIntent when it is updated Choosing another payment method calls stripe-update-payment-intent, which updated the PaymentIntent and then created a new checkout without its PaymentIntent id. The app captured with that checkout's token and capture refused it ('Stripe PaymentIntent is not linked to this checkout'). The checkout that started the PaymentIntent is now updated in place (a PaymentIntent belongs to one checkout), and its promotion reservations are released before being reserved again at the new price. A PaymentIntent on another customer's checkout is refused. With no existing checkout, the new one is linked to the PaymentIntent. --- .../Controllers/v1/CheckoutController.php | 20 ++++++++-- .../src/Promotions/PromotionRedemptions.php | 11 ++++++ .../CheckoutBoundaryContractsTest.php | 38 ++++++++++++++++++- .../Unit/Promotions/PromotionCheckoutTest.php | 16 ++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 1aa9443c..5f8eeba3 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -700,8 +700,7 @@ public function updateStripePaymentIntent(Request $request) return response()->apiError('Failed to create ephemeral key: ' . $e->getMessage()); } - // Create a new checkout token - $checkout = Checkout::create([ + $attributes = [ 'company_uuid' => session('company'), 'store_uuid' => session('storefront_store'), 'network_uuid' => session('storefront_network'), @@ -715,7 +714,22 @@ public function updateStripePaymentIntent(Request $request) 'is_pickup' => $isPickup, 'options' => $checkoutOptions, 'cart_state' => $cart->toArray(), - ]); + ]; + + // Capture verifies the payment against the checkout's PaymentIntent, and a + // PaymentIntent belongs to one checkout, so update the checkout that started it + // rather than adding an unlinked one. + $checkout = Checkout::where('stripe_payment_intent_id', $paymentIntent->id)->first(); + if ($checkout && $checkout->owner_uuid !== $customer->uuid) { + return response()->apiError('PaymentIntent belongs to another checkout.', 422); + } + if ($checkout) { + // Its promotion uses are reserved again below at the new price. + PromotionRedemptions::releaseFor($checkout); + $checkout->update($attributes); + } else { + $checkout = Checkout::create([...$attributes, 'stripe_payment_intent_id' => $paymentIntent->id]); + } // Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is // the chkt_* public id GET /checkouts/status requires alongside the token. diff --git a/server/src/Promotions/PromotionRedemptions.php b/server/src/Promotions/PromotionRedemptions.php index e0be01d1..ec3f07ba 100644 --- a/server/src/Promotions/PromotionRedemptions.php +++ b/server/src/Promotions/PromotionRedemptions.php @@ -90,6 +90,17 @@ public static function redeem(Checkout $checkout, Order $order): void } } + /** + * Release a checkout's reserved promotion uses, so the checkout can be priced and + * reserved again (the customer changed how they pay before capturing). + */ + public static function releaseFor(Checkout $checkout): int + { + return PromotionRedemption::where('checkout_uuid', $checkout->uuid) + ->where('status', PromotionRedemption::STATUS_RESERVED) + ->update(['status' => PromotionRedemption::STATUS_RELEASED]); + } + /** * Release reservations of checkouts that were never captured. */ diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 7d02eee8..196807f5 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -501,7 +501,7 @@ function createCheckoutBoundarySchema(): void $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['carts', 'gateways', 'contacts', 'service_quotes', 'integrated_vendors', 'checkouts', 'networks', 'stores', 'orders'] as $table) { + foreach (['carts', 'gateways', 'contacts', 'service_quotes', 'integrated_vendors', 'checkouts', 'promotion_redemptions', 'networks', 'stores', 'orders'] as $table) { $schema->dropIfExists($table); } @@ -591,6 +591,14 @@ function createCheckoutBoundarySchema(): void $table->timestamps(); $table->timestamp('deleted_at')->nullable(); }); + $schema->create('promotion_redemptions', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('promotion_uuid')->nullable(); + $table->string('checkout_uuid')->nullable(); + $table->string('status')->default('reserved'); + $table->timestamps(); + }); $schema->create('stores', function ($table) { $table->increments('id'); $table->string('uuid')->nullable(); @@ -2482,7 +2490,35 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'PUT', $input )); + $checkout = Checkout::query()->first(); + + // Updating the same PaymentIntent again (another payment method chosen) updates that + // checkout instead of adding one capture couldn't verify. + $again = $controller->updateStripePaymentIntent(Request::create( + '/checkout/stripe-update', + 'PUT', + [...$input, 'pickup' => false] + )); + $againData = $again->getData(true); + + // Another customer's checkout keeps its PaymentIntent. + $connection->table('checkouts')->where('uuid', $checkout->uuid)->update(['owner_uuid' => 'other_customer_uuid']); + $foreign = $controller->updateStripePaymentIntent(Request::create( + '/checkout/stripe-update', + 'PUT', + $input + )); + $connection->table('checkouts')->where('uuid', $checkout->uuid)->update(['owner_uuid' => 'customer_uuid', 'is_pickup' => true]); Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient()); + + expect(Checkout::query()->count())->toBe(1) + ->and($againData['checkout'])->toBe($checkout->public_id) + ->and($againData['token'])->toBe($checkout->token) + ->and($checkout->stripe_payment_intent_id)->toBe('pi_checkout') + ->and(Checkout::query()->first()->stripe_payment_intent_id)->toBe('pi_checkout') + ->and($foreign->getStatusCode())->toBe(422) + ->and($foreign->getData(true))->toBe(['error' => 'PaymentIntent belongs to another checkout.']); + $checkout = Checkout::query()->first(); $meta = json_decode($connection->table('contacts')->where('uuid', 'customer_uuid')->value('meta'), true); $updatedData = $updated->getData(true); diff --git a/server/tests/Unit/Promotions/PromotionCheckoutTest.php b/server/tests/Unit/Promotions/PromotionCheckoutTest.php index 85b6c15e..e64d962c 100644 --- a/server/tests/Unit/Promotions/PromotionCheckoutTest.php +++ b/server/tests/Unit/Promotions/PromotionCheckoutTest.php @@ -206,6 +206,22 @@ function promotionCart(array $items, ?string $codes = null): Cart ]); }); +test("a checkout's own reservations can be released before it is reserved again", function () { + $promotion = makePromotion(); + promotionDb()->table('promotion_redemptions')->insert([ + ['uuid' => 'mine', 'promotion_uuid' => $promotion->uuid, 'checkout_uuid' => 'checkout_uuid', 'status' => 'reserved', 'created_at' => now()], + ['uuid' => 'used', 'promotion_uuid' => $promotion->uuid, 'checkout_uuid' => 'checkout_uuid', 'status' => 'redeemed', 'created_at' => now()], + ['uuid' => 'theirs', 'promotion_uuid' => $promotion->uuid, 'checkout_uuid' => 'other_checkout_uuid', 'status' => 'reserved', 'created_at' => now()], + ]); + + expect(PromotionRedemptions::releaseFor(promotionCheckout()))->toBe(1) + ->and(promotionDb()->table('promotion_redemptions')->orderBy('uuid')->pluck('status', 'uuid')->all())->toBe([ + 'mine' => 'released', + 'theirs' => 'reserved', + 'used' => 'redeemed', + ]); +}); + test('stale reservations are released by the scheduled command', function () { $promotion = makePromotion(); promotionDb()->table('promotion_redemptions')->insert([ From eb267af51a71f0ab31e2845dd8548b0bee0976e0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 18:36:18 +0800 Subject: [PATCH 19/50] fix(seeders): write Singapore dollar amounts as S$ in promotion and campaign text The seeded stores and network sell in SGD, but promotion names, descriptions and campaign copy wrote amounts as "$40", while the app formats the amounts it calculates as "S$40". --- server/seeders/Testing/NetworkSeeder.php | 12 ++++++------ server/seeders/Testing/StoreSeeder.php | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/server/seeders/Testing/NetworkSeeder.php b/server/seeders/Testing/NetworkSeeder.php index b70e4518..c88f3b9f 100644 --- a/server/seeders/Testing/NetworkSeeder.php +++ b/server/seeders/Testing/NetworkSeeder.php @@ -329,9 +329,9 @@ protected function marketingDefinition(): array { return [ 'promotions' => [ - 'welcome' => ['name' => 'Welcome to Fleetbase Marketplace', 'description' => '15% off your first order, up to $10.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 1000, 'min_subtotal' => 2000, 'first_order_only' => true, 'code' => 'WELCOME15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], - 'free-delivery' => ['name' => 'Free delivery over $40', 'description' => 'Delivery is on us when your basket is $40 or more.', 'type' => 'free_delivery', 'min_subtotal' => 4000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], - 'weekend-five' => ['name' => '$5 off weekend orders', 'description' => 'Every Saturday and Sunday, $5 off orders of $25 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 2500, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], + 'welcome' => ['name' => 'Welcome to Fleetbase Marketplace', 'description' => '15% off your first order, up to S$10.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 1000, 'min_subtotal' => 2000, 'first_order_only' => true, 'code' => 'WELCOME15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], + 'free-delivery' => ['name' => 'Free delivery over S$40', 'description' => 'Delivery is on us when your basket is S$40 or more.', 'type' => 'free_delivery', 'min_subtotal' => 4000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], + 'weekend-five' => ['name' => 'S$5 off weekend orders', 'description' => 'Every Saturday and Sunday, S$5 off orders of S$25 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 2500, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], 'services-launch' => ['name' => 'Home & Wellness launch', 'description' => '20% off any booked service.', 'type' => 'percentage', 'value' => 20, 'max_discount_amount' => 3000, 'applies_to' => ['stores' => ['lion-city-cleaning', 'tiong-bahru-wellness']], 'starts_in_days' => 3, 'ends_in_days' => 33], 'spring-sale' => ['name' => 'Spring sale', 'description' => '10% off everything (ended).', 'type' => 'percentage', 'value' => 10, 'starts_in_days' => -40, 'ends_in_days' => -10], 'vip-preview' => ['name' => 'VIP preview (draft)', 'description' => 'Not published yet.', 'type' => 'percentage', 'value' => 25, 'status' => 'draft', 'is_public' => false, 'code' => 'VIPPREVIEW'], @@ -345,9 +345,9 @@ protected function marketingDefinition(): array ], 'campaigns' => [ 'welcome-sent' => ['name' => 'Welcome offer', 'title' => '15% off your first order', 'body' => 'Use WELCOME15 at checkout. Ends soon.', 'status' => 'sent', 'segment' => 'never', 'promotion' => 'welcome', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 3, 'stats' => ['targeted' => 0, 'batches' => 0]], - 'delivery-sent' => ['name' => 'Free delivery week', 'title' => 'Free delivery over $40', 'body' => 'Stock up this week, delivery is on us.', 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'free-delivery', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 1, 'stats' => ['targeted' => 12, 'batches' => 1]], + 'delivery-sent' => ['name' => 'Free delivery week', 'title' => 'Free delivery over S$40', 'body' => 'Stock up this week, delivery is on us.', 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'free-delivery', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 1, 'stats' => ['targeted' => 12, 'batches' => 1]], 'services-soon' => ['name' => 'Home & Wellness launch', 'title' => 'Book a clean or a massage', 'body' => 'New on Fleetbase Marketplace: 20% off booked services.', 'status' => 'scheduled', 'segment' => 'recent', 'promotion' => 'services-launch', 'action' => ['type' => 'store', 'ref' => 'lion-city-cleaning'], 'send_in_days' => 14], - 'winback-draft' => ['name' => 'We miss you (draft)', 'title' => 'Come back for $5 off', 'body' => 'Here is $5 off your next weekend order.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'weekend-five'], + 'winback-draft' => ['name' => 'We miss you (draft)', 'title' => 'Come back for S$5 off', 'body' => 'Here is S$5 off your next weekend order.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'weekend-five'], ], ]; } @@ -373,7 +373,7 @@ protected function storeMarketingDefinitions(): array ], 'lion-city-cleaning' => [ 'promotions' => [ - 'first-clean' => ['name' => '$20 off your first clean', 'description' => 'For new customers, on any home clean.', 'type' => 'fixed_amount', 'value' => 2000, 'min_subtotal' => 6000, 'first_order_only' => true, 'code' => 'FIRSTCLEAN', 'applies_to' => ['categories' => ['lion-city-cleaning:cleaning']], 'starts_in_days' => -20, 'ends_in_days' => 70], + 'first-clean' => ['name' => 'S$20 off your first clean', 'description' => 'For new customers, on any home clean.', 'type' => 'fixed_amount', 'value' => 2000, 'min_subtotal' => 6000, 'first_order_only' => true, 'code' => 'FIRSTCLEAN', 'applies_to' => ['categories' => ['lion-city-cleaning:cleaning']], 'starts_in_days' => -20, 'ends_in_days' => 70], ], ], ]; diff --git a/server/seeders/Testing/StoreSeeder.php b/server/seeders/Testing/StoreSeeder.php index cb9dfe9a..90b89208 100644 --- a/server/seeders/Testing/StoreSeeder.php +++ b/server/seeders/Testing/StoreSeeder.php @@ -241,7 +241,7 @@ protected function marketingDefinition(): array return [ 'promotions' => [ 'coffee-hour' => ['name' => 'Coffee hour', 'description' => '20% off beverages, every morning 7 to 10.', 'type' => 'percentage', 'value' => 20, 'applies_to' => ['categories' => [$key . ':beverages']], 'schedule' => [['days' => [1, 2, 3, 4, 5, 6, 7], 'start' => '07:00', 'end' => '10:00']], 'starts_in_days' => -14, 'ends_in_days' => 60], - 'market-five' => ['name' => '$5 off $30', 'description' => 'Use MARKET5 on orders of $30 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 3000, 'code' => 'MARKET5', 'usage_limit_per_customer' => 3, 'starts_in_days' => -7, 'ends_in_days' => 30], + 'market-five' => ['name' => 'S$5 off S$30', 'description' => 'Use MARKET5 on orders of S$30 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 3000, 'code' => 'MARKET5', 'usage_limit_per_customer' => 3, 'starts_in_days' => -7, 'ends_in_days' => 30], 'free-delivery' => ['name' => 'Free delivery for first orders', 'description' => 'Your first delivery is free.', 'type' => 'free_delivery', 'first_order_only' => true, 'starts_in_days' => -30, 'ends_in_days' => 90], 'harvest' => ['name' => 'Harvest week', 'description' => '15% off fresh produce (coming soon).', 'type' => 'percentage', 'value' => 15, 'applies_to' => ['categories' => [$key . ':produce']], 'starts_in_days' => 5, 'ends_in_days' => 12], ], @@ -252,7 +252,7 @@ protected function marketingDefinition(): array 'campaigns' => [ 'coffee-sent' => ['name' => 'Coffee hour', 'title' => '20% off coffee before 10', 'body' => 'Cold brew and lattes, every morning.', 'status' => 'sent', 'segment' => 'regulars', 'promotion' => 'coffee-hour', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 6, 'batches' => 1]], 'harvest-soon' => ['name' => 'Harvest week', 'title' => 'Harvest week starts soon', 'body' => '15% off fresh produce all week.', 'status' => 'scheduled', 'promotion' => 'harvest', 'action' => ['type' => 'promotion'], 'send_in_days' => 5], - 'winback-draft' => ['name' => 'Win back (draft)', 'title' => '$5 off your next order', 'body' => 'Use MARKET5 on orders of $30 or more.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'market-five'], + 'winback-draft' => ['name' => 'Win back (draft)', 'title' => 'S$5 off your next order', 'body' => 'Use MARKET5 on orders of S$30 or more.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'market-five'], ], ]; } From 17184b4d7811a4e739521f576df8fdd5905816f9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 20:44:43 +0800 Subject: [PATCH 20/50] feat(orders): customer order activity flow from the order's own config GET storefront/v1/orders/{id}/activity-flow returns the signed-in customer's order as a line of steps from its order config, so apps can show custom flows (e.g. Created > Dispatched > Started > Enroute > Completed) instead of guessing from status codes. Steps are the activities the order has been through (tracking history, first time each was reached), its current activity, then the path it is expected to take. At a branch, Fleet-Ops' own activity conditions decide: an activity whose conditions this order meets (pickup's 'ready for pickup') wins over an unconditional one, cancellation branches are never projected, and a looping flow is capped. Unknown statuses still appear, labelled from tracking history. Labels and details come from the config, with {storefront.name}-style placeholders filled. --- .../Http/Controllers/v1/OrderController.php | 29 +++ server/src/Support/OrderActivityFlow.php | 198 ++++++++++++++++++ server/src/routes.php | 1 + .../ReviewAndOrderControllerContractsTest.php | 117 +++++++++++ .../Unit/Routes/StorefrontRoutesTest.php | 1 + .../Unit/Support/OrderActivityFlowTest.php | 116 ++++++++++ 6 files changed, 462 insertions(+) create mode 100644 server/src/Support/OrderActivityFlow.php create mode 100644 server/tests/Unit/Support/OrderActivityFlowTest.php diff --git a/server/src/Http/Controllers/v1/OrderController.php b/server/src/Http/Controllers/v1/OrderController.php index 09119489..75746311 100644 --- a/server/src/Http/Controllers/v1/OrderController.php +++ b/server/src/Http/Controllers/v1/OrderController.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Support\OrderActivityFlow; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Http\JsonResponse; @@ -57,6 +58,34 @@ protected function updateOrderStatus(Order $order, string $status) return $order->updateStatus($status); } + /** + * The customer's order as a line of steps from its own order config: where it has been, + * where it is, and where it is expected to go (see OrderActivityFlow). + * + * GET storefront/v1/orders/{id}/activity-flow + */ + public function getActivityFlow(string $id) + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.'); + } + + $order = Order::where('public_id', $id) + ->whereNull('deleted_at') + ->first(); + + if (!$order) { + return response()->apiError('No order found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + return response()->json(OrderActivityFlow::forOrder($order)); + } + /** * Get receipt for an order based on the payment method type. * diff --git a/server/src/Support/OrderActivityFlow.php b/server/src/Support/OrderActivityFlow.php new file mode 100644 index 00000000..cc4ba0cf --- /dev/null +++ b/server/src/Support/OrderActivityFlow.php @@ -0,0 +1,198 @@ +getConnectionName()); + $config = $order->order_config_uuid ? $db->table('order_configs')->where('uuid', $order->order_config_uuid)->first(['public_id', 'key', 'name', 'flow']) : null; + $flow = $config ? (json_decode((string) $config->flow, true) ?: []) : []; + $history = $order->tracking_number_uuid + ? $db->table('tracking_statuses') + ->where('tracking_number_uuid', $order->tracking_number_uuid) + ->whereNull('deleted_at') + ->orderBy('created_at') + ->get(['code', 'status', 'created_at']) + ->map(fn ($status) => ['code' => $status->code, 'label' => $status->status, 'at' => $status->created_at ? Carbon::parse($status->created_at)->toIso8601String() : null]) + ->all() + : []; + $tokens = ['storefront' => ['name' => $order->getMeta('storefront')]]; + $passes = function (array $activity) use ($order, $flow): bool { + try { + return (new Activity($activity, $flow))->passes($order); + } catch (\Throwable $e) { + return false; + } + }; + + return [ + 'order' => $order->public_id, + 'order_config' => $config ? ['id' => $config->public_id, 'key' => $config->key, 'name' => $config->name] : null, + ...static::resolve($flow, $order->status, $history, $passes, $order->created_at ? Carbon::parse($order->created_at)->toIso8601String() : null, $tokens), + ]; + } + + /** + * @param array $flow the order config's activities, keyed by code + * @param string|null $status the order's current status code + * @param array $history tracking statuses, oldest first: ['code', 'label', 'at'] + * @param callable $passes whether an activity's conditions hold for the order + * @param string|null $createdAt when the order was placed + * @param array $tokens values for `{placeholders}` in activity details + */ + public static function resolve(array $flow, ?string $status, array $history, callable $passes, ?string $createdAt = null, array $tokens = []): array + { + $activities = []; + foreach ($flow as $key => $activity) { + if (is_array($activity)) { + $code = static::code($activity['code'] ?? $key); + $activities[$code] = $activity; + } + } + + // The path so far: each activity once, in the order it was first reached. + $path = []; + $reached = []; + foreach ($history as $entry) { + $code = static::code($entry['code'] ?? null); + if ($code === '' || isset($reached[$code])) { + continue; + } + $reached[$code] = $entry['at'] ?? null; + $path[] = ['code' => $code, 'label' => $entry['label'] ?? null]; + } + if (isset($activities['created']) && !isset($reached['created'])) { + array_unshift($path, ['code' => 'created', 'label' => null]); + $reached['created'] = $createdAt; + } + + $current = static::code($status); + if ($current !== '' && !isset($reached[$current])) { + $path[] = ['code' => $current, 'label' => null]; + $reached[$current] = null; + } + $currentIndex = $current === '' ? count($path) - 1 : array_search($current, array_column($path, 'code'), true); + + $canceled = $current !== '' && static::isCancellation($current, $activities[$current] ?? []); + $completed = !$canceled && static::isComplete($activities[$current] ?? []); + + // Where the order is expected to go next. + $upcoming = []; + if (!$canceled && !$completed) { + $visited = array_flip(array_column($path, 'code')); + $cursor = $activities[$current] ?? null; + while ($cursor && count($upcoming) < static::MAX_PROJECTED) { + $next = static::nextActivity($cursor, $activities, $visited, $passes); + if (!$next) { + break; + } + $code = static::code($next['code']); + $visited[$code] = true; + $upcoming[] = ['code' => $code, 'label' => null]; + $cursor = static::isComplete($next) ? null : $next; + } + } + + $steps = []; + foreach (array_merge($path, $upcoming) as $index => $step) { + $activity = $activities[$step['code']] ?? []; + $state = $index < $currentIndex ? 'done' : ($index === $currentIndex ? ($completed ? 'done' : 'current') : 'upcoming'); + $steps[] = [ + 'code' => $step['code'], + 'label' => $activity['status'] ?? $step['label'] ?? static::humanize($step['code']), + 'details' => isset($activity['details']) ? static::fill((string) $activity['details'], $tokens) : null, + 'state' => $state, + 'reached_at' => $state === 'upcoming' ? null : ($reached[$step['code']] ?? null), + 'complete' => static::isComplete($activity), + ]; + } + + return ['status' => $current ?: null, 'canceled' => $canceled, 'completed' => $completed, 'steps' => $steps]; + } + + /** + * The activity this order is expected to take after `$activity`: among the following + * activities it hasn't been through, not cancellations, whose conditions pass, one + * with conditions (it was chosen for this kind of order) before one without. + */ + protected static function nextActivity(array $activity, array $activities, array $visited, callable $passes): ?array + { + $candidates = []; + foreach ((array) ($activity['activities'] ?? []) as $nextCode) { + $code = static::code($nextCode); + $next = $activities[$code] ?? null; + if (!$next || isset($visited[$code]) || static::isCancellation($code, $next) || !$passes($next)) { + continue; + } + $candidates[] = $next; + } + + foreach ($candidates as $candidate) { + if (!empty($candidate['logic'])) { + return $candidate; + } + } + + return $candidates[0] ?? null; + } + + protected static function isCancellation(string $code, array $activity): bool + { + return in_array($code, ['canceled', 'cancelled', 'order_canceled'], true) || in_array('order.canceled', (array) ($activity['events'] ?? []), true); + } + + protected static function isComplete(array $activity): bool + { + return filter_var($activity['complete'] ?? false, FILTER_VALIDATE_BOOLEAN); + } + + protected static function code(mixed $code): string + { + return strtolower(trim((string) $code)); + } + + protected static function humanize(string $code): string + { + return ucfirst(str_replace('_', ' ', $code)); + } + + /** Fill `{storefront.name}`-style placeholders; ones without a value are dropped. */ + protected static function fill(string $text, array $tokens): string + { + $filled = preg_replace_callback('/\{([a-z0-9_.]+)\}/i', function ($match) use ($tokens) { + $value = data_get($tokens, $match[1]); + + return is_scalar($value) ? (string) $value : ''; + }, $text); + + return trim(preg_replace('/\s{2,}/', ' ', $filled)); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index ef9c5ed8..92d8f71a 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -98,6 +98,7 @@ function ($router) { $router->group(['prefix' => 'orders'], function () use ($router) { $router->put('picked-up', 'OrderController@completeOrderPickup'); $router->post('receipt', 'OrderController@getReceipt'); + $router->get('{id}/activity-flow', 'OrderController@getActivityFlow'); $router->get('{id}/chat', 'OrderChatController@show'); $router->get('{id}/chat/messages', 'OrderChatController@messages'); $router->post('{id}/chat/messages', 'OrderChatController@send'); diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 8d513886..2baa9dcd 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -1412,3 +1412,120 @@ public function put(string $path, string $contents, mixed $options = []): bool 'order' => 'order_uuid', ]))['status'])->toBe('canceled'); }); + +test('a customer gets their own order as steps from its order config', function () { + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + foreach (['personal_access_tokens', 'contacts', 'orders', 'order_configs', 'tracking_statuses'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->integer('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token'); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('type')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('order_config_uuid')->nullable(); + $table->string('tracking_number_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('order_configs', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('flow')->nullable(); + }); + $schema->create('tracking_statuses', function ($table) { + $table->increments('id'); + $table->string('tracking_number_uuid')->nullable(); + $table->string('code')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $connection->table('contacts')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'public_id' => 'contact_customer', 'type' => 'customer']); + $connection->table('personal_access_tokens')->insert([ + 'name' => '11111111-1111-4111-8111-111111111111', + 'token' => hash('sha256', 'customer-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + // A branch whose condition can't be evaluated is skipped rather than failing the request. + $connection->table('order_configs')->insert([ + 'uuid' => 'config_uuid', + 'public_id' => 'order_config_olimax', + 'key' => 'olimax', + 'name' => 'Oli Max delivery', + 'flow' => json_encode([ + 'created' => ['code' => 'created', 'status' => 'Created', 'activities' => ['dispatched']], + 'dispatched' => ['code' => 'dispatched', 'status' => 'Dispatched', 'details' => 'Sent from {storefront.name}', 'activities' => ['unsupported', 'started']], + 'unsupported' => ['code' => 'unsupported', 'status' => 'Unsupported', 'logic' => [['type' => 'not-a-logic-type', 'conditions' => []]]], + 'started' => ['code' => 'started', 'status' => 'Started', 'activities' => ['completed']], + 'completed' => ['code' => 'completed', 'status' => 'Completed', 'complete' => true], + ]), + ]); + $connection->table('orders')->insert([ + ['uuid' => 'mine_uuid', 'public_id' => 'order_mine', 'customer_uuid' => '11111111-1111-4111-8111-111111111111', 'order_config_uuid' => 'config_uuid', 'tracking_number_uuid' => 'tracking_uuid', 'status' => 'dispatched', 'meta' => json_encode(['storefront' => 'Oli Max']), 'created_at' => '2026-10-08 10:00:00', 'updated_at' => now(), 'deleted_at' => null], + ['uuid' => 'bare_uuid', 'public_id' => 'order_bare', 'customer_uuid' => '11111111-1111-4111-8111-111111111111', 'order_config_uuid' => null, 'tracking_number_uuid' => null, 'status' => 'created', 'meta' => '{}', 'created_at' => null, 'updated_at' => null, 'deleted_at' => null], + ['uuid' => 'theirs_uuid', 'public_id' => 'order_theirs', 'customer_uuid' => '22222222-2222-4222-8222-222222222222', 'order_config_uuid' => null, 'tracking_number_uuid' => null, 'status' => 'created', 'meta' => '{}', 'created_at' => null, 'updated_at' => null, 'deleted_at' => null], + ]); + $connection->table('tracking_statuses')->insert([ + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'CREATED', 'status' => 'Created', 'created_at' => '2026-10-08 10:00:00', 'deleted_at' => null], + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'DISPATCHED', 'status' => 'Dispatched', 'created_at' => '2026-10-08 10:05:00', 'deleted_at' => null], + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'REMOVED', 'status' => 'Removed', 'created_at' => '2026-10-08 10:06:00', 'deleted_at' => now()], + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'NO_TIME', 'status' => 'No time', 'created_at' => null, 'deleted_at' => null], + ]); + + $signedOut = Request::create('/orders'); + $signedOut->setLaravelSession(new Illuminate\Session\Store('activity-flow-signed-out', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $signedOut); + $unauthenticated = (new OrderController())->getActivityFlow('order_mine'); + + $boundRequest = Request::create('/orders'); + $boundRequest->headers->set('Customer-Token', 'customer-secret'); + $boundRequest->setLaravelSession(new Illuminate\Session\Store('activity-flow-test', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $boundRequest); + $controller = new OrderController(); + + $missing = $controller->getActivityFlow('order_missing'); + $unauthorized = $controller->getActivityFlow('order_theirs'); + $flow = $controller->getActivityFlow('order_mine')->getData(true); + $bare = $controller->getActivityFlow('order_bare')->getData(true); + + expect($unauthenticated->getData(true))->toBe(['error' => 'Customer is not authenticated.']) + ->and($missing->getStatusCode())->toBe(404) + ->and($unauthorized->getStatusCode())->toBe(403) + ->and($unauthorized->getData(true))->toBe(['error' => 'Not authorized to view this order.']) + ->and($flow['order'])->toBe('order_mine') + ->and($flow['order_config'])->toBe(['id' => 'order_config_olimax', 'key' => 'olimax', 'name' => 'Oli Max delivery']) + // History without a time sorts first; deleted history is left out. + ->and(array_map(fn ($step) => $step['code'] . ':' . $step['state'], $flow['steps']))->toBe(['no_time:done', 'created:done', 'dispatched:current', 'started:upcoming', 'completed:upcoming']) + ->and($flow['steps'][2]['details'])->toBe('Sent from Oli Max') + ->and($flow['steps'][2]['reached_at'])->toStartWith('2026-10-08T10:05:00') + ->and($flow['steps'][0]['reached_at'])->toBeNull() + ->and($bare['order_config'])->toBeNull() + ->and(array_map(fn ($step) => $step['code'] . ':' . $step['state'], $bare['steps']))->toBe(['created:current']); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index f1690eb0..80cf5f9b 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -92,6 +92,7 @@ private function record(string $method, string $uri, mixed $action): self ['GET', 'about', 'StoreController@about'], ['POST', '/', 'ProductController@create'], ['POST', 'receipt', 'OrderController@getReceipt'], + ['GET', '{id}/activity-flow', 'OrderController@getActivityFlow'], ['POST', 'socket-token', 'CustomerController@socketToken'], ['POST', 'send-push-notification', 'ActionController@sendPushNotification'], ['FLEETBASE', 'orders', null], diff --git a/server/tests/Unit/Support/OrderActivityFlowTest.php b/server/tests/Unit/Support/OrderActivityFlowTest.php new file mode 100644 index 00000000..5010b109 --- /dev/null +++ b/server/tests/Unit/Support/OrderActivityFlowTest.php @@ -0,0 +1,116 @@ + 'if', 'conditions' => [['field' => 'meta.is_pickup', 'operator' => 'equal', 'value' => 'true']]]]; + + return [ + 'created' => ['code' => 'created', 'status' => 'Order Created', 'activities' => ['started', 'canceled']], + 'started' => ['code' => 'started', 'status' => 'Order Started', 'activities' => ['canceled', 'preparing']], + 'preparing' => ['code' => 'preparing', 'status' => 'Order is being prepared', 'details' => '{storefront.name} is preparing your order {unknown}', 'activities' => ['driver_enroute_to_store', 'pickup_ready']], + 'driver_enroute_to_store' => ['code' => 'driver_enroute_to_store', 'status' => 'Driver en-route', 'activities' => ['driver_picked_up']], + 'driver_picked_up' => ['code' => 'driver_picked_up', 'status' => 'Driver picked up', 'activities' => ['driver_enroute']], + 'driver_enroute' => ['code' => 'driver_enroute', 'status' => 'Driver en-route to you', 'activities' => ['completed']], + 'completed' => ['code' => 'completed', 'status' => 'Order completed', 'complete' => true], + 'pickup_ready' => ['code' => 'pickup_ready', 'status' => 'Ready for pickup', 'logic' => $pickupOnly, 'activities' => ['picked_up']], + 'picked_up' => ['code' => 'picked_up', 'status' => 'Picked up', 'complete' => 'true'], + 'canceled' => ['code' => 'canceled', 'status' => 'Order canceled', 'events' => ['order.canceled']], + 'not-an-activity' => 'ignored', + ]; +} + +function flowCodes(array $result): array +{ + return array_map(fn ($step) => $step['code'] . ':' . $step['state'], $result['steps']); +} + +test('a delivery order follows the unconditional branch and stops at the completing activity', function () { + $history = [ + ['code' => 'CREATED', 'label' => 'Order Created', 'at' => '2026-10-08T10:00:00+00:00'], + ['code' => 'started', 'label' => 'Order Started', 'at' => '2026-10-08T10:01:00+00:00'], + ['code' => 'started', 'label' => 'Order Started', 'at' => '2026-10-08T10:02:00+00:00'], + ['code' => '', 'label' => 'Blank'], + ['code' => 'preparing', 'label' => 'Order is being prepared', 'at' => '2026-10-08T10:03:00+00:00'], + ]; + // Only pickup_ready has conditions, and a delivery order fails them. + $result = OrderActivityFlow::resolve(storefrontFlow(), 'preparing', $history, fn ($activity) => empty($activity['logic']), null, ['storefront' => ['name' => 'Orchard Grocers']]); + + expect(flowCodes($result))->toBe([ + 'created:done', 'started:done', 'preparing:current', + 'driver_enroute_to_store:upcoming', 'driver_picked_up:upcoming', 'driver_enroute:upcoming', 'completed:upcoming', + ]) + ->and($result['status'])->toBe('preparing') + ->and($result['canceled'])->toBeFalse() + ->and($result['completed'])->toBeFalse() + ->and($result['steps'][0]['reached_at'])->toBe('2026-10-08T10:00:00+00:00') + ->and($result['steps'][1]['reached_at'])->toBe('2026-10-08T10:01:00+00:00') + ->and($result['steps'][2]['details'])->toBe('Orchard Grocers is preparing your order') + ->and($result['steps'][3]['reached_at'])->toBeNull() + ->and($result['steps'][6]['complete'])->toBeTrue(); +}); + +test('a pickup order takes the branch whose conditions it meets', function () { + $result = OrderActivityFlow::resolve(storefrontFlow(), 'preparing', [], fn () => true, '2026-10-08T09:59:00+00:00'); + + expect(flowCodes($result))->toBe(['created:done', 'preparing:current', 'pickup_ready:upcoming', 'picked_up:upcoming']) + ->and($result['steps'][0]['reached_at'])->toBe('2026-10-08T09:59:00+00:00') + ->and($result['steps'][1]['details'])->toBe('is preparing your order'); +}); + +test('a custom linear flow shows its own steps and labels', function () { + $flow = [ + 'created' => ['status' => 'Created', 'activities' => ['dispatched']], + 'dispatched' => ['status' => 'Dispatched', 'activities' => ['started']], + 'started' => ['status' => 'Started', 'activities' => ['enroute']], + 'enroute' => ['status' => 'Enroute', 'activities' => ['completed']], + 'completed' => ['status' => 'Completed', 'complete' => true], + ]; + $result = OrderActivityFlow::resolve($flow, 'started', [['code' => 'created', 'label' => 'Created'], ['code' => 'dispatched', 'label' => 'Dispatched']], fn () => true); + + expect(flowCodes($result))->toBe(['created:done', 'dispatched:done', 'started:current', 'enroute:upcoming', 'completed:upcoming']) + ->and(array_column($result['steps'], 'label'))->toBe(['Created', 'Dispatched', 'Started', 'Enroute', 'Completed']); +}); + +test('completed and canceled orders project nothing further', function () { + $completed = OrderActivityFlow::resolve(storefrontFlow(), 'completed', [['code' => 'created'], ['code' => 'completed']], fn () => true); + $canceled = OrderActivityFlow::resolve(storefrontFlow(), 'canceled', [['code' => 'created'], ['code' => 'started']], fn () => true); + + expect(flowCodes($completed))->toBe(['created:done', 'completed:done']) + ->and($completed['completed'])->toBeTrue() + ->and(flowCodes($canceled))->toBe(['created:done', 'started:done', 'canceled:current']) + ->and($canceled['canceled'])->toBeTrue() + ->and($canceled['completed'])->toBeFalse(); +}); + +test('a status the flow does not know is still shown, with its tracking label or a readable code', function () { + $tracked = OrderActivityFlow::resolve(storefrontFlow(), 'at_warehouse', [['code' => 'created'], ['code' => 'at_warehouse', 'label' => 'At Warehouse']], fn () => true); + $untracked = OrderActivityFlow::resolve([], 'at_warehouse', [], fn () => true); + $empty = OrderActivityFlow::resolve([], null, [], fn () => true); + $historyOnly = OrderActivityFlow::resolve([], null, [['code' => 'created', 'label' => 'Created']], fn () => true); + + expect(flowCodes($tracked))->toBe(['created:done', 'at_warehouse:current']) + ->and($tracked['steps'][1]['label'])->toBe('At Warehouse') + ->and($untracked['steps'][0]['label'])->toBe('At warehouse') + ->and($empty)->toBe(['status' => null, 'canceled' => false, 'completed' => false, 'steps' => []]) + ->and(flowCodes($historyOnly))->toBe(['created:current']); +}); + +test('projection skips cancellations, visited activities and failing branches, and stops at a limit', function () { + $loop = [ + 'created' => ['activities' => ['canceled', 'missing', 'created', 'step_0']], + 'canceled' => ['activities' => []], + 'cancel_event' => ['events' => ['order.canceled']], + ]; + for ($i = 0; $i < 30; $i++) { + $loop["step_$i"] = ['activities' => ['cancel_event', 'step_' . ($i + 1)]]; + } + $result = OrderActivityFlow::resolve($loop, 'created', [], fn () => true); + $blocked = OrderActivityFlow::resolve(['created' => ['activities' => ['next']], 'next' => []], 'created', [], fn () => false); + + expect($result['steps'])->toHaveLength(1 + OrderActivityFlow::MAX_PROJECTED) + ->and($result['steps'][1]['code'])->toBe('step_0') + ->and(flowCodes($blocked))->toBe(['created:current']); +}); From e00f4ea4a688864e85d91211ae0011024cc14f4f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 22:26:54 +0800 Subject: [PATCH 21/50] fix(seeders): write store hour days capitalised, as the console does Seeded store hours used "monday", while the console writes "Monday" and clients such as the storefront SDK match on that, so the app's store info screen crashed on seeded stores. --- server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index 0c3e4e82..0b468718 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -390,7 +390,8 @@ protected function createStoreLocation(Company $company, Store $store, string $s $this->createRecord(StoreHour::class, [ 'store_location_uuid' => $storeLocation->uuid, - 'day_of_week' => $day, + // Capitalised like the console writes them ("Monday"); clients match on it. + 'day_of_week' => ucfirst($day), 'start' => $start, 'end' => $end, ]); From 14fea8af0a5502e1e7b07edeeb29c09cde892601 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 10:47:06 +0800 Subject: [PATCH 22/50] fix(orders): console order view loads items, barcode and the promotions applied --- .../order/details/commerce-summary.hbs | 11 +++++ .../order/details/commerce-summary.js | 24 +++++++++- addon/services/storefront.js | 11 +++-- server/src/Http/Resources/Index/Order.php | 3 ++ server/src/Http/Resources/Order.php | 46 ++++++++++++++++++- 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/addon/components/storefront/order/details/commerce-summary.hbs b/addon/components/storefront/order/details/commerce-summary.hbs index 4904e39b..b1218291 100644 --- a/addon/components/storefront/order/details/commerce-summary.hbs +++ b/addon/components/storefront/order/details/commerce-summary.hbs @@ -56,6 +56,17 @@ {{#unless @resource.meta.is_pickup}}
Delivery fee{{format-currency @resource.meta.delivery_fee @resource.meta.currency}}
{{/unless}} + {{#each this.promotions as |promotion|}} +
+ + {{promotion.name}}{{#if promotion.code}} · {{promotion.code}}{{/if}} + + -{{format-currency promotion.amount @resource.meta.currency}} +
+ {{/each}} + {{#if this.unattributedDiscount}} +
Discount-{{format-currency this.unattributedDiscount @resource.meta.currency}}
+ {{/if}} {{#if @resource.meta.tip}}
Tip{{get-tip-amount @resource.meta.tip @resource.meta.subtotal @resource.meta.currency}}
{{/if}} diff --git a/addon/components/storefront/order/details/commerce-summary.js b/addon/components/storefront/order/details/commerce-summary.js index 882ecda7..9e1b1419 100644 --- a/addon/components/storefront/order/details/commerce-summary.js +++ b/addon/components/storefront/order/details/commerce-summary.js @@ -1,3 +1,25 @@ import Component from '@glimmer/component'; -export default class StorefrontOrderDetailsCommerceSummaryComponent extends Component {} +export default class StorefrontOrderDetailsCommerceSummaryComponent extends Component { + /** Each promotion applied at checkout, with what it took off the order (items and delivery). */ + get promotions() { + const promotions = this.args.resource?.meta?.promotions; + if (!Array.isArray(promotions)) { + return []; + } + + return promotions.map((promotion) => ({ + name: promotion.name || promotion.code || 'Promotion', + code: promotion.code, + isFreeDelivery: promotion.type === 'free_delivery', + amount: (Number(promotion.amount) || 0) + (Number(promotion.delivery_amount) || 0), + })); + } + + /** The order's discount when no promotion breakdown was recorded. */ + get unattributedDiscount() { + const discount = Number(this.args.resource?.meta?.discount) || 0; + + return this.promotions.length === 0 && discount > 0 ? discount : 0; + } +} diff --git a/addon/services/storefront.js b/addon/services/storefront.js index 3ce5d258..a13fbf9a 100644 --- a/addon/services/storefront.js +++ b/addon/services/storefront.js @@ -96,11 +96,12 @@ export default class StorefrontService extends Service.extend(Evented) { * @param {Object} store - The store associated with the order. */ async alertIncomingOrder(orderId, store) { - const order = await this.store.queryRecord('order', { - public_id: orderId, - single: true, - with: ['customer', 'payload', 'trackingNumber'], - }); + // Through Storefront's own order endpoint: it shapes the customer and items so the console can load them. + const order = await this.fetch.get( + `orders/${orderId}`, + { storefront: store?.public_id ?? this.getActiveStore('public_id') }, + { namespace: 'storefront/int/v1', normalizeToEmberData: true, normalizeModelType: 'order' } + ); this.playAlert(); this.trigger('order.incoming', order, store); diff --git a/server/src/Http/Resources/Index/Order.php b/server/src/Http/Resources/Index/Order.php index f2907654..b6bce693 100644 --- a/server/src/Http/Resources/Index/Order.php +++ b/server/src/Http/Resources/Index/Order.php @@ -40,6 +40,9 @@ private function storefrontOrderMeta(): array 'tip', 'delivery_tip', 'total', + 'discount', + 'promotions', + 'payment_status', 'currency', 'gateway', 'is_pickup', diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php index e6b04a8b..97487bad 100644 --- a/server/src/Http/Resources/Order.php +++ b/server/src/Http/Resources/Order.php @@ -14,7 +14,7 @@ class Order extends FleetOpsOrderResource */ public function toArray($request): array { - $data = parent::toArray($request); + $data = $this->withoutPlaceTypes(json_decode(json_encode($this->filter(parent::toArray($request))), true) ?? []); $data['customer_name'] = $this->customer_name; $data['transaction_amount'] = $this->transaction_amount; $data['meta'] = $this->storefrontOrderMeta(); @@ -49,6 +49,9 @@ private function storefrontOrderMeta(): array 'tip', 'delivery_tip', 'total', + 'discount', + 'promotions', + 'payment_status', 'currency', 'gateway', 'is_pickup', @@ -69,6 +72,47 @@ private function storefrontOrderMeta(): array return $meta; } + /** + * A customer's places carry their kind in `type` (apartment, house, office...). + * The console reads `type` on an embedded record as its model name, so the + * customer copies in this order (its own and each item's) leave it out. + */ + private function withoutPlaceTypes(array $data): array + { + $strip = function ($customer) { + if (!is_array($customer)) { + return $customer; + } + if (is_array($customer['place'] ?? null)) { + unset($customer['place']['type']); + } + if (is_array($customer['places'] ?? null)) { + $customer['places'] = array_map(function ($place) { + if (is_array($place)) { + unset($place['type']); + } + + return $place; + }, $customer['places']); + } + + return $customer; + }; + + if (isset($data['customer'])) { + $data['customer'] = $strip($data['customer']); + } + if (is_array(data_get($data, 'payload.entities'))) { + foreach ($data['payload']['entities'] as $index => $entity) { + if (is_array($entity) && isset($entity['customer'])) { + $data['payload']['entities'][$index]['customer'] = $strip($entity['customer']); + } + } + } + + return $data; + } + private function normalizeMeta($meta): array { if ($meta instanceof Arrayable) { From bfcfa7f3c80217012f76ec2401a5b2f690387b6e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 11:18:16 +0800 Subject: [PATCH 23/50] fix(orders): order data leaves out the customer's saved places; one driver-assigned notice per driver --- server/src/Http/Resources/Order.php | 27 ++++++------------- .../Listeners/HandleOrderDriverAssigned.php | 6 ++++- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php index 97487bad..aade2cfe 100644 --- a/server/src/Http/Resources/Order.php +++ b/server/src/Http/Resources/Order.php @@ -14,7 +14,7 @@ class Order extends FleetOpsOrderResource */ public function toArray($request): array { - $data = $this->withoutPlaceTypes(json_decode(json_encode($this->filter(parent::toArray($request))), true) ?? []); + $data = $this->withoutCustomerPlaces(json_decode(json_encode($this->filter(parent::toArray($request))), true) ?? []); $data['customer_name'] = $this->customer_name; $data['transaction_amount'] = $this->transaction_amount; $data['meta'] = $this->storefrontOrderMeta(); @@ -73,27 +73,16 @@ private function storefrontOrderMeta(): array } /** - * A customer's places carry their kind in `type` (apartment, house, office...). - * The console reads `type` on an embedded record as its model name, so the - * customer copies in this order (its own and each item's) leave it out. + * A customer's places carry their kind in `type` (apartment, house, office...), which the + * console reads as a model name, and without it they cannot be identified at all. The + * customer copies in this order (its own and each item's) leave their places out. */ - private function withoutPlaceTypes(array $data): array + private function withoutCustomerPlaces(array $data): array { + // The order views don't use the customer's address book, so it is left out entirely. $strip = function ($customer) { - if (!is_array($customer)) { - return $customer; - } - if (is_array($customer['place'] ?? null)) { - unset($customer['place']['type']); - } - if (is_array($customer['places'] ?? null)) { - $customer['places'] = array_map(function ($place) { - if (is_array($place)) { - unset($place['type']); - } - - return $place; - }, $customer['places']); + if (is_array($customer)) { + unset($customer['place'], $customer['places']); } return $customer; diff --git a/server/src/Listeners/HandleOrderDriverAssigned.php b/server/src/Listeners/HandleOrderDriverAssigned.php index 7687978d..640b969e 100644 --- a/server/src/Listeners/HandleOrderDriverAssigned.php +++ b/server/src/Listeners/HandleOrderDriverAssigned.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Support\Facades\Cache; class HandleOrderDriverAssigned implements ShouldQueue { @@ -35,7 +36,10 @@ public function handle(OrderDriverAssigned $event) if ($order->hasMeta('storefront_id')) { $order->load(['customer']); - if ($order->customer) { + // Assigning and dispatching can both raise this event for the same driver; the + // customer hears about each driver once (a reassignment is a new driver). + $onceKey = 'storefront:driver-assigned:' . $order->uuid . ':' . ($order->driver_assigned_uuid ?? 'none'); + if ($order->customer && Cache::add($onceKey, true, now()->addDay())) { $order->customer->notify(new StorefrontOrderDriverAssigned($order)); } From 1b2a65735f3445cea1d62276a97ab17c3d40b876 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 11:37:13 +0800 Subject: [PATCH 24/50] revert(orders): order data keeps the customer's places as the API returns them; the console serializers handle place types --- addon/services/storefront.js | 11 +++++----- server/src/Http/Resources/Order.php | 32 +---------------------------- 2 files changed, 6 insertions(+), 37 deletions(-) diff --git a/addon/services/storefront.js b/addon/services/storefront.js index a13fbf9a..3ce5d258 100644 --- a/addon/services/storefront.js +++ b/addon/services/storefront.js @@ -96,12 +96,11 @@ export default class StorefrontService extends Service.extend(Evented) { * @param {Object} store - The store associated with the order. */ async alertIncomingOrder(orderId, store) { - // Through Storefront's own order endpoint: it shapes the customer and items so the console can load them. - const order = await this.fetch.get( - `orders/${orderId}`, - { storefront: store?.public_id ?? this.getActiveStore('public_id') }, - { namespace: 'storefront/int/v1', normalizeToEmberData: true, normalizeModelType: 'order' } - ); + const order = await this.store.queryRecord('order', { + public_id: orderId, + single: true, + with: ['customer', 'payload', 'trackingNumber'], + }); this.playAlert(); this.trigger('order.incoming', order, store); diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php index aade2cfe..5e785c59 100644 --- a/server/src/Http/Resources/Order.php +++ b/server/src/Http/Resources/Order.php @@ -14,7 +14,7 @@ class Order extends FleetOpsOrderResource */ public function toArray($request): array { - $data = $this->withoutCustomerPlaces(json_decode(json_encode($this->filter(parent::toArray($request))), true) ?? []); + $data = parent::toArray($request); $data['customer_name'] = $this->customer_name; $data['transaction_amount'] = $this->transaction_amount; $data['meta'] = $this->storefrontOrderMeta(); @@ -72,36 +72,6 @@ private function storefrontOrderMeta(): array return $meta; } - /** - * A customer's places carry their kind in `type` (apartment, house, office...), which the - * console reads as a model name, and without it they cannot be identified at all. The - * customer copies in this order (its own and each item's) leave their places out. - */ - private function withoutCustomerPlaces(array $data): array - { - // The order views don't use the customer's address book, so it is left out entirely. - $strip = function ($customer) { - if (is_array($customer)) { - unset($customer['place'], $customer['places']); - } - - return $customer; - }; - - if (isset($data['customer'])) { - $data['customer'] = $strip($data['customer']); - } - if (is_array(data_get($data, 'payload.entities'))) { - foreach ($data['payload']['entities'] as $index => $entity) { - if (is_array($entity) && isset($entity['customer'])) { - $data['payload']['entities'][$index]['customer'] = $strip($entity['customer']); - } - } - } - - return $data; - } - private function normalizeMeta($meta): array { if ($meta instanceof Arrayable) { From c4b66dcfb1cfaceb98ef6af264ac26cb63ca891b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 13:22:42 +0800 Subject: [PATCH 25/50] feat(notifications): customers are notified of each order step without its own notification --- .../Notifications/StorefrontOrderActivity.php | 22 +++++++ .../src/Observers/OrderActivityObserver.php | 63 +++++++++++++++++++ .../Providers/StorefrontServiceProvider.php | 1 + 3 files changed, 86 insertions(+) create mode 100644 server/src/Notifications/StorefrontOrderActivity.php create mode 100644 server/src/Observers/OrderActivityObserver.php diff --git a/server/src/Notifications/StorefrontOrderActivity.php b/server/src/Notifications/StorefrontOrderActivity.php new file mode 100644 index 00000000..2b840c31 --- /dev/null +++ b/server/src/Notifications/StorefrontOrderActivity.php @@ -0,0 +1,22 @@ +subject = $label; + $this->body = $details ?: 'Your order from ' . $this->storefront->name . ' has been updated: ' . $label . '.'; + $this->status = 'order_activity'; + } +} diff --git a/server/src/Observers/OrderActivityObserver.php b/server/src/Observers/OrderActivityObserver.php new file mode 100644 index 00000000..f7dcc434 --- /dev/null +++ b/server/src/Observers/OrderActivityObserver.php @@ -0,0 +1,63 @@ +wasChanged('status') || !$order->hasMeta('storefront_id')) { + return; + } + + $status = (string) $order->status; + if ($status === '' || in_array($status, static::NOTIFIED_ELSEWHERE, true)) { + return; + } + + $orderUuid = $order->uuid; + + // After the change is committed, so the step's tracking status is recorded too. + dispatch(function () use ($orderUuid, $status) { + OrderActivityObserver::notify($orderUuid, $status); + })->afterCommit(); + } + + public static function notify(string $orderUuid, string $status): void + { + // An activity update can save the order more than once; each step is told once. + if (!Cache::add('storefront:order-activity:' . $orderUuid . ':' . $status, true, now()->addDay())) { + return; + } + + try { + $order = Order::where('uuid', $orderUuid)->with(['customer'])->first(); + if (!$order || !$order->customer || $order->status !== $status) { + return; + } + + $flow = OrderActivityFlow::forOrder($order); + $step = collect($flow['steps'] ?? [])->firstWhere('code', $status); + $label = data_get($step, 'label') ?: ucfirst(str_replace('_', ' ', $status)); + + $order->customer->notify(new StorefrontOrderActivity($order, $label, data_get($step, 'details') ?: null)); + } catch (\Throwable $e) { + app(ExceptionHandler::class)->report($e); + } + } +} diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index b8b23369..937912b0 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -36,6 +36,7 @@ class StorefrontServiceProvider extends CoreServiceProvider \Fleetbase\Storefront\Models\FoodTruck::class => \Fleetbase\Storefront\Observers\FoodTruckObserver::class, \Fleetbase\Models\Company::class => \Fleetbase\Storefront\Observers\CompanyObserver::class, \Fleetbase\Models\ChatMessage::class => \Fleetbase\Storefront\Observers\ChatMessageObserver::class, + \Fleetbase\FleetOps\Models\Order::class => \Fleetbase\Storefront\Observers\OrderActivityObserver::class, ]; /** From 7c9bab347b5e2cfd3e3f2d7fd613e3cd11b0f6af Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 14:06:34 +0800 Subject: [PATCH 26/50] feat(bookings): service bookings follow their own order flow, with products brought to the earliest appointment --- addon/services/storefront-order-workflow.js | 23 +++- .../src/Http/Controllers/OrderController.php | 19 ++- .../Controllers/v1/CheckoutController.php | 68 ++++++++++ server/src/Http/Resources/Index/Order.php | 3 + server/src/Http/Resources/Order.php | 3 + server/src/Listeners/HandleOrderCompleted.php | 6 + .../src/Listeners/HandleOrderDispatched.php | 6 + server/src/Listeners/HandleOrderStarted.php | 6 + .../StorefrontOrderDriverAssigned.php | 10 +- .../src/Observers/OrderActivityObserver.php | 8 +- server/src/Support/Storefront.php | 125 +++++++++++++++++- 11 files changed, 267 insertions(+), 10 deletions(-) diff --git a/addon/services/storefront-order-workflow.js b/addon/services/storefront-order-workflow.js index cae4f70b..e012ee5a 100644 --- a/addon/services/storefront-order-workflow.js +++ b/addon/services/storefront-order-workflow.js @@ -19,6 +19,11 @@ export default class StorefrontOrderWorkflowService extends Service { return toBoolean(order?.meta?.is_pickup) === true; } + /** A service booking (at the customer's address, or at the store when it is a pickup). */ + isBookingOrder(order) { + return toBoolean(order?.meta?.is_booking) === true; + } + isDefaultStorefrontConfig(order) { const orderConfig = order?.order_config; @@ -89,6 +94,12 @@ export default class StorefrontOrderWorkflowService extends Service { } if (status === 'accepted') { + // An at-store booking starts when the customer comes in; a home visit is dispatched to the provider. + if (this.isBookingOrder(order) && this.isPickupOrder(order)) { + const flow = order?.order_config?.flow ?? {}; + return [this.descriptorFor('update_activity', order, flow.in_progress ?? { code: 'in_progress', key: 'in_progress', status: 'Service in progress' })]; + } + return [this.descriptorFor('mark_ready', order)]; } @@ -101,6 +112,10 @@ export default class StorefrontOrderWorkflowService extends Service { } if (['completed', 'picked_up'].includes(code)) { + if (this.isBookingOrder(order)) { + return this.descriptorFor('mark_completed', order); + } + return this.descriptorFor(this.isPickupOrder(order) ? 'mark_picked_up' : 'mark_completed', order); } @@ -114,12 +129,16 @@ export default class StorefrontOrderWorkflowService extends Service { case 'accept': return { action, - text: 'Accept order', + text: this.isBookingOrder(order) ? 'Confirm booking' : 'Accept order', icon: 'check', type: 'success', }; case 'mark_ready': + if (this.isBookingOrder(order)) { + return { action, text: 'Dispatch provider', icon: 'paper-plane', type: 'magic' }; + } + return { action, text: 'Mark as Ready', @@ -138,7 +157,7 @@ export default class StorefrontOrderWorkflowService extends Service { case 'mark_completed': return { action, - text: 'Complete order', + text: this.isBookingOrder(order) ? 'Complete booking' : 'Complete order', icon: 'check', type: 'success', }; diff --git a/server/src/Http/Controllers/OrderController.php b/server/src/Http/Controllers/OrderController.php index 09a6d51c..f1627968 100644 --- a/server/src/Http/Controllers/OrderController.php +++ b/server/src/Http/Controllers/OrderController.php @@ -76,6 +76,11 @@ protected function createAcceptedActivity($orderConfig) protected function notifyOrderAccepted(Order $order): void { + // A booking's confirmation is announced as its activity ("Booking confirmed"). + if (Storefront::isBookingOrder($order)) { + return; + } + $order->customer->notify(new StorefrontOrderAccepted($order)); } @@ -172,6 +177,13 @@ public function markOrderAsReady(Request $request) // Patch order config $this->patchOrderConfig($order); + // A booking at the store starts when the customer comes in; nobody is dispatched. + if (Storefront::isBookingOrder($order) && $order->isMeta('is_pickup')) { + $order->updateStatus('in_progress'); + + return $this->orderResponse($order); + } + if ($order->isMeta('is_pickup')) { $order->updateStatus('pickup_ready'); @@ -188,9 +200,12 @@ public function markOrderAsReady(Request $request) $order->assignDriver($driver); } - // Dispatch the order and move Storefront delivery orders into preparation. + // Dispatch the order and move Storefront delivery orders into preparation. A booking + // is dispatched to its provider; there is nothing to prepare. $order->dispatchWithActivity(); - $order->updateStatus('preparing'); + if (!Storefront::isBookingOrder($order)) { + $order->updateStatus('preparing'); + } return $this->orderResponse($order); } diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 5f8eeba3..446b0353 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -23,6 +23,7 @@ use Fleetbase\Storefront\Models\Gateway; use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Product; +use Illuminate\Support\Carbon; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Models\StoreLocation; use Fleetbase\Storefront\Promotions\PromotionContext; @@ -1125,6 +1126,66 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note * * @return void */ + /** + * The booking an order's items make, if any: a cart with a booked service (a bookable + * product with a chosen time) is a booking order. Products in it come with the earliest + * appointment, so the order follows the booking flow and carries that appointment's time. + * + * The time is kept in the order meta (`booking_at`), not `scheduled_at`: Fleet-Ops + * dispatches scheduled orders by themselves on the day, before the store confirms. + */ + protected static function bookingFor(iterable $cartItems): ?array + { + $items = collect($cartItems); + $productIds = $items->map(fn ($item) => data_get($item, 'product_id'))->filter()->unique()->values(); + $bookable = $productIds->isEmpty() ? collect() : Product::whereIn('public_id', $productIds)->where('is_bookable', true)->pluck('public_id'); + + $times = $items + ->filter(fn ($item) => data_get($item, 'scheduled_at') || $bookable->contains(data_get($item, 'product_id'))) + ->map(fn ($item) => data_get($item, 'scheduled_at')) + ->filter() + ->map(function ($at) { + try { + return Carbon::parse($at); + } catch (\Throwable $e) { + return null; + } + }) + ->filter() + ->sort(); + + $hasService = $items->contains(fn ($item) => data_get($item, 'scheduled_at') || $bookable->contains(data_get($item, 'product_id'))); + if (!$hasService) { + return null; + } + + $first = $times->first(); + + return [ + 'is_booking' => true, + 'booking_at' => $first ? $first->toIso8601String() : null, + 'booking_has_items' => $items->contains(fn ($item) => !data_get($item, 'scheduled_at') && !$bookable->contains(data_get($item, 'product_id'))), + ]; + } + + /** + * Mark an order as a booking (meta and the company's booking order config) when its items make one. + */ + protected static function applyBooking(iterable $cartItems, array $orderMeta, array $orderInput): array + { + $booking = static::bookingFor($cartItems); + if (!$booking) { + return [$orderMeta, $orderInput]; + } + + $config = Storefront::getBookingOrderConfig($orderInput['company_uuid'] ?? null); + if ($config) { + $orderInput['order_config_uuid'] = $config->uuid; + } + + return [array_merge($orderMeta, $booking), $orderInput]; + } + private function processCartItem($cartItem, $payload, $customer) { $product = Product::where('public_id', $cartItem->product_id)->first(); @@ -1426,6 +1487,10 @@ public function captureOrder(CaptureOrderRequest $request) 'notes' => $notes, ]; + // A booked service makes this a booking order, with its own flow. + [$bookingMeta, $orderInput] = static::applyBooking($cart->items ?? [], $orderInput['meta'], $orderInput); + $orderInput['meta'] = $bookingMeta; + // if it's integrated vendor order apply to meta if ($integratedVendorOrder) { $orderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; @@ -1785,6 +1850,9 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $orderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); } + // A booked service makes this store's order a booking order, with its own flow. + [$orderMeta, $orderInput] = static::applyBooking($cartItems, $orderMeta, $orderInput); + // set meta to order input last $orderInput['meta'] = $orderMeta; diff --git a/server/src/Http/Resources/Index/Order.php b/server/src/Http/Resources/Index/Order.php index b6bce693..75ef05b9 100644 --- a/server/src/Http/Resources/Index/Order.php +++ b/server/src/Http/Resources/Index/Order.php @@ -46,6 +46,9 @@ private function storefrontOrderMeta(): array 'currency', 'gateway', 'is_pickup', + 'is_booking', + 'booking_at', + 'booking_has_items', 'is_master_order', 'related_orders', 'master_order_id', diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php index 5e785c59..81b4a02a 100644 --- a/server/src/Http/Resources/Order.php +++ b/server/src/Http/Resources/Order.php @@ -55,6 +55,9 @@ private function storefrontOrderMeta(): array 'currency', 'gateway', 'is_pickup', + 'is_booking', + 'booking_at', + 'booking_has_items', 'is_master_order', 'related_orders', 'master_order_id', diff --git a/server/src/Listeners/HandleOrderCompleted.php b/server/src/Listeners/HandleOrderCompleted.php index 43113c3b..9b622f85 100644 --- a/server/src/Listeners/HandleOrderCompleted.php +++ b/server/src/Listeners/HandleOrderCompleted.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Listeners; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\FleetOps\Events\OrderCompleted; use Fleetbase\Storefront\Notifications\StorefrontOrderCompleted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -23,6 +24,11 @@ public function handle(OrderCompleted $event) /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); + // Bookings are told about each step by the order activity observer, in booking terms. + if (Storefront::isBookingOrder($order)) { + return; + } + // if storefront order notify customer driver has been addigned if ($order->hasMeta('storefront_id')) { $order->load(['customer']); diff --git a/server/src/Listeners/HandleOrderDispatched.php b/server/src/Listeners/HandleOrderDispatched.php index b84ec3d5..967c459c 100644 --- a/server/src/Listeners/HandleOrderDispatched.php +++ b/server/src/Listeners/HandleOrderDispatched.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Listeners; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\FleetOps\Events\OrderDispatched; use Fleetbase\Storefront\Notifications\StorefrontOrderReadyForPickup; use Illuminate\Contracts\Queue\ShouldQueue; @@ -23,6 +24,11 @@ public function handle(OrderDispatched $event) /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); + // Bookings are told about each step by the order activity observer, in booking terms. + if (Storefront::isBookingOrder($order)) { + return; + } + // notufy customer order is ready for pickup if ($order->isMeta('is_pickup')) { $order->load(['customer']); diff --git a/server/src/Listeners/HandleOrderStarted.php b/server/src/Listeners/HandleOrderStarted.php index d7000b34..cfd8875d 100644 --- a/server/src/Listeners/HandleOrderStarted.php +++ b/server/src/Listeners/HandleOrderStarted.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Listeners; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\FleetOps\Events\OrderStarted; use Fleetbase\Storefront\Notifications\StorefrontOrderEnroute; @@ -19,6 +20,11 @@ public function handle(OrderStarted $event) /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); + // Bookings are told about each step by the order activity observer, in booking terms. + if (Storefront::isBookingOrder($order)) { + return; + } + // if storefront order / notify customer driver has started and is en-route if ($order->hasMeta('storefront_id')) { $order->load(['customer']); diff --git a/server/src/Notifications/StorefrontOrderDriverAssigned.php b/server/src/Notifications/StorefrontOrderDriverAssigned.php index 9f89deed..469e2ff4 100644 --- a/server/src/Notifications/StorefrontOrderDriverAssigned.php +++ b/server/src/Notifications/StorefrontOrderDriverAssigned.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; +use Fleetbase\Storefront\Support\Storefront; class StorefrontOrderDriverAssigned extends StorefrontOrderNotification { @@ -22,8 +23,13 @@ public function __construct(Order $order) parent::__construct($order); $this->driver = $order->driverAssigned; - $this->subject = 'Your driver is ' . $this->driver->name; - $this->body = 'A driver has been assigned to your order from ' . $this->storefront->name . ', they are currently en-route for pickup'; + if (Storefront::isBookingOrder($order)) { + $this->subject = 'Your provider is ' . $this->driver->name; + $this->body = $this->driver->name . ' will be your provider for your booking with ' . $this->storefront->name . '.'; + } else { + $this->subject = 'Your driver is ' . $this->driver->name; + $this->body = 'A driver has been assigned to your order from ' . $this->storefront->name . ', they are currently en-route for pickup'; + } $this->status = 'order_driver_assigned'; } } diff --git a/server/src/Observers/OrderActivityObserver.php b/server/src/Observers/OrderActivityObserver.php index f7dcc434..b3b80c31 100644 --- a/server/src/Observers/OrderActivityObserver.php +++ b/server/src/Observers/OrderActivityObserver.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\Storefront\Notifications\StorefrontOrderActivity; use Fleetbase\Storefront\Support\OrderActivityFlow; +use Fleetbase\Storefront\Support\Storefront; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Support\Facades\Cache; @@ -25,8 +26,11 @@ public function updated(Order $order): void return; } - $status = (string) $order->status; - if ($status === '' || in_array($status, static::NOTIFIED_ELSEWHERE, true)) { + // Bookings have no dedicated step notifications (those speak of deliveries), so every + // step after the request is announced here. + $status = (string) $order->status; + $skipped = Storefront::isBookingOrder($order) ? ['created', 'pending'] : static::NOTIFIED_ELSEWHERE; + if ($status === '' || in_array($status, $skipped, true)) { return; } diff --git a/server/src/Support/Storefront.php b/server/src/Support/Storefront.php index 98cf68fe..06fe8ca4 100644 --- a/server/src/Support/Storefront.php +++ b/server/src/Support/Storefront.php @@ -33,6 +33,12 @@ class Storefront private const CONFIG_NS = 'system:order-config:storefront'; /** @var array In-memory cache keyed by company UUID */ private static array $configCache = []; + /** @var string */ + public const BOOKING_CONFIG_KEY = 'storefront_booking'; + /** @var string */ + public const BOOKING_CONFIG_NS = 'system:order-config:storefront-booking'; + /** @var array In-memory cache of booking configs keyed by company UUID */ + private static array $bookingConfigCache = []; /** * Returns current store or network based on session `storefront_key` @@ -574,6 +580,108 @@ public static function createStorefrontConfig(Company|string $company): OrderCon ); } + /** + * Whether an order is a service booking: it holds at least one booked service, and any + * products in it come with the (earliest) appointment. + */ + public static function isBookingOrder($order): bool + { + return $order instanceof Order && $order->isMeta('is_booking'); + } + + /** + * Get (or lazily create) the Storefront booking OrderConfig for a company. Bookings have + * their own steps (confirmed, provider on the way, in progress...) instead of delivery's, + * and a config of their own so the delivery config, which may be customised, is untouched. + */ + public static function getBookingOrderConfig(Company|string|null $company): ?OrderConfig + { + $companyUuid = static::resolveCompanyUuid($company); + if (!$companyUuid) { + return null; + } + + if (isset(static::$bookingConfigCache[$companyUuid])) { + return static::$bookingConfigCache[$companyUuid]; + } + + $config = OrderConfig::where(['company_uuid' => $companyUuid, 'key' => self::BOOKING_CONFIG_KEY, 'namespace' => self::BOOKING_CONFIG_NS])->first() + ?? DB::transaction(fn () => static::createBookingConfig($companyUuid)); + + return static::$bookingConfigCache[$companyUuid] = $config; + } + + /** + * Create (or retrieve) the Storefront booking config for a company. + * + * At the customer's address: requested, confirmed, provider assigned, on the way, arrived, + * in progress, completed. At the store (a pickup booking) the customer comes in, so it goes + * from confirmed straight to in progress: only "provider assigned" is conditional, so it is + * the branch taken when the booking is at the customer's address. + */ + public static function createBookingConfig(Company|string $company): OrderConfig + { + $companyUuid = $company instanceof Company ? $company->uuid : $company; + $atCustomer = [['type' => 'not', 'conditions' => [['field' => 'meta.is_pickup', 'operator' => 'equal', 'value' => 'true']]]]; + + $flow = [ + 'created' => static::bookingActivity('created', 'Booking requested', '{storefront.name} will confirm your booking shortly.', ['accepted', 'canceled']), + 'accepted' => static::bookingActivity('accepted', 'Booking confirmed', '{storefront.name} has confirmed your booking.', ['dispatched', 'in_progress', 'canceled']), + 'dispatched' => static::bookingActivity('dispatched', 'Provider assigned', 'Your provider has been assigned and will set off in time for your appointment.', ['started'], $atCustomer), + 'started' => static::bookingActivity('started', 'Provider on the way', 'Your provider is on the way to you.', ['provider_arrived']), + 'provider_arrived' => static::bookingActivity('provider_arrived', 'Provider arrived', 'Your provider has arrived.', ['in_progress']), + 'in_progress' => static::bookingActivity('in_progress', 'Service in progress', 'Your booking with {storefront.name} is under way.', ['completed']), + 'completed' => static::bookingActivity('completed', 'Booking completed', 'Your booking with {storefront.name} is complete.', [], [], ['complete' => true]), + 'canceled' => static::bookingActivity('canceled', 'Booking canceled', 'Your booking was canceled.', [], [], ['events' => ['order.canceled']]), + ]; + + return OrderConfig::firstOrCreate( + [ + 'company_uuid' => $companyUuid, + 'key' => self::BOOKING_CONFIG_KEY, + 'namespace' => self::BOOKING_CONFIG_NS, + ], + [ + 'name' => 'Storefront Booking', + 'key' => self::BOOKING_CONFIG_KEY, + 'namespace' => self::BOOKING_CONFIG_NS, + 'description' => 'Storefront order configuration for booked services, at the customer\'s address or at the store', + 'core_service' => 1, + 'status' => 'private', + 'version' => '0.0.1', + 'tags' => ['storefront', 'booking', 'services'], + 'entities' => [], + 'meta' => [], + 'flow' => $flow, + ] + ); + } + + /** + * One activity of the booking flow, in the same shape as the delivery flow's. + */ + protected static function bookingActivity(string $code, string $status, string $details, array $activities, array $logic = [], array $overrides = []): array + { + return array_merge([ + 'key' => $code, + 'code' => $code, + 'color' => '#1f2937', + 'logic' => $logic, + 'events' => [], + 'status' => $status, + 'actions' => [], + 'details' => $details, + 'options' => [], + 'complete' => false, + 'entities' => [], + 'sequence' => 0, + 'activities' => $activities, + 'internalId' => (string) Str::uuid(), + 'pod_method' => 'scan', + 'require_pod' => false, + ], $overrides); + } + /** * Normalize a Company|UUID|null to a UUID string (or null if unresolved). */ @@ -592,6 +700,12 @@ protected static function resolveCompanyUuid(Company|string|null $company): ?str public static function createAcceptedActivity(?OrderConfig $orderConfig = null): Activity { + // A config with its own acceptance step (e.g. bookings: "Booking confirmed") uses it. + $flow = $orderConfig ? $orderConfig->flow : null; + if (is_array($flow) && is_array($flow['accepted'] ?? null)) { + return new Activity($flow['accepted'], $orderConfig->activities()->toArray()); + } + return new Activity([ 'key' => 'accepted', 'code' => 'accepted', @@ -633,9 +747,11 @@ public static function autoAcceptOrder(Order $order) return response()->error('Unable to accept order.'); } - // Notify customer order was accepted + // Notify customer order was accepted (a booking's confirmation is sent as its activity) try { - $order->customer->notify(new StorefrontOrderAccepted($order)); + if (!static::isBookingOrder($order)) { + $order->customer->notify(new StorefrontOrderAccepted($order)); + } } catch (\Throwable $e) { Log::error('[Storefront] was unable to notify the customer that their order was accepted.', ['order' => $order->public_id, 'error' => $e->getMessage()]); } @@ -648,6 +764,11 @@ public static function autoDispatchOrder(Order $order, bool $adhoc = true) // Patch order config Storefront::patchOrderConfig($order); + // An at-store booking has nobody to dispatch: it starts when the customer comes in. + if (static::isBookingOrder($order) && $order->isMeta('is_pickup')) { + return $order; + } + if ($order->isMeta('is_pickup')) { $order->updateStatus('pickup_ready'); From b9165c86309de4f171e5bbb7865d96666e29bbe6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 15:39:20 +0800 Subject: [PATCH 27/50] fix(orders): console can move orders on a custom flow to their next activity --- server/src/routes.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/routes.php b/server/src/routes.php index 92d8f71a..779a3765 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -223,6 +223,8 @@ function ($router, $controller) { $router->post('completed', $controller('markOrderAsCompleted')); $router->patch('cancel', $controller('rejectOrder')); $router->post('unassign-driver', $controller('unassignDriver')); + // Moves an order on a custom flow (e.g. a booking) to its next activity. + $router->patch('update-activity/{id}', $controller('updateActivity')); } ); $router->fleetbaseRoutes( From 6c5e71c6d3f19b48f7faf468abdf23820c0ec3a3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 16:44:57 +0800 Subject: [PATCH 28/50] fix(network): multi-store delivery quotes measure the real route; network review setting is enforced --- .../Http/Controllers/v1/ReviewController.php | 34 ++++++++++++++++++ .../Controllers/v1/ServiceQuoteController.php | 35 +++++++++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index e33b7ebd..dbce143a 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\Network; use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; @@ -71,6 +72,23 @@ protected function subjectBelongsToContext($subject): bool return false; } + /** + * Whether reviews are switched off for this storefront. A network's setting covers all of + * its stores and reviews are only on when it is switched on; a single store's app keeps + * reviews unless the store switches them off. + */ + protected static function reviewsDisabled(): bool + { + $about = Storefront::about(); + if (!$about) { + return false; + } + + $enabled = data_get($about->options, 'reviews_enabled'); + + return $about instanceof Network ? $enabled !== true : $enabled === false; + } + /** * Query for Storefront Review resources. * @@ -78,6 +96,10 @@ protected function subjectBelongsToContext($subject): bool */ public function query(Request $request) { + if (static::reviewsDisabled()) { + return StorefrontReview::collection([]); + } + $results = []; $limit = $request->input('limit', false); $offset = $request->input('offset', false); @@ -173,6 +195,10 @@ public function count(Request $request) $counts = []; $range = range(1, 5); + if (static::reviewsDisabled()) { + return response()->json(array_fill_keys($range, 0)); + } + if (session('storefront_store')) { foreach ($range as $rating) { $counts[$rating] = Review::where(['subject_uuid' => session('storefront_store'), 'rating' => $rating])->count(); @@ -237,6 +263,10 @@ public function eligibility(Request $request) return response()->error('Invalid subject for review'); } + if (static::reviewsDisabled()) { + return response()->json(['can_review' => false, 'reason' => 'reviews_disabled', 'message' => 'Reviews are turned off.', 'order' => null, 'review' => null]); + } + $eligibility = ReviewEligibility::check(Storefront::getCustomerFromToken(), $subject, $request->input('order')); return response()->json($eligibility->toArray()); @@ -258,6 +288,10 @@ public function create(CreateReviewRequest $request) return response()->error('Not authorized to create reviews'); } + if (static::reviewsDisabled()) { + return response()->json(['error' => 'Reviews are turned off.', 'reason' => 'reviews_disabled'], 403); + } + $subject = Utils::resolveSubject($request->input('subject')); if (!$subject || !$this->subjectBelongsToContext($subject)) { diff --git a/server/src/Http/Controllers/v1/ServiceQuoteController.php b/server/src/Http/Controllers/v1/ServiceQuoteController.php index cee388a0..4e791897 100644 --- a/server/src/Http/Controllers/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/v1/ServiceQuoteController.php @@ -10,6 +10,7 @@ use Fleetbase\FleetOps\Models\ServiceQuoteItem; use Fleetbase\FleetOps\Models\ServiceRate; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\DistanceMatrix; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Http\Requests\GetServiceQuoteFromCart; @@ -423,11 +424,41 @@ protected function getDrivingMatrix(Place $origin, Place $destination): object } /** - * Resolve the multi-origin distance matrix used to quote a network order. + * Resolve the distance and time of a network order's route: the driver collects from each + * store, then delivers. Each leg is measured like a single store's (`getDrivingMatrix`) and + * the legs are added up. + * + * The stores are visited farthest from the customer first, then each nearest next, so the + * route ends close to the customer. (`Utils::distanceMatrix` cannot be used here: it joins + * the stores into one origin string, which the straight-line provider misreads and Google + * answers for the first store only.) */ protected function getNetworkDistanceMatrix($origins, Place $destination): object { - return Utils::distanceMatrix($origins, [$destination]); + $remaining = collect($origins)->filter()->values(); + if ($remaining->isEmpty()) { + return new DistanceMatrix(0, 0); + } + + $straightLine = fn ($from, $to) => (float) Utils::getPreliminaryDistanceMatrix($from, $to)->distance; + + $current = $remaining->sortByDesc(fn ($place) => $straightLine($place, $destination))->first(); + $remaining = $remaining->reject(fn ($place) => $place === $current)->values(); + $distance = 0; + $time = 0; + + while ($remaining->isNotEmpty()) { + $next = $remaining->sortBy(fn ($place) => $straightLine($current, $place))->first(); + $leg = $this->getDrivingMatrix($current, $next); + $distance += (float) $leg->distance; + $time += (float) $leg->time; + $current = $next; + $remaining = $remaining->reject(fn ($place) => $place === $next)->values(); + } + + $leg = $this->getDrivingMatrix($current, $destination); + + return new DistanceMatrix($distance + (float) $leg->distance, $time + (float) $leg->time); } /** From c8af4136d48d229f80333f240c56e003645620f3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 17:23:53 +0800 Subject: [PATCH 29/50] feat(network): networks can split store tips across the stores of a multi-store order; otherwise the tip goes to the network --- .../networks/index/network/index.hbs | 10 ++++ .../Controllers/v1/CheckoutController.php | 51 ++++++++++++++++++- translations/ar-ae.yaml | 1 + translations/bg-bg.yaml | 1 + translations/en-us.yaml | 1 + translations/es-es.yaml | 1 + translations/fr-fr.yaml | 1 + translations/mn-mn.yaml | 1 + translations/pt-br.yaml | 1 + translations/ru-ru.yaml | 1 + translations/uk-ua.yaml | 1 + translations/vi-vn.yaml | 1 + translations/zh-cn.yaml | 1 + 13 files changed, 71 insertions(+), 1 deletion(-) diff --git a/addon/templates/networks/index/network/index.hbs b/addon/templates/networks/index/network/index.hbs index 925f9791..4666f10b 100644 --- a/addon/templates/networks/index/network/index.hbs +++ b/addon/templates/networks/index/network/index.hbs @@ -268,6 +268,16 @@ }} + {{#if (and @model.options.tips_enabled @model.options.multi_cart_enabled)}} + {{! Off: a multi-store order's store tip goes to the network. On: it is split across the stores by their share of the order. }} +
+ + {{t + "storefront.networks.index.network.index.general-network-settings-form.config-switches.split-tips-across-stores" + }} + +
+ {{/if}}
{{t diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 446b0353..e2fc5f43 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -1778,6 +1778,20 @@ public function captureMultipleOrders(CaptureOrderRequest $request) }); $destination = Place::createFromMixed($destination); + // The store tip goes to the network that runs the app, unless the network splits it + // across the stores, each by its share of the order. + $splitTips = $checkout->hasOption('tip') && $about->isOption('split_tips_across_stores'); + $tipShares = []; + if ($splitTips) { + $storeSubtotals = $origins + ->map(fn ($pickup) => Storefront::getStoreFromLocation($pickup->uuid)) + ->filter() + ->unique('public_id') + ->mapWithKeys(fn ($store) => [$store->public_id => (int) $cart->getSubtotalForStore($store)]) + ->all(); + $tipShares = static::splitByShare((int) static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), $storeSubtotals); + } + $multipleOrders = []; foreach ($origins as $pickup) { @@ -1815,7 +1829,8 @@ public function captureMultipleOrders(CaptureOrderRequest $request) 'checkout_id' => $checkout->public_id, 'subtotal' => $subtotal, 'delivery_fee' => 0, - 'tip' => 0, + 'tip' => $tipShares[$store->public_id] ?? 0, + 'tip_recipient' => $splitTips ? 'store' : 'network', 'delivery_tip' => 0, 'discount' => $storeDiscount, 'total' => $subtotal - $storeDiscount, @@ -1907,6 +1922,7 @@ public function captureMultipleOrders(CaptureOrderRequest $request) 'subtotal' => Utils::numbersOnly($cart->subtotal), 'delivery_fee' => $checkout->is_pickup ? 0 : Utils::numbersOnly($serviceQuote->amount), 'tip' => $checkout->getOption('tip'), + 'tip_recipient' => $splitTips ? 'stores' : 'network', 'delivery_tip' => $checkout->getOption('delivery_tip'), 'discount' => $promotions->discount(), 'promotions' => $promotions->toPublicArray()['applied'], @@ -2310,6 +2326,39 @@ protected static function reservePromotions(Checkout $checkout, $checkoutOptions return null; } + /** + * Split an amount across stores in proportion to their subtotals. Shares are whole cents + * that add up to the amount; the cents left over by rounding go to the largest shares. + * + * @param array $subtotals store id => subtotal + * + * @return array store id => share + */ + protected static function splitByShare(int $amount, array $subtotals): array + { + $total = array_sum($subtotals); + if ($amount <= 0 || $total <= 0) { + return array_map(fn () => 0, $subtotals); + } + + $shares = []; + foreach ($subtotals as $storeId => $subtotal) { + $shares[$storeId] = intdiv($amount * $subtotal, $total); + } + + $left = $amount - array_sum($shares); + arsort($subtotals); + foreach (array_keys($subtotals) as $storeId) { + if ($left <= 0) { + break; + } + $shares[$storeId]++; + $left--; + } + + return $shares; + } + private static function calculateTipAmount($tip, $subtotal) { $tipAmount = 0; diff --git a/translations/ar-ae.yaml b/translations/ar-ae.yaml index b19c5612..fb433bfc 100644 --- a/translations/ar-ae.yaml +++ b/translations/ar-ae.yaml @@ -404,6 +404,7 @@ storefront: enable-tips: تفعيل الإكراميات enable-delivery-tips: تفعيل إكراميات التوصيل enable-multi-cart-checkout: تفعيل الدفع لعربات متعددة + split-tips-across-stores: "تقسيم الإكراميات بين المتاجر في الطلبات متعددة المتاجر" enable-user-reviews: تفعيل تقييمات المستخدمين api-panel: panel-title: واجهة برمجة التطبيقات diff --git a/translations/bg-bg.yaml b/translations/bg-bg.yaml index eebf92e4..ec596968 100644 --- a/translations/bg-bg.yaml +++ b/translations/bg-bg.yaml @@ -422,6 +422,7 @@ storefront: enable-tips: Активиране на бакшиши enable-delivery-tips: Активиране на бакшиши за доставка enable-multi-cart-checkout: Активиране на плащане с няколко колички + split-tips-across-stores: "Разпределяне на бакшишите между магазините при поръчки от няколко магазина" enable-user-reviews: Активиране на потребителски отзиви api-panel: panel-title: API diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 4995aeb3..6ed1d6bd 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -394,6 +394,7 @@ storefront: enable-tips: Enable tips enable-delivery-tips: Enable delivery tips enable-multi-cart-checkout: Enable multi-cart checkout + split-tips-across-stores: "Split store tips across stores in multi-store orders" enable-user-reviews: Enable user reviews api-panel: panel-title: API diff --git a/translations/es-es.yaml b/translations/es-es.yaml index 62bb3713..f120758d 100644 --- a/translations/es-es.yaml +++ b/translations/es-es.yaml @@ -420,6 +420,7 @@ storefront: enable-tips: Habilitar propinas enable-delivery-tips: Habilitar propinas para entrega enable-multi-cart-checkout: Habilitar pago de múltiples carritos + split-tips-across-stores: "Repartir las propinas entre las tiendas en pedidos de varias tiendas" enable-user-reviews: Habilitar reseñas de usuarios api-panel: panel-title: API diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 75d2880f..3fa3a392 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -433,6 +433,7 @@ storefront: enable-tips: Activer les pourboires enable-delivery-tips: Activer les pourboires de livraison enable-multi-cart-checkout: Activer le paiement multi-panier + split-tips-across-stores: "Répartir les pourboires entre les boutiques dans les commandes multi-boutiques" enable-user-reviews: Activer les avis utilisateurs api-panel: panel-title: API diff --git a/translations/mn-mn.yaml b/translations/mn-mn.yaml index 38aa4ffb..d7cdff31 100644 --- a/translations/mn-mn.yaml +++ b/translations/mn-mn.yaml @@ -414,6 +414,7 @@ storefront: enable-tips: Зөвлөмжийг идэвхжүүлэх enable-delivery-tips: Хүргэлтийн зөвлөмжийг идэвхжүүлэх enable-multi-cart-checkout: Олон сагснаас төлбөр хийхийг идэвхжүүлэх + split-tips-across-stores: "Олон дэлгүүрийн захиалгад цайны мөнгийг дэлгүүрүүдэд хуваах" enable-user-reviews: Хэрэглэгчийн үнэлгээг идэвхжүүлэх api-panel: panel-title: API diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index 84bd755e..e4d2dc60 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -421,6 +421,7 @@ storefront: enable-tips: Ativar gorjetas enable-delivery-tips: Ativar gorjetas para entrega enable-multi-cart-checkout: Ativar checkout para múltiplos carrinhos + split-tips-across-stores: "Dividir as gorjetas entre as lojas em pedidos de várias lojas" enable-user-reviews: Ativar avaliações de usuários api-panel: panel-title: API diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index df34f2ae..20a565f1 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -417,6 +417,7 @@ storefront: enable-tips: Включить чаевые enable-delivery-tips: Включить чаевые за доставку enable-multi-cart-checkout: Включить оформление заказа из нескольких + split-tips-across-stores: "Распределять чаевые между магазинами в заказах из нескольких магазинов" корзин enable-user-reviews: Включить отзывы пользователей api-panel: diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index 38fe4995..848cd75d 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -386,6 +386,7 @@ storefront: enable-tips: Увімкнути чайові enable-delivery-tips: Увімкнути чайові кур'єру enable-multi-cart-checkout: Увімкнути оформлення з кількох кошиків + split-tips-across-stores: "Розподіляти чайові між магазинами в замовленнях із кількох магазинів" enable-user-reviews: Увімкнути відгуки користувачів api-panel: panel-title: API diff --git a/translations/vi-vn.yaml b/translations/vi-vn.yaml index e4d5d1a0..effb1087 100644 --- a/translations/vi-vn.yaml +++ b/translations/vi-vn.yaml @@ -417,6 +417,7 @@ storefront: enable-tips: Bật tiền boa enable-delivery-tips: Bật tiền boa cho giao hàng enable-multi-cart-checkout: Bật thanh toán nhiều giỏ hàng + split-tips-across-stores: "Chia tiền tip cho các cửa hàng trong đơn hàng nhiều cửa hàng" enable-user-reviews: Bật đánh giá người dùng api-panel: panel-title: API diff --git a/translations/zh-cn.yaml b/translations/zh-cn.yaml index 28fcab63..ac31946e 100644 --- a/translations/zh-cn.yaml +++ b/translations/zh-cn.yaml @@ -382,6 +382,7 @@ storefront: enable-tips: 启用小费 enable-delivery-tips: 启用配送小费 enable-multi-cart-checkout: 启用多购物车结账 + split-tips-across-stores: "在多店铺订单中将小费分配给各店铺" enable-user-reviews: 启用用户评价 api-panel: panel-title: API From 02f9ff8618507a7e4dc428e1c1e423526d9c8b98 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 17:34:18 +0800 Subject: [PATCH 30/50] fix(i18n): keep the Russian multi-cart label on its own lines so the translations build again c8af413 inserted the split-tips label between the two lines of the enable-multi-cart-checkout value, which js-yaml reads as a bad mapping entry and the engine build fails. --- translations/ru-ru.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index 20a565f1..d815e0f5 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -417,8 +417,8 @@ storefront: enable-tips: Включить чаевые enable-delivery-tips: Включить чаевые за доставку enable-multi-cart-checkout: Включить оформление заказа из нескольких - split-tips-across-stores: "Распределять чаевые между магазинами в заказах из нескольких магазинов" корзин + split-tips-across-stores: "Распределять чаевые между магазинами в заказах из нескольких магазинов" enable-user-reviews: Включить отзывы пользователей api-panel: panel-title: API From c833b5c8ad29481058d7d5418b3b22d40f3c8d63 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 17:46:29 +0800 Subject: [PATCH 31/50] feat(identity): drivers, customers, vehicles and places render through the shared identity cells, pills and select options The orders, network orders, customers and network customers tables use table/cell/identity typed for the shared fleetops-data descriptors, reading the loaded relation or an identity stub named after the row, so a row shows the right tile and name before the relation loads. Clicking a customer opens the storefront customer page; a driver or place opens its FleetOps panel, loading the engine only then. The order details, fulfillment and customer panels, the incoming-order modal, the orders and customers widgets and the food-truck cards use Customer::Pill, Driver::Pill and Vehicle::Pill in place of hand-rolled avatar blocks, whose styles are removed. Driver, vehicle, customer, user and category selects render SelectOption rows with compact selected items. The fleetops-data range moves to ^0.2.3, the version the workspace links. --- addon/components/modals/assign-driver.hbs | 3 +- addon/components/modals/create-food-truck.hbs | 11 +-- .../modals/create-network-category.hbs | 2 +- addon/components/modals/import-products.hbs | 2 +- addon/components/modals/incoming-order.hbs | 24 ++----- .../modals/order-ready-assign-driver.hbs | 3 +- .../storefront/order/details/customer.hbs | 16 ++--- .../storefront/order/details/detail.hbs | 18 +---- .../storefront/order/details/fulfillment.hbs | 13 +--- addon/components/widget/customers.hbs | 11 +-- addon/components/widget/orders.hbs | 20 +++--- addon/controllers/customers/index.js | 3 +- .../networks/index/network/customers.js | 3 +- .../networks/index/network/orders.js | 38 ++++++++-- addon/controllers/orders/index.js | 38 ++++++++-- addon/styles/storefront-engine.css | 41 ----------- addon/templates/food-trucks/index.hbs | 7 +- .../networks/index/network/index.hbs | 3 +- .../promotions/push-notifications.hbs | 19 +---- addon/templates/settings/index.hbs | 3 +- package.json | 2 +- .../components/shared-identity-test.js | 71 +++++++++++++++++++ .../storefront/order/details-test.js | 26 +++++++ .../components/widget/customers-test.js | 2 +- .../unit/controllers/customers/index-test.js | 9 +++ .../networks/index/network/customers-test.js | 10 ++- .../networks/index/network/orders-test.js | 21 +++++- tests/unit/controllers/orders/index-test.js | 66 +++++++++++++++++ 28 files changed, 312 insertions(+), 173 deletions(-) create mode 100644 tests/integration/components/shared-identity-test.js diff --git a/addon/components/modals/assign-driver.hbs b/addon/components/modals/assign-driver.hbs index 02f6de6a..88b0947d 100644 --- a/addon/components/modals/assign-driver.hbs +++ b/addon/components/modals/assign-driver.hbs @@ -16,6 +16,7 @@ - {{model.name}} +
diff --git a/addon/components/modals/create-food-truck.hbs b/addon/components/modals/create-food-truck.hbs index 41b9a9e9..3332b6b6 100644 --- a/addon/components/modals/create-food-truck.hbs +++ b/addon/components/modals/create-food-truck.hbs @@ -3,6 +3,7 @@ -
-
- -
-
-
{{model.display_name}}
-
{{model.plate_number}}
-
-
+
diff --git a/addon/components/modals/create-network-category.hbs b/addon/components/modals/create-network-category.hbs index 0a489992..832f5419 100644 --- a/addon/components/modals/create-network-category.hbs +++ b/addon/components/modals/create-network-category.hbs @@ -12,7 +12,7 @@ @onChange={{@options.setParentCategory}} as |model| > - {{model.name}} + - {{model.name}} + diff --git a/addon/components/modals/incoming-order.hbs b/addon/components/modals/incoming-order.hbs index 9accc241..fba4a8f3 100644 --- a/addon/components/modals/incoming-order.hbs +++ b/addon/components/modals/incoming-order.hbs @@ -34,16 +34,7 @@
{{t "storefront.component.modals.incoming-order.assigned"}}
{{#if @options.order.driver_assigned.id}} -
- {{@options.order.driver_assigned.name}} -
-
{{n-a @options.order.driver_assigned.displayName}}
-
{{n-a - @options.order.driver_assigned.phone - (t "storefront.component.modals.incoming-order.no-phone") - }}
-
-
+ {{else}}
{{t "storefront.component.modals.incoming-order.not-assigned"}}
@@ -72,16 +63,9 @@
{{t "storefront.common.customer"}}
-
-
- {{@options.order.customer.name}} -
-
-
{{@options.order.customer.name}}
-
{{@options.order.customer.email}}
-
{{@options.order.customer.phone}}
-
-
+ +
{{n-a @options.order.customer.email}}
+
{{n-a @options.order.customer.phone}}
{{#unless @options.order.meta.is_pickup}}
{{t "storefront.component.modals.incoming-order.address"}}
diff --git a/addon/components/modals/order-ready-assign-driver.hbs b/addon/components/modals/order-ready-assign-driver.hbs index 83e23bc6..9e313645 100644 --- a/addon/components/modals/order-ready-assign-driver.hbs +++ b/addon/components/modals/order-ready-assign-driver.hbs @@ -29,6 +29,7 @@ - {{model.name}} + {{/unless}} diff --git a/addon/components/storefront/order/details/customer.hbs b/addon/components/storefront/order/details/customer.hbs index d0eb04a3..59957374 100644 --- a/addon/components/storefront/order/details/customer.hbs +++ b/addon/components/storefront/order/details/customer.hbs @@ -1,17 +1,9 @@
-
- {{@resource.customer.name}} -
-
{{n-a @resource.customer.name "No customer"}}
-
{{n-a @resource.customer.email "No email"}}
-
{{n-a @resource.customer.phone "No phone"}}
-
+ +
+
Email{{n-a @resource.customer.email "No email"}}
+
Phone{{n-a @resource.customer.phone "No phone"}}
{{#unless @resource.meta.is_pickup}} diff --git a/addon/components/storefront/order/details/detail.hbs b/addon/components/storefront/order/details/detail.hbs index ca92fadc..9ffc644b 100644 --- a/addon/components/storefront/order/details/detail.hbs +++ b/addon/components/storefront/order/details/detail.hbs @@ -3,27 +3,13 @@
Customer
-
- {{@resource.customer.name}} -
-
{{n-a @resource.customer.name "No customer"}}
-
{{n-a (or @resource.customer.phone @resource.customer.email) "No contact details"}}
-
-
+
Driver
-
- {{@resource.driver_assigned.name}} -
-
- {{n-a (or @resource.driver_assigned.displayName @resource.driver_assigned.name) "No driver assigned"}} -
-
{{n-a @resource.driver_assigned.phone "No phone"}}
-
-
+
diff --git a/addon/components/storefront/order/details/fulfillment.hbs b/addon/components/storefront/order/details/fulfillment.hbs index 508ca07d..36e5b262 100644 --- a/addon/components/storefront/order/details/fulfillment.hbs +++ b/addon/components/storefront/order/details/fulfillment.hbs @@ -3,18 +3,7 @@
Driver
-
- {{@resource.driver_assigned.name}} -
-
{{n-a (or @resource.driver_assigned.displayName @resource.driver_assigned.name) "No driver assigned"}}
-
{{n-a @resource.driver_assigned.phone "No phone"}}
-
-
+
Schedule
diff --git a/addon/components/widget/customers.hbs b/addon/components/widget/customers.hbs index f9268eb5..afb75753 100644 --- a/addon/components/widget/customers.hbs +++ b/addon/components/widget/customers.hbs @@ -15,10 +15,8 @@ {{#each this.customers as |customer|}}
- {{customer.name}}
- {{n-a customer.name}} -
{{customer.public_id}}
+
{{n-a customer.orders 0}}
@@ -32,7 +30,7 @@ {{/each}}
{{else}} -
+
@@ -48,10 +46,7 @@ diff --git a/addon/components/widget/orders.hbs b/addon/components/widget/orders.hbs index 3ea48856..d256ad12 100644 --- a/addon/components/widget/orders.hbs +++ b/addon/components/widget/orders.hbs @@ -26,14 +26,12 @@
- {{n-a order.customer.name}} - - {{#if order.meta.is_pickup}} - {{t "storefront.component.widget.orders.pickup-order"}} - {{else}} - {{n-a order.driver_assigned.name}} - {{/if}} - + + {{#if order.meta.is_pickup}} + {{t "storefront.component.widget.orders.pickup-order"}} + {{else}} + + {{/if}}
@@ -46,7 +44,7 @@ {{/each}}
{{else}} -
+
{{customer.public_id}} -
- {{customer.name}} - {{n-a customer.name}} -
+
{{n-a customer.phone}} {{n-a customer.email}}
@@ -68,14 +66,14 @@ - + diff --git a/addon/controllers/customers/index.js b/addon/controllers/customers/index.js index 6242a732..72ffa14e 100644 --- a/addon/controllers/customers/index.js +++ b/addon/controllers/customers/index.js @@ -123,7 +123,8 @@ export default class CustomersIndexController extends BaseController { label: this.intl.t('storefront.common.name'), valuePath: 'name', width: '15%', - cellComponent: 'table/cell/media-name', + cellComponent: 'table/cell/identity', + resourceType: 'customer', action: this.viewCustomer, resizable: true, sortable: true, diff --git a/addon/controllers/networks/index/network/customers.js b/addon/controllers/networks/index/network/customers.js index 26776546..6d8f8dfc 100644 --- a/addon/controllers/networks/index/network/customers.js +++ b/addon/controllers/networks/index/network/customers.js @@ -15,7 +15,8 @@ export default class NetworksIndexNetworkCustomersController extends CustomersIn label: this.intl.t('storefront.common.name'), valuePath: 'name', width: '25%', - cellComponent: 'table/cell/media-name', + cellComponent: 'table/cell/identity', + resourceType: 'customer', action: this.viewCustomer, resizable: true, sortable: true, diff --git a/addon/controllers/networks/index/network/orders.js b/addon/controllers/networks/index/network/orders.js index eccfe065..2411a5d0 100644 --- a/addon/controllers/networks/index/network/orders.js +++ b/addon/controllers/networks/index/network/orders.js @@ -1,4 +1,6 @@ import BaseController from '@fleetbase/storefront-engine/controllers/base-controller'; +import { relationValue } from '@fleetbase/ember-ui/utils/resource-registry'; +import { buildIdentityStub } from '@fleetbase/fleetops-data/utils/identity-stub'; import { inject as service } from '@ember/service'; import { tracked, cached } from '@glimmer/tracking'; import { isBlank } from '@ember/utils'; @@ -11,6 +13,7 @@ export default class NetworksIndexNetworkOrdersController extends BaseController * * @var {Service} */ + @service store; @service notifications; /** @@ -101,7 +104,11 @@ export default class NetworksIndexNetworkOrdersController extends BaseController id: 'customer-name', label: this.intl.t('storefront.orders.index.customer'), valuePath: 'customer.name', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'customer', + resourcePath: (order) => relationValue(order, 'customer') ?? buildIdentityStub(order, { type: order.customer_type ?? 'customer', nameKey: 'customer_name' }), + action: this.viewCustomer, + emptyText: 'No customer', width: '125px', resizable: true, sortable: true, @@ -116,7 +123,9 @@ export default class NetworksIndexNetworkOrdersController extends BaseController id: 'pickup-name', label: this.intl.t('storefront.common.pickup'), valuePath: 'pickupName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'pickup') ?? buildIdentityStub(order, { type: 'place', nameKey: 'pickupName' }), width: '160px', resizable: true, sortable: true, @@ -130,7 +139,9 @@ export default class NetworksIndexNetworkOrdersController extends BaseController id: 'dropoff-name', label: this.intl.t('storefront.common.dropoff'), valuePath: 'dropoffName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'dropoff') ?? buildIdentityStub(order, { type: 'place', nameKey: 'dropoffName' }), width: '160px', resizable: true, sortable: true, @@ -185,9 +196,13 @@ export default class NetworksIndexNetworkOrdersController extends BaseController { id: 'driver-assigned', label: this.intl.t('storefront.orders.index.driver-assigned'), - cellComponent: 'table/cell/driver-name', + cellComponent: 'table/cell/identity', + resourceType: 'driver', valuePath: 'driver_assigned', - modelPath: 'driver_assigned', + resourcePath: (order) => + relationValue(order, 'driver_assigned') ?? + buildIdentityStub(order, { type: 'driver', load: () => (order.driver_assigned_uuid ? this.store.findRecord('driver', order.driver_assigned_uuid) : null) }), + emptyText: 'No driver assigned', width: '170px', resizable: true, sortable: true, @@ -339,6 +354,19 @@ export default class NetworksIndexNetworkOrdersController extends BaseController this.query = value; } + /** + * Opens the storefront customer behind an order. A stub that only carries + * the customer's name has no page to open. + * @param {Object} customer + */ + @action viewCustomer(customer) { + if (!customer?.public_id) { + return; + } + + return this.transitionToRoute('customers.index.view', customer.public_id); + } + @action viewOrder(order) { return this.storefrontOrderActions.viewOrder(order); } diff --git a/addon/controllers/orders/index.js b/addon/controllers/orders/index.js index e5575279..1e77eb19 100644 --- a/addon/controllers/orders/index.js +++ b/addon/controllers/orders/index.js @@ -1,4 +1,6 @@ import BaseController from '@fleetbase/storefront-engine/controllers/base-controller'; +import { relationValue } from '@fleetbase/ember-ui/utils/resource-registry'; +import { buildIdentityStub } from '@fleetbase/fleetops-data/utils/identity-stub'; import { tracked } from '@glimmer/tracking'; import { action, get } from '@ember/object'; import { inject as service } from '@ember/service'; @@ -7,6 +9,7 @@ import { isArray } from '@ember/array'; import { timeout, task } from 'ember-concurrency'; export default class OrdersIndexController extends BaseController { + @service store; @service notifications; @service intl; @service modalsManager; @@ -131,7 +134,11 @@ export default class OrdersIndexController extends BaseController { id: 'customer-name', label: this.intl.t('storefront.orders.index.customer'), valuePath: 'customer.name', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'customer', + resourcePath: (order) => relationValue(order, 'customer') ?? buildIdentityStub(order, { type: order.customer_type ?? 'customer', nameKey: 'customer_name' }), + action: this.viewCustomer, + emptyText: 'No customer', width: '100px', resizable: true, sortable: true, @@ -157,7 +164,9 @@ export default class OrdersIndexController extends BaseController { id: 'pickup-name', label: this.intl.t('storefront.common.pickup'), valuePath: 'pickupName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'pickup') ?? buildIdentityStub(order, { type: 'place', nameKey: 'pickupName' }), width: '150px', resizable: true, sortable: true, @@ -171,7 +180,9 @@ export default class OrdersIndexController extends BaseController { id: 'dropoff-name', label: this.intl.t('storefront.common.dropoff'), valuePath: 'dropoffName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'dropoff') ?? buildIdentityStub(order, { type: 'place', nameKey: 'dropoffName' }), width: '150px', resizable: true, sortable: true, @@ -184,9 +195,13 @@ export default class OrdersIndexController extends BaseController { { id: 'driver-assigned', label: this.intl.t('storefront.orders.index.driver-assigned'), - cellComponent: 'table/cell/driver-name', + cellComponent: 'table/cell/identity', + resourceType: 'driver', valuePath: 'driver_assigned', - modelPath: 'driver_assigned', + resourcePath: (order) => + relationValue(order, 'driver_assigned') ?? + buildIdentityStub(order, { type: 'driver', load: () => (order.driver_assigned_uuid ? this.store.findRecord('driver', order.driver_assigned_uuid) : null) }), + emptyText: 'No driver assigned', width: '150px', resizable: true, sortable: true, @@ -388,6 +403,19 @@ export default class OrdersIndexController extends BaseController { this.query = value; } + /** + * Opens the storefront customer behind an order. A stub that only carries + * the customer's name has no page to open. + * @param {Object} customer + */ + @action viewCustomer(customer) { + if (!customer?.public_id) { + return; + } + + return this.transitionToRoute('customers.index.view', customer.public_id); + } + @action viewOrder(order) { return this.transitionToRoute('orders.index.view', order); } diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 1b4350cb..9034af9d 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -1912,22 +1912,6 @@ body[data-theme='dark'] .storefront-widget-count { background-color: #1d4ed8; } -.storefront-customer-cell { - display: flex; - min-width: 0; - align-items: center; - gap: 0.65rem; -} - -.storefront-customer-avatar { - width: 2rem; - height: 2rem; - flex: 0 0 auto; - border-radius: 0.35rem; - object-fit: cover; - box-shadow: 0 1px 2px rgb(15 23 42 / 16%); -} - .storefront-customer-name { display: block; overflow: hidden; @@ -2244,24 +2228,12 @@ body[data-theme='dark'] .storefront-order-totals strong { gap: 0.75rem; } -.storefront-order-person--compact { - align-items: flex-start; -} - .storefront-order-store { display: flex; align-items: flex-start; gap: 0.9rem; } -.storefront-order-person__avatar { - width: 2.75rem; - height: 2.75rem; - flex: 0 0 auto; - border-radius: 0.5rem; - object-fit: cover; -} - .storefront-order-person__name { overflow: hidden; color: #111827; @@ -2275,19 +2247,6 @@ body[data-theme='dark'] .storefront-order-person__name { color: #f8fafc; } -.storefront-order-person__meta { - overflow: hidden; - color: #64748b; - font-size: 0.75rem; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -body[data-theme='dark'] .storefront-order-person__meta { - color: #94a3b8; -} - .storefront-order-facts, .storefront-order-totals { display: flex; diff --git a/addon/templates/food-trucks/index.hbs b/addon/templates/food-trucks/index.hbs index b707b1dd..61cf0250 100644 --- a/addon/templates/food-trucks/index.hbs +++ b/addon/templates/food-trucks/index.hbs @@ -9,11 +9,8 @@ {{#each @model as |foodTruck|}}
-
- -
-
-
{{foodTruck.vehicle.display_name}}
+
+
{{n-a foodTruck.service_area.name}}
diff --git a/addon/templates/networks/index/network/index.hbs b/addon/templates/networks/index/network/index.hbs index 4666f10b..e0d28ae5 100644 --- a/addon/templates/networks/index/network/index.hbs +++ b/addon/templates/networks/index/network/index.hbs @@ -139,6 +139,7 @@ - {{model.name}} +
diff --git a/addon/templates/promotions/push-notifications.hbs b/addon/templates/promotions/push-notifications.hbs index b74e9994..35f04270 100644 --- a/addon/templates/promotions/push-notifications.hbs +++ b/addon/templates/promotions/push-notifications.hbs @@ -37,6 +37,7 @@ -
-
- -
-
-
{{model.name}}
-
{{n-a model.email}}
-
{{n-a model.phone}}
-
-
+
{{/unless}} diff --git a/addon/templates/settings/index.hbs b/addon/templates/settings/index.hbs index 581dec48..3e3ee253 100644 --- a/addon/templates/settings/index.hbs +++ b/addon/templates/settings/index.hbs @@ -217,6 +217,7 @@ - {{model.name}} +
diff --git a/package.json b/package.json index 43ea033e..344a57db 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.25", "@fleetbase/ember-ui": "^0.4.5", - "@fleetbase/fleetops-data": "^0.1.37", + "@fleetbase/fleetops-data": "^0.2.3", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", "@fortawesome/free-brands-svg-icons": "6.4.0", diff --git a/tests/integration/components/shared-identity-test.js b/tests/integration/components/shared-identity-test.js new file mode 100644 index 00000000..00bf135c --- /dev/null +++ b/tests/integration/components/shared-identity-test.js @@ -0,0 +1,71 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; + +/** + * Storefront never loads the FleetOps engine to show a driver, customer or + * place: the descriptors and wrappers come from fleetops-data and the + * generic components from ember-ui, both of which this engine bundles. + */ +module('Integration | Component | shared identity components', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + initialize(this.owner); + }); + + test('a driver identity cell renders the name, status dot and vehicle badge from the shared descriptor', async function (assert) { + this.set('row', { driver_assigned: { resourceType: 'driver', name: 'Ada Driver', status: 'available', vehicle_name: 'Truck 10' } }); + this.set('column', { resourceType: 'driver', resourcePath: 'driver_assigned', popover: false }); + + await render(hbs``); + + assert.dom('[data-test-identity-label]').hasText('Ada Driver'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('text-green-500'); + assert.dom('[data-test-resource-identity-meta-badge][data-badge-key="vehicle"]').hasText('Truck 10'); + }); + + test('an empty relation renders the column empty text instead of a cell', async function (assert) { + this.set('row', {}); + this.set('column', { resourceType: 'driver', resourcePath: 'driver_assigned', emptyText: 'No driver assigned' }); + + await render(hbs``); + + assert.dom('[data-test-identity-empty-text]').hasText('No driver assigned'); + assert.dom('[data-test-identity-cell]').doesNotExist(); + }); + + test('the customer, driver, place and vehicle pills and select options resolve inside storefront', async function (assert) { + this.set('customer', { resourceType: 'customer', name: 'Ava Chen', customer_type: 'fleet-ops:contact' }); + this.set('driver', { resourceType: 'driver', name: 'Ada Driver', phone: '+15550100' }); + this.set('place', { resourceType: 'place', name: 'Depot', city: 'Singapore', country: 'SG' }); + this.set('vehicle', { resourceType: 'vehicle', displayName: 'Truck 10', plate_number: 'TRK-10' }); + + await render(hbs` +
+
+
+
+
+ `); + + assert.dom('[data-test-customer] [data-test-resource-pill-title]').hasText('Ava Chen'); + assert.dom('[data-test-customer] [data-test-resource-pill-subtitle]').hasText('Customer'); + assert.dom('[data-test-driver] [data-test-resource-pill-title]').hasText('Ada Driver'); + assert.dom('[data-test-driver] [data-test-resource-pill-subtitle]').hasText('+15550100'); + assert.dom('[data-test-place] [data-test-resource-pill-title]').hasText('Depot'); + assert.dom('[data-test-place] [data-test-resource-pill-subtitle]').hasText('Singapore, SG'); + assert.dom('[data-test-vehicle] [data-test-select-option-title]').hasText('Truck 10'); + assert.dom('[data-test-vehicle] .select-option').hasClass('select-option--compact'); + assert.dom('[data-test-driver-option] [data-test-select-option-title]').hasText('Ada Driver'); + }); + + test('a pill with no record shows its fallback title and does not open anything', async function (assert) { + await render(hbs``); + + assert.dom('[data-test-resource-pill-title]').hasText('No driver assigned'); + assert.dom('[data-test-resource-pill] a[href]').doesNotExist(); + }); +}); diff --git a/tests/integration/components/storefront/order/details-test.js b/tests/integration/components/storefront/order/details-test.js index 1d392fca..981b8944 100644 --- a/tests/integration/components/storefront/order/details-test.js +++ b/tests/integration/components/storefront/order/details-test.js @@ -2,10 +2,15 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; module('Integration | Component | storefront/order/details', function (hooks) { setupRenderingTest(hooks); + hooks.beforeEach(function () { + initialize(this.owner); + }); + test('it renders dedicated storefront order detail panels', async function (assert) { this.set('order', { public_id: 'order_test', @@ -58,9 +63,30 @@ module('Integration | Component | storefront/order/details', function (hooks) { assert.dom('.next-content-panel-title-container').hasTextContaining('Route'); assert.dom('.next-content-panel-title-container').hasTextContaining('Metadata'); assert.dom('.storefront-order-person__name').hasTextContaining('Tasty Store'); + assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('No driver assigned', 'an unassigned driver is a static pill with the fallback title'); + assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('No customer'); assert.dom('.storefront-order-item__image').exists(); assert.dom('.storefront-order-item__price').exists(); assert.dom().doesNotContainText('Delivery Address'); assert.dom().doesNotContainText('Pickup Address'); }); + + test('a loaded customer and driver render as their pills', async function (assert) { + this.set('order', { + public_id: 'order_test', + meta: { total: 10, currency: 'USD', is_pickup: true }, + customer: { id: 'contact_1', resourceType: 'customer', name: 'Ava Chen', email: 'ava@example.test', phone: '+15550201' }, + driver_assigned: { id: 'driver_1', resourceType: 'driver', name: 'Ada Driver', phone: '+15550100' }, + payload: { pickup: {}, dropoff: {}, entities: [] }, + tracking_number: {}, + tracking_statuses: [], + files: [], + }); + + await render(hbs``); + + assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('Ava Chen'); + assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('Ada Driver'); + assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-subtitle]').hasText('+15550100'); + }); }); diff --git a/tests/integration/components/widget/customers-test.js b/tests/integration/components/widget/customers-test.js index 9d51d239..4aa03639 100644 --- a/tests/integration/components/widget/customers-test.js +++ b/tests/integration/components/widget/customers-test.js @@ -57,7 +57,7 @@ module('Integration | Component | widget/customers', function (hooks) { assert.dom('.storefront-widget-count').hasText('1'); assert.dom('.storefront-customers-table').exists(); assert.dom('.storefront-customer-id').hasText('contact_1'); - assert.dom('.storefront-customers-table').includesText('Ava Chen'); + assert.dom('.storefront-customers-table [data-test-resource-pill-title]').hasText('Ava Chen', 'the customer renders as its pill'); assert.dom('.storefront-customers-table').includesText('ava.chen@example.test'); }); }); diff --git a/tests/unit/controllers/customers/index-test.js b/tests/unit/controllers/customers/index-test.js index 1acfcb49..d8189724 100644 --- a/tests/unit/controllers/customers/index-test.js +++ b/tests/unit/controllers/customers/index-test.js @@ -17,4 +17,13 @@ module('Unit | Controller | customers/index', function (hooks) { assert.true(nameColumn.sticky, 'name column is sticky'); assert.strictEqual(actionColumn.sticky, 'right', 'action column is sticky on the right'); }); + + test('the name column is a customer identity cell that opens the storefront customer', function (assert) { + let controller = this.owner.lookup('controller:customers/index'); + let [nameColumn] = controller.columns; + + assert.strictEqual(nameColumn.cellComponent, 'table/cell/identity'); + assert.strictEqual(nameColumn.resourceType, 'customer'); + assert.strictEqual(nameColumn.action, controller.viewCustomer); + }); }); diff --git a/tests/unit/controllers/networks/index/network/customers-test.js b/tests/unit/controllers/networks/index/network/customers-test.js index 844ec516..bd90c9fb 100644 --- a/tests/unit/controllers/networks/index/network/customers-test.js +++ b/tests/unit/controllers/networks/index/network/customers-test.js @@ -4,9 +4,17 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Controller | networks/index/network/customers', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/customers'); assert.ok(controller); }); + + test('the name column is a customer identity cell that opens the customer panel', function (assert) { + let controller = this.owner.lookup('controller:networks/index/network/customers'); + let [nameColumn] = controller.columns; + + assert.strictEqual(nameColumn.cellComponent, 'table/cell/identity'); + assert.strictEqual(nameColumn.resourceType, 'customer'); + assert.strictEqual(nameColumn.action, controller.viewCustomer); + }); }); diff --git a/tests/unit/controllers/networks/index/network/orders-test.js b/tests/unit/controllers/networks/index/network/orders-test.js index 59ef20f3..141a91cc 100644 --- a/tests/unit/controllers/networks/index/network/orders-test.js +++ b/tests/unit/controllers/networks/index/network/orders-test.js @@ -4,9 +4,28 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Controller | networks/index/network/orders', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/orders'); assert.ok(controller); }); + + test('customer, pickup, dropoff and driver are identity cells typed for the shared descriptors', function (assert) { + const controller = this.owner.lookup('controller:networks/index/network/orders'); + const byId = Object.fromEntries(controller.columns.map((column) => [column.id, column])); + + for (const [id, resourceType] of [ + ['customer-name', 'customer'], + ['pickup-name', 'place'], + ['dropoff-name', 'place'], + ['driver-assigned', 'driver'], + ]) { + assert.strictEqual(byId[id].cellComponent, 'table/cell/identity', `${id} is an identity cell`); + assert.strictEqual(byId[id].resourceType, resourceType, `${id} is a ${resourceType}`); + assert.strictEqual(typeof byId[id].resourcePath, 'function', `${id} resolves its resource`); + } + + const driver = { id: 'driver_1' }; + assert.strictEqual(byId['driver-assigned'].resourcePath({ driver_assigned: driver }), driver); + assert.strictEqual(byId['driver-assigned'].resourcePath({ driver_name: 'Bob' }).name, 'Bob'); + }); }); diff --git a/tests/unit/controllers/orders/index-test.js b/tests/unit/controllers/orders/index-test.js index b471c95b..6c9ca5d4 100644 --- a/tests/unit/controllers/orders/index-test.js +++ b/tests/unit/controllers/orders/index-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Controller | orders/index', function (hooks) { setupTest(hooks); @@ -17,4 +18,69 @@ module('Unit | Controller | orders/index', function (hooks) { assert.true(idColumn.sticky, 'id column is sticky'); assert.strictEqual(actionColumn.sticky, 'right', 'action column is sticky on the right'); }); + + test('customer, pickup, dropoff and driver are identity cells typed for the shared descriptors', function (assert) { + const controller = this.owner.lookup('controller:orders/index'); + const byId = Object.fromEntries(controller.columns.map((column) => [column.id, column])); + + assert.strictEqual(byId['customer-name'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['customer-name'].resourceType, 'customer'); + assert.strictEqual(byId['pickup-name'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['pickup-name'].resourceType, 'place'); + assert.strictEqual(byId['dropoff-name'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['dropoff-name'].resourceType, 'place'); + assert.strictEqual(byId['driver-assigned'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['driver-assigned'].resourceType, 'driver'); + assert.strictEqual(byId['driver-assigned'].emptyText, 'No driver assigned'); + }); + + test('identity cells read the loaded relation, or a stub named after the row', async function (assert) { + const controller = this.owner.lookup('controller:orders/index'); + const byId = Object.fromEntries(controller.columns.map((column) => [column.id, column])); + const customer = { id: 'contact_1', name: 'Ada' }; + const driver = { id: 'driver_1', name: 'Bob' }; + const pickup = { id: 'place_1', name: 'Depot' }; + + assert.strictEqual(byId['customer-name'].resourcePath({ customer }), customer); + assert.strictEqual(byId['driver-assigned'].resourcePath({ driver_assigned: driver }), driver); + assert.strictEqual(byId['pickup-name'].resourcePath({ payload: { pickup } }), pickup); + + const customerStub = byId['customer-name'].resourcePath({ customer_name: 'Ada', customer_type: 'fleet-ops:contact' }); + assert.strictEqual(customerStub.name, 'Ada'); + assert.strictEqual(customerStub.resourceType, 'fleet-ops:contact'); + assert.true(customerStub.isIdentityStub); + + const placeStub = byId['dropoff-name'].resourcePath({ dropoffName: '1 Main St' }); + assert.strictEqual(placeStub.name, '1 Main St'); + assert.strictEqual(placeStub.resourceType, 'place'); + + const found = { id: 'driver_9' }; + const findRecord = (modelName, id) => (modelName === 'driver' && id === 'driver_9' ? Promise.resolve(found) : Promise.resolve(null)); + controller.store = { findRecord }; + const driverStub = byId['driver-assigned'].resourcePath({ driver_name: 'Bob', driver_assigned_uuid: 'driver_9' }); + assert.strictEqual(driverStub.name, 'Bob'); + assert.strictEqual(await driverStub.loadResource(), found, 'the stub loads the driver by uuid on demand'); + assert.strictEqual(await byId['driver-assigned'].resourcePath({ driver_name: 'Bob' }).loadResource(), null, 'no uuid, nothing to load'); + + assert.strictEqual(byId['driver-assigned'].resourcePath({}), null, 'no driver at all renders the empty text'); + }); + + test('clicking a customer opens the storefront customer page, and a name-only stub opens nothing', function (assert) { + const transitions = []; + this.owner.register( + 'service:hostRouter', + class extends Service { + transitionTo(...args) { + transitions.push(args); + } + } + ); + const controller = this.owner.lookup('controller:orders/index'); + + controller.viewCustomer({ public_id: 'contact_1', name: 'Ada' }); + controller.viewCustomer({ name: 'Ada', isIdentityStub: true }); + controller.viewCustomer(null); + + assert.deepEqual(transitions, [['console.storefront.customers.index.view', 'contact_1']]); + }); }); From e96ed4b73251dcf7c66d61de7c0678c4f2851ff6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 17:54:15 +0800 Subject: [PATCH 32/50] fix(checkout): multi-store orders can be placed; stores are found before anything is created and a failed attempt is undone --- .../Controllers/v1/CheckoutController.php | 508 ++++++++++-------- server/src/Support/Storefront.php | 14 +- 2 files changed, 281 insertions(+), 241 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index e2fc5f43..8a405d87 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -1688,6 +1688,14 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $integratedVendorOrder = $vendorResult['order']; } + // Find each pickup's store before anything is created: an order that cannot be built + // fails here, without a payment record or half the orders left behind. + $originPlaces = collect($origins)->map(fn ($publicId) => Place::createFromMixed($publicId))->values(); + $originStores = $originPlaces->map(fn ($pickup) => $pickup instanceof Place ? Storefront::getStoreFromLocation($pickup->uuid) : null)->values(); + if ($originPlaces->isEmpty() || $originStores->contains(fn ($store) => !$store)) { + return response()->apiError('A store in this order could not be found, so it was not placed. Your payment has not been used.'); + } + // setup transaction meta $transactionMeta = [ 'storefront_network' => $about->name, @@ -1695,145 +1703,238 @@ public function captureMultipleOrders(CaptureOrderRequest $request) ...$transactionDetails, ]; - // create transactions for cart - $transaction = Transaction::create([ - 'company_uuid' => session('company'), - 'customer_uuid' => $customer->uuid, - 'customer_type' => Utils::getMutationType('fleet-ops:contact'), - 'gateway_transaction_id' => Utils::or($transactionDetails, ['id', 'transaction_id']) ?? Transaction::generateNumber(), - 'gateway' => $gateway->code, - 'gateway_uuid' => $gateway->uuid, - 'amount' => $amount, - 'currency' => $currency, - 'description' => 'Storefront network order', - 'type' => 'storefront', - 'status' => Transaction::STATUS_SUCCESS, - 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, - 'settled_at' => now(), - 'settled_amount' => $amount, - 'settled_currency' => $currency, - 'meta' => $transactionMeta, - ]); + $transaction = null; + $multipleOrders = []; - // create transaction items - foreach ($cart->items as $cartItem) { - $store = Storefront::findAbout($cartItem->store_id); + try { + // create transactions for cart + $transaction = Transaction::create([ + 'company_uuid' => session('company'), + 'customer_uuid' => $customer->uuid, + 'customer_type' => Utils::getMutationType('fleet-ops:contact'), + 'gateway_transaction_id' => Utils::or($transactionDetails, ['id', 'transaction_id']) ?? Transaction::generateNumber(), + 'gateway' => $gateway->code, + 'gateway_uuid' => $gateway->uuid, + 'amount' => $amount, + 'currency' => $currency, + 'description' => 'Storefront network order', + 'type' => 'storefront', + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'settled_at' => now(), + 'settled_amount' => $amount, + 'settled_currency' => $currency, + 'meta' => $transactionMeta, + ]); - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => $cartItem->subtotal, - 'currency' => $checkout->currency, - 'details' => Storefront::getFullDescriptionFromCartItem($cartItem), - 'code' => 'product', - 'meta' => [ + // create transaction items + foreach ($cart->items as $cartItem) { + $store = Storefront::findAbout($cartItem->store_id); + + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => $cartItem->subtotal, + 'currency' => $checkout->currency, + 'details' => Storefront::getFullDescriptionFromCartItem($cartItem), + 'code' => 'product', + 'meta' => [ + 'storefront_network' => $about->name, + 'storefront_network_id' => $about->public_id, + 'storefront' => $store->name ?? null, + 'storefront_id' => $store->public_id ?? null, + ], + ]); + } + + // create transaction item for service quote + if (!$checkout->is_pickup) { + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => $serviceQuote->amount, + 'currency' => $serviceQuote->currency, + 'details' => 'Delivery fee', + 'code' => 'delivery_fee', + ]); + } + + // if tip create transaction item for tip + if ($checkout->hasOption('tip')) { + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), + 'currency' => $checkout->currency, + 'details' => 'Tip', + 'code' => 'tip', + ]); + } + + // if delivery tip create transaction item for tip + if ($checkout->hasOption('delivery_tip')) { + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => static::calculateTipAmount($checkout->getOption('delivery_tip'), $cart->subtotal), + 'currency' => $checkout->currency, + 'details' => 'Delivery Tip', + 'code' => 'delivery_tip', + ]); + } + + // if promotions were applied create a (credit) transaction item for the discount + $promotions = PromotionResult::fromArray(data_get($checkout->options, 'promotions')); + $discountAllocations = $promotions->allocationsByStore(); + static::createDiscountTransactionItem($transaction, $promotions, $checkout->currency); + + // payload pickups (resolved above) and the destination as places + $origins = $originPlaces; + $destination = Place::createFromMixed($destination); + + // The store tip goes to the network that runs the app, unless the network splits it + // across the stores, each by its share of the order. + $splitTips = $checkout->hasOption('tip') && $about->isOption('split_tips_across_stores'); + $tipShares = []; + if ($splitTips) { + $storeSubtotals = $originStores + ->unique('public_id') + ->mapWithKeys(fn ($store) => [$store->public_id => (int) $cart->getSubtotalForStore($store)]) + ->all(); + $tipShares = static::splitByShare((int) static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), $storeSubtotals); + } + + $multipleOrders = []; + + foreach ($origins as $index => $pickup) { + $store = $originStores[$index]; + + // create payload + $payload = Payload::create([ + 'company_uuid' => $store->company_uuid, + 'pickup_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'dropoff_uuid' => $destination instanceof Place ? $destination->uuid : null, + 'return_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'payment_method' => $gateway->type, + 'type' => 'storefront', + ]); + + // get cart items from this store + $cartItems = $cart->getItemsForStore($store); + + // create entities + foreach ($cartItems as $cartItem) { + $this->processCartItem($cartItem, $payload, $customer); + } + + // get order subtotal and this store's share of the item discount + $subtotal = $cart->getSubtotalForStore($store); + $storeDiscount = min((int) ($discountAllocations[$store->public_id] ?? 0), (int) $subtotal); + + // prepare order meta + $orderMeta = [ + 'is_master_order' => false, + 'storefront' => $store->name, + 'storefront_id' => $store->public_id, 'storefront_network' => $about->name, 'storefront_network_id' => $about->public_id, - 'storefront' => $store->name ?? null, - 'storefront_id' => $store->public_id ?? null, - ], - ]); - } + 'checkout_id' => $checkout->public_id, + 'subtotal' => $subtotal, + 'delivery_fee' => 0, + 'tip' => $tipShares[$store->public_id] ?? 0, + 'tip_recipient' => $splitTips ? 'store' : 'network', + 'delivery_tip' => 0, + 'discount' => $storeDiscount, + 'total' => $subtotal - $storeDiscount, + 'currency' => $currency, + 'gateway' => $gateway->type, + 'require_pod' => $about->getOption('require_pod'), + 'pod_method' => $about->pod_method, + 'is_pickup' => $checkout->is_pickup, + ...$transactionDetails, + ]; - // create transaction item for service quote - if (!$checkout->is_pickup) { - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => $serviceQuote->amount, - 'currency' => $serviceQuote->currency, - 'details' => 'Delivery fee', - 'code' => 'delivery_fee', - ]); - } + // prepare order input + $orderInput = [ + 'company_uuid' => $store->company_uuid, + 'payload_uuid' => $payload->uuid, + 'customer_uuid' => $customer->uuid, + 'customer_type' => Utils::getMutationType('fleet-ops:contact'), + 'transaction_uuid' => $transaction->uuid, + 'order_config_uuid' => $store->getOrderConfigId(), + 'adhoc' => $about->isOption('auto_dispatch'), + 'type' => 'storefront', + 'status' => 'created', + 'notes' => $notes, + ]; - // if tip create transaction item for tip - if ($checkout->hasOption('tip')) { - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), - 'currency' => $checkout->currency, - 'details' => 'Tip', - 'code' => 'tip', - ]); - } + // if it's integrated vendor order apply to meta + if ($integratedVendorOrder) { + $orderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; + $orderMeta['integrated_vendor_order'] = $integratedVendorOrder; + // order input + $orderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; + $orderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); + } - // if delivery tip create transaction item for tip - if ($checkout->hasOption('delivery_tip')) { - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => static::calculateTipAmount($checkout->getOption('delivery_tip'), $cart->subtotal), - 'currency' => $checkout->currency, - 'details' => 'Delivery Tip', - 'code' => 'delivery_tip', - ]); - } + // A booked service makes this store's order a booking order, with its own flow. + [$orderMeta, $orderInput] = static::applyBooking($cartItems, $orderMeta, $orderInput); - // if promotions were applied create a (credit) transaction item for the discount - $promotions = PromotionResult::fromArray(data_get($checkout->options, 'promotions')); - $discountAllocations = $promotions->allocationsByStore(); - static::createDiscountTransactionItem($transaction, $promotions, $checkout->currency); + // set meta to order input last + $orderInput['meta'] = $orderMeta; - // convert payload destinations to Place - $origins = collect($origins)->map(function ($publicId) { - return Place::createFromMixed($publicId); - }); - $destination = Place::createFromMixed($destination); + // create order + $multipleOrders[] = $order = Order::create($orderInput); - // The store tip goes to the network that runs the app, unless the network splits it - // across the stores, each by its share of the order. - $splitTips = $checkout->hasOption('tip') && $about->isOption('split_tips_across_stores'); - $tipShares = []; - if ($splitTips) { - $storeSubtotals = $origins - ->map(fn ($pickup) => Storefront::getStoreFromLocation($pickup->uuid)) - ->filter() - ->unique('public_id') - ->mapWithKeys(fn ($store) => [$store->public_id => (int) $cart->getSubtotalForStore($store)]) - ->all(); - $tipShares = static::splitByShare((int) static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), $storeSubtotals); - } + // set driving distance and time + $order->setPreliminaryDistanceAndTime(); - $multipleOrders = []; + // purchase service quote + $order->purchaseQuote($serviceQuote->uuid, $transactionDetails); + + // if order is auto accepted update status + if ($store->isOption('auto_accept_orders')) { + $this->autoAcceptOrder($order); + if ($store->isOption('auto_dispatch')) { + $this->autoDispatchOrder($order); + } + } + + // notify order creation + Storefront::alertNewOrder($order); + } - foreach ($origins as $pickup) { - $store = Storefront::getStoreFromLocation($pickup->uuid); + // convert origin to Place + $origin = Place::createFromMixed($origin); - // create payload + // create master payload $payload = Payload::create([ - 'company_uuid' => $store->company_uuid, - 'pickup_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'company_uuid' => session('company'), + 'pickup_uuid' => $origin instanceof Place ? $origin->uuid : null, 'dropoff_uuid' => $destination instanceof Place ? $destination->uuid : null, - 'return_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'return_uuid' => $origin instanceof Place ? $origin->uuid : null, 'payment_method' => $gateway->type, 'type' => 'storefront', - ]); - - // get cart items from this store - $cartItems = $cart->getItemsForStore($store); + ])->setWaypoints($waypoints); // create entities - foreach ($cartItems as $cartItem) { + foreach ($cart->items as $cartItem) { $this->processCartItem($cartItem, $payload, $customer); } - // get order subtotal and this store's share of the item discount - $subtotal = $cart->getSubtotalForStore($store); - $storeDiscount = min((int) ($discountAllocations[$store->public_id] ?? 0), (int) $subtotal); - - // prepare order meta - $orderMeta = [ - 'is_master_order' => false, - 'storefront' => $store->name, - 'storefront_id' => $store->public_id, + // prepare master order meta + $masterOrderMeta = [ + 'is_master_order' => true, + 'related_orders' => collect($multipleOrders)->pluck('public_id')->toArray(), + 'storefront' => $about->name, + 'storefront_id' => $about->public_id, 'storefront_network' => $about->name, 'storefront_network_id' => $about->public_id, 'checkout_id' => $checkout->public_id, - 'subtotal' => $subtotal, - 'delivery_fee' => 0, - 'tip' => $tipShares[$store->public_id] ?? 0, - 'tip_recipient' => $splitTips ? 'store' : 'network', - 'delivery_tip' => 0, - 'discount' => $storeDiscount, - 'total' => $subtotal - $storeDiscount, + 'subtotal' => Utils::numbersOnly($cart->subtotal), + 'delivery_fee' => $checkout->is_pickup ? 0 : Utils::numbersOnly($serviceQuote->amount), + 'tip' => $checkout->getOption('tip'), + 'tip_recipient' => $splitTips ? 'stores' : 'network', + 'delivery_tip' => $checkout->getOption('delivery_tip'), + 'discount' => $promotions->discount(), + 'promotions' => $promotions->toPublicArray()['applied'], + 'total' => Utils::numbersOnly($amount), 'currency' => $currency, 'gateway' => $gateway->type, 'require_pod' => $about->getOption('require_pod'), @@ -1842,37 +1943,44 @@ public function captureMultipleOrders(CaptureOrderRequest $request) ...$transactionDetails, ]; - // prepare order input - $orderInput = [ - 'company_uuid' => $store->company_uuid, + // prepare master order input + $masterOrderInput = [ + 'company_uuid' => session('company'), 'payload_uuid' => $payload->uuid, 'customer_uuid' => $customer->uuid, 'customer_type' => Utils::getMutationType('fleet-ops:contact'), 'transaction_uuid' => $transaction->uuid, - 'order_config_uuid' => $store->getOrderConfigId(), + 'order_config_uuid' => $about->getOrderConfigId(), 'adhoc' => $about->isOption('auto_dispatch'), 'type' => 'storefront', 'status' => 'created', - 'notes' => $notes, ]; // if it's integrated vendor order apply to meta if ($integratedVendorOrder) { - $orderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; - $orderMeta['integrated_vendor_order'] = $integratedVendorOrder; + $masterOrderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; + $masterOrderMeta['integrated_vendor_order'] = $integratedVendorOrder; // order input - $orderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; - $orderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); + $masterOrderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; + $masterOrderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); } - // A booked service makes this store's order a booking order, with its own flow. - [$orderMeta, $orderInput] = static::applyBooking($cartItems, $orderMeta, $orderInput); + // finally apply meta to master order + $masterOrderInput['meta'] = $masterOrderMeta; + + // create master order + $order = Order::create($masterOrderInput); - // set meta to order input last - $orderInput['meta'] = $orderMeta; + // record the promotions as used by this checkout's master order + PromotionRedemptions::redeem($checkout, $order); - // create order - $multipleOrders[] = $order = Order::create($orderInput); + // update child orders with master order id in meta + foreach ($multipleOrders as $childOrder) { + $childOrder->updateMeta('master_order_id', $order->public_id); + } + + // notify driver if assigned + $order->notifyDriverAssigned(); // set driving distance and time $order->setPreliminaryDistanceAndTime(); @@ -1880,120 +1988,48 @@ public function captureMultipleOrders(CaptureOrderRequest $request) // purchase service quote $order->purchaseQuote($serviceQuote->uuid, $transactionDetails); - // if order is auto accepted update status - if ($store->isOption('auto_accept_orders')) { - $this->autoAcceptOrder($order); - if ($store->isOption('auto_dispatch')) { - $this->autoDispatchOrder($order); - } - } - - // notify order creation - Storefront::alertNewOrder($order); - } + // dispatch if flagged true + $order->firstDispatch(); - // convert origin to Place - $origin = Place::createFromMixed($origin); + // update the cart with the checkout + $checkout->checkedout(); - // create master payload - $payload = Payload::create([ - 'company_uuid' => session('company'), - 'pickup_uuid' => $origin instanceof Place ? $origin->uuid : null, - 'dropoff_uuid' => $destination instanceof Place ? $destination->uuid : null, - 'return_uuid' => $origin instanceof Place ? $origin->uuid : null, - 'payment_method' => $gateway->type, - 'type' => 'storefront', - ])->setWaypoints($waypoints); - - // create entities - foreach ($cart->items as $cartItem) { - $this->processCartItem($cartItem, $payload, $customer); - } - - // prepare master order meta - $masterOrderMeta = [ - 'is_master_order' => true, - 'related_orders' => collect($multipleOrders)->pluck('public_id')->toArray(), - 'storefront' => $about->name, - 'storefront_id' => $about->public_id, - 'storefront_network' => $about->name, - 'storefront_network_id' => $about->public_id, - 'checkout_id' => $checkout->public_id, - 'subtotal' => Utils::numbersOnly($cart->subtotal), - 'delivery_fee' => $checkout->is_pickup ? 0 : Utils::numbersOnly($serviceQuote->amount), - 'tip' => $checkout->getOption('tip'), - 'tip_recipient' => $splitTips ? 'stores' : 'network', - 'delivery_tip' => $checkout->getOption('delivery_tip'), - 'discount' => $promotions->discount(), - 'promotions' => $promotions->toPublicArray()['applied'], - 'total' => Utils::numbersOnly($amount), - 'currency' => $currency, - 'gateway' => $gateway->type, - 'require_pod' => $about->getOption('require_pod'), - 'pod_method' => $about->pod_method, - 'is_pickup' => $checkout->is_pickup, - ...$transactionDetails, - ]; + // update checkout token + $checkout->update([ + 'order_uuid' => $order->uuid, + // 'store_uuid' => $about->uuid, + 'captured' => true, + ]); - // prepare master order input - $masterOrderInput = [ - 'company_uuid' => session('company'), - 'payload_uuid' => $payload->uuid, - 'customer_uuid' => $customer->uuid, - 'customer_type' => Utils::getMutationType('fleet-ops:contact'), - 'transaction_uuid' => $transaction->uuid, - 'order_config_uuid' => $about->getOrderConfigId(), - 'adhoc' => $about->isOption('auto_dispatch'), - 'type' => 'storefront', - 'status' => 'created', - ]; + return new OrderResource($order); + } catch (\Throwable $e) { + // Undo this attempt's records so a retry ("Finish placing order") starts clean + // instead of leaving another payment record with no order. + static::discardFailedCapture($transaction, $multipleOrders); - // if it's integrated vendor order apply to meta - if ($integratedVendorOrder) { - $masterOrderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; - $masterOrderMeta['integrated_vendor_order'] = $integratedVendorOrder; - // order input - $masterOrderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; - $masterOrderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); + throw $e; } + } - // finally apply meta to master order - $masterOrderInput['meta'] = $masterOrderMeta; - - // create master order - $order = Order::create($masterOrderInput); - - // record the promotions as used by this checkout's master order - PromotionRedemptions::redeem($checkout, $order); - - // update child orders with master order id in meta - foreach ($multipleOrders as $childOrder) { - $childOrder->updateMeta('master_order_id', $order->public_id); + /** + * Remove what a failed capture attempt created: its transaction (with its line items) and + * any of the store orders already made. Nothing else refers to them yet. + */ + protected static function discardFailedCapture(?Transaction $transaction, array $orders = []): void + { + try { + foreach ($orders as $order) { + if ($order instanceof Order) { + $order->delete(); + } + } + if ($transaction) { + TransactionItem::where('transaction_uuid', $transaction->uuid)->delete(); + $transaction->delete(); + } + } catch (\Throwable $cleanup) { + Log::error('[Storefront] Unable to discard a failed order capture.', ['transaction' => $transaction?->public_id, 'error' => $cleanup->getMessage()]); } - - // notify driver if assigned - $order->notifyDriverAssigned(); - - // set driving distance and time - $order->setPreliminaryDistanceAndTime(); - - // purchase service quote - $order->purchaseQuote($serviceQuote->uuid, $transactionDetails); - - // dispatch if flagged true - $order->firstDispatch(); - - // update the cart with the checkout - $checkout->checkedout(); - - // update checkout token - $checkout->update([ - 'order_uuid' => $order->uuid, - // 'store_uuid' => $about->uuid, - 'captured' => true, - ]); - - return new OrderResource($order); } public function afterCheckout(Request $request) diff --git a/server/src/Support/Storefront.php b/server/src/Support/Storefront.php index 06fe8ca4..a75c050d 100644 --- a/server/src/Support/Storefront.php +++ b/server/src/Support/Storefront.php @@ -108,11 +108,15 @@ public static function getStoreFromLocation(string $id, $columns = [], $with = [ $columns = array_merge(['uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', 'order_config_uuid', 'name', 'description', 'translations', 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone', 'tags', 'currency', 'timezone', 'pod_method', 'options'], $columns); } - return Store::select($columns)->with($with)->whereHas('locations', function ($q) use ($id) { - $q->where('place_uuid', $id); - $q->orWhereHas('place', function ($q) use ($id) { - $q->where('public_id', $id); - }); + // Places live in the Fleet-Ops database and store locations in Storefront's, so the place + // is resolved on its own (a join across the two databases fails) before the store lookup. + $placeUuid = Str::isUuid($id) ? $id : \Fleetbase\FleetOps\Models\Place::where('public_id', $id)->value('uuid'); + if (!$placeUuid) { + return null; + } + + return Store::select($columns)->with($with)->whereHas('locations', function ($q) use ($placeUuid) { + $q->where('place_uuid', $placeUuid); })->first(); } From babef37792c2bb70e7f72d612d6869cafc00f01d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 18:00:42 +0800 Subject: [PATCH 33/50] perf(notifications): order, new-order and chat notifications are sent from the queue, not during checkout --- server/src/Notifications/StorefrontOrderChatMessage.php | 5 ++++- server/src/Notifications/StorefrontOrderCreated.php | 5 ++++- server/src/Notifications/StorefrontOrderNotification.php | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/server/src/Notifications/StorefrontOrderChatMessage.php b/server/src/Notifications/StorefrontOrderChatMessage.php index 753060a1..a4d37073 100644 --- a/server/src/Notifications/StorefrontOrderChatMessage.php +++ b/server/src/Notifications/StorefrontOrderChatMessage.php @@ -11,6 +11,8 @@ use Fleetbase\Storefront\Push\StorefrontPushChannel; use Fleetbase\Storefront\Support\CustomerNotificationPresenter; use Illuminate\Bus\Queueable; +use Illuminate\Queue\SerializesModels; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\BroadcastMessage; use Illuminate\Notifications\Notification; @@ -20,9 +22,10 @@ * Messages about an order in progress always reach the customer: they are not promotional * and do not depend on the order update preference. */ -class StorefrontOrderChatMessage extends Notification implements SendsPushNotification +class StorefrontOrderChatMessage extends Notification implements SendsPushNotification, ShouldQueue { use Queueable; + use SerializesModels; public function __construct(public ChatMessage $message, public Order $order) { diff --git a/server/src/Notifications/StorefrontOrderCreated.php b/server/src/Notifications/StorefrontOrderCreated.php index 06b05192..1dd5439d 100644 --- a/server/src/Notifications/StorefrontOrderCreated.php +++ b/server/src/Notifications/StorefrontOrderCreated.php @@ -8,14 +8,17 @@ use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Utils; use Illuminate\Bus\Queueable; +use Illuminate\Queue\SerializesModels; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; use NotificationChannels\Twilio\TwilioChannel; use NotificationChannels\Twilio\TwilioSmsMessage; -class StorefrontOrderCreated extends Notification +class StorefrontOrderCreated extends Notification implements ShouldQueue { use Queueable; + use SerializesModels; /** * The order instance this notification is for. diff --git a/server/src/Notifications/StorefrontOrderNotification.php b/server/src/Notifications/StorefrontOrderNotification.php index 22e2f6dd..d254e7ca 100644 --- a/server/src/Notifications/StorefrontOrderNotification.php +++ b/server/src/Notifications/StorefrontOrderNotification.php @@ -14,6 +14,8 @@ use Fleetbase\Storefront\Support\NotificationPreferences; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Bus\Queueable; +use Illuminate\Queue\SerializesModels; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\BroadcastMessage; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; @@ -21,9 +23,10 @@ /** * Base class for order lifecycle notifications sent to storefront customers. */ -abstract class StorefrontOrderNotification extends Notification implements SendsPushNotification +abstract class StorefrontOrderNotification extends Notification implements SendsPushNotification, ShouldQueue { use Queueable; + use SerializesModels; /** * The order instance this notification is for. From 7aaf8e4e73a8171779b6039d5e9537641d94600d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 20:08:37 +0800 Subject: [PATCH 34/50] feat(orders): a multi-store order is listed once, with its breakdown by store --- .../Controllers/v1/CustomerController.php | 8 +- .../Http/Controllers/v1/OrderController.php | 75 +++++++++++++++++++ server/src/routes.php | 1 + 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 6cc9cf7c..01ea14f2 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -185,12 +185,10 @@ public function orders(Request $request) $results = Order::queryWithRequest($request, function (&$query) use ($customer) { $query->where('customer_uuid', $customer->uuid)->whereNull('deleted_at')->withoutGlobalScopes(); - // dont query any master orders if its a network + // A multi-store checkout is one order to the customer: list its master order (which + // shows each store's part) and leave out the stores' own orders. if (session('storefront_network')) { - $query->where(function ($q) { - $q->where('meta->is_master_order', false); - $q->orWhere('meta', 'not like', '%related_orders%'); - }); + $query->whereNull('meta->master_order_id'); } }, true); diff --git a/server/src/Http/Controllers/v1/OrderController.php b/server/src/Http/Controllers/v1/OrderController.php index 75746311..9dcfd7d9 100644 --- a/server/src/Http/Controllers/v1/OrderController.php +++ b/server/src/Http/Controllers/v1/OrderController.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Support\OrderActivityFlow; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; @@ -58,6 +59,80 @@ protected function updateOrderStatus(Order $order, string $status) return $order->updateStatus($status); } + /** + * The stores a multi-store (network) order was placed with: each store's own order, its + * progress, the items it is preparing and its subtotal. The customer's order is the one + * delivery that brings them together; this is its breakdown by store. Empty for an order + * from a single store. + */ + public function getStores(string $id) + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.'); + } + + $order = Order::where('public_id', $id)->whereNull('deleted_at')->first(); + if (!$order) { + return response()->apiError('No order found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + $related = array_values(array_filter((array) $order->getMeta('related_orders', []))); + if (!$order->getMeta('is_master_order') || empty($related)) { + return response()->json(['stores' => []]); + } + + $children = Order::whereIn('public_id', $related) + ->where('customer_uuid', $customer->uuid) + ->whereNull('deleted_at') + ->with(['payload.pickup', 'payload.entities']) + ->get() + ->sortBy(fn ($child) => array_search($child->public_id, $related)) + ->values(); + + $storeIds = $children->map(fn ($child) => $child->getMeta('storefront_id'))->filter()->unique()->values(); + $stores = Store::whereIn('public_id', $storeIds)->get()->keyBy('public_id'); + + $sections = $children->map(function (Order $child) use ($stores) { + $store = $stores->get($child->getMeta('storefront_id')); + $pickup = data_get($child, 'payload.pickup'); + $flow = OrderActivityFlow::forOrder($child); + $step = collect($flow['steps'] ?? [])->firstWhere('state', 'current') ?? collect($flow['steps'] ?? [])->last(); + + return [ + 'order' => $child->public_id, + 'status' => $child->status, + 'label' => data_get($step, 'label'), + 'store' => [ + 'id' => $store?->public_id ?? $child->getMeta('storefront_id'), + 'name' => $store?->name ?? $child->getMeta('storefront'), + 'logo_url' => $store?->logo_url, + 'phone' => $store?->phone, + 'address' => $pickup ? ($pickup->address ?? null) : null, + ], + 'items' => collect(data_get($child, 'payload.entities', []))->map(fn ($entity) => [ + 'id' => $entity->public_id, + 'name' => $entity->name, + 'quantity' => (int) (data_get($entity, 'meta.quantity') ?: 1), + 'subtotal' => (int) data_get($entity, 'meta.subtotal', 0), + 'variants' => data_get($entity, 'meta.variants', []), + 'addons' => data_get($entity, 'meta.addons', []), + 'image_url' => data_get($entity, 'meta.image_url') ?? $entity->photo_url ?? null, + ])->values(), + 'subtotal' => (int) $child->getMeta('subtotal', 0), + 'discount' => (int) $child->getMeta('discount', 0), + 'tip' => (int) $child->getMeta('tip', 0), + 'currency' => $child->getMeta('currency'), + ]; + })->values(); + + return response()->json(['stores' => $sections]); + } + /** * The customer's order as a line of steps from its own order config: where it has been, * where it is, and where it is expected to go (see OrderActivityFlow). diff --git a/server/src/routes.php b/server/src/routes.php index 779a3765..0f39a4d6 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -99,6 +99,7 @@ function ($router) { $router->put('picked-up', 'OrderController@completeOrderPickup'); $router->post('receipt', 'OrderController@getReceipt'); $router->get('{id}/activity-flow', 'OrderController@getActivityFlow'); + $router->get('{id}/stores', 'OrderController@getStores'); $router->get('{id}/chat', 'OrderChatController@show'); $router->get('{id}/chat/messages', 'OrderChatController@messages'); $router->post('{id}/chat/messages', 'OrderChatController@send'); From 8c5d6e84f4ca06ca571787c5eb6ba41d338dd432 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 20:12:14 +0800 Subject: [PATCH 35/50] feat(checkout): a multi-store order records the names of its stores --- server/src/Http/Controllers/v1/CheckoutController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 8a405d87..1276293c 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -1922,6 +1922,8 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $masterOrderMeta = [ 'is_master_order' => true, 'related_orders' => collect($multipleOrders)->pluck('public_id')->toArray(), + // the stores this order brings together, for lists that show it as one order + 'store_names' => $originStores->pluck('name')->filter()->unique()->values()->all(), 'storefront' => $about->name, 'storefront_id' => $about->public_id, 'storefront_network' => $about->name, From d72c44b5f62f9376c518bec914b5884107cd5e7f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 12:03:43 +0800 Subject: [PATCH 36/50] feat(food-trucks): networks show their member stores' trucks; a truck is only found by its own storefront --- .../Controllers/v1/FoodTruckController.php | 56 ++++++++++++++----- server/src/Http/Resources/FoodTruck.php | 8 +++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/server/src/Http/Controllers/v1/FoodTruckController.php b/server/src/Http/Controllers/v1/FoodTruckController.php index be35a803..4f07070b 100644 --- a/server/src/Http/Controllers/v1/FoodTruckController.php +++ b/server/src/Http/Controllers/v1/FoodTruckController.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Http\Resources\FoodTruck as FoodTruckResource; use Fleetbase\Storefront\Models\FoodTruck; +use Fleetbase\Storefront\Models\Network; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -17,25 +18,47 @@ class FoodTruckController extends Controller */ public function query(Request $request) { - $limit = $request->input('limit', false); - $offset = $request->input('offset', false); - $results = []; + $limit = $request->input('limit', false); + $offset = $request->input('offset', false); + $storeIds = static::storeUuidsForStorefront(); + if (empty($storeIds)) { + return FoodTruckResource::collection([]); + } + + $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset, $storeIds) { + $query->whereIn('store_uuid', $storeIds)->with(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + + if ($limit) { + $query->limit($limit); + } + + if ($offset) { + $query->offset($offset); + } + }); + return FoodTruckResource::collection($results); + } + + /** + * The stores whose food trucks this storefront shows: a store's own, or in a network + * every member store's, so a network can run trucks alongside its stores. + * + * @return array + */ + protected static function storeUuidsForStorefront(): array + { if (session('storefront_store')) { - $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset) { - $query->where('store_uuid', session('storefront_store')); + return [session('storefront_store')]; + } - if ($limit) { - $query->limit($limit); - } + if (session('storefront_network')) { + $network = Network::where('uuid', session('storefront_network'))->first(); - if ($offset) { - $query->offset($offset); - } - }); + return $network ? $network->stores()->pluck('stores.uuid')->all() : []; } - return FoodTruckResource::collection($results); + return []; } /** @@ -52,6 +75,13 @@ public function find($id) return response()->error('Food Truck resource not found.'); } + // Only a truck of this storefront (its own, or a network member store's). + if (!in_array($foodTruck->store_uuid, static::storeUuidsForStorefront(), true)) { + return response()->error('Food Truck resource not found.', 404); + } + + $foodTruck->loadMissing(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + // response the product resource return new FoodTruckResource($foodTruck); } diff --git a/server/src/Http/Resources/FoodTruck.php b/server/src/Http/Resources/FoodTruck.php index 942e3624..028be3e6 100644 --- a/server/src/Http/Resources/FoodTruck.php +++ b/server/src/Http/Resources/FoodTruck.php @@ -36,6 +36,14 @@ public function toArray($request) 'location' => $this->vehicle ? $this->vehicle->location : null, 'online' => $this->vehicle ? $this->vehicle->online : false, 'status' => $this->status, + // The store the truck belongs to, so a network can name it and order from it. + 'store' => $this->whenLoaded('store', fn () => $this->store ? [ + 'id' => $this->store->public_id, + 'name' => $this->store->name, + 'logo_url' => $this->store->logo_url, + 'currency' => $this->store->currency, + 'options' => $this->store->options, + ] : null), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; From 8911aba6768c7b135eec98db63e7334402613704 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 14:59:27 +0800 Subject: [PATCH 37/50] feat(seeders): Mongolian testing network with QPay sandbox gateways, Ulaanbaatar zones and food trucks --- server/seeders/Testing/MongoliaSeeder.php | 551 ++++++++++++++++++++++ 1 file changed, 551 insertions(+) create mode 100644 server/seeders/Testing/MongoliaSeeder.php diff --git a/server/seeders/Testing/MongoliaSeeder.php b/server/seeders/Testing/MongoliaSeeder.php new file mode 100644 index 00000000..bdf16d40 --- /dev/null +++ b/server/seeders/Testing/MongoliaSeeder.php @@ -0,0 +1,551 @@ +seedName()]; + } + + protected function seedCurrency(): string + { + return 'MNT'; + } + + protected function seedTimezone(): string + { + return 'Asia/Ulaanbaatar'; + } + + public function run(): void + { + $company = $this->prepareCompany(); + if (!$company) { + return; + } + + $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); + + $network = $this->createNetwork($company, $this->networkDefinition()); + $this->createMongoliaDeliveryRate($company); + $gateway = $this->createQPayGateway($company, $network, 'storefront:network', 'gateway:' . static::NETWORK_KEY . ':qpay'); + + $categories = []; + foreach ($this->networkCategories() as $categoryKey => $category) { + $categories[$categoryKey] = $this->createNetworkCategory($company, $network, static::NETWORK_KEY, $categoryKey, $category); + } + + $customers = $this->seedCustomers($company, $this->customerFixtures()); + $orderCount = 0; + $bundles = []; + + foreach ($this->storeDefinitions() as $definition) { + $bundle = $this->seedStore($company, $definition); + if (($definition['gateway'] ?? null) === 'qpay') { + $bundle['gateway'] = $this->createQPayGateway($company, $bundle['store'], 'storefront:store', 'gateway:' . $definition['key'] . ':qpay'); + } + + $this->addStoreToNetwork($network, $bundle['store'], $categories[$definition['network_category']] ?? null); + + $orders = $this->seedStoreActivity($company, $bundle, $customers, $network, $definition['order_count'] ?? 6); + $orderCount += count($orders); + $bundles[$definition['key']] = $bundle; + } + + [$serviceArea, $zones] = $this->seedServiceArea($company); + $trucks = $this->seedFoodTrucks($company, $bundles[static::TRUCK_STORE], $serviceArea, $zones); + + $this->command?->info(sprintf('Seeded the Mongolian testing network for company %s with %d stores, %d food trucks and %d orders.', $company->public_id, count($bundles), count($trucks), $orderCount)); + $this->reportStorefront($network, $gateway, ' '); + foreach ($bundles as $bundle) { + $this->reportStorefront($bundle['store'], $bundle['gateway'], ' '); + } + foreach ($trucks as $truck) { + $this->command?->line(sprintf(' Food truck %s "%s" zone=%s %s', $truck->public_id, $truck->vehicle?->name, $truck->zone?->name, $truck->vehicle?->online ? 'online' : 'offline')); + } + $this->command?->line(sprintf(' Customers sign in with %s (and the others in customerFixtures()).', $customers[0]->phone)); + } + + public function purgeSeedData(): void + { + $storeUuids = $this->seededUuids(Store::class); + $truckUuids = DB::connection($this->storefrontConnection())->table('food_trucks')->whereIn('store_uuid', $storeUuids)->pluck('uuid')->all(); + + $this->deleteFrom($this->storefrontConnection(), 'catalog_subjects', fn ($query) => $query->whereIn('subject_uuid', $truckUuids)); + $this->deleteFrom($this->storefrontConnection(), 'food_trucks', fn ($query) => $query->whereIn('uuid', $truckUuids)); + $this->purgeModel(Vehicle::class); + $this->purgeModel(Zone::class); + $this->purgeModel(ServiceArea::class); + + $this->purgeStorefrontFixtures(); + } + + /* + |-------------------------------------------------------------------------- + | Payments and delivery + |-------------------------------------------------------------------------- + */ + + /** + * A sandbox QPay gateway. Sandbox checkouts use QPay's TEST_INVOICE, so the invoice + * ids only matter for live gateways. + */ + protected function createQPayGateway(Company $company, Store|Network $owner, string $ownerType, string $seedId): Gateway + { + return $this->createRecord(Gateway::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'name' => 'QPay', + 'description' => 'Sandbox QPay gateway seeded for Storefront testing.', + 'code' => 'qpay', + 'type' => 'qpay', + 'sandbox' => true, + 'config' => [ + 'username' => env('SEED_QPAY_USERNAME', 'TEST_MERCHANT'), + 'password' => env('SEED_QPAY_PASSWORD', '123456'), + 'invoice_id' => env('SEED_QPAY_INVOICE_ID', 'TEST_INVOICE'), + 'ebarimt_invoice_id' => env('SEED_QPAY_EBARIMT_INVOICE_ID', 'TEST_INVOICE'), + ], + 'return_url' => null, + 'callback_url' => null, + 'meta' => $this->meta($seedId), + ]); + } + + /** + * Delivery in tögrög: 3,000₮ plus 500₮ per kilometre (amounts are in minor units). + */ + protected function createMongoliaDeliveryRate(Company $company): ServiceRate + { + $orderConfig = Storefront::getOrderConfig($company); + + return $this->createRecord(ServiceRate::class, [ + '_key' => $this->fixtureKey('service-rate:' . static::NETWORK_KEY), + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'order_config_uuid' => $orderConfig?->uuid, + 'service_name' => 'Улаанбаатар хүргэлт', + 'service_type' => data_get($orderConfig, 'key', 'storefront'), + 'rate_calculation_method' => 'per_meter', + 'base_fee' => 300000, + 'per_meter_flat_rate_fee' => 50000, + 'per_meter_unit' => 'km', + 'currency' => 'MNT', + 'duration_terms' => 'Нэг цагийн дотор хүргэнэ', + 'estimated_days' => 0, + 'has_cod_fee' => false, + ]); + } + + /* + |-------------------------------------------------------------------------- + | Service area, zones and food trucks + |-------------------------------------------------------------------------- + */ + + /** + * A closed ring of points from [south, west, north, east] bounds. + */ + protected function ring(array $bounds): LineString + { + [$south, $west, $north, $east] = $bounds; + + return new LineString([ + new Point($south, $west), + new Point($north, $west), + new Point($north, $east), + new Point($south, $east), + new Point($south, $west), + ]); + } + + /** + * @return array{0: ServiceArea, 1: array} + */ + protected function seedServiceArea(Company $company): array + { + $serviceArea = $this->createRecord(ServiceArea::class, [ + '_key' => $this->fixtureKey('service-area:ulaanbaatar'), + 'company_uuid' => $company->uuid, + 'name' => 'Улаанбаатар', + 'type' => 'city', + 'country' => 'MN', + 'border' => new MultiPolygon([new Polygon([$this->ring([47.80, 106.70, 48.00, 107.15])])]), + 'color' => '#2563eb33', + 'stroke_color' => '#2563eb', + 'status' => 'active', + ]); + + $zones = []; + foreach ($this->zoneDefinitions() as $zoneKey => $zone) { + $zones[$zoneKey] = $this->createRecord(Zone::class, [ + '_key' => $this->fixtureKey('zone:' . $zoneKey), + 'company_uuid' => $company->uuid, + 'service_area_uuid' => $serviceArea->uuid, + 'name' => $zone['name'], + 'description' => $zone['description'], + // Passed as a spatial expression: the zone's cast keeps a Polygon object + // out of the spatial insert, which then sends it as plain text. + 'border' => new SpatialExpression(new Polygon([$this->ring($zone['bounds'])])), + 'color' => $zone['color'] . '33', + 'stroke_color' => $zone['color'], + 'status' => 'active', + ]); + } + + return [$serviceArea, $zones]; + } + + protected function zoneDefinitions(): array + { + return [ + 'central' => ['name' => 'Төв', 'description' => 'Сүхбаатарын талбай, Энхтайвны өргөн чөлөө.', 'bounds' => [47.905, 106.880, 47.930, 106.940], 'color' => '#16a34a'], + 'zaisan' => ['name' => 'Зайсан', 'description' => 'Хан-Уул, Зайсангийн орчим.', 'bounds' => [47.865, 106.880, 47.903, 106.950], 'color' => '#ea580c'], + 'bayanzurkh' => ['name' => 'Баянзүрх', 'description' => 'Баянзүрх дүүрэг, зүүн хэсэг.', 'bounds' => [47.905, 106.942, 47.940, 107.010], 'color' => '#7c3aed'], + ]; + } + + /** + * @param array{store: Store, products: array} $bundle + * @param array $zones + * + * @return FoodTruck[] + */ + protected function seedFoodTrucks(Company $company, array $bundle, ServiceArea $serviceArea, array $zones): array + { + $store = $bundle['store']; + $trucks = []; + + foreach ($this->truckDefinitions() as $truckKey => $definition) { + $seedId = 'vehicle:' . $truckKey; + $vehicle = $this->createRecord(Vehicle::class, [ + 'company_uuid' => $company->uuid, + 'name' => $definition['name'], + 'make' => 'Hyundai', + 'model' => 'Porter II', + 'year' => '2021', + 'plate_number' => $definition['plate'], + 'type' => 'food_truck', + 'status' => 'active', + 'online' => $definition['online'], + 'location' => new Point($definition['position'][0], $definition['position'][1]), + 'meta' => $this->meta($seedId), + ]); + + $truck = $this->createRecord(FoodTruck::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'store_uuid' => $store->uuid, + 'vehicle_uuid' => $vehicle->uuid, + 'service_area_uuid' => $serviceArea->uuid, + 'zone_uuid' => $zones[$definition['zone']]->uuid, + 'status' => 'active', + ]); + + $catalog = $this->createCatalog($company, $store, static::TRUCK_STORE . '-' . $truckKey, $definition['catalog'], $bundle['products']); + $this->createRecord(CatalogSubject::class, [ + 'catalog_uuid' => $catalog->uuid, + 'subject_type' => FoodTruck::class, + 'subject_uuid' => $truck->uuid, + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + ]); + + $trucks[] = $truck->load(['vehicle', 'zone']); + } + + return $trucks; + } + + protected function truckDefinitions(): array + { + return [ + 'central' => [ + 'name' => 'Хаан Бууз — Төв', + 'plate' => '1234 УБА', + 'zone' => 'central', + 'online' => true, + 'position' => [47.9178, 106.9122], + 'catalog' => ['name' => 'Төв машины цэс', 'categories' => ['Бууз' => ['buuz', 'buuz-mix'], 'Хуушуур' => ['khuushuur'], 'Ундаа' => ['suutei-tsai', 'aaruul-shake']]], + ], + 'zaisan' => [ + 'name' => 'Хаан Бууз — Зайсан', + 'plate' => '5678 УБВ', + 'zone' => 'zaisan', + 'online' => true, + 'position' => [47.8860, 106.9170], + 'catalog' => ['name' => 'Зайсан машины цэс', 'categories' => ['Хуушуур' => ['khuushuur'], 'Шөл' => ['guriltai-shul'], 'Ундаа' => ['suutei-tsai']]], + ], + 'bayanzurkh' => [ + 'name' => 'Хаан Бууз — Баянзүрх', + 'plate' => '9012 УБГ', + 'zone' => 'bayanzurkh', + 'online' => false, + 'position' => [47.9215, 106.9710], + 'catalog' => ['name' => 'Баянзүрх машины цэс', 'categories' => ['Бууз' => ['buuz'], 'Ундаа' => ['suutei-tsai']]], + ], + ]; + } + + /* + |-------------------------------------------------------------------------- + | Customers + |-------------------------------------------------------------------------- + */ + + protected function customerFixtures(): array + { + return [ + ['Болд Батбаяр', 'bold.batbayar@example.test', '+976 9911 0001'], + ['Сарнай Ганбаатар', 'sarnai.ganbaatar@example.test', '+976 9911 0002'], + ['Тэмүүлэн Дорж', 'temuulen.dorj@example.test', '+976 9911 0003'], + ['Номин Энхбаяр', 'nomin.enkhbayar@example.test', '+976 9911 0004'], + ['Ариунаа Мөнх', 'ariunaa.munkh@example.test', '+976 9911 0005'], + ['Ганзориг Сүх', 'ganzorig.sukh@example.test', '+976 9911 0006'], + ]; + } + + /** + * Customer addresses are in the Central zone, so the Central truck delivers to them + * and the Zaisan truck doesn't. + */ + protected function customerPlace(Company $company, Contact $customer, int $index): Place + { + if (isset($this->customerPlaces[$customer->uuid])) { + return $this->customerPlaces[$customer->uuid]; + } + + $addresses = [ + ['Энхтайвны өргөн чөлөө 44', '14200', 47.9160, 106.9105], + ['Сөүлийн гудамж 21', '14251', 47.9132, 106.9150], + ['Бага тойруу 15', '14201', 47.9205, 106.9120], + ['Чингисийн өргөн чөлөө 9', '14240', 47.9110, 106.9205], + ['Жуулчны гудамж 5', '14192', 47.9150, 106.9260], + ]; + [$street, $postal, $lat, $lng] = $addresses[$index % count($addresses)]; + $seedId = 'customer-address:' . Str::slug($customer->name); + + return $this->customerPlaces[$customer->uuid] = $this->createRecord(Place::class, [ + '_key' => $this->fixtureKey($seedId), + 'company_uuid' => $company->uuid, + 'owner_uuid' => $customer->uuid, + 'owner_type' => FleetOpsUtils::getMutationType('fleet-ops:contact'), + 'name' => 'Гэр', + 'street1' => $street, + 'city' => 'Улаанбаатар', + 'postal_code' => $postal, + 'country' => 'MN', + 'location' => new Point($lat, $lng), + 'meta' => $this->meta($seedId), + ]); + } + + /* + |-------------------------------------------------------------------------- + | Network and stores + |-------------------------------------------------------------------------- + */ + + protected function networkDefinition(): array + { + return [ + 'key' => static::NETWORK_KEY, + 'name' => 'Улаанбаатар Маркет', + 'description' => 'Улаанбаатарын дэлгүүр, ресторан, үйлчилгээ нэг дор. Монгол хэл, төгрөг, QPay-г туршихад зориулсан.', + 'email' => 'market@example.mn', + 'phone' => '+976 7700 0100', + 'website' => 'https://example.mn/ulaanbaatar-market', + 'tags' => ['marketplace', 'testing', 'mongolia'], + 'currency' => 'MNT', + 'timezone' => 'Asia/Ulaanbaatar', + 'pod_method' => 'scan', + 'options' => [ + 'auto_accept_orders' => false, + 'auto_dispatch' => false, + 'require_pod' => false, + 'multi_cart_enabled' => true, + 'tips_enabled' => true, + 'reviews_enabled' => true, + 'pickup_enabled' => true, + ], + ]; + } + + protected function networkCategories(): array + { + return [ + 'groceries' => ['name' => 'Хүнсний дэлгүүр', 'description' => 'Супермаркет, шинэ хүнс.', 'icon' => 'basket-shopping', 'icon_color' => '#16a34a', 'tags' => ['food']], + 'restaurants' => ['name' => 'Ресторан', 'description' => 'Хүргэлт болон авч явах хоол.', 'icon' => 'utensils', 'icon_color' => '#ea580c', 'tags' => ['food', 'meals']], + 'services' => ['name' => 'Гоо сайхан', 'description' => 'Үсчин, гоо сайхны үйлчилгээ захиалах.', 'icon' => 'calendar-check', 'icon_color' => '#7c3aed', 'tags' => ['services']], + ]; + } + + protected function storeDefinitions(): array + { + $common = ['currency' => 'MNT', 'timezone' => 'Asia/Ulaanbaatar', 'pod_method' => 'scan']; + + return [ + $common + [ + 'key' => 'nomin-supermarket', + 'network_category' => 'groceries', + 'order_count' => 6, + 'name' => 'Номин Супермаркет', + 'description' => 'Энхтайвны өргөн чөлөөн дээрх супермаркет. Өдөр бүр хүргэнэ.', + 'email' => 'nomin@example.mn', + 'phone' => '+976 7700 0201', + 'tags' => ['groceries', 'supermarket'], + 'gateway' => 'qpay', + 'options' => ['pickup_enabled' => true], + 'location' => ['name' => 'Номин Супермаркет', 'street1' => 'Энхтайвны өргөн чөлөө 34', 'city' => 'Улаанбаатар', 'postal_code' => '14200', 'country' => 'MN', 'lat' => 47.9166, 'lng' => 106.9050], + 'hours' => ['start' => '08:00', 'end' => '23:00'], + 'categories' => [ + 'dairy' => ['name' => 'Сүү, сүүн бүтээгдэхүүн', 'description' => 'Сүү, тараг, ааруул.'], + 'meat' => ['name' => 'Мах', 'description' => 'Шинэ мах.'], + 'bakery' => ['name' => 'Талх, нарийн боов', 'description' => 'Өдөр бүр шинэ.'], + ], + 'addon_categories' => [ + 'bags' => ['name' => 'Савлагаа', 'description' => 'Уут сонгох.', 'max_selectable' => 1, 'is_required' => false, 'addons' => [['Цаасан уут', 'Дахин боловсруулдаг.', 20000], ['Хөргөгчтэй уут', 'Хүйтэн байлгана.', 150000]]], + ], + 'products' => [ + 'milk' => ['name' => 'Сүү 1л', 'description' => 'Пастержүүлсэн үнээний сүү.', 'price' => 380000, 'category' => 'dairy', 'tags' => ['dairy'], 'recommended' => true, 'addon_categories' => ['bags']], + 'tarag' => ['name' => 'Тараг 500г', 'description' => 'Уламжлалт исгэлэн тараг.', 'price' => 420000, 'sale_price' => 350000, 'category' => 'dairy', 'tags' => ['dairy']], + 'aaruul' => ['name' => 'Ааруул 250г', 'description' => 'Гэрийн хатаасан ааруул.', 'price' => 650000, 'category' => 'dairy', 'tags' => ['dairy']], + 'mutton' => ['name' => 'Хонины мах 1кг', 'description' => 'Шинэ хонины мах.', 'price' => 1800000, 'category' => 'meat', 'tags' => ['meat'], 'variants' => [['name' => 'Хэрчилт', 'required' => true, 'options' => [['Бүтэн', 0], ['Хэрчсэн', 100000]]]]], + 'beef' => ['name' => 'Үхрийн мах 1кг', 'description' => 'Ястай үхрийн мах.', 'price' => 2200000, 'category' => 'meat', 'tags' => ['meat']], + 'bread' => ['name' => 'Атар талх', 'description' => 'Өглөө бүр шинээр жигнэнэ.', 'price' => 250000, 'category' => 'bakery', 'tags' => ['bread'], 'recommended' => true], + 'boortsog' => ['name' => 'Боорцог 500г', 'description' => 'Шаржигнуур боорцог.', 'price' => 600000, 'category' => 'bakery', 'tags' => ['pastry']], + ], + 'catalog' => ['name' => 'Номин Супермаркетын каталог', 'categories' => ['Сүүн бүтээгдэхүүн' => ['milk', 'tarag', 'aaruul'], 'Мах' => ['mutton', 'beef'], 'Талх' => ['bread', 'boortsog']]], + ], + $common + [ + 'key' => static::TRUCK_STORE, + 'network_category' => 'restaurants', + 'order_count' => 6, + 'name' => 'Хаан Бууз', + 'description' => 'Гар хийцийн бууз, хуушуур. Ресторан болон хоолны машинууд.', + 'email' => 'khaanbuuz@example.mn', + 'phone' => '+976 7700 0202', + 'tags' => ['restaurant', 'buuz', 'food-truck'], + 'gateway' => 'qpay', + 'options' => ['auto_accept_orders' => true, 'pickup_enabled' => true], + 'location' => ['name' => 'Хаан Бууз', 'street1' => 'Бага тойруу 12', 'city' => 'Улаанбаатар', 'postal_code' => '14201', 'country' => 'MN', 'lat' => 47.9203, 'lng' => 106.9180], + 'hours' => ['start' => '09:00', 'end' => '22:00'], + 'categories' => [ + 'buuz' => ['name' => 'Бууз', 'description' => 'Уурын бууз.'], + 'khuushuur' => ['name' => 'Хуушуур', 'description' => 'Шарсан хуушуур.'], + 'soup' => ['name' => 'Шөл', 'description' => 'Халуун шөл.'], + 'drinks' => ['name' => 'Ундаа', 'description' => 'Цай, ундаа.'], + ], + 'addon_categories' => [ + 'sides' => ['name' => 'Нэмэлт', 'description' => 'Нэмэлт хачир.', 'max_selectable' => 2, 'is_required' => false, 'addons' => [['Даршилсан ногоо', 'Байцаа, лууван.', 150000], ['Кетчуп', 'Нэмэлт соус.', 0]]], + ], + 'products' => [ + 'buuz' => ['name' => 'Бууз (10ш)', 'description' => 'Хонины махтай уурын бууз.', 'price' => 1200000, 'category' => 'buuz', 'tags' => ['buuz'], 'recommended' => true, 'addon_categories' => ['sides']], + 'buuz-mix' => ['name' => 'Холимог бууз (10ш)', 'description' => 'Үхэр, хонины холимог махтай.', 'price' => 1300000, 'category' => 'buuz', 'tags' => ['buuz'], 'variants' => [['name' => 'Хэмжээ', 'required' => true, 'options' => [['10ш', 0], ['20ш', 1200000]]]]], + 'khuushuur' => ['name' => 'Хуушуур (4ш)', 'description' => 'Шинэхэн шарсан хуушуур.', 'price' => 1000000, 'sale_price' => 850000, 'category' => 'khuushuur', 'tags' => ['khuushuur'], 'recommended' => true, 'addon_categories' => ['sides']], + 'guriltai-shul' => ['name' => 'Гурилтай шөл', 'description' => 'Гар гурилтай махан шөл.', 'price' => 1100000, 'category' => 'soup', 'tags' => ['soup']], + 'suutei-tsai' => ['name' => 'Сүүтэй цай', 'description' => 'Халуун сүүтэй цай.', 'price' => 200000, 'category' => 'drinks', 'tags' => ['tea']], + 'aaruul-shake' => ['name' => 'Ааруулын коктейль', 'description' => 'Ааруул, сүү, зөгийн бал.', 'price' => 650000, 'category' => 'drinks', 'tags' => ['drink']], + ], + 'catalog' => ['name' => 'Хаан Буузын цэс', 'categories' => ['Бууз' => ['buuz', 'buuz-mix'], 'Хуушуур' => ['khuushuur'], 'Шөл' => ['guriltai-shul'], 'Ундаа' => ['suutei-tsai', 'aaruul-shake']]], + ], + $common + [ + 'key' => 'modern-salon', + 'network_category' => 'services', + 'order_count' => 4, + 'name' => 'Модерн Салон', + 'description' => 'Үс засалт, маникюр. Цагаа урьдчилан захиална уу.', + 'email' => 'salon@example.mn', + 'phone' => '+976 7700 0203', + 'tags' => ['services', 'beauty'], + 'gateway' => null, + 'location' => ['name' => 'Модерн Салон', 'street1' => 'Сөүлийн гудамж 18', 'city' => 'Улаанбаатар', 'postal_code' => '14251', 'country' => 'MN', 'lat' => 47.9128, 'lng' => 106.9162], + 'hours' => ['start' => '10:00', 'end' => '20:00', 'closed' => ['monday']], + 'categories' => [ + 'hair' => ['name' => 'Үс засалт', 'description' => 'Эрэгтэй, эмэгтэй үс засалт.'], + 'nails' => ['name' => 'Хумс', 'description' => 'Маникюр, педикюр.'], + ], + 'products' => [ + 'haircut' => ['name' => 'Эмэгтэй үс засалт', 'description' => 'Угаалт, засалт, хатаалт.', 'price' => 3500000, 'category' => 'hair', 'tags' => ['hair'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60], 'recommended' => true], + 'barber' => ['name' => 'Эрэгтэй үс засалт', 'description' => 'Сахал засалттай.', 'price' => 2500000, 'category' => 'hair', 'tags' => ['hair'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 45]], + 'manicure' => ['name' => 'Гель маникюр', 'description' => 'Өнгө сонгох боломжтой.', 'price' => 4000000, 'category' => 'nails', 'tags' => ['nails'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 90]], + ], + 'catalog' => ['name' => 'Модерн Салоны үйлчилгээ', 'categories' => ['Үс засалт' => ['haircut', 'barber'], 'Хумс' => ['manicure']]], + ], + ]; + } +} From 72c0b5cd6e71725b202425e353df35e5c28ec2cf Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 15:28:01 +0800 Subject: [PATCH 38/50] feat(seeders): Mongolian network and store promotions, segments and a campaign --- server/seeders/Testing/MongoliaSeeder.php | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/server/seeders/Testing/MongoliaSeeder.php b/server/seeders/Testing/MongoliaSeeder.php index bdf16d40..cc4c93a5 100644 --- a/server/seeders/Testing/MongoliaSeeder.php +++ b/server/seeders/Testing/MongoliaSeeder.php @@ -124,10 +124,20 @@ public function run(): void $bundles[$definition['key']] = $bundle; } + $marketing = $this->seedMarketing($company, $network, static::NETWORK_KEY, $this->marketingDefinition(), $bundles); + foreach ($this->storeMarketingDefinitions() as $storeKey => $definition) { + if (isset($bundles[$storeKey])) { + foreach ($this->seedMarketing($company, $bundles[$storeKey]['store'], $storeKey, $definition, $bundles) as $kind => $records) { + $marketing[$kind] = array_merge($marketing[$kind], array_values($records)); + } + } + } + [$serviceArea, $zones] = $this->seedServiceArea($company); $trucks = $this->seedFoodTrucks($company, $bundles[static::TRUCK_STORE], $serviceArea, $zones); $this->command?->info(sprintf('Seeded the Mongolian testing network for company %s with %d stores, %d food trucks and %d orders.', $company->public_id, count($bundles), count($trucks), $orderCount)); + $this->command?->info(sprintf(' Marketing: %d promotions, %d segments, %d campaigns.', count($marketing['promotions']), count($marketing['segments']), count($marketing['campaigns']))); $this->reportStorefront($network, $gateway, ' '); foreach ($bundles as $bundle) { $this->reportStorefront($bundle['store'], $bundle['gateway'], ' '); @@ -414,6 +424,53 @@ protected function customerPlace(Company $company, Contact $customer, int $index ]); } + /* + |-------------------------------------------------------------------------- + | Marketing (amounts in minor units) + |-------------------------------------------------------------------------- + */ + + protected function marketingDefinition(): array + { + return [ + 'promotions' => [ + 'welcome' => ['name' => 'Тавтай морил', 'description' => 'Анхны захиалгадаа 15% хөнгөлөлт, дээд тал нь 10,000₮.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 1000000, 'min_subtotal' => 2000000, 'first_order_only' => true, 'code' => 'SAIN15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], + 'free-delivery' => ['name' => '50,000₮-өөс дээш үнэгүй хүргэлт', 'description' => '50,000₮ ба түүнээс дээш захиалгад хүргэлт үнэгүй.', 'type' => 'free_delivery', 'min_subtotal' => 5000000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], + 'weekend' => ['name' => 'Амралтын өдрийн 5,000₮', 'description' => 'Бямба, ням гарагт 30,000₮-өөс дээш захиалгад 5,000₮ хасна.', 'type' => 'fixed_amount', 'value' => 500000, 'min_subtotal' => 3000000, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], + 'upcoming' => ['name' => 'Цагаан сарын урамшуулал', 'description' => 'Удахгүй: бүх бүтээгдэхүүнд 10%.', 'type' => 'percentage', 'value' => 10, 'starts_in_days' => 5, 'ends_in_days' => 20], + ], + 'segments' => [ + 'loyal' => ['name' => 'Байнгын үйлчлүүлэгч', 'description' => 'Гурав ба түүнээс дээш захиалга.', 'rules' => ['min_orders' => 3]], + 'never' => ['name' => 'Захиалга хийгээгүй', 'description' => 'Бүртгүүлсэн ч захиалаагүй.', 'rules' => ['max_orders' => 0]], + ], + 'campaigns' => [ + 'welcome-sent' => ['name' => 'Тавтай морил', 'title' => 'Анхны захиалгадаа 15%', 'body' => 'SAIN15 кодыг ашиглаарай.', 'status' => 'sent', 'segment' => 'never', 'promotion' => 'welcome', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 0, 'batches' => 0]], + ], + ]; + } + + protected function storeMarketingDefinitions(): array + { + return [ + static::TRUCK_STORE => [ + 'promotions' => [ + 'buuz-bogo' => ['name' => '2 бууз авбал 1 үнэгүй', 'description' => 'Ямар ч буузны багцад.', 'type' => 'bogo', 'bogo_config' => ['buy_quantity' => 2, 'get_quantity' => 1, 'discount_percent' => 100], 'applies_to' => ['products' => [static::TRUCK_STORE . ':buuz', static::TRUCK_STORE . ':buuz-mix']], 'starts_in_days' => -5, 'ends_in_days' => 25], + 'lunch' => ['name' => 'Өдрийн хоолны цаг', 'description' => 'Ажлын өдөр 12-14 цагт 20% хөнгөлөлт.', 'type' => 'percentage', 'value' => 20, 'schedule' => [['days' => [1, 2, 3, 4, 5], 'start' => '12:00', 'end' => '14:00']], 'starts_in_days' => -10, 'ends_in_days' => 50], + ], + ], + 'nomin-supermarket' => [ + 'promotions' => [ + 'dairy' => ['name' => 'Сүүн бүтээгдэхүүн 10%', 'description' => 'Сүү, тараг, ааруулд 10% хөнгөлөлт.', 'type' => 'percentage', 'value' => 10, 'applies_to' => ['categories' => ['nomin-supermarket:dairy']], 'starts_in_days' => -3, 'ends_in_days' => 27], + ], + ], + 'modern-salon' => [ + 'promotions' => [ + 'first-visit' => ['name' => 'Анхны үйлчилгээнд 10,000₮', 'description' => 'Шинэ үйлчлүүлэгчдэд, 30,000₮-өөс дээш.', 'type' => 'fixed_amount', 'value' => 1000000, 'min_subtotal' => 3000000, 'first_order_only' => true, 'code' => 'SALON10', 'starts_in_days' => -20, 'ends_in_days' => 70], + ], + ], + ]; + } + /* |-------------------------------------------------------------------------- | Network and stores From 1c1e0b98a9eb8a77edbb58dcb7805ef7a59ba0d5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 16:40:44 +0800 Subject: [PATCH 39/50] fix(seeders): re-seeding keeps product, vehicle and food truck ids, so carts left open in an app keep working --- .../Concerns/SeedsStorefrontFixtures.php | 13 ++++++----- server/seeders/Testing/MongoliaSeeder.php | 22 +++++++++++++++++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index 0b468718..0e2bc61b 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -82,9 +82,10 @@ trait SeedsStorefrontFixtures protected array $customerPlaces = []; /** - * uuid, public id and key of the stores and networks this seeder created before, by - * class and seed id, so re-seeding keeps them. Apps are built against a storefront key, - * so a new key would need an app rebuild. + * uuid, public id and key of the stores, networks, products and vehicles this seeder + * created before, by class and seed id, so re-seeding keeps them. Apps are built against + * a storefront key, so a new key would need an app rebuild, and carts left open in an + * app point at products by id. * * @var array> */ @@ -189,7 +190,8 @@ protected function rememberSeededIdentities(): void { $this->seededIdentities = []; - foreach ([Store::class => 'meta', Network::class => 'options'] as $modelClass => $column) { + // Products and vehicles too: open carts point at products, and food trucks at vehicles. + foreach ([Store::class => 'meta', Network::class => 'options', Product::class => 'meta', \Fleetbase\FleetOps\Models\Vehicle::class => 'meta'] as $modelClass => $column) { foreach ($this->seededQuery($modelClass)->get() as $model) { $tag = (array) $model->{$column}; $seedId = $tag['seed_id'] ?? null; @@ -508,7 +510,8 @@ protected function createProduct(Company $company, Store $store, string $storeKe { $salePrice = (int) ($product['sale_price'] ?? 0); - return $this->createRecord(Product::class, [ + // Same id as last time, so carts left open in an app still find the product. + return $this->createWithSeededIdentity(Product::class, 'product:' . $storeKey . ':' . $productKey, [ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'store_uuid' => $store->uuid, diff --git a/server/seeders/Testing/MongoliaSeeder.php b/server/seeders/Testing/MongoliaSeeder.php index cc4c93a5..06ebfebf 100644 --- a/server/seeders/Testing/MongoliaSeeder.php +++ b/server/seeders/Testing/MongoliaSeeder.php @@ -153,13 +153,31 @@ public function purgeSeedData(): void $storeUuids = $this->seededUuids(Store::class); $truckUuids = DB::connection($this->storefrontConnection())->table('food_trucks')->whereIn('store_uuid', $storeUuids)->pluck('uuid')->all(); + // Food trucks have no tag of their own: remember each by its vehicle's seed id, so the + // truck keeps its id and carts holding it still find their delivery origin. + $truckIdentities = []; + $vehicleSeeds = $this->seededQuery(Vehicle::class)->get(['uuid', 'meta'])->mapWithKeys(fn ($vehicle) => [$vehicle->uuid => data_get($vehicle, 'meta.seed_id')]); + foreach (DB::connection($this->storefrontConnection())->table('food_trucks')->whereIn('uuid', $truckUuids)->get(['uuid', 'public_id', 'vehicle_uuid']) as $truck) { + $vehicleSeed = $vehicleSeeds[$truck->vehicle_uuid] ?? null; + if ($vehicleSeed) { + $truckIdentities['food-truck:' . Str::after($vehicleSeed, 'vehicle:')] = ['uuid' => $truck->uuid, 'public_id' => $truck->public_id, 'key' => null]; + } + } + $this->deleteFrom($this->storefrontConnection(), 'catalog_subjects', fn ($query) => $query->whereIn('subject_uuid', $truckUuids)); $this->deleteFrom($this->storefrontConnection(), 'food_trucks', fn ($query) => $query->whereIn('uuid', $truckUuids)); + + // Vehicles are purged here, before purgeStorefrontFixtures() remembers identities + // again, so remember theirs first and put them back afterwards. + $this->rememberSeededIdentities(); + $vehicleIdentities = $this->seededIdentities[Vehicle::class] ?? []; $this->purgeModel(Vehicle::class); $this->purgeModel(Zone::class); $this->purgeModel(ServiceArea::class); $this->purgeStorefrontFixtures(); + $this->seededIdentities[Vehicle::class] = $vehicleIdentities; + $this->seededIdentities[FoodTruck::class] = $truckIdentities; } /* @@ -302,7 +320,7 @@ protected function seedFoodTrucks(Company $company, array $bundle, ServiceArea $ foreach ($this->truckDefinitions() as $truckKey => $definition) { $seedId = 'vehicle:' . $truckKey; - $vehicle = $this->createRecord(Vehicle::class, [ + $vehicle = $this->createWithSeededIdentity(Vehicle::class, $seedId, [ 'company_uuid' => $company->uuid, 'name' => $definition['name'], 'make' => 'Hyundai', @@ -316,7 +334,7 @@ protected function seedFoodTrucks(Company $company, array $bundle, ServiceArea $ 'meta' => $this->meta($seedId), ]); - $truck = $this->createRecord(FoodTruck::class, [ + $truck = $this->createWithSeededIdentity(FoodTruck::class, 'food-truck:' . $truckKey, [ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'store_uuid' => $store->uuid, From 99aa860eb82783b6b2480db6cafbabd66f6e1e6c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 16:51:14 +0800 Subject: [PATCH 40/50] fix(quotes): carts with lines that can no longer be ordered get a cart_items_unavailable error naming them; seeders keep customer ids and their app accounts --- .../Concerns/SeedsStorefrontFixtures.php | 9 ++- .../Controllers/v1/ServiceQuoteController.php | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index 0e2bc61b..58d48a2b 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -191,7 +191,8 @@ protected function rememberSeededIdentities(): void $this->seededIdentities = []; // Products and vehicles too: open carts point at products, and food trucks at vehicles. - foreach ([Store::class => 'meta', Network::class => 'options', Product::class => 'meta', \Fleetbase\FleetOps\Models\Vehicle::class => 'meta'] as $modelClass => $column) { + // Customers too, with the app account they signed in with, so a signed-in app keeps its customer. + foreach ([Store::class => 'meta', Network::class => 'options', Product::class => 'meta', \Fleetbase\FleetOps\Models\Vehicle::class => 'meta', Contact::class => 'meta'] as $modelClass => $column) { foreach ($this->seededQuery($modelClass)->get() as $model) { $tag = (array) $model->{$column}; $seedId = $tag['seed_id'] ?? null; @@ -203,6 +204,7 @@ protected function rememberSeededIdentities(): void 'uuid' => $model->uuid, 'public_id' => $model->public_id, 'key' => $model->key, + 'user_uuid' => $modelClass === Contact::class ? $model->user_uuid : null, ]; } } @@ -231,7 +233,7 @@ protected function createWithSeededIdentity(string $modelClass, string $seedId, $model = $this->createRecord($modelClass, $attributes); if ($previous) { - $identity = array_filter(['public_id' => $previous['public_id'], 'key' => $previous['key']]); + $identity = array_filter(['public_id' => $previous['public_id'], 'key' => $previous['key'] ?? null, 'user_uuid' => $previous['user_uuid'] ?? null]); if ($identity) { $model->getConnection()->table($model->getTable())->where('uuid', $model->uuid)->update($identity); $model->forceFill($identity)->syncOriginal(); @@ -902,7 +904,8 @@ protected function createCustomer(Company $company, string $name, string $email, { $seedId = 'customer:' . Str::slug($name); - return $this->createRecord(Contact::class, [ + // Same id (and app account) as last time, so a customer signed in to an app stays signed in. + return $this->createWithSeededIdentity(Contact::class, $seedId, [ '_key' => $this->fixtureKey($seedId), 'company_uuid' => $company->uuid, 'name' => $name, diff --git a/server/src/Http/Controllers/v1/ServiceQuoteController.php b/server/src/Http/Controllers/v1/ServiceQuoteController.php index 4e791897..fd9857d5 100644 --- a/server/src/Http/Controllers/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/v1/ServiceQuoteController.php @@ -50,6 +50,11 @@ public function fromCart(GetServiceQuoteFromCart $request) $all = $request->boolean('all'); $isRouteOptimized = $request->boolean('is_route_optimized', true); + // Lines whose product or food truck is gone can't be quoted (or ordered). + if ($unavailable = $this->unavailableCartItems($cart, $request->input('origin'))) { + return $unavailable; + } + if (!$origin) { return response()->error('No delivery origin!'); } @@ -203,6 +208,11 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) $all = $request->boolean('all'); $isRouteOptimized = $request->boolean('is_route_optimized', true); + // Lines whose product, store or food truck is gone can't be quoted (or ordered). + if ($unavailable = $this->unavailableCartItems($cart, $request->input('origin'))) { + return $unavailable; + } + // make sure destination is set if (!$destination) { return response()->error('No delivery destination!'); @@ -471,6 +481,58 @@ protected function getServiceRates(Place $destination, string $orderConfigKey, ? }); } + /** + * A 422 naming the cart lines that can no longer be ordered, or null when every line + * is fine: the product was deleted or unpublished, or the line (or the requested + * origin) points at a food truck or store location that no longer exists. Apps show + * "some items are no longer available" instead of an address problem. + */ + protected function unavailableCartItems(?Cart $cart, string|array|null $origin = null) + { + $items = collect($cart?->items ?? []); + if ($items->isEmpty()) { + return null; + } + + $productIds = $items->pluck('product_id')->filter()->unique()->values(); + $products = Product::whereIn('public_id', $productIds)->get(['public_id', 'is_available', 'status'])->keyBy('public_id'); + $truckIds = $items->pluck('food_truck_id')->filter()->unique()->values(); + $origins = collect(is_array($origin) ? $origin : explode(',', (string) $origin))->filter(); + $truckIds = $truckIds->merge($origins->filter(fn ($id) => Str::startsWith($id, 'food_truck_')))->unique()->values(); + $trucks = $truckIds->isEmpty() ? collect() : FoodTruck::whereIn('public_id', $truckIds)->pluck('public_id'); + $locationIds = $items->pluck('store_location_id')->filter(fn ($id) => Str::startsWith((string) $id, 'store_location'))->unique()->values(); + $locations = $locationIds->isEmpty() ? collect() : StoreLocation::whereIn('public_id', $locationIds)->pluck('public_id'); + $missingTruck = $origins->first(fn ($id) => Str::startsWith($id, 'food_truck_') && !$trucks->contains($id)); + + $unavailable = $items + ->filter(function ($item) use ($products, $trucks, $locations, $missingTruck) { + $product = $products->get(data_get($item, 'product_id')); + if (!$product || $product->is_available === false || $product->status === 'draft') { + return true; + } + $truckId = data_get($item, 'food_truck_id'); + if (($truckId && !$trucks->contains($truckId)) || ($missingTruck && $truckId === $missingTruck)) { + return true; + } + $locationId = data_get($item, 'store_location_id'); + + return Str::startsWith((string) $locationId, 'store_location') && !$locations->contains($locationId); + }) + ->map(fn ($item) => ['id' => data_get($item, 'id'), 'product_id' => data_get($item, 'product_id'), 'name' => data_get($item, 'name')]) + ->values(); + + // A missing truck the app asked to deliver from, even if no line names it. + if ($unavailable->isEmpty() && !$missingTruck) { + return null; + } + + return response()->json([ + 'errors' => ['Some items in your cart are no longer available.'], + 'code' => 'cart_items_unavailable', + 'items' => $unavailable->all(), + ], 422); + } + /** * Returns a place from either a place id or store location id. */ From ea4d524a5ddac96a99a26f72b2c6cfc634177aa9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 17:46:08 +0800 Subject: [PATCH 41/50] fix(qpay): retry an invoice without the receiver email when QPay rejects only that; Mongolian test customers use emails QPay accepts --- server/seeders/Testing/MongoliaSeeder.php | 16 ++++++++++------ .../Http/Controllers/v1/CheckoutController.php | 8 ++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/seeders/Testing/MongoliaSeeder.php b/server/seeders/Testing/MongoliaSeeder.php index 06ebfebf..742d2ceb 100644 --- a/server/seeders/Testing/MongoliaSeeder.php +++ b/server/seeders/Testing/MongoliaSeeder.php @@ -395,15 +395,19 @@ protected function truckDefinitions(): array |-------------------------------------------------------------------------- */ + /** + * Emails on a real-looking domain: QPay rejects the whole invoice for addresses it + * doesn't accept, such as the reserved `.test` domain. + */ protected function customerFixtures(): array { return [ - ['Болд Батбаяр', 'bold.batbayar@example.test', '+976 9911 0001'], - ['Сарнай Ганбаатар', 'sarnai.ganbaatar@example.test', '+976 9911 0002'], - ['Тэмүүлэн Дорж', 'temuulen.dorj@example.test', '+976 9911 0003'], - ['Номин Энхбаяр', 'nomin.enkhbayar@example.test', '+976 9911 0004'], - ['Ариунаа Мөнх', 'ariunaa.munkh@example.test', '+976 9911 0005'], - ['Ганзориг Сүх', 'ganzorig.sukh@example.test', '+976 9911 0006'], + ['Болд Батбаяр', 'bold.batbayar@example.mn', '+976 9911 0001'], + ['Сарнай Ганбаатар', 'sarnai.ganbaatar@example.mn', '+976 9911 0002'], + ['Тэмүүлэн Дорж', 'temuulen.dorj@example.mn', '+976 9911 0003'], + ['Номин Энхбаяр', 'nomin.enkhbayar@example.mn', '+976 9911 0004'], + ['Ариунаа Мөнх', 'ariunaa.munkh@example.mn', '+976 9911 0005'], + ['Ганзориг Сүх', 'ganzorig.sukh@example.mn', '+976 9911 0006'], ]; } diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 1276293c..931ccf1e 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -851,6 +851,14 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa $invoice = null; if ($ebarimtInvoiceCode) { $invoice = $qpay->createEbarimtInvoice($ebarimtInvoiceCode, $senderInvoiceNo, $invoiceReceiverCode, $invoiceReceiverData, $invoiceDescription, $taxType, $districtCode, $lines); + + // The receiver's email is optional, but QPay rejects the whole invoice when it + // doesn't accept the address (e.g. an unusual domain). Try again without it so + // the customer can still pay. + if (data_get($invoice, 'error.email') && isset($invoiceReceiverData['email'])) { + unset($invoiceReceiverData['email']); + $invoice = $qpay->createEbarimtInvoice($ebarimtInvoiceCode, $senderInvoiceNo, $invoiceReceiverCode, $invoiceReceiverData, $invoiceDescription, $taxType, $districtCode, $lines); + } } else { $invoice = $qpay->createSimpleInvoice($invoiceAmount, $invoiceCode, $invoiceDescription, $invoiceReceiverCode, $senderInvoiceNo); } From e234ca1f7364c893aea75875cd8231647ca45232 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 17:53:47 +0800 Subject: [PATCH 42/50] revert(qpay): drop the retry-without-email added in ea4d524 --- server/src/Http/Controllers/v1/CheckoutController.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 931ccf1e..1276293c 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -851,14 +851,6 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa $invoice = null; if ($ebarimtInvoiceCode) { $invoice = $qpay->createEbarimtInvoice($ebarimtInvoiceCode, $senderInvoiceNo, $invoiceReceiverCode, $invoiceReceiverData, $invoiceDescription, $taxType, $districtCode, $lines); - - // The receiver's email is optional, but QPay rejects the whole invoice when it - // doesn't accept the address (e.g. an unusual domain). Try again without it so - // the customer can still pay. - if (data_get($invoice, 'error.email') && isset($invoiceReceiverData['email'])) { - unset($invoiceReceiverData['email']); - $invoice = $qpay->createEbarimtInvoice($ebarimtInvoiceCode, $senderInvoiceNo, $invoiceReceiverCode, $invoiceReceiverData, $invoiceDescription, $taxType, $districtCode, $lines); - } } else { $invoice = $qpay->createSimpleInvoice($invoiceAmount, $invoiceCode, $invoiceDescription, $invoiceReceiverCode, $senderInvoiceNo); } From 59b7a6dd42c77d28147337e9b1f4b112584b695d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 18:03:09 +0800 Subject: [PATCH 43/50] fix(qpay): follow QPay API v2: paid status and amount before ordering, SUCCESS callback reply, clean invoice codes, token reuse Checked against QPay's API v2 documentation (with e-barimt 3.0): - Payments: an order is created only for a payment/check row with payment_status PAID whose paid_amount covers the invoice amount recorded when the invoice was created. NEW, FAILED, PARTIAL, REFUNDED and underpaid payments create nothing, in the callback and in the status check alike. - Callback: QPay's call is answered with HTTP 200 and the body SUCCESS, the only format QPay allows. `respond=1` still returns JSON for manual checks. - sender_invoice_no and invoice_receiver_code contain letters and digits only (no special characters): the checkout id and the customer's id. invoice_receiver_code was the fixed value CITIZEN; the e-barimt receiver type is sent separately with ebarimt_v3/create. - Sandbox e-barimt invoices use QPay's TEST_EB_INVOICE code. - district_code: still taken from the gateway settings; a live e-barimt invoice without one is logged as a warning. - Access tokens are reused per merchant and environment until five minutes before expiry (expires_in is an absolute UNIX timestamp), instead of a new token per call. A token QPay refuses (401) is dropped and the request retried once with a new one. --- .../Controllers/v1/CheckoutController.php | 134 +++++++++++++----- server/src/Support/QPay.php | 95 +++++++++++-- 2 files changed, 185 insertions(+), 44 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 1276293c..562e6d80 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -805,13 +805,24 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa // Create invoice description $taxType = '1'; // Start with VAT required - $ebarimtInvoiceCode = $gateway->sandbox ? 'TEST_INVOICE' : $gateway->config?->ebarimt_invoice_id ?? null; + // Sandbox e-barimt invoices use QPay's TEST_EB_INVOICE code (QPay API v2, invoice_create_ebarimt). + $ebarimtInvoiceCode = $gateway->sandbox ? 'TEST_EB_INVOICE' : $gateway->config?->ebarimt_invoice_id ?? null; $invoiceAmount = $amount; $invoiceCode = $gateway->sandbox ? 'TEST_INVOICE' : $gateway->config?->invoice_id ?? null; $invoiceDescription = $about->name . ' cart checkout'; - $invoiceReceiverCode = 'CITIZEN'; - $senderInvoiceNo = $checkout->public_id; + // The customer's own unique code (QPay: "unique number of the customer receiving the + // invoice"). The e-barimt receiver type is sent separately, with ebarimt_v3/create. + $invoiceReceiverCode = static::qpayCode($customer->public_id); + // Unique per checkout, without special characters (QPay: sender_invoice_no). + $senderInvoiceNo = static::qpayCode($checkout->public_id); $districtCode = $gateway->config?->district_code ?? null; + // QPay requires district_code on e-barimt invoices (QPay API v2, invoice_create_ebarimt): + // the 4-digit code of where the business operates (district + sub-district, see the + // district_code list), set in the gateway settings. QPay's sandbox accepts invoices + // without it, so a live gateway missing it is reported rather than guessed. + if ($ebarimtInvoiceCode && !$districtCode && !$gateway->sandbox) { + Log::warning('[QPAY]: e-barimt invoice without a district code; set district_code in the QPay gateway settings', ['gateway' => $gateway->public_id]); + } $invoiceReceiverData = Utils::filterArray([ 'register' => $ebarimtRegistationNumber, 'name' => $customer->name, @@ -855,8 +866,12 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa $invoice = $qpay->createSimpleInvoice($invoiceAmount, $invoiceCode, $invoiceDescription, $invoiceReceiverCode, $senderInvoiceNo); } - // Update checkout with invoice id + // Update checkout with invoice id, and the amount the invoice asks for so a payment + // can be checked against it before the order is created. $checkout->updateOption('qpay_invoice_id', data_get($invoice, 'invoice_id')); + if (data_get($invoice, 'invoice_id')) { + $checkout->updateOption('qpay_invoice_amount', $ebarimtInvoiceCode ? QPay::linesTotal($lines) : (float) $invoiceAmount); + } return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'invoice' => $invoice, @@ -897,8 +912,13 @@ public function captureQPayCallback(Request $request) $shouldRespond = $request->boolean('respond'); $testScenario = $request->input('test'); // Expected: 'success' or 'error' + // QPay calls this URL (GET) when a payment is made and requires the reply to be + // HTTP 200 with the body SUCCESS, in no other format. `respond=1` (manual checks) + // gets the details as JSON instead. + $reply = fn (array $data) => $shouldRespond ? response()->json($data) : response('SUCCESS', 200)->header('Content-Type', 'text/plain'); + if (!$checkoutId) { - return response()->json([ + return $reply([ 'error' => 'CHECKOUT_ID_MISSING', 'checkout' => null, 'payment' => null, @@ -907,7 +927,7 @@ public function captureQPayCallback(Request $request) $checkout = Checkout::where('public_id', $checkoutId)->first(); if (!$checkout) { - return response()->json([ + return $reply([ 'error' => 'CHECKOUT_SESSION_NOT_FOUND', 'checkout' => null, 'payment' => null, @@ -916,7 +936,7 @@ public function captureQPayCallback(Request $request) $gateway = Gateway::where('uuid', $checkout->gateway_uuid)->first(); if (!$gateway) { - return response()->json([ + return $reply([ 'error' => 'GATEWAY_NOT_CONFIGURED', 'checkout' => $checkout->public_id, 'payment' => null, @@ -943,7 +963,7 @@ public function captureQPayCallback(Request $request) static::publishCheckoutUpdate($checkout, $testScenario === 'success', $data['error']); - return $shouldRespond ? response()->json($data) : response()->json(); + return $reply($data); } // Create the QPay instance. @@ -959,45 +979,42 @@ public function captureQPayCallback(Request $request) if (!$invoiceId) { Log::error("Missing QPay invoice ID for checkout: {$checkout->public_id}"); - return response()->json([ + return $reply([ 'error' => 'MISSING_INVOICE_ID', 'checkout' => $checkout->public_id, 'payment' => null, ]); } + // The order is created only for a payment QPay reports as PAID, covering the + // invoice amount. NEW, FAILED, PARTIAL and REFUNDED payments create nothing. $paymentCheck = $qpay->paymentCheck($invoiceId); - if (!$paymentCheck || empty($paymentCheck->count) || $paymentCheck->count < 1) { - return response()->json([ - 'error' => 'PAYMENT_NOTFOUND', + $payment = static::paidQPayPayment($paymentCheck, $checkout); + if (!$payment) { + return $reply([ + 'error' => 'PAYMENT_NOT_PAID', 'checkout' => $checkout->public_id, 'payment' => null, ]); } - $payment = data_get($paymentCheck, 'rows.0'); - - if ($payment) { - // Create order from payment using reusable gateway-agnostic method - $transactionDetails = [ - 'transaction_id' => $payment->payment_id, - 'payment_status' => 'PAID', - 'payment_wallet' => $payment->payment_wallet ?? 'QPay', - ]; - - $this->createOrderFromCheckout($checkout, $transactionDetails); - $checkout->refresh(); + // Create order from payment using reusable gateway-agnostic method + $transactionDetails = [ + 'transaction_id' => $payment->payment_id, + 'payment_status' => $payment->payment_status, + 'payment_wallet' => $payment->payment_wallet ?? 'QPay', + ]; - $data = [ - 'checkout' => $checkout->public_id, - 'payment' => (array) $payment, - 'error' => null, - ]; + $this->createOrderFromCheckout($checkout, $transactionDetails); + $checkout->refresh(); - static::publishCheckoutUpdate($checkout, true); + static::publishCheckoutUpdate($checkout, true); - return $shouldRespond ? response()->json($data) : response()->json(); - } + return $reply([ + 'checkout' => $checkout->public_id, + 'payment' => (array) $payment, + 'error' => null, + ]); } catch (\Exception $e) { Log::error('[QPAY CHECKOUT ERROR]: ' . $e->getMessage(), ['checkout' => $checkout->toArray()]); if ($shouldRespond) { @@ -1005,7 +1022,53 @@ public function captureQPayCallback(Request $request) } } - return response()->json(); + return $reply(['checkout' => $checkout->public_id, 'payment' => null, 'error' => null]); + } + + /** + * The payment that pays a checkout's QPay invoice, or null. + * + * QPay's payment/check returns `count`, `paid_amount` and `rows`, each row with a + * `payment_status` of NEW, FAILED, PAID, PARTIAL or REFUNDED. A checkout is paid only + * by a PAID row, and only when the amount paid covers the invoice amount recorded when + * the invoice was created (older checkouts without it are checked by status only). + */ + protected static function paidQPayPayment($paymentCheck, Checkout $checkout): ?object + { + $rows = collect(data_get($paymentCheck, 'rows', [])); + $paid = $rows->first(fn ($row) => data_get($row, 'payment_status') === 'PAID'); + if (!$paid) { + return null; + } + + $expected = $checkout->getOption('qpay_invoice_amount'); + if ($expected !== null) { + $paidAmount = data_get($paymentCheck, 'paid_amount'); + if ($paidAmount === null) { + $paidAmount = $rows->filter(fn ($row) => data_get($row, 'payment_status') === 'PAID')->sum(fn ($row) => (float) data_get($row, 'payment_amount', 0)); + } + // Amounts are decimals in the invoice currency; allow for rounding only. + if ((float) $paidAmount + 0.01 < (float) $expected) { + Log::warning('[QPAY]: payment is less than the invoice amount; no order created', [ + 'checkout' => $checkout->public_id, + 'paid_amount' => $paidAmount, + 'expected' => $expected, + ]); + + return null; + } + } + + return (object) $paid; + } + + /** + * A value QPay accepts where it doesn't allow special characters (sender_invoice_no, + * invoice_receiver_code): letters and digits only, at most 45 characters. + */ + protected static function qpayCode(?string $value): string + { + return substr(preg_replace('/[^A-Za-z0-9]/', '', (string) $value), 0, 45); } /** @@ -2101,10 +2164,11 @@ public function getCheckoutStatus(Request $request) // Verify payment status with QPay $paymentCheck = $qpay->paymentCheck($qpayInvoiceId); - $payment = data_get($paymentCheck, 'rows.0'); + // Only a PAID payment covering the invoice amount (see paidQPayPayment) + $payment = static::paidQPayPayment($paymentCheck, $checkout); } - if ($payment && $payment->payment_status === 'PAID') { + if ($payment) { $response['status'] = 'paid'; $response['payment'] = [ 'payment_id' => $payment->payment_id, diff --git a/server/src/Support/QPay.php b/server/src/Support/QPay.php index bb38d4b9..dcf15789 100644 --- a/server/src/Support/QPay.php +++ b/server/src/Support/QPay.php @@ -8,6 +8,7 @@ use Fleetbase\Storefront\Models\Product; use Fleetbase\Support\Utils; use GuzzleHttp\Client; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; /** @@ -42,6 +43,23 @@ class QPay */ private array $requestOptions = []; + /** + * Merchant credentials (client id and secret), for requesting access tokens. + */ + private ?string $username = null; + + private ?string $password = null; + + /** + * The cache key of the access token in use, when it came from the cache. + */ + private ?string $cachedTokenKey = null; + + /** + * Seconds before QPay's expiry at which a cached token is no longer used. + */ + private const TOKEN_EXPIRY_MARGIN = 300; + /** * The Guzzle HTTP client instance. */ @@ -124,6 +142,8 @@ public function __construct(?string $username = null, ?string $password = null, // checkout id, so callers must provide a full callback URL via setCallback() // or the constructor for payments to be captured $this->callbackUrl = $callbackUrl ?? static::callbackUrl(); + $this->username = $username; + $this->password = $password; $this->requestOptions = [ 'base_uri' => $this->buildRequestUrl(), 'auth' => [$username, $password], @@ -251,6 +271,16 @@ private function request(string $method, string $path, array $options = []) $options['http_errors'] = false; $response = $this->client->request($method, $path, $options); + + // A cached token QPay no longer accepts (e.g. revoked early): forget it, request a + // new one and try once more. + if ($response->getStatusCode() === 401 && $this->cachedTokenKey && $path !== 'auth/token') { + Cache::forget($this->cachedTokenKey); + $this->cachedTokenKey = null; + $this->mintAuthToken(); + $response = $this->client->request($method, $path, $options); + } + $body = $response->getBody(); $contents = $body->getContents(); $json = json_decode($contents); @@ -352,21 +382,68 @@ public function setAuthToken(?string $accessToken = null): QPay return $this; } - // Always mint a fresh token. QPay returns `expires_in` as an absolute UNIX - // timestamp, not a lifetime in seconds, so caching keyed off it cached the token - // for decades and every call kept sending a token QPay had already expired — - // answering NO_CREDENTIALS with perfectly valid credentials. Reuse is worth one - // round trip only if the real expiry is honoured; until then, correctness wins. - $response = $this->getAuthToken(); - $token = data_get($response, 'access_token'); + // QPay asks merchants not to request tokens again and again before they expire. + // A token is reused, per merchant and environment, until shortly before its expiry. + // `expires_in` is the expiry as an absolute UNIX timestamp (QPay API v2, token), not a + // lifetime in seconds; treating it as seconds once cached tokens for decades. + $key = $this->tokenCacheKey(); + $cached = Cache::get($key); + if (is_array($cached) && !empty($cached['token']) && (int) ($cached['expires_at'] ?? 0) - self::TOKEN_EXPIRY_MARGIN > time()) { + $this->cachedTokenKey = $key; + $this->useBearerToken($cached['token']); - if ($token) { - $this->useBearerToken($token); + return $this; + } + + return $this->mintAuthToken(); + } + + /** + * Request a new access token with the merchant credentials and keep it until shortly + * before it expires. + */ + private function mintAuthToken(): QPay + { + // The token request authenticates with the credentials (Basic), not a bearer token. + $headers = $this->requestOptions['headers'] ?? []; + unset($headers['Authorization']); + $this->requestOptions['headers'] = $headers; + $this->updateRequestOption('auth', [$this->username, $this->password]); + + $response = $this->getAuthToken(); + $token = data_get($response, 'access_token'); + $expiresAt = (int) data_get($response, 'expires_in', 0); + + if (!$token) { + return $this; + } + + $this->useBearerToken($token); + + $ttl = $expiresAt - time() - self::TOKEN_EXPIRY_MARGIN; + if ($ttl > 0) { + $key = $this->tokenCacheKey(); + Cache::put($key, ['token' => $token, 'expires_at' => $expiresAt], $ttl); + $this->cachedTokenKey = $key; } return $this; } + private function tokenCacheKey(): string + { + return 'storefront:qpay:token:' . sha1($this->host . '|' . $this->username . '|' . $this->password); + } + + /** + * The total a set of invoice lines asks for: quantity times unit price, summed + * (unit prices include VAT; a discount is a negative line). + */ + public static function linesTotal(array $lines): float + { + return round(array_reduce($lines, fn ($total, $line) => $total + (float) data_get($line, 'line_quantity', 0) * (float) data_get($line, 'line_unit_price', 0), 0.0), 2); + } + /** * Create a simple invoice without eBarimt (electronic receipt). * From 7145ab0cc65207a1aa14168d3dd5828959d6ea35 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 18:17:54 +0800 Subject: [PATCH 44/50] =?UTF-8?q?fix(seeders):=20Mongolian=20amounts=20in?= =?UTF-8?q?=20whole=20t=C3=B6gr=C3=B6g,=20a=201,000=E2=82=AE=20test=20item?= =?UTF-8?q?,=20and=20QPay=20gateway=20settings=20from=20the=20Console=20ke?= =?UTF-8?q?pt=20on=20re-seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/seeders/Testing/MongoliaSeeder.php | 75 +++++++++++++---------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/server/seeders/Testing/MongoliaSeeder.php b/server/seeders/Testing/MongoliaSeeder.php index 742d2ceb..2729fc0e 100644 --- a/server/seeders/Testing/MongoliaSeeder.php +++ b/server/seeders/Testing/MongoliaSeeder.php @@ -42,7 +42,8 @@ * - live in the Zaisan zone (a different zone, so it can't deliver to the customers), * - offline in the Bayanzürkh zone. * - * QPay uses the public sandbox merchant unless SEED_QPAY_* are set. Customers have + * QPay uses the public sandbox merchant unless SEED_QPAY_* are set; settings entered in the + * Console (e.g. a live merchant for payment tests) are kept when re-seeding. Customers have * Mongolian phone numbers and addresses in the Central zone. * * Re-running the seeder purges and recreates its own fixtures only, keeping the network and @@ -60,6 +61,9 @@ class MongoliaSeeder extends Seeder use SeedsStorefrontFixtures; public const NETWORK_KEY = 'ulaanbaatar-market'; + + /** QPay gateway settings from the previous run, by seed id. */ + protected array $qpayGateways = []; public const TRUCK_STORE = 'khaan-buuz'; /** Central Ulaanbaatar: Sükhbaatar Square. */ @@ -153,6 +157,13 @@ public function purgeSeedData(): void $storeUuids = $this->seededUuids(Store::class); $truckUuids = DB::connection($this->storefrontConnection())->table('food_trucks')->whereIn('store_uuid', $storeUuids)->pluck('uuid')->all(); + // QPay settings entered in the Console (e.g. a live merchant for payment tests) are + // kept: a re-seed must never swap them back to the sandbox test merchant. + $this->qpayGateways = $this->seededQuery(Gateway::class)->get(['meta', 'config', 'sandbox']) + ->filter(fn ($gateway) => data_get($gateway, 'meta.seed') === $this->seedName() && data_get($gateway, 'meta.seed_id')) + ->mapWithKeys(fn ($gateway) => [data_get($gateway, 'meta.seed_id') => ['config' => (array) $gateway->config, 'sandbox' => (bool) $gateway->sandbox]]) + ->all(); + // Food trucks have no tag of their own: remember each by its vehicle's seed id, so the // truck keeps its id and carts holding it still find their delivery origin. $truckIdentities = []; @@ -201,12 +212,12 @@ protected function createQPayGateway(Company $company, Store|Network $owner, str 'description' => 'Sandbox QPay gateway seeded for Storefront testing.', 'code' => 'qpay', 'type' => 'qpay', - 'sandbox' => true, - 'config' => [ + 'sandbox' => $this->qpayGateways[$seedId]['sandbox'] ?? true, + 'config' => $this->qpayGateways[$seedId]['config'] ?? [ 'username' => env('SEED_QPAY_USERNAME', 'TEST_MERCHANT'), 'password' => env('SEED_QPAY_PASSWORD', '123456'), 'invoice_id' => env('SEED_QPAY_INVOICE_ID', 'TEST_INVOICE'), - 'ebarimt_invoice_id' => env('SEED_QPAY_EBARIMT_INVOICE_ID', 'TEST_INVOICE'), + 'ebarimt_invoice_id' => env('SEED_QPAY_EBARIMT_INVOICE_ID', 'TEST_EB_INVOICE'), ], 'return_url' => null, 'callback_url' => null, @@ -215,7 +226,7 @@ protected function createQPayGateway(Company $company, Store|Network $owner, str } /** - * Delivery in tögrög: 3,000₮ plus 500₮ per kilometre (amounts are in minor units). + * Delivery in tögrög: 3,000₮ plus 500₮ per kilometre. MNT has no minor unit: every amount in this seeder is whole tögrög, as the app and QPay use it. */ protected function createMongoliaDeliveryRate(Company $company): ServiceRate { @@ -229,8 +240,8 @@ protected function createMongoliaDeliveryRate(Company $company): ServiceRate 'service_name' => 'Улаанбаатар хүргэлт', 'service_type' => data_get($orderConfig, 'key', 'storefront'), 'rate_calculation_method' => 'per_meter', - 'base_fee' => 300000, - 'per_meter_flat_rate_fee' => 50000, + 'base_fee' => 3000, + 'per_meter_flat_rate_fee' => 500, 'per_meter_unit' => 'km', 'currency' => 'MNT', 'duration_terms' => 'Нэг цагийн дотор хүргэнэ', @@ -368,7 +379,7 @@ protected function truckDefinitions(): array 'zone' => 'central', 'online' => true, 'position' => [47.9178, 106.9122], - 'catalog' => ['name' => 'Төв машины цэс', 'categories' => ['Бууз' => ['buuz', 'buuz-mix'], 'Хуушуур' => ['khuushuur'], 'Ундаа' => ['suutei-tsai', 'aaruul-shake']]], + 'catalog' => ['name' => 'Төв машины цэс', 'categories' => ['Бууз' => ['buuz', 'buuz-mix'], 'Хуушуур' => ['khuushuur'], 'Ундаа' => ['suutei-tsai', 'aaruul-shake', 'test-item']]], ], 'zaisan' => [ 'name' => 'Хаан Бууз — Зайсан', @@ -456,9 +467,9 @@ protected function marketingDefinition(): array { return [ 'promotions' => [ - 'welcome' => ['name' => 'Тавтай морил', 'description' => 'Анхны захиалгадаа 15% хөнгөлөлт, дээд тал нь 10,000₮.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 1000000, 'min_subtotal' => 2000000, 'first_order_only' => true, 'code' => 'SAIN15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], - 'free-delivery' => ['name' => '50,000₮-өөс дээш үнэгүй хүргэлт', 'description' => '50,000₮ ба түүнээс дээш захиалгад хүргэлт үнэгүй.', 'type' => 'free_delivery', 'min_subtotal' => 5000000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], - 'weekend' => ['name' => 'Амралтын өдрийн 5,000₮', 'description' => 'Бямба, ням гарагт 30,000₮-өөс дээш захиалгад 5,000₮ хасна.', 'type' => 'fixed_amount', 'value' => 500000, 'min_subtotal' => 3000000, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], + 'welcome' => ['name' => 'Тавтай морил', 'description' => 'Анхны захиалгадаа 15% хөнгөлөлт, дээд тал нь 10,000₮.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 10000, 'min_subtotal' => 20000, 'first_order_only' => true, 'code' => 'SAIN15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], + 'free-delivery' => ['name' => '50,000₮-өөс дээш үнэгүй хүргэлт', 'description' => '50,000₮ ба түүнээс дээш захиалгад хүргэлт үнэгүй.', 'type' => 'free_delivery', 'min_subtotal' => 50000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], + 'weekend' => ['name' => 'Амралтын өдрийн 5,000₮', 'description' => 'Бямба, ням гарагт 30,000₮-өөс дээш захиалгад 5,000₮ хасна.', 'type' => 'fixed_amount', 'value' => 5000, 'min_subtotal' => 30000, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], 'upcoming' => ['name' => 'Цагаан сарын урамшуулал', 'description' => 'Удахгүй: бүх бүтээгдэхүүнд 10%.', 'type' => 'percentage', 'value' => 10, 'starts_in_days' => 5, 'ends_in_days' => 20], ], 'segments' => [ @@ -487,7 +498,7 @@ protected function storeMarketingDefinitions(): array ], 'modern-salon' => [ 'promotions' => [ - 'first-visit' => ['name' => 'Анхны үйлчилгээнд 10,000₮', 'description' => 'Шинэ үйлчлүүлэгчдэд, 30,000₮-өөс дээш.', 'type' => 'fixed_amount', 'value' => 1000000, 'min_subtotal' => 3000000, 'first_order_only' => true, 'code' => 'SALON10', 'starts_in_days' => -20, 'ends_in_days' => 70], + 'first-visit' => ['name' => 'Анхны үйлчилгээнд 10,000₮', 'description' => 'Шинэ үйлчлүүлэгчдэд, 30,000₮-өөс дээш.', 'type' => 'fixed_amount', 'value' => 10000, 'min_subtotal' => 30000, 'first_order_only' => true, 'code' => 'SALON10', 'starts_in_days' => -20, 'ends_in_days' => 70], ], ], ]; @@ -557,16 +568,16 @@ protected function storeDefinitions(): array 'bakery' => ['name' => 'Талх, нарийн боов', 'description' => 'Өдөр бүр шинэ.'], ], 'addon_categories' => [ - 'bags' => ['name' => 'Савлагаа', 'description' => 'Уут сонгох.', 'max_selectable' => 1, 'is_required' => false, 'addons' => [['Цаасан уут', 'Дахин боловсруулдаг.', 20000], ['Хөргөгчтэй уут', 'Хүйтэн байлгана.', 150000]]], + 'bags' => ['name' => 'Савлагаа', 'description' => 'Уут сонгох.', 'max_selectable' => 1, 'is_required' => false, 'addons' => [['Цаасан уут', 'Дахин боловсруулдаг.', 200], ['Хөргөгчтэй уут', 'Хүйтэн байлгана.', 1500]]], ], 'products' => [ - 'milk' => ['name' => 'Сүү 1л', 'description' => 'Пастержүүлсэн үнээний сүү.', 'price' => 380000, 'category' => 'dairy', 'tags' => ['dairy'], 'recommended' => true, 'addon_categories' => ['bags']], - 'tarag' => ['name' => 'Тараг 500г', 'description' => 'Уламжлалт исгэлэн тараг.', 'price' => 420000, 'sale_price' => 350000, 'category' => 'dairy', 'tags' => ['dairy']], - 'aaruul' => ['name' => 'Ааруул 250г', 'description' => 'Гэрийн хатаасан ааруул.', 'price' => 650000, 'category' => 'dairy', 'tags' => ['dairy']], - 'mutton' => ['name' => 'Хонины мах 1кг', 'description' => 'Шинэ хонины мах.', 'price' => 1800000, 'category' => 'meat', 'tags' => ['meat'], 'variants' => [['name' => 'Хэрчилт', 'required' => true, 'options' => [['Бүтэн', 0], ['Хэрчсэн', 100000]]]]], - 'beef' => ['name' => 'Үхрийн мах 1кг', 'description' => 'Ястай үхрийн мах.', 'price' => 2200000, 'category' => 'meat', 'tags' => ['meat']], - 'bread' => ['name' => 'Атар талх', 'description' => 'Өглөө бүр шинээр жигнэнэ.', 'price' => 250000, 'category' => 'bakery', 'tags' => ['bread'], 'recommended' => true], - 'boortsog' => ['name' => 'Боорцог 500г', 'description' => 'Шаржигнуур боорцог.', 'price' => 600000, 'category' => 'bakery', 'tags' => ['pastry']], + 'milk' => ['name' => 'Сүү 1л', 'description' => 'Пастержүүлсэн үнээний сүү.', 'price' => 3800, 'category' => 'dairy', 'tags' => ['dairy'], 'recommended' => true, 'addon_categories' => ['bags']], + 'tarag' => ['name' => 'Тараг 500г', 'description' => 'Уламжлалт исгэлэн тараг.', 'price' => 4200, 'sale_price' => 3500, 'category' => 'dairy', 'tags' => ['dairy']], + 'aaruul' => ['name' => 'Ааруул 250г', 'description' => 'Гэрийн хатаасан ааруул.', 'price' => 6500, 'category' => 'dairy', 'tags' => ['dairy']], + 'mutton' => ['name' => 'Хонины мах 1кг', 'description' => 'Шинэ хонины мах.', 'price' => 18000, 'category' => 'meat', 'tags' => ['meat'], 'variants' => [['name' => 'Хэрчилт', 'required' => true, 'options' => [['Бүтэн', 0], ['Хэрчсэн', 1000]]]]], + 'beef' => ['name' => 'Үхрийн мах 1кг', 'description' => 'Ястай үхрийн мах.', 'price' => 22000, 'category' => 'meat', 'tags' => ['meat']], + 'bread' => ['name' => 'Атар талх', 'description' => 'Өглөө бүр шинээр жигнэнэ.', 'price' => 2500, 'category' => 'bakery', 'tags' => ['bread'], 'recommended' => true], + 'boortsog' => ['name' => 'Боорцог 500г', 'description' => 'Шаржигнуур боорцог.', 'price' => 6000, 'category' => 'bakery', 'tags' => ['pastry']], ], 'catalog' => ['name' => 'Номин Супермаркетын каталог', 'categories' => ['Сүүн бүтээгдэхүүн' => ['milk', 'tarag', 'aaruul'], 'Мах' => ['mutton', 'beef'], 'Талх' => ['bread', 'boortsog']]], ], @@ -590,17 +601,19 @@ protected function storeDefinitions(): array 'drinks' => ['name' => 'Ундаа', 'description' => 'Цай, ундаа.'], ], 'addon_categories' => [ - 'sides' => ['name' => 'Нэмэлт', 'description' => 'Нэмэлт хачир.', 'max_selectable' => 2, 'is_required' => false, 'addons' => [['Даршилсан ногоо', 'Байцаа, лууван.', 150000], ['Кетчуп', 'Нэмэлт соус.', 0]]], + 'sides' => ['name' => 'Нэмэлт', 'description' => 'Нэмэлт хачир.', 'max_selectable' => 2, 'is_required' => false, 'addons' => [['Даршилсан ногоо', 'Байцаа, лууван.', 1500], ['Кетчуп', 'Нэмэлт соус.', 0]]], ], 'products' => [ - 'buuz' => ['name' => 'Бууз (10ш)', 'description' => 'Хонины махтай уурын бууз.', 'price' => 1200000, 'category' => 'buuz', 'tags' => ['buuz'], 'recommended' => true, 'addon_categories' => ['sides']], - 'buuz-mix' => ['name' => 'Холимог бууз (10ш)', 'description' => 'Үхэр, хонины холимог махтай.', 'price' => 1300000, 'category' => 'buuz', 'tags' => ['buuz'], 'variants' => [['name' => 'Хэмжээ', 'required' => true, 'options' => [['10ш', 0], ['20ш', 1200000]]]]], - 'khuushuur' => ['name' => 'Хуушуур (4ш)', 'description' => 'Шинэхэн шарсан хуушуур.', 'price' => 1000000, 'sale_price' => 850000, 'category' => 'khuushuur', 'tags' => ['khuushuur'], 'recommended' => true, 'addon_categories' => ['sides']], - 'guriltai-shul' => ['name' => 'Гурилтай шөл', 'description' => 'Гар гурилтай махан шөл.', 'price' => 1100000, 'category' => 'soup', 'tags' => ['soup']], - 'suutei-tsai' => ['name' => 'Сүүтэй цай', 'description' => 'Халуун сүүтэй цай.', 'price' => 200000, 'category' => 'drinks', 'tags' => ['tea']], - 'aaruul-shake' => ['name' => 'Ааруулын коктейль', 'description' => 'Ааруул, сүү, зөгийн бал.', 'price' => 650000, 'category' => 'drinks', 'tags' => ['drink']], + 'buuz' => ['name' => 'Бууз (10ш)', 'description' => 'Хонины махтай уурын бууз.', 'price' => 12000, 'category' => 'buuz', 'tags' => ['buuz'], 'recommended' => true, 'addon_categories' => ['sides']], + 'buuz-mix' => ['name' => 'Холимог бууз (10ш)', 'description' => 'Үхэр, хонины холимог махтай.', 'price' => 13000, 'category' => 'buuz', 'tags' => ['buuz'], 'variants' => [['name' => 'Хэмжээ', 'required' => true, 'options' => [['10ш', 0], ['20ш', 12000]]]]], + 'khuushuur' => ['name' => 'Хуушуур (4ш)', 'description' => 'Шинэхэн шарсан хуушуур.', 'price' => 10000, 'sale_price' => 8500, 'category' => 'khuushuur', 'tags' => ['khuushuur'], 'recommended' => true, 'addon_categories' => ['sides']], + 'guriltai-shul' => ['name' => 'Гурилтай шөл', 'description' => 'Гар гурилтай махан шөл.', 'price' => 11000, 'category' => 'soup', 'tags' => ['soup']], + 'suutei-tsai' => ['name' => 'Сүүтэй цай', 'description' => 'Халуун сүүтэй цай.', 'price' => 2000, 'category' => 'drinks', 'tags' => ['tea']], + // A 1,000₮ item for end-to-end payment tests on a live QPay merchant. + 'test-item' => ['name' => 'Туршилтын бараа', 'description' => 'Төлбөрийн туршилтад зориулсан 1,000₮-ийн бараа.', 'price' => 1000, 'category' => 'drinks', 'tags' => ['test']], + 'aaruul-shake' => ['name' => 'Ааруулын коктейль', 'description' => 'Ааруул, сүү, зөгийн бал.', 'price' => 6500, 'category' => 'drinks', 'tags' => ['drink']], ], - 'catalog' => ['name' => 'Хаан Буузын цэс', 'categories' => ['Бууз' => ['buuz', 'buuz-mix'], 'Хуушуур' => ['khuushuur'], 'Шөл' => ['guriltai-shul'], 'Ундаа' => ['suutei-tsai', 'aaruul-shake']]], + 'catalog' => ['name' => 'Хаан Буузын цэс', 'categories' => ['Бууз' => ['buuz', 'buuz-mix'], 'Хуушуур' => ['khuushuur'], 'Шөл' => ['guriltai-shul'], 'Ундаа' => ['suutei-tsai', 'aaruul-shake', 'test-item']]], ], $common + [ 'key' => 'modern-salon', @@ -619,9 +632,9 @@ protected function storeDefinitions(): array 'nails' => ['name' => 'Хумс', 'description' => 'Маникюр, педикюр.'], ], 'products' => [ - 'haircut' => ['name' => 'Эмэгтэй үс засалт', 'description' => 'Угаалт, засалт, хатаалт.', 'price' => 3500000, 'category' => 'hair', 'tags' => ['hair'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60], 'recommended' => true], - 'barber' => ['name' => 'Эрэгтэй үс засалт', 'description' => 'Сахал засалттай.', 'price' => 2500000, 'category' => 'hair', 'tags' => ['hair'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 45]], - 'manicure' => ['name' => 'Гель маникюр', 'description' => 'Өнгө сонгох боломжтой.', 'price' => 4000000, 'category' => 'nails', 'tags' => ['nails'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 90]], + 'haircut' => ['name' => 'Эмэгтэй үс засалт', 'description' => 'Угаалт, засалт, хатаалт.', 'price' => 35000, 'category' => 'hair', 'tags' => ['hair'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60], 'recommended' => true], + 'barber' => ['name' => 'Эрэгтэй үс засалт', 'description' => 'Сахал засалттай.', 'price' => 25000, 'category' => 'hair', 'tags' => ['hair'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 45]], + 'manicure' => ['name' => 'Гель маникюр', 'description' => 'Өнгө сонгох боломжтой.', 'price' => 40000, 'category' => 'nails', 'tags' => ['nails'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 90]], ], 'catalog' => ['name' => 'Модерн Салоны үйлчилгээ', 'categories' => ['Үс засалт' => ['haircut', 'barber'], 'Хумс' => ['manicure']]], ], From a49efed5bc3033ec7b3d3a1d97f37280b03f0bb3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 18:34:28 +0800 Subject: [PATCH 45/50] =?UTF-8?q?fix(qpay):=20exact=20VAT=20amounts;=20flo?= =?UTF-8?q?ating=20point=20truncated=20e.g.=203190=20=E2=86=92=20289.9999?= =?UTF-8?q?=20instead=20of=20290=20(VAT=5FAMOUNT=5FINVALID)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/Support/QPay.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/server/src/Support/QPay.php b/server/src/Support/QPay.php index dcf15789..69f419ce 100644 --- a/server/src/Support/QPay.php +++ b/server/src/Support/QPay.php @@ -712,19 +712,27 @@ public static function mockEbarimtResponse(): array /** * Calculate VAT (Value Added Tax) from a total amount. * - * Assumes 10% VAT rate is included in the amount. Calculates the VAT portion - * by dividing by 1.1 and multiplying by 0.10, then truncates to 4 decimal places. + * Assumes 10% VAT rate is included in the amount: VAT is the amount divided by 11, + * truncated to 4 decimal places, as in QPay's e-barimt examples (50.00 → 4.5454, + * 100.00 → 9.0909, 1000.00 → 90.909, 2000.00 → 181.8181). * - * @param float|int $amount The total amount including VAT + * Computed in whole units of 0.0001 with integers. Floating point gave e.g. + * 3190 / 1.1 * 0.1 = 289.99999…, truncated to 289.9999 instead of 290, which QPay + * rejects with VAT_AMOUNT_INVALID. + * + * @param float|int|string $amount The total amount including VAT (up to 2 decimals) * * @return float The calculated VAT amount truncated to 4 decimal places */ public static function calculateTax($amount): float { - $result = ((float) $amount / 1.1) * 0.10; - $truncated = floor($result * 10000) / 10000; + // Amounts carry at most 2 decimals, so cents are exact once rounded. + $cents = (int) round((float) $amount * 100); + $negative = $cents < 0; + // VAT in ten-thousandths: cents × 100 / 11, truncated toward zero. + $vat = intdiv(abs($cents) * 100, 11); - return $truncated; + return ($negative ? -$vat : $vat) / 10000; } /** From 02ea7322e1c960d9d577fe86efd0a953230d6a49 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 18:58:14 +0800 Subject: [PATCH 46/50] fix(checkout): QPay's callback creates the order; the app hears about the payment at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Order creation (createOrderFromCheckout → captureOrder) read the storefront from the request, and QPay's callback carries no storefront key, so captureOrder stopped at "No storefront in request" and no order was created; orders only appeared when the app's status check made them. The checkout's own storefront (its network, else its store) is now set the way SetStorefrontSession does for the storefront API. - Checkout's company/order/owner/serviceQuote relations called $this->setConnection(), switching the checkout itself to the Fleetbase database, so a later refresh failed ("Table 'fleetbase.checkouts' doesn't exist"). The related models choose their database themselves. - The callback publishes "paid" to the checkout channel as soon as QPay confirms the payment, then the order once created. - The status endpoint answers with the order when it exists and only asks QPay while it doesn't (QPay: no repeated payment checks). --- .../Controllers/v1/CheckoutController.php | 34 +++++++++++++++++-- server/src/Models/Checkout.php | 16 ++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 562e6d80..87b92f29 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -18,6 +18,7 @@ use Fleetbase\Storefront\Http\Requests\InitializeCheckoutRequest; use Fleetbase\Storefront\Models\Cart; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Http\Middleware\SetStorefrontSession; use Fleetbase\Storefront\Models\Customer; use Fleetbase\Storefront\Models\FoodTruck; use Fleetbase\Storefront\Models\Gateway; @@ -998,6 +999,10 @@ public function captureQPayCallback(Request $request) ]); } + // Tell the app at once that the payment is confirmed; creating the order takes a + // moment, and a second update follows with the order. + static::publishCheckoutUpdate($checkout, true); + // Create order from payment using reusable gateway-agnostic method $transactionDetails = [ 'transaction_id' => $payment->payment_id, @@ -1025,6 +1030,24 @@ public function captureQPayCallback(Request $request) return $reply(['checkout' => $checkout->public_id, 'payment' => null, 'error' => null]); } + /** + * Make the checkout's storefront (its network, else its store) the storefront of the + * current request, exactly as SetStorefrontSession does for the storefront API. Order + * creation reads the storefront from there, and requests from a payment provider (QPay's + * callback) arrive without one. Always the checkout's own, so an order is never created + * under another storefront's settings. + */ + protected static function useCheckoutStorefront(Checkout $checkout): void + { + $owner = $checkout->network_uuid + ? Network::select(['key'])->where('uuid', $checkout->network_uuid)->first() + : ($checkout->store_uuid ? Store::select(['key'])->where('uuid', $checkout->store_uuid)->first() : null); + + if ($owner && $owner->key) { + app(SetStorefrontSession::class)->setKey($owner->key); + } + } + /** * The payment that pays a checkout's QPay invoice, or null. * @@ -1113,6 +1136,11 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note 'transaction_id' => $transactionDetails['transaction_id'] ?? null, ]); + // captureOrder() works within the storefront of the request. QPay's callback + // carries no storefront key, so set the checkout's own storefront the way the + // storefront API does for app requests. + static::useCheckoutStorefront($checkout); + // Create CaptureOrderRequest with payment details $captureRequest = CaptureOrderRequest::create('', 'POST', [ 'token' => $checkout->token, @@ -2143,8 +2171,10 @@ public function getCheckoutStatus(Request $request) 'order' => $checkout->order ? new OrderResource($checkout->order) : null, ]; - // Check if this is a QPay checkout - if ($checkout->gateway_uuid) { + // Check if this is a QPay checkout. Once the order exists it is the answer: + // QPay asks merchants not to check payments over and over (payment_check), so + // it is only asked while the order is still missing. + if ($checkout->gateway_uuid && !$response['order']) { $gateway = Gateway::where('uuid', $checkout->gateway_uuid)->first(); if ($gateway && $gateway->code === 'qpay') { diff --git a/server/src/Models/Checkout.php b/server/src/Models/Checkout.php index 16ef909b..97a0af7a 100644 --- a/server/src/Models/Checkout.php +++ b/server/src/Models/Checkout.php @@ -75,12 +75,20 @@ public static function boot() }); } + /* + * The related models (Company, Order, Contact, ServiceQuote) choose the Fleetbase + * database themselves (Fleetbase\Models\Model::getConnectionName()). These relations + * used to call $this->setConnection(...), which switched the checkout itself to that + * database, so any later refresh or save of the checkout failed (checkouts lives in + * the storefront database) — e.g. after a QPay payment, before the order was created. + */ + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function company() { - return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(Company::class); + return $this->belongsTo(Company::class); } /** @@ -88,7 +96,7 @@ public function company() */ public function order() { - return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(Order::class); + return $this->belongsTo(Order::class); } /** @@ -96,7 +104,7 @@ public function order() */ public function owner() { - return $this->setConnection(config('fleetbase.connection.db'))->morphTo(__FUNCTION__, 'owner_type', 'owner_uuid')->withoutGlobalScopes(); + return $this->morphTo(__FUNCTION__, 'owner_type', 'owner_uuid')->withoutGlobalScopes(); } /** @@ -104,7 +112,7 @@ public function owner() */ public function serviceQuote() { - return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(ServiceQuote::class); + return $this->belongsTo(ServiceQuote::class); } /** From 10836c5e8276a0d8c91a8f2a1b709033170af587 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 19:27:27 +0800 Subject: [PATCH 47/50] fix(order-panel): wire the Items/Route/Payment/Activity/Customer/Data tabs into the panel, stack the heading so the status and identity strips sit under the title, 600px --- addon/controllers/orders/index/view.js | 35 ++++++++++++++++----- addon/styles/storefront-engine.css | 6 ++-- addon/templates/orders/index/view.hbs | 11 +++++-- addon/templates/orders/index/view/index.hbs | 2 -- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/addon/controllers/orders/index/view.js b/addon/controllers/orders/index/view.js index 0ac7c1ce..9e8b2008 100644 --- a/addon/controllers/orders/index/view.js +++ b/addon/controllers/orders/index/view.js @@ -3,21 +3,40 @@ import BaseController from '../../base-controller'; import { inject as service } from '@ember/service'; import { isArray } from '@ember/array'; +const TAB_ICONS = { + items: 'box-open', + route: 'route', + payment: 'credit-card', + activity: 'wave-square', + customer: 'user', + data: 'database', +}; + export default class OrdersIndexViewController extends BaseController { @service storefrontOrderActions; + @service intl; @service('universe/menu-service') menuService; + /** + * Items, Route, Payment, Activity, Customer and Data; tabs registered by other + * extensions on `storefront:component:order:details` are appended. + */ get tabs() { const registeredTabs = this.menuService.getMenuItems('storefront:component:order:details'); + const ownTabs = Object.keys(TAB_ICONS).map((id) => ({ + id, + label: this.intl.t(`storefront.order.tabs.${id}`), + icon: TAB_ICONS[id], + component: `storefront/order/details/tabs/${id}`, + })); + const extraTabs = (isArray(registeredTabs) ? registeredTabs : []).map((item) => ({ + id: `registered:${item.slug ?? item.id}`, + label: item.title ?? item.label, + icon: item.icon, + registeredSlug: item.slug ?? item.id, + })); - return [ - { - route: 'orders.index.view.index', - label: 'Overview', - icon: 'folder-open', - }, - ...(isArray(registeredTabs) ? registeredTabs : []), - ]; + return [...ownTabs, ...extraTabs]; } get actionButtons() { diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 9034af9d..e3f3fbc2 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -2034,9 +2034,9 @@ body[data-theme='dark'] .storefront-order-panel-header { .storefront-order-panel-heading { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; + flex-direction: column; + align-items: stretch; + gap: 0.625rem; padding: 0.75rem 1rem; } diff --git a/addon/templates/orders/index/view.hbs b/addon/templates/orders/index/view.hbs index 5393ed48..409372f5 100644 --- a/addon/templates/orders/index/view.hbs +++ b/addon/templates/orders/index/view.hbs @@ -7,10 +7,15 @@ @onPressCancel={{this.transitionBack}} @headerClass="storefront-order-panel-header no-bottom-border" @bodyClass="no-scroll" - @width="560px" + @width="600px" @isResizable={{true}} > - - {{outlet}} + + {{#if activeTab.registeredSlug}} + + {{else if activeTab.component}} + {{component activeTab.component resource=@model order=@model refresh=this.refreshOrder}} + {{/if}} + {{outlet}} diff --git a/addon/templates/orders/index/view/index.hbs b/addon/templates/orders/index/view/index.hbs index 11419622..e69de29b 100644 --- a/addon/templates/orders/index/view/index.hbs +++ b/addon/templates/orders/index/view/index.hbs @@ -1,2 +0,0 @@ - - From fa38fd23f41b1b5cd7f63aa9378a1e5b0fa4c321 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 19:27:54 +0800 Subject: [PATCH 48/50] feat(carts): carts keep what they held: open, checked out and cleared statuses; orders from the checkout's copy - carts.status (open, checked_out, cleared); carts already linked to a checkout are migrated to checked_out. - Cart::retrieve() returns only open carts. An id naming a checked-out or cleared cart (an app can still hold one) resolves to the device's open cart, instead of starting an anonymous cart no device finds again. - Emptying a cart (PUT carts/{id}/empty) now closes it as cleared with its items kept and continues with the device's open cart. A checked-out or cleared cart is never touched. - Checkout::checkedout() marks the cart checked_out. - Orders are created from the checkout's saved copy (cart_state), the items and prices that were priced and charged, so a cart changed or cleared between payment and order creation can't change the order. The live cart only for checkouts saved without a copy. - storefront:purge-carts deletes only expired carts that are open and empty. Checked out and cleared carts, and abandoned carts with items, are kept. --- ...10_10_000001_add_status_to_carts_table.php | 41 +++++++++ .../Console/Commands/PurgeExpiredCarts.php | 12 ++- .../Http/Controllers/v1/CartController.php | 7 +- .../Controllers/v1/CheckoutController.php | 7 +- server/src/Models/Cart.php | 84 +++++++++++++++++-- server/src/Models/Checkout.php | 27 +++++- .../Unit/Console/CommandContractsTest.php | 24 ++++-- .../AnalyticsControllerContractsTest.php | 1 + .../CheckoutBoundaryContractsTest.php | 1 + .../PublicCommerceControllerContractsTest.php | 12 ++- .../ServiceQuoteControllerContractsTest.php | 1 + server/tests/Unit/Models/CartTest.php | 74 ++++++++++++++++ .../Unit/Models/CheckoutBehaviorTest.php | 46 +++++++++- .../Unit/Promotions/PromotionCheckoutTest.php | 1 + .../Promotions/PromotionEndpointsTest.php | 1 + server/tests/Unit/Rules/RuleContractsTest.php | 1 + 16 files changed, 318 insertions(+), 22 deletions(-) create mode 100644 server/migrations/2026_10_10_000001_add_status_to_carts_table.php diff --git a/server/migrations/2026_10_10_000001_add_status_to_carts_table.php b/server/migrations/2026_10_10_000001_add_status_to_carts_table.php new file mode 100644 index 00000000..0016d624 --- /dev/null +++ b/server/migrations/2026_10_10_000001_add_status_to_carts_table.php @@ -0,0 +1,41 @@ +hasColumn('carts', 'status')) { + Schema::connection($connection)->table('carts', function (Blueprint $table) { + $table->string('status', 20)->default('open')->after('checkout_uuid')->index('carts_status_index'); + }); + } + + DB::connection($connection)->table('carts')->whereNotNull('checkout_uuid')->update(['status' => 'checked_out']); + } + + public function down(): void + { + $connection = config('storefront.connection.db'); + + if (!Schema::connection($connection)->hasColumn('carts', 'status')) { + return; + } + + Schema::connection($connection)->table('carts', function (Blueprint $table) { + $table->dropIndex('carts_status_index'); + $table->dropColumn('status'); + }); + } +}; diff --git a/server/src/Console/Commands/PurgeExpiredCarts.php b/server/src/Console/Commands/PurgeExpiredCarts.php index ccc2db22..d7a9fafb 100644 --- a/server/src/Console/Commands/PurgeExpiredCarts.php +++ b/server/src/Console/Commands/PurgeExpiredCarts.php @@ -19,7 +19,7 @@ class PurgeExpiredCarts extends Command * * @var string */ - protected $description = 'Permanently delete all expired carts from the database'; + protected $description = 'Permanently delete expired carts that are still open and empty'; /** * Execute the console command. @@ -34,8 +34,18 @@ public function handle() $schema->disableForeignKeyConstraints(); try { + // Only expired carts that are still open and hold nothing. Checked out and + // cleared carts, and abandoned carts with items, are the record of what was + // bought or wanted, so they are kept. $dbDeletedCount = $dbConnection->table('carts') ->where('expires_at', '<', now()) + ->whereNull('checkout_uuid') + ->where(function ($query) { + $query->whereNull('status')->orWhere('status', 'open'); + }) + ->where(function ($query) { + $query->whereNull('items')->orWhereIn('items', ['[]', '{}', '', 'null']); + }) ->delete(); } finally { $schema->enableForeignKeyConstraints(); diff --git a/server/src/Http/Controllers/v1/CartController.php b/server/src/Http/Controllers/v1/CartController.php index 165d767c..3d77f8f2 100644 --- a/server/src/Http/Controllers/v1/CartController.php +++ b/server/src/Http/Controllers/v1/CartController.php @@ -127,11 +127,12 @@ public function remove(?string $cartId, ?string $cartItemId, Request $request) */ public function empty(string $cartId) { + // Emptying closes the cart as cleared, keeping its items as a record of what it + // held, and continues with the device's open cart. A cart already checked out or + // cleared is never touched: retrieveCart() resolves its id to the device's open cart. $cart = $this->retrieveCart($cartId); - $cart->empty(); - - return new StorefrontCart($cart); + return new StorefrontCart($cart->clear()); } /** diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 87b92f29..a5c54c1d 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -1338,7 +1338,9 @@ public function captureOrder(CaptureOrderRequest $request) $gateway = $checkout->is_cod ? Gateway::cash() : $checkout->gateway; $origin = $serviceQuote ? $serviceQuote->getMeta('origin', []) : null; $destination = $serviceQuote ? $serviceQuote->getMeta('destination') : null; - $cart = $checkout->cart; + // The cart as it was priced and charged (see Checkout::cartAtCheckout()); the live + // cart only for checkouts saved before the copy existed. + $cart = $checkout->cartAtCheckout() ?? $checkout->cart; // If the checkout already has an order created if ($checkout->order_uuid) { @@ -1750,7 +1752,8 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $origin = Arr::first($origins); $waypoints = array_slice($origins, 1); $destination = $serviceQuote->getMeta('destination'); - $cart = $checkout->cart; + // The cart as it was priced and charged (see Checkout::cartAtCheckout()). + $cart = $checkout->cartAtCheckout() ?? $checkout->cart; // $amount = $checkout->amount ?? ($checkout->is_pickup ? $cart->subtotal : $cart->subtotal + $serviceQuote->amount); $amount = static::calculateCheckoutAmount($cart, $serviceQuote, $checkout->options); $currency = $checkout->currency ?? $cart->getCurrency(); diff --git a/server/src/Models/Cart.php b/server/src/Models/Cart.php index 6e3ca0bf..17c7e590 100644 --- a/server/src/Models/Cart.php +++ b/server/src/Models/Cart.php @@ -49,7 +49,15 @@ class Cart extends StorefrontModel * * @var array */ - protected $fillable = ['company_uuid', 'user_uuid', 'checkout_uuid', 'customer_id', 'unique_identifier', 'currency', 'discount_code', 'items', 'events', 'expires_at']; + protected $fillable = ['company_uuid', 'user_uuid', 'checkout_uuid', 'status', 'customer_id', 'unique_identifier', 'currency', 'discount_code', 'items', 'events', 'expires_at']; + + /** + * Cart statuses. Only an open cart is shopped; a checked out or cleared cart keeps its + * items as the record of what it held, and the device continues with a new open cart. + */ + public const STATUS_OPEN = 'open'; + public const STATUS_CHECKED_OUT = 'checked_out'; + public const STATUS_CLEARED = 'cleared'; /** * The attributes that should be cast to native types. @@ -660,11 +668,66 @@ public static function newCart($uniqueId = null): Cart 'expires_at' => Carbon::now()->addDays(7), 'currency' => session('storefront_currency'), 'customer_id' => session('customer_id'), + 'status' => static::STATUS_OPEN, 'items' => [], 'events' => [], ]); } + /** + * Carts still being shopped: not checked out, not cleared. + */ + public function scopeOpen($query) + { + return $query->whereNull('checkout_uuid')->where(function ($query) { + $query->whereNull('status')->orWhere('status', static::STATUS_OPEN); + }); + } + + public function isOpen(): bool + { + return $this->checkout_uuid === null && in_array($this->status, [null, static::STATUS_OPEN], true); + } + + /** + * Close this cart as cleared, keeping its items, and return the open cart the device + * continues with. An empty open cart is simply kept; a closed cart is left as it is. + */ + public function clear(): Cart + { + if ($this->isOpen()) { + if (count($this->getAttribute('items') ?? []) === 0) { + return $this; + } + + $this->createEvent('cart.cleared', null, false); + $this->status = static::STATUS_CLEARED; + $this->save(); + } + + return static::openCartFor($this->unique_identifier); + } + + /** + * The open cart of a device (its unique identifier), or a new one for it. + */ + public static function openCartFor(?string $uniqueId): Cart + { + if ($uniqueId) { + $query = static::where('unique_identifier', $uniqueId)->open(); + if (session('company')) { + $query->where('company_uuid', session('company')); + } + + $open = $query->latest()->first(); + if ($open) { + return $open; + } + } + + return static::newCart($uniqueId); + } + /** * Retrieve a cart by id or unique id. */ @@ -683,17 +746,24 @@ public static function retrieve(?string $id, bool $excludeCheckedout = true): Ca $query->where('company_uuid', session('company')); } - if ($excludeCheckedout) { - $query->whereNull('checkout_uuid'); + if (!$excludeCheckedout) { + return $query->first() ?? static::newCart(!Str::startsWith($id, 'cart_') ? $id : null); } - $cart = $query->first(); + $cart = (clone $query)->open()->first(); + if ($cart) { + return $cart; + } - if (!$cart) { - return static::newCart(!Str::startsWith($id, 'cart_') ? $id : null); + // The id may name a cart that has been checked out or cleared (an app can still + // hold it): continue with the open cart of the same device rather than starting an + // anonymous cart that no device will find again. + $closed = (clone $query)->latest()->first(); + if ($closed && $closed->unique_identifier) { + return static::openCartFor($closed->unique_identifier); } - return $cart; + return static::newCart(!Str::startsWith($id, 'cart_') ? $id : null); } /** diff --git a/server/src/Models/Checkout.php b/server/src/Models/Checkout.php index 97a0af7a..d5cb4d21 100644 --- a/server/src/Models/Checkout.php +++ b/server/src/Models/Checkout.php @@ -10,6 +10,7 @@ use Fleetbase\Traits\HasOptionsAttributes; use Fleetbase\Traits\HasPublicid; use Fleetbase\Traits\HasUuid; +use Illuminate\Support\Arr; use Illuminate\Support\Str; class Checkout extends StorefrontModel @@ -171,7 +172,31 @@ public function checkedout() $cart = $this->cart; if ($cart) { - $this->cart->update(['checkout_uuid' => $this->uuid]); + $this->cart->update(['checkout_uuid' => $this->uuid, 'status' => Cart::STATUS_CHECKED_OUT]); } } + + /** + * The cart as it was when this checkout was created (cart_state): the items and prices + * that were priced and charged. Orders are created from it, so a cart changed or + * cleared between payment and order creation can't change the order. Null for older + * checkouts saved without it. The returned cart is a read-only copy, never saved. + */ + public function cartAtCheckout(): ?Cart + { + $state = json_decode(json_encode($this->cart_state ?? null), true); + if (!is_array($state) || !array_key_exists('items', $state) || !is_array($state['items'])) { + return null; + } + + $attributes = Arr::only($state, ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'checkout_uuid', 'status', 'customer_id', 'unique_identifier', 'currency', 'discount_code', 'expires_at', 'created_at', 'updated_at']); + $attributes['uuid'] = $attributes['uuid'] ?? $this->cart_uuid; + $attributes['items'] = json_encode($state['items']); + $attributes['events'] = json_encode($state['events'] ?? []); + + $cart = new Cart(); + $cart->setRawAttributes($attributes, true); + + return $cart; + } } diff --git a/server/tests/Unit/Console/CommandContractsTest.php b/server/tests/Unit/Console/CommandContractsTest.php index 5eba62cc..35c165f1 100644 --- a/server/tests/Unit/Console/CommandContractsTest.php +++ b/server/tests/Unit/Console/CommandContractsTest.php @@ -108,19 +108,31 @@ public function updateMeta($key, $value = null): Fleetbase\FleetOps\Models\Order } } -test('purge carts deletes only expired records and reports the affected count', function () { +test('purge carts deletes only expired open empty carts and keeps every cart that held something', function () { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); $schema->dropIfExists('carts'); $schema->create('carts', function ($table) { $table->increments('id'); + $table->string('name'); + $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('items')->nullable(); $table->timestamp('expires_at'); }); + $expired = now()->subDay(); $connection->table('carts')->insert([ - ['expires_at' => now()->subMinute()], - ['expires_at' => now()->subDay()], - ['expires_at' => now()->addHour()], + // deleted: expired, open and empty (every way an empty cart is stored) + ['name' => 'empty-array', 'checkout_uuid' => null, 'status' => 'open', 'items' => '[]', 'expires_at' => $expired], + ['name' => 'empty-null', 'checkout_uuid' => null, 'status' => null, 'items' => null, 'expires_at' => now()->subMinute()], + ['name' => 'empty-object', 'checkout_uuid' => null, 'status' => 'open', 'items' => '{}', 'expires_at' => $expired], + // kept: abandoned with items, checked out, cleared, linked to a checkout, not expired + ['name' => 'abandoned', 'checkout_uuid' => null, 'status' => 'open', 'items' => '[{"id":"cart_item_1"}]', 'expires_at' => $expired], + ['name' => 'checked-out', 'checkout_uuid' => 'checkout_uuid', 'status' => 'checked_out', 'items' => '[{"id":"cart_item_2"}]', 'expires_at' => $expired], + ['name' => 'checkout-linked-empty', 'checkout_uuid' => 'checkout_uuid', 'status' => null, 'items' => '[]', 'expires_at' => $expired], + ['name' => 'cleared', 'checkout_uuid' => null, 'status' => 'cleared', 'items' => '[{"id":"cart_item_3"}]', 'expires_at' => $expired], + ['name' => 'current', 'checkout_uuid' => null, 'status' => 'open', 'items' => '[]', 'expires_at' => now()->addHour()], ]); $buffer = new BufferedOutput(); @@ -129,8 +141,8 @@ public function updateMeta($key, $value = null): Fleetbase\FleetOps\Models\Order $command->setOutput($output); expect($command->handle())->toBe(Command::SUCCESS) - ->and($connection->table('carts')->count())->toBe(1) - ->and($buffer->fetch())->toContain('Successfully deleted 2 expired carts.'); + ->and($connection->table('carts')->orderBy('id')->pluck('name')->all())->toBe(['abandoned', 'checked-out', 'checkout-linked-empty', 'cleared', 'current']) + ->and($buffer->fetch())->toContain('Successfully deleted 3 expired carts.'); }); test('purge carts restores foreign key enforcement when deletion fails', function () { diff --git a/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php b/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php index eb30761f..efb1d2db 100644 --- a/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php @@ -50,6 +50,7 @@ function createAnalyticsControllerSchema(): void }); $schema->create('carts', function ($table) { $table->increments('id'); + $table->string('status')->nullable(); $table->string('uuid')->nullable(); $table->string('company_uuid')->nullable(); $table->text('items')->nullable(); diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 196807f5..abffcab4 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -513,6 +513,7 @@ function createCheckoutBoundarySchema(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); diff --git a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php index a4adbdd2..f6ae8266 100644 --- a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php @@ -38,6 +38,13 @@ public function empty() return $this; } + public function clear(): Fleetbase\Storefront\Models\Cart + { + $this->calls['clear'] = true; + + return $this; + } + public function delete() { $this->calls['delete'] = true; @@ -70,6 +77,7 @@ function createPublicCartControllerSchema(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); @@ -404,7 +412,9 @@ function createPublicCartControllerSchema(): void '2026-07-29 10:00:00', ]) ->and($cart->calls['remove'])->toBe(['line_item_abcdefgh']) - ->and($cart->calls['empty'])->toBeTrue() + // Emptying closes the cart (keeping its items) rather than wiping it. + ->and($cart->calls['clear'])->toBeTrue() + ->and($cart->calls)->not->toHaveKey('empty') ->and($cart->calls['delete'])->toBeTrue(); }); diff --git a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php index c967b641..b2bbfcff 100644 --- a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php @@ -227,6 +227,7 @@ function createServiceQuoteLookupSchema(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); diff --git a/server/tests/Unit/Models/CartTest.php b/server/tests/Unit/Models/CartTest.php index da61660e..41d65754 100644 --- a/server/tests/Unit/Models/CartTest.php +++ b/server/tests/Unit/Models/CartTest.php @@ -46,6 +46,7 @@ function createCartLifecycleSchema(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); @@ -314,3 +315,76 @@ function cartWithItems(): Cart ->and($cart->currency)->toBeNull() ->and($cart->last_event->event)->toBe('cart.emptied'); }); + +function insertLifecycleCart(array $attributes): void +{ + Capsule::connection('mysql')->table('carts')->insert(array_merge([ + 'items' => '[]', + 'events' => '[]', + 'created_at' => now(), + 'updated_at' => now(), + ], $attributes)); +} + +test('cart retrieval keeps a device on its open cart and never returns a closed one', function () { + createCartLifecycleSchema(); + insertLifecycleCart(['uuid' => 'checked_uuid', 'public_id' => 'cart_checked', 'unique_identifier' => 'device-1', 'checkout_uuid' => 'checkout_uuid', 'status' => 'checked_out', 'items' => '[{"id":"cart_item_1","subtotal":1000}]', 'created_at' => now()->subHour()]); + insertLifecycleCart(['uuid' => 'open_uuid', 'public_id' => 'cart_open', 'unique_identifier' => 'device-1', 'status' => 'open']); + insertLifecycleCart(['uuid' => 'legacy_uuid', 'public_id' => 'cart_legacy', 'unique_identifier' => 'device-2', 'status' => null]); + + expect(Cart::retrieve('device-1')->public_id)->toBe('cart_open') + // an app still holding the checked-out cart continues with the device's open cart + ->and(Cart::retrieve('cart_checked')->public_id)->toBe('cart_open') + // carts saved before statuses existed are open + ->and(Cart::retrieve('device-2')->public_id)->toBe('cart_legacy') + // asking for closed carts too still finds them + ->and(Cart::retrieve('cart_checked', false)->public_id)->toBe('cart_checked') + ->and(Capsule::connection('mysql')->table('carts')->where('uuid', 'checked_uuid')->value('items'))->toBe('[{"id":"cart_item_1","subtotal":1000}]'); +}); + +test('cart retrieval starts a new cart for the device when only closed carts remain', function () { + createCartLifecycleSchema(); + insertLifecycleCart(['uuid' => 'cleared_uuid', 'public_id' => 'cart_cleared', 'unique_identifier' => 'device-3', 'status' => 'cleared', 'items' => '[{"id":"cart_item_1"}]']); + + $fromDevice = Cart::retrieve('device-3'); + $fromStaleId = Cart::retrieve('cart_cleared'); + + expect($fromDevice->public_id)->not->toBe('cart_cleared') + ->and($fromDevice->unique_identifier)->toBe('device-3') + ->and($fromDevice->status)->toBe(Cart::STATUS_OPEN) + // the stale id finds the device's new open cart instead of an anonymous one + ->and($fromStaleId->uuid)->toBe($fromDevice->uuid) + ->and(Cart::retrieve('cart_unknown')->unique_identifier)->toBeNull() + ->and(Cart::retrieve(null)->status)->toBe(Cart::STATUS_OPEN); +}); + +test('clearing a cart closes it with its items and continues with an open cart for the device', function () { + createCartLifecycleSchema(); + insertLifecycleCart(['uuid' => 'full_uuid', 'public_id' => 'cart_full', 'unique_identifier' => 'device-4', 'status' => 'open', 'items' => '[{"id":"cart_item_1","subtotal":1000}]']); + insertLifecycleCart(['uuid' => 'empty_uuid', 'public_id' => 'cart_empty', 'unique_identifier' => 'device-5', 'status' => 'open']); + insertLifecycleCart(['uuid' => 'done_uuid', 'public_id' => 'cart_done', 'unique_identifier' => 'device-6', 'checkout_uuid' => 'checkout_uuid', 'status' => 'checked_out', 'items' => '[{"id":"cart_item_2"}]']); + + $full = Cart::where('uuid', 'full_uuid')->firstOrFail(); + $next = $full->clear(); + $empty = Cart::where('uuid', 'empty_uuid')->firstOrFail(); + $done = Cart::where('uuid', 'done_uuid')->firstOrFail(); + $after = $done->clear(); + $row = fn ($uuid) => Capsule::connection('mysql')->table('carts')->where('uuid', $uuid)->first(); + + expect($row('full_uuid')->status)->toBe(Cart::STATUS_CLEARED) + ->and($row('full_uuid')->items)->toBe('[{"id":"cart_item_1","subtotal":1000}]') + ->and(json_decode($row('full_uuid')->events)[0]->event)->toBe('cart.cleared') + ->and($next->uuid)->not->toBe('full_uuid') + ->and($next->unique_identifier)->toBe('device-4') + ->and($next->isOpen())->toBeTrue() + // an empty open cart has nothing to keep + ->and($empty->clear()->uuid)->toBe('empty_uuid') + ->and($row('empty_uuid')->status)->toBe(Cart::STATUS_OPEN) + // a checked-out cart is never touched + ->and($row('done_uuid')->status)->toBe(Cart::STATUS_CHECKED_OUT) + ->and($row('done_uuid')->items)->toBe('[{"id":"cart_item_2"}]') + ->and($after->unique_identifier)->toBe('device-6') + ->and($after->uuid)->not->toBe('done_uuid') + ->and($done->isOpen())->toBeFalse(); +}); + diff --git a/server/tests/Unit/Models/CheckoutBehaviorTest.php b/server/tests/Unit/Models/CheckoutBehaviorTest.php index 7c0b86f1..80eb126f 100644 --- a/server/tests/Unit/Models/CheckoutBehaviorTest.php +++ b/server/tests/Unit/Models/CheckoutBehaviorTest.php @@ -56,6 +56,7 @@ public function clear(): void $table->increments('id'); $table->string('uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); $table->softDeletes(); @@ -93,5 +94,48 @@ public function clear(): void ->table('carts') ->where('uuid', 'cart_uuid') ->value('checkout_uuid') - )->toBe('checkout_uuid'); + )->toBe('checkout_uuid') + ->and(Illuminate\Database\Capsule\Manager::connection('mysql')->table('carts')->where('uuid', 'cart_uuid')->value('status'))->toBe('checked_out'); +}); + +test('checkout rebuilds the cart as it was priced and charged from its saved copy', function () { + $checkout = new Checkout(); + $checkout->forceFill([ + 'cart_uuid' => 'cart_uuid', + 'cart_state' => [ + 'public_id' => 'cart_public', + 'currency' => 'MNT', + 'unique_identifier' => 'device-store_x', + 'subtotal' => 99999, // appended accessors in the copy are recomputed, not trusted + 'items' => [ + ['id' => 'cart_item_1', 'name' => 'Туршилтын бараа', 'store_id' => 'store_a', 'quantity' => 1, 'subtotal' => 1000], + ['id' => 'cart_item_2', 'name' => 'Бууз', 'store_id' => 'store_a', 'quantity' => 2, 'subtotal' => 24000], + ], + 'events' => [['event' => 'cart.item_added']], + ], + ]); + + $cart = $checkout->cartAtCheckout(); + + expect($cart)->toBeInstanceOf(Fleetbase\Storefront\Models\Cart::class) + ->and($cart->exists)->toBeFalse() + ->and($cart->uuid)->toBe('cart_uuid') + ->and($cart->public_id)->toBe('cart_public') + ->and($cart->getCurrency())->toBe('MNT') + ->and(count($cart->items))->toBe(2) + ->and($cart->items[1]->name)->toBe('Бууз') + ->and($cart->subtotal)->toBe(25000) + ->and(count($cart->events))->toBe(1); +}); + +test('checkout without a saved cart copy has no cart at checkout', function () { + $none = new Checkout(); + $noItems = new Checkout(); + $noItems->forceFill(['cart_state' => ['public_id' => 'cart_public']]); + $badItems = new Checkout(); + $badItems->forceFill(['cart_state' => ['items' => 'not-a-list']]); + + expect($none->cartAtCheckout())->toBeNull() + ->and($noItems->cartAtCheckout())->toBeNull() + ->and($badItems->cartAtCheckout())->toBeNull(); }); diff --git a/server/tests/Unit/Promotions/PromotionCheckoutTest.php b/server/tests/Unit/Promotions/PromotionCheckoutTest.php index e64d962c..9aa4eff9 100644 --- a/server/tests/Unit/Promotions/PromotionCheckoutTest.php +++ b/server/tests/Unit/Promotions/PromotionCheckoutTest.php @@ -302,6 +302,7 @@ function createCheckoutPromotionTables(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); diff --git a/server/tests/Unit/Promotions/PromotionEndpointsTest.php b/server/tests/Unit/Promotions/PromotionEndpointsTest.php index 57556924..34b61d41 100644 --- a/server/tests/Unit/Promotions/PromotionEndpointsTest.php +++ b/server/tests/Unit/Promotions/PromotionEndpointsTest.php @@ -35,6 +35,7 @@ function seedPromotionCart(?string $codes = null): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); diff --git a/server/tests/Unit/Rules/RuleContractsTest.php b/server/tests/Unit/Rules/RuleContractsTest.php index d9e3dec2..3bc34a00 100644 --- a/server/tests/Unit/Rules/RuleContractsTest.php +++ b/server/tests/Unit/Rules/RuleContractsTest.php @@ -41,6 +41,7 @@ $schema->dropIfExists('carts'); $schema->create('carts', function (Illuminate\Database\Schema\Blueprint $table) { $table->increments('id'); + $table->string('status')->nullable(); $table->string('public_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->timestamp('expires_at')->nullable(); From ace3e57957863779a9feba9c01c4dafe62a8b93c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 21:09:03 +0800 Subject: [PATCH 49/50] feat(qpay): payment recorded on the checkout the moment it's verified; status answered from the checkout - The callback records the verified payment on the checkout (qpay_payment: id, status, amount, currency, wallet, recorded_at) and publishes "paid" with it before creating the order. - GET checkouts/status answers QPay checkouts from the checkout: paid as soon as a payment is recorded, completed once the order exists. No QPay call and no waiting on the order lock, so the app can ask often and get an answer at once. - QPay is asked only with verify=1 (the app's check when the customer returns from paying), at most once every 5 seconds per checkout, per QPay's guidance to rely on the callback rather than repeated payment checks. - Safety net: a payment recorded more than 15 seconds ago with no order (e.g. a failed callback) gets its order created on the next status check. --- .../Controllers/v1/CheckoutController.php | 96 +++++++------ .../CheckoutBoundaryContractsTest.php | 131 +++++++++++------- 2 files changed, 137 insertions(+), 90 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index a5c54c1d..684eccd4 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -999,8 +999,9 @@ public function captureQPayCallback(Request $request) ]); } - // Tell the app at once that the payment is confirmed; creating the order takes a - // moment, and a second update follows with the order. + // Record the payment on the checkout and tell the app at once; creating the order + // takes a moment, and a second update follows with the order. + static::recordQPayPayment($checkout, $payment); static::publishCheckoutUpdate($checkout, true); // Create order from payment using reusable gateway-agnostic method @@ -1048,6 +1049,31 @@ protected static function useCheckoutStorefront(Checkout $checkout): void } } + /** + * Save a verified QPay payment on the checkout (once) and return its summary, so the + * app can be told it was paid without asking QPay again. + */ + protected static function recordQPayPayment(Checkout $checkout, object $payment): array + { + $existing = $checkout->getOption('qpay_payment'); + if ($existing) { + return (array) $existing; + } + + $summary = [ + 'payment_id' => $payment->payment_id ?? null, + 'payment_status' => $payment->payment_status ?? 'PAID', + 'payment_amount' => $payment->payment_amount ?? null, + 'payment_currency' => $payment->payment_currency ?? $checkout->currency, + 'payment_date' => $payment->payment_date ?? null, + 'payment_wallet' => $payment->payment_wallet ?? 'QPay', + 'recorded_at' => now()->toIso8601String(), + ]; + $checkout->updateOption('qpay_payment', $summary); + + return $summary; + } + /** * The payment that pays a checkout's QPay invoice, or null. * @@ -2174,67 +2200,57 @@ public function getCheckoutStatus(Request $request) 'order' => $checkout->order ? new OrderResource($checkout->order) : null, ]; - // Check if this is a QPay checkout. Once the order exists it is the answer: - // QPay asks merchants not to check payments over and over (payment_check), so - // it is only asked while the order is still missing. + // QPay: answered from the checkout, so the app can ask often and cheaply. The + // payment is recorded on the checkout as soon as QPay's callback (or a verify + // below) confirms it. QPay itself is asked only when the app says the customer + // is back from paying (verify=1), at most once every few seconds per checkout: + // QPay asks merchants to rely on the callback, not repeated payment checks. if ($checkout->gateway_uuid && !$response['order']) { $gateway = Gateway::where('uuid', $checkout->gateway_uuid)->first(); if ($gateway && $gateway->code === 'qpay') { - // Get QPay invoice ID from checkout options - $qpayInvoiceId = $checkout->getOption('qpay_invoice_id'); - $payment = null; + $recorded = $checkout->getOption('qpay_payment'); - if ($qpayInvoiceId) { - // Create QPay instance with correct credentials + if (!$recorded && $request->boolean('verify') && $checkout->getOption('qpay_invoice_id') && Cache::add('storefront:qpay-verify:' . $checkout->uuid, 1, 5)) { $qpay = static::qpayForGateway($gateway); - if ($gateway->sandbox) { $qpay->useSandbox(); } - $qpay->setAuthToken(); - // Verify payment status with QPay - $paymentCheck = $qpay->paymentCheck($qpayInvoiceId); - // Only a PAID payment covering the invoice amount (see paidQPayPayment) - $payment = static::paidQPayPayment($paymentCheck, $checkout); + $payment = static::paidQPayPayment($qpay->paymentCheck($checkout->getOption('qpay_invoice_id')), $checkout); + if ($payment) { + $recorded = static::recordQPayPayment($checkout, $payment); + static::publishCheckoutUpdate($checkout, true); + } } - if ($payment) { + if ($recorded) { $response['status'] = 'paid'; - $response['payment'] = [ - 'payment_id' => $payment->payment_id, - 'payment_status' => $payment->payment_status, - 'payment_amount' => $payment->payment_amount, - 'payment_date' => $payment->payment_date ?? null, - 'payment_wallet' => $payment->payment_wallet ?? 'QPay', - ]; + $response['payment'] = $recorded; - // FALLBACK: If payment confirmed but order doesn't exist, create it - if (!$checkout->order_uuid) { + // Safety net: the callback creates the order right after recording the + // payment. If it still hasn't appeared a while later (e.g. the callback + // failed), create it here; createOrderFromCheckout is idempotent. + $recordedAt = data_get($recorded, 'recorded_at'); + if ($recordedAt && Carbon::parse($recordedAt)->lt(now()->subSeconds(15))) { Log::info('[CHECKOUT STATUS FALLBACK]: Payment confirmed but no order exists, attempting to create', [ 'checkout_id' => $checkout->public_id, - 'payment_id' => $payment->payment_id, + 'payment_id' => data_get($recorded, 'payment_id'), ]); - $transactionDetails = [ - 'transaction_id' => $payment->payment_id, - 'payment_status' => 'PAID', - 'payment_wallet' => $payment->payment_wallet ?? 'QPay', - ]; - try { - // Use the reusable gateway-agnostic method to create order - // createOrderFromCheckout has built-in idempotency checks - $order = $this->createOrderFromCheckout($checkout, $transactionDetails); + $order = $this->createOrderFromCheckout($checkout, [ + 'transaction_id' => data_get($recorded, 'payment_id'), + 'payment_status' => 'PAID', + 'payment_wallet' => data_get($recorded, 'payment_wallet') ?? 'QPay', + ]); if ($order) { $response['status'] = 'completed'; $response['order'] = new OrderResource($order); } } catch (\Exception $e) { - // If order creation fails (e.g., race condition), refresh and check again Log::warning('[CHECKOUT STATUS FALLBACK]: Order creation failed, checking if order was created by another request', [ 'checkout_id' => $checkout->public_id, 'error' => $e->getMessage(), @@ -2242,15 +2258,10 @@ public function getCheckoutStatus(Request $request) $checkout->refresh(); if ($checkout->order_uuid) { - // Order was created by another request $response['status'] = 'completed'; $response['order'] = new OrderResource($checkout->order); } } - } else { - // Order already exists - $response['status'] = 'completed'; - $response['order'] = new OrderResource($checkout->order); } } } @@ -2424,6 +2435,7 @@ protected static function publishCheckoutUpdate(Checkout $checkout, bool $paid, 'checkout' => $checkout->public_id, 'status' => $status, 'order' => $order ? static::checkoutChannelOrder($order) : null, + 'payment' => !$error ? $checkout->getOption('qpay_payment') : null, 'error' => $error, ]; diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index abffcab4..6c75152f 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -19,6 +19,7 @@ class CheckoutQPayStub extends Fleetbase\Storefront\Support\QPay public static ?Throwable $failure = null; public static bool $sandboxUsed = false; public static bool $authenticated = false; + public static int $checks = 0; public static ?string $invoiceKind = null; public static array $invoiceArguments = []; @@ -42,6 +43,7 @@ public function setAuthToken(?string $accessToken = null): Fleetbase\Storefront\ public function paymentCheck(string $invoiceId, $options = []) { + static::$checks++; if (static::$failure) { throw static::$failure; } @@ -1344,7 +1346,7 @@ public function notify($notification): void ]); }); -test('checkout status reports qpay pending paid fallback and provider failure states', function () { +test('checkout status answers qpay from the checkout and asks qpay only to verify a return from paying', function () { createCheckoutCaptureExecutionSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); $connection->table('gateways')->insert([ @@ -1359,73 +1361,106 @@ public function notify($notification): void 'password' => 'secret', ]), ]); - $connection->table('checkouts')->insert([ - 'uuid' => 'checkout_uuid', - 'public_id' => 'checkout_abcdefgh', - 'gateway_uuid' => 'qpay_gateway_uuid', - 'options' => json_encode(['qpay_invoice_id' => 'invoice_checkout']), - 'token' => 'checkout-token', - 'captured' => false, - ]); + foreach (['checkout_abcdefgh' => 'checkout_uuid', 'checkout_failing' => 'checkout_failing_uuid', 'checkout_unpaid' => 'checkout_unpaid_uuid'] as $publicId => $uuid) { + $connection->table('checkouts')->insert([ + 'uuid' => $uuid, + 'public_id' => $publicId, + 'gateway_uuid' => 'qpay_gateway_uuid', + 'options' => json_encode(['qpay_invoice_id' => 'invoice_' . $publicId]), + 'token' => 'checkout-token', + 'captured' => false, + ]); + } $connection->table('orders')->insert([ 'uuid' => 'status_order_uuid', 'public_id' => 'order_status', ]); CheckoutQPayStub::$failure = null; - CheckoutQPayStub::$paymentCheckResult = (object) ['rows' => []]; + CheckoutQPayStub::$checks = 0; CheckoutQPayStub::$sandboxUsed = false; + CheckoutQPayStub::$paymentCheckResult = (object) [ + 'count' => 1, + 'paid_amount' => 2500, + 'rows' => [ + (object) [ + 'payment_id' => 'payment_checkout', + 'payment_status' => 'PAID', + 'payment_amount' => 2500, + 'payment_currency' => 'MNT', + 'payment_date' => '2026-07-27 10:00:00', + 'payment_wallet' => 'Most money', + ], + ], + ]; TestableCheckoutController::$statusFallbackOrder = null; TestableCheckoutController::$statusFallbackFailure = null; $controller = new TestableCheckoutController(); - $request = fn () => Request::create('/checkouts/status', 'GET', [ - 'checkout' => 'checkout_abcdefgh', + $request = fn (string $checkout = 'checkout_abcdefgh', bool $verify = false) => Request::create('/checkouts/status', 'GET', [ + 'checkout' => $checkout, 'token' => 'checkout-token', + 'verify' => $verify ? '1' : '0', ]); + $record = fn () => Fleetbase\Storefront\Models\Checkout::where('uuid', 'checkout_uuid')->first()->getOption('qpay_payment'); - $pending = $controller->getCheckoutStatus($request()); - CheckoutQPayStub::$paymentCheckResult = (object) [ - 'rows' => [ - (object) [ - 'payment_id' => 'payment_checkout', - 'payment_status' => 'PAID', - 'payment_amount' => 2500, - 'payment_date' => '2026-07-27 10:00:00', - 'payment_wallet' => 'QPay', - ], - ], - ]; + // Without verify the answer comes from the checkout alone: QPay is not asked. + $pending = $controller->getCheckoutStatus($request())->getData(true); + $checksAfterPending = CheckoutQPayStub::$checks; + + // Back from paying: one verify asks QPay and records the payment. + $paid = $controller->getCheckoutStatus($request('checkout_abcdefgh', true))->getData(true); + $checksAfterVerify = CheckoutQPayStub::$checks; + $recorded = $record(); + + // Recorded payments are answered from the checkout, even when verify is asked again. + $again = $controller->getCheckoutStatus($request('checkout_abcdefgh', true))->getData(true); + $checksAfterAgain = CheckoutQPayStub::$checks; + + // A payment recorded a while ago without an order gets its order (safety net). + $checkout = Fleetbase\Storefront\Models\Checkout::where('uuid', 'checkout_uuid')->first(); + $checkout->updateOption('qpay_payment', array_merge((array) $recorded, ['recorded_at' => now()->subSeconds(30)->toIso8601String()])); + TestableCheckoutController::$statusFallbackOrder = Fleetbase\FleetOps\Models\Order::where('uuid', 'status_order_uuid')->firstOrFail(); session(['storefront_key' => null]); - TestableCheckoutController::$statusFallbackOrder = Fleetbase\FleetOps\Models\Order::where( - 'uuid', - 'status_order_uuid' - )->firstOrFail(); - $paid = $controller->getCheckoutStatus($request()); + $fallback = $controller->getCheckoutStatus($request())->getData(true); + + // A concurrent capture that won the race is picked up. TestableCheckoutController::$statusFallbackFailure = new RuntimeException('Concurrent status capture'); - $raceRecovered = $controller->getCheckoutStatus($request()); + $raceRecovered = $controller->getCheckoutStatus($request())->getData(true); TestableCheckoutController::$statusFallbackFailure = null; - $alreadyCompleted = $controller->getCheckoutStatus($request()); - CheckoutQPayStub::$failure = new RuntimeException('QPay status unavailable'); - $failure = $controller->getCheckoutStatus($request()); - CheckoutQPayStub::$failure = null; - $pendingData = $pending->getData(true); - $paidData = $paid->getData(true); - expect($pendingData['status'])->toBe('pending') - ->and($pendingData['payment'])->toBeNull() - ->and($pendingData['order'])->toBeNull() - ->and($paidData['status'])->toBe('completed') - ->and($paidData['payment']['payment_id'])->toBe('payment_checkout') - ->and($paidData['payment']['payment_status'])->toBe('PAID') - ->and($paidData['payment']['payment_amount'])->toBe(2500) - ->and($paidData['payment']['payment_wallet'])->toBe('QPay') - ->and($paidData['order']['id'])->toBe('order_status') - ->and($raceRecovered->getData(true)['status'])->toBe('completed') - ->and($alreadyCompleted->getData(true)['status'])->toBe('completed') + + // QPay asked to verify but failing surfaces as an error; an unpaid verify is throttled. + CheckoutQPayStub::$failure = new RuntimeException('QPay status unavailable'); + $failure = $controller->getCheckoutStatus($request('checkout_failing', true)); + CheckoutQPayStub::$failure = null; + CheckoutQPayStub::$paymentCheckResult = (object) ['count' => 0, 'rows' => []]; + $unpaidFirst = $controller->getCheckoutStatus($request('checkout_unpaid', true))->getData(true); + $checksUnpaid = CheckoutQPayStub::$checks; + $unpaidSecond = $controller->getCheckoutStatus($request('checkout_unpaid', true))->getData(true); + + expect($pending['status'])->toBe('pending') + ->and($pending['payment'])->toBeNull() + ->and($checksAfterPending)->toBe(0) + ->and($paid['status'])->toBe('paid') + ->and($paid['order'])->toBeNull() + ->and($paid['payment']['payment_id'])->toBe('payment_checkout') + ->and($paid['payment']['payment_amount'])->toBe(2500) + ->and($paid['payment']['payment_wallet'])->toBe('Most money') + ->and($checksAfterVerify)->toBe(1) ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue() + ->and(data_get($recorded, 'payment_currency'))->toBe('MNT') + ->and(data_get($recorded, 'recorded_at'))->not->toBeNull() + ->and($again['status'])->toBe('paid') + ->and($checksAfterAgain)->toBe(1) + ->and($fallback['status'])->toBe('completed') + ->and($fallback['order']['id'])->toBe('order_status') + ->and($raceRecovered['status'])->toBe('completed') ->and($failure->getStatusCode())->toBe(500) ->and($failure->getData(true))->toBe([ 'error' => 'Failed to retrieve checkout status', 'message' => 'QPay status unavailable', - ]); + ]) + ->and($unpaidFirst['status'])->toBe('pending') + ->and($unpaidSecond['status'])->toBe('pending') + ->and(CheckoutQPayStub::$checks)->toBe($checksUnpaid); }); test('customer lookup safely returns null for unknown customer aliases', function () { From aa7c29c085eda77039f0bdf4a064ce2a45b6835c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 13:26:57 +0800 Subject: [PATCH 50/50] fix(checkout): add a network order's other stores as pickup waypoints A network order is collected from every store in the cart: the first is the payload pickup and the others become waypoints. They were added with no type, so Payload::setWaypoints made them dropoffs, and drivers, the console and the customer tracking page read the other stores as places the order is delivered to. --- .../Controllers/v1/CheckoutController.php | 14 +++++++++++++- .../CheckoutPickupWaypointsTest.php | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 server/tests/Unit/Http/Controllers/CheckoutPickupWaypointsTest.php diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 684eccd4..dee6e4bd 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -1776,7 +1776,7 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $origins = $serviceQuote->getMeta('origin'); // set origin $origin = Arr::first($origins); - $waypoints = array_slice($origins, 1); + $waypoints = static::pickupWaypoints(array_slice($origins, 1)); $destination = $serviceQuote->getMeta('destination'); // The cart as it was priced and charged (see Checkout::cartAtCheckout()). $cart = $checkout->cartAtCheckout() ?? $checkout->cart; @@ -2281,6 +2281,18 @@ public function getCheckoutStatus(Request $request) } } + /** + * The stores after the first on a network order, as payload waypoints the driver + * collects from. Without a type a waypoint is a dropoff, so drivers, the console and + * the tracking page read the other stores as places the order is delivered to. + * + * An origin is a store location's place public id, or a place given as an array. + */ + private static function pickupWaypoints(array $origins): array + { + return array_map(fn ($origin) => is_array($origin) ? ['place' => $origin, 'type' => 'pickup'] : ['id' => $origin, 'type' => 'pickup'], array_values($origins)); + } + /** * Calculates the total checkout amount. * diff --git a/server/tests/Unit/Http/Controllers/CheckoutPickupWaypointsTest.php b/server/tests/Unit/Http/Controllers/CheckoutPickupWaypointsTest.php new file mode 100644 index 00000000..fb2d0961 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/CheckoutPickupWaypointsTest.php @@ -0,0 +1,19 @@ +invoke(null, [ + 1 => 'place_rochor', + 2 => ['name' => 'Orchard Grocers', 'location' => [1.3048, 103.8318]], + ]); + + expect($waypoints)->toBe([ + ['id' => 'place_rochor', 'type' => 'pickup'], + ['place' => ['name' => 'Orchard Grocers', 'location' => [1.3048, 103.8318]], 'type' => 'pickup'], + ]); +}); + +test('a single-store order has no pickup waypoints', function () { + expect((new ReflectionMethod(CheckoutController::class, 'pickupWaypoints'))->invoke(null, []))->toBe([]); +});
{{format-currency order.meta.total order.meta.currency}}{{n-a order.customer.name}} {{#if order.meta.is_pickup}} {{t "storefront.component.widget.orders.pickup-order" }} {{else}} - {{n-a order.driver_assigned.name}} + {{/if}}