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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/bazaar:ledger/44-the-last-unit-goes-to-exactly-one-shoppe → main 5 commits fork 4 threads · 4 unresolved

  • Merged. ledger/44-the-last-unit-goes-to-exactly-one-shoppe was merged into main as a33fde8.

Review

Sign in to review this change.
8 files changed +561 −17
README.md +53 −1
@@ -60,6 +60,58 @@ admin's three live pages load one small file — see
6060 delivered, `/orders` is how a shopper gets back to one — see below.
6161 - No goods ship. It is a demonstration store, and every page says so.
6262
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
outdated

Changes requested. (The one-character comment on src/orders.ts is 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 reverted src/orders.ts and src/pages/checkout.ts to the merge base and watched all four new tests go red, then ran the suite green (100 pass), merged main — the branch is one commit behind it — and ran it green again (107 pass, and with --randomize). The conditional UPDATE really 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.id is a bare INTEGER PRIMARY KEY, no AUTOINCREMENT), SQL stays parameterised, the error is escaped at src/pages/checkout.ts, and no door, cap or rate limit changed. On the preview I put 99 kettles in a cart: /cart and /checkout both say only 15 in stock, /checkout links 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.md paragraph 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, /checkout drawn 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 a gift_card_entry written 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.place cannot refuse it, because src/shop.ts builds shown by re-quoting the same cart it is compared against, exactly as the new comment there says; the new balance check at src/orders.ts is 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 (in quote'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.ts exports outOfStock as the shared voice "at the till", and the till never says it. A line that was already at zero before the shopper ever reached /checkout is 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 at src/pages/checkout.ts and "… is out of stock." from quote.problems on the line below. Pick one: have place say outOfStock(what) when input.shown already knew the line was at zero, or stop exporting a helper nothing outside cart.ts calls 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.ts proves it for a caller that hands in the older quote, and src/server.test.ts has 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.

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
63115 ## Codes, cards and tax
64116
65117 One box at the cart takes a code of either kind, because a shopper does not
@@ -454,7 +506,7 @@ saying so.
454506 | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents |
455507 | `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 |
456508 | `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 |
458510
459511 ### What the door refuses
460512
src/cart.ts +12 −7
@@ -56,6 +56,15 @@ export type Quote = {
5656 code_problems: string[];
5757 };
5858
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
5968 /** Free over $75, otherwise a flat $6. Gift cards are posted with nothing. */
6069 export const SHIPPING_CENTS = 600;
6170 export const FREE_SHIPPING_OVER_CENTS = 7500;
@@ -120,16 +129,12 @@ export function quote(lines: CartLine[], options: { codes?: Applied; destination
120129 for (const line of lines) {
121130 const variant = byId.get(line.variantId);
122131 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);
124133 continue;
125134 }
126135 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));
133138 priced.push({
134139 variant,
135140 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(
7878 return took;
7979 }
8080
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
8185 /** Set stock to a number by writing the difference as a correction. */
8286 export function setStock(variantId: number, target: number, reason: string, by: Actor): void {
8387 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";
2222 import { randomBytes } from "node:crypto";
2323 import { cells, type Actor } from "./actor.ts";
2424 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";
2626 import { dueCents, sumLines } from "./money.ts";
2727 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";
2929 import { writeReceipt } from "./mail.ts";
3030 import type { Applied } from "./codes.ts";
3131
@@ -181,7 +181,27 @@ export function place(input: {
181181
182182 const priced = quote(input.cart, { codes: input.codes, destination: input.address });
183183 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 probe comment on src/orders.ts is mine — this endpoint wants an anchor and I found that out the clumsy way; there is no delete route. Ignore it, this is the review.)

Reviewed at 1e2b050. I read every changed line, ran the suite at this head, and used the preview as a shopper.

What I checked

  • bun test: 134 pass, 0 fail, and green again under --randomize, so the new file's own database holds. The new tests do fail without the change: restoring only the old if (priced.problems.length > 0) return ... guard in place() turns 2 of the 4 src/contention.test.ts tests red, with exactly the sentences the new ones assert.
  • Preview: a line asking for 99 of a variant with 30 on the shelf carries only 30 in stock on /cart and on /checkout, in the same words, and the checkout page carries the back-to-the-cart link. / answers in ~140 ms with the floor intact — CSP with frame-ancestors 'none' and base-uri 'none', nosniff, Referrer-Policy, HSTS, and a session cookie that is HttpOnly; SameSite=Lax; Secure.
  • Security read of the diff: no new input surface, no new dependency, no secret. The refusal sentence is composed server-side from catalog rows and rendered through escape; every new query binds its parameters; identity, CSRF, the byte cap and the rate limit are untouched. The money story holds — the order row, the discount redemption, the gift-card spend, the lines, the events and the number all die with the transaction; inventory.announce still returns early on db.inTransaction and emit still runs only after commit, so a rolled-back attempt reaches no admin's screen. Moving the stock refusal inside the transaction is the right call: the conditional UPDATE is the only honest authority, and a doomed attempt now costs one short write transaction instead of a lie.
  • The terminal is untouched, and rightly so: buying was never a src/tty.ts action, the stock tab reads the same variant.stock a web order moves, and desk.mayPress still gates every read and write by the signed identity.

Findings, none blocking

  1. (low, this line) The gift-card staleness check compares balances. A card that drops from $200 to $100 while the order is $20 still covers the order exactly as the page showed, yet is refused with "That gift card no longer has that much on it." — a sentence that is not true of this order. Comparing against what the page promised the card would pay, priced.gift_card_balance_cents < input.shown.gift_card_cents, refuses only when the card can no longer keep that promise, and sidesteps the tax-follows-address problem the comment above it describes. Fix this one first if anything here is touched again.
  2. (low) That check cannot fire from the only production caller, because shop.placeOrder builds shown by quoting the same cart again. You say so in the pull body, at the call site and in the README, and #64 tracks the signed field that makes it bite — the honest way to leave it. Worth doing next: three staleness checks now guard a path only a synthetic caller reaches.
  3. (nit, src/server.test.ts) expect(page).toContain('href="/cart"') also matches the header's Cart link, so it does not actually prove the new line; assert the sentence around it. next.order.id === won.id + 1 likewise asserts SQLite's rowid allocation more than it asserts behaviour — the stock_entry, discount_redemption and gift_card_entry counts beside it are what carry that test.
  4. (nit) The rollback is asserted ledger by ledger except one: nothing says the refused attempt wrote no live_event row. It cannot, given the inTransaction guard, but that is the ledger a regression would slip past unseen.

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." };
185205 if (priced.code_problems.length > 0) return { ok: false, error: priced.code_problems[0]! };
186206
187207 const itemRows = priced.lines.map((line) => ({
@@ -199,10 +219,21 @@ export function place(input: {
199219 if (priced.discount_cents !== input.shown.discount_cents)
200220 return { ok: false, error: "That code stopped applying while you were checking out." };
201221
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
202229 let placed: Order;
203230 try {
204231 placed = db.transaction(() => {
205232 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.
206237 for (let attempt = 0; attempt < 5 && created === null; attempt++) {
207238 try {
208239 created = db
@@ -281,8 +312,20 @@ export function place(input: {
281312 item.quantity,
282313 item.gift_card_cents,
283314 );
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 }
286329 }
287330 note(
288331 created.id,
@@ -296,8 +339,9 @@ export function place(input: {
296339 })();
297340 } catch (error) {
298341 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) };
301345 if (message.startsWith("CODE_GONE:")) return { ok: false, error: message.slice("CODE_GONE:".length) };
302346 return { ok: false, error: "The order could not be placed. Nothing was charged." };
303347 }
src/pages/checkout.ts +15 −2
@@ -46,10 +46,14 @@ export function checkout(input: {
4646 error?: string;
4747 }): string {
4848 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.
4952 const summary = quote.lines
5053 .map(
5154 (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>
5357 <td class="num">${line.quantity}</td><td class="num price">${escape(formatCents(line.line_total_cents))}</td></tr>`,
5458 )
5559 .join("\n");
@@ -88,13 +92,22 @@ export function checkout(input: {
8892 return `
8993 <h1>Checkout</h1>
9094 ${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")}
92102 <div class="product">
93103 ${left}
94104 <div>
95105 <h2 style="margin-top:0">Your order</h2>
96106 <table><tbody>${summary}</tbody></table>
97107 ${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>
98111 ${codeBox(quote, session, "checkout")}
99112 <p class="muted" style="font-size:.85rem">Tax is worked out from the address on the left when the order is placed.</p>
100113 </div>
src/server.test.ts +151
@@ -892,6 +892,149 @@ test("something sold out cannot be bought", async () => {
892892 ).toBe(0);
893893 });
894894
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
8951038 test("checkout is rate limited", async () => {
8961039 limit.forget();
8971040 const shopper = asCustomer();
@@ -1032,6 +1175,14 @@ test("the audit trail is an admin's, and it pages from a cursor", async () => {
10321175 expect((await new Shopper().get("/admin/audit")).status).toBe(403);
10331176 expect((await asCustomer().get("/admin/audit")).status).toBe(403);
10341177
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
10351186 const page = await admin.page("/admin/audit");
10361187 expect(page).toContain("Audit");
10371188 const older = /href="(\/admin\/audit\?q=&amp;before=[^"]+)"/.exec(page)?.[1]?.replace(/&amp;/g, "&");
src/shop.ts +6
@@ -308,6 +308,12 @@ export function placeOrder(ctx: Ctx): Response {
308308 : undefined;
309309 if (problem) return checkoutForm(ctx, problem, values);
310310
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.
311317 const shown = cart.quote(ctx.cart, { codes: ctx.codes });
312318 const result = orders.place({
313319 address: values,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs