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
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/12-every-ledger-row-says-who-did-it-and-adm → main 1 commit fork
19 files
changed +992
−193
README.md +15 −3
| @@ -40,6 +40,17 @@ admin's three live pages load one small file — see | ||
| 40 | 40 | changes to what the shop sells and what it charges are a ledger too: a |
| 41 | 41 | price, a slug, a discount code or a tax rate that moves writes a row at |
| 42 | 42 | `/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. | |
| 43 | 54 | - **Simulated unless you configure it**: the payment. With no Stripe keys the |
| 44 | 55 | store uses a **mock gateway** — a page labelled as a mock, with Approve and |
| 45 | 56 | 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 | ||
| 487 | 498 | stock move or a refund made here reaches an admin's open `/admin` over the |
| 488 | 499 | live stream within about a second, not on that page's next load. `1`–`7` |
| 489 | 500 | 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 | |
| 492 | 503 | 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 | |
| 494 | 505 | they are on: |
| 495 | 506 | |
| 496 | 507 | | Tab | Keys | |
| @@ -500,6 +511,7 @@ they are on: | ||
| 500 | 511 | | 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 | |
| 501 | 512 | | 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 | |
| 502 | 513 | | 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 | | |
| 503 | 515 | | 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 | |
| 504 | 516 | |
| 505 | 517 | `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 @@ | ||
| 7 | 7 | * saying who asked and why. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import * as actors from "./actor.ts"; | |
| 11 | import * as audit from "./audit.ts"; | |
| 10 | 12 | import * as catalog from "./catalog.ts"; |
| 11 | 13 | import * as changes from "./changes.ts"; |
| 12 | 14 | import * as discounts from "./discounts.ts"; |
| @@ -67,7 +69,7 @@ const guard = (ctx: Ctx): Response | null => | ||
| 67 | 69 | * src/who.ts, and it is the only source there is: a form field or a header |
| 68 | 70 | * that named someone else would be the caller signing their own trail. |
| 69 | 71 | */ |
| 70 | const actor = (ctx: Ctx): changes.Actor => changes.actorOf(ctx.who); | |
| 72 | const actor = (ctx: Ctx): actors.Actor => actors.of(ctx.who); | |
| 71 | 73 | |
| 72 | 74 | /** A rate as the tax table says it. */ |
| 73 | 75 | const percent = (bp: number): string => `${(bp / 100).toFixed(2)}%`; |
| @@ -78,6 +80,42 @@ export function changeList(ctx: Ctx): Response { | ||
| 78 | 80 | return render(ctx, { title: "Changes", admin: true }, pages.changes(changes.recent(100))); |
| 79 | 81 | } |
| 80 | 82 | |
| 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 | ||
| 81 | 119 | // --- the door --------------------------------------------------------------- |
| 82 | 120 | |
| 83 | 121 | export function dashboard(ctx: Ctx): Response { |
| @@ -245,7 +283,7 @@ export function variantCreate(ctx: Ctx): Response { | ||
| 245 | 283 | // Opening stock is not a field of the trail: it moves through the stock |
| 246 | 284 | // ledger, which already says who and why. |
| 247 | 285 | 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) }); | |
| 249 | 287 | changes.changed(actor(ctx), "variant", id, {}, { sku, name, price: formatCents(price) }); |
| 250 | 288 | })(); |
| 251 | 289 | return redirect(`/admin/products/${found.id}?m=variant-added`); |
| @@ -306,8 +344,8 @@ export function inventoryMove(ctx: Ctx): Response { | ||
| 306 | 344 | const reason = field(ctx.form, "reason", 120); |
| 307 | 345 | const variant = variantId === undefined ? null : catalog.variantById(variantId); |
| 308 | 346 | 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)); | |
| 311 | 349 | return redirect("/admin/inventory?m=stocked"); |
| 312 | 350 | } |
| 313 | 351 | |
| @@ -365,14 +403,22 @@ export async function orderRefund(ctx: Ctx): Promise<Response> { | ||
| 365 | 403 | if (order.gateway === "stripe" && order.payment_ref?.startsWith("pi_")) { |
| 366 | 404 | const result = await gateway.refundCharge(order.payment_ref, amount, key); |
| 367 | 405 | if (!result.ok) { |
| 368 | orders.note(order.id, "refund-failed", result.error); | |
| 406 | orders.note(order.id, "refund-failed", actor(ctx), result.error); | |
| 369 | 407 | return redirect(`/admin/orders/${order.number}?m=refund-failed`); |
| 370 | 408 | } |
| 371 | 409 | gatewayRef = result.value.id; |
| 372 | 410 | } |
| 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 | }); | |
| 374 | 420 | if (!done.ok) { |
| 375 | orders.note(order.id, "refund-refused", done.error); | |
| 421 | orders.note(order.id, "refund-refused", actor(ctx), done.error); | |
| 376 | 422 | return redirect(`/admin/orders/${order.number}?m=bad-amount`); |
| 377 | 423 | } |
| 378 | 424 | return redirect(`/admin/orders/${order.number}?m=refunded`); |
| @@ -383,7 +429,7 @@ export function orderCancel(ctx: Ctx): Response { | ||
| 383 | 429 | if (blocked) return blocked; |
| 384 | 430 | const order = orders.byNumber((ctx.params.number ?? "").slice(0, 20)); |
| 385 | 431 | 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)); | |
| 387 | 433 | return redirect(`/admin/orders/${order.number}?m=cancelled`); |
| 388 | 434 | } |
| 389 | 435 | |
| @@ -501,7 +547,7 @@ export function giftCardIssue(ctx: Ctx): Response { | ||
| 501 | 547 | if (blocked) return blocked; |
| 502 | 548 | const amount = parseCents(field(ctx.form, "amount", 12)); |
| 503 | 549 | 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) }); | |
| 505 | 551 | return giftCardList(ctx, issued.code); |
| 506 | 552 | } |
| 507 | 553 | |
| @@ -514,7 +560,7 @@ export function giftCardAdjust(ctx: Ctx): Response { | ||
| 514 | 560 | const reason = field(ctx.form, "reason", 120) || "adjusted in admin"; |
| 515 | 561 | const asked = field(ctx.form, "void", 1); |
| 516 | 562 | if (asked !== "") { |
| 517 | giftcards.setActive(card.id, asked === "0", reason); | |
| 563 | giftcards.setActive(card.id, asked === "0", reason, actor(ctx)); | |
| 518 | 564 | return redirect("/admin/giftcards?m=saved"); |
| 519 | 565 | } |
| 520 | 566 | const raw = field(ctx.form, "amount", 12).replace(/^\+/, ""); |
| @@ -522,8 +568,8 @@ export function giftCardAdjust(ctx: Ctx): Response { | ||
| 522 | 568 | const cents = parseCents(down ? raw.slice(1) : raw); |
| 523 | 569 | if (cents === undefined || cents === 0) return redirect("/admin/giftcards?m=bad-amount"); |
| 524 | 570 | 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()}`); | |
| 527 | 573 | return redirect(`/admin/giftcards?m=${moved ? "saved" : "bad-amount"}`); |
| 528 | 574 | } |
| 529 | 575 |
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 @@ | ||
| 4 | 4 | * carts, orders or money beyond the integer cents it stores. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { SYSTEM, type Actor } from "./actor.ts"; | |
| 7 | 8 | import { db, type Category, type Product, type Variant } from "./db.ts"; |
| 8 | 9 | import { addStock } from "./inventory.ts"; |
| 9 | 10 | |
| @@ -170,13 +171,18 @@ export function updateProduct( | ||
| 170 | 171 | }); |
| 171 | 172 | } |
| 172 | 173 | |
| 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 | */ | |
| 174 | 178 | export function createVariant(input: { |
| 175 | 179 | product_id: number; |
| 176 | 180 | sku: string; |
| 177 | 181 | name: string; |
| 178 | 182 | price_cents: number; |
| 179 | 183 | stock: number; |
| 184 | /** Who is adding it: an admin, or the seed that fills an empty shop. */ | |
| 185 | by: Actor; | |
| 180 | 186 | /** Non-zero makes this variant a gift card of that face value. */ |
| 181 | 187 | gift_card_cents?: number; |
| 182 | 188 | }): number { |
| @@ -194,7 +200,7 @@ export function createVariant(input: { | ||
| 194 | 200 | $price_cents: input.price_cents, |
| 195 | 201 | $gift_card_cents: input.gift_card_cents ?? 0, |
| 196 | 202 | }); |
| 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); | |
| 198 | 204 | return row!.id; |
| 199 | 205 | } |
| 200 | 206 | |
| @@ -238,7 +244,7 @@ export function seedGiftCards(): void { | ||
| 238 | 244 | ["GFT-CARD-50", "$50", 5000], |
| 239 | 245 | ["GFT-CARD-100", "$100", 10000], |
| 240 | 246 | ] 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 }); | |
| 242 | 248 | })(); |
| 243 | 249 | } |
| 244 | 250 | |
| @@ -343,7 +349,7 @@ export function seed(): void { | ||
| 343 | 349 | active: true, |
| 344 | 350 | }); |
| 345 | 351 | 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 }); | |
| 347 | 353 | } |
| 348 | 354 | } |
| 349 | 355 | } |
src/changes.ts +3 −14
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | * The actor is whoever the forge named: `ctx.who` on the web, the account the |
| 12 | 12 | * forge put in the environment at the terminal. It is never a form field and |
| 13 | 13 | * 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. | |
| 15 | 16 | * |
| 16 | 17 | * Nothing spendable is recorded. A discount code is printed on a poster; a |
| 17 | 18 | * gift card's code is bearer money and its balance has a ledger of its own, |
| @@ -19,10 +20,9 @@ | ||
| 19 | 20 | */ |
| 20 | 21 | |
| 21 | 22 | import { db, type AdminChange } from "./db.ts"; |
| 22 | import { atKeyboard, type Who } from "./who.ts"; | |
| 23 | import type { Actor } from "./actor.ts"; | |
| 23 | 24 | |
| 24 | 25 | export type Change = AdminChange; |
| 25 | export type Actor = { id: string; name: string }; | |
| 26 | 26 | export type Field = { field: string; before: string; after: string }; |
| 27 | 27 | |
| 28 | 28 | /** 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 => | ||
| 58 | 58 | /** A value as a row says it: flat, and short enough to be a row. */ |
| 59 | 59 | export const say = (value: Value): string => clip(flat(value)); |
| 60 | 60 | |
| 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 | ||
| 72 | 61 | /** |
| 73 | 62 | * The fields of two snapshots that differ. A form submitted with nothing |
| 74 | 63 | * 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 { | ||
| 276 | 276 | addColumn(handle, "order", "buyer_id", "TEXT NOT NULL DEFAULT ''"); |
| 277 | 277 | addColumn(handle, "order", "buyer_name", "TEXT NOT NULL DEFAULT ''"); |
| 278 | 278 | 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 | `); | |
| 279 | 300 | } |
| 280 | 301 | |
| 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 | ||
| 281 | 305 | /** |
| 282 | 306 | * Open the database kept in a directory: the directory is made if it is not |
| 283 | 307 | * there, the file is opened, the pragmas are set, the schema is created and |
| @@ -430,7 +454,7 @@ export type TaxRate = { | ||
| 430 | 454 | label: string; |
| 431 | 455 | active: number; |
| 432 | 456 | }; |
| 433 | export type Payment = { | |
| 457 | export type Payment = ActorColumns & { | |
| 434 | 458 | id: number; |
| 435 | 459 | order_id: number; |
| 436 | 460 | kind: "charge" | "refund"; |
src/desk.ts +19 −8
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | * it is a function from `Who` to rows, so the rule is tested without a pty. |
| 22 | 22 | */ |
| 23 | 23 | |
| 24 | import * as actors from "./actor.ts"; | |
| 24 | 25 | import * as changes from "./changes.ts"; |
| 25 | 26 | import * as discounts from "./discounts.ts"; |
| 26 | 27 | import * as giftcards from "./giftcards.ts"; |
| @@ -41,7 +42,7 @@ const percent = (bp: number): string => `${(bp / 100).toFixed(2)}%`; | ||
| 41 | 42 | * the same row. The actor is the account the forge named at the keyboard — |
| 42 | 43 | * the environment it started us with, which is the same trust as the socket. |
| 43 | 44 | */ |
| 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; | |
| 45 | 46 | export type Tab = (typeof TABS)[number]; |
| 46 | 47 | |
| 47 | 48 | /** |
| @@ -112,6 +113,12 @@ const DESK: Record<Tab, { read: "anyone" | "own" | "admin"; keys: Offer[] }> = { | ||
| 112 | 113 | // Who changed a price, a code or a rate is the shop's own business, and it |
| 113 | 114 | // names people: the same gate the orders and the cards are behind. |
| 114 | 115 | 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 | }, | |
| 115 | 122 | }; |
| 116 | 123 | |
| 117 | 124 | /** |
| @@ -218,7 +225,7 @@ export function newDiscount(line: string): Done { | ||
| 218 | 225 | expires_at: expires, |
| 219 | 226 | }); |
| 220 | 227 | changes.changed( |
| 221 | changes.atDesk(), | |
| 228 | actors.atDesk(), | |
| 222 | 229 | "discount", |
| 223 | 230 | made, |
| 224 | 231 | {}, |
| @@ -250,7 +257,7 @@ export function toggleDiscount(id: number): Done { | ||
| 250 | 257 | if (!found) return no("No code selected."); |
| 251 | 258 | db.transaction(() => { |
| 252 | 259 | 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 }); | |
| 254 | 261 | })(); |
| 255 | 262 | return yes(`${found.code} is now ${found.active === 1 ? "off" : "on"}.`); |
| 256 | 263 | } |
| @@ -260,7 +267,11 @@ export function issueCard(line: string): Done { | ||
| 260 | 267 | const parts = words(line); |
| 261 | 268 | const cents = parseCents(parts[0] ?? ""); |
| 262 | 269 | 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 | }); | |
| 264 | 275 | return yes(`${issued.code} — ${formatCents(cents)}. This is the only copy of the code.`); |
| 265 | 276 | } |
| 266 | 277 | |
| @@ -268,7 +279,7 @@ export function toggleCard(id: number, reason: string): Done { | ||
| 268 | 279 | const card = giftcards.byId(id); |
| 269 | 280 | if (!card) return no("No card selected."); |
| 270 | 281 | 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()); | |
| 272 | 283 | return yes( |
| 273 | 284 | reviving |
| 274 | 285 | ? `····${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 { | ||
| 316 | 327 | db.transaction(() => { |
| 317 | 328 | const id = tax.upsertRate({ country, region, rate_bp: points, label, active: true }); |
| 318 | 329 | changes.changed( |
| 319 | changes.atDesk(), | |
| 330 | actors.atDesk(), | |
| 320 | 331 | "tax_rate", |
| 321 | 332 | id, |
| 322 | 333 | existing ? { rate: percent(existing.rate_bp), label: existing.label, active: existing.active === 1 } : {}, |
| @@ -332,7 +343,7 @@ export function setDefaultRate(text: string): Done { | ||
| 332 | 343 | const was = tax.defaultRateBp(); |
| 333 | 344 | db.transaction(() => { |
| 334 | 345 | 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) }); | |
| 336 | 347 | })(); |
| 337 | 348 | return yes(`Anywhere the table does not name is taxed at ${(points / 100).toFixed(2)}%.`); |
| 338 | 349 | } |
| @@ -343,7 +354,7 @@ export function removeRate(id: number, confirmation: string): Done { | ||
| 343 | 354 | if (!/^y(es)?$/i.test(confirmation.trim())) return no(`${rate.country} ${rate.region} kept.`); |
| 344 | 355 | db.transaction(() => { |
| 345 | 356 | tax.removeRate(rate.id); |
| 346 | changes.record(changes.atDesk(), "tax_rate", rate.id, [ | |
| 357 | changes.record(actors.atDesk(), "tax_rate", rate.id, [ | |
| 347 | 358 | { |
| 348 | 359 | field: "removed", |
| 349 | 360 | before: `${rate.country}${rate.region ? ` ${rate.region}` : ""} ${percent(rate.rate_bp)} ${rate.label}`, |
src/discounts.ts +15 −6
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * A discount never applies to a gift card: money is not bought at a discount. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { cells, type Actor } from "./actor.ts"; | |
| 14 | 15 | import { db, type Discount } from "./db.ts"; |
| 15 | 16 | import { formatCents } from "./money.ts"; |
| 16 | 17 | |
| @@ -106,7 +107,13 @@ export function evaluate(code: string, base_cents: number, now = new Date()): Ch | ||
| 106 | 107 | * said the same thing a moment earlier, but only this decides, so a code that |
| 107 | 108 | * expired or was switched off in between is not taken anyway. |
| 108 | 109 | */ |
| 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 { | |
| 110 | 117 | const changes = db |
| 111 | 118 | .query( |
| 112 | 119 | `UPDATE discount SET used_count = used_count + 1 |
| @@ -116,18 +123,20 @@ export function redeem(discountId: number, orderId: number, amount_cents: number | ||
| 116 | 123 | .run(discountId, stampOf(now)).changes; |
| 117 | 124 | if (changes !== 1) return false; |
| 118 | 125 | 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)); | |
| 121 | 129 | return true; |
| 122 | 130 | } |
| 123 | 131 | |
| 124 | 132 | /** 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 { | |
| 126 | 134 | db.transaction(() => { |
| 127 | 135 | db.query("UPDATE discount SET used_count = MAX(0, used_count - 1) WHERE id = ?1").run(discountId); |
| 128 | 136 | 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)); | |
| 131 | 140 | })(); |
| 132 | 141 | } |
| 133 | 142 |
src/giftcards.ts +22 −22
| @@ -12,7 +12,8 @@ | ||
| 12 | 12 | * so two checkouts racing for the last $5 cannot both win. |
| 13 | 13 | */ |
| 14 | 14 | |
| 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"; | |
| 16 | 17 | import { hmac, loadSecret } from "./sign.ts"; |
| 17 | 18 | import type { Order, OrderItem } from "./db.ts"; |
| 18 | 19 | |
| @@ -21,7 +22,7 @@ const secret = loadSecret(dataDir); | ||
| 21 | 22 | /** No I, O, 0 or 1: a code is read aloud and typed by hand. */ |
| 22 | 23 | const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; |
| 23 | 24 | |
| 24 | export type Entry = { | |
| 25 | export type Entry = ActorColumns & { | |
| 25 | 26 | id: number; |
| 26 | 27 | gift_card_id: number; |
| 27 | 28 | delta: number; |
| @@ -112,7 +113,7 @@ export type Issued = { card: GiftCard; code: string }; | ||
| 112 | 113 | * Make a card. The plain code is returned once, to be printed on a receipt or |
| 113 | 114 | * shown to the admin who asked for it, and then it exists nowhere else. |
| 114 | 115 | */ |
| 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 { | |
| 116 | 117 | const code = freshCode(); |
| 117 | 118 | const card = db.transaction(() => { |
| 118 | 119 | const row = db |
| @@ -127,7 +128,7 @@ export function issue(input: { cents: number; note: string; orderId?: number }): | ||
| 127 | 128 | $note: input.note, |
| 128 | 129 | $order: input.orderId ?? null, |
| 129 | 130 | })!; |
| 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); | |
| 131 | 132 | return byId(row.id)!; |
| 132 | 133 | })(); |
| 133 | 134 | return { card, code }; |
| @@ -142,6 +143,7 @@ export function credit( | ||
| 142 | 143 | giftCardId: number, |
| 143 | 144 | cents: number, |
| 144 | 145 | reason: string, |
| 146 | by: Actor, | |
| 145 | 147 | orderId?: number, |
| 146 | 148 | idempotencyKey?: string, |
| 147 | 149 | ): boolean { |
| @@ -149,10 +151,11 @@ export function credit( | ||
| 149 | 151 | return db.transaction(() => { |
| 150 | 152 | const inserted = db |
| 151 | 153 | .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)`, | |
| 154 | 157 | ) |
| 155 | .run(giftCardId, cents, reason, orderId ?? null, idempotencyKey ?? null).changes; | |
| 158 | .run(giftCardId, cents, reason, orderId ?? null, idempotencyKey ?? null, ...cells(by)).changes; | |
| 156 | 159 | if (inserted !== 1) return false; |
| 157 | 160 | db.query("UPDATE gift_card SET balance_cents = balance_cents + ?2 WHERE id = ?1").run(giftCardId, cents); |
| 158 | 161 | return true; |
| @@ -163,35 +166,32 @@ export function credit( | ||
| 163 | 166 | * Take money off a card. False and nothing written when the balance is not |
| 164 | 167 | * there or the card is void; the caller turns that into a refusal. |
| 165 | 168 | */ |
| 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 { | |
| 167 | 170 | if (cents <= 0) return true; |
| 168 | 171 | return db.transaction(() => { |
| 169 | 172 | const changes = db |
| 170 | 173 | .query("UPDATE gift_card SET balance_cents = balance_cents - ?2 WHERE id = ?1 AND active = 1 AND balance_cents >= ?2") |
| 171 | 174 | .run(giftCardId, cents).changes; |
| 172 | 175 | 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)); | |
| 179 | 180 | return true; |
| 180 | 181 | })(); |
| 181 | 182 | } |
| 182 | 183 | |
| 183 | 184 | /** 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 { | |
| 185 | 186 | const card = byId(giftCardId); |
| 186 | 187 | if (!card) return; |
| 187 | 188 | db.transaction(() => { |
| 188 | 189 | if (!active && card.balance_cents > 0) { |
| 189 | 190 | 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)); | |
| 195 | 195 | } |
| 196 | 196 | db.query("UPDATE gift_card SET active = ?2 WHERE id = ?1").run(giftCardId, active ? 1 : 0); |
| 197 | 197 | })(); |
| @@ -202,7 +202,7 @@ export function setActive(giftCardId: number, active: boolean, reason: string): | ||
| 202 | 202 | * quantity three issues three cards, each with its own code, each worth what |
| 203 | 203 | * was paid for it. Idempotent — an order that already issued issues nothing. |
| 204 | 204 | */ |
| 205 | export function issueForOrder(order: Order, items: OrderItem[]): Issued[] { | |
| 205 | export function issueForOrder(order: Order, items: OrderItem[], by: Actor): Issued[] { | |
| 206 | 206 | const lines = items.filter((item) => item.gift_card_cents > 0); |
| 207 | 207 | if (lines.length === 0) return []; |
| 208 | 208 | const already = db |
| @@ -212,7 +212,7 @@ export function issueForOrder(order: Order, items: OrderItem[]): Issued[] { | ||
| 212 | 212 | const issued: Issued[] = []; |
| 213 | 213 | for (const line of lines) |
| 214 | 214 | 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 })); | |
| 216 | 216 | return issued; |
| 217 | 217 | } |
| 218 | 218 |
src/inventory.ts +28 −19
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | * cannot both win, because the UPDATE only matches while stock is enough. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { db } from "./db.ts"; | |
| 8 | import { cells, type Actor } from "./actor.ts"; | |
| 9 | import { db, type ActorColumns } from "./db.ts"; | |
| 9 | 10 | import { emit } from "./live.ts"; |
| 10 | 11 | |
| 11 | export type StockEntry = { | |
| 12 | export type StockEntry = ActorColumns & { | |
| 12 | 13 | id: number; |
| 13 | 14 | variant_id: number; |
| 14 | 15 | delta: number; |
| @@ -18,15 +19,13 @@ export type StockEntry = { | ||
| 18 | 19 | sku: string; |
| 19 | 20 | }; |
| 20 | 21 | |
| 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)); | |
| 30 | 29 | }; |
| 31 | 30 | |
| 32 | 31 | /** |
| @@ -41,12 +40,16 @@ const announce = (variantId: number): void => { | ||
| 41 | 40 | if (row) emit("stock.moved", { id: variantId, sku: row.sku }); |
| 42 | 41 | }; |
| 43 | 42 | |
| 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 { | |
| 46 | 49 | if (quantity <= 0) return; |
| 47 | 50 | db.transaction(() => { |
| 48 | 51 | 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); | |
| 50 | 53 | })(); |
| 51 | 54 | announce(variantId); |
| 52 | 55 | } |
| @@ -55,14 +58,20 @@ export function addStock(variantId: number, quantity: number, reason: string, or | ||
| 55 | 58 | * Take stock for a sale. Returns false and changes nothing when there is not |
| 56 | 59 | * enough; the caller turns that into "out of stock" at the boundary. |
| 57 | 60 | */ |
| 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 { | |
| 59 | 68 | if (quantity <= 0) return true; |
| 60 | 69 | const took = db.transaction(() => { |
| 61 | 70 | const changes = db |
| 62 | 71 | .query("UPDATE variant SET stock = stock - ?2 WHERE id = ?1 AND stock >= ?2") |
| 63 | 72 | .run(variantId, quantity).changes; |
| 64 | 73 | if (changes !== 1) return false; |
| 65 | record(variantId, -quantity, reason, orderId); | |
| 74 | record(variantId, -quantity, reason, by, orderId); | |
| 66 | 75 | return true; |
| 67 | 76 | })(); |
| 68 | 77 | if (took) announce(variantId); |
| @@ -70,15 +79,15 @@ export function takeStock(variantId: number, quantity: number, reason: string, o | ||
| 70 | 79 | } |
| 71 | 80 | |
| 72 | 81 | /** 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 { | |
| 74 | 83 | const current = db.query<{ stock: number }, [number]>("SELECT stock FROM variant WHERE id = ?1").get(variantId); |
| 75 | 84 | if (!current) return; |
| 76 | 85 | const delta = target - current.stock; |
| 77 | 86 | if (delta === 0) return; |
| 78 | if (delta > 0) return addStock(variantId, delta, reason); | |
| 87 | if (delta > 0) return addStock(variantId, delta, reason, by); | |
| 79 | 88 | db.transaction(() => { |
| 80 | 89 | 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); | |
| 82 | 91 | })(); |
| 83 | 92 | announce(variantId); |
| 84 | 93 | } |
src/orders.ts +69 −30
| @@ -16,10 +16,12 @@ | ||
| 16 | 16 | * webhook arriving twice writes one row, so it announces one thing. |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | import * as actors from "./actor.ts"; | |
| 19 | 20 | import * as discounts from "./discounts.ts"; |
| 20 | 21 | import * as giftcards from "./giftcards.ts"; |
| 21 | 22 | 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"; | |
| 23 | 25 | import { addStock, takeStock } from "./inventory.ts"; |
| 24 | 26 | import { dueCents, sumLines } from "./money.ts"; |
| 25 | 27 | import { emit } from "./live.ts"; |
| @@ -38,8 +40,13 @@ export type Address = { | ||
| 38 | 40 | country: string; |
| 39 | 41 | }; |
| 40 | 42 | |
| 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; | |
| 43 | 50 | |
| 44 | 51 | export type PlaceResult = { ok: true; order: Order; reused: boolean } | { ok: false; error: string }; |
| 45 | 52 | |
| @@ -61,15 +68,22 @@ export const itemsOf = (orderId: number): OrderItem[] => | ||
| 61 | 68 | export const paymentsOf = (orderId: number): Payment[] => |
| 62 | 69 | db.query<Payment, [number]>("SELECT * FROM payment WHERE order_id = ?1 ORDER BY id").all(orderId); |
| 63 | 70 | |
| 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[] => | |
| 65 | 74 | 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`, | |
| 68 | 78 | ) |
| 69 | 79 | .all(orderId); |
| 70 | 80 | |
| 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)); | |
| 73 | 87 | }; |
| 74 | 88 | |
| 75 | 89 | /** A needle for LIKE: the shopper's text is data, and `%` and `_` are theirs to type. */ |
| @@ -238,15 +252,18 @@ export function place(input: { | ||
| 238 | 252 | // out first stops the whole order rather than half of it. |
| 239 | 253 | if (priced.discount_code !== "") { |
| 240 | 254 | 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)) | |
| 242 | 256 | 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)}`); | |
| 244 | 258 | } |
| 245 | 259 | if (priced.gift_card_cents > 0) { |
| 246 | 260 | 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 | ) | |
| 248 | 265 | 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)}`); | |
| 250 | 267 | } |
| 251 | 268 | |
| 252 | 269 | const insertItem = db.query( |
| @@ -264,12 +281,13 @@ export function place(input: { | ||
| 264 | 281 | item.quantity, |
| 265 | 282 | item.gift_card_cents, |
| 266 | 283 | ); |
| 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)) | |
| 268 | 285 | throw new Error(`OUT_OF_STOCK:${item.product_title} (${item.variant_name})`); |
| 269 | 286 | } |
| 270 | 287 | note( |
| 271 | 288 | created.id, |
| 272 | 289 | "placed", |
| 290 | input.buyer, | |
| 273 | 291 | `${itemRows.length} line${itemRows.length === 1 ? "" : "s"}, awaiting payment${ |
| 274 | 292 | created.buyer_name === "" ? "" : ` — ${created.buyer_name}` |
| 275 | 293 | }`, |
| @@ -289,7 +307,7 @@ export function place(input: { | ||
| 289 | 307 | emit("order.placed", { id: placed.id, number: placed.number, cents: placed.total_cents }); |
| 290 | 308 | for (const item of itemRows) emit("stock.moved", { id: item.variant_id, sku: item.sku }); |
| 291 | 309 | // 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 }; | |
| 293 | 311 | } |
| 294 | 312 | |
| 295 | 313 | export type Charge = { gateway: string; ref: string; amount_cents: number }; |
| @@ -332,7 +350,7 @@ const alreadyShort = (orderId: number): boolean => | ||
| 332 | 350 | .query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM order_event WHERE order_id = ?1 AND kind = 'short-payment'") |
| 333 | 351 | .get(orderId)!.n > 0; |
| 334 | 352 | |
| 335 | export function settleFromGateway(order: Order, charge: Charge): Order { | |
| 353 | export function settleFromGateway(order: Order, charge: Charge, by: Actor): Order { | |
| 336 | 354 | // A gateway may say anything; a charge is a whole number of cents, and the |
| 337 | 355 | // payment ledger has no room for a row that moved nothing. |
| 338 | 356 | 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 { | ||
| 344 | 362 | amount > 0 |
| 345 | 363 | ? db |
| 346 | 364 | .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)`, | |
| 349 | 368 | ) |
| 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 | |
| 351 | 370 | : 0; |
| 352 | 371 | // Already recorded: the other arrival settled this order, whichever way. |
| 353 | 372 | if (inserted === 0 && amount > 0) return { paid: false, order: byId(fresh.id)! }; |
| @@ -358,7 +377,7 @@ export function settleFromGateway(order: Order, charge: Charge): Order { | ||
| 358 | 377 | `UPDATE "order" SET status = 'paid', paid_at = datetime('now'), payment_ref = ?2, gateway = ?3 |
| 359 | 378 | WHERE id = ?1 AND status = 'pending_payment'`, |
| 360 | 379 | ).run(fresh.id, ref, charge.gateway).changes; |
| 361 | note(fresh.id, "paid", `${charge.gateway} ${ref}`); | |
| 380 | note(fresh.id, "paid", by, `${charge.gateway} ${ref}`); | |
| 362 | 381 | return { paid: changed === 1, order: byId(fresh.id)! }; |
| 363 | 382 | } |
| 364 | 383 | // 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 { | ||
| 374 | 393 | note( |
| 375 | 394 | fresh.id, |
| 376 | 395 | "short-payment", |
| 396 | by, | |
| 377 | 397 | `asked ${(due / 100).toFixed(2)} ${fresh.currency}, ${charge.gateway} paid ${(amount / 100).toFixed(2)} — ` + |
| 378 | 398 | `${((due - amount) / 100).toFixed(2)} short${ref ? ` (${ref})` : ""}`, |
| 379 | 399 | ); |
| @@ -392,7 +412,7 @@ export function settleFromGateway(order: Order, charge: Charge): Order { | ||
| 392 | 412 | * charge to record — the money moved when the balance did, and that row is |
| 393 | 413 | * already written. |
| 394 | 414 | */ |
| 395 | export function payWithGiftCard(order: Order): Order { | |
| 415 | export function payWithGiftCard(order: Order, by: Actor): Order { | |
| 396 | 416 | const took = db.transaction(() => { |
| 397 | 417 | const changed = db |
| 398 | 418 | .query( |
| @@ -400,7 +420,8 @@ export function payWithGiftCard(order: Order): Order { | ||
| 400 | 420 | WHERE id = ?1 AND status = 'pending_payment'`, |
| 401 | 421 | ) |
| 402 | 422 | .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 ?? "????"}`); | |
| 404 | 425 | return { charged: changed === 1, order: byId(order.id)! }; |
| 405 | 426 | })(); |
| 406 | 427 | const paid = settle(took.order); |
| @@ -416,7 +437,9 @@ export function payWithGiftCard(order: Order): Order { | ||
| 416 | 437 | function settle(order: Order): Order { |
| 417 | 438 | if (order.status !== "paid") return order; |
| 418 | 439 | 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)); | |
| 420 | 443 | if (!hasReceipt(order.id)) writeReceipt(order, items, issued.map((one) => one.code)); |
| 421 | 444 | return order; |
| 422 | 445 | } |
| @@ -439,6 +462,8 @@ export function refund(input: { | ||
| 439 | 462 | restock: boolean; |
| 440 | 463 | gateway_ref: string; |
| 441 | 464 | idempotencyKey: string; |
| 465 | /** Whoever is giving the money back: an admin, on a page or at the terminal. */ | |
| 466 | by: Actor; | |
| 442 | 467 | }): RefundResult { |
| 443 | 468 | const { order } = input; |
| 444 | 469 | if (input.amount_cents <= 0) return { ok: false, error: "A refund is more than nothing." }; |
| @@ -456,12 +481,15 @@ export function refund(input: { | ||
| 456 | 481 | if (input.amount_cents > left) return { ok: false, error: "That is more than the order has left to refund." }; |
| 457 | 482 | const split = splitRefund(fresh, input.amount_cents); |
| 458 | 483 | |
| 484 | const [actorId, actorName, actorRole] = cells(input.by); | |
| 459 | 485 | let payment: Payment | null = null; |
| 460 | 486 | if (split.gateway_cents > 0) |
| 461 | 487 | payment = db |
| 462 | 488 | .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 *`, | |
| 465 | 493 | ) |
| 466 | 494 | .get({ |
| 467 | 495 | $order_id: fresh.id, |
| @@ -469,6 +497,9 @@ export function refund(input: { | ||
| 469 | 497 | $gateway: fresh.gateway, |
| 470 | 498 | $ref: input.gateway_ref, |
| 471 | 499 | $key: input.idempotencyKey, |
| 500 | $actor_id: actorId, | |
| 501 | $actor_name: actorName, | |
| 502 | $actor_role: actorRole, | |
| 472 | 503 | })!; |
| 473 | 504 | if (split.gift_card_cents > 0) { |
| 474 | 505 | const card = giftcards.cardSpentOn(fresh.id); |
| @@ -477,11 +508,17 @@ export function refund(input: { | ||
| 477 | 508 | card.id, |
| 478 | 509 | split.gift_card_cents, |
| 479 | 510 | `refunded from ${fresh.number}`, |
| 511 | input.by, | |
| 480 | 512 | fresh.id, |
| 481 | 513 | `${input.idempotencyKey}:card`, |
| 482 | 514 | ); |
| 483 | 515 | 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 | ); | |
| 485 | 522 | } |
| 486 | 523 | |
| 487 | 524 | const refunded = refundedCents(fresh.id); |
| @@ -489,10 +526,11 @@ export function refund(input: { | ||
| 489 | 526 | db.query('UPDATE "order" SET status = ?2 WHERE id = ?1').run(fresh.id, status); |
| 490 | 527 | if (input.restock) |
| 491 | 528 | 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); | |
| 493 | 530 | note( |
| 494 | 531 | fresh.id, |
| 495 | 532 | "refunded", |
| 533 | input.by, | |
| 496 | 534 | `${(input.amount_cents / 100).toFixed(2)} ${fresh.currency}${input.reason ? ` — ${input.reason}` : ""}${ |
| 497 | 535 | input.restock ? " (restocked)" : "" |
| 498 | 536 | }`, |
| @@ -515,16 +553,16 @@ export function refund(input: { | ||
| 515 | 553 | * An unpaid order an admin gives up on: the stock goes back on the shelf, the |
| 516 | 554 | * code gets its use back and the gift card gets its balance back. |
| 517 | 555 | */ |
| 518 | export function cancel(order: Order, reason: string): Order { | |
| 556 | export function cancel(order: Order, reason: string, by: Actor): Order { | |
| 519 | 557 | const ended = db.transaction(() => { |
| 520 | 558 | const fresh = byId(order.id)!; |
| 521 | 559 | if (fresh.status !== "pending_payment") return { cancelled: false, order: fresh, items: [] as OrderItem[] }; |
| 522 | 560 | db.query('UPDATE "order" SET status = \'cancelled\' WHERE id = ?1').run(fresh.id); |
| 523 | 561 | 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); | |
| 525 | 563 | if (fresh.discount_code !== "") { |
| 526 | 564 | 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); | |
| 528 | 566 | } |
| 529 | 567 | if (fresh.gift_card_cents > 0) { |
| 530 | 568 | const card = giftcards.cardSpentOn(fresh.id); |
| @@ -533,11 +571,12 @@ export function cancel(order: Order, reason: string): Order { | ||
| 533 | 571 | card.id, |
| 534 | 572 | fresh.gift_card_cents, |
| 535 | 573 | `released from ${fresh.number}`, |
| 574 | by, | |
| 536 | 575 | fresh.id, |
| 537 | 576 | `release:${fresh.number}`, |
| 538 | 577 | ); |
| 539 | 578 | } |
| 540 | note(fresh.id, "cancelled", reason); | |
| 579 | note(fresh.id, "cancelled", by, reason); | |
| 541 | 580 | return { cancelled: true, order: byId(fresh.id)!, items: itemsOf(fresh.id) }; |
| 542 | 581 | })(); |
| 543 | 582 |
src/pages/admin.ts +111 −4
| @@ -16,13 +16,14 @@ import { formatCents } from "../money.ts"; | ||
| 16 | 16 | import { itemsTable, orderTotals, statusPill } from "./checkout.ts"; |
| 17 | 17 | import { describe } from "../discounts.ts"; |
| 18 | 18 | import { href, nameOf, type Reading } from "../changes.ts"; |
| 19 | import type { Balance, Entry } from "../audit.ts"; | |
| 19 | 20 | import type { DiscountStats } from "../discounts.ts"; |
| 20 | 21 | import type { ProductWithPrice } from "../catalog.ts"; |
| 21 | 22 | import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant } from "../db.ts"; |
| 22 | 23 | import type { StockEntry } from "../inventory.ts"; |
| 23 | 24 | import type { Email } from "../mail.ts"; |
| 24 | 25 | import type { Session } from "../session.ts"; |
| 25 | import type { Shortfall } from "../orders.ts"; | |
| 26 | import type { OrderEvent, Shortfall } from "../orders.ts"; | |
| 26 | 27 | |
| 27 | 28 | /** What the ledger emits about an order, and about a stock movement. */ |
| 28 | 29 | const ORDER_EVENTS = "order.placed order.paid order.refunded order.cancelled"; |
| @@ -46,6 +47,7 @@ export const nav = (current: string): string => | ||
| 46 | 47 | ["/admin/tax", "Tax", "tax"], |
| 47 | 48 | ["/admin/mail", "Outbox", "mail"], |
| 48 | 49 | ["/admin/changes", "Changes", "changes"], |
| 50 | ["/admin/audit", "Audit", "audit"], | |
| 49 | 51 | ] |
| 50 | 52 | .map(([href, label, key]) => |
| 51 | 53 | key === current ? `<strong>${escape(label)}</strong>` : `<a href="${escape(href)}">${escape(label)}</a>`, |
| @@ -141,11 +143,21 @@ ${nav("orders")} | ||
| 141 | 143 | ${live("order-list", ORDER_EVENTS, orderRows(list))} |
| 142 | 144 | `; |
| 143 | 145 | |
| 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 | ||
| 144 | 156 | export function order( |
| 145 | 157 | found: Order, |
| 146 | 158 | items: OrderItem[], |
| 147 | 159 | payments: Payment[], |
| 148 | events: { kind: string; detail: string; at: string }[], | |
| 160 | events: OrderEvent[], | |
| 149 | 161 | refunded: number, |
| 150 | 162 | refundable: number, |
| 151 | 163 | email: Email | null, |
| @@ -203,11 +215,12 @@ ${orderTotals(found, refunded)} | ||
| 203 | 215 | ${ |
| 204 | 216 | payments.length === 0 |
| 205 | 217 | ? '<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> | |
| 207 | 219 | <tbody>${payments |
| 208 | 220 | .map( |
| 209 | 221 | (payment) => `<tr><td class="muted">${escape(payment.created_at)}</td><td>${escape(payment.kind)}</td> |
| 210 | 222 | <td class="num price">${escape(formatCents(payment.kind === "refund" ? -payment.amount_cents : payment.amount_cents))}</td> |
| 223 | <td>${who(payment)}</td> | |
| 211 | 224 | <td class="muted" style="word-break:break-all">${escape(payment.gateway_ref || "—")}</td></tr>`, |
| 212 | 225 | ) |
| 213 | 226 | .join("")}</tbody></table>` |
| @@ -218,7 +231,7 @@ ${orderTotals(found, refunded)} | ||
| 218 | 231 | (event) => |
| 219 | 232 | `<tr><td class="muted" style="white-space:nowrap">${escape(event.at)}</td><td><strong>${escape( |
| 220 | 233 | event.kind, |
| 221 | )}</strong> ${escape(event.detail)}</td></tr>`, | |
| 234 | )}</strong> ${escape(event.detail)}</td><td>${who(event)}</td></tr>`, | |
| 222 | 235 | ) |
| 223 | 236 | .join("")}</tbody></table> |
| 224 | 237 | ${ |
| @@ -433,6 +446,100 @@ ${ | ||
| 433 | 446 | } |
| 434 | 447 | `; |
| 435 | 448 | |
| 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 | ||
| 436 | 543 | // --- codes, cards and tax ---------------------------------------------------- |
| 437 | 544 | |
| 438 | 545 | const dateCell = (value: string | null): string => escape(value ?? "—"); |
src/routes.ts +1
| @@ -75,6 +75,7 @@ export const routes: Route[] = [ | ||
| 75 | 75 | { method: "POST", pattern: "/admin/orders/:number/cancel", handler: admin.orderCancel }, |
| 76 | 76 | { method: "GET", pattern: "/admin/mail", handler: admin.mailbox }, |
| 77 | 77 | { method: "GET", pattern: "/admin/changes", handler: admin.changeList }, |
| 78 | { method: "GET", pattern: "/admin/audit", handler: admin.auditList }, | |
| 78 | 79 | ]; |
| 79 | 80 | |
| 80 | 81 | export type Match = { route: Route; params: Record<string, string> }; |
src/server.test.ts +82 −12
| @@ -99,6 +99,9 @@ class Shopper { | ||
| 99 | 99 | } |
| 100 | 100 | } |
| 101 | 101 | |
| 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 | ||
| 102 | 105 | /** An admin, the way the forge signs one in. */ |
| 103 | 106 | const asAdmin = (): Shopper => new Shopper({ id: "u_1", name: "ada", role: "admin" }); |
| 104 | 107 | |
| @@ -421,8 +424,8 @@ test("a discount, a part-spent gift card and a state's rate become an order that | ||
| 421 | 424 | limit.forget(); |
| 422 | 425 | const shopper = asCustomer(); |
| 423 | 426 | // 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); | |
| 426 | 429 | |
| 427 | 430 | await addToCart(shopper, "COF-GUJI-250", 3); // $57.00 of goods |
| 428 | 431 | 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 | ||
| 500 | 503 | |
| 501 | 504 | test("two orders on one gift card spend what is on it and no more", async () => { |
| 502 | 505 | 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 }); | |
| 504 | 507 | |
| 505 | 508 | // Two shoppers hold the same code, each with $23.32 to pay. |
| 506 | 509 | const first = asCustomer(); |
| @@ -876,6 +879,73 @@ test("an unsigned webhook is refused", async () => { | ||
| 876 | 879 | expect(response.status).toBe(400); |
| 877 | 880 | }); |
| 878 | 881 | |
| 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=&before=[^"]+)"/.exec(page)?.[1]?.replace(/&/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 | ||
| 879 | 949 | test("/admin opens for an admin the forge signed, and refunds and restocks", async () => { |
| 880 | 950 | limit.forget(); |
| 881 | 951 | const shopper = asCustomer(); |
| @@ -1161,8 +1231,8 @@ test("an admin adds a product and a variant, and the shop sells it", async () => | ||
| 1161 | 1231 | |
| 1162 | 1232 | test("whoever holds a gift card's code can see what is left on it, and nobody else learns anything", async () => { |
| 1163 | 1233 | 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); | |
| 1166 | 1236 | |
| 1167 | 1237 | const holder = new Shopper(); |
| 1168 | 1238 | 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 ( | ||
| 1243 | 1313 | |
| 1244 | 1314 | test("a long ledger says it was cut off, and a void card offers nothing to spend", async () => { |
| 1245 | 1315 | 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); | |
| 1248 | 1318 | |
| 1249 | 1319 | const holder = new Shopper(); |
| 1250 | 1320 | 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 | ||
| 1254 | 1324 | expect(page).toContain("Spend it in your cart"); // live card, money on it |
| 1255 | 1325 | |
| 1256 | 1326 | // 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); | |
| 1258 | 1328 | const voided = await holder.post("/gift-cards", { code }, Shopper.tokenIn(form)).then((r) => r.text()); |
| 1259 | 1329 | expect(voided).toContain("Void — nothing can be spent"); |
| 1260 | 1330 | expect(voided).not.toContain("Spend it in your cart"); |
| 1261 | 1331 | |
| 1262 | 1332 | // 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); | |
| 1265 | 1335 | const flat = await holder.post("/gift-cards", { code: empty.code }, Shopper.tokenIn(form)).then((r) => r.text()); |
| 1266 | 1336 | expect(flat).not.toContain("Spend it in your cart"); |
| 1267 | 1337 | expect(flat).not.toContain("The last 20 movements"); |
| 1268 | 1338 | |
| 1269 | 1339 | // Exactly as many movements as the window holds is not a cut-off: issuing |
| 1270 | 1340 | // 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); | |
| 1273 | 1343 | const full = await holder.post("/gift-cards", { code: exact.code }, Shopper.tokenIn(form)).then((r) => r.text()); |
| 1274 | 1344 | expect(full).toContain("$20.00"); // twenty rows of a dollar, all of them shown |
| 1275 | 1345 | expect(full).not.toContain("The last 20 movements"); |
src/shop.ts +37 −21
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * and payment are rate limited. Nothing that takes money happens on a GET. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import * as actors from "./actor.ts"; | |
| 12 | 13 | import * as cart from "./cart.ts"; |
| 13 | 14 | import * as codes from "./codes.ts"; |
| 14 | 15 | import * as discounts from "./discounts.ts"; |
| @@ -283,7 +284,9 @@ export function placeOrder(ctx: Ctx): Response { | ||
| 283 | 284 | // An order belongs to an account, so there is nothing to place without one: |
| 284 | 285 | // no row, no stock taken, no code redeemed. Back to the page that says so. |
| 285 | 286 | 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); | |
| 287 | 290 | const values = { |
| 288 | 291 | email: field(ctx.form, "email", 200), |
| 289 | 292 | name: field(ctx.form, "name", 120), |
| @@ -380,15 +383,19 @@ export function payMock(ctx: Ctx): Response { | ||
| 380 | 383 | if (chosen().kind !== "mock") return redirect(orderLink(ctx, order, "pay")); |
| 381 | 384 | const decision = field(ctx.form, "decision", 10); |
| 382 | 385 | 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"); | |
| 384 | 387 | ctx.params.number = order.number; |
| 385 | 388 | return payPageFor(ctx, order, "The mock gateway declined this payment. Nothing was charged; try again."); |
| 386 | 389 | } |
| 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 | ); | |
| 392 | 399 | return redirect(orderLink(ctx, paid)); |
| 393 | 400 | } |
| 394 | 401 | |
| @@ -415,7 +422,7 @@ export async function payStripe(ctx: Ctx): Promise<Response> { | ||
| 415 | 422 | cancel: `${publicOrigin()}/pay/${order.number}?token=${encodeURIComponent(order.access_token)}`, |
| 416 | 423 | }); |
| 417 | 424 | 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); | |
| 419 | 426 | orders.rememberGatewayRef(order, session.value.id); |
| 420 | 427 | // A form POST answered with a redirect off-site: the response says where it |
| 421 | 428 | // 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> { | ||
| 427 | 434 | export async function orderPage(ctx: Ctx): Promise<Response> { |
| 428 | 435 | let order = openOrder(ctx, "query"); |
| 429 | 436 | if (!order) return notFound(ctx); |
| 430 | order = await settleStripe(order); | |
| 437 | order = await settleStripe(order, actors.paying(ctx.who, order)); | |
| 431 | 438 | const refunded = orders.refundedCents(order.id); |
| 432 | 439 | return render( |
| 433 | 440 | ctx, |
| @@ -510,7 +517,7 @@ export function findOrder(ctx: Ctx): Response { | ||
| 510 | 517 | if (!order || !matched) return ordersPage(ctx, NOT_FOUND, values); |
| 511 | 518 | |
| 512 | 519 | // 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}`); | |
| 514 | 521 | rememberOrder(ctx, order); |
| 515 | 522 | return redirect(orderLink(ctx, order)); |
| 516 | 523 | } |
| @@ -530,18 +537,22 @@ export async function receiptPage(ctx: Ctx): Promise<Response> { | ||
| 530 | 537 | * wins because the charge has an idempotency key. Stripe's amount is recorded, |
| 531 | 538 | * not trusted: what it means for the order is decided there, against the total. |
| 532 | 539 | */ |
| 533 | async function settleStripe(order: Order): Promise<Order> { | |
| 540 | async function settleStripe(order: Order, by: actors.Actor): Promise<Order> { | |
| 534 | 541 | if (order.status !== "pending_payment") return order; |
| 535 | 542 | if (order.gateway !== "stripe" || !order.payment_ref?.startsWith("cs_")) return order; |
| 536 | 543 | const session = await gateway.readCheckout(order.payment_ref); |
| 537 | 544 | if (!session.ok || session.value.payment_status !== "paid") return order; |
| 538 | 545 | // Stripe's own number for what it took, believed as a number and no further: |
| 539 | 546 | // 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 | ); | |
| 545 | 556 | } |
| 546 | 557 | |
| 547 | 558 | /** |
| @@ -565,11 +576,16 @@ export function stripeWebhook(ctx: Ctx): Response { | ||
| 565 | 576 | const number = String(session.client_reference_id ?? session.metadata?.order_number ?? "").slice(0, 20); |
| 566 | 577 | const order = number ? orders.byNumber(number) : null; |
| 567 | 578 | 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 | ); | |
| 573 | 589 | } |
| 574 | 590 | return text("ok"); |
| 575 | 591 | } |
src/stripe.test.ts +2
| @@ -568,6 +568,8 @@ test("a signed webhook pays an order that never came back", async () => { | ||
| 568 | 568 | expect(charges.length).toBe(1); |
| 569 | 569 | expect(charges[0].amount_cents).toBe(dueOf(placed.number)); |
| 570 | 570 | 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"]); | |
| 571 | 573 | |
| 572 | 574 | // And the shopper who finally comes back is shown the order, not a charge. |
| 573 | 575 | expect((await comeBack(shopper, placed)).status).toBe(200); |
src/tty.ts +76 −11
| @@ -18,15 +18,17 @@ | ||
| 18 | 18 | * `src/desk.ts` holds the one table all of these gates read, and `ordersFor` |
| 19 | 19 | * is the one place the rows are chosen. |
| 20 | 20 | * |
| 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. | |
| 28 | 29 | */ |
| 29 | 30 | |
| 31 | import * as audit from "./audit.ts"; | |
| 30 | 32 | import * as desk from "./desk.ts"; |
| 31 | 33 | import * as discounts from "./discounts.ts"; |
| 32 | 34 | import * as giftcards from "./giftcards.ts"; |
| @@ -37,8 +39,10 @@ import { formatCents } from "./money.ts"; | ||
| 37 | 39 | import { history, addStock, setStock } from "./inventory.ts"; |
| 38 | 40 | import { itemsOf, paymentsOf, refund, refundableCents, refundedCents, shortfall } from "./orders.ts"; |
| 39 | 41 | import { outbox } from "./mail.ts"; |
| 42 | import { atDesk } from "./actor.ts"; | |
| 40 | 43 | import { atKeyboard } from "./who.ts"; |
| 41 | 44 | import { nameOf, recent as recentChanges, type Reading } from "./changes.ts"; |
| 45 | import { byNumber } from "./orders.ts"; | |
| 42 | 46 | import type { GiftCard, Order, TaxRate } from "./db.ts"; |
| 43 | 47 | |
| 44 | 48 | const person = atKeyboard(); |
| @@ -91,6 +95,14 @@ let filter = ""; | ||
| 91 | 95 | // is the shop's, anybody else's is their own account's — narrowed by `/`. |
| 92 | 96 | const orderRows = (): Order[] => desk.ordersFor(person, filter); |
| 93 | 97 | |
| 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 | ||
| 94 | 106 | const rowsFor = (): unknown[] => |
| 95 | 107 | tab === "stock" |
| 96 | 108 | ? stockRows() |
| @@ -104,7 +116,9 @@ const rowsFor = (): unknown[] => | ||
| 104 | 116 | ? giftcards.all() |
| 105 | 117 | : tab === "tax" |
| 106 | 118 | ? tax.rates() |
| 107 | : recentChanges(100); | |
| 119 | : tab === "changes" | |
| 120 | ? recentChanges(100) | |
| 121 | : auditRows(); | |
| 108 | 122 | |
| 109 | 123 | /** A message from the desk, with a refusal marked so it cannot be missed. */ |
| 110 | 124 | 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); | ||
| 130 | 144 | */ |
| 131 | 145 | const hints = (): string => |
| 132 | 146 | [ |
| 133 | "1-7 tabs", | |
| 147 | `1-${TABS.length} tabs`, | |
| 134 | 148 | "j/k move", |
| 135 | 149 | ...desk.offered(tab, person), |
| 136 | 150 | ...(tab === "orders" && filter !== "" ? [`showing “${filter}”`] : []), |
| 151 | ...(tab === "audit" && trailFilter !== "" ? [`showing “${trailFilter}”`] : []), | |
| 137 | 152 | "q quit", |
| 138 | 153 | ].join(" · "); |
| 139 | 154 | |
| @@ -251,7 +266,46 @@ function draw(): void { | ||
| 251 | 266 | ).slice(0, 20)}`; |
| 252 | 267 | lines.push(`${mark} ${i === cursor ? bold(text) : text}`); |
| 253 | 268 | }); |
| 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 | }); | |
| 254 | 280 | 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 | } | |
| 255 | 309 | if (message) lines.push(message, ""); |
| 256 | 310 | lines.push(prompt === undefined ? dim(hints()) : `${prompt.label} ${prompt.text}${esc}7m ${esc}0m`); |
| 257 | 311 | out(`${esc}2J${esc}H` + lines.join("\r\n")); |
| @@ -267,8 +321,8 @@ function moveStock(value: string): string { | ||
| 267 | 321 | const reason = rest.join(" ").trim(); |
| 268 | 322 | if (delta === undefined || delta === 0) return "A move is a whole number, like 12 or -3."; |
| 269 | 323 | 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()); | |
| 272 | 326 | return `${row.sku}: ${delta > 0 ? "+" : ""}${delta} — ${reason}`; |
| 273 | 327 | } |
| 274 | 328 | |
| @@ -281,6 +335,14 @@ function findOrders(value: string): string { | ||
| 281 | 335 | return found === 0 ? `Nothing matches “${filter}”.` : `${found} order${found === 1 ? "" : "s"} match “${filter}”.`; |
| 282 | 336 | } |
| 283 | 337 | |
| 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 | ||
| 284 | 346 | function refundRest(reason: string): string { |
| 285 | 347 | const order = orderRows()[cursor]; |
| 286 | 348 | if (!order) return "No order selected."; |
| @@ -293,6 +355,7 @@ function refundRest(reason: string): string { | ||
| 293 | 355 | restock: true, |
| 294 | 356 | gateway_ref: `tty_refund_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`, |
| 295 | 357 | idempotencyKey: `refund:${order.number}:${refundedCents(order.id)}:${left}`, |
| 358 | by: atDesk(), | |
| 296 | 359 | }); |
| 297 | 360 | return done.ok ? `${order.number}: refunded ${formatCents(left)} and restocked.` : done.error; |
| 298 | 361 | } |
| @@ -335,6 +398,8 @@ process.stdin.on("data", (key: string) => { | ||
| 335 | 398 | prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock }; |
| 336 | 399 | if (key === "/" && tab === "orders" && may("/")) |
| 337 | 400 | 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 }; | |
| 338 | 403 | if (key === "r" && tab === "orders" && may("r")) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest }; |
| 339 | 404 | if (key === "c" && (tab === "codes" || tab === "cards") && may("c")) |
| 340 | 405 | 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"); | ||
| 17 | 17 | const peer = await import("./peer.ts"); |
| 18 | 18 | const { escape } = await import("./html.ts"); |
| 19 | 19 | 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"); | |
| 20 | 23 | const changes = await import("./changes.ts"); |
| 21 | 24 | const desk = await import("./desk.ts"); |
| 22 | 25 | const discounts = await import("./discounts.ts"); |
| @@ -35,6 +38,9 @@ tax.seed(); | ||
| 35 | 38 | const variantId = (sku: string): number => |
| 36 | 39 | db.query<{ id: number }, [string]>("SELECT id FROM variant WHERE sku = ?1").get(sku)!.id; |
| 37 | 40 | |
| 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 | ||
| 38 | 44 | test("money is integer cents in and out", () => { |
| 39 | 45 | expect(formatCents(129900)).toBe("$1,299.00"); |
| 40 | 46 | 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 | ||
| 106 | 112 | const money = variantId("GFT-CARD-25"); // $25.00 of money, which is not a sale |
| 107 | 113 | |
| 108 | 114 | // 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); | |
| 111 | 117 | expect(giftcards.byId(card.id)!.balance_cents).toBe(3500); |
| 112 | 118 | |
| 113 | 119 | const priced = quote( |
| @@ -175,17 +181,17 @@ test("the rate follows the address, and tax is taken after the discount", () => | ||
| 175 | 181 | }); |
| 176 | 182 | |
| 177 | 183 | 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 }); | |
| 179 | 185 | |
| 180 | 186 | // 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); | |
| 183 | 189 | expect(giftcards.byId(card.id)!.balance_cents).toBe(500); |
| 184 | 190 | |
| 185 | 191 | // 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); | |
| 187 | 193 | 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); | |
| 189 | 195 | |
| 190 | 196 | // A refusal writes nothing, and the balance is the sum of what was written. |
| 191 | 197 | 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 | ||
| 319 | 325 | expect(row.actor_name).toBe("ada"); |
| 320 | 326 | |
| 321 | 327 | // 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" }); | |
| 323 | 329 | expect(changes.say(true)).toBe("yes"); |
| 324 | 330 | expect(changes.say(undefined)).toBe(""); |
| 325 | 331 | }); |
| @@ -400,7 +406,7 @@ test("a code's limits and expiry are enforced where the use is taken, not only w | ||
| 400 | 406 | starts_at: null, |
| 401 | 407 | expires_at: "2020-01-01 23:59:59", |
| 402 | 408 | }); |
| 403 | expect(discounts.redeem(expired, orderId, 500)).toBe(false); | |
| 409 | expect(discounts.redeem(expired, orderId, 500, ADA)).toBe(false); | |
| 404 | 410 | expect(used(expired)).toBe(0); |
| 405 | 411 | expect(rows(expired)).toBe(0); |
| 406 | 412 | |
| @@ -414,7 +420,7 @@ test("a code's limits and expiry are enforced where the use is taken, not only w | ||
| 414 | 420 | starts_at: "2099-01-01 00:00:00", |
| 415 | 421 | expires_at: null, |
| 416 | 422 | }); |
| 417 | expect(discounts.redeem(early, orderId, 500)).toBe(false); | |
| 423 | expect(discounts.redeem(early, orderId, 500, ADA)).toBe(false); | |
| 418 | 424 | expect(used(early)).toBe(0); |
| 419 | 425 | |
| 420 | 426 | // 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 | ||
| 427 | 433 | starts_at: null, |
| 428 | 434 | expires_at: "2099-12-31 23:59:59", |
| 429 | 435 | }); |
| 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); | |
| 432 | 438 | expect(used(once)).toBe(1); |
| 433 | 439 | expect(rows(once)).toBe(1); |
| 434 | 440 | |
| @@ -443,11 +449,11 @@ test("a code's limits and expiry are enforced where the use is taken, not only w | ||
| 443 | 449 | starts_at: null, |
| 444 | 450 | expires_at: "2099-12-31 23:59:59", |
| 445 | 451 | }); |
| 446 | expect(discounts.redeem(roomy, orderId, 500, later)).toBe(false); | |
| 452 | expect(discounts.redeem(roomy, orderId, 500, ADA, later)).toBe(false); | |
| 447 | 453 | expect(used(roomy)).toBe(0); |
| 448 | 454 | |
| 449 | 455 | // 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); | |
| 451 | 457 | expect(used(once)).toBe(0); |
| 452 | 458 | expect(rows(once)).toBe(2); |
| 453 | 459 | 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 | ||
| 697 | 703 | }); |
| 698 | 704 | |
| 699 | 705 | 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 }); | |
| 701 | 707 | 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); | |
| 704 | 710 | |
| 705 | 711 | const shown = giftcards.publicEntriesOf(card.id); |
| 706 | 712 | 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) | ||
| 727 | 733 | test("a gateway's amount is compared to what the order owes before anything is called paid", () => { |
| 728 | 734 | // Short: the money that moved is written down, and the order is not paid. |
| 729 | 735 | 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); | |
| 731 | 737 | expect(after.status).toBe("pending_payment"); |
| 732 | 738 | expect(after.paid_at).toBeNull(); |
| 733 | 739 | 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 | ||
| 740 | 746 | |
| 741 | 747 | // Exactly what is owed pays it, and so does more than that. |
| 742 | 748 | 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"); | |
| 744 | 750 | expect(orders.shortfall(orders.byNumber("BZ-EXACT-1")!)).toBeNull(); |
| 745 | 751 | 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"); | |
| 747 | 753 | expect(orders.chargedCents(over.id)).toBe(5100); |
| 748 | 754 | |
| 749 | 755 | // A gateway that names no amount moved no money we can write a row for, and |
| 750 | 756 | // it says so once however many times the same claim is delivered. |
| 751 | 757 | const nothing = pendingOrder("BZ-NOTHING-1", 5000); |
| 752 | 758 | 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); | |
| 754 | 760 | expect(orders.paymentsOf(nothing.id).length).toBe(0); |
| 755 | 761 | expect(shortEvents(nothing.id).length).toBe(1); |
| 756 | 762 | expect(orders.byNumber("BZ-NOTHING-1")!.status).toBe("pending_payment"); |
| 757 | 763 | |
| 758 | 764 | // One key per order: a second arrival is the same charge, not another one. |
| 759 | 765 | 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); | |
| 762 | 768 | expect(orders.paymentsOf(twice.id).length).toBe(1); |
| 763 | 769 | expect(orders.eventsOf(twice.id).filter((event) => event.kind === "paid").length).toBe(1); |
| 764 | 770 | }); |
| @@ -771,16 +777,16 @@ test("the admin's screen is told about a payment only when the order becomes pai | ||
| 771 | 777 | // Short. The ledger says awaiting payment, so a live page that said paid |
| 772 | 778 | // would be the admin's screen lying about the money. |
| 773 | 779 | 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); | |
| 775 | 781 | expect(paidEvents()).toEqual([]); |
| 776 | 782 | |
| 777 | 783 | // Covered: one event, from the branch that moved the row to paid. |
| 778 | 784 | 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); | |
| 780 | 786 | expect(paidEvents()).toEqual([{ type: "order.paid", data: { id: full.id, number: "BZ-FULL-LIVE", cents: 5000 } }]); |
| 781 | 787 | |
| 782 | 788 | // 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); | |
| 784 | 790 | expect(paidEvents().length).toBe(1); |
| 785 | 791 | } finally { |
| 786 | 792 | unsubscribe(); |
| @@ -829,3 +835,45 @@ test("the route table knows which paths a GET answers", () => { | ||
| 829 | 835 | expect(getAnswers("/checkout")).toBe(true); |
| 830 | 836 | expect(getAnswers("/gift-cards")).toBe(true); |
| 831 | 837 | }); |
| 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 | }); |