Public: anyone can read this; the crew can change it.
The last unit goes to exactly one shopper, and the one who missed it loses nothing #65
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/44-the-last-unit-goes-to-exactly-one-shoppe → main 5 commits fork 4 threads · 4 unresolved
8 files
changed +561
−17
README.md +53 −1
| @@ -60,6 +60,58 @@ admin's three live pages load one small file — see | ||
| 60 | 60 | delivered, `/orders` is how a shopper gets back to one — see below. |
| 61 | 61 | - No goods ship. It is a demonstration store, and every page says so. |
| 62 | 62 | |
| 63 | ## Stock, and who gets the last one | |
| 64 | ||
| 65 | Stock only ever moves by writing a row in `stock_entry` that says why, and | |
| 66 | `variant.stock` moves in the same statement. Taking it for a sale is a | |
| 67 | conditional `UPDATE ... WHERE stock >= ?`, so the shelf can never go below | |
| 68 | zero however many tills ask at once. | |
| 69 | ||
| 70 | That statement is also the only thing that decides who gets the last one. The | |
| 71 | cart page and the checkout page both say what is on the shelf — a line that | |
| 72 | asks for more carries `only N in stock` in the same words on both — but a | |
| 73 | count read while a page was being drawn is not a promise. **If the last one | |
| 74 | goes while you are typing your address, the shop tells you so and you lose | |
| 75 | nothing else.** Your order is written, your code redeemed and your gift card | |
| 76 | spent inside one transaction; the `UPDATE` that takes the stock refuses, and | |
| 77 | the whole attempt is rolled back: no order, no line, no event, no use of the | |
| 78 | code, no movement off the card, and the order number it had drawn goes back | |
| 79 | in the hat. The checkout page comes back saying what the shelf says now — "… | |
| 80 | is out of stock." when it is gone, "Only 2 left of …" when some are still | |
| 81 | there — your cart, your code and your card are exactly as they were, and | |
| 82 | there is a link back to `/cart` so you can change the line rather than press | |
| 83 | a button that will fail the same way. The wording is never a race the shop | |
| 84 | cannot vouch for: "sold out while you were checking out" is said only when the | |
| 85 | quote handed to `place()` still had the unit. Over the web that quote is taken | |
| 86 | a few lines before the `UPDATE`, not when the page was drawn (#64 again), so | |
| 87 | what a shopper reads there is almost always the shelf's plain words. | |
| 88 | ||
| 89 | A gift card in that same gap is three different stories. A card another till | |
| 90 | empties to **nothing** is no longer a code this cart can use: the checkout | |
| 91 | page comes back with "Gift card ····1234 has nothing left on it.", no order is | |
| 92 | written and the balance is untouched. A card that is spent down but is | |
| 93 | **still worth what the page said it would pay** keeps its promise, so the | |
| 94 | order goes through exactly as it was shown — the shopper is not told about | |
| 95 | money that was never theirs. A card that falls **below** what the page said it | |
| 96 | would pay cannot keep that promise, and `orders.place` refuses with "That gift | |
| 97 | card no longer has that much on it." rather than quietly asking the gateway | |
| 98 | for the difference. That last check compares what the card is worth now | |
| 99 | against what the page showed it paying, not against what it was worth then: | |
| 100 | a card falling from $200 to $100 still covers a $20 order, and refusing it | |
| 101 | would say a sentence that is not true of this order. | |
| 102 | ||
| 103 | The middle and last stories cannot be told apart from the web today, because | |
| 104 | `src/shop.ts` builds the "shown" quote by pricing the same cart again in the | |
| 105 | POST handler, so the comparison is a quote against itself and a part-spent | |
| 106 | card is simply re-priced — what is left comes off the total and the gateway is | |
| 107 | asked for the rest. Issue #64 — the form carrying the figures it was drawn | |
| 108 | with, signed — is what makes that check bite over HTTP. | |
| 109 | ||
| 110 | `src/contention.test.ts` is that story as a test — two `place()` calls for one | |
| 111 | unit, with the loser's rollback asserted ledger by ledger. `src/server.test.ts` | |
| 112 | holds the same ground over HTTP: the shopper who misses the last one keeps | |
| 113 | cart, code and card, and the emptied card is refused with its balance intact. | |
| 114 | ||
| 63 | 115 | ## Codes, cards and tax |
| 64 | 116 | |
| 65 | 117 | One box at the cart takes a code of either kind, because a shopper does not |
| @@ -454,7 +506,7 @@ saying so. | ||
| 454 | 506 | | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents | |
| 455 | 507 | | `src/live.ts` `static/live.js` | The bus: a `live_event` row, and the stream that hands rows out across both processes; the one script, which refetches the `data-live` regions of the page it is on | |
| 456 | 508 | | `src/asset.ts` | A file under `static/`, addressed by its contents: the hash in the path has to be the hash of the bytes | |
| 457 | | `src/units.test.ts` `src/live.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` `src/https.test.ts` | The rules on their own, the bus on its own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address | | |
| 509 | | `src/units.test.ts` `src/contention.test.ts` `src/live.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` `src/https.test.ts` | The rules on their own, the last unit and the rollback of the attempt that missed it, the bus on its own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address | | |
| 458 | 510 | |
| 459 | 511 | ### What the door refuses |
| 460 | 512 |
src/cart.ts +12 −7
| @@ -56,6 +56,15 @@ export type Quote = { | ||
| 56 | 56 | code_problems: string[]; |
| 57 | 57 | }; |
| 58 | 58 | |
| 59 | /** | |
| 60 | * The words for a line the shop cannot sell as the cart asks. They are said | |
| 61 | * the same way wherever the shortfall is found — on the cart page, on the | |
| 62 | * checkout page, and at the till — so a shopper reads one voice. | |
| 63 | */ | |
| 64 | export const LINE_GONE = "An item in your cart is no longer for sale and was removed."; | |
| 65 | export const outOfStock = (what: string): string => `${what} is out of stock.`; | |
| 66 | export const onlyLeft = (what: string, left: number): string => `Only ${left} left of ${what}.`; | |
| 67 | ||
| 59 | 68 | /** Free over $75, otherwise a flat $6. Gift cards are posted with nothing. */ |
| 60 | 69 | export const SHIPPING_CENTS = 600; |
| 61 | 70 | export const FREE_SHIPPING_OVER_CENTS = 7500; |
| @@ -120,16 +129,12 @@ export function quote(lines: CartLine[], options: { codes?: Applied; destination | ||
| 120 | 129 | for (const line of lines) { |
| 121 | 130 | const variant = byId.get(line.variantId); |
| 122 | 131 | if (!variant || variant.active !== 1 || variant.product_active !== 1) { |
| 123 | problems.push("An item in your cart is no longer for sale and was removed."); | |
| 132 | problems.push(LINE_GONE); | |
| 124 | 133 | continue; |
| 125 | 134 | } |
| 126 | 135 | const available = Math.min(line.quantity, Math.max(0, variant.stock)); |
| 127 | if (available < line.quantity) | |
| 128 | problems.push( | |
| 129 | available === 0 | |
| 130 | ? `${variant.product_title} (${variant.name}) is out of stock.` | |
| 131 | : `Only ${available} left of ${variant.product_title} (${variant.name}).`, | |
| 132 | ); | |
| 136 | const what = `${variant.product_title} (${variant.name})`; | |
| 137 | if (available < line.quantity) problems.push(available === 0 ? outOfStock(what) : onlyLeft(what, available)); | |
| 133 | 138 | priced.push({ |
| 134 | 139 | variant, |
| 135 | 140 | quantity: line.quantity, |
src/contention.test.ts added +269
| @@ -0,0 +1,269 @@ | ||
| 1 | /** | |
| 2 | * The last unit, and the shopper who does not get it. | |
| 3 | * | |
| 4 | * Two `place()` calls in this file are sequential, not threads: this process | |
| 5 | * has one of each. What they prove is not a race but the statement a race | |
| 6 | * would land on — the conditional `UPDATE ... WHERE stock >= ?` in takeStock, | |
| 7 | * which is the only authority on whether the last one is still there. The | |
| 8 | * first call takes it; the second reaches that statement, is refused by it, | |
| 9 | * and every row the attempt had already written goes back. | |
| 10 | * | |
| 11 | * So the assertions are row by row. A rolled-back attempt is invisible: no | |
| 12 | * order, no line, no event, no use of the code, no movement of the card. The | |
| 13 | * shopper who missed it still has a cart, a code and a balance. | |
| 14 | */ | |
| 15 | ||
| 16 | import { expect, test } from "bun:test"; | |
| 17 | import { freshData } from "./fresh.ts"; | |
| 18 | ||
| 19 | // A database of its own: these tests place real orders, and never into | |
| 20 | // another test file's rows, whichever order the files run in. | |
| 21 | await freshData("contention"); | |
| 22 | ||
| 23 | const catalog = await import("./catalog.ts"); | |
| 24 | const discounts = await import("./discounts.ts"); | |
| 25 | const giftcards = await import("./giftcards.ts"); | |
| 26 | const inventory = await import("./inventory.ts"); | |
| 27 | const orders = await import("./orders.ts"); | |
| 28 | const tax = await import("./tax.ts"); | |
| 29 | const cart = await import("./cart.ts"); | |
| 30 | const { db } = await import("./db.ts"); | |
| 31 | ||
| 32 | catalog.seed(); | |
| 33 | discounts.seed(); | |
| 34 | tax.seed(); | |
| 35 | ||
| 36 | const buyer = { id: "u_contender", name: "contender", role: "user" } as const; | |
| 37 | /** Whoever stocks the shelf and hands out the cards in this file. */ | |
| 38 | const ADA = { id: "u_ada", name: "ada", role: "admin" } as const; | |
| 39 | ||
| 40 | const address = { | |
| 41 | email: "[email protected]", | |
| 42 | name: "A Shopper", | |
| 43 | address1: "1 Test Street", | |
| 44 | address2: "", | |
| 45 | city: "Testville", | |
| 46 | region: "CA", | |
| 47 | postal: "12345", | |
| 48 | country: "United States", | |
| 49 | }; | |
| 50 | ||
| 51 | /** A product nothing else in this file touches, with exactly `stock` of it. */ | |
| 52 | function oneOff(slug: string, cents: number, stock: number): number { | |
| 53 | const category = db.query<{ id: number }, []>("SELECT id FROM category ORDER BY id LIMIT 1").get()!.id; | |
| 54 | const product = catalog.createProduct({ | |
| 55 | title: `The ${slug}`, | |
| 56 | slug, | |
| 57 | blurb: "", | |
| 58 | description: "", | |
| 59 | category_id: category, | |
| 60 | active: true, | |
| 61 | }); | |
| 62 | return catalog.createVariant({ | |
| 63 | product_id: product, | |
| 64 | sku: slug.toUpperCase(), | |
| 65 | name: "Only one", | |
| 66 | price_cents: cents, | |
| 67 | stock, | |
| 68 | by: ADA, | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | const count = (sql: string, ...args: any[]): number => | |
| 73 | db.query<{ n: number }, any[]>(sql).get(...args)!.n; | |
| 74 | ||
| 75 | const stockEntries = (variantId: number): number => | |
| 76 | count("SELECT COUNT(*) AS n FROM stock_entry WHERE variant_id = ?1", variantId); | |
| 77 | ||
| 78 | test("the UPDATE in takeStock decides the last unit, and the attempt it refuses leaves no row", () => { | |
| 79 | const variant = oneOff("last-mug", 2500, 1); | |
| 80 | const lines = [{ variantId: variant, quantity: 1 }]; | |
| 81 | // One code, typed by both shoppers, and a card of the loser's own: so the | |
| 82 | // attempt that fails has something to give back on every ledger there is. | |
| 83 | const code = discounts.create({ | |
| 84 | code: "LASTONE10", | |
| 85 | kind: "fixed", | |
| 86 | value: 500, | |
| 87 | min_subtotal_cents: 0, | |
| 88 | max_redemptions: null, | |
| 89 | starts_at: null, | |
| 90 | expires_at: null, | |
| 91 | }); | |
| 92 | const { card, code: cardCode } = giftcards.issue({ cents: 1000, note: "the loser's card", by: ADA }); | |
| 93 | const winnerCodes = { discount: "LASTONE10", gift: "" }; | |
| 94 | const loserCodes = { discount: "LASTONE10", gift: cardCode }; | |
| 95 | const entriesBefore = giftcards.entriesOf(card.id).length; | |
| 96 | const openingEntries = stockEntries(variant); | |
| 97 | ||
| 98 | // Both quoted the same shelf: one unit, and the page said so to each. | |
| 99 | const shown = cart.quote(lines, { codes: loserCodes }); | |
| 100 | expect(shown.lines[0]!.available).toBe(1); | |
| 101 | expect(shown.gift_card_cents).toBeGreaterThan(0); | |
| 102 | ||
| 103 | const winner = orders.place({ | |
| 104 | address, | |
| 105 | buyer, | |
| 106 | cart: lines, | |
| 107 | codes: winnerCodes, | |
| 108 | shown: cart.quote(lines, { codes: winnerCodes }), | |
| 109 | idempotencyKey: "winner", | |
| 110 | gateway: "mock", | |
| 111 | }); | |
| 112 | expect(winner.ok).toBe(true); | |
| 113 | // Every ledger as the winner left it. The refused attempt must add a row to | |
| 114 | // none of them, `live_event` included: `announce` returns early inside a | |
| 115 | // transaction and `emit` runs only after the commit, so an attempt that died | |
| 116 | // reaches no admin's screen. Counted here rather than asserted as totals, | |
| 117 | // because this file's tests share one database in whatever order they run. | |
| 118 | const ledgers = ['"order"', "order_item", "order_event", "payment", "gift_card_entry", "live_event"]; | |
| 119 | const before = ledgers.map((table) => count(`SELECT COUNT(*) AS n FROM ${table}`)); | |
| 120 | expect(before[before.length - 1]).toBeGreaterThan(0); // the winner was announced | |
| 121 | ||
| 122 | const loser = orders.place({ | |
| 123 | address, | |
| 124 | buyer, | |
| 125 | cart: lines, | |
| 126 | codes: loserCodes, | |
| 127 | shown, | |
| 128 | idempotencyKey: "loser", | |
| 129 | gateway: "mock", | |
| 130 | }); | |
| 131 | expect(loser.ok).toBe(false); | |
| 132 | expect(loser.ok === false && loser.error).toBe("The last-mug (Only one) sold out while you were checking out."); | |
| 133 | ||
| 134 | // Not one row anywhere: the attempt is as though it had never been made. | |
| 135 | expect(ledgers.map((table) => count(`SELECT COUNT(*) AS n FROM ${table}`))).toEqual(before); | |
| 136 | expect(count('SELECT COUNT(*) AS n FROM "order" WHERE idempotency_key = ?1', "loser")).toBe(0); | |
| 137 | ||
| 138 | // One sale was written against the shelf, and the shelf is at zero — never | |
| 139 | // below it, which is what the conditional UPDATE buys. | |
| 140 | expect(inventory.stockOf(variant)).toBe(0); | |
| 141 | const sales = inventory.historyOf(variant).filter((entry) => entry.delta < 0); | |
| 142 | expect(sales.length).toBe(1); | |
| 143 | expect(sales[0]!.delta).toBe(-1); | |
| 144 | expect(stockEntries(variant)).toBe(openingEntries + 1); | |
| 145 | ||
| 146 | // The loser's rollback, ledger by ledger. The discount was redeemed and the | |
| 147 | // card was spent inside the attempt: both went back when it was thrown away. | |
| 148 | const won = winner.ok === true ? winner.order : null; | |
| 149 | expect(discounts.byId(code)!.used_count).toBe(1); | |
| 150 | expect(count("SELECT COUNT(*) AS n FROM discount_redemption WHERE discount_id = ?1", code)).toBe(1); | |
| 151 | expect(count("SELECT COUNT(*) AS n FROM discount_redemption WHERE order_id = ?1", won!.id)).toBe(1); | |
| 152 | expect(giftcards.byId(card.id)!.balance_cents).toBe(card.balance_cents); | |
| 153 | expect(giftcards.entriesOf(card.id).length).toBe(entriesBefore); | |
| 154 | expect(count("SELECT COUNT(*) AS n FROM gift_card_entry WHERE gift_card_id = ?1 AND order_id IS NOT NULL", card.id)) | |
| 155 | .toBe(0); | |
| 156 | expect(count("SELECT COUNT(*) AS n FROM order_item WHERE variant_id = ?1", variant)).toBe(1); | |
| 157 | expect(count("SELECT COUNT(*) AS n FROM order_item WHERE order_id = ?1", won!.id)).toBe(1); | |
| 158 | ||
| 159 | // The number the refused attempt drew went back in the hat with its row. | |
| 160 | // This asserts that SQLite hands the rolled-back rowid to the next insert, | |
| 161 | // which is what "no gap in the books" rests on; the ledger counts above are | |
| 162 | // what prove the rollback itself. | |
| 163 | const another = [{ variantId: oneOff("another-mug", 2500, 1), quantity: 1 }]; | |
| 164 | const next = orders.place({ | |
| 165 | address, | |
| 166 | buyer, | |
| 167 | cart: another, | |
| 168 | codes: { discount: "", gift: "" }, | |
| 169 | shown: cart.quote(another), | |
| 170 | idempotencyKey: "next", | |
| 171 | gateway: "mock", | |
| 172 | }); | |
| 173 | expect(next.ok).toBe(true); | |
| 174 | expect(next.ok === true && next.order.id).toBe(won!.id + 1); | |
| 175 | }); | |
| 176 | ||
| 177 | test("a line that asks for more than the shelf has is refused in the words the cart uses", () => { | |
| 178 | const variant = oneOff("two-left", 1000, 2); | |
| 179 | const lines = [{ variantId: variant, quantity: 3 }]; | |
| 180 | const codes = { discount: "", gift: "" }; | |
| 181 | const shown = cart.quote(lines, { codes }); | |
| 182 | // The quote says it on the page, and the till says the same thing: a | |
| 183 | // shortfall that is not a sell-out is never called one. | |
| 184 | expect(shown.problems).toEqual(["Only 2 left of The two-left (Only one)."]); | |
| 185 | const refused = orders.place({ address, buyer, cart: lines, codes, shown, idempotencyKey: "greedy", gateway: "mock" }); | |
| 186 | expect(refused.ok).toBe(false); | |
| 187 | expect(refused.ok === false && refused.error).toBe("Only 2 left of The two-left (Only one)."); | |
| 188 | expect(inventory.stockOf(variant)).toBe(2); | |
| 189 | expect(count("SELECT COUNT(*) AS n FROM stock_entry WHERE variant_id = ?1 AND delta < 0", variant)).toBe(0); | |
| 190 | }); | |
| 191 | ||
| 192 | test("a line the page already showed empty is not told as a race it did not lose", () => { | |
| 193 | const variant = oneOff("already-gone", 1500, 0); | |
| 194 | const lines = [{ variantId: variant, quantity: 1 }]; | |
| 195 | const codes = { discount: "", gift: "" }; | |
| 196 | // Nothing on the shelf when the page was drawn: the shopper was told so, | |
| 197 | // and pressing the button anyway is the same sentence, not a story about | |
| 198 | // somebody who beat them to it. | |
| 199 | const shown = cart.quote(lines, { codes }); | |
| 200 | expect(shown.problems).toEqual(["The already-gone (Only one) is out of stock."]); | |
| 201 | const refused = orders.place({ address, buyer, cart: lines, codes, shown, idempotencyKey: "too-late", gateway: "mock" }); | |
| 202 | expect(refused.ok).toBe(false); | |
| 203 | expect(refused.ok === false && refused.error).toBe("The already-gone (Only one) is out of stock."); | |
| 204 | expect(count('SELECT COUNT(*) AS n FROM "order" WHERE idempotency_key = ?1', "too-late")).toBe(0); | |
| 205 | }); | |
| 206 | ||
| 207 | test("a card that can no longer pay what the page said it would is refused, balance untouched", () => { | |
| 208 | const variant = oneOff("card-paid", 2000, 5); | |
| 209 | const lines = [{ variantId: variant, quantity: 1 }]; | |
| 210 | const { card, code } = giftcards.issue({ cents: 5000, note: "spent elsewhere in a moment", by: ADA }); | |
| 211 | const codes = { discount: "", gift: code }; | |
| 212 | ||
| 213 | // The page was drawn while the card was worth $50, covering the lot. | |
| 214 | const shown = cart.quote(lines, { codes }); | |
| 215 | expect(shown.gift_card_balance_cents).toBe(5000); | |
| 216 | expect(shown.gift_card_cents).toBeGreaterThan(0); | |
| 217 | expect(shown.due_cents).toBe(0); | |
| 218 | ||
| 219 | // Another till spends it between the page and the button. | |
| 220 | expect(giftcards.spend(card.id, 5000, "spent at another till", ADA)).toBe(true); | |
| 221 | const entries = giftcards.entriesOf(card.id).length; | |
| 222 | ||
| 223 | const refused = orders.place({ address, buyer, cart: lines, codes, shown, idempotencyKey: "emptied", gateway: "mock" }); | |
| 224 | expect(refused.ok).toBe(false); | |
| 225 | expect(refused.ok === false && refused.error).toBe("That gift card no longer has that much on it."); | |
| 226 | // Nothing was written: no order to charge, and the card is as it was left. | |
| 227 | expect(count('SELECT COUNT(*) AS n FROM "order" WHERE idempotency_key = ?1', "emptied")).toBe(0); | |
| 228 | expect(giftcards.byId(card.id)!.balance_cents).toBe(0); | |
| 229 | expect(giftcards.entriesOf(card.id).length).toBe(entries); | |
| 230 | expect(inventory.stockOf(variant)).toBe(5); | |
| 231 | ||
| 232 | // A card worth less than it was, but still worth more than the page said it | |
| 233 | // would pay, keeps its promise: this order is exactly the order that was | |
| 234 | // shown, so it goes through. Refusing it would say a sentence that is not | |
| 235 | // true of this order. | |
| 236 | const spare = giftcards.issue({ cents: 5000, note: "mostly spent in a moment", by: ADA }); | |
| 237 | const shownSpare = cart.quote(lines, { codes: { discount: "", gift: spare.code } }); | |
| 238 | const promised = shownSpare.gift_card_cents; | |
| 239 | expect(giftcards.spend(spare.card.id, 5000 - promised, "spent at another till", ADA)).toBe(true); | |
| 240 | const kept = orders.place({ | |
| 241 | address, | |
| 242 | buyer, | |
| 243 | cart: lines, | |
| 244 | codes: { discount: "", gift: spare.code }, | |
| 245 | shown: shownSpare, | |
| 246 | idempotencyKey: "still-covers", | |
| 247 | gateway: "mock", | |
| 248 | }); | |
| 249 | expect(kept.ok).toBe(true); | |
| 250 | expect(kept.ok === true && kept.order.gift_card_cents).toBe(promised); | |
| 251 | expect(giftcards.byId(spare.card.id)!.balance_cents).toBe(0); | |
| 252 | ||
| 253 | // One cent below what it was shown paying, and it cannot keep the promise. | |
| 254 | const short = giftcards.issue({ cents: 5000, note: "a cent short in a moment", by: ADA }); | |
| 255 | const shownShort = cart.quote(lines, { codes: { discount: "", gift: short.code } }); | |
| 256 | expect(giftcards.spend(short.card.id, 5000 - shownShort.gift_card_cents + 1, "spent at another till", ADA)).toBe(true); | |
| 257 | const missed = orders.place({ | |
| 258 | address, | |
| 259 | buyer, | |
| 260 | cart: lines, | |
| 261 | codes: { discount: "", gift: short.code }, | |
| 262 | shown: shownShort, | |
| 263 | idempotencyKey: "a-cent-short", | |
| 264 | gateway: "mock", | |
| 265 | }); | |
| 266 | expect(missed.ok).toBe(false); | |
| 267 | expect(missed.ok === false && missed.error).toBe("That gift card no longer has that much on it."); | |
| 268 | expect(giftcards.byId(short.card.id)!.balance_cents).toBe(shownShort.gift_card_cents - 1); | |
| 269 | }); |
src/inventory.ts +4
| @@ -78,6 +78,10 @@ export function takeStock( | ||
| 78 | 78 | return took; |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | /** What is on the shelf this instant. Zero for a variant that is not there. */ | |
| 82 | export const stockOf = (variantId: number): number => | |
| 83 | db.query<{ stock: number }, [number]>("SELECT stock FROM variant WHERE id = ?1").get(variantId)?.stock ?? 0; | |
| 84 | ||
| 81 | 85 | /** Set stock to a number by writing the difference as a correction. */ |
| 82 | 86 | export function setStock(variantId: number, target: number, reason: string, by: Actor): void { |
| 83 | 87 | const current = db.query<{ stock: number }, [number]>("SELECT stock FROM variant WHERE id = ?1").get(variantId); |
src/orders.ts +51 −7
| @@ -22,10 +22,10 @@ import * as giftcards from "./giftcards.ts"; | ||
| 22 | 22 | import { randomBytes } from "node:crypto"; |
| 23 | 23 | import { cells, type Actor } from "./actor.ts"; |
| 24 | 24 | import { db, type ActorColumns, type Order, type OrderItem, type Payment } from "./db.ts"; |
| 25 | import { addStock, takeStock } from "./inventory.ts"; | |
| 25 | import { addStock, stockOf, takeStock } from "./inventory.ts"; | |
| 26 | 26 | import { dueCents, sumLines } from "./money.ts"; |
| 27 | 27 | import { emit } from "./live.ts"; |
| 28 | import { quote, type CartLine, type Quote } from "./cart.ts"; | |
| 28 | import { LINE_GONE, onlyLeft, outOfStock, quote, type CartLine, type Quote } from "./cart.ts"; | |
| 29 | 29 | import { writeReceipt } from "./mail.ts"; |
| 30 | 30 | import type { Applied } from "./codes.ts"; |
| 31 | 31 | |
| @@ -181,7 +181,27 @@ export function place(input: { | ||
| 181 | 181 | |
| 182 | 182 | const priced = quote(input.cart, { codes: input.codes, destination: input.address }); |
| 183 | 183 | if (priced.lines.length === 0) return { ok: false, error: "Your cart is empty." }; |
| 184 | if (priced.problems.length > 0) return { ok: false, error: priced.problems[0]! }; | |
| 184 | // A line the quote could not price at all is refused now: the order would | |
outdated
Approved. (The Reviewed at What I checked
Findings, none blocking
The README section is long, and honest about what is not yet true. Good change. |
||
| 185 | // otherwise be placed for fewer things than the cart holds. A line that has | |
| 186 | // outrun its stock is not refused here — a count read a moment ago is not | |
| 187 | // the authority on what is on the shelf. The conditional UPDATE in takeStock | |
| 188 | // is, and it runs inside the transaction below, where a refusal takes the | |
| 189 | // whole attempt back with it. | |
| 190 | if (priced.lines.length < input.cart.length) return { ok: false, error: LINE_GONE }; | |
| 191 | // A card that can no longer keep the promise the page made for it: the page | |
| 192 | // said it would pay this much, another till has spent it down since, and | |
| 193 | // going on would quietly ask the gateway for the difference. What the card | |
| 194 | // is worth now is measured against what it was shown paying, not against | |
| 195 | // what it was worth then — a card that falls from $200 to $100 still pays a | |
| 196 | // $20 order exactly as the page said, and refusing that would be a sentence | |
| 197 | // that is not true of this order. Comparing against the shown payment also | |
| 198 | // sidesteps the tax: what the card pays here follows the total, and the | |
| 199 | // total follows the address, which the page did not have. Asked before the | |
| 200 | // code problems, so a card emptied since the page was drawn is told in those | |
| 201 | // words rather than in the words for one that was already empty when it was | |
| 202 | // drawn. | |
| 203 | if (priced.gift_card_balance_cents < input.shown.gift_card_cents) | |
| 204 | return { ok: false, error: "That gift card no longer has that much on it." }; | |
| 185 | 205 | if (priced.code_problems.length > 0) return { ok: false, error: priced.code_problems[0]! }; |
| 186 | 206 | |
| 187 | 207 | const itemRows = priced.lines.map((line) => ({ |
| @@ -199,10 +219,21 @@ export function place(input: { | ||
| 199 | 219 | if (priced.discount_cents !== input.shown.discount_cents) |
| 200 | 220 | return { ok: false, error: "That code stopped applying while you were checking out." }; |
| 201 | 221 | |
| 222 | // Lines the page already showed short. A shopper who was told "out of | |
| 223 | // stock" and pressed the button anyway lost no race, so the refusal below | |
| 224 | // says what the shelf says rather than a story about one. | |
| 225 | const knownShort = new Set( | |
| 226 | input.shown.lines.filter((line) => line.available < line.quantity).map((line) => line.variant.id), | |
| 227 | ); | |
| 228 | ||
| 202 | 229 | let placed: Order; |
| 203 | 230 | try { |
| 204 | 231 | placed = db.transaction(() => { |
| 205 | 232 | let created: Order | null = null; |
| 233 | // The number is drawn inside the transaction, so an attempt that rolls | |
| 234 | // back consumes none: the number goes back in the hat with the row that | |
| 235 | // held it, and the id it would have taken is given to the next order. | |
| 236 | // A shopper who loses the last unit here leaves no gap in the books. | |
| 206 | 237 | for (let attempt = 0; attempt < 5 && created === null; attempt++) { |
| 207 | 238 | try { |
| 208 | 239 | created = db |
| @@ -281,8 +312,20 @@ export function place(input: { | ||
| 281 | 312 | item.quantity, |
| 282 | 313 | item.gift_card_cents, |
| 283 | 314 | ); |
| 284 | if (!takeStock(item.variant_id, item.quantity, `sold on ${created.number}`, input.buyer, created.id)) | |
| 285 | throw new Error(`OUT_OF_STOCK:${item.product_title} (${item.variant_name})`); | |
| 315 | if (!takeStock(item.variant_id, item.quantity, `sold on ${created.number}`, input.buyer, created.id)) { | |
| 316 | // What is left is read back here, inside the transaction that is | |
| 317 | // about to be thrown away, so the sentence is true at the instant | |
| 318 | // the shelf refused. Something left is the cart's own words for a | |
| 319 | // line that asks for more than there is; nothing left is the last | |
| 320 | // one gone — said as a race only when the page this shopper was | |
| 321 | // shown still had it, and in the cart's plain words otherwise. | |
| 322 | const left = stockOf(item.variant_id); | |
| 323 | const what = `${item.product_title} (${item.variant_name})`; | |
| 324 | const gone = knownShort.has(item.variant_id) | |
| 325 | ? outOfStock(what) | |
| 326 | : `${what} sold out while you were checking out.`; | |
| 327 | throw new Error("OUT_OF_STOCK:" + (left <= 0 ? gone : onlyLeft(what, left))); | |
| 328 | } | |
| 286 | 329 | } |
| 287 | 330 | note( |
| 288 | 331 | created.id, |
| @@ -296,8 +339,9 @@ export function place(input: { | ||
| 296 | 339 | })(); |
| 297 | 340 | } catch (error) { |
| 298 | 341 | const message = String(error instanceof Error ? error.message : error); |
| 299 | if (message.startsWith("OUT_OF_STOCK:")) | |
| 300 | return { ok: false, error: `${message.slice("OUT_OF_STOCK:".length)} sold out while you were checking out.` }; | |
| 342 | // Both sentinels carry the shopper's whole sentence; the prefix says which | |
| 343 | // rule refused, and either way nothing of the attempt survived the throw. | |
| 344 | if (message.startsWith("OUT_OF_STOCK:")) return { ok: false, error: message.slice("OUT_OF_STOCK:".length) }; | |
| 301 | 345 | if (message.startsWith("CODE_GONE:")) return { ok: false, error: message.slice("CODE_GONE:".length) }; |
| 302 | 346 | return { ok: false, error: "The order could not be placed. Nothing was charged." }; |
| 303 | 347 | } |
src/pages/checkout.ts +15 −2
| @@ -46,10 +46,14 @@ export function checkout(input: { | ||
| 46 | 46 | error?: string; |
| 47 | 47 | }): string { |
| 48 | 48 | const { quote, session, who, gateway, values, error } = input; |
| 49 | // A line that asks for more than is on the shelf is said here before the | |
| 50 | // button is pressed, in the words the cart page uses, because the till will | |
| 51 | // refuse it: pressing the button cannot make the shelf fuller. | |
| 49 | 52 | const summary = quote.lines |
| 50 | 53 | .map( |
| 51 | 54 | (line) => |
| 52 | `<tr><td>${escape(line.variant.product_title)} <span class="muted">${escape(line.variant.name)}</span></td> | |
| 55 | `<tr><td>${escape(line.variant.product_title)} <span class="muted">${escape(line.variant.name)}</span> | |
| 56 | ${line.available < line.quantity ? `<div class="pill warn">only ${line.available} in stock</div>` : ""}</td> | |
| 53 | 57 | <td class="num">${line.quantity}</td><td class="num price">${escape(formatCents(line.line_total_cents))}</td></tr>`, |
| 54 | 58 | ) |
| 55 | 59 | .join("\n"); |
| @@ -88,13 +92,22 @@ export function checkout(input: { | ||
| 88 | 92 | return ` |
| 89 | 93 | <h1>Checkout</h1> |
| 90 | 94 | ${error ? `<p class="notice bad">${escape(error)}</p>` : ""} |
| 91 | ${quote.problems.concat(quote.code_problems).map((problem) => `<p class="notice bad">${escape(problem)}</p>`).join("\n")} | |
| 95 | ${quote.problems | |
| 96 | .concat(quote.code_problems) | |
| 97 | // The till and the shelf share their words, so a refusal is often the | |
| 98 | // problem the quote found a moment later. It is worth saying once. | |
| 99 | .filter((problem) => problem !== error) | |
| 100 | .map((problem) => `<p class="notice bad">${escape(problem)}</p>`) | |
| 101 | .join("\n")} | |
| 92 | 102 | <div class="product"> |
| 93 | 103 | ${left} |
| 94 | 104 | <div> |
| 95 | 105 | <h2 style="margin-top:0">Your order</h2> |
| 96 | 106 | <table><tbody>${summary}</tbody></table> |
| 97 | 107 | ${totals(quote)} |
| 108 | <p class="muted" style="font-size:.88rem; margin:.5rem 0 0"> | |
| 109 | <a href="/cart">Back to the cart</a> to change a quantity or take something out. | |
| 110 | </p> | |
| 98 | 111 | ${codeBox(quote, session, "checkout")} |
| 99 | 112 | <p class="muted" style="font-size:.85rem">Tax is worked out from the address on the left when the order is placed.</p> |
| 100 | 113 | </div> |
src/server.test.ts +151
| @@ -892,6 +892,149 @@ test("something sold out cannot be bought", async () => { | ||
| 892 | 892 | ).toBe(0); |
| 893 | 893 | }); |
| 894 | 894 | |
| 895 | /** A shelf of this test's own: its own category, product and count of stock. */ | |
| 896 | async function ownShelf(input: { slug: string; title: string; sku: string; cents: number; stock: number }): Promise<void> { | |
| 897 | const catalog = await import("./catalog.ts"); | |
| 898 | const category = db | |
| 899 | .query<{ id: number }, [string, string]>("INSERT INTO category (slug, name) VALUES (?1, ?2) RETURNING id") | |
| 900 | .get(`${input.slug}-corner`, `${input.title} corner`)!.id; | |
| 901 | const product = catalog.createProduct({ | |
| 902 | title: input.title, | |
| 903 | slug: input.slug, | |
| 904 | blurb: "", | |
| 905 | description: "", | |
| 906 | category_id: category, | |
| 907 | active: true, | |
| 908 | }); | |
| 909 | catalog.createVariant({ | |
| 910 | product_id: product, | |
| 911 | sku: input.sku, | |
| 912 | name: "Only one", | |
| 913 | price_cents: input.cents, | |
| 914 | stock: input.stock, | |
| 915 | by: ADA, | |
| 916 | }); | |
| 917 | } | |
| 918 | ||
| 919 | const ordersFor = (email: string): number => | |
| 920 | db.query<{ n: number }, [string]>('SELECT COUNT(*) AS n FROM "order" WHERE email = ?1').get(email)!.n; | |
| 921 | ||
| 922 | test("the last unit goes to one shopper, and the one who missed it keeps cart, code and card", async () => { | |
| 923 | limit.forget(); | |
| 924 | await ownShelf({ slug: "last-teapot", title: "The last teapot", sku: "LAST-TEAPOT", cents: 3000, stock: 1 }); | |
| 925 | const { card, code } = giftcards.issue({ cents: 1000, note: "the unlucky shopper's card", by: ADA }); | |
| 926 | ||
| 927 | // The shopper who will miss it: a line, a discount code and a gift card, | |
| 928 | // and a checkout page drawn while the one teapot is still on the shelf. | |
| 929 | const missed = asCustomer(); | |
| 930 | await addToCart(missed, "LAST-TEAPOT", 1); | |
| 931 | expect((await applyCode(missed, "welcome10")).headers.get("location")).toContain("m=applied"); | |
| 932 | expect((await applyCode(missed, code)).headers.get("location")).toContain("m=applied"); | |
| 933 | const checkout = await missed.page("/checkout"); | |
| 934 | expect(checkout).toContain("The last teapot"); | |
| 935 | expect(checkout).toContain('<a href="/cart">Back to the cart</a> to change a quantity'); | |
| 936 | const idempotency = /name="idempotency" value="([^"]+)"/.exec(checkout)![1]!; | |
| 937 | const csrf = Shopper.tokenIn(checkout); | |
| 938 | ||
| 939 | // The other shopper buys it while the first is typing their address. | |
| 940 | const bought = asCustomer(); | |
| 941 | await addToCart(bought, "LAST-TEAPOT", 1); | |
| 942 | expect((await submitCheckout(bought, { email: "[email protected]" })).status).toBe(303); | |
| 943 | expect(stockOf("LAST-TEAPOT")).toBe(0); | |
| 944 | ||
| 945 | const refused = await missed.post( | |
| 946 | "/checkout", | |
| 947 | { ...ADDRESS, email: "[email protected]", idempotency }, | |
| 948 | csrf, | |
| 949 | ); | |
| 950 | expect(refused.status).toBe(200); | |
| 951 | const page = await refused.text(); | |
| 952 | // The shelf is read again when the button is pressed, so over the web the | |
| 953 | // till knows only that the teapot is gone, not that it went in this gap — | |
| 954 | // and it says what it knows. (The form carrying what it was drawn with is | |
| 955 | // #64; the race sentence is proved in `src/contention.test.ts`, where the | |
| 956 | // caller hands in the older quote.) Once on the page, not twice: the | |
| 957 | // refusal and the quote's problem are the same words. | |
| 958 | expect(page).toContain("The last teapot (Only one) is out of stock."); | |
| 959 | expect(page.split("The last teapot (Only one) is out of stock.").length - 1).toBe(1); | |
| 960 | expect(page).not.toContain("sold out while you were checking out"); | |
| 961 | // Back to the cart, where the line can be changed — not a form that will | |
| 962 | // fail again in the same way. The whole sentence, because the header has a | |
| 963 | // Cart link on every page and matching that would prove nothing. | |
| 964 | expect(page).toContain('<a href="/cart">Back to the cart</a> to change a quantity'); | |
| 965 | // Exactly one order, and none of it is theirs. | |
| 966 | expect(ordersFor("[email protected]")).toBe(1); | |
| 967 | expect(ordersFor("[email protected]")).toBe(0); | |
| 968 | ||
| 969 | // The cookies came back untouched: the cart still holds the line, the code | |
| 970 | // cookie still holds both codes, and the card is worth what it was. | |
| 971 | const cart = await missed.page("/cart"); | |
| 972 | expect(cart).toContain("The last teapot"); | |
| 973 | expect(cart).toContain("WELCOME10"); | |
| 974 | expect(cart).toContain(`····${card.last4}`); | |
| 975 | expect(giftcards.byId(card.id)!.balance_cents).toBe(1000); | |
| 976 | expect( | |
| 977 | db.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM gift_card_entry WHERE gift_card_id = ?1").get(card.id)! | |
| 978 | .n, | |
| 979 | ).toBe(1); // the issue, and nothing since | |
| 980 | }); | |
| 981 | ||
| 982 | test("a cart line that outruns the shelf says so on the checkout page, before the button", async () => { | |
| 983 | limit.forget(); | |
| 984 | await ownShelf({ slug: "two-teacups", title: "Two teacups", sku: "TWO-TEACUPS", cents: 1200, stock: 2 }); | |
| 985 | const shopper = asCustomer(); | |
| 986 | await addToCart(shopper, "TWO-TEACUPS", 3); | |
| 987 | ||
| 988 | // The same words on both pages, so nothing new has to be learnt at the till. | |
| 989 | expect(await shopper.page("/cart")).toContain("only 2 in stock"); | |
| 990 | const checkout = await shopper.page("/checkout"); | |
| 991 | expect(checkout).toContain("only 2 in stock"); | |
| 992 | expect(checkout).toContain('<a href="/cart">Back to the cart</a> to change a quantity'); | |
| 993 | ||
| 994 | // Pressing it anyway is refused in the cart's words, and nothing moves. | |
| 995 | const refused = await submitCheckout(shopper, { email: "[email protected]" }); | |
| 996 | expect(refused.status).toBe(200); | |
| 997 | expect(await refused.text()).toContain("Only 2 left of Two teacups (Only one)."); | |
| 998 | expect(stockOf("TWO-TEACUPS")).toBe(2); | |
| 999 | expect(ordersFor("[email protected]")).toBe(0); | |
| 1000 | }); | |
| 1001 | ||
| 1002 | test("a gift card another till empties between the page and the button is refused, balance untouched", async () => { | |
| 1003 | limit.forget(); | |
| 1004 | await ownShelf({ slug: "kettle-of-one", title: "Kettle of one", sku: "KETTLE-ONE", cents: 2000, stock: 3 }); | |
| 1005 | const { card, code } = giftcards.issue({ cents: 20000, note: "a card worth more than the kettle", by: ADA }); | |
| 1006 | const shopper = asCustomer(); | |
| 1007 | await addToCart(shopper, "KETTLE-ONE", 1); | |
| 1008 | expect((await applyCode(shopper, code)).headers.get("location")).toContain("m=applied"); | |
| 1009 | ||
| 1010 | // The page was drawn with the card covering the lot: nothing due. | |
| 1011 | const checkout = await shopper.page("/checkout"); | |
| 1012 | expect(checkout).toContain(`····${card.last4}`); | |
| 1013 | ||
| 1014 | // Another till empties it while the address is being typed. | |
| 1015 | expect(giftcards.spend(card.id, 20000, "spent at another till", ADA)).toBe(true); | |
| 1016 | const entries = db | |
| 1017 | .query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM gift_card_entry WHERE gift_card_id = ?1") | |
| 1018 | .get(card.id)!.n; | |
| 1019 | ||
| 1020 | // A card with nothing left is no longer a code this cart can use, and that | |
| 1021 | // is what the shopper is told — before any order is written. (A card only | |
| 1022 | // part-spent in that gap is re-priced instead, because the form does not | |
| 1023 | // carry what it was drawn with: #64.) | |
| 1024 | const refused = await submitCheckout(shopper, { email: "[email protected]" }); | |
| 1025 | expect(refused.status).toBe(200); | |
| 1026 | expect(await refused.text()).toContain(`Gift card ····${card.last4} has nothing left on it.`); | |
| 1027 | expect(ordersFor("[email protected]")).toBe(0); | |
| 1028 | expect(giftcards.byId(card.id)!.balance_cents).toBe(0); | |
| 1029 | expect( | |
| 1030 | db.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM gift_card_entry WHERE gift_card_id = ?1").get(card.id)! | |
| 1031 | .n, | |
| 1032 | ).toBe(entries); | |
| 1033 | expect(stockOf("KETTLE-ONE")).toBe(3); | |
| 1034 | // The cart survives the refusal, as it does for the sold-out path. | |
| 1035 | expect(await shopper.page("/cart")).toContain("Kettle of one"); | |
| 1036 | }); | |
| 1037 | ||
| 895 | 1038 | test("checkout is rate limited", async () => { |
| 896 | 1039 | limit.forget(); |
| 897 | 1040 | const shopper = asCustomer(); |
| @@ -1032,6 +1175,14 @@ test("the audit trail is an admin's, and it pages from a cursor", async () => { | ||
| 1032 | 1175 | expect((await new Shopper().get("/admin/audit")).status).toBe(403); |
| 1033 | 1176 | expect((await asCustomer().get("/admin/audit")).status).toBe(403); |
| 1034 | 1177 | |
| 1178 | // A trail of this test's own, longer than one page. Whether there is an | |
| 1179 | // older page must not depend on how much the tests before it happened to | |
| 1180 | // write: this file shares one database, and it runs in any order. | |
| 1181 | const inventory = await import("./inventory.ts"); | |
| 1182 | await ownShelf({ slug: "audit-shelf", title: "Audit shelf", sku: "AUDIT-SHELF", cents: 100, stock: 0 }); | |
| 1183 | const shelf = variantId("AUDIT-SHELF"); | |
| 1184 | for (let n = 0; n < 70; n++) inventory.addStock(shelf, 1, `stocktake ${n}`, ADA); | |
| 1185 | ||
| 1035 | 1186 | const page = await admin.page("/admin/audit"); |
| 1036 | 1187 | expect(page).toContain("Audit"); |
| 1037 | 1188 | const older = /href="(\/admin\/audit\?q=&before=[^"]+)"/.exec(page)?.[1]?.replace(/&/g, "&"); |
src/shop.ts +6
| @@ -308,6 +308,12 @@ export function placeOrder(ctx: Ctx): Response { | ||
| 308 | 308 | : undefined; |
| 309 | 309 | if (problem) return checkoutForm(ctx, problem, values); |
| 310 | 310 | |
| 311 | // What the page showed, as near as this handler can get to it: the same | |
| 312 | // cart and codes, priced again without the address, because the form does | |
| 313 | // not carry the figures it was drawn with. So `place`'s staleness checks — | |
| 314 | // the price, the code, what the card was shown paying — compare this quote | |
| 315 | // against itself and only catch a caller that passes the older one. Carrying | |
| 316 | // the drawn figures in a signed field is what would make them bite here. | |
| 311 | 317 | const shown = cart.quote(ctx.cart, { codes: ctx.codes }); |
| 312 | 318 | const result = orders.place({ |
| 313 | 319 | address: values, |
Changes requested. (The one-character comment on
src/orders.tsis mine — a probe while I found the shape this endpoint wants. Ignore it; this is the review.)Reviewed at
8d0c22a. The code in this pull is right and the tests are real: I revertedsrc/orders.tsandsrc/pages/checkout.tsto the merge base and watched all four new tests go red, then ran the suite green (100 pass), mergedmain— the branch is one commit behind it — and ran it green again (107 pass, and with--randomize). The conditionalUPDATEreally is the only authority now, the sentence is read off the shelf inside the doomed transaction, the id-in-the-hat comment is true (order.idis a bareINTEGER PRIMARY KEY, noAUTOINCREMENT), SQL stays parameterised, the error is escaped atsrc/pages/checkout.ts, and no door, cap or rate limit changed. On the preview I put 99 kettles in a cart:/cartand/checkoutboth sayonly 15 in stock,/checkoutlinks back to/cart, and pressing the button comes back 200 with "Only 15 left of Pour-over Kettle (Brushed steel)." and nothing moved.One miss, and it is about money.
1. the new
README.mdparagraph promises a gift-card safety the web does not have — fix this first. The paragraph ends "The same holds for a gift card another till empties in that gap: the order is refused rather than quietly charging your card for the difference, and the balance is untouched." I ran that over HTTP against the merged tree: a card worth $200, one kettle,/checkoutdrawn while the card covered the lot; another till spends $190; press the button. The order is placed — 303 to/pay/BZ-…— the card is drained to 0 with agift_card_entrywritten against that order, and a shopper who was shown nothing due is standing at a gateway for the rest. Nothing was refused and the balance was very much touched.orders.placecannot refuse it, becausesrc/shop.tsbuildsshownby re-quoting the same cart it is compared against, exactly as the new comment there says; the new balance check atsrc/orders.tsis dead over HTTP and only its unit test sees it. So the README states, as a shopper-facing fact, the one thing #64 exists to make true. Make the paragraph true today: a card emptied to nothing is refused over the web (inquote's code-problem words, not this sentence's), a card part-spent is silently re-priced, and #64 is what turns the second into a refusal. Or cut the sentence and let #64's pull add it. Everything else in the paragraph I checked and it holds.2.
src/cart.tsexportsoutOfStockas the shared voice "at the till", and the till never says it. A line that was already at zero before the shopper ever reached/checkoutis refused with "… sold out while you were checking out." — a race that did not happen — and the refused page prints both sentences, the error notice atsrc/pages/checkout.tsand "… is out of stock." fromquote.problemson the line below. Pick one: haveplacesayoutOfStock(what)wheninput.shownalready knew the line was at zero, or stop exporting a helper nothing outsidecart.tscalls and drop "and at the till" from its comment.3. The emptied-card case has no test at the door it is claimed at. #44 asked for it;
src/contention.test.tsproves it for a caller that hands in the older quote, andsrc/server.test.tshas nothing — which is why finding 1 stayed invisible. One HTTP test for the fully emptied card (the refusal a shopper actually gets, balance untouched, cart intact) pins what is true today and goes red the day #64 changes it.Fix 1 and I approve; 2 and 3 are cheap while you are in there.