Skip to content

fix(orders,quotes): idempotent order creation, UTC scheduling, quote currency, expiry and preview - #412

Merged
roncodes merged 2 commits into
release/v0.6.75from
fix/quote-preview-currency-utc-idempotency
Oct 23, 2026
Merged

roncodes merged 2 commits into
release/v0.6.75from
fix/quote-preview-currency-utc-idempotency

Conversation

@roncodes

Copy link
Copy Markdown
Member

Why

Four API problems found while building the WooCommerce plugin and the Shopify app, which create and quote orders on merchants' behalf:

  1. No idempotent order creation. A connector that times out on POST /v1/orders cannot know whether the order was created, so a retry can duplicate deliveries.
  2. scheduled_at loses its UTC offset. The datetime cast formats the parsed value as Y-m-d H:i:s, so 2026-10-21T11:00:00-07:00 was stored as 11:00 UTC instead of 18:00 UTC. The schedule endpoint had the same problem: it read the time in the company timezone, then stored that wall time as UTC. Dispatch compares scheduled_at with now() in UTC, so orders for companies outside UTC were dispatched at the wrong time.
  3. The currency filter returns nothing. queryFromPreliminary() read currency with $request->has('currency'), so it filtered rates by a boolean.
  4. Every checkout price check stores quote rows with no expired_at, which is noisy for storefront integrations that price each address a customer types.

Changes

  • Idempotency: OrderController::create() (API v1) checks an Idempotency-Key header (max 255 characters), scoped to the company and API credential.
    • A repeat within 24 hours returns the order already created, with Idempotent-Replayed: true.
    • A concurrent duplicate gets 409.
    • A failed creation does not claim the key.
    • Requests without the header behave as before.
  • UTC scheduling: an Order::setScheduledAtAttribute() mutator, plus the time-window normaliser, converts values that carry an offset to the app timezone before formatting.
    • Values without an offset are unchanged.
    • Epoch-dated time-only windows still take the order's date.
  • Quotes:
    • currency is read as a code everywhere.
    • Stored quotes get expired_at, defaulting to 48 hours to match fleetops:purge-service-quotes; set fleetops.service_quotes.ttl_minutes to change it.
    • preview=1 calculates quotes without storing quote or item rows. Preview quotes carry no id, so callers request a stored quote when they need to attach one to an order.

Behaviour change to note

PUT /v1/orders/{id}/schedule with a date, time and company timezone now stores the UTC instant (14:30 Singapore is stored as 06:30), and API inputs with offsets are stored as their real instant. Rows written before this keep their old values. Two existing tests that expected the old values (currency => true and the schedule wall time) are updated.

Verification

  • New tests:
    • OrderControllerIdempotencyTest: replay, scoping, concurrent duplicate, failed creation, missing key.
    • OrderSchedulingTimezoneTest: offsets, Z, no offset, DateTime objects, time windows.
    • ServiceQuoteControllerQueryTest: expiry, TTL fallback, preview with no rows, currency parsing, the preview flag.
  • 186 test files touching orders, quotes or scheduling pass locally (PHP 8.4, Pest). php-cs-fixer is clean. CI will report coverage.

…currency, expiry and preview

- POST /v1/orders honours an Idempotency-Key header: repeats within a day return
  the first order (Idempotent-Replayed: true); a concurrent duplicate gets 409.
- scheduled_at and time windows sent with a UTC offset are stored as the same
  instant in the app timezone. The datetime cast used to drop the offset. The
  schedule endpoint's company-timezone input is now stored in UTC as well.
- Service quotes: the preliminary path read currency with has(), filtering by a
  boolean; it is now the currency code. Stored quotes get expired_at (default
  48h, fleetops.service_quotes.ttl_minutes). preview=1 prices without storing
  quote rows, for display-only callers such as storefront checkouts.
@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (33032e5) to head (6845d8f).
⚠️ Report is 1 commits behind head on release/v0.6.75.

Additional details and impacted files
@@                 Coverage Diff                 @@
##             release/v0.6.75      #412   +/-   ##
===================================================
  Coverage             100.00%   100.00%           
- Complexity             13596     13625   +29     
===================================================
  Files                    634       634           
  Lines                  49342     49393   +51     
===================================================
+ Hits                   49342     49393   +51     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…he locked replay

toStorageDateTime() parsed every value with Carbon::parse(), which throws on a
numeric string such as "1792605600". The datetime cast it replaces accepted Unix
timestamps, so form-encoded API clients that send one would have started
getting a 500. Numeric values now go through Carbon::createFromTimestamp().

Also covers the replay found after the lock is taken (a duplicate that finished
first), the one line codecov/patch flagged.
@roncodes roncodes mentioned this pull request Oct 23, 2026
@roncodes
roncodes changed the base branch from main to release/v0.6.75 October 23, 2026 03:21
@roncodes

Copy link
Copy Markdown
Member Author

Review: the idempotency, currency, expiry and preview changes look right. I pushed one fix and one test (6845d8f):

  • Regression fixed: Order::toStorageDateTime() used Carbon::parse() for every value. That throws InvalidFormatException on a numeric string such as "1792605600", which the datetime cast it replaces accepted (asDateTime() treats numeric values as Unix timestamps). Form-encoded API clients sending an epoch would have got a 500. Numeric values now go through Carbon::createFromTimestamp(). Tests cover an int and a numeric string.
  • codecov/patch: the one uncovered line was the replay found after taking the lock, when a duplicate finished first. A new test covers it.

Notes, not blocking:

  • Replays don't compare the request body with the original, so the same key with a different body returns the first order. Stripe returns 422 in that case. That would be worth a follow-up if connectors could reuse keys.
  • Cache::lock needs a cache store that supports locks (redis, database, file or array). That's fine for the supported deployments.

Retargeted to release/v0.6.75 (#415).

@roncodes
roncodes merged commit 8832c16 into release/v0.6.75 Oct 23, 2026
11 checks passed
@roncodes
roncodes deleted the fix/quote-preview-currency-utc-idempotency branch October 23, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant