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.ts | zod schemas: option, product route rule, route setting, route selector. Pure; the admin imports it as @commerce/core/delivery-options |
routes.ts | Route identity (providerId:serviceId), carrier names (“Bluedart Air” → Blue Dart), Surface vs Express, selector matching, route settings, insurance price |
rules.ts | Which rules a cart matches per option, and the pool they leave (intersect; nothing in common → the highest-placed rule wins) |
resolve.ts | resolveDeliveryOptions(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.ts | gatherProviderAnswers: 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.ts | Circuit breaker per store and app, each app’s recent live calls, the hourly checkout and backup counters, and deliveryHealth for the app page |
margin.ts | shippingMargin: shipping charged against courier cost plus insurance. Pure; the admin imports it as @commerce/core/delivery-margin |
checkout.ts | quoteDelivery (gather + resolve), the quote snapshot shape, orderDeliveryFor (the choice → price charged + OrderDeliveryRoute) |
service.ts | Tables: load the config, save/reorder options and rules, route settings; cart and product rule facts |
booking.ts | The saved route among Create shipment’s rate rows, why it cannot be booked, and the fallback |
admin.ts | App 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:
- Candidates: routes the option allows (
any,allow,excludeselectors) with its speed. - Rule pool: the enabled rules the cart’s items match for this option (
rules.ts). - 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. - Pick: the rule’s default route when it fits; else
cheapest(cost to the store incl. insurance; tie → direct) orfastest(latest arrival; a real ETA beats an estimate; then cheapest).preferDirectWithinPaiseswaps an aggregator winner for a direct route within ₹X (same date forfastest). - 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 asnull, and the admin warns). - COD: Standard (and only a standard option with COD allowed) is picked a second time
with
cod: true; Express is always prepaid only. - 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.tsruns 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 ontoCheckoutRatecards withdeliveryextras (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 toshown. - Pay sends
shippingRate(the option id) anddeliveryQuote(quoteId,routeKey,insured).deliveryForOrderreads the snapshot (or, if it is gone or the cart changed, cache-only answers) andplaceOrderre-runs the engine on those answers inside its transaction, without any courier call, chargingmin(now, shown). The order’sshipping.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,errorsays 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 isfailedwithSkipped 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 }.checkoutDeliverycounts each fresh quote (not the insurance re-ask) in hourlydelivery-quotesand, whenonBackup(result)(status: 'none'or abackupoption),delivery-backupcounters (25 h).deliveryOptions.overviewreturnshealth(per app: calls, success rate, median latency, last error, skipped since; and checkouts / backup in the last 24 h) fromApiContext.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
quoteShipmentsmarks the saved route’s rowchosenAtCheckoutand returnscheckout(what the shopper paid, the problem when the route cannot be booked, the fallback key).bulkBookShipmentsbooks an order with a saved route through that route’s provider andserviceId; a failure is returned withneedsDecision: trueinstead of another courier. Orders without a route filter the rate rows through the product rules before the store’s courier rule picks.createShipmentForOrderpassesinsuredto the adapter (CreateShipmentInput.insured) and storesshipment.carrier;applyTrackingstoresdelivered_atfrom 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.