Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions server/src/Http/Controllers/Api/v1/OrderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand Down
66 changes: 55 additions & 11 deletions server/src/Http/Controllers/Api/v1/ServiceQuoteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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),
];
}

Expand Down
34 changes: 33 additions & 1 deletion server/src/Models/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();
}

/**
Expand Down
2 changes: 1 addition & 1 deletion server/tests/ControllerHelperContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading