Skip to content

9.7.24 — Shopping Cart & Order

"Design an online shopping cart and the order it turns into."

Someone puts a jacket in their cart on Tuesday and checks out on Friday. In between, the price changed, a discount code expired, the last one in their size sold out, and they logged in on a different device. The cart is easy to draw and hard to get right, and every one of those four sentences is a design decision most candidates never make explicitly.

The spine of the page is one rule: nothing in a cart is yours. A cart is a list of intentions, not a claim on anything, and the moment it stops being that — the moment you reserve stock for a cart — you have built a completely different and much worse system. Everything below follows from that.

1. The cart holds references, and the price is not one of them

typescript
interface Cart {
  id: CartId;
  ownerId: UserId | null;           // (1)
  sessionId: SessionId | null;      // (2)
  lines: CartLine[];
  updatedAt: Instant;               // (3)
}

interface CartLine {
  id: LineId;
  skuId: SkuId;                     // (4)
  quantity: number;
  addedAt: Instant;
  priceWhenAdded: Money;            // (5)
}

(1) and (2) A cart belongs to a signed-in user or to an anonymous browser session. Both must work, because a shop that demands a login before letting you add anything loses most of its customers at that step. Section 3 handles what happens when the two meet.

(3) The last touch, which is what an abandoned-cart job reads and what an expiry policy uses.

(4) The line points at a SKU, which is short for stock keeping unit and means one exact purchasable thing: this jacket, in navy, in medium. Not the product. A product page shows one jacket; a cart holds a specific size and colour, and stock is counted per SKU. Storing a product identifier and hoping the size is somewhere else is the most common data-model error in this problem.

(5) The price the customer saw when they added it — kept for display only, so the cart can say "this went down by £8 since you added it", which is a real feature. It is not what they will be charged. That difference is the next section.

Now the rule stated plainly: adding to a cart reserves nothing. No stock is decremented, no row is locked, nothing is held. Two thousand people can have the last jacket in their carts at once, and 1,999 of them will be disappointed at checkout.

This is a deliberate choice, and the interviewer will ask you to defend it. The alternative is holding stock per cart, and it fails on the arithmetic: carts live for days, most are abandoned, and holding stock for abandoned carts makes a shop that has inventory report itself as sold out. Every business that tried this went back. The narrow exception is a genuinely scarce, high-demand item — event tickets, a limited drop — where a short hold at the point of checkout is worth its cost, which is the flash-sale design in Part 11 rather than the shop design here.

So the honest promise a cart makes is: we will keep your list, we will tell you when something in it changes, and we will not lie to you about availability. That last one is a real obligation — the cart page must show current stock and current price, not the values captured when the item was added.

2. The price is computed at checkout, by a pipeline, in order

The total is not a sum of line prices. It is the output of a sequence of steps, and the order of the steps changes the number, which is why this belongs in the design rather than in a helper function.

① line price£120 × 1② line discount−£20 sale③ cart discount−10% code④ shipping+£0 (free over £75)⑤ taxon ③+④swap ② and ③ and the customer pays a different amount10% off £120 then −£20 is £88. −£20 then 10% off is £90. Both are defensible policies and only one is your policy,so the order must be written down rather than emerging from whichever function happened to run first.Stage ④ reads the result of ③, which is why "free shipping over £75" is ambiguous until you say: before or after the discount?
Figure 1 — The pricing pipeline. Five ordered stages, each reading the output of the last. The order is policy, not implementation detail, and two of the stages depend on the result of the one before.
typescript
interface PricingStage {                                        // (1)
  name: string;
  apply(quote: Quote, ctx: PricingContext): Quote;
}

const pipeline: PricingStage[] = [                              // (2)
  linePrices, lineDiscounts, cartDiscounts, shipping, taxes,
];

function priceCart(cart: Cart, ctx: PricingContext): Quote {
  return pipeline.reduce((q, stage) => stage.apply(q, ctx), emptyQuote(cart));  // (3)
}

(1) Every stage has the same shape: take the quote so far, return a new quote. That uniformity is what makes the list in (2) meaningful — the sequence is the pricing policy, readable in one line, changeable without touching the stages.

(3) Fold the stages over the quote in order. Nothing mutates, so the intermediate results survive, and that matters for the next paragraph.

The quote must keep its working, not just its answer. A customer who is charged £88 will ask why, a support agent will ask why, and an accountant will ask why. So the quote carries a list of adjustments — each with the stage that produced it, the rule that fired, and the amount — and the total is the sum of them. A total with no explanation is a support ticket waiting to happen, and reconstructing one later from rules that have since changed is impossible.

Two rules about discounts that must be explicit because "the code did whatever it did" is not a policy:

Do discounts stack? If two codes apply, does the customer get both, the larger one, or the first one? Every shop has an answer and most candidates never ask. The clean model is that each discount declares whether it can combine, and the stage resolves conflicts by a stated rule.

Can the total go below zero? A £30 discount on a £25 order must clamp at zero and must not generate a refund. This sounds obvious and it is a real bug that ships regularly.

Rounding happens once, at the end of the money stage, and it happens on the total rather than per line — or a ten-line order accumulates ten rounding errors and the customer's arithmetic disagrees with yours by a few pence. Tax is the one place this bites hardest, and the rule your tax authority requires is a question to ask rather than a choice to make.

3. The guest cart and the account cart meet

Someone shops anonymously, adds three things, then logs in — and they already have a cart from last week on their laptop. This is asked in almost every interview on this problem and there is no single right answer, so the strong response is to name the options and pick one with a reason.

typescript
function mergeCarts(guest: Cart, account: Cart): Cart {         // (1)
  const merged = new Map<SkuId, CartLine>();

  for (const line of account.lines) merged.set(line.skuId, line);           // (2)

  for (const line of guest.lines) {
    const existing = merged.get(line.skuId);
    merged.set(line.skuId, existing
      ? { ...existing, quantity: Math.max(existing.quantity, line.quantity) } // (3)
      : line);
  }
  return { ...account, lines: [...merged.values()] };                        // (4)
}

(1) Merge rather than replace. Replacing loses whichever side the customer did not expect to lose, and they will not tell you — they will just leave.

(2) Start from the account cart, so the older, deliberate list is the base.

(3) For a SKU in both, take the larger quantity rather than the sum. Adding them is the intuitive move and it is wrong: someone who put one jacket in on their phone and one on their laptop wants one jacket, and a shop that quietly makes it two will get a return and a complaint. The maximum is the interpretation that is never surprising.

(4) The merged cart keeps the account's identity, so the guest cart can be discarded and nothing points at it afterwards.

The one case where the maximum is wrong is a customer who genuinely added two separately and wants two, and there is no way to tell those apart from the data. So the humane answer is to merge with the maximum and show the customer the merged cart before checkout, with the quantities visible. The design decision is not the rule; it is that the customer sees the outcome before they pay.

A related trap: the guest cart's lines may no longer be valid. The SKU may be discontinued, the price may have changed, the stock may be gone. Merging is therefore also a revalidation — every line is re-checked, and anything that has become unavailable is moved into a visible "no longer available" list rather than silently dropped. Silently dropping a line is how a customer receives four items when they thought they ordered five.

4. Checkout turns intentions into a frozen record

The moment of checkout is where the cart's "nothing is yours" ends. Three things happen and their order matters.

First, everything is revalidated against the present. Prices, stock, discount codes, the address, the shipping options. The cart's stored priceWhenAdded is irrelevant here; the customer is charged the price now, and if it moved they are told before they pay rather than after.

Second, the order is written as a snapshot. This is the most important sentence on the page:

typescript
interface Order {
  id: OrderId;
  customerId: UserId;
  placedAt: Instant;
  lines: OrderLine[];               // (1)
  shippingAddress: Address;         // (2)
  adjustments: Adjustment[];        // (3)
  total: Money;
  state: OrderState;
  idempotencyKey: string;           // (4)
}

interface OrderLine {
  skuId: SkuId;
  titleAtPurchase: string;          // (5)
  quantity: number;
  unitPrice: Money;
  lineTotal: Money;
}

(1) to (5) all serve one rule: an order never reads anything that can change. The price is copied. The title is copied, so a product renamed next year does not rewrite last year's invoice. The address is copied, so a customer updating their profile does not retroactively change where an old parcel went. Even the discount is copied as a resolved adjustment rather than a code that would have to be re-evaluated. An order is a record of an agreement at a moment, and a record that changes when the world changes is not a record.

(4) The idempotency key is generated by the browser before the checkout request and enforced by a unique index. The customer double-clicks Pay, the network times out and the app retries, and neither produces a second order. Every payment path in this book has this and it is always one index (9.7.10).

Third, stock is claimed — and only now.

sql
UPDATE sku_inventory
   SET available = available - :qty
 WHERE sku_id = :skuId
   AND available >= :qty;           -- (1)

(1) The same conditional claim as everywhere else: zero rows affected means it has gone. All the lines are claimed in one transaction, so a partially claimed order cannot exist, and the lines are claimed in a consistent order — sorted by SKU identifier — because two concurrent multi-line orders that lock the same two rows in opposite orders will deadlock. That is Coffman's circular wait from 9.5.3, and sorting the identifiers is the standard cure.

What happens when a line fails is a product decision that must be stated. Three options: fail the whole order, ship what is available and cancel the rest, or offer the customer the choice. Most shops offer the choice at the point of failure because the alternatives both lose sales, and this is a good moment to say that the design supports all three because the claim is per line inside one transaction that can be rolled back.

Then the cart is emptied — but not deleted. The lines that became an order are removed; anything that failed revalidation stays, so the customer's remaining list is intact and they can see what did not go through.

5. The order's life, and why one order becomes several parcels

The state machine everyone draws is a straight line: placed → paid → shipped → delivered. Real orders are not a straight line, because an order of three items can ship as two parcels from two warehouses on two days.

typescript
type OrderState =
  | { kind: "pendingPayment" }
  | { kind: "paid"; paymentId: PaymentId }
  | { kind: "partiallyFulfilled" }                          // (1)
  | { kind: "fulfilled" }
  | { kind: "cancelled"; reason: CancelReason; at: Instant }
  | { kind: "returned"; returnId: ReturnId };

interface Shipment {                                        // (2)
  id: ShipmentId;
  orderId: OrderId;
  warehouseId: WarehouseId;
  lines: { skuId: SkuId; quantity: number }[];              // (3)
  trackingNumber: string | null;
  state: "packing" | "dispatched" | "delivered" | "lost";
}

(1) A real state, not a gap between two others. Half the order is with the customer and half is not, and everything from the tracking page to the refund calculation has to work in this state.

(2) The shipment is its own entity because it has its own life: it is packed, it is dispatched, it is delivered, it can be lost, and none of those events belong to the order as a whole.

(3) A shipment carries quantities of SKUs, not order lines, because a line of three jackets can split across two parcels. Modelling the shipment as a set of line identifiers makes that unrepresentable, and it is exactly what happens when a warehouse has two of the three.

So an order line's fulfilled quantity is derived, by summing that SKU's quantities across the order's dispatched shipments. The order's state is derived from that in turn: fulfilled when every line is fully covered, partially fulfilled when some are. Deriving these rather than storing them means a shipment being marked delivered updates the order automatically and cannot leave the two disagreeing.

Payment and fulfilment are separate timelines, and the order they happen in is a business decision. Most shops authorise at checkout — the bank confirms the money exists and sets it aside — and capture when the parcel is dispatched, which is often the legal requirement. That single detail means the order has a paid-looking state where no money has actually moved, and a design with one boolean isPaid cannot express it. Same distinction as the wallet's held versus settled balance in 9.7.10.

6. Cancelling, returning and refunding are three different things

Cancelling before dispatch is easy and is the only one that is simple: the shipment has not left, the stock is returned to available with a conditional increment, and the payment authorisation is released rather than refunded. Releasing an authorisation costs nothing; refunding a capture costs the shop a transaction fee, which is why the ordering in section 5 exists.

Cancelling after dispatch is not a cancellation, it is a return, and the difference matters because the parcel exists in the world. The customer's request creates a Return with its own life: requested, approved, in transit, received, inspected, refunded. Stock does not go back to available when the return is approved. It goes back when it is received and inspected, because a returned item may be damaged, may be the wrong item, or may be an empty box.

A refund is a compensating event, never an edit. The order's total does not change. A refund is a new record pointing at the payment, with its own amount, reason and state, and the amount owed is derived from the payments minus the refunds. Editing the order's total to £0 destroys the fact that £120 was once taken, which is the exact fact an accountant, a dispute and a tax return all need.

Partial refunds are the normal case, and they are where the arithmetic gets sharp. A customer returns one of three jackets from an order that had a "10% off orders over £100" discount. Refund the full £40 line price and the remaining order no longer qualifies for the discount the customer received, so the shop has refunded more than it took. The correct refund is the line price minus its share of the cart-level discount, which is only computable because section 2's quote kept every adjustment and which stage produced it. This is the payoff for storing the working rather than the total, and it is a strong thing to volunteer.

7. What the interviewer will push on

"Do you reserve stock when something is added to the cart?" No, and the reason is arithmetic rather than taste: carts live for days, most are abandoned, and holding stock for abandoned carts makes a shop with inventory report itself sold out. The cart holds references and promises only to be honest about availability. The narrow exception is a genuinely scarce item, where a short hold at checkout is worth its cost.

"The price changed while the item sat in the cart." The customer pays the current price and is told before they pay, not after. priceWhenAdded is kept for display — "this dropped by £8" — and is never charged. Then the follow-up worth pre-empting: the order copies its prices, so once placed, nothing about it moves again.

"They log in and already have a cart." Merge rather than replace; for a SKU in both, take the maximum quantity, not the sum, because one jacket on the phone and one on the laptop means one jacket. Then say the part that makes it humane: merging also revalidates, anything unavailable goes into a visible list rather than being dropped, and the customer sees the merged cart before paying.

"Two customers check out the last item at the same instant." One conditional UPDATE per line inside one transaction, with the lines locked in sorted SKU order so two multi-line orders cannot deadlock by taking the same rows in opposite orders. Zero rows affected is the signal, not an error, and what happens next — fail the order, ship what is available, or ask — is a stated product decision.

"Why is the order a snapshot?" Because an order is a record of an agreement at a moment. Prices, titles and addresses are all copied, so a renamed product does not rewrite an old invoice and a profile edit does not change where a parcel went. An order that reads live data is not a record of anything.

"One order, two warehouses." Shipments are separate entities carrying quantities of SKUs rather than line identifiers, because a line of three can split across two parcels. partiallyFulfilled is a real state, and the order's state is derived from the shipments rather than stored alongside them.

"Refund one item from a discounted order." The line price minus its share of the cart-level discount, which is computable only because the quote stored each adjustment and its stage. Refunding the full line price on a discounted order refunds more than was taken. And the refund is a new record, never an edit to the order's total.

The thing to volunteer that nobody asks for: the difference between authorising and capturing. Most candidates have one boolean for payment, and that boolean cannot express the normal state of a real shop — money confirmed and set aside at checkout, actually taken when the parcel is dispatched, and released for free if the order is cancelled before then. Knowing that a cancellation before dispatch costs nothing while a refund after dispatch costs a fee is the sort of detail that only comes from having worked on one of these.

Recall

  • Nothing in a cart is yours. Adding reserves no stock. Carts live for days and are mostly abandoned, so holding stock makes a stocked shop report itself sold out.
  • A cart line points at a SKU — the exact purchasable variant — not a product.
  • priceWhenAdded is for display only ("this dropped by £8"). The charge is the current price, shown before payment.
  • Pricing is an ordered pipeline: line prices, line discounts, cart discounts, shipping, tax. Swapping two stages changes the amount, so the order is policy.
  • The quote keeps its working — every adjustment with the stage and rule that produced it. That is what makes a partial refund computable later.
  • State whether discounts stack, and clamp the total at zero.
  • Merge carts on login, taking the maximum quantity per SKU, not the sum. Merging also revalidates, and unavailable lines become visible rather than dropped.
  • An order is a snapshot. Prices, titles, addresses and resolved discounts are copied, so nothing that changes later can rewrite it.
  • Stock is claimed at checkout, one conditional UPDATE per line in one transaction, lines sorted by SKU to prevent deadlock.
  • An idempotency key from the browser with a unique index stops the double-click becoming two orders.
  • Shipments are separate entities carrying SKU quantities, because one line can split across two parcels. partiallyFulfilled is a real state and order state is derived from shipments.
  • Authorise at checkout, capture at dispatch. Cancelling before dispatch releases for free; refunding after costs a fee.
  • Refunds are compensating records, never edits. A partial refund is the line price minus its share of the cart-level discount.
  • Returned stock becomes available when received and inspected, not when the return is approved.

Self-test: Why does a cart reserve nothing? Which price is charged and which is displayed? Why does stage order change the total? Why maximum rather than sum on merge? Why sort the SKUs before claiming? Why can a shipment not be a list of line identifiers?

Quiz Bank

FoundationalModel a cart and defend the decision not to reserve stock when an item is added.

The model is deliberately thin, and the thinness is the point.

typescript
interface Cart {
  id: CartId;
  ownerId: UserId | null;      // signed in
  sessionId: SessionId | null; // or anonymous
  lines: CartLine[];
  updatedAt: Instant;
}

interface CartLine {
  id: LineId;
  skuId: SkuId;                // the exact variant, not the product
  quantity: number;
  addedAt: Instant;
  priceWhenAdded: Money;       // display only
}

The line points at a SKU, which means one exact purchasable thing — this jacket, navy, medium. A product page shows a jacket; a cart holds a size. Stock is counted per SKU, so a cart line that names a product cannot be checked for availability at all without guessing which variant was meant.

priceWhenAdded is not what the customer pays. It exists so the cart can say "this dropped by £8 since you added it", which is a genuinely useful feature and a good reason to keep it. The charge is always the current price, revalidated at checkout and shown before payment. Charging the captured price sounds customer-friendly and is a liability: a pricing error left in a hundred thousand carts becomes a hundred thousand orders at the wrong price.

Now the defence, which is arithmetic rather than philosophy.

Carts live for days. Most are abandoned — the widely quoted figure is around 70%, and the exact number does not matter because any number near it produces the same conclusion. If adding to a cart reserved stock, then most reserved stock would be held for people who will never buy, and a shop with a warehouse full of jackets would tell customers it had none.

The workarounds all fail in their own way. Short cart expiry means a customer returns after lunch to an empty cart. Long expiry means the sold-out problem. Reserving only for "serious" carts requires knowing which are serious, which is the thing you cannot know.

So the cart's honest promise is narrow and worth stating explicitly: we keep your list, we show you current price and current availability rather than stale captured values, and we tell you clearly if something changes. That third obligation is real design work — the cart page reads live stock and live price, and a line that has become unavailable is shown as unavailable rather than quietly rendering as normal.

The exception, named so the answer is not dogmatic. A genuinely scarce item with enormous demand — a ticket release, a limited drop — does justify a hold, because there the cost of disappointing 1,999 people at the payment page is worse than the cost of holding stock. But the hold belongs at checkout and lasts minutes, not at add-to-cart and lasting days, and it needs the expiry-by-being-ignored mechanism from 9.7.9 so that no cleanup job races a paying customer.

What the cart does need is expiry of its own, for a different reason: an abandoned cart from eight months ago is full of discontinued SKUs and prices that no longer exist. Expiring or revalidating old carts is housekeeping, not inventory control, and keeping those two motivations separate is what stops the design drifting back towards reservation.

AppliedShow the pricing pipeline in code and prove with numbers that the stage order matters. Then show how a partial refund uses what it stored.

The pipeline is a list of stages, and the list is the policy.

typescript
interface PricingStage {
  name: string;
  apply(quote: Quote, ctx: PricingContext): Quote;
}

const pipeline: PricingStage[] = [
  linePrices, lineDiscounts, cartDiscounts, shipping, taxes,
];

function priceCart(cart: Cart, ctx: PricingContext): Quote {
  return pipeline.reduce((q, s) => s.apply(q, ctx), emptyQuote(cart));
}

Every stage takes the quote so far and returns a new one, which is what makes the sequence readable in a single line and changeable without touching any stage's code.

Now the numbers, on a £120 jacket with a £20 sale and a 10% code.

Order as written — line discount before cart discount: £120 − £20 = £100, then 10% off = £90.

Order reversed — cart discount before line discount: 10% off £120 = £108, then −£20 = £88.

Two pounds apart, both perfectly defensible as policy, and only one of them is your policy. If the order is decided by which function happens to run first, then the shop's pricing policy is an accident, and it will change the day someone reorders the calls while tidying up.

The dependency goes further than arithmetic. Shipping reads the discounted total, so "free shipping over £75" is ambiguous until you say before or after the discount. On these numbers it decides whether an £88 order ships free. Tax then reads shipping's output, because in many places delivery is taxable. Three of the five stages depend on what came before, which is precisely why this is a pipeline and not a bag of independent rules.

The quote keeps its working, not just its total:

typescript
interface Quote {
  lines: QuoteLine[];
  adjustments: Adjustment[];   // { stage, ruleId, label, amount, appliesTo }
  total: Money;
}

Every adjustment records which stage produced it, which rule fired, what it was worth, and what it applied to. The total is the sum of them. This is what lets a support agent answer "why is this £90" a year later, when the rule that produced it has been deleted.

And now the partial refund, which is where the stored working pays for itself.

An order of three jackets at £40 each is £120, with a "10% off orders over £100" cart discount, so the customer paid £108. They return one jacket.

The naive refund is the line price: £40. But the remaining order is now £80, which no longer qualifies for the discount the customer already received. The shop has refunded £40 on a sale it took £108 for, on goods worth £80 at full price — it has given away £12 it never had.

The correct refund is the line's price minus its share of the cart-level discount. The £12 discount applied across £120 of goods, so this £40 line carried £4 of it, and the refund is £36.

typescript
function refundFor(line: OrderLine, quote: Quote): Money {
  const cartLevel = quote.adjustments.filter(a => a.appliesTo === "cart");
  const share = cartLevel.reduce(
    (sum, a) => sum.plus(a.amount.times(line.lineTotal.dividedBy(quote.goodsTotal))),
    Money.zero,
  );
  return line.lineTotal.minus(share);
}

This is only computable because the adjustments were stored with the stage and the scope that produced them. A quote that saved only total: £108 cannot answer it, and the shop is left choosing between over-refunding every return or working it out by hand.

Two rules worth stating alongside, because they are common bugs rather than edge cases. Discounts must declare whether they stack, and the conflict rule must be written down rather than being whichever code path ran. And the total must clamp at zero: a £30 discount on a £25 order is a £25 discount, not a £5 payment to the customer.

InterviewTwo customers check out the last unit at the same instant, and one of them has three items in their basket. Walk the transaction and everything it must survive.

The claim happens at checkout and nowhere earlier, which is what makes this moment the only contended point in the whole design. Everything before it — browsing, adding, editing quantities — touched no shared row at all.

The claim itself is one conditional statement per line:

sql
UPDATE sku_inventory
   SET available = available - :qty
 WHERE sku_id = :skuId
   AND available >= :qty;

The row lock serialises the two customers whether they like it or not. One statement finds available >= 1 true and affects a row; the other affects zero rows. Zero rows affected is the signal, not an error — that customer is told the item has gone, at the checkout page, before any money moves.

All the lines are claimed inside one transaction. A three-item order must not end up with two items claimed and one not, because that is a state nobody can act on: the stock is consumed, no order exists, and no process will ever release it. One transaction means either all three are claimed or the whole thing rolls back and nothing is consumed.

And the lines must be claimed in a consistent order — sorted by SKU identifier. This is the detail that separates a working design from one that mysteriously fails under load. Two multi-line orders sharing two SKUs, each locking them in the opposite order, produce a circular wait: each holds one row and waits for the other. That is Coffman's fourth condition from 9.5.3, and the standard cure is exactly this — impose a global order on lock acquisition, and the cycle becomes impossible to form. Sorting the SKU identifiers costs one line and removes a class of production incident that is very unpleasant to diagnose.

What happens when one line of three fails is a product decision, and the design must support all three answers:

Fail everything. Simple, and it loses a sale that was two-thirds available.

Ship what is available, drop the rest. Better for revenue, worse for a customer who wanted the set.

Ask the customer. Almost always what real shops do, because it converts more of them, and because it is the only option that does not decide something on the customer's behalf.

Because the claim is per line inside a rollback-able transaction, all three are available without restructuring anything — which is the thing worth saying, since it shows the design has room for the answer rather than assuming one.

Now the retries, which are guaranteed. The customer double-clicks Pay. The mobile network times out and the app resends. Both must produce one order:

typescript
idempotencyKey: string;  // generated by the browser, unique index in the database

The second attempt violates the index and returns the first attempt's result instead of creating anything. Without this, the duplicate does not merely create a second order — it claims the stock twice, which is the more expensive half of the bug.

Payment sits after the claim, and its failure has to be handled explicitly. Stock is claimed, the card is declined, and the stock must go back. So the order enters pendingPayment with the stock held, and a payment failure or a timeout releases it with a conditional increment. That release must be idempotent and it must have a fallback: an order sitting in pendingPayment past a deadline is swept and released, because a process that crashed between claiming and paying would otherwise hold stock forever.

One thing I would say without being asked. The right sequence is authorise the payment, then capture at dispatch — so at checkout the money is confirmed and set aside but not taken. That makes the failure path above cheap: releasing an authorisation costs nothing, while refunding a capture costs the shop a fee on every abandoned checkout. A design that captures at checkout works, and quietly pays for its own convenience on every failed order.

StaffAn order of three items ships as two parcels from two warehouses, one parcel is lost, and the customer returns one item from the parcel that arrived. Show that the model handles all of it.

The straight-line state machine cannot handle any of this, and that is the point of the question. Placed → paid → shipped → delivered has no way to describe half an order in the customer's hands, and a lost parcel has no place at all.

Fulfilment is its own entity, and it carries quantities rather than line references:

typescript
interface Shipment {
  id: ShipmentId;
  orderId: OrderId;
  warehouseId: WarehouseId;
  lines: { skuId: SkuId; quantity: number }[];
  trackingNumber: string | null;
  state: "packing" | "dispatched" | "delivered" | "lost";
}

The { skuId, quantity } shape is essential rather than convenient. A line of three jackets can split as two in one parcel and one in another, and a shipment modelled as a list of order-line identifiers cannot represent that at all. The moment a warehouse has two of the three, that model is stuck.

The order's progress is derived, never stored twice. A line's fulfilled quantity is the sum of that SKU's quantities across dispatched shipments; the order is fulfilled when every line is covered and partiallyFulfilled when some are. Deriving it means marking a shipment delivered updates the order with no extra write, and there is no way for the two to disagree — which they would, eventually, if both were stored.

Now the lost parcel. lost is a shipment state, not an order state, and that distinction is what makes the rest work. The order is still valid, the customer still paid, and one parcel's worth of goods is gone. What follows is a decision with two branches:

Reship. A new shipment is created for the same SKUs and quantities from whichever warehouse has them. The order gains a third shipment and no other entity changes. Notice how little the model has to bend for this — it is just another row.

Refund those lines. A refund record for the value of those lines, computed with the discount-share arithmetic below.

Stock accounting must not be forgotten in the lost case. The units in the lost parcel are gone from the warehouse but were never delivered. They are written off rather than returned to available, which is a different ledger movement from a return, and conflating the two makes the inventory count drift in a way nobody can trace. Saying this unprompted is worth a lot, because it is the part that only shows up when someone reconciles a warehouse.

Then the return from the parcel that arrived. A return is not a cancellation, because the goods are in the world:

typescript
type ReturnState =
  | { kind: "requested" } | { kind: "approved"; label: string }
  | { kind: "inTransit" } | { kind: "received" }
  | { kind: "inspected"; outcome: "restock" | "damaged" | "wrongItem" }
  | { kind: "refunded"; refundId: RefundId };

Stock returns to available at inspected, not at approved. A returned item can be damaged, can be the wrong item, or can be an empty box, and a system that restocks on approval will sell a customer an item that does not exist. The inspection outcome is what decides whether the unit rejoins the sellable count or is written off.

The refund amount is where the earlier decisions pay off. Suppose the order was three items at £40 with a 10% cart discount over £100, so £108 was taken. Refunding the returned line at £40 gives back more than the shop received for it. The correct amount is the line total minus its share of the cart-level adjustment — £36 — and it is computable only because the quote stored each adjustment with the stage and scope that produced it.

And the refund is a new record, never an edit. The order's total stays at £108 forever. Payments and refunds are two lists, and what the customer is owed is the difference. Editing the order's total to £72 would destroy the fact that £108 was taken, which is exactly the fact that a dispute, an accountant and a tax return all need. It is the same append-only reasoning that makes a bid retraction an event in 9.7.20 and a wallet balance a sum of entries in 9.7.10 — three different problems, one habit.

What I would monitor. The rate of checkouts failing at the stock claim, since a rise means the availability shown to customers is drifting from reality; orders stuck in pendingPayment past the sweep deadline, which should be near zero and is stock quietly held hostage when it is not; the gap between shipments dispatched and shipments confirmed delivered, which is where lost parcels hide; and refund totals against payment totals per order, which must never exceed and whose exceeding is a bug in the discount-share arithmetic rather than a metric.

Flashcards

FlashNothing in a cart is yours

Adding reserves no stock. Carts live for days and most are abandoned, so reserving makes a stocked shop report itself sold out. The cart's only promise is an honest, live view of price and availability.

FlashTwo prices, two purposes

priceWhenAdded is display only — "this dropped by £8". The customer is charged the current price, revalidated at checkout and shown before payment. Once the order exists, its prices are copied and frozen.

FlashStage order is policy

£120 with a £20 sale and a 10% code is £90 one way and £88 the other. Both are defensible, only one is yours, so the pipeline order is written down. Shipping and tax also read the discounted total.

FlashMerging carts

Take the maximum quantity per SKU, not the sum — one jacket on the phone and one on the laptop means one jacket. Merging also revalidates, and unavailable lines are shown rather than dropped.

FlashSort before you claim

Claim all lines in one transaction with a conditional UPDATE each, sorted by SKU identifier. Two multi-line orders taking the same rows in opposite orders is a circular wait; a global lock order makes the cycle impossible.

FlashAuthorise, then capture

Authorise at checkout, capture at dispatch. Cancelling before dispatch releases the authorisation for free; refunding a capture costs a fee. One isPaid boolean cannot express the normal state of a real shop.

Next: 9.7.25 — the order book, where two lists of intentions have to be matched against each other in the right order, every time, with no ambiguity at all.