Skip to Content
DevelopersBuildingDelivery options engine

5 min read

Delivery options engine

The Delivery options app (delivery-options, built-in, manifest in packages/core/src/shipping/delivery-options/app.ts) shows courier-backed delivery options at checkout. Design: docs/superpowers/specs/2026-10-29-delivery-options-design.md. This page is phases P1 and P2 (resilience and margin) as built.

Where the code is

File (packages/core/src/shipping/delivery-options/)What it does
config.tszod schemas: option, product route rule, route setting, route selector. Pure; the admin imports it as @commerce/core/delivery-options
routes.tsRoute identity (providerId:serviceId), carrier names (“Bluedart Air” → Blue Dart), Surface vs Express, selector matching, route settings, insurance price
rules.tsWhich rules a cart matches per option, and the pool they leave (intersect; nothing in common → the highest-placed rule wins)
resolve.tsresolveDeliveryOptions(input): the pure engine. Pool → drop with reasons → pick by strategy (or a rule’s default) → price. Standard picked again for COD. Backup. Trace
quotes.tsgatherProviderAnswers: every domestic app in parallel, 800 ms checkout deadline each, cache by app, origin>PIN, 500 g slab and value bucket (fresh 6 h, kept 7 days for stale-while-revalidate). Late answers still land in the cache
health.tsCircuit breaker per store and app, each app’s recent live calls, the hourly checkout and backup counters, and deliveryHealth for the app page
margin.tsshippingMargin: shipping charged against courier cost plus insurance. Pure; the admin imports it as @commerce/core/delivery-margin
checkout.tsquoteDelivery (gather + resolve), the quote snapshot shape, orderDeliveryFor (the choice → price charged + OrderDeliveryRoute)
service.tsTables: load the config, save/reorder options and rules, route settings; cart and product rule facts
booking.tsThe saved route among Create shipment’s rate rows, why it cannot be booked, and the fallback
admin.tsApp page overview and Try a PIN

Tables (migration 0190_delivery_options): delivery_option and delivery_route_rule (name, position, config JSONB validated by the schemas), delivery_route_setting (key, insures, insurance_bps, insurance_min_paise, cost_card, enabled). shipment gained carrier and delivered_at. All store-scoped with RLS.

The engine

resolveDeliveryOptions({ config, cart, answers, zone, policy, now, declineInsurance }) → { status: 'options' | 'unserviceable' | 'none', options: ResolvedOption[], trace }

For each enabled option:

  1. Candidates: routes the option allows (any, allow, exclude selectors) with its speed.
  2. Rule pool: the enabled rules the cart’s items match for this option (rules.ts).
  3. Drop, first reason wins: not_allowed, wrong_speed, rule, disabled, not_serviceable, no_price (no live price and no cost card), no_insurance (insurance on and the route does not insure), no_cod (COD pick only), over_max_eta.
  4. Pick: the rule’s default route when it fits; else cheapest (cost to the store incl. insurance; tie → direct) or fastest (latest arrival; a real ETA beats an estimate; then cheapest). preferDirectWithinPaise swaps an aggregator winner for a direct route within ₹X (same date for fastest).
  5. Price: roundUpToTenRupees(cost + markup + insurance), then min/max caps, then free-above. Insurance is the quote’s own insurance line, else the route setting’s % with its minimum, else ₹0 (reported as null, and the admin warns).
  6. COD: Standard (and only a standard option with COD allowed) is picked a second time with cod: true; Express is always prepaid only.
  7. Backup: no fitting route, a courier failed and no answered route was serviceable → Express hides, Standard shows the cheapest zone rate (source: 'backup', no route).

Checkout and place order

  • apps/storefront/src/lib/delivery-options.ts runs the engine when the app is installed with an enabled option, writes the quote snapshot to Redis for 30 minutes ({shipping}:delivery-snapshot:<store>:<quoteId>: the courier answers, the cart key, the PIN, the config version and every price shown), and maps options onto CheckoutRate cards with delivery extras (COD, sub-choices, list price).
  • Unticking insurance asks again with the same quoteId: the snapshot’s answers are re-used, no courier is called, and the new prices are added to shown.
  • Pay sends shippingRate (the option id) and deliveryQuote (quoteId, routeKey, insured). deliveryForOrder reads the snapshot (or, if it is gone or the cart changed, cache-only answers) and placeOrder re-runs the engine on those answers inside its transaction, without any courier call, charging min(now, shown). The order’s shipping.delivery (OrderDeliveryRoute) records the option, route, pickedBy, insurance, markup, courier cost, promised dates and quote source.
  • COD with a prepaid-only option throws CheckoutRuleViolation('cod_prepaid_only_option').

Resilience (P2)

The cache, breaker and health all go through one store, QuoteCache (= HealthStore): get/set plus optional incr, push (capped list) and list. The storefront and the admin build it with deliveryQuoteCache(redis) from @commerce/queue. A store without the optional calls (tests, a plain JSON cache) has no breaker and no health; nothing else changes.

  • Stale-while-revalidate. A cache entry is { at, answer } under {shipping}:delivery-quote:v2:… for 7 days. Under 6 h old it is used as is. Older, the app is asked live within the 800 ms deadline; if that misses or fails, the stale answer is used (source: 'cache', stale: true, error says why the live call was not used) and the live call keeps running and rewrites the entry when it lands. cacheOnly (place order without a snapshot) takes stale entries too and never calls a courier.
  • Circuit breaker. Each live call that throws or misses the deadline counts towards {shipping}:delivery-breaker-fails:<store>:<app> (a 60 s window). The third opens {shipping}:delivery-breaker:<store>:<app> for 5 minutes ({ since, error }); while it is open the app is not asked and its stale answer (if any) stands in, else it is failed with Skipped since …. The breaker is per app, not per route: one app’s call answers for all its routes, so that is the unit that fails.
  • Health. Every live call is pushed to {shipping}:delivery-health:<store>:<app> (last 50, 24 h): { at, ok, ms, error }. checkoutDelivery counts each fresh quote (not the insurance re-ask) in hourly delivery-quotes and, when onBackup(result) (status: 'none' or a backup option), delivery-backup counters (25 h). deliveryOptions.overview returns health (per app: calls, success rate, median latency, last error, skipped since; and checkouts / backup in the last 24 h) from ApiContext.deliveryHealth. Try a PIN uses no cache and records nothing.

Order page (P2)

DeliveryRouteCard (apps/admin/src/components/orders/delivery-route-card.tsx) shows order.shipping.delivery: option, carrier, service and app, insured, promised dates. An order with quoteSource: 'backup' (or no route) carries the Placed on backup chip. Staff with analytics.read also see the shipping margin: order.shipping_paise against the latest shipment’s courier_charge_paise (else the route’s courierCostPaise from checkout) plus the insurance charged when insured. No migration: all of it is in the order’s shipping JSONB and the shipment row.

Booking

  • quoteShipments marks the saved route’s row chosenAtCheckout and returns checkout (what the shopper paid, the problem when the route cannot be booked, the fallback key).
  • bulkBookShipments books an order with a saved route through that route’s provider and serviceId; a failure is returned with needsDecision: true instead of another courier. Orders without a route filter the rate rows through the product rules before the store’s courier rule picks.
  • createShipmentForOrder passes insured to the adapter (CreateShipmentInput.insured) and stores shipment.carrier; applyTracking stores delivered_at from the delivered scan.
  • Booking a different route than the saved one (on purpose) adds an order note through noteOffRoute (booking.ts), shown on the order timeline.

No adapter sends an insurance field yet: none is confirmed in the Delhivery, Shiprocket or RapidShyp docs the adapters were written against, and insurance on those accounts is a contract or account setting. Add the field in the adapter once confirmed against a live account.

Tests

resolve.test.ts (strategies, filtering, the Glasspads and Mice rules, mixed carts, ₹10 round-up, Express prepaid only, fallback), quotes.test.ts (deadline, cache, cache-only, with mocked courier clients), health.test.ts (breaker on errors and missed deadlines, stale-while-revalidate, health line, backup share, shipping margin), booking.test.ts and the booking case in shipments.test.ts, the admin flow-state.test.ts and the storefront checkout-preview.test.ts.

Was this helpful?
Last updated on