diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index 75e7cf96b..b4e654289 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -41,6 +41,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -58,6 +59,74 @@ class OrderController extends Controller * @return \Fleetbase\Http\Resources\Order */ public function create(CreateOrderRequest $request) + { + $idempotencyKey = $this->idempotencyKeyFromRequest($request); + if ($idempotencyKey === null) { + return $this->createOrderFromRequest($request); + } + + // An `Idempotency-Key` makes retries safe: the first request creates the order, and any + // repeat with the same key (same company and credential) within a day returns that order + // instead of creating another. A concurrent duplicate is refused while the first runs. + $cacheKey = $this->orderIdempotencyCacheKey($idempotencyKey); + if ($replayed = $this->replayIdempotentOrder($cacheKey, $request)) { + return $replayed; + } + + $lock = Cache::lock($cacheKey . ':lock', 180); + if (!$lock->get()) { + return response()->json(['errors' => ['A request with this Idempotency-Key is already being processed.']], 409); + } + + try { + if ($replayed = $this->replayIdempotentOrder($cacheKey, $request)) { + return $replayed; + } + + $response = $this->createOrderFromRequest($request); + if ($response instanceof OrderResource && $response->resource instanceof Order && $response->resource->public_id) { + Cache::put($cacheKey, $response->resource->public_id, now()->addDay()); + } + + return $response; + } finally { + $lock->release(); + } + } + + protected function idempotencyKeyFromRequest(Request $request): ?string + { + $key = trim((string) $request->header('Idempotency-Key', '')); + + return $key !== '' && strlen($key) <= 255 ? $key : null; + } + + protected function orderIdempotencyCacheKey(string $idempotencyKey): string + { + return 'fleetops:order-create:' . sha1(implode('|', [(string) $this->sessionCompany(), (string) session('api_credential'), $idempotencyKey])); + } + + protected function replayIdempotentOrder(string $cacheKey, Request $request) + { + $publicId = Cache::get($cacheKey); + if (!is_string($publicId) || $publicId === '') { + return null; + } + + $order = Order::where('public_id', $publicId)->where('company_uuid', $this->sessionCompany())->first(); + if (!$order) { + return null; + } + + return $this->orderResource($order)->response($request)->header('Idempotent-Replayed', 'true'); + } + + /** + * Creates a Fleetbase Order resource from the request (the body of create()). + * + * @return \Fleetbase\Http\Resources\v1\Order + */ + protected function createOrderFromRequest(CreateOrderRequest $request) { set_time_limit(180); diff --git a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php index e3861fa30..17518b775 100644 --- a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php @@ -26,15 +26,16 @@ class ServiceQuoteController extends Controller */ public function query(QueryServiceQuotesRequest $request) { - $payload = $request->input('payload'); - $currency = $request->input('currency'); - $facilitator = $request->input('facilitator'); - $scheduledAt = $request->input('scheduled_at'); - $service = $request->input('service', 'all'); // the specific service rate to query - defaults to `all` - $serviceType = $request->input('service_type'); // the specific type of service rate to query - $single = $request->boolean('single'); - $isRouteOptimized = $request->boolean('is_route_optimized', true); - $requestId = $this->generateServiceQuoteRequestId(); + $this->previewOnly = $request->boolean('preview'); + $payload = $request->input('payload'); + $currency = $this->currencyFromRequest($request); + $facilitator = $request->input('facilitator'); + $scheduledAt = $request->input('scheduled_at'); + $service = $request->input('service', 'all'); // the specific service rate to query - defaults to `all` + $serviceType = $request->input('service_type'); // the specific type of service rate to query + $single = $request->boolean('single'); + $isRouteOptimized = $request->boolean('is_route_optimized', true); + $requestId = $this->generateServiceQuoteRequestId(); if (Utils::isPublicId($payload)) { $payload = Payload::with(['pickup', 'dropoff', 'waypoints', 'entities']) @@ -172,7 +173,7 @@ public function queryFromPreliminary(QueryServiceQuotesRequest $request) $service = $request->input('service', 'all'); // the specific service rate to query - defaults to `all` $serviceType = $request->input('service_type'); // the specific type of service rate to query $isCashOnDelivery = $request->has('cod'); - $currency = $request->has('currency'); + $currency = $this->currencyFromRequest($request); $totalDistance = $request->input('distance'); $totalTime = $request->input('time'); $preliminaryData = $this->preliminaryDataFromRequest($request); @@ -377,16 +378,59 @@ protected function getServicableServiceRates(iterable $waypoints, ?string $servi return ServiceRate::getServicableForPlaces($waypoints, $serviceType, $currency, $callback); } + /** + * Whether this request only previews prices. Preview quotes are calculated the same way but + * never stored, so display-only callers (a storefront checkout pricing every address a + * customer types) do not leave a quote row behind per request. They carry no id and cannot + * be attached to an order; request a stored quote for that. + */ + protected bool $previewOnly = false; + protected function createServiceQuote(array $attributes): ServiceQuote { + $attributes['expired_at'] ??= now()->addMinutes($this->quoteTtlMinutes()); + + if ($this->previewOnly) { + $quote = new ServiceQuote($attributes); + $quote->uuid = (string) Str::uuid(); + + return $quote; + } + return ServiceQuote::create($attributes); } protected function createServiceQuoteItem(array $attributes): ServiceQuoteItem { + if ($this->previewOnly) { + return new ServiceQuoteItem($attributes); + } + return ServiceQuoteItem::create($attributes); } + /** + * How long a stored quote stays valid. Unpurchased quotes are purged after 48 hours + * (fleetops:purge-service-quotes), so the default matches that window. + */ + protected function quoteTtlMinutes(): int + { + $minutes = config('fleetops.service_quotes.ttl_minutes'); + + return is_numeric($minutes) && (int) $minutes > 0 ? (int) $minutes : 2880; + } + + /** + * The requested currency as a code, or null. `currency` used to be read with `has()`, which + * turned it into a boolean and filtered every rate out. + */ + protected function currencyFromRequest(Request $request): ?string + { + $currency = $request->input('currency'); + + return is_string($currency) && trim($currency) !== '' ? strtoupper(trim($currency)) : null; + } + protected function serviceQuoteResource(ServiceQuote $serviceQuote) { return new ServiceQuoteResource($serviceQuote); @@ -406,7 +450,7 @@ protected function preliminaryDataFromRequest(Request $request): array 'waypoints' => $this->requestFirst($request, ['payload.waypoints', 'waypoints'], []), 'entities' => $this->requestFirst($request, ['payload.entities', 'entities']), 'cod' => $request->has('cod'), - 'currency' => $request->has('currency'), + 'currency' => $this->currencyFromRequest($request), ]; } diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 0af1fae5a..7afd523b8 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -768,6 +768,38 @@ public function setStatusAttribute(?string $status = null): void $this->attributes['status'] = is_string($status) ? Str::snake($status) : 'created'; } + /** + * Store scheduled_at in the application timezone. + * + * @see toStorageDateTime() + */ + public function setScheduledAtAttribute($value): void + { + $this->attributes['scheduled_at'] = static::toStorageDateTime($value); + } + + /** + * Format a datetime for storage in the application timezone. + * + * The `datetime` cast formats a parsed value as Y-m-d H:i:s and drops its UTC offset, so + * "2026-10-21T11:00:00-07:00" was stored as 11:00 UTC instead of 18:00 UTC. Converting to the + * application timezone first keeps the instant. Values without an offset parse in the + * application timezone and are stored unchanged. Unix timestamps, numeric strings included, + * are accepted as the cast accepted them. + * + * @param mixed $value A Unix timestamp, anything Carbon::parse() accepts, or null + */ + public static function toStorageDateTime($value): ?string + { + if ($value === null || $value === '') { + return null; + } + + $date = is_numeric($value) ? Carbon::createFromTimestamp($value) : Carbon::parse($value); + + return $date->setTimezone(config('app.timezone', 'UTC'))->toDateTimeString(); + } + /** * Normalise the date portion of time_window_start on write. * @@ -823,7 +855,7 @@ protected function normaliseTimeWindowValue($value): ?string $parsed->setDate($ref->year, $ref->month, $ref->day); } - return $parsed->toDateTimeString(); + return $parsed->setTimezone(config('app.timezone', 'UTC'))->toDateTimeString(); } /** diff --git a/server/tests/ControllerHelperContractsTest.php b/server/tests/ControllerHelperContractsTest.php index cc0cbcdaf..7fd04389e 100644 --- a/server/tests/ControllerHelperContractsTest.php +++ b/server/tests/ControllerHelperContractsTest.php @@ -1175,7 +1175,7 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti ['name' => 'Box'], ], 'cod' => true, - 'currency' => true, + 'currency' => 'USD', ])->and($controller->callHelper('preliminaryStops', $pickup, [null, 'waypoint-public'], $dropoff)->values()->all())->toBe([ $pickup, 'waypoint-public', diff --git a/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php b/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php new file mode 100644 index 000000000..fd3fd4646 --- /dev/null +++ b/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php @@ -0,0 +1,183 @@ + $this->resource->public_id]; + } +} + +class FleetOpsOrderIdempotencyProbe extends OrderController +{ + public int $created = 0; + public array $orders = []; + public mixed $nextResponse = null; + + public function __construct() + { + } + + protected function createOrderFromRequest(CreateOrderRequest $request) + { + $this->created++; + if ($this->nextResponse !== null) { + return $this->nextResponse; + } + + $order = new Order(); + $order->setRawAttributes(['uuid' => 'order-uuid-' . $this->created, 'public_id' => 'order_idem' . $this->created, 'company_uuid' => 'company-1'], true); + $this->orders[$order->public_id] = $order; + + return new OrderResource($order); + } + + protected function sessionCompany(): ?string + { + return 'company-1'; + } + + protected function orderResource(Order $order) + { + return new FleetOpsOrderIdempotencyResource($order); + } + + public function callProtected(string $method, array $arguments = []): mixed + { + $reflection = new ReflectionMethod(OrderController::class, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($this, ...$arguments); + } +} + +function fleetopsIdempotencyRequest(?string $key): CreateOrderRequest +{ + $request = CreateOrderRequest::create('/v1/orders', 'POST', [], [], [], $key === null ? [] : ['HTTP_IDEMPOTENCY_KEY' => $key]); + $request->setLaravelSession(app('session.store')); + + return $request; +} + +function fleetopsIdempotencyBoot(): Illuminate\Database\SQLiteConnection +{ + Cache::flush(); + session(['company' => 'company-1', 'api_credential' => 'cred-1']); + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); + $connection->getSchemaBuilder()->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + + return $connection; +} + +test('requests without an idempotency key create every time', function () { + fleetopsIdempotencyBoot(); + $controller = new FleetOpsOrderIdempotencyProbe(); + + $controller->create(fleetopsIdempotencyRequest(null)); + $controller->create(fleetopsIdempotencyRequest(' ')); + $controller->create(fleetopsIdempotencyRequest(str_repeat('k', 256))); + + expect($controller->created)->toBe(3); +}); + +test('a repeated idempotency key replays the first order', function () { + $connection = fleetopsIdempotencyBoot(); + $controller = new FleetOpsOrderIdempotencyProbe(); + + $first = $controller->create(fleetopsIdempotencyRequest('shopify:shop1:5001')); + expect($first)->toBeInstanceOf(OrderResource::class) + ->and($controller->created)->toBe(1); + + $connection->table('orders')->insert(['uuid' => 'order-uuid-1', 'public_id' => 'order_idem1', 'company_uuid' => 'company-1']); + $replay = $controller->create(fleetopsIdempotencyRequest('shopify:shop1:5001')); + expect($controller->created)->toBe(1) + ->and($replay->headers->get('Idempotent-Replayed'))->toBe('true') + ->and(json_decode($replay->getContent(), true)['data']['id'] ?? json_decode($replay->getContent(), true)['id'] ?? null)->toBe('order_idem1'); + + // A different key creates a new order + $controller->create(fleetopsIdempotencyRequest('shopify:shop1:5002')); + expect($controller->created)->toBe(2); +}); + +test('keys are scoped to the company and credential, and a vanished order is created again', function () { + fleetopsIdempotencyBoot(); + $controller = new FleetOpsOrderIdempotencyProbe(); + + $a = $controller->callProtected('orderIdempotencyCacheKey', ['k']); + session(['api_credential' => 'cred-2']); + $b = $controller->callProtected('orderIdempotencyCacheKey', ['k']); + expect($a)->not->toBe($b); + + // The cached order no longer exists: replay finds nothing and the order is created + Cache::put($b, 'order_gone', 60); + $controller->create(fleetopsIdempotencyRequest('k')); + expect($controller->created)->toBe(1); +}); + +test('a concurrent duplicate is refused and failed creations are not remembered', function () { + fleetopsIdempotencyBoot(); + $controller = new FleetOpsOrderIdempotencyProbe(); + $cacheKey = $controller->callProtected('orderIdempotencyCacheKey', ['busy']); + + $lock = Cache::lock($cacheKey . ':lock', 60); + $lock->get(); + $busy = $controller->create(fleetopsIdempotencyRequest('busy')); + expect($busy->getStatusCode())->toBe(409) + ->and($controller->created)->toBe(0); + $lock->release(); + + // An error response is passed through and does not claim the key + $controller->nextResponse = response()->json(['errors' => ['nope']], 400); + $failed = $controller->create(fleetopsIdempotencyRequest('busy')); + expect($failed->getStatusCode())->toBe(400) + ->and(Cache::get($cacheKey))->toBeNull(); + + $controller->nextResponse = null; + $controller->create(fleetopsIdempotencyRequest('busy')); + expect(Cache::get($cacheKey))->toBe('order_idem2'); +}); + +test('an order stored by a duplicate that finished first is replayed once the lock is taken', function () { + $connection = fleetopsIdempotencyBoot(); + $controller = new class extends FleetOpsOrderIdempotencyProbe { + public int $lookups = 0; + + protected function replayIdempotentOrder(string $cacheKey, Illuminate\Http\Request $request) + { + // The first lookup misses; the duplicate finishes before this request takes the lock + if ($this->lookups++ === 0) { + Cache::put($cacheKey, 'order_raced', 60); + + return null; + } + + return parent::replayIdempotentOrder($cacheKey, $request); + } + }; + $connection->table('orders')->insert(['uuid' => 'order-uuid-raced', 'public_id' => 'order_raced', 'company_uuid' => 'company-1']); + + $replay = $controller->create(fleetopsIdempotencyRequest('raced')); + + expect($controller->created)->toBe(0) + ->and($controller->lookups)->toBe(2) + ->and($replay->headers->get('Idempotent-Replayed'))->toBe('true'); +}); diff --git a/server/tests/Feature/Http/Api/OrderControllerLookupHelpersTest.php b/server/tests/Feature/Http/Api/OrderControllerLookupHelpersTest.php index f77c99dcb..2cae77b97 100644 --- a/server/tests/Feature/Http/Api/OrderControllerLookupHelpersTest.php +++ b/server/tests/Feature/Http/Api/OrderControllerLookupHelpersTest.php @@ -199,7 +199,8 @@ protected function findOrder(string $id, array $with = [], array $withCount = [] ]); $response = $probe->scheduleOrder('order_test', $request); - expect($connection->table('orders')->value('scheduled_at'))->toContain('2026-08-01 14:30'); + // 14:30 in the company timezone (Singapore, UTC+8) is stored as the same instant in UTC + expect($connection->table('orders')->value('scheduled_at'))->toContain('2026-08-01 06:30'); $missing = $probe->scheduleOrder('missing', $request); expect($missing->getStatusCode())->toBe(404) diff --git a/server/tests/Feature/Http/Api/ServiceQuoteControllerQueryTest.php b/server/tests/Feature/Http/Api/ServiceQuoteControllerQueryTest.php index 9228ab184..d92be4128 100644 --- a/server/tests/Feature/Http/Api/ServiceQuoteControllerQueryTest.php +++ b/server/tests/Feature/Http/Api/ServiceQuoteControllerQueryTest.php @@ -403,3 +403,87 @@ public function callProtected(string $method, array $arguments = []): mixed expect(fn () => $controller->callProtected('findServiceQuote', ['missing'])) ->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class); }); + +test('stored quotes expire with the purge window and preview quotes are never stored', function () { + $connection = fleetopsApiServiceQuoteQueryBoot(); + $controller = new class extends ServiceQuoteController { + public function callProtected(string $method, array $arguments = []): mixed + { + $reflection = new ReflectionMethod(ServiceQuoteController::class, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($this, ...$arguments); + } + + public function preview(bool $on): void + { + $this->previewOnly = $on; + } + }; + + Illuminate\Support\Carbon::setTestNow('2026-10-22 12:00:00'); + config()->set('fleetops.service_quotes.ttl_minutes', 30); + $attributes = ['request_id' => 'request_x', 'company_uuid' => 'company-1', 'service_rate_uuid' => 'rate-uuid', 'amount' => 100, 'currency' => 'USD']; + + $stored = $controller->callProtected('createServiceQuote', [$attributes]); + expect($stored->exists)->toBeTrue() + ->and($stored->expired_at->toDateTimeString())->toBe('2026-10-22 12:30:00') + ->and($connection->table('service_quotes')->count())->toBe(1); + + // An explicit expiry is kept; the default follows the 48 hour purge window + config()->set('fleetops.service_quotes.ttl_minutes', null); + expect($controller->callProtected('quoteTtlMinutes'))->toBe(2880) + ->and($controller->callProtected('createServiceQuote', [$attributes + ['expired_at' => '2026-10-23 00:00:00']])->expired_at->toDateTimeString()) + ->toBe('2026-10-23 00:00:00'); + + $controller->preview(true); + $preview = $controller->callProtected('createServiceQuote', [$attributes]); + $item = $controller->callProtected('createServiceQuoteItem', [['service_quote_uuid' => $preview->uuid, 'amount' => 100, 'currency' => 'USD', 'details' => 'Base', 'code' => 'BASE']]); + expect($preview->exists)->toBeFalse() + ->and($preview->uuid)->toBeString() + ->and($item->exists)->toBeFalse() + ->and($connection->table('service_quotes')->count())->toBe(2) + ->and($connection->table('service_quote_items')->count())->toBe(0); + + Illuminate\Support\Carbon::setTestNow(); +}); + +test('the requested currency is read as a code, never as a boolean', function () { + $controller = new class extends ServiceQuoteController { + public function currency(array $input): ?string + { + $reflection = new ReflectionMethod(ServiceQuoteController::class, 'currencyFromRequest'); + $reflection->setAccessible(true); + + return $reflection->invoke($this, new Illuminate\Http\Request($input)); + } + + public function preliminary(array $input): array + { + $reflection = new ReflectionMethod(ServiceQuoteController::class, 'preliminaryDataFromRequest'); + $reflection->setAccessible(true); + + return $reflection->invoke($this, new Illuminate\Http\Request($input)); + } + }; + + expect($controller->currency(['currency' => ' usd ']))->toBe('USD') + ->and($controller->currency(['currency' => '']))->toBeNull() + ->and($controller->currency(['currency' => ['USD']]))->toBeNull() + ->and($controller->currency([]))->toBeNull() + ->and($controller->preliminary(['currency' => 'sgd'])['currency'])->toBe('SGD'); +}); + +test('query switches to preview mode when asked', function () { + fleetopsApiServiceQuoteQueryBoot(); + $probe = new FleetOpsApiServiceQuoteQueryProbe(); + + $probe->query(fleetopsApiServiceQuoteQueryRequest(['preview' => true])); + $previewOnly = new ReflectionProperty(ServiceQuoteController::class, 'previewOnly'); + $previewOnly->setAccessible(true); + expect($previewOnly->getValue($probe))->toBeTrue(); + + $probe->query(fleetopsApiServiceQuoteQueryRequest([])); + expect($previewOnly->getValue($probe))->toBeFalse() + ->and($probe->preliminaryCalls)->toHaveCount(2); +}); diff --git a/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php b/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php new file mode 100644 index 000000000..1bd479ece --- /dev/null +++ b/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php @@ -0,0 +1,48 @@ +set('app.timezone', 'UTC'); + $order = new Order(); + + $order->scheduled_at = '2026-10-21T11:00:00-07:00'; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + + $order->scheduled_at = '2026-10-21T18:00:00Z'; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + + // Values without an offset are read in the app timezone and stored unchanged + $order->scheduled_at = '2026-10-21 09:30:00'; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 09:30:00'); + + $order->scheduled_at = new DateTimeImmutable('2026-10-21 11:00:00', new DateTimeZone('America/Los_Angeles')); + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + + // Unix timestamps, as an integer or a numeric string from a form body, are the same instant + $order->scheduled_at = 1792605600; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + + $order->scheduled_at = '1792605600'; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + + $order->scheduled_at = ''; + expect($order->getAttributes()['scheduled_at'])->toBeNull() + ->and(Order::toStorageDateTime(null))->toBeNull(); +}); + +test('time windows with an offset are converted too, and epoch-dated times take the order date', function () { + config()->set('app.timezone', 'UTC'); + $order = new Order(); + + $order->time_window_start = '2026-10-21T09:00:00-07:00'; + expect($order->getAttributes()['time_window_start'])->toBe('2026-10-21 16:00:00'); + + $order->scheduled_at = '2026-10-23 08:00:00'; + $order->time_window_end = '1970-01-01 11:00:00'; + expect($order->getAttributes()['time_window_end'])->toBe('2026-10-23 11:00:00'); +});