Public: anyone can read this; the crew can change it.

Every ledger row says who did it, and /admin/audit is the whole money trail on one page #77

Merged opened this

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

Merged

ledger/bazaar:ledger/12-every-ledger-row-says-who-did-it-and-adm → main 1 commit fork

  • Merged. ledger/12-every-ledger-row-says-who-did-it-and-adm was merged into main as bb98ad6.

Review

Sign in to review this change.
19 files changed +992 −193
README.md +15 −3
@@ -40,6 +40,17 @@ admin's three live pages load one small file — see
4040 changes to what the shop sells and what it charges are a ledger too: a
4141 price, a slug, a discount code or a tax rate that moves writes a row at
4242 `/admin/changes` naming who moved it and what it was before.
43 - **The audit trail**: every stock movement, payment, refund, order event,
44 gift-card movement and use of a code names the actor who caused it — the
45 buyer for a purchase, the admin for an admin's action, `stripe` for a
46 webhook, `system` for the seed, and the forge's account for anything typed
47 at the terminal. `/admin/audit` reads those five ledgers as one timeline,
48 newest first, narrowed to one order by its number and paged with a cursor;
49 it is an admin's page, and an admin's alone, as is the `audit` tab in the
50 terminal. For one order it shows the arithmetic rather than a claim:
51 charges plus the gift card, less refunds and what went back on the card,
52 against what the order says was paid. Rows written before the actor column
53 existed say `unknown`, which is the truth about them.
4354 - **Simulated unless you configure it**: the payment. With no Stripe keys the
4455 store uses a **mock gateway** — a page labelled as a mock, with Approve and
4556 Decline buttons, that moves no money.
@@ -487,10 +498,10 @@ is written to the same database, and — because an event is a row in it — a
487498 stock move or a refund made here reaches an admin's open `/admin` over the
488499 live stream within about a second, not on that page's next load. `1`–`7`
489500 switch between stock, orders, the outbox, discount codes, gift cards, the tax
490 table and the changes an admin made to any of it — everything `/admin`
491 can see — and `j`/`k` move. A tab whose rows are the shop's own business is an
501 table, the changes an admin made to any of it and the audit trail — everything
502 `/admin` can see — and `j`/`k` move. A tab whose rows are the shop's own business is an
492503 admin's to open: a non-admin gets stock, tax and their own orders, the other
493 four are marked with a `·` in the tab bar and answer without leaving the tab
504 five are marked with a `·` in the tab bar and answer without leaving the tab
494505 they are on:
495506
496507 | Tab | Keys |
@@ -500,6 +511,7 @@ they are on:
500511 | codes | `n` writes a code (`CODE 20% [min 25] [uses 100] [until 2026-12-31]`), `t` turns one on or off, `c` looks a code up |
501512 | cards | `i` issues a card (`25 a sorry-for-the-wait card`) and prints its code once, `v` voids or revives one, `c` looks a code up |
502513 | tax | `e` sets a rate (`COUNTRY / REGION / 8.75 / what to call it`), `d` sets the rate for anywhere the table does not name, `x` removes one |
514 | audit | reads only: the five money ledgers as one timeline, newest first, the terminal's half of `/admin/audit`. `/` narrows it to one order by its number, and the foot says what that order's rows add up to against what the order says was paid |
503515 | changes | reads only: the last hundred changes an admin made to the catalog, the codes or the tax table, newest first — the terminal's half of `/admin/changes`. A change made here writes one of these rows too, naming the account the forge put at the keyboard |
504516
505517 `q` leaves. The first line says who the forge named and what they may read;
src/actor.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * Who caused a row.
3 *
4 * Every ledger in this shop says what happened and why. This is the last
5 * column: who. A stock correction, a refund, a movement of a gift card's
6 * balance — each of them was caused by somebody, and an auditor's first
7 * question about any of them is whose hand it was.
8 *
9 * There are five kinds of hand and no more:
10 *
11 * - an admin, whom the forge named and `src/who.ts` verified;
12 * - a shopper, the account an order belongs to;
13 * - `stripe`, for a row a webhook caused — the gateway told us, nobody asked;
14 * - `system`, for a seed or a boot-time job, which is nobody in particular;
15 * - `unknown`, for a row written before this column existed, and for anyone
16 * the forge did not name.
17 *
18 * The actor is never a form field and never a header the caller wrote: the
19 * caller does not get to sign the trail that is about them. It is also not a
20 * second place that says who bought an order — `order.buyer_id` is the one
21 * answer to that, and this says who caused this row.
22 */
23
24 import { atKeyboard, type Who } from "./who.ts";
25 import type { Order } from "./db.ts";
26
27 export type Role = "admin" | "user" | "stripe" | "system" | "unknown";
28 export type Actor = { id: string; name: string; role: Role };
29
30 /** Nobody the forge named, and the rows written before there was a column. */
31 export const UNKNOWN: Actor = { id: "", name: "unknown", role: "unknown" };
32
33 /** The app itself: a seed, or a job that runs because the process started. */
34 export const SYSTEM: Actor = { id: "", name: "system", role: "system" };
35
36 /** The gateway, when it is the gateway that told us rather than a person. */
37 export const STRIPE: Actor = { id: "", name: "stripe", role: "stripe" };
38
39 /** Whoever the forge signed in, as an actor. Anonymous is nobody. */
40 export const of = (who: Who): Actor =>
41 who.role === "anon" ? UNKNOWN : { id: who.id, name: who.name, role: who.role };
42
43 /**
44 * Who is at the keyboard of the app's terminal. There are no headers on an
45 * ssh session; the forge names the person in the environment before it starts
46 * us, which is the same trust as the socket itself.
47 */
48 export const atDesk = (): Actor => of(atKeyboard());
49
50 /** The account an order belongs to. A guest order from before accounts is nobody's. */
51 export const buyerOf = (order: Pick<Order, "buyer_id" | "buyer_name">): Actor =>
52 order.buyer_id === "" ? UNKNOWN : { id: order.buyer_id, name: order.buyer_name, role: "user" };
53
54 /**
55 * Who is paying: whoever the forge has signed in, or — for a shopper holding
56 * nothing but an order's access token — the account the order belongs to.
57 */
58 export const paying = (who: Who, order: Pick<Order, "buyer_id" | "buyer_name">): Actor =>
59 who.role === "anon" ? buyerOf(order) : of(who);
60
61 /** How long a name or an id is allowed to be in a row: enough, and no more. */
62 const CAP = 64;
63
64 /**
65 * The three columns a ledger row keeps, clipped to what the columns hold.
66 * Spread into the parameters of an INSERT, in the order id, name, role.
67 */
68 export const cells = (by: Actor): [string, string, string] => [
69 by.id.slice(0, CAP),
70 (by.name || "unknown").slice(0, CAP),
71 by.role,
72 ];
src/admin.ts +58 −12
@@ -7,6 +7,8 @@
77 * saying who asked and why.
88 */
99
10 import * as actors from "./actor.ts";
11 import * as audit from "./audit.ts";
1012 import * as catalog from "./catalog.ts";
1113 import * as changes from "./changes.ts";
1214 import * as discounts from "./discounts.ts";
@@ -67,7 +69,7 @@ const guard = (ctx: Ctx): Response | null =>
6769 * src/who.ts, and it is the only source there is: a form field or a header
6870 * that named someone else would be the caller signing their own trail.
6971 */
70 const actor = (ctx: Ctx): changes.Actor => changes.actorOf(ctx.who);
72 const actor = (ctx: Ctx): actors.Actor => actors.of(ctx.who);
7173
7274 /** A rate as the tax table says it. */
7375 const percent = (bp: number): string => `${(bp / 100).toFixed(2)}%`;
@@ -78,6 +80,42 @@ export function changeList(ctx: Ctx): Response {
7880 return render(ctx, { title: "Changes", admin: true }, pages.changes(changes.recent(100)));
7981 }
8082
83 /**
84 * The whole money trail on one page: the five ledgers merged, newest first,
85 * narrowed to one order by its number and paged with a cursor. The number is
86 * looked up before anything is read, so what reaches the query is an id; the
87 * cursor is parsed to a stamp and two numbers or thrown away.
88 */
89 export function auditList(ctx: Ctx): Response {
90 const blocked = guard(ctx);
91 if (blocked) return blocked;
92 const query = (ctx.url.searchParams.get("q") ?? "").slice(0, 20).trim().toUpperCase();
93 const order = query === "" ? null : orders.byNumber(query);
94 const before = audit.readCursor(ctx.url.searchParams.get("before") ?? "");
95 const found =
96 query !== "" && !order ? [] : audit.timeline({ orderId: order?.id, before, limit: AUDIT_PAGE });
97 const rows = found.slice(0, AUDIT_PAGE);
98 const last = rows[rows.length - 1];
99 const older =
100 found.length > AUDIT_PAGE && last
101 ? `/admin/audit?q=${encodeURIComponent(query)}&before=${encodeURIComponent(audit.cursorOf(last))}`
102 : null;
103 return render(
104 ctx,
105 { title: "Audit", admin: true },
106 pages.audit(rows, {
107 query,
108 order,
109 balance: order ? audit.balanceOf(order) : null,
110 shop: audit.shopBalance(),
111 older,
112 }),
113 );
114 }
115
116 /** How much of the timeline one page shows. */
117 const AUDIT_PAGE = 60;
118
81119 // --- the door ---------------------------------------------------------------
82120
83121 export function dashboard(ctx: Ctx): Response {
@@ -245,7 +283,7 @@ export function variantCreate(ctx: Ctx): Response {
245283 // Opening stock is not a field of the trail: it moves through the stock
246284 // ledger, which already says who and why.
247285 db.transaction(() => {
248 const id = catalog.createVariant({ product_id: found.id, sku, name, price_cents: price, stock });
286 const id = catalog.createVariant({ product_id: found.id, sku, name, price_cents: price, stock, by: actor(ctx) });
249287 changes.changed(actor(ctx), "variant", id, {}, { sku, name, price: formatCents(price) });
250288 })();
251289 return redirect(`/admin/products/${found.id}?m=variant-added`);
@@ -306,8 +344,8 @@ export function inventoryMove(ctx: Ctx): Response {
306344 const reason = field(ctx.form, "reason", 120);
307345 const variant = variantId === undefined ? null : catalog.variantById(variantId);
308346 if (!variant || delta === undefined || delta === 0 || reason === "") return redirect("/admin/inventory?m=bad-delta");
309 if (delta > 0) inventory.addStock(variant.id, delta, reason);
310 else inventory.setStock(variant.id, Math.max(0, variant.stock + delta), reason);
347 if (delta > 0) inventory.addStock(variant.id, delta, reason, actor(ctx));
348 else inventory.setStock(variant.id, Math.max(0, variant.stock + delta), reason, actor(ctx));
311349 return redirect("/admin/inventory?m=stocked");
312350 }
313351
@@ -365,14 +403,22 @@ export async function orderRefund(ctx: Ctx): Promise<Response> {
365403 if (order.gateway === "stripe" && order.payment_ref?.startsWith("pi_")) {
366404 const result = await gateway.refundCharge(order.payment_ref, amount, key);
367405 if (!result.ok) {
368 orders.note(order.id, "refund-failed", result.error);
406 orders.note(order.id, "refund-failed", actor(ctx), result.error);
369407 return redirect(`/admin/orders/${order.number}?m=refund-failed`);
370408 }
371409 gatewayRef = result.value.id;
372410 }
373 const done = orders.refund({ order, amount_cents: amount, reason, restock, gateway_ref: gatewayRef, idempotencyKey: key });
411 const done = orders.refund({
412 order,
413 amount_cents: amount,
414 reason,
415 restock,
416 gateway_ref: gatewayRef,
417 idempotencyKey: key,
418 by: actor(ctx),
419 });
374420 if (!done.ok) {
375 orders.note(order.id, "refund-refused", done.error);
421 orders.note(order.id, "refund-refused", actor(ctx), done.error);
376422 return redirect(`/admin/orders/${order.number}?m=bad-amount`);
377423 }
378424 return redirect(`/admin/orders/${order.number}?m=refunded`);
@@ -383,7 +429,7 @@ export function orderCancel(ctx: Ctx): Response {
383429 if (blocked) return blocked;
384430 const order = orders.byNumber((ctx.params.number ?? "").slice(0, 20));
385431 if (!order) return notFound(ctx);
386 orders.cancel(order, field(ctx.form, "reason", 200) || "cancelled in admin");
432 orders.cancel(order, field(ctx.form, "reason", 200) || "cancelled in admin", actor(ctx));
387433 return redirect(`/admin/orders/${order.number}?m=cancelled`);
388434 }
389435
@@ -501,7 +547,7 @@ export function giftCardIssue(ctx: Ctx): Response {
501547 if (blocked) return blocked;
502548 const amount = parseCents(field(ctx.form, "amount", 12));
503549 if (amount === undefined || amount <= 0) return redirect("/admin/giftcards?m=bad-amount");
504 const issued = giftcards.issue({ cents: amount, note: field(ctx.form, "note", 120), orderId: undefined });
550 const issued = giftcards.issue({ cents: amount, note: field(ctx.form, "note", 120), by: actor(ctx) });
505551 return giftCardList(ctx, issued.code);
506552 }
507553
@@ -514,7 +560,7 @@ export function giftCardAdjust(ctx: Ctx): Response {
514560 const reason = field(ctx.form, "reason", 120) || "adjusted in admin";
515561 const asked = field(ctx.form, "void", 1);
516562 if (asked !== "") {
517 giftcards.setActive(card.id, asked === "0", reason);
563 giftcards.setActive(card.id, asked === "0", reason, actor(ctx));
518564 return redirect("/admin/giftcards?m=saved");
519565 }
520566 const raw = field(ctx.form, "amount", 12).replace(/^\+/, "");
@@ -522,8 +568,8 @@ export function giftCardAdjust(ctx: Ctx): Response {
522568 const cents = parseCents(down ? raw.slice(1) : raw);
523569 if (cents === undefined || cents === 0) return redirect("/admin/giftcards?m=bad-amount");
524570 const moved = down
525 ? giftcards.spend(card.id, cents, reason)
526 : giftcards.credit(card.id, cents, reason, undefined, `admin:${crypto.randomUUID()}`);
571 ? giftcards.spend(card.id, cents, reason, actor(ctx))
572 : giftcards.credit(card.id, cents, reason, actor(ctx), undefined, `admin:${crypto.randomUUID()}`);
527573 return redirect(`/admin/giftcards?m=${moved ? "saved" : "bad-amount"}`);
528574 }
529575
src/audit.ts added +273
@@ -0,0 +1,273 @@
1 /**
2 * The money trail, as one timeline.
3 *
4 * Five tables say what this shop did with money and goods: `stock_entry`,
5 * `payment`, `order_event`, `gift_card_entry` and `discount_redemption`. Each
6 * is authoritative about its own thing and each is read on its own page. This
7 * module reads all five at once, newest first, so that an auditor's question —
8 * what happened, in what order, and who did it — has one answer on one page.
9 *
10 * It is a bounded read per table and a merge in memory, never five scans: each
11 * table is ordered by an index on (at DESC, id DESC) and asked for one page's
12 * worth, so the work is the same whether the shop has a hundred rows or a
13 * million. Paging is a cursor rather than an offset, for the same reason — and
14 * because rows arriving while somebody reads must not shift the page under
15 * them. The cursor is (when, which table, which row), which is exactly the
16 * ordering, so it is unambiguous even when a second holds a dozen rows.
17 *
18 * Nothing here reads a column the order page does not already show: no gift
19 * card codes (only the last four, as everywhere), no gateway secrets.
20 */
21
22 import { creditedForOrder } from "./giftcards.ts";
23 import { db, type Order } from "./db.ts";
24
25 /** Which ledger a row came from. */
26 export type Source = "stock" | "payment" | "event" | "gift_card" | "discount";
27
28 /**
29 * The tie-break between two rows written in the same second. It is arbitrary
30 * but fixed: the cursor and the ordering have to agree, or paging would skip.
31 */
32 const RANK: Record<Source, number> = { stock: 0, payment: 1, event: 2, gift_card: 3, discount: 4 };
33
34 export type Entry = {
35 source: Source;
36 id: number;
37 at: string;
38 actor_id: string;
39 actor_name: string;
40 actor_role: string;
41 /** The kind of thing that happened, in one or two words. */
42 what: string;
43 /** The row's own words: a reason, a reference, a SKU. */
44 detail: string;
45 order_id: number | null;
46 order_number: string | null;
47 /** Money as the shop's balance felt it: positive in, negative out, null for goods. */
48 cents: number | null;
49 };
50
51 /** Where a page of the timeline stops: the ordering, written down. */
52 export type Cursor = { at: string; rank: number; id: number };
53
54 export const PAGE = 100;
55
56 export const cursorOf = (entry: Entry): string => `${entry.at}|${RANK[entry.source]}|${entry.id}`;
57
58 /**
59 * A cursor as it came back from a URL. A stamp, a rank and an id, or nothing:
60 * whatever else was typed there is not a place in this timeline.
61 */
62 export function readCursor(raw: string): Cursor | undefined {
63 const fields = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\|([0-4])\|(\d{1,15})$/.exec(raw.slice(0, 40));
64 if (!fields) return undefined;
65 return { at: fields[1]!, rank: Number(fields[2]), id: Number(fields[3]) };
66 }
67
68 type Raw = {
69 id: number;
70 at: string;
71 actor_id: string;
72 actor_name: string;
73 actor_role: string;
74 order_id: number | null;
75 order_number: string | null;
76 a: string;
77 b: string;
78 n: number;
79 };
80
81 /**
82 * The five reads. Each selects the same shape — when, who, which order, and
83 * three loose columns the mapping below turns into words — and each is ordered
84 * by the index its table has for exactly this. `$where` is the only thing that
85 * varies, and it is built below from an id and a cursor, never from text.
86 */
87 type Read = { alias: string; stamp: string; sql: (where: string) => string };
88
89 const READS: Record<Source, Read> = {
90 stock: {
91 alias: "s",
92 stamp: "at",
93 sql: (where) => `
94 SELECT s.id, s.at, s.actor_id, s.actor_name, s.actor_role, s.order_id,
95 (SELECT number FROM "order" o WHERE o.id = s.order_id) AS order_number,
96 (SELECT sku FROM variant v WHERE v.id = s.variant_id) AS a, s.reason AS b, s.delta AS n
97 FROM stock_entry s WHERE ${where} ORDER BY s.at DESC, s.id DESC LIMIT $limit`,
98 },
99 payment: {
100 alias: "p",
101 stamp: "created_at",
102 sql: (where) => `
103 SELECT p.id, p.created_at AS at, p.actor_id, p.actor_name, p.actor_role, p.order_id,
104 (SELECT number FROM "order" o WHERE o.id = p.order_id) AS order_number,
105 p.kind AS a, p.gateway || CASE WHEN p.gateway_ref = '' THEN '' ELSE ' ' || p.gateway_ref END AS b,
106 p.amount_cents AS n
107 FROM payment p WHERE ${where} ORDER BY p.created_at DESC, p.id DESC LIMIT $limit`,
108 },
109 event: {
110 alias: "e",
111 stamp: "at",
112 sql: (where) => `
113 SELECT e.id, e.at, e.actor_id, e.actor_name, e.actor_role, e.order_id,
114 (SELECT number FROM "order" o WHERE o.id = e.order_id) AS order_number,
115 e.kind AS a, e.detail AS b, 0 AS n
116 FROM order_event e WHERE ${where} ORDER BY e.at DESC, e.id DESC LIMIT $limit`,
117 },
118 gift_card: {
119 alias: "g",
120 stamp: "at",
121 sql: (where) => `
122 SELECT g.id, g.at, g.actor_id, g.actor_name, g.actor_role, g.order_id,
123 (SELECT number FROM "order" o WHERE o.id = g.order_id) AS order_number,
124 (SELECT last4 FROM gift_card c WHERE c.id = g.gift_card_id) AS a, g.reason AS b, g.delta AS n
125 FROM gift_card_entry g WHERE ${where} ORDER BY g.at DESC, g.id DESC LIMIT $limit`,
126 },
127 discount: {
128 alias: "r",
129 stamp: "at",
130 sql: (where) => `
131 SELECT r.id, r.at, r.actor_id, r.actor_name, r.actor_role, r.order_id,
132 (SELECT number FROM "order" o WHERE o.id = r.order_id) AS order_number,
133 r.kind AS a, (SELECT code FROM discount d WHERE d.id = r.discount_id) AS b, r.amount_cents AS n
134 FROM discount_redemption r WHERE ${where} ORDER BY r.at DESC, r.id DESC LIMIT $limit`,
135 },
136 };
137
138 /**
139 * One table's page, as raw rows. Both conditions are built here and never from
140 * anything a caller typed: the order is an id looked up first, and the cursor
141 * is a stamp and two numbers. The table's own rank against the cursor's is
142 * decided in this process, so the statement takes the same parameters however
143 * the two compare.
144 */
145 function page(source: Source, options: { orderId?: number; before?: Cursor; limit: number }): Raw[] {
146 const { alias, stamp, sql } = READS[source];
147 const when = `${alias}.${stamp}`;
148 const where = [
149 options.orderId === undefined ? "" : `${alias}.order_id = $order`,
150 options.before === undefined
151 ? ""
152 : `(${when} < $at OR (${when} = $at AND ($below = 1 OR ($equal = 1 AND ${alias}.id < $id))))`,
153 ].filter((part) => part !== "");
154 return db.query<Raw, any>(sql(where.length === 0 ? "1 = 1" : where.join(" AND "))).all({
155 ...(options.orderId === undefined ? {} : { $order: options.orderId }),
156 ...(options.before === undefined
157 ? {}
158 : {
159 $at: options.before.at,
160 $below: RANK[source] < options.before.rank ? 1 : 0,
161 $equal: RANK[source] === options.before.rank ? 1 : 0,
162 $id: options.before.id,
163 }),
164 $limit: options.limit,
165 });
166 }
167
168 const sign = (value: number, negative: boolean): number => (negative ? -value : value);
169
170 /** A raw row as the timeline says it: source by source, in the shop's words. */
171 function read(source: Source, row: Raw): Entry {
172 const common = {
173 source,
174 id: row.id,
175 at: row.at,
176 actor_id: row.actor_id,
177 actor_name: row.actor_name,
178 actor_role: row.actor_role,
179 order_id: row.order_id,
180 order_number: row.order_number,
181 };
182 if (source === "stock")
183 return { ...common, what: "stock", detail: `${row.a} ${row.n > 0 ? "+" : ""}${row.n} — ${row.b}`, cents: null };
184 if (source === "payment")
185 return { ...common, what: row.a, detail: row.b, cents: sign(row.n, row.a === "refund") };
186 if (source === "event") return { ...common, what: row.a, detail: row.b, cents: null };
187 if (source === "gift_card")
188 return { ...common, what: "gift card", detail: `····${row.a} — ${row.b}`, cents: row.n };
189 return { ...common, what: row.a, detail: row.b ?? "", cents: sign(row.n, row.a === "redeemed") };
190 }
191
192 /**
193 * The timeline, newest first. Five short reads, merged and cut to the limit:
194 * the merge is over at most five pages' worth of rows, and the ordering is the
195 * one the cursor speaks. One row more than the limit comes back when there is
196 * one, so the page can say whether there is another without counting anything.
197 */
198 export function timeline(options: { orderId?: number; before?: Cursor; limit?: number } = {}): Entry[] {
199 const limit = Math.max(1, Math.min(PAGE, Math.trunc(options.limit ?? PAGE) || PAGE));
200 const asked = { orderId: options.orderId, before: options.before, limit: limit + 1 };
201 const all: Entry[] = [];
202 for (const source of Object.keys(READS) as Source[])
203 for (const row of page(source, asked)) all.push(read(source, row));
204 all.sort(
205 (one, two) =>
206 (one.at < two.at ? 1 : one.at > two.at ? -1 : 0) ||
207 RANK[two.source] - RANK[one.source] ||
208 two.id - one.id,
209 );
210 return all.slice(0, limit + 1);
211 }
212
213 /**
214 * What an order's ledgers add up to, beside what the order itself says was
215 * paid. Every figure is a sum of rows anyone can check on this page; the
216 * order's own claim is the one thing that is not, which is the point of
217 * holding them up against each other.
218 */
219 export type Balance = {
220 charged_cents: number;
221 refunded_cents: number;
222 gift_card_cents: number;
223 gift_card_back_cents: number;
224 /** Charges and gift card, less refunds and what went back on the card. */
225 held_cents: number;
226 /** What the order row says has been paid and not given back. */
227 says_cents: number;
228 agrees: boolean;
229 };
230
231 const PAID = ["paid", "partially_refunded", "refunded"];
232
233 const sumPayments = (orderId: number, kind: "charge" | "refund"): number =>
234 db
235 .query<{ total: number }, [number, string]>(
236 `SELECT COALESCE(SUM(amount_cents), 0) AS total FROM payment
237 WHERE order_id = ?1 AND kind = ?2 AND status = 'succeeded'`,
238 )
239 .get(orderId, kind)!.total;
240
241 export function balanceOf(order: Order): Balance {
242 const charged = sumPayments(order.id, "charge");
243 const refunded = sumPayments(order.id, "refund");
244 const spent = db
245 .query<{ total: number }, [number]>(
246 "SELECT COALESCE(-SUM(delta), 0) AS total FROM gift_card_entry WHERE order_id = ?1 AND delta < 0",
247 )
248 .get(order.id)!.total;
249 const back = creditedForOrder(order.id);
250 const held = charged + spent - refunded - back;
251 const says = PAID.includes(order.status) ? order.total_cents - (refunded + back) : 0;
252 return {
253 charged_cents: charged,
254 refunded_cents: refunded,
255 gift_card_cents: spent,
256 gift_card_back_cents: back,
257 held_cents: held,
258 says_cents: says,
259 agrees: held === says,
260 };
261 }
262
263 /** The same sum over the whole shop: every charge, less every refund. */
264 export const shopBalance = (): { charged_cents: number; refunded_cents: number; held_cents: number } => {
265 const row = db
266 .query<{ charged: number; refunded: number }, []>(
267 `SELECT
268 (SELECT COALESCE(SUM(amount_cents), 0) FROM payment WHERE kind = 'charge' AND status = 'succeeded') AS charged,
269 (SELECT COALESCE(SUM(amount_cents), 0) FROM payment WHERE kind = 'refund' AND status = 'succeeded') AS refunded`,
270 )
271 .get()!;
272 return { charged_cents: row.charged, refunded_cents: row.refunded, held_cents: row.charged - row.refunded };
273 };
src/catalog.ts +10 −4
@@ -4,6 +4,7 @@
44 * carts, orders or money beyond the integer cents it stores.
55 */
66
7 import { SYSTEM, type Actor } from "./actor.ts";
78 import { db, type Category, type Product, type Variant } from "./db.ts";
89 import { addStock } from "./inventory.ts";
910
@@ -170,13 +171,18 @@ export function updateProduct(
170171 });
171172 }
172173
173 /** A new variant starts at zero stock; stock only ever moves through the ledger. */
174 /**
175 * A new variant starts at zero stock; stock only ever moves through the
176 * ledger, so opening stock is a row like any other and names whoever opened it.
177 */
174178 export function createVariant(input: {
175179 product_id: number;
176180 sku: string;
177181 name: string;
178182 price_cents: number;
179183 stock: number;
184 /** Who is adding it: an admin, or the seed that fills an empty shop. */
185 by: Actor;
180186 /** Non-zero makes this variant a gift card of that face value. */
181187 gift_card_cents?: number;
182188 }): number {
@@ -194,7 +200,7 @@ export function createVariant(input: {
194200 $price_cents: input.price_cents,
195201 $gift_card_cents: input.gift_card_cents ?? 0,
196202 });
197 if (input.stock > 0) addStock(row!.id, input.stock, "opening stock");
203 if (input.stock > 0) addStock(row!.id, input.stock, "opening stock", input.by);
198204 return row!.id;
199205 }
200206
@@ -238,7 +244,7 @@ export function seedGiftCards(): void {
238244 ["GFT-CARD-50", "$50", 5000],
239245 ["GFT-CARD-100", "$100", 10000],
240246 ] as [string, string, number][])
241 createVariant({ product_id: productId, sku, name, price_cents: cents, stock: 1000, gift_card_cents: cents });
247 createVariant({ product_id: productId, sku, name, price_cents: cents, stock: 1000, gift_card_cents: cents, by: SYSTEM });
242248 })();
243249 }
244250
@@ -343,7 +349,7 @@ export function seed(): void {
343349 active: true,
344350 });
345351 for (const [sku, name, price_cents, stock] of product.variants) {
346 createVariant({ product_id: productId, sku, name, price_cents, stock });
352 createVariant({ product_id: productId, sku, name, price_cents, stock, by: SYSTEM });
347353 }
348354 }
349355 }
src/changes.ts +3 −14
@@ -11,7 +11,8 @@
1111 * The actor is whoever the forge named: `ctx.who` on the web, the account the
1212 * forge put in the environment at the terminal. It is never a form field and
1313 * never a header the caller wrote — the caller does not get to sign the trail
14 * that is about them.
14 * that is about them. `src/actor.ts` is where an actor comes from, here and
15 * in every other ledger; this table keeps the id and the name of one.
1516 *
1617 * Nothing spendable is recorded. A discount code is printed on a poster; a
1718 * gift card's code is bearer money and its balance has a ledger of its own,
@@ -19,10 +20,9 @@
1920 */
2021
2122 import { db, type AdminChange } from "./db.ts";
22 import { atKeyboard, type Who } from "./who.ts";
23 import type { Actor } from "./actor.ts";
2324
2425 export type Change = AdminChange;
25 export type Actor = { id: string; name: string };
2626 export type Field = { field: string; before: string; after: string };
2727
2828 /** A value as an audit row says it: plain data in, one short string out. */
@@ -58,17 +58,6 @@ export const flat = (value: Value): string =>
5858 /** A value as a row says it: flat, and short enough to be a row. */
5959 export const say = (value: Value): string => clip(flat(value));
6060
61 /** Who the forge says is asking, as an actor. Anonymous cannot be an admin. */
62 export const actorOf = (who: Who): Actor =>
63 who.role === "anon" ? { id: "", name: "unknown" } : { id: who.id, name: who.name };
64
65 /**
66 * Who is at the keyboard of the app's terminal. There are no headers on an
67 * ssh session; the forge names the person in the environment before it starts
68 * us, which is the same trust as the socket itself.
69 */
70 export const atDesk = (): Actor => actorOf(atKeyboard());
71
7261 /**
7362 * The fields of two snapshots that differ. A form submitted with nothing
7463 * changed produces an empty list, and an empty list writes nothing.
src/db.ts +25 −1
@@ -276,8 +276,32 @@ function migrate(handle: Database): void {
276276 addColumn(handle, "order", "buyer_id", "TEXT NOT NULL DEFAULT ''");
277277 addColumn(handle, "order", "buyer_name", "TEXT NOT NULL DEFAULT ''");
278278 handle.exec('CREATE INDEX IF NOT EXISTS order_buyer ON "order"(buyer_id, id DESC)');
279
280 // Who caused the row. The ledgers already said what happened and why; these
281 // three columns say whose hand it was — see src/actor.ts for the five kinds
282 // of hand there are. The rows written before the column tell the truth about
283 // themselves: no id, and a name and a role of "unknown", because nobody
284 // wrote down who they were and this is not the place to guess.
285 for (const table of ["stock_entry", "payment", "order_event", "gift_card_entry", "discount_redemption"]) {
286 addColumn(handle, table, "actor_id", "TEXT NOT NULL DEFAULT ''");
287 addColumn(handle, table, "actor_name", "TEXT NOT NULL DEFAULT 'unknown'");
288 addColumn(handle, table, "actor_role", "TEXT NOT NULL DEFAULT 'unknown'");
289 }
290 // /admin/audit reads those five newest-first and merges them. Each ordering
291 // is an index, so the timeline is five short reads however many rows there
292 // are — never five scans.
293 handle.exec(`
294 CREATE INDEX IF NOT EXISTS stock_entry_at ON stock_entry(at DESC, id DESC);
295 CREATE INDEX IF NOT EXISTS payment_at ON payment(created_at DESC, id DESC);
296 CREATE INDEX IF NOT EXISTS order_event_at ON order_event(at DESC, id DESC);
297 CREATE INDEX IF NOT EXISTS gift_card_entry_at ON gift_card_entry(at DESC, id DESC);
298 CREATE INDEX IF NOT EXISTS discount_redemption_at ON discount_redemption(at DESC, id DESC);
299 `);
279300 }
280301
302 /** The three columns every ledger row carries about who caused it. */
303 export type ActorColumns = { actor_id: string; actor_name: string; actor_role: string };
304
281305 /**
282306 * Open the database kept in a directory: the directory is made if it is not
283307 * there, the file is opened, the pragmas are set, the schema is created and
@@ -430,7 +454,7 @@ export type TaxRate = {
430454 label: string;
431455 active: number;
432456 };
433 export type Payment = {
457 export type Payment = ActorColumns & {
434458 id: number;
435459 order_id: number;
436460 kind: "charge" | "refund";
src/desk.ts +19 −8
@@ -21,6 +21,7 @@
2121 * it is a function from `Who` to rows, so the rule is tested without a pty.
2222 */
2323
24 import * as actors from "./actor.ts";
2425 import * as changes from "./changes.ts";
2526 import * as discounts from "./discounts.ts";
2627 import * as giftcards from "./giftcards.ts";
@@ -41,7 +42,7 @@ const percent = (bp: number): string => `${(bp / 100).toFixed(2)}%`;
4142 * the same row. The actor is the account the forge named at the keyboard —
4243 * the environment it started us with, which is the same trust as the socket.
4344 */
44 export const TABS = ["stock", "orders", "outbox", "codes", "cards", "tax", "changes"] as const;
45 export const TABS = ["stock", "orders", "outbox", "codes", "cards", "tax", "changes", "audit"] as const;
4546 export type Tab = (typeof TABS)[number];
4647
4748 /**
@@ -112,6 +113,12 @@ const DESK: Record<Tab, { read: "anyone" | "own" | "admin"; keys: Offer[] }> = {
112113 // Who changed a price, a code or a rate is the shop's own business, and it
113114 // names people: the same gate the orders and the cards are behind.
114115 changes: { read: "admin", keys: [] },
116 // The five money ledgers as one timeline — the same rows /admin/audit shows,
117 // and the same gate: it names people, and it says what every order took.
118 audit: {
119 read: "admin",
120 keys: [{ key: "/", label: "/ one order", press: "admin" }],
121 },
115122 };
116123
117124 /**
@@ -218,7 +225,7 @@ export function newDiscount(line: string): Done {
218225 expires_at: expires,
219226 });
220227 changes.changed(
221 changes.atDesk(),
228 actors.atDesk(),
222229 "discount",
223230 made,
224231 {},
@@ -250,7 +257,7 @@ export function toggleDiscount(id: number): Done {
250257 if (!found) return no("No code selected.");
251258 db.transaction(() => {
252259 discounts.setActive(found.id, found.active !== 1);
253 changes.changed(changes.atDesk(), "discount", found.id, { active: found.active === 1 }, { active: found.active !== 1 });
260 changes.changed(actors.atDesk(), "discount", found.id, { active: found.active === 1 }, { active: found.active !== 1 });
254261 })();
255262 return yes(`${found.code} is now ${found.active === 1 ? "off" : "on"}.`);
256263 }
@@ -260,7 +267,11 @@ export function issueCard(line: string): Done {
260267 const parts = words(line);
261268 const cents = parseCents(parts[0] ?? "");
262269 if (cents === undefined || cents <= 0) return no("A card is worth an amount of money, like 25 or 25.00.");
263 const issued = giftcards.issue({ cents, note: parts.slice(1).join(" ").slice(0, 120) || "issued at the terminal" });
270 const issued = giftcards.issue({
271 cents,
272 note: parts.slice(1).join(" ").slice(0, 120) || "issued at the terminal",
273 by: actors.atDesk(),
274 });
264275 return yes(`${issued.code} — ${formatCents(cents)}. This is the only copy of the code.`);
265276 }
266277
@@ -268,7 +279,7 @@ export function toggleCard(id: number, reason: string): Done {
268279 const card = giftcards.byId(id);
269280 if (!card) return no("No card selected.");
270281 const reviving = card.active !== 1;
271 giftcards.setActive(card.id, reviving, reason.trim() || "voided at the terminal");
282 giftcards.setActive(card.id, reviving, reason.trim() || "voided at the terminal", actors.atDesk());
272283 return yes(
273284 reviving
274285 ? `····${card.last4} can be used again; it holds ${formatCents(giftcards.byId(card.id)!.balance_cents)}.`
@@ -316,7 +327,7 @@ export function setRate(line: string): Done {
316327 db.transaction(() => {
317328 const id = tax.upsertRate({ country, region, rate_bp: points, label, active: true });
318329 changes.changed(
319 changes.atDesk(),
330 actors.atDesk(),
320331 "tax_rate",
321332 id,
322333 existing ? { rate: percent(existing.rate_bp), label: existing.label, active: existing.active === 1 } : {},
@@ -332,7 +343,7 @@ export function setDefaultRate(text: string): Done {
332343 const was = tax.defaultRateBp();
333344 db.transaction(() => {
334345 tax.setDefaultRateBp(points);
335 changes.changed(changes.atDesk(), "tax_default", 0, { rate: percent(was) }, { rate: percent(points) });
346 changes.changed(actors.atDesk(), "tax_default", 0, { rate: percent(was) }, { rate: percent(points) });
336347 })();
337348 return yes(`Anywhere the table does not name is taxed at ${(points / 100).toFixed(2)}%.`);
338349 }
@@ -343,7 +354,7 @@ export function removeRate(id: number, confirmation: string): Done {
343354 if (!/^y(es)?$/i.test(confirmation.trim())) return no(`${rate.country} ${rate.region} kept.`);
344355 db.transaction(() => {
345356 tax.removeRate(rate.id);
346 changes.record(changes.atDesk(), "tax_rate", rate.id, [
357 changes.record(actors.atDesk(), "tax_rate", rate.id, [
347358 {
348359 field: "removed",
349360 before: `${rate.country}${rate.region ? ` ${rate.region}` : ""} ${percent(rate.rate_bp)} ${rate.label}`,
src/discounts.ts +15 −6
@@ -11,6 +11,7 @@
1111 * A discount never applies to a gift card: money is not bought at a discount.
1212 */
1313
14 import { cells, type Actor } from "./actor.ts";
1415 import { db, type Discount } from "./db.ts";
1516 import { formatCents } from "./money.ts";
1617
@@ -106,7 +107,13 @@ export function evaluate(code: string, base_cents: number, now = new Date()): Ch
106107 * said the same thing a moment earlier, but only this decides, so a code that
107108 * expired or was switched off in between is not taken anyway.
108109 */
109 export function redeem(discountId: number, orderId: number, amount_cents: number, now = new Date()): boolean {
110 export function redeem(
111 discountId: number,
112 orderId: number,
113 amount_cents: number,
114 by: Actor,
115 now = new Date(),
116 ): boolean {
110117 const changes = db
111118 .query(
112119 `UPDATE discount SET used_count = used_count + 1
@@ -116,18 +123,20 @@ export function redeem(discountId: number, orderId: number, amount_cents: number
116123 .run(discountId, stampOf(now)).changes;
117124 if (changes !== 1) return false;
118125 db.query(
119 "INSERT INTO discount_redemption (discount_id, order_id, kind, amount_cents) VALUES (?1, ?2, 'redeemed', ?3)",
120 ).run(discountId, orderId, amount_cents);
126 `INSERT INTO discount_redemption (discount_id, order_id, kind, amount_cents, actor_id, actor_name, actor_role)
127 VALUES (?1, ?2, 'redeemed', ?3, ?4, ?5, ?6)`,
128 ).run(discountId, orderId, amount_cents, ...cells(by));
121129 return true;
122130 }
123131
124132 /** An order that never happened gives its use back, and says so in a row. */
125 export function release(discountId: number, orderId: number, amount_cents: number): void {
133 export function release(discountId: number, orderId: number, amount_cents: number, by: Actor): void {
126134 db.transaction(() => {
127135 db.query("UPDATE discount SET used_count = MAX(0, used_count - 1) WHERE id = ?1").run(discountId);
128136 db.query(
129 "INSERT INTO discount_redemption (discount_id, order_id, kind, amount_cents) VALUES (?1, ?2, 'released', ?3)",
130 ).run(discountId, orderId, amount_cents);
137 `INSERT INTO discount_redemption (discount_id, order_id, kind, amount_cents, actor_id, actor_name, actor_role)
138 VALUES (?1, ?2, 'released', ?3, ?4, ?5, ?6)`,
139 ).run(discountId, orderId, amount_cents, ...cells(by));
131140 })();
132141 }
133142
src/giftcards.ts +22 −22
@@ -12,7 +12,8 @@
1212 * so two checkouts racing for the last $5 cannot both win.
1313 */
1414
15 import { dataDir, db, type GiftCard } from "./db.ts";
15 import { cells, type Actor } from "./actor.ts";
16 import { dataDir, db, type ActorColumns, type GiftCard } from "./db.ts";
1617 import { hmac, loadSecret } from "./sign.ts";
1718 import type { Order, OrderItem } from "./db.ts";
1819
@@ -21,7 +22,7 @@ const secret = loadSecret(dataDir);
2122 /** No I, O, 0 or 1: a code is read aloud and typed by hand. */
2223 const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
2324
24 export type Entry = {
25 export type Entry = ActorColumns & {
2526 id: number;
2627 gift_card_id: number;
2728 delta: number;
@@ -112,7 +113,7 @@ export type Issued = { card: GiftCard; code: string };
112113 * Make a card. The plain code is returned once, to be printed on a receipt or
113114 * shown to the admin who asked for it, and then it exists nowhere else.
114115 */
115 export function issue(input: { cents: number; note: string; orderId?: number }): Issued {
116 export function issue(input: { cents: number; note: string; by: Actor; orderId?: number }): Issued {
116117 const code = freshCode();
117118 const card = db.transaction(() => {
118119 const row = db
@@ -127,7 +128,7 @@ export function issue(input: { cents: number; note: string; orderId?: number }):
127128 $note: input.note,
128129 $order: input.orderId ?? null,
129130 })!;
130 credit(row.id, input.cents, input.orderId ? "issued with an order" : "issued", input.orderId);
131 credit(row.id, input.cents, input.orderId ? "issued with an order" : "issued", input.by, input.orderId);
131132 return byId(row.id)!;
132133 })();
133134 return { card, code };
@@ -142,6 +143,7 @@ export function credit(
142143 giftCardId: number,
143144 cents: number,
144145 reason: string,
146 by: Actor,
145147 orderId?: number,
146148 idempotencyKey?: string,
147149 ): boolean {
@@ -149,10 +151,11 @@ export function credit(
149151 return db.transaction(() => {
150152 const inserted = db
151153 .query(
152 `INSERT OR IGNORE INTO gift_card_entry (gift_card_id, delta, reason, order_id, idempotency_key)
153 VALUES (?1, ?2, ?3, ?4, ?5)`,
154 `INSERT OR IGNORE INTO gift_card_entry (gift_card_id, delta, reason, order_id, idempotency_key,
155 actor_id, actor_name, actor_role)
156 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
154157 )
155 .run(giftCardId, cents, reason, orderId ?? null, idempotencyKey ?? null).changes;
158 .run(giftCardId, cents, reason, orderId ?? null, idempotencyKey ?? null, ...cells(by)).changes;
156159 if (inserted !== 1) return false;
157160 db.query("UPDATE gift_card SET balance_cents = balance_cents + ?2 WHERE id = ?1").run(giftCardId, cents);
158161 return true;
@@ -163,35 +166,32 @@ export function credit(
163166 * Take money off a card. False and nothing written when the balance is not
164167 * there or the card is void; the caller turns that into a refusal.
165168 */
166 export function spend(giftCardId: number, cents: number, reason: string, orderId?: number): boolean {
169 export function spend(giftCardId: number, cents: number, reason: string, by: Actor, orderId?: number): boolean {
167170 if (cents <= 0) return true;
168171 return db.transaction(() => {
169172 const changes = db
170173 .query("UPDATE gift_card SET balance_cents = balance_cents - ?2 WHERE id = ?1 AND active = 1 AND balance_cents >= ?2")
171174 .run(giftCardId, cents).changes;
172175 if (changes !== 1) return false;
173 db.query("INSERT INTO gift_card_entry (gift_card_id, delta, reason, order_id) VALUES (?1, ?2, ?3, ?4)").run(
174 giftCardId,
175 -cents,
176 reason,
177 orderId ?? null,
178 );
176 db.query(
177 `INSERT INTO gift_card_entry (gift_card_id, delta, reason, order_id, actor_id, actor_name, actor_role)
178 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
179 ).run(giftCardId, -cents, reason, orderId ?? null, ...cells(by));
179180 return true;
180181 })();
181182 }
182183
183184 /** Void a card by writing its balance away, or bring it back to life. */
184 export function setActive(giftCardId: number, active: boolean, reason: string): void {
185 export function setActive(giftCardId: number, active: boolean, reason: string, by: Actor): void {
185186 const card = byId(giftCardId);
186187 if (!card) return;
187188 db.transaction(() => {
188189 if (!active && card.balance_cents > 0) {
189190 db.query("UPDATE gift_card SET balance_cents = 0 WHERE id = ?1").run(giftCardId);
190 db.query("INSERT INTO gift_card_entry (gift_card_id, delta, reason) VALUES (?1, ?2, ?3)").run(
191 giftCardId,
192 -card.balance_cents,
193 reason || "voided",
194 );
191 db.query(
192 `INSERT INTO gift_card_entry (gift_card_id, delta, reason, actor_id, actor_name, actor_role)
193 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
194 ).run(giftCardId, -card.balance_cents, reason || "voided", ...cells(by));
195195 }
196196 db.query("UPDATE gift_card SET active = ?2 WHERE id = ?1").run(giftCardId, active ? 1 : 0);
197197 })();
@@ -202,7 +202,7 @@ export function setActive(giftCardId: number, active: boolean, reason: string):
202202 * quantity three issues three cards, each with its own code, each worth what
203203 * was paid for it. Idempotent — an order that already issued issues nothing.
204204 */
205 export function issueForOrder(order: Order, items: OrderItem[]): Issued[] {
205 export function issueForOrder(order: Order, items: OrderItem[], by: Actor): Issued[] {
206206 const lines = items.filter((item) => item.gift_card_cents > 0);
207207 if (lines.length === 0) return [];
208208 const already = db
@@ -212,7 +212,7 @@ export function issueForOrder(order: Order, items: OrderItem[]): Issued[] {
212212 const issued: Issued[] = [];
213213 for (const line of lines)
214214 for (let n = 0; n < line.quantity; n++)
215 issued.push(issue({ cents: line.gift_card_cents, note: `bought on ${order.number}`, orderId: order.id }));
215 issued.push(issue({ cents: line.gift_card_cents, note: `bought on ${order.number}`, by, orderId: order.id }));
216216 return issued;
217217 }
218218
src/inventory.ts +28 −19
@@ -5,10 +5,11 @@
55 * cannot both win, because the UPDATE only matches while stock is enough.
66 */
77
8 import { db } from "./db.ts";
8 import { cells, type Actor } from "./actor.ts";
9 import { db, type ActorColumns } from "./db.ts";
910 import { emit } from "./live.ts";
1011
11 export type StockEntry = {
12 export type StockEntry = ActorColumns & {
1213 id: number;
1314 variant_id: number;
1415 delta: number;
@@ -18,15 +19,13 @@ export type StockEntry = {
1819 sku: string;
1920 };
2021
21 /** The row that says why stock moved. Prepared on the handle in use, never
22 * held across one: `db.query` caches the statement on the database itself. */
23 const record = (variantId: number, delta: number, reason: string, orderId?: number): void => {
24 db.query("INSERT INTO stock_entry (variant_id, delta, reason, order_id) VALUES (?1, ?2, ?3, ?4)").run(
25 variantId,
26 delta,
27 reason,
28 orderId ?? null,
29 );
22 /** The row that says why stock moved, and who moved it. Prepared on the handle
23 * in use, never held across one: `db.query` caches it on the database itself. */
24 const record = (variantId: number, delta: number, reason: string, by: Actor, orderId?: number): void => {
25 db.query(
26 `INSERT INTO stock_entry (variant_id, delta, reason, order_id, actor_id, actor_name, actor_role)
27 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
28 ).run(variantId, delta, reason, orderId ?? null, ...cells(by));
3029 };
3130
3231 /**
@@ -41,12 +40,16 @@ const announce = (variantId: number): void => {
4140 if (row) emit("stock.moved", { id: variantId, sku: row.sku });
4241 };
4342
44 /** Add stock (a delivery, a correction, a restock after a refund). */
45 export function addStock(variantId: number, quantity: number, reason: string, orderId?: number): void {
43 /**
44 * Add stock (a delivery, a correction, a restock after a refund). The actor is
45 * required, and every mover of stock is named: an anonymous correction is the
46 * one row an auditor cannot follow.
47 */
48 export function addStock(variantId: number, quantity: number, reason: string, by: Actor, orderId?: number): void {
4649 if (quantity <= 0) return;
4750 db.transaction(() => {
4851 db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, quantity);
49 record(variantId, quantity, reason, orderId);
52 record(variantId, quantity, reason, by, orderId);
5053 })();
5154 announce(variantId);
5255 }
@@ -55,14 +58,20 @@ export function addStock(variantId: number, quantity: number, reason: string, or
5558 * Take stock for a sale. Returns false and changes nothing when there is not
5659 * enough; the caller turns that into "out of stock" at the boundary.
5760 */
58 export function takeStock(variantId: number, quantity: number, reason: string, orderId?: number): boolean {
61 export function takeStock(
62 variantId: number,
63 quantity: number,
64 reason: string,
65 by: Actor,
66 orderId?: number,
67 ): boolean {
5968 if (quantity <= 0) return true;
6069 const took = db.transaction(() => {
6170 const changes = db
6271 .query("UPDATE variant SET stock = stock - ?2 WHERE id = ?1 AND stock >= ?2")
6372 .run(variantId, quantity).changes;
6473 if (changes !== 1) return false;
65 record(variantId, -quantity, reason, orderId);
74 record(variantId, -quantity, reason, by, orderId);
6675 return true;
6776 })();
6877 if (took) announce(variantId);
@@ -70,15 +79,15 @@ export function takeStock(variantId: number, quantity: number, reason: string, o
7079 }
7180
7281 /** Set stock to a number by writing the difference as a correction. */
73 export function setStock(variantId: number, target: number, reason: string): void {
82 export function setStock(variantId: number, target: number, reason: string, by: Actor): void {
7483 const current = db.query<{ stock: number }, [number]>("SELECT stock FROM variant WHERE id = ?1").get(variantId);
7584 if (!current) return;
7685 const delta = target - current.stock;
7786 if (delta === 0) return;
78 if (delta > 0) return addStock(variantId, delta, reason);
87 if (delta > 0) return addStock(variantId, delta, reason, by);
7988 db.transaction(() => {
8089 db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, delta);
81 record(variantId, delta, reason);
90 record(variantId, delta, reason, by);
8291 })();
8392 announce(variantId);
8493 }
src/orders.ts +69 −30
@@ -16,10 +16,12 @@
1616 * webhook arriving twice writes one row, so it announces one thing.
1717 */
1818
19 import * as actors from "./actor.ts";
1920 import * as discounts from "./discounts.ts";
2021 import * as giftcards from "./giftcards.ts";
2122 import { randomBytes } from "node:crypto";
22 import { db, type Order, type OrderItem, type Payment } from "./db.ts";
23 import { cells, type Actor } from "./actor.ts";
24 import { db, type ActorColumns, type Order, type OrderItem, type Payment } from "./db.ts";
2325 import { addStock, takeStock } from "./inventory.ts";
2426 import { dueCents, sumLines } from "./money.ts";
2527 import { emit } from "./live.ts";
@@ -38,8 +40,13 @@ export type Address = {
3840 country: string;
3941 };
4042
41 /** The account an order belongs to: who the forge signed in, and nothing else. */
42 export type Buyer = { id: string; name: string };
43 /**
44 * The account an order belongs to: who the forge signed in, and nothing else.
45 * It is also the actor of every row the order writes as it is placed — the
46 * stock it takes, the code it redeems, the balance it spends — because a
47 * shopper buying something is who caused all of them.
48 */
49 export type Buyer = Actor;
4350
4451 export type PlaceResult = { ok: true; order: Order; reused: boolean } | { ok: false; error: string };
4552
@@ -61,15 +68,22 @@ export const itemsOf = (orderId: number): OrderItem[] =>
6168 export const paymentsOf = (orderId: number): Payment[] =>
6269 db.query<Payment, [number]>("SELECT * FROM payment WHERE order_id = ?1 ORDER BY id").all(orderId);
6370
64 export const eventsOf = (orderId: number) =>
71 export type OrderEvent = ActorColumns & { id: number; kind: string; detail: string; at: string };
72
73 export const eventsOf = (orderId: number): OrderEvent[] =>
6574 db
66 .query<{ id: number; kind: string; detail: string; at: string }, [number]>(
67 "SELECT id, kind, detail, at FROM order_event WHERE order_id = ?1 ORDER BY id",
75 .query<OrderEvent, [number]>(
76 `SELECT id, kind, detail, at, actor_id, actor_name, actor_role FROM order_event
77 WHERE order_id = ?1 ORDER BY id`,
6878 )
6979 .all(orderId);
7080
71 export const note = (orderId: number, kind: string, detail = ""): void => {
72 db.query("INSERT INTO order_event (order_id, kind, detail) VALUES (?1, ?2, ?3)").run(orderId, kind, detail);
81 /** An event on an order's history, and who caused it. */
82 export const note = (orderId: number, kind: string, by: Actor, detail = ""): void => {
83 db.query(
84 `INSERT INTO order_event (order_id, kind, detail, actor_id, actor_name, actor_role)
85 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
86 ).run(orderId, kind, detail, ...cells(by));
7387 };
7488
7589 /** A needle for LIKE: the shopper's text is data, and `%` and `_` are theirs to type. */
@@ -238,15 +252,18 @@ export function place(input: {
238252 // out first stops the whole order rather than half of it.
239253 if (priced.discount_code !== "") {
240254 const discount = discounts.byCode(priced.discount_code)!;
241 if (!discounts.redeem(discount.id, created.id, priced.discount_cents))
255 if (!discounts.redeem(discount.id, created.id, priced.discount_cents, input.buyer))
242256 throw new Error(`CODE_GONE:${priced.discount_code} stopped applying while you were checking out.`);
243 note(created.id, "discount", `${discount.code} — ${discounts.describe(discount)}`);
257 note(created.id, "discount", input.buyer, `${discount.code} — ${discounts.describe(discount)}`);
244258 }
245259 if (priced.gift_card_cents > 0) {
246260 const card = giftcards.byCode(input.codes.gift);
247 if (!card || !giftcards.spend(card.id, priced.gift_card_cents, `paid on ${created.number}`, created.id))
261 if (
262 !card ||
263 !giftcards.spend(card.id, priced.gift_card_cents, `paid on ${created.number}`, input.buyer, created.id)
264 )
248265 throw new Error("CODE_GONE:That gift card no longer has that much on it.");
249 note(created.id, "gift-card", `····${card.last4} paid ${(priced.gift_card_cents / 100).toFixed(2)}`);
266 note(created.id, "gift-card", input.buyer, `····${card.last4} paid ${(priced.gift_card_cents / 100).toFixed(2)}`);
250267 }
251268
252269 const insertItem = db.query(
@@ -264,12 +281,13 @@ export function place(input: {
264281 item.quantity,
265282 item.gift_card_cents,
266283 );
267 if (!takeStock(item.variant_id, item.quantity, `sold on ${created.number}`, created.id))
284 if (!takeStock(item.variant_id, item.quantity, `sold on ${created.number}`, input.buyer, created.id))
268285 throw new Error(`OUT_OF_STOCK:${item.product_title} (${item.variant_name})`);
269286 }
270287 note(
271288 created.id,
272289 "placed",
290 input.buyer,
273291 `${itemRows.length} line${itemRows.length === 1 ? "" : "s"}, awaiting payment${
274292 created.buyer_name === "" ? "" : ` — ${created.buyer_name}`
275293 }`,
@@ -289,7 +307,7 @@ export function place(input: {
289307 emit("order.placed", { id: placed.id, number: placed.number, cents: placed.total_cents });
290308 for (const item of itemRows) emit("stock.moved", { id: item.variant_id, sku: item.sku });
291309 // A gift card that covered the lot leaves nothing for a gateway to take.
292 return { ok: true, order: dueCents(placed) === 0 ? payWithGiftCard(placed) : placed, reused: false };
310 return { ok: true, order: dueCents(placed) === 0 ? payWithGiftCard(placed, input.buyer) : placed, reused: false };
293311 }
294312
295313 export type Charge = { gateway: string; ref: string; amount_cents: number };
@@ -332,7 +350,7 @@ const alreadyShort = (orderId: number): boolean =>
332350 .query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM order_event WHERE order_id = ?1 AND kind = 'short-payment'")
333351 .get(orderId)!.n > 0;
334352
335 export function settleFromGateway(order: Order, charge: Charge): Order {
353 export function settleFromGateway(order: Order, charge: Charge, by: Actor): Order {
336354 // A gateway may say anything; a charge is a whole number of cents, and the
337355 // payment ledger has no room for a row that moved nothing.
338356 const amount = Number.isFinite(charge.amount_cents) ? Math.trunc(charge.amount_cents) : 0;
@@ -344,10 +362,11 @@ export function settleFromGateway(order: Order, charge: Charge): Order {
344362 amount > 0
345363 ? db
346364 .query(
347 `INSERT OR IGNORE INTO payment (order_id, kind, amount_cents, gateway, gateway_ref, status, idempotency_key)
348 VALUES (?1, 'charge', ?2, ?3, ?4, 'succeeded', ?5)`,
365 `INSERT OR IGNORE INTO payment (order_id, kind, amount_cents, gateway, gateway_ref, status,
366 idempotency_key, actor_id, actor_name, actor_role)
367 VALUES (?1, 'charge', ?2, ?3, ?4, 'succeeded', ?5, ?6, ?7, ?8)`,
349368 )
350 .run(fresh.id, amount, charge.gateway, ref, `charge:${fresh.number}`).changes
369 .run(fresh.id, amount, charge.gateway, ref, `charge:${fresh.number}`, ...cells(by)).changes
351370 : 0;
352371 // Already recorded: the other arrival settled this order, whichever way.
353372 if (inserted === 0 && amount > 0) return { paid: false, order: byId(fresh.id)! };
@@ -358,7 +377,7 @@ export function settleFromGateway(order: Order, charge: Charge): Order {
358377 `UPDATE "order" SET status = 'paid', paid_at = datetime('now'), payment_ref = ?2, gateway = ?3
359378 WHERE id = ?1 AND status = 'pending_payment'`,
360379 ).run(fresh.id, ref, charge.gateway).changes;
361 note(fresh.id, "paid", `${charge.gateway} ${ref}`);
380 note(fresh.id, "paid", by, `${charge.gateway} ${ref}`);
362381 return { paid: changed === 1, order: byId(fresh.id)! };
363382 }
364383 // Short. The order stays where it was; the gateway's handle is kept so
@@ -374,6 +393,7 @@ export function settleFromGateway(order: Order, charge: Charge): Order {
374393 note(
375394 fresh.id,
376395 "short-payment",
396 by,
377397 `asked ${(due / 100).toFixed(2)} ${fresh.currency}, ${charge.gateway} paid ${(amount / 100).toFixed(2)} — ` +
378398 `${((due - amount) / 100).toFixed(2)} short${ref ? ` (${ref})` : ""}`,
379399 );
@@ -392,7 +412,7 @@ export function settleFromGateway(order: Order, charge: Charge): Order {
392412 * charge to record — the money moved when the balance did, and that row is
393413 * already written.
394414 */
395 export function payWithGiftCard(order: Order): Order {
415 export function payWithGiftCard(order: Order, by: Actor): Order {
396416 const took = db.transaction(() => {
397417 const changed = db
398418 .query(
@@ -400,7 +420,8 @@ export function payWithGiftCard(order: Order): Order {
400420 WHERE id = ?1 AND status = 'pending_payment'`,
401421 )
402422 .run(order.id).changes;
403 if (changed === 1) note(order.id, "paid", `gift card ····${giftcards.cardSpentOn(order.id)?.last4 ?? "????"}`);
423 if (changed === 1)
424 note(order.id, "paid", by, `gift card ····${giftcards.cardSpentOn(order.id)?.last4 ?? "????"}`);
404425 return { charged: changed === 1, order: byId(order.id)! };
405426 })();
406427 const paid = settle(took.order);
@@ -416,7 +437,9 @@ export function payWithGiftCard(order: Order): Order {
416437 function settle(order: Order): Order {
417438 if (order.status !== "paid") return order;
418439 const items = itemsOf(order.id);
419 const issued = giftcards.issueForOrder(order, items);
440 // The cards an order bought were caused by whoever bought it, not by the
441 // gateway that happened to say the money arrived.
442 const issued = giftcards.issueForOrder(order, items, actors.buyerOf(order));
420443 if (!hasReceipt(order.id)) writeReceipt(order, items, issued.map((one) => one.code));
421444 return order;
422445 }
@@ -439,6 +462,8 @@ export function refund(input: {
439462 restock: boolean;
440463 gateway_ref: string;
441464 idempotencyKey: string;
465 /** Whoever is giving the money back: an admin, on a page or at the terminal. */
466 by: Actor;
442467 }): RefundResult {
443468 const { order } = input;
444469 if (input.amount_cents <= 0) return { ok: false, error: "A refund is more than nothing." };
@@ -456,12 +481,15 @@ export function refund(input: {
456481 if (input.amount_cents > left) return { ok: false, error: "That is more than the order has left to refund." };
457482 const split = splitRefund(fresh, input.amount_cents);
458483
484 const [actorId, actorName, actorRole] = cells(input.by);
459485 let payment: Payment | null = null;
460486 if (split.gateway_cents > 0)
461487 payment = db
462488 .query<Payment, any>(
463 `INSERT INTO payment (order_id, kind, amount_cents, gateway, gateway_ref, status, idempotency_key)
464 VALUES ($order_id, 'refund', $amount, $gateway, $ref, 'succeeded', $key) RETURNING *`,
489 `INSERT INTO payment (order_id, kind, amount_cents, gateway, gateway_ref, status, idempotency_key,
490 actor_id, actor_name, actor_role)
491 VALUES ($order_id, 'refund', $amount, $gateway, $ref, 'succeeded', $key,
492 $actor_id, $actor_name, $actor_role) RETURNING *`,
465493 )
466494 .get({
467495 $order_id: fresh.id,
@@ -469,6 +497,9 @@ export function refund(input: {
469497 $gateway: fresh.gateway,
470498 $ref: input.gateway_ref,
471499 $key: input.idempotencyKey,
500 $actor_id: actorId,
501 $actor_name: actorName,
502 $actor_role: actorRole,
472503 })!;
473504 if (split.gift_card_cents > 0) {
474505 const card = giftcards.cardSpentOn(fresh.id);
@@ -477,11 +508,17 @@ export function refund(input: {
477508 card.id,
478509 split.gift_card_cents,
479510 `refunded from ${fresh.number}`,
511 input.by,
480512 fresh.id,
481513 `${input.idempotencyKey}:card`,
482514 );
483515 if (!put) return { ok: false, error: "That refund has already gone back to the card." };
484 note(fresh.id, "gift-card", `····${card.last4} took back ${(split.gift_card_cents / 100).toFixed(2)}`);
516 note(
517 fresh.id,
518 "gift-card",
519 input.by,
520 `····${card.last4} took back ${(split.gift_card_cents / 100).toFixed(2)}`,
521 );
485522 }
486523
487524 const refunded = refundedCents(fresh.id);
@@ -489,10 +526,11 @@ export function refund(input: {
489526 db.query('UPDATE "order" SET status = ?2 WHERE id = ?1').run(fresh.id, status);
490527 if (input.restock)
491528 for (const item of itemsOf(fresh.id))
492 addStock(item.variant_id, item.quantity, `restocked from ${fresh.number}`, fresh.id);
529 addStock(item.variant_id, item.quantity, `restocked from ${fresh.number}`, input.by, fresh.id);
493530 note(
494531 fresh.id,
495532 "refunded",
533 input.by,
496534 `${(input.amount_cents / 100).toFixed(2)} ${fresh.currency}${input.reason ? ` — ${input.reason}` : ""}${
497535 input.restock ? " (restocked)" : ""
498536 }`,
@@ -515,16 +553,16 @@ export function refund(input: {
515553 * An unpaid order an admin gives up on: the stock goes back on the shelf, the
516554 * code gets its use back and the gift card gets its balance back.
517555 */
518 export function cancel(order: Order, reason: string): Order {
556 export function cancel(order: Order, reason: string, by: Actor): Order {
519557 const ended = db.transaction(() => {
520558 const fresh = byId(order.id)!;
521559 if (fresh.status !== "pending_payment") return { cancelled: false, order: fresh, items: [] as OrderItem[] };
522560 db.query('UPDATE "order" SET status = \'cancelled\' WHERE id = ?1').run(fresh.id);
523561 for (const item of itemsOf(fresh.id))
524 addStock(item.variant_id, item.quantity, `cancelled ${fresh.number}`, fresh.id);
562 addStock(item.variant_id, item.quantity, `cancelled ${fresh.number}`, by, fresh.id);
525563 if (fresh.discount_code !== "") {
526564 const discount = discounts.byCode(fresh.discount_code);
527 if (discount) discounts.release(discount.id, fresh.id, fresh.discount_cents);
565 if (discount) discounts.release(discount.id, fresh.id, fresh.discount_cents, by);
528566 }
529567 if (fresh.gift_card_cents > 0) {
530568 const card = giftcards.cardSpentOn(fresh.id);
@@ -533,11 +571,12 @@ export function cancel(order: Order, reason: string): Order {
533571 card.id,
534572 fresh.gift_card_cents,
535573 `released from ${fresh.number}`,
574 by,
536575 fresh.id,
537576 `release:${fresh.number}`,
538577 );
539578 }
540 note(fresh.id, "cancelled", reason);
579 note(fresh.id, "cancelled", by, reason);
541580 return { cancelled: true, order: byId(fresh.id)!, items: itemsOf(fresh.id) };
542581 })();
543582
src/pages/admin.ts +111 −4
@@ -16,13 +16,14 @@ import { formatCents } from "../money.ts";
1616 import { itemsTable, orderTotals, statusPill } from "./checkout.ts";
1717 import { describe } from "../discounts.ts";
1818 import { href, nameOf, type Reading } from "../changes.ts";
19 import type { Balance, Entry } from "../audit.ts";
1920 import type { DiscountStats } from "../discounts.ts";
2021 import type { ProductWithPrice } from "../catalog.ts";
2122 import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant } from "../db.ts";
2223 import type { StockEntry } from "../inventory.ts";
2324 import type { Email } from "../mail.ts";
2425 import type { Session } from "../session.ts";
25 import type { Shortfall } from "../orders.ts";
26 import type { OrderEvent, Shortfall } from "../orders.ts";
2627
2728 /** What the ledger emits about an order, and about a stock movement. */
2829 const ORDER_EVENTS = "order.placed order.paid order.refunded order.cancelled";
@@ -46,6 +47,7 @@ export const nav = (current: string): string =>
4647 ["/admin/tax", "Tax", "tax"],
4748 ["/admin/mail", "Outbox", "mail"],
4849 ["/admin/changes", "Changes", "changes"],
50 ["/admin/audit", "Audit", "audit"],
4951 ]
5052 .map(([href, label, key]) =>
5153 key === current ? `<strong>${escape(label)}</strong>` : `<a href="${escape(href)}">${escape(label)}</a>`,
@@ -141,11 +143,21 @@ ${nav("orders")}
141143 ${live("order-list", ORDER_EVENTS, orderRows(list))}
142144 `;
143145
146 /**
147 * Who caused a row, as a ledger says it: the name the forge knew them by, and
148 * under it the part that does not change — the role, and the account's id.
149 * A row from before the column says "unknown", which is the truth about it.
150 */
151 const who = (row: { actor_name: string; actor_role: string; actor_id: string }): string =>
152 `${escape(row.actor_name || "unknown")}<div class="muted" style="font-size:.8rem">${escape(row.actor_role)}${
153 row.actor_id === "" ? "" : ` · ${escape(row.actor_id)}`
154 }</div>`;
155
144156 export function order(
145157 found: Order,
146158 items: OrderItem[],
147159 payments: Payment[],
148 events: { kind: string; detail: string; at: string }[],
160 events: OrderEvent[],
149161 refunded: number,
150162 refundable: number,
151163 email: Email | null,
@@ -203,11 +215,12 @@ ${orderTotals(found, refunded)}
203215 ${
204216 payments.length === 0
205217 ? '<p class="muted">No payments recorded.</p>'
206 : `<table><thead><tr><th>When</th><th>Kind</th><th class="num">Amount</th><th>Reference</th></tr></thead>
218 : `<table><thead><tr><th>When</th><th>Kind</th><th class="num">Amount</th><th>Who</th><th>Reference</th></tr></thead>
207219 <tbody>${payments
208220 .map(
209221 (payment) => `<tr><td class="muted">${escape(payment.created_at)}</td><td>${escape(payment.kind)}</td>
210222 <td class="num price">${escape(formatCents(payment.kind === "refund" ? -payment.amount_cents : payment.amount_cents))}</td>
223 <td>${who(payment)}</td>
211224 <td class="muted" style="word-break:break-all">${escape(payment.gateway_ref || "—")}</td></tr>`,
212225 )
213226 .join("")}</tbody></table>`
@@ -218,7 +231,7 @@ ${orderTotals(found, refunded)}
218231 (event) =>
219232 `<tr><td class="muted" style="white-space:nowrap">${escape(event.at)}</td><td><strong>${escape(
220233 event.kind,
221 )}</strong> ${escape(event.detail)}</td></tr>`,
234 )}</strong> ${escape(event.detail)}</td><td>${who(event)}</td></tr>`,
222235 )
223236 .join("")}</tbody></table>
224237 ${
@@ -433,6 +446,100 @@ ${
433446 }
434447 `;
435448
449 /**
450 * The audit page: five ledgers as one timeline, newest first.
451 *
452 * Every column here is a column some other admin page already shows — the
453 * gift card's last four, the gateway's reference, the reason somebody typed —
454 * and every one of them goes out through `escape`. The figures above the table
455 * are sums of the rows below it, so a reader can add them up rather than
456 * believe them.
457 */
458 export function audit(
459 rows: Entry[],
460 view: {
461 query: string;
462 order: Order | null;
463 balance: Balance | null;
464 shop: { charged_cents: number; refunded_cents: number; held_cents: number };
465 older: string | null;
466 },
467 ): string {
468 const figure = (label: string, cents: number, strong = false): string =>
469 `<tr><td>${strong ? `<strong>${escape(label)}</strong>` : escape(label)}</td>
470 <td class="num price">${strong ? "<strong>" : ""}${escape(formatCents(cents))}${strong ? "</strong>" : ""}</td></tr>`;
471 return `
472 ${nav("audit")}
473 <h1>Audit</h1>
474 <p class="lede">Every stock movement, payment, refund, order event, gift-card movement and use of a code, in the
475 order they happened, and who caused each one. This is the same five tables the pages above read one at a time.</p>
476 <form method="get" action="/admin/audit" class="row-actions" style="margin-bottom:1rem">
477 <input name="q" value="${escape(view.query)}" placeholder="Order number, like BZ-XXXXXXXX" maxlength="20"
478 style="max-width:22rem" aria-label="Order number">
479 <button class="btn ghost">Show that order</button>
480 ${view.query === "" ? "" : '<a class="btn ghost" href="/admin/audit">Everything</a>'}
481 </form>
482 ${
483 view.order && view.balance
484 ? `<div class="notice">
485 <h2 style="margin-top:0">${escape(view.order.number)} adds up</h2>
486 <table class="steady" style="max-width:26rem">
487 <colgroup><col style="width:65%"><col style="width:35%"></colgroup>
488 <tbody>
489 ${figure("Charged through the gateway", view.balance.charged_cents)}
490 ${figure("Paid with a gift card", view.balance.gift_card_cents)}
491 ${figure("Refunded through the gateway", -view.balance.refunded_cents)}
492 ${figure("Put back on the card", -view.balance.gift_card_back_cents)}
493 ${figure("The ledgers hold", view.balance.held_cents, true)}
494 ${figure("The order says it was paid", view.balance.says_cents, true)}
495 </tbody>
496 </table>
497 <p>${
498 view.balance.agrees
499 ? '<span class="pill ok">these agree</span>'
500 : `<span class="pill warn">these do not agree</span> ${
501 view.order.status === "pending_payment"
502 ? "Money arrived for an order that is still awaiting payment — see the shortfall on the order itself."
503 : "Somebody should look at this order."
504 }`
505 }</p>
506 </div>`
507 : view.query === ""
508 ? `<p class="notice">Every charge in this shop comes to ${escape(formatCents(view.shop.charged_cents))}, every
509 refund to ${escape(formatCents(view.shop.refunded_cents))}: <strong>${escape(
510 formatCents(view.shop.held_cents),
511 )}</strong> taken and not given back.</p>`
512 : `<p class="notice bad">No order called ${escape(view.query)}.</p>`
513 }
514 ${
515 rows.length === 0
516 ? '<p class="muted">Nothing here.</p>'
517 : `<table class="steady">
518 <colgroup><col style="width:15%"><col style="width:15%"><col style="width:12%"><col style="width:33%">
519 <col style="width:13%"><col style="width:12%"></colgroup>
520 <thead><tr><th>When</th><th>Who</th><th>What</th><th>Detail</th><th>Order</th><th class="num">Amount</th></tr></thead>
521 <tbody>${rows
522 .map(
523 (row) => `<tr>
524 <td class="muted" style="white-space:nowrap">${escape(row.at)}</td>
525 <td>${who(row)}</td>
526 <td>${escape(row.what)}</td>
527 <td class="muted">${escape(row.detail)}</td>
528 <td>${
529 row.order_number === null
530 ? '<span class="muted">—</span>'
531 : `<a href="/admin/orders/${escape(row.order_number)}">${escape(row.order_number)}</a>`
532 }</td>
533 <td class="num price">${row.cents === null ? '<span class="muted">—</span>' : escape(formatCents(row.cents))}</td>
534 </tr>`,
535 )
536 .join("\n")}</tbody>
537 </table>`
538 }
539 ${view.older === null ? "" : `<p><a class="btn ghost" href="${escape(view.older)}">Older →</a></p>`}
540 `;
541 }
542
436543 // --- codes, cards and tax ----------------------------------------------------
437544
438545 const dateCell = (value: string | null): string => escape(value ?? "—");
src/routes.ts +1
@@ -75,6 +75,7 @@ export const routes: Route[] = [
7575 { method: "POST", pattern: "/admin/orders/:number/cancel", handler: admin.orderCancel },
7676 { method: "GET", pattern: "/admin/mail", handler: admin.mailbox },
7777 { method: "GET", pattern: "/admin/changes", handler: admin.changeList },
78 { method: "GET", pattern: "/admin/audit", handler: admin.auditList },
7879 ];
7980
8081 export type Match = { route: Route; params: Record<string, string> };
src/server.test.ts +82 −12
@@ -99,6 +99,9 @@ class Shopper {
9999 }
100100 }
101101
102 /** Somebody, for the rows a test writes by hand: every ledger names one. */
103 const ADA = { id: "u_1", name: "ada", role: "admin" } as const;
104
102105 /** An admin, the way the forge signs one in. */
103106 const asAdmin = (): Shopper => new Shopper({ id: "u_1", name: "ada", role: "admin" });
104107
@@ -421,8 +424,8 @@ test("a discount, a part-spent gift card and a state's rate become an order that
421424 limit.forget();
422425 const shopper = asCustomer();
423426 // A card someone has already spent $15 of, so $35 of its $50 is left.
424 const { card, code } = giftcards.issue({ cents: 5000, note: "bought earlier" });
425 expect(giftcards.spend(card.id, 1500, "spent at another till")).toBe(true);
427 const { card, code } = giftcards.issue({ cents: 5000, note: "bought earlier", by: ADA });
428 expect(giftcards.spend(card.id, 1500, "spent at another till", ADA)).toBe(true);
426429
427430 await addToCart(shopper, "COF-GUJI-250", 3); // $57.00 of goods
428431 await addToCart(shopper, "GFT-CARD-25", 1); // $25.00 of money, which is not a sale
@@ -500,7 +503,7 @@ test("a discount, a part-spent gift card and a state's rate become an order that
500503
501504 test("two orders on one gift card spend what is on it and no more", async () => {
502505 limit.forget();
503 const { card, code } = giftcards.issue({ cents: 3000, note: "one card, two shoppers" });
506 const { card, code } = giftcards.issue({ cents: 3000, note: "one card, two shoppers", by: ADA });
504507
505508 // Two shoppers hold the same code, each with $23.32 to pay.
506509 const first = asCustomer();
@@ -876,6 +879,73 @@ test("an unsigned webhook is refused", async () => {
876879 expect(response.status).toBe(400);
877880 });
878881
882
883 test("every ledger row names who caused it, and /admin/audit is the five of them at once", async () => {
884 limit.forget();
885 const person = account();
886 const shopper = new Shopper(person);
887 const { number } = await buy(shopper, "COF-DECAF-250", 1);
888 const order = orderNamed(number);
889
890 // The buyer caused the sale: the stock it took, the charge and the events.
891 const rowsOf = (table: string, where: string): any[] =>
892 db.query<any, [number]>(`SELECT * FROM ${table} WHERE ${where} = ?1 ORDER BY id`).all(order.id);
893 const sold = rowsOf("stock_entry", "order_id").find((row) => row.delta < 0)!;
894 expect([sold.actor_id, sold.actor_name, sold.actor_role]).toEqual([person.id, person.name, "user"]);
895 const charge = rowsOf("payment", "order_id").find((row) => row.kind === "charge")!;
896 expect([charge.actor_id, charge.actor_role]).toEqual([person.id, "user"]);
897 expect(rowsOf("order_event", "order_id").every((row) => row.actor_id === person.id)).toBe(true);
898
899 // The admin caused the refund, and their name is on that row and no other.
900 const admin = asAdmin();
901 const orderPage = await admin.page(`/admin/orders/${number}`);
902 const refunded = await admin.post(
903 `/admin/orders/${number}/refund`,
904 { amount: "1.00", reason: "a tear in the bag", restock: "" },
905 Shopper.tokenIn(orderPage),
906 );
907 expect(refunded.status).toBe(303);
908 const refund = rowsOf("payment", "order_id").find((row) => row.kind === "refund")!;
909 expect([refund.actor_id, refund.actor_name, refund.actor_role]).toEqual(["u_1", "ada", "admin"]);
910
911 // The order page says who, on every payment and every event it lists.
912 const detail = await admin.page(`/admin/orders/${number}`);
913 expect(detail).toContain(">ada<");
914 expect(detail).toContain(person.name);
915
916 // And the timeline reads all five, newest first: the refund is above the
917 // charge, both name their actor, and each links to the order.
918 const page = await admin.page(`/admin/audit?q=${number}`);
919 expect(page.indexOf("refund")).toBeLessThan(page.indexOf("charge"));
920 expect(page).toContain(`href="/admin/orders/${number}"`);
921 expect(page).toContain("ada");
922 expect(page).toContain(person.name);
923 // It reconciles: what the ledgers hold is what the order says was paid.
924 expect(page).toContain("these agree");
925 expect(page).toContain(`${((order.total_cents - 100) / 100).toFixed(2)}`);
926
927 // A number nobody has is not a way to read the whole trail.
928 expect(await admin.page("/admin/audit?q=BZ-NOSUCHROW")).toContain("No order called BZ-NOSUCHROW");
929 });
930
931 test("the audit trail is an admin's, and it pages from a cursor", async () => {
932 limit.forget();
933 const admin = asAdmin();
934 expect((await new Shopper().get("/admin/audit")).status).toBe(403);
935 expect((await asCustomer().get("/admin/audit")).status).toBe(403);
936
937 const page = await admin.page("/admin/audit");
938 expect(page).toContain("Audit");
939 const older = /href="(\/admin\/audit\?q=&amp;before=[^"]+)"/.exec(page)?.[1]?.replace(/&amp;/g, "&");
940 expect(older).toBeDefined();
941 // The link leads to a page of the same trail; src/units.test.ts is where
942 // the cursor is held to not repeating or skipping a row.
943 expect(await admin.page(older!)).toContain("Audit");
944
945 // A cursor that is not one is not a place: the newest page comes back.
946 expect(await admin.page("/admin/audit?before=' OR 1=1 --")).toContain("Audit");
947 });
948
879949 test("/admin opens for an admin the forge signed, and refunds and restocks", async () => {
880950 limit.forget();
881951 const shopper = asCustomer();
@@ -1161,8 +1231,8 @@ test("an admin adds a product and a variant, and the shop sells it", async () =>
11611231
11621232 test("whoever holds a gift card's code can see what is left on it, and nobody else learns anything", async () => {
11631233 limit.forget();
1164 const { card, code } = giftcards.issue({ cents: 2500, note: "a present" });
1165 expect(giftcards.spend(card.id, 1000, "spent on something else")).toBe(true);
1234 const { card, code } = giftcards.issue({ cents: 2500, note: "a present", by: ADA });
1235 expect(giftcards.spend(card.id, 1000, "spent on something else", ADA)).toBe(true);
11661236
11671237 const holder = new Shopper();
11681238 const form = await holder.page("/gift-cards");
@@ -1243,8 +1313,8 @@ test("reading a balance does not use up the tokens for spending a code", async (
12431313
12441314 test("a long ledger says it was cut off, and a void card offers nothing to spend", async () => {
12451315 limit.forget();
1246 const { card, code } = giftcards.issue({ cents: 100, note: "much used" });
1247 for (let n = 0; n < 22; n++) expect(giftcards.credit(card.id, 100, `top-up ${n}`)).toBe(true);
1316 const { card, code } = giftcards.issue({ cents: 100, note: "much used", by: ADA });
1317 for (let n = 0; n < 22; n++) expect(giftcards.credit(card.id, 100, `top-up ${n}`, ADA)).toBe(true);
12481318
12491319 const holder = new Shopper();
12501320 const form = await holder.page("/gift-cards");
@@ -1254,22 +1324,22 @@ test("a long ledger says it was cut off, and a void card offers nothing to spend
12541324 expect(page).toContain("Spend it in your cart"); // live card, money on it
12551325
12561326 // Voided: the balance is written away, so there is nothing to spend.
1257 giftcards.setActive(card.id, false, "lost in the post");
1327 giftcards.setActive(card.id, false, "lost in the post", ADA);
12581328 const voided = await holder.post("/gift-cards", { code }, Shopper.tokenIn(form)).then((r) => r.text());
12591329 expect(voided).toContain("Void — nothing can be spent");
12601330 expect(voided).not.toContain("Spend it in your cart");
12611331
12621332 // A live card with nothing left is the same: no link, and no cut-off note.
1263 const empty = giftcards.issue({ cents: 500, note: "spent out" });
1264 expect(giftcards.spend(empty.card.id, 500, "spent at another till")).toBe(true);
1333 const empty = giftcards.issue({ cents: 500, note: "spent out", by: ADA });
1334 expect(giftcards.spend(empty.card.id, 500, "spent at another till", ADA)).toBe(true);
12651335 const flat = await holder.post("/gift-cards", { code: empty.code }, Shopper.tokenIn(form)).then((r) => r.text());
12661336 expect(flat).not.toContain("Spend it in your cart");
12671337 expect(flat).not.toContain("The last 20 movements");
12681338
12691339 // Exactly as many movements as the window holds is not a cut-off: issuing
12701340 // wrote the first row, so nineteen more make twenty and nothing is hidden.
1271 const exact = giftcards.issue({ cents: 100, note: "twenty on the nose" });
1272 for (let n = 0; n < 19; n++) expect(giftcards.credit(exact.card.id, 100, `top-up ${n}`)).toBe(true);
1341 const exact = giftcards.issue({ cents: 100, note: "twenty on the nose", by: ADA });
1342 for (let n = 0; n < 19; n++) expect(giftcards.credit(exact.card.id, 100, `top-up ${n}`, ADA)).toBe(true);
12731343 const full = await holder.post("/gift-cards", { code: exact.code }, Shopper.tokenIn(form)).then((r) => r.text());
12741344 expect(full).toContain("$20.00"); // twenty rows of a dollar, all of them shown
12751345 expect(full).not.toContain("The last 20 movements");
src/shop.ts +37 −21
@@ -9,6 +9,7 @@
99 * and payment are rate limited. Nothing that takes money happens on a GET.
1010 */
1111
12 import * as actors from "./actor.ts";
1213 import * as cart from "./cart.ts";
1314 import * as codes from "./codes.ts";
1415 import * as discounts from "./discounts.ts";
@@ -283,7 +284,9 @@ export function placeOrder(ctx: Ctx): Response {
283284 // An order belongs to an account, so there is nothing to place without one:
284285 // no row, no stock taken, no code redeemed. Back to the page that says so.
285286 if (ctx.who.role === "anon") return redirect("/checkout");
286 const buyer = { id: ctx.who.id, name: ctx.who.name };
287 // The buyer is the order's account and the actor of every row placing it
288 // writes: the stock it takes, the code it redeems, the balance it spends.
289 const buyer = actors.of(ctx.who);
287290 const values = {
288291 email: field(ctx.form, "email", 200),
289292 name: field(ctx.form, "name", 120),
@@ -380,15 +383,19 @@ export function payMock(ctx: Ctx): Response {
380383 if (chosen().kind !== "mock") return redirect(orderLink(ctx, order, "pay"));
381384 const decision = field(ctx.form, "decision", 10);
382385 if (decision !== "approve") {
383 orders.note(order.id, "declined", "the mock gateway was asked to decline");
386 orders.note(order.id, "declined", actors.paying(ctx.who, order), "the mock gateway was asked to decline");
384387 ctx.params.number = order.number;
385388 return payPageFor(ctx, order, "The mock gateway declined this payment. Nothing was charged; try again.");
386389 }
387 const paid = orders.settleFromGateway(order, {
388 gateway: "mock",
389 ref: `mock_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`,
390 amount_cents: dueCents(order),
391 });
390 const paid = orders.settleFromGateway(
391 order,
392 {
393 gateway: "mock",
394 ref: `mock_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`,
395 amount_cents: dueCents(order),
396 },
397 actors.paying(ctx.who, order),
398 );
392399 return redirect(orderLink(ctx, paid));
393400 }
394401
@@ -415,7 +422,7 @@ export async function payStripe(ctx: Ctx): Promise<Response> {
415422 cancel: `${publicOrigin()}/pay/${order.number}?token=${encodeURIComponent(order.access_token)}`,
416423 });
417424 if (!session.ok) return payPageFor(ctx, order, session.error);
418 orders.note(order.id, "checkout-started", session.value.id);
425 orders.note(order.id, "checkout-started", actors.paying(ctx.who, order), session.value.id);
419426 orders.rememberGatewayRef(order, session.value.id);
420427 // A form POST answered with a redirect off-site: the response says where it
421428 // goes, so the policy on it allows that one hop and no other.
@@ -427,7 +434,7 @@ export async function payStripe(ctx: Ctx): Promise<Response> {
427434 export async function orderPage(ctx: Ctx): Promise<Response> {
428435 let order = openOrder(ctx, "query");
429436 if (!order) return notFound(ctx);
430 order = await settleStripe(order);
437 order = await settleStripe(order, actors.paying(ctx.who, order));
431438 const refunded = orders.refundedCents(order.id);
432439 return render(
433440 ctx,
@@ -510,7 +517,7 @@ export function findOrder(ctx: Ctx): Response {
510517 if (!order || !matched) return ordersPage(ctx, NOT_FOUND, values);
511518
512519 // The address, not the header: what goes in the trail has to be a fact.
513 orders.note(order.id, "looked-up", `found from ${ctx.peer.address}`);
520 orders.note(order.id, "looked-up", actors.of(ctx.who), `found from ${ctx.peer.address}`);
514521 rememberOrder(ctx, order);
515522 return redirect(orderLink(ctx, order));
516523 }
@@ -530,18 +537,22 @@ export async function receiptPage(ctx: Ctx): Promise<Response> {
530537 * wins because the charge has an idempotency key. Stripe's amount is recorded,
531538 * not trusted: what it means for the order is decided there, against the total.
532539 */
533 async function settleStripe(order: Order): Promise<Order> {
540 async function settleStripe(order: Order, by: actors.Actor): Promise<Order> {
534541 if (order.status !== "pending_payment") return order;
535542 if (order.gateway !== "stripe" || !order.payment_ref?.startsWith("cs_")) return order;
536543 const session = await gateway.readCheckout(order.payment_ref);
537544 if (!session.ok || session.value.payment_status !== "paid") return order;
538545 // Stripe's own number for what it took, believed as a number and no further:
539546 // settleFromGateway compares it to what the order owes.
540 return orders.settleFromGateway(order, {
541 gateway: "stripe",
542 ref: session.value.payment_intent ?? order.payment_ref,
543 amount_cents: session.value.amount_total ?? 0,
544 });
547 return orders.settleFromGateway(
548 order,
549 {
550 gateway: "stripe",
551 ref: session.value.payment_intent ?? order.payment_ref,
552 amount_cents: session.value.amount_total ?? 0,
553 },
554 by,
555 );
545556 }
546557
547558 /**
@@ -565,11 +576,16 @@ export function stripeWebhook(ctx: Ctx): Response {
565576 const number = String(session.client_reference_id ?? session.metadata?.order_number ?? "").slice(0, 20);
566577 const order = number ? orders.byNumber(number) : null;
567578 if (order && session.payment_status === "paid")
568 orders.settleFromGateway(order, {
569 gateway: "stripe",
570 ref: typeof session.payment_intent === "string" ? session.payment_intent : String(session.id ?? ""),
571 amount_cents: typeof session.amount_total === "number" ? session.amount_total : 0,
572 });
579 // Nobody asked for this row: Stripe told us, so Stripe is what it names.
580 orders.settleFromGateway(
581 order,
582 {
583 gateway: "stripe",
584 ref: typeof session.payment_intent === "string" ? session.payment_intent : String(session.id ?? ""),
585 amount_cents: typeof session.amount_total === "number" ? session.amount_total : 0,
586 },
587 actors.STRIPE,
588 );
573589 }
574590 return text("ok");
575591 }
src/stripe.test.ts +2
@@ -568,6 +568,8 @@ test("a signed webhook pays an order that never came back", async () => {
568568 expect(charges.length).toBe(1);
569569 expect(charges[0].amount_cents).toBe(dueOf(placed.number));
570570 expect(charges[0].gateway_ref).toBe(sessions.get(sessionId)!.payment_intent);
571 // Nobody asked for this row: Stripe told us, so Stripe is what it names.
572 expect([charges[0].actor_id, charges[0].actor_name, charges[0].actor_role]).toEqual(["", "stripe", "stripe"]);
571573
572574 // And the shopper who finally comes back is shown the order, not a charge.
573575 expect((await comeBack(shopper, placed)).status).toBe(200);
src/tty.ts +76 −11
@@ -18,15 +18,17 @@
1818 * `src/desk.ts` holds the one table all of these gates read, and `ordersFor`
1919 * is the one place the rows are chosen.
2020 *
21 * 1..7 switch between stock, orders, the outbox, discount codes, gift cards,
22 * the tax table and the changes an admin made to any of it — everything
23 * /admin can see. j/k move, and each tab says at its foot what it can do: s
24 * moves stock with a reason, / narrows the orders in front of you by number,
25 * name or email, r refunds what is left of an order and puts it back on the
26 * shelf, n writes a code, i issues a card, e sets a tax rate, c looks a code
27 * up. q leaves. Plain ANSI, no dependencies.
21 * 1..8 switch between stock, orders, the outbox, discount codes, gift cards,
22 * the tax table, the changes an admin made to any of it and the audit trail —
23 * everything /admin can see. j/k move, and each tab says at its foot what it
24 * can do: s moves stock with a reason, / narrows the orders in front of you by
25 * number, name or email — or, on the audit tab, the trail to one order — r
26 * refunds what is left of an order and puts it back on the shelf, n writes a
27 * code, i issues a card, e sets a tax rate, c looks a code up. q leaves.
28 * Plain ANSI, no dependencies.
2829 */
2930
31 import * as audit from "./audit.ts";
3032 import * as desk from "./desk.ts";
3133 import * as discounts from "./discounts.ts";
3234 import * as giftcards from "./giftcards.ts";
@@ -37,8 +39,10 @@ import { formatCents } from "./money.ts";
3739 import { history, addStock, setStock } from "./inventory.ts";
3840 import { itemsOf, paymentsOf, refund, refundableCents, refundedCents, shortfall } from "./orders.ts";
3941 import { outbox } from "./mail.ts";
42 import { atDesk } from "./actor.ts";
4043 import { atKeyboard } from "./who.ts";
4144 import { nameOf, recent as recentChanges, type Reading } from "./changes.ts";
45 import { byNumber } from "./orders.ts";
4246 import type { GiftCard, Order, TaxRate } from "./db.ts";
4347
4448 const person = atKeyboard();
@@ -91,6 +95,14 @@ let filter = "";
9195 // is the shop's, anybody else's is their own account's — narrowed by `/`.
9296 const orderRows = (): Order[] => desk.ordersFor(person, filter);
9397
98 // What the audit tab is narrowed to: one order, by its number, or the shop.
99 let trailFilter = "";
100
101 const auditRows = (): audit.Entry[] => {
102 const order = trailFilter === "" ? null : byNumber(trailFilter);
103 return trailFilter !== "" && !order ? [] : audit.timeline({ orderId: order?.id, limit: 60 });
104 };
105
94106 const rowsFor = (): unknown[] =>
95107 tab === "stock"
96108 ? stockRows()
@@ -104,7 +116,9 @@ const rowsFor = (): unknown[] =>
104116 ? giftcards.all()
105117 : tab === "tax"
106118 ? tax.rates()
107 : recentChanges(100);
119 : tab === "changes"
120 ? recentChanges(100)
121 : auditRows();
108122
109123 /** A message from the desk, with a refusal marked so it cannot be missed. */
110124 const say = (done: desk.Done): string => (done.ok ? done.message : `${esc}33m${done.message}${esc}0m`);
@@ -130,10 +144,11 @@ const may = (key: string): boolean => desk.mayPress(tab, key, person);
130144 */
131145 const hints = (): string =>
132146 [
133 "1-7 tabs",
147 `1-${TABS.length} tabs`,
134148 "j/k move",
135149 ...desk.offered(tab, person),
136150 ...(tab === "orders" && filter !== "" ? [`showing “${filter}”`] : []),
151 ...(tab === "audit" && trailFilter !== "" ? [`showing “${trailFilter}”`] : []),
137152 "q quit",
138153 ].join(" · ");
139154
@@ -251,7 +266,46 @@ function draw(): void {
251266 ).slice(0, 20)}`;
252267 lines.push(`${mark} ${i === cursor ? bold(text) : text}`);
253268 });
269 if (tab === "audit")
270 (rows as audit.Entry[]).forEach((row, i) => {
271 const mark = i === cursor ? "▸" : " ";
272 const text = `${row.at.padEnd(20)} ${row.actor_name.padEnd(12).slice(0, 12)} ${row.what
273 .padEnd(14)
274 .slice(0, 14)} ${(row.order_number ?? "—").padEnd(12)} ${(row.cents === null
275 ? "—"
276 : formatCents(row.cents)
277 ).padStart(10)} ${row.detail.slice(0, 34)}`;
278 lines.push(`${mark} ${i === cursor ? bold(text) : text}`);
279 });
254280 lines.push("");
281 if (tab === "audit") {
282 // The same figure the page reconciles: charges less refunds, against what
283 // the order says was paid.
284 const order = trailFilter === "" ? null : byNumber(trailFilter);
285 if (order) {
286 const balance = audit.balanceOf(order);
287 lines.push(
288 dim(
289 ` ${order.number}: ${formatCents(balance.charged_cents)} charged + ${formatCents(
290 balance.gift_card_cents,
291 )} card − ${formatCents(balance.refunded_cents + balance.gift_card_back_cents)} back = ${formatCents(
292 balance.held_cents,
293 )}; the order says ${formatCents(balance.says_cents)}${balance.agrees ? "" : " — these do not agree"}`,
294 ),
295 "",
296 );
297 } else if (trailFilter === "") {
298 const shop = audit.shopBalance();
299 lines.push(
300 dim(
301 ` ${formatCents(shop.charged_cents)} charged − ${formatCents(shop.refunded_cents)} refunded = ${formatCents(
302 shop.held_cents,
303 )} held`,
304 ),
305 "",
306 );
307 }
308 }
255309 if (message) lines.push(message, "");
256310 lines.push(prompt === undefined ? dim(hints()) : `${prompt.label} ${prompt.text}${esc}7m ${esc}0m`);
257311 out(`${esc}2J${esc}H` + lines.join("\r\n"));
@@ -267,8 +321,8 @@ function moveStock(value: string): string {
267321 const reason = rest.join(" ").trim();
268322 if (delta === undefined || delta === 0) return "A move is a whole number, like 12 or -3.";
269323 if (reason === "") return "A move needs a reason.";
270 if (delta > 0) addStock(row.id, delta, reason);
271 else setStock(row.id, Math.max(0, row.stock + delta), reason);
324 if (delta > 0) addStock(row.id, delta, reason, atDesk());
325 else setStock(row.id, Math.max(0, row.stock + delta), reason, atDesk());
272326 return `${row.sku}: ${delta > 0 ? "+" : ""}${delta} — ${reason}`;
273327 }
274328
@@ -281,6 +335,14 @@ function findOrders(value: string): string {
281335 return found === 0 ? `Nothing matches “${filter}”.` : `${found} order${found === 1 ? "" : "s"} match “${filter}”.`;
282336 }
283337
338 /** `/` on the audit tab: one order's trail, or the shop's when it is cleared. */
339 function findTrail(value: string): string {
340 trailFilter = value.trim().slice(0, 20).toUpperCase();
341 cursor = 0;
342 if (trailFilter === "") return "Showing the whole trail.";
343 return byNumber(trailFilter) ? `The trail of ${trailFilter}.` : `No order called ${trailFilter}.`;
344 }
345
284346 function refundRest(reason: string): string {
285347 const order = orderRows()[cursor];
286348 if (!order) return "No order selected.";
@@ -293,6 +355,7 @@ function refundRest(reason: string): string {
293355 restock: true,
294356 gateway_ref: `tty_refund_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`,
295357 idempotencyKey: `refund:${order.number}:${refundedCents(order.id)}:${left}`,
358 by: atDesk(),
296359 });
297360 return done.ok ? `${order.number}: refunded ${formatCents(left)} and restocked.` : done.error;
298361 }
@@ -335,6 +398,8 @@ process.stdin.on("data", (key: string) => {
335398 prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock };
336399 if (key === "/" && tab === "orders" && may("/"))
337400 prompt = { label: "Find an order (number, name or email):", text: filter, run: findOrders };
401 if (key === "/" && tab === "audit" && may("/"))
402 prompt = { label: "The trail of one order (its number, or nothing for all):", text: trailFilter, run: findTrail };
338403 if (key === "r" && tab === "orders" && may("r")) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest };
339404 if (key === "c" && (tab === "codes" || tab === "cards") && may("c"))
340405 prompt = { label: "Check a code:", text: "", run: (value) => say(desk.look(value)) };
src/units.test.ts +74 −26
@@ -17,6 +17,9 @@ const mine = await import("./mine.ts");
1717 const peer = await import("./peer.ts");
1818 const { escape } = await import("./html.ts");
1919 const catalog = await import("./catalog.ts");
20 const actors = await import("./actor.ts");
21 const audit = await import("./audit.ts");
22 const inventory = await import("./inventory.ts");
2023 const changes = await import("./changes.ts");
2124 const desk = await import("./desk.ts");
2225 const discounts = await import("./discounts.ts");
@@ -35,6 +38,9 @@ tax.seed();
3538 const variantId = (sku: string): number =>
3639 db.query<{ id: number }, [string]>("SELECT id FROM variant WHERE sku = ?1").get(sku)!.id;
3740
41 /** Somebody, for the rows these tests write by hand: every ledger names one. */
42 const ADA = { id: "u_9", name: "ada", role: "admin" } as const;
43
3844 test("money is integer cents in and out", () => {
3945 expect(formatCents(129900)).toBe("$1,299.00");
4046 expect(formatCents(0)).toBe("$0.00");
@@ -106,8 +112,8 @@ test("a percent discount, a part-spent gift card and a regional rate reconcile i
106112 const money = variantId("GFT-CARD-25"); // $25.00 of money, which is not a sale
107113
108114 // A card someone has already spent $15 of: what is left is what it can pay.
109 const { card, code } = giftcards.issue({ cents: 5000, note: "used once already" });
110 expect(giftcards.spend(card.id, 1500, "spent at another till")).toBe(true);
115 const { card, code } = giftcards.issue({ cents: 5000, note: "used once already", by: ADA });
116 expect(giftcards.spend(card.id, 1500, "spent at another till", ADA)).toBe(true);
111117 expect(giftcards.byId(card.id)!.balance_cents).toBe(3500);
112118
113119 const priced = quote(
@@ -175,17 +181,17 @@ test("the rate follows the address, and tax is taken after the discount", () =>
175181 });
176182
177183 test("two redemptions of one gift card cannot drive its balance negative", () => {
178 const { card } = giftcards.issue({ cents: 2500, note: "one card, two tills" });
184 const { card } = giftcards.issue({ cents: 2500, note: "one card, two tills", by: ADA });
179185
180186 // Two tills reach for the same $25 with $20 each: only the first is paid.
181 expect(giftcards.spend(card.id, 2000, "till one")).toBe(true);
182 expect(giftcards.spend(card.id, 2000, "till two")).toBe(false);
187 expect(giftcards.spend(card.id, 2000, "till one", ADA)).toBe(true);
188 expect(giftcards.spend(card.id, 2000, "till two", ADA)).toBe(false);
183189 expect(giftcards.byId(card.id)!.balance_cents).toBe(500);
184190
185191 // The second till may still have what is actually there, and no more.
186 expect(giftcards.spend(card.id, 500, "till two, for what is left")).toBe(true);
192 expect(giftcards.spend(card.id, 500, "till two, for what is left", ADA)).toBe(true);
187193 expect(giftcards.byId(card.id)!.balance_cents).toBe(0);
188 expect(giftcards.spend(card.id, 1, "till three")).toBe(false);
194 expect(giftcards.spend(card.id, 1, "till three", ADA)).toBe(false);
189195
190196 // A refusal writes nothing, and the balance is the sum of what was written.
191197 const entries = giftcards.entriesOf(card.id).reverse();
@@ -319,7 +325,7 @@ test("an audit row is one field that moved, capped, and a no-op is no row at all
319325 expect(row.actor_name).toBe("ada");
320326
321327 // Whoever the forge did not name is nobody, and a boolean reads as a word.
322 expect(changes.actorOf({ role: "anon" })).toEqual({ id: "", name: "unknown" });
328 expect(actors.of({ role: "anon" })).toEqual({ id: "", name: "unknown", role: "unknown" });
323329 expect(changes.say(true)).toBe("yes");
324330 expect(changes.say(undefined)).toBe("");
325331 });
@@ -400,7 +406,7 @@ test("a code's limits and expiry are enforced where the use is taken, not only w
400406 starts_at: null,
401407 expires_at: "2020-01-01 23:59:59",
402408 });
403 expect(discounts.redeem(expired, orderId, 500)).toBe(false);
409 expect(discounts.redeem(expired, orderId, 500, ADA)).toBe(false);
404410 expect(used(expired)).toBe(0);
405411 expect(rows(expired)).toBe(0);
406412
@@ -414,7 +420,7 @@ test("a code's limits and expiry are enforced where the use is taken, not only w
414420 starts_at: "2099-01-01 00:00:00",
415421 expires_at: null,
416422 });
417 expect(discounts.redeem(early, orderId, 500)).toBe(false);
423 expect(discounts.redeem(early, orderId, 500, ADA)).toBe(false);
418424 expect(used(early)).toBe(0);
419425
420426 // One that is live, with one use: the first takes it and the second cannot.
@@ -427,8 +433,8 @@ test("a code's limits and expiry are enforced where the use is taken, not only w
427433 starts_at: null,
428434 expires_at: "2099-12-31 23:59:59",
429435 });
430 expect(discounts.redeem(once, orderId, 500)).toBe(true);
431 expect(discounts.redeem(once, orderId, 500)).toBe(false);
436 expect(discounts.redeem(once, orderId, 500, ADA)).toBe(true);
437 expect(discounts.redeem(once, orderId, 500, ADA)).toBe(false);
432438 expect(used(once)).toBe(1);
433439 expect(rows(once)).toBe(1);
434440
@@ -443,11 +449,11 @@ test("a code's limits and expiry are enforced where the use is taken, not only w
443449 starts_at: null,
444450 expires_at: "2099-12-31 23:59:59",
445451 });
446 expect(discounts.redeem(roomy, orderId, 500, later)).toBe(false);
452 expect(discounts.redeem(roomy, orderId, 500, ADA, later)).toBe(false);
447453 expect(used(roomy)).toBe(0);
448454
449455 // A use given back is a row of its own, and the counter follows it.
450 discounts.release(once, orderId, 500);
456 discounts.release(once, orderId, 500, ADA);
451457 expect(used(once)).toBe(0);
452458 expect(rows(once)).toBe(2);
453459 expect(discounts.withUse().find((row) => row.code === "ONE-USE-ONLY")!.given_cents).toBe(0);
@@ -697,10 +703,10 @@ test("a deployment that knows how many proxies are in front says so, and is beli
697703 });
698704
699705 test("a card's holder is told what happened, not what the shop wrote to itself", () => {
700 const { card } = giftcards.issue({ cents: 4000, note: "a present" });
706 const { card } = giftcards.issue({ cents: 4000, note: "a present", by: ADA });
701707 const orderId = bareOrder("BZ-CARD-WORDS");
702 expect(giftcards.spend(card.id, 1000, "paid on BZ-CARD-WORDS", orderId)).toBe(true);
703 giftcards.setActive(card.id, false, "chargeback from the buyer, do not honour");
708 expect(giftcards.spend(card.id, 1000, "paid on BZ-CARD-WORDS", ADA, orderId)).toBe(true);
709 giftcards.setActive(card.id, false, "chargeback from the buyer, do not honour", ADA);
704710
705711 const shown = giftcards.publicEntriesOf(card.id);
706712 expect(shown.map((entry) => entry.what)).toEqual(["Taken off the card", "Spent on an order", "Issued"]);
@@ -727,7 +733,7 @@ const shortEvents = (orderId: number) => orders.eventsOf(orderId).filter((event)
727733 test("a gateway's amount is compared to what the order owes before anything is called paid", () => {
728734 // Short: the money that moved is written down, and the order is not paid.
729735 const short = pendingOrder("BZ-SHORT-1", 5000);
730 const after = orders.settleFromGateway(short, { gateway: "mock", ref: "ref_short", amount_cents: 4500 });
736 const after = orders.settleFromGateway(short, { gateway: "mock", ref: "ref_short", amount_cents: 4500 }, ADA);
731737 expect(after.status).toBe("pending_payment");
732738 expect(after.paid_at).toBeNull();
733739 expect(orders.chargedCents(short.id)).toBe(4500);
@@ -740,25 +746,25 @@ test("a gateway's amount is compared to what the order owes before anything is c
740746
741747 // Exactly what is owed pays it, and so does more than that.
742748 const exact = pendingOrder("BZ-EXACT-1", 5000);
743 expect(orders.settleFromGateway(exact, { gateway: "mock", ref: "ref_exact", amount_cents: 5000 }).status).toBe("paid");
749 expect(orders.settleFromGateway(exact, { gateway: "mock", ref: "ref_exact", amount_cents: 5000 }, ADA).status).toBe("paid");
744750 expect(orders.shortfall(orders.byNumber("BZ-EXACT-1")!)).toBeNull();
745751 const over = pendingOrder("BZ-OVER-1", 5000);
746 expect(orders.settleFromGateway(over, { gateway: "mock", ref: "ref_over", amount_cents: 5100 }).status).toBe("paid");
752 expect(orders.settleFromGateway(over, { gateway: "mock", ref: "ref_over", amount_cents: 5100 }, ADA).status).toBe("paid");
747753 expect(orders.chargedCents(over.id)).toBe(5100);
748754
749755 // A gateway that names no amount moved no money we can write a row for, and
750756 // it says so once however many times the same claim is delivered.
751757 const nothing = pendingOrder("BZ-NOTHING-1", 5000);
752758 for (let i = 0; i < 3; i++)
753 orders.settleFromGateway(nothing, { gateway: "stripe", ref: "pi_nothing", amount_cents: 0 });
759 orders.settleFromGateway(nothing, { gateway: "stripe", ref: "pi_nothing", amount_cents: 0 }, ADA);
754760 expect(orders.paymentsOf(nothing.id).length).toBe(0);
755761 expect(shortEvents(nothing.id).length).toBe(1);
756762 expect(orders.byNumber("BZ-NOTHING-1")!.status).toBe("pending_payment");
757763
758764 // One key per order: a second arrival is the same charge, not another one.
759765 const twice = pendingOrder("BZ-TWICE-1", 5000);
760 orders.settleFromGateway(twice, { gateway: "mock", ref: "ref_one", amount_cents: 5000 });
761 orders.settleFromGateway(twice, { gateway: "mock", ref: "ref_two", amount_cents: 5000 });
766 orders.settleFromGateway(twice, { gateway: "mock", ref: "ref_one", amount_cents: 5000 }, ADA);
767 orders.settleFromGateway(twice, { gateway: "mock", ref: "ref_two", amount_cents: 5000 }, ADA);
762768 expect(orders.paymentsOf(twice.id).length).toBe(1);
763769 expect(orders.eventsOf(twice.id).filter((event) => event.kind === "paid").length).toBe(1);
764770 });
@@ -771,16 +777,16 @@ test("the admin's screen is told about a payment only when the order becomes pai
771777 // Short. The ledger says awaiting payment, so a live page that said paid
772778 // would be the admin's screen lying about the money.
773779 const short = pendingOrder("BZ-SHORT-LIVE", 5000);
774 orders.settleFromGateway(short, { gateway: "mock", ref: "ref_short_live", amount_cents: 4500 });
780 orders.settleFromGateway(short, { gateway: "mock", ref: "ref_short_live", amount_cents: 4500 }, ADA);
775781 expect(paidEvents()).toEqual([]);
776782
777783 // Covered: one event, from the branch that moved the row to paid.
778784 const full = pendingOrder("BZ-FULL-LIVE", 5000);
779 orders.settleFromGateway(full, { gateway: "mock", ref: "ref_full_live", amount_cents: 5000 });
785 orders.settleFromGateway(full, { gateway: "mock", ref: "ref_full_live", amount_cents: 5000 }, ADA);
780786 expect(paidEvents()).toEqual([{ type: "order.paid", data: { id: full.id, number: "BZ-FULL-LIVE", cents: 5000 } }]);
781787
782788 // The webhook arriving behind the browser is the same charge, not another.
783 orders.settleFromGateway(full, { gateway: "mock", ref: "ref_full_again", amount_cents: 5000 });
789 orders.settleFromGateway(full, { gateway: "mock", ref: "ref_full_again", amount_cents: 5000 }, ADA);
784790 expect(paidEvents().length).toBe(1);
785791 } finally {
786792 unsubscribe();
@@ -829,3 +835,45 @@ test("the route table knows which paths a GET answers", () => {
829835 expect(getAnswers("/checkout")).toBe(true);
830836 expect(getAnswers("/gift-cards")).toBe(true);
831837 });
838
839 test("a stock move typed at the terminal names the account the forge put there", () => {
840 const before = { ...process.env };
841 process.env.FORGE_APP_USER = "bo";
842 process.env.FORGE_APP_USER_ID = "u_7";
843 process.env.FORGE_APP_USER_ROLE = "admin";
844 try {
845 const id = variantId("COF-GUJI-250");
846 inventory.addStock(id, 3, "a delivery arrived", actors.atDesk());
847 const row = inventory.historyOf(id, 1)[0]!;
848 expect([row.actor_id, row.actor_name, row.actor_role]).toEqual(["u_7", "bo", "admin"]);
849 } finally {
850 if (before.FORGE_APP_USER === undefined) delete process.env.FORGE_APP_USER;
851 if (before.FORGE_APP_USER_ID === undefined) delete process.env.FORGE_APP_USER_ID;
852 if (before.FORGE_APP_USER_ROLE === undefined) delete process.env.FORGE_APP_USER_ROLE;
853 }
854 });
855
856 test("the audit timeline is one order of rows, whether it is read whole or paged", () => {
857 const name = (entry: { source: string; id: number }): string => `${entry.source}:${entry.id}`;
858 const whole = audit.timeline({ limit: 12 }).slice(0, 12).map(name);
859 expect(whole.length).toBe(12);
860
861 // The same rows, three at a time, following the cursor each page says.
862 const paged: string[] = [];
863 let before: ReturnType<typeof audit.readCursor>;
864 while (paged.length < 12) {
865 const rows = audit.timeline({ limit: 3, before }).slice(0, 3);
866 if (rows.length === 0) break;
867 for (const row of rows) paged.push(name(row));
868 // The cursor goes out through a URL and comes back as text, as it does
869 // on the page: what a browser hands back is what is trusted here.
870 before = audit.readCursor(audit.cursorOf(rows[rows.length - 1]!));
871 expect(before).toBeDefined();
872 }
873 expect(paged).toEqual(whole);
874 expect(new Set(paged).size).toBe(paged.length);
875
876 // A cursor nobody wrote is not a place in the timeline.
877 expect(audit.readCursor("' OR 1=1 --")).toBeUndefined();
878 expect(audit.readCursor("2026-01-01 00:00:00|9|1")).toBeUndefined();
879 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs