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

A paid order is dispatched: the buyer sees what happens next and the dispatch is a row #89

Merged opened this

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

Merged

ledger/bazaar:ledger/40-a-paid-order-is-dispatched-the-buyer-see → main 5 commits fork

  • Merged. ledger/40-a-paid-order-is-dispatched-the-buyer-see was merged into main as 5a076e8.

Review

Sign in to review this change.
14 files changed +626 −40
README.md +60 −12
@@ -3,8 +3,9 @@
33 A small shop: a catalog with categories and search, product pages with
44 variants and stock, a cart in a signed cookie, discount codes and gift cards,
55 tax by where the parcel goes, a gift card balance window, checkout with an
6 address, a payment step, receipts, and an `/admin` for products, inventory,
7 codes, cards, tax and orders with refunds. Looking around and filling a cart
6 address, a payment step, receipts, dispatch, and an `/admin` for products,
7 inventory, codes, cards, tax and orders with refunds and dispatch. Looking
8 around and filling a cart
89 need no account; buying needs one, because an order belongs to the account
910 that placed it and `/orders` is that account's list of them. Every price is an
1011 integer number of cents, every form carries a CSRF token, and checkout,
@@ -54,11 +55,18 @@ admin's live pages load one small file — see
5455 - **Simulated unless you configure it**: the payment. With no Stripe keys the
5556 store uses a **mock gateway** — a page labelled as a mock, with Approve and
5657 Decline buttons, that moves no money.
57 - **Never real**: email. There is no mail server. A receipt is composed and
58 - **Never real**: email. There is no mail server. A letter is composed and
5859 stored, shown as an email-style page at `/orders/<number>/receipt` and
59 listed in the admin outbox at `/admin/mail`. Because no receipt is ever
60 delivered, `/orders` is how a shopper gets back to one — see below.
61 - No goods ship. It is a demonstration store, and every page says so.
60 listed in the admin outbox at `/admin/mail`. There are two letters about an
61 order and no more — the receipt when it is paid, and "on its way" when it is
62 dispatched — each written once and never rewritten. Because no letter is
63 ever delivered, `/orders` is how a shopper gets back to one — see below.
64 - **Never real**: the parcel. **No goods ship.** Dispatch is an admin saying
65 the parcel went: a date on the order, a row in its history and a letter in
66 the outbox. Nothing is handed to a carrier and no tracking reference is
67 looked up anywhere — whatever is typed in that box is stored and shown back,
68 and that is the whole of it. It is a demonstration store, and every page
69 says so.
6270
6371 ## Stock, and who gets the last one
6472
@@ -335,6 +343,45 @@ return and a webhook that pay less than the order owes, a refund, Stripe
335343 answering 500, Stripe stopped, and a live key refused with no request made at
336344 all. No test in this repository talks to the network.
337345
346 ## After it is paid: dispatch
347
348 An order's life used to end at `paid`. It has one more step, and the step is
349 a column and a row rather than a state machine:
350
351 - `order.fulfilled_at` — added with `addColumn`, null on every order placed
352 before it existed, which is the truth about them: nobody wrote down that
353 they went. `status` goes on meaning what it meant. It is about the money —
354 `paid`, `partially_refunded`, `refunded` — and `fulfilled_at` is about the
355 goods, because those are two different facts: a refunded order may well have
356 gone out, and a paid one may not have.
357 - One `order_event` of kind `dispatched`, naming the admin who did it from the
358 actor columns every ledger row here carries, with the carrier and the
359 tracking reference in its detail. It shows up in the order's history, in
360 `/admin/audit` and in the terminal's audit tab like any other row.
361 - One letter in the outbox: "your order is on its way", with the tracking
362 reference on it. The receipt already written is never touched — `email.kind`
363 is what tells the two apart.
364
365 `POST /admin/orders/<number>/fulfil` takes a carrier and a tracking reference,
366 both optional and both capped at the boundary; it is admin-only and
367 same-origin like every other admin POST. Only a paid order goes: the `UPDATE`
368 carries the rule in its `WHERE`, so two admins clicking at once write one row,
369 one letter and one date between them, and the second is told it had already
370 gone. Anything that is not paid is refused in words, and the refusal is itself
371 a row on the order.
372
373 Cancelling an order that has been dispatched is refused in words — it can be
374 refunded, but not cancelled — and refunding one works exactly as before, with
375 restocking still the choice the admin makes.
376
377 The buyer sees the order's life in three plain steps on their own order page:
378 **placed**, **paid**, **dispatched**, each with its date or a dash, and under
379 them what happens next in words — pay for it, wait for the shop, or nothing,
380 because nothing ships. The tracking reference is shown there, escaped like
381 every other value. No JavaScript and no new dependency for any of it.
382 `/admin/orders` and the terminal's orders tab tell dispatched from paid at a
383 glance, and `d` at the terminal does the same job as the form.
384
338385 ## Environment
339386
340387 | Variable | Meaning |
@@ -410,9 +457,9 @@ and an anonymous keyboard cannot open the tab at all.
410457 `desk.ordersFor(who, filter)` is the one place those rows are chosen — a
411458 function from `Who` to rows, so the rule is tested without a pty.
412459
413 Every key that does anything — moving stock, refunding, writing a code,
414 issuing or voiding a card, setting a rate — is an admin's, and so are the two
415 keys that only read something out: `c`, which prints a balance, and `h`, which
460 Every key that does anything — moving stock, dispatching, refunding, writing a
461 code, issuing or voiding a card, setting a rate — is an admin's, and so are the
462 two keys that only read something out: `c`, which prints a balance, and `h`, which
416463 prints the stock ledger. The exception is `/`, which narrows the rows already
417464 in front of whoever pressed it: it reaches no further than they can already
418465 see, so the buyer at the keyboard may press it on their own orders.
@@ -465,8 +512,9 @@ what it wrote, and the page listens.
465512 - `emit` is called where a ledger row is written and nowhere else: an order
466513 placed, an order paid — through the gateway, through a gift card that
467514 covered the lot, or through Stripe's webhook, which all end in one
468 `settleFromGateway` — a refund, a cancellation, and a stock movement. Only
469 the branch that actually moves the row to `paid` says `order.paid`: a
515 `settleFromGateway` — a refund, a cancellation, a dispatch, and a stock
516 movement. Only the branch that actually moves the row to `paid` says
517 `order.paid`: a
470518 charge that falls short of what the order owes is written to the ledger and
471519 leaves the order awaiting payment, and the admin's screen is told nothing,
472520 because a screen that said paid there would be lying about the money. Every call
@@ -609,7 +657,7 @@ they are on:
609657 | Tab | Keys |
610658 | --- | --- |
611659 | stock | `s` moves stock with a reason, `h` shows the last of the ledger |
612 | orders | the orders you may see — your account's, or the shop's for an admin. `/` narrows them by number, name or email, and `r` (an admin's) refunds what is left of one and puts it back on the shelf |
660 | orders | the orders you may see — your account's, or the shop's for an admin. `/` narrows them by number, name or email; `d` (an admin's) dispatches a paid one (`CARRIER / TRACKING`, either may be left out), and `r` (an admin's) refunds what is left of one and puts it back on the shelf. A row says whether it has been dispatched, and the open row says when and with what |
613661 | 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 |
614662 | 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 |
615663 | 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 |
src/admin.ts +36
@@ -34,6 +34,10 @@ const MESSAGES: Record<string, { text: string; bad?: boolean }> = {
3434 stocked: { text: "Stock moved and written to the ledger." },
3535 refunded: { text: "Refunded." },
3636 cancelled: { text: "Order cancelled and the stock put back." },
37 dispatched: { text: "Dispatched. The buyer's page says so, and a letter is in the outbox." },
38 "already-dispatched": { text: "This order had already gone out. Nothing was written." },
39 "not-dispatched": { text: "Only a paid order is dispatched.", bad: true },
40 "gone-already": { text: "This order has been dispatched: it can be refunded, but not cancelled.", bad: true },
3741 "refund-failed": { text: "The gateway refused the refund. Nothing was recorded.", bad: true },
3842 "bad-amount": { text: "That amount is not a number of dollars and cents.", bad: true },
3943 "bad-price": { text: "That price is not a number of dollars and cents.", bad: true },
@@ -377,6 +381,7 @@ export function orderDetail(ctx: Ctx): Response {
377381 ctx.session,
378382 messageFrom(ctx),
379383 orders.shortfall(order),
384 orders.dispatchOf(order),
380385 ),
381386 );
382387 }
@@ -424,11 +429,42 @@ export async function orderRefund(ctx: Ctx): Promise<Response> {
424429 return redirect(`/admin/orders/${order.number}?m=refunded`);
425430 }
426431
432 /**
433 * The parcel goes out. A carrier and a tracking reference are both optional —
434 * plenty of parcels have neither — and both are capped here, at the boundary,
435 * before anything is written. The work and its refusals are in src/orders.ts;
436 * this reads the form and says in words which of them happened.
437 */
438 export function orderFulfil(ctx: Ctx): Response {
439 const blocked = guard(ctx);
440 if (blocked) return blocked;
441 const order = orders.byNumber((ctx.params.number ?? "").slice(0, 20));
442 if (!order) return notFound(ctx);
443 const done = orders.fulfil({
444 order,
445 carrier: field(ctx.form, "carrier", orders.CARRIER_MAX),
446 tracking: field(ctx.form, "tracking", orders.TRACKING_MAX),
447 by: actor(ctx),
448 });
449 if (!done.ok) {
450 orders.note(order.id, "dispatch-refused", actor(ctx), done.error);
451 return redirect(`/admin/orders/${order.number}?m=not-dispatched`);
452 }
453 return redirect(`/admin/orders/${order.number}?m=${done.dispatched ? "dispatched" : "already-dispatched"}`);
454 }
455
456 /**
457 * Cancelling is for an order that never went anywhere. One that has been
458 * dispatched is refused in words rather than quietly doing nothing: the money
459 * on it goes back through the refund above, where restocking stays the choice
460 * the admin already makes.
461 */
427462 export function orderCancel(ctx: Ctx): Response {
428463 const blocked = guard(ctx);
429464 if (blocked) return blocked;
430465 const order = orders.byNumber((ctx.params.number ?? "").slice(0, 20));
431466 if (!order) return notFound(ctx);
467 if (order.fulfilled_at !== null) return redirect(`/admin/orders/${order.number}?m=gone-already`);
432468 orders.cancel(order, field(ctx.form, "reason", 200) || "cancelled in admin", actor(ctx));
433469 return redirect(`/admin/orders/${order.number}?m=cancelled`);
434470 }
src/db.ts +13
@@ -277,6 +277,17 @@ function migrate(handle: Database): void {
277277 addColumn(handle, "order", "buyer_name", "TEXT NOT NULL DEFAULT ''");
278278 handle.exec('CREATE INDEX IF NOT EXISTS order_buyer ON "order"(buyer_id, id DESC)');
279279
280 // When the parcel went out. Dispatch is a column and an `order_event` row,
281 // not a status: `status` says where the money stands — paid, refunded,
282 // partially refunded — and this says where the goods are. An order paid and
283 // out of the door before this column existed says NULL, which is the truth
284 // about it: nobody wrote down that it went.
285 addColumn(handle, "order", "fulfilled_at", "TEXT");
286 // What a letter in the outbox is. The receipt is written once when an order
287 // is paid and never rewritten; the dispatch letter is a second letter about
288 // the same order, so a row has to say which of the two it is.
289 addColumn(handle, "email", "kind", "TEXT NOT NULL DEFAULT 'receipt'");
290
280291 // Who caused the row. The ledgers already said what happened and why; these
281292 // three columns say whose hand it was — see src/actor.ts for the five kinds
282293 // of hand there are. The rows written before the column tell the truth about
@@ -398,6 +409,8 @@ export type Order = {
398409 idempotency_key: string;
399410 created_at: string;
400411 paid_at: string | null;
412 /** When it was dispatched, or null while it is still with the shop. */
413 fulfilled_at: string | null;
401414 };
402415 export type OrderItem = {
403416 id: number;
src/desk.ts +21
@@ -82,6 +82,7 @@ const DESK: Record<Tab, { read: "anyone" | "own" | "admin"; keys: Offer[] }> = {
8282 read: "own",
8383 keys: [
8484 { key: "/", label: "/ find", press: "reader" },
85 { key: "d", label: "d dispatch", press: "admin" },
8586 { key: "r", label: "r refund the rest and restock", press: "admin" },
8687 ],
8788 },
@@ -311,6 +312,26 @@ export function look(typed: string): Done {
311312 return no("That is not a code this shop knows.");
312313 }
313314
315 export const DISPATCH_SHAPE = "CARRIER / TRACKING, either may be left out";
316
317 /**
318 * "Royal Mail / 1Z999" — the parcel goes out, with whatever the shop knows
319 * about where it went. Both halves are optional, and the refusals are the
320 * page's, because they are the same rule: only a paid order goes, and it
321 * goes once.
322 */
323 export function dispatch(order: Order | undefined, line: string): Done {
324 if (!order) return no("No order selected.");
325 const [first, second] = line.split("/");
326 const carrier = (first ?? "").trim();
327 const tracking = (second ?? "").trim();
328 const done = orders.fulfil({ order, carrier, tracking, by: actors.atDesk() });
329 if (!done.ok) return no(done.error);
330 if (!done.dispatched) return no(`${order.number} had already gone out.`);
331 const said = orders.describeDispatch(carrier, tracking);
332 return yes(`${order.number} is dispatched${said === "" ? "" : ` — ${said}`}, and the buyer has a letter.`);
333 }
334
314335 /**
315336 * What a gift card paid on an order, in one line, or "" when no card was on
316337 * it. A total of $25.00 against a payment of $0.50 is not a mistake when a
src/mail.ts +60 −11
@@ -1,8 +1,13 @@
11 /**
22 * Mail, written rather than sent. This app has no mail server and pretends to
3 * have none: a receipt is composed as plain text, stored in the `email` table
3 * have none: a letter is composed as plain text, stored in the `email` table
44 * and shown as an email-style page. /admin/mail is the outbox — what a
55 * customer would have received, exactly as it was written.
6 *
7 * There are two letters about an order and no more: the receipt, written once
8 * when it is paid, and the dispatch letter, written once when it goes out.
9 * Neither is ever rewritten — a letter that was sent cannot be unsent, so a
10 * later fact is a later letter.
611 */
712
813 import { db, type Order, type OrderItem } from "./db.ts";
@@ -11,6 +16,8 @@ import { dueCents, formatCents } from "./money.ts";
1116 export type Email = {
1217 id: number;
1318 order_id: number;
19 /** Which letter this is: the receipt, or the one saying it went out. */
20 kind: "receipt" | "dispatch";
1421 to_address: string;
1522 subject: string;
1623 body: string;
@@ -75,21 +82,63 @@ export function receiptText(order: Order, items: OrderItem[], giftCardCodes: str
7582 ].join("\n");
7683 }
7784
78 export function writeReceipt(order: Order, items: OrderItem[], giftCardCodes: string[] = []): Email {
79 return db
85 const write = (order: Order, kind: Email["kind"], subject: string, body: string): Email =>
86 db
8087 .query<Email, any>(
81 "INSERT INTO email (order_id, to_address, subject, body) VALUES ($order_id, $to_address, $subject, $body) RETURNING *",
88 `INSERT INTO email (order_id, kind, to_address, subject, body)
89 VALUES ($order_id, $kind, $to_address, $subject, $body) RETURNING *`,
8290 )
83 .get({
84 $order_id: order.id,
85 $to_address: order.email,
86 $subject: `Your bazaar order ${order.number}`,
87 $body: receiptText(order, items, giftCardCodes),
88 })!;
91 .get({ $order_id: order.id, $kind: kind, $to_address: order.email, $subject: subject, $body: body })!;
92
93 export function writeReceipt(order: Order, items: OrderItem[], giftCardCodes: string[] = []): Email {
94 return write(order, "receipt", `Your bazaar order ${order.number}`, receiptText(order, items, giftCardCodes));
8995 }
9096
97 /**
98 * The second letter: the parcel is out of the door. It says what the shop
99 * handed it to and under what reference, and it is honest that nothing
100 * physically ships from a demonstration store.
101 */
102 export function dispatchText(order: Order, items: OrderItem[], carrier: string, tracking: string): string {
103 return [
104 `Your order ${order.number} is on its way.`,
105 "",
106 ...items.map((item) => ` ${item.product_title} — ${item.variant_name} x${item.quantity}`),
107 "",
108 carrier === "" ? " Carrier not given" : ` Carrier ${carrier}`,
109 tracking === "" ? " Tracking none for this parcel" : ` Tracking ${tracking}`,
110 "",
111 "Going to",
112 ` ${order.name}`,
113 ` ${order.address1}`,
114 ...(order.address2 ? [` ${order.address2}`] : []),
115 ` ${order.city}${order.region ? ", " + order.region : ""} ${order.postal}`,
116 ` ${order.country}`,
117 "",
118 `Your receipt is unchanged, and the order page shows the same thing: ${order.number}.`,
119 "This is a demonstration store: nothing ships, and no money moves.",
120 ].join("\n");
121 }
122
123 export const writeDispatch = (order: Order, items: OrderItem[], carrier: string, tracking: string): Email =>
124 write(
125 order,
126 "dispatch",
127 `Your bazaar order ${order.number} is on its way`,
128 dispatchText(order, items, carrier, tracking),
129 );
130
131 /** The receipt for an order: the letter written when it was paid. */
91132 export const forOrder = (orderId: number): Email | null =>
92 db.query<Email, [number]>("SELECT * FROM email WHERE order_id = ?1 ORDER BY id DESC LIMIT 1").get(orderId);
133 db
134 .query<Email, [number]>("SELECT * FROM email WHERE order_id = ?1 AND kind = 'receipt' ORDER BY id DESC LIMIT 1")
135 .get(orderId);
136
137 /** The dispatch letter, if one has been written. There is at most one. */
138 export const dispatchFor = (orderId: number): Email | null =>
139 db
140 .query<Email, [number]>("SELECT * FROM email WHERE order_id = ?1 AND kind = 'dispatch' ORDER BY id DESC LIMIT 1")
141 .get(orderId);
93142
94143 export const outbox = (limit = 50): (Email & { number: string })[] =>
95144 db
src/orders.ts +91 −3
@@ -25,9 +25,10 @@ import { db, type ActorColumns, type Order, type OrderItem, type Payment } from
2525 import { addStock, stockOf, takeStock } from "./inventory.ts";
2626 import { dueCents, sumLines } from "./money.ts";
2727 import { emit } from "./live.ts";
28 import * as mail from "./mail.ts";
2829 import { LINE_GONE, onlyLeft, outOfStock, quote, type CartLine, type Quote } from "./cart.ts";
29 import { writeReceipt } from "./mail.ts";
3030 import type { Applied } from "./codes.ts";
31 import type { Email } from "./mail.ts";
3132
3233 export type Address = {
3334 email: string;
@@ -484,12 +485,99 @@ function settle(order: Order): Order {
484485 // The cards an order bought were caused by whoever bought it, not by the
485486 // gateway that happened to say the money arrived.
486487 const issued = giftcards.issueForOrder(order, items, actors.buyerOf(order));
487 if (!hasReceipt(order.id)) writeReceipt(order, items, issued.map((one) => one.code));
488 if (!hasReceipt(order.id)) mail.writeReceipt(order, items, issued.map((one) => one.code));
488489 return order;
489490 }
490491
491492 const hasReceipt = (orderId: number): boolean =>
492 db.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM email WHERE order_id = ?1").get(orderId)!.n > 0;
493 db
494 .query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM email WHERE order_id = ?1 AND kind = 'receipt'")
495 .get(orderId)!.n > 0;
496
497 /**
498 * Dispatch: the goods leave, and that is a column and a row.
499 *
500 * `status` is about the money and stays about the money — paid, refunded and
501 * partially refunded mean exactly what they meant. Where the parcel is, is
502 * `fulfilled_at` and one `order_event` naming whoever sent it. There is no
503 * table of transitions here because there is nothing to transition: an order
504 * is dispatched once, and a second click is told that it already was.
505 *
506 * Only a paid order goes. The UPDATE carries the whole rule in its WHERE, so
507 * two admins clicking together write one row between them and one letter:
508 * whoever moved the row writes the event, the letter and the emit, and the
509 * other is told it had already gone.
510 */
511 export type Dispatch = { at: string; detail: string };
512
513 export type FulfilResult =
514 | { ok: true; order: Order; dispatched: boolean; email: Email | null }
515 | { ok: false; error: string };
516
517 /** How long a carrier's name and a tracking reference may be: enough, no more. */
518 export const CARRIER_MAX = 60;
519 export const TRACKING_MAX = 80;
520
521 /** The dispatch in the words the event carries; empty when neither was given. */
522 export const describeDispatch = (carrier: string, tracking: string): string =>
523 carrier === "" && tracking === ""
524 ? ""
525 : tracking === ""
526 ? carrier
527 : carrier === ""
528 ? `tracking ${tracking}`
529 : `${carrier}, tracking ${tracking}`;
530
531 /**
532 * When the parcel went and what was said about it, for the buyer's page and
533 * the admin's. The carrier and the tracking are the event's words rather than
534 * two more columns: nothing in this shop computes with them, and the one row
535 * that names who sent it is where they belong.
536 */
537 export function dispatchOf(order: Order): Dispatch | null {
538 if (order.fulfilled_at === null) return null;
539 const event = db
540 .query<{ detail: string }, [number]>(
541 "SELECT detail FROM order_event WHERE order_id = ?1 AND kind = 'dispatched' ORDER BY id DESC LIMIT 1",
542 )
543 .get(order.id);
544 return { at: order.fulfilled_at, detail: event?.detail ?? "" };
545 }
546
547 export function fulfil(input: { order: Order; carrier: string; tracking: string; by: Actor }): FulfilResult {
548 const carrier = input.carrier.slice(0, CARRIER_MAX);
549 const tracking = input.tracking.slice(0, TRACKING_MAX);
550
551 const sent = db.transaction(() => {
552 const fresh = byId(input.order.id)!;
553 if (fresh.fulfilled_at !== null) return { moved: false, refused: "", order: fresh };
554 if (fresh.status !== "paid")
555 return {
556 moved: false,
557 refused:
558 fresh.status === "pending_payment"
559 ? "This order is not paid, so nothing goes out."
560 : `An order that is ${fresh.status.replace(/_/g, " ")} is not dispatched.`,
561 order: fresh,
562 };
563 const changed = db
564 .query(
565 `UPDATE "order" SET fulfilled_at = datetime('now')
566 WHERE id = ?1 AND status = 'paid' AND fulfilled_at IS NULL`,
567 )
568 .run(fresh.id).changes;
569 if (changed === 1) note(fresh.id, "dispatched", input.by, describeDispatch(carrier, tracking));
570 return { moved: changed === 1, refused: "", order: byId(fresh.id)! };
571 })();
572
573 if (sent.refused !== "") return { ok: false, error: sent.refused };
574 if (!sent.moved) return { ok: true, order: sent.order, dispatched: false, email: mail.dispatchFor(sent.order.id) };
575 // Committed: the parcel has gone as far as this shop is concerned, so this
576 // is where the letter is written and where the admin's screen hears of it.
577 const email = mail.writeDispatch(sent.order, itemsOf(sent.order.id), carrier, tracking);
578 emit("order.fulfilled", { id: sent.order.id, number: sent.order.number });
579 return { ok: true, order: sent.order, dispatched: true, email };
580 }
493581
494582 export type RefundResult = { ok: true; payment: Payment | null; order: Order } | { ok: false; error: string };
495583
src/pages/admin.ts +28 −9
@@ -14,7 +14,7 @@
1414 import { csrfField, escape, variantRowStyle } from "../html.ts";
1515 import { FORGE_LINK_ATTRS, SIGN_OUT_HREF, signInHref } from "../who.ts";
1616 import { formatCents } from "../money.ts";
17 import { itemsTable, orderTotals, statusPill } from "./checkout.ts";
17 import { dispatchPill, itemsTable, orderTotals, statusPill } from "./checkout.ts";
1818 import { describe } from "../discounts.ts";
1919 import { href, nameOf, type Reading } from "../changes.ts";
2020 import type { Balance, Entry } from "../audit.ts";
@@ -24,10 +24,10 @@ import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant }
2424 import type { StockEntry } from "../inventory.ts";
2525 import type { Email } from "../mail.ts";
2626 import type { Session } from "../session.ts";
27 import type { OrderEvent, Shortfall } from "../orders.ts";
27 import type { Dispatch, OrderEvent, Shortfall } from "../orders.ts";
2828
2929 /** What the ledger emits about an order, and about a stock movement. */
30 const ORDER_EVENTS = "order.placed order.paid order.refunded order.cancelled";
30 const ORDER_EVENTS = "order.placed order.paid order.refunded order.cancelled order.fulfilled";
3131 const STOCK_EVENTS = "stock.moved";
3232
3333 /**
@@ -123,7 +123,7 @@ export function orderRows(orders: Order[]): string {
123123 // Fixed columns: a row arriving at the top cannot move the ones below it
124124 // sideways, whatever length the name in it is.
125125 return `<table class="steady">
126 <colgroup><col style="width:20%"><col style="width:22%"><col style="width:28%"><col style="width:15%"><col style="width:15%"></colgroup>
126 <colgroup><col style="width:18%"><col style="width:20%"><col style="width:26%"><col style="width:21%"><col style="width:15%"></colgroup>
127127 <thead><tr><th>Order</th><th>Placed</th><th>Customer</th><th>Status</th><th class="num">Total</th></tr></thead>
128128 <tbody>${orders
129129 .map(
@@ -133,7 +133,7 @@ export function orderRows(orders: Order[]): string {
133133 <td>${escape(order.name)}<div class="muted" style="font-size:.85rem">${escape(order.email)} · ${account(
134134 order,
135135 )}</div></td>
136 <td>${statusPill(order.status)}</td>
136 <td>${statusPill(order.status)}${dispatchPill(order)}</td>
137137 <td class="num price">${escape(formatCents(order.total_cents))}</td>
138138 </tr>`,
139139 )
@@ -172,12 +172,14 @@ export function order(
172172 session: Session,
173173 message?: { text: string; bad?: boolean },
174174 short: Shortfall | null = null,
175 dispatch: Dispatch | null = null,
175176 ): string {
176177 // Everything below the nav is one region keyed to this order's number: the
177 // status, the totals, what is left to refund, the payments and the history
178 // all move together when this order is paid, refunded or cancelled, and a
179 // page watching a payment settle does not have to be reloaded to see it.
180 const detail = `<h1>${escape(found.number)} ${statusPill(found.status)}</h1>
178 // status, the totals, what is left to refund, the dispatch, the payments and
179 // the history all move together when this order is paid, refunded, cancelled
180 // or sent, and a page watching a payment settle does not have to be reloaded
181 // to see it.
182 const detail = `<h1>${escape(found.number)} ${statusPill(found.status)}${dispatchPill(found)}</h1>
181183 <p class="lede">${escape(found.name)} · ${escape(found.email)} · ${account(found)} · ${escape(found.gateway)}${
182184 found.payment_ref ? ` · ${escape(found.payment_ref)}` : ""
183185 }</p>
@@ -211,6 +213,23 @@ ${orderTotals(found, refunded)}
211213 </form>`
212214 : '<p class="muted">Nothing left to refund.</p>'
213215 }
216 <h2>Dispatch</h2>
217 ${
218 dispatch
219 ? `<p>Went out <strong>${escape(dispatch.at)} UTC</strong>${
220 dispatch.detail === "" ? ", with no carrier or tracking given" : ` — ${escape(dispatch.detail)}`
221 }. A letter saying so is in the outbox, and the buyer's page shows it.</p>
222 <p class="muted">Nothing ships from this shop: this is the row that says it did.</p>`
223 : found.status === "paid"
224 ? `<form method="post" action="/admin/orders/${escape(found.number)}/fulfil" class="stack">
225 ${csrfField(session)}
226 <label>Carrier <input name="carrier" maxlength="60" placeholder="Royal Mail, DHL, the shop's own van"></label>
227 <label>Tracking reference <input name="tracking" maxlength="80" placeholder="Left empty when there is none"></label>
228 <div><button class="btn">Mark dispatched</button></div>
229 </form>
230 <p class="muted" style="font-size:.88rem">Both are optional. It happens once, and the buyer is told.</p>`
231 : '<p class="muted">Only a paid order is dispatched.</p>'
232 }
214233 </div>
215234 <div>
216235 <h2>Payments</h2>
src/pages/checkout.ts +57 −1
@@ -13,7 +13,7 @@ import { tokenQuery } from "../orderlink.ts";
1313 import { FORGE_LINK_ATTRS, signInHref } from "../who.ts";
1414 import type { Quote } from "../cart.ts";
1515 import type { Order, OrderItem, Payment } from "../db.ts";
16 import type { Shortfall } from "../orders.ts";
16 import type { Dispatch, Shortfall } from "../orders.ts";
1717 import type { GatewayInfo } from "../gateway.ts";
1818 import type { Email } from "../mail.ts";
1919 import type { Session } from "../session.ts";
@@ -227,6 +227,18 @@ const STATUS_WORDS: Record<string, string> = {
227227 cancelled: "Cancelled",
228228 };
229229
230 /**
231 * Where the goods are, beside where the money is. It is a second pill rather
232 * than a second word in the first one because they are two different facts: a
233 * refunded order may well have gone out, and a paid one may not have.
234 */
235 export const dispatchPill = (order: Pick<Order, "status" | "fulfilled_at">): string =>
236 order.fulfilled_at !== null
237 ? ' <span class="pill ok">Dispatched</span>'
238 : order.status === "paid"
239 ? ' <span class="pill">Not dispatched</span>'
240 : "";
241
230242 export const statusPill = (status: string): string =>
231243 `<span class="pill ${status === "paid" ? "ok" : status === "pending_payment" ? "" : "warn"}">${escape(
232244 STATUS_WORDS[status] ?? status,
@@ -244,6 +256,48 @@ export const shortPayment = (order: Order, short: Shortfall): string =>
244256 The order is not marked paid and nothing has shipped. The shop is looking at it; nothing more
245257 has been taken, and if you need to talk to someone, quote ${escape(order.number)}.</p>`;
246258
259 /**
260 * The order's life in three plain steps: placed, paid, dispatched. It is a
261 * list and not a diagram, and it says what is next in words rather than
262 * leaving a shopper to work it out from a status. Three steps because there
263 * are three facts — `created_at`, `paid_at`, `fulfilled_at` — and no fourth.
264 */
265 export function life(order: Order, dispatch: Dispatch | null): string {
266 const cancelled = order.status === "cancelled";
267 const step = (title: string, at: string | null, words: string): string =>
268 `<li${at === null ? "" : ' class="done"'}>
269 <div class="what">${escape(title)}</div>
270 <div class="muted" style="font-size:.85rem">${at === null ? "—" : `${escape(at)} UTC`}</div>
271 <div style="font-size:.9rem">${escape(words)}</div>
272 </li>`;
273 const next = cancelled
274 ? "This order was cancelled, so nothing more happens to it."
275 : order.paid_at === null
276 ? "Next: pay for it. Nothing is packed and nothing is taken until then."
277 : dispatch === null
278 ? "Next: the shop packs it and marks it dispatched. This page will say so, with the tracking reference if there is one."
279 : "That is the whole of it. Nothing physically ships — this is a demonstration store.";
280 return `<h2>Where this order is</h2>
281 <ol class="steps">
282 ${step("Placed", order.created_at, `Order ${order.number} was written down.`)}
283 ${step(
284 "Paid",
285 order.paid_at,
286 order.paid_at === null ? (cancelled ? "Never paid." : "Waiting for payment.") : "The money is in.",
287 )}
288 ${step(
289 "Dispatched",
290 dispatch?.at ?? null,
291 dispatch === null
292 ? "Still with the shop."
293 : dispatch.detail === ""
294 ? "No carrier or tracking was given."
295 : dispatch.detail,
296 )}
297 </ol>
298 <p class="muted">${escape(next)}</p>`;
299 }
300
247301 export function confirmation(
248302 order: Order,
249303 items: OrderItem[],
@@ -251,6 +305,7 @@ export function confirmation(
251305 email: Email | null,
252306 who: Who,
253307 short: Shortfall | null = null,
308 dispatch: Dispatch | null = null,
254309 ): string {
255310 const paid = order.status === "paid" || order.status === "refunded" || order.status === "partially_refunded";
256311 // A card's code is printed once, on the receipt. Say so here: a buyer who
@@ -284,6 +339,7 @@ ${
284339 and <a href="/gift-cards">/gift-cards</a> says what is left on one.</p>`
285340 : ""
286341 }
342 ${life(order, dispatch)}
287343 ${itemsTable(items)}
288344 ${orderTotals(order, refunded)}
289345 <h2>Shipping to</h2>
src/routes.ts +1
@@ -72,6 +72,7 @@ export const routes: Route[] = [
7272 { method: "GET", pattern: "/admin/orders", handler: admin.orderList },
7373 { method: "GET", pattern: "/admin/orders/:number", handler: admin.orderDetail },
7474 { method: "POST", pattern: "/admin/orders/:number/refund", handler: admin.orderRefund },
75 { method: "POST", pattern: "/admin/orders/:number/fulfil", handler: admin.orderFulfil },
7576 { method: "POST", pattern: "/admin/orders/:number/cancel", handler: admin.orderCancel },
7677 { method: "GET", pattern: "/admin/mail", handler: admin.mailbox },
7778 { method: "GET", pattern: "/admin/changes", handler: admin.changeList },
src/server.test.ts +170
@@ -1406,6 +1406,126 @@ test("/admin opens for an admin the forge signed, and refunds and restocks", asy
14061406 expect(await admin.page("/admin/mail")).toContain(`Your bazaar order ${number}`);
14071407 });
14081408
1409 /** How many rows of one kind an order's history holds. */
1410 const eventsNamed = (orderId: number, kind: string): number =>
1411 db
1412 .query<{ n: number }, [number, string]>("SELECT COUNT(*) AS n FROM order_event WHERE order_id = ?1 AND kind = ?2")
1413 .get(orderId, kind)!.n;
1414
1415 /** And how many letters of one kind were written about it. */
1416 const lettersNamed = (orderId: number, kind: string): number =>
1417 db
1418 .query<{ n: number }, [number, string]>("SELECT COUNT(*) AS n FROM email WHERE order_id = ?1 AND kind = ?2")
1419 .get(orderId, kind)!.n;
1420
1421 test("a paid order is dispatched: one column, one row, one letter, and the buyer is told", async () => {
1422 limit.forget();
1423 const shopper = asCustomer();
1424 const { number } = await buy(shopper, "COF-GUJI-250", 1);
1425 const admin = asAdmin();
1426 expect(orderNamed(number).fulfilled_at).toBeNull();
1427 expect(await admin.page("/admin/orders")).toContain("Not dispatched");
1428
1429 const waiting = await admin.page(`/admin/orders/${number}`);
1430 expect(waiting).toContain(`/admin/orders/${number}/fulfil`);
1431 const csrf = Shopper.tokenIn(waiting);
1432 // A tracking reference is whatever the carrier calls it, "<" and all.
1433 const sent = await admin.post(
1434 `/admin/orders/${number}/fulfil`,
1435 { carrier: "Royal Mail", tracking: "1Z<999" },
1436 csrf,
1437 );
1438 expect(sent.headers.get("location")).toContain("m=dispatched");
1439
1440 const order = orderNamed(number);
1441 expect(order.fulfilled_at).not.toBeNull();
1442 // Where the money is did not move: paid still means paid.
1443 expect(order.status).toBe("paid");
1444 expect(eventsNamed(order.id, "dispatched")).toBe(1);
1445 expect(lettersNamed(order.id, "dispatch")).toBe(1);
1446
1447 // The admin who did it is on the row, from the actor columns and nowhere else.
1448 const event = db
1449 .query<any, [number]>("SELECT * FROM order_event WHERE order_id = ?1 AND kind = 'dispatched'")
1450 .get(order.id)!;
1451 expect([event.actor_id, event.actor_name, event.actor_role]).toEqual(["u_1", "ada", "admin"]);
1452 expect(event.detail).toBe("Royal Mail, tracking 1Z<999");
1453
1454 // A second click writes nothing: not a row, not a letter, not a new stamp.
1455 const again = await admin.post(`/admin/orders/${number}/fulfil`, { carrier: "DHL", tracking: "other" }, csrf);
1456 expect(again.headers.get("location")).toContain("m=already-dispatched");
1457 expect(eventsNamed(order.id, "dispatched")).toBe(1);
1458 expect(lettersNamed(order.id, "dispatch")).toBe(1);
1459 expect(orderNamed(number).fulfilled_at).toBe(order.fulfilled_at);
1460
1461 // The receipt written when it was paid is not rewritten; the outbox has both.
1462 expect(lettersNamed(order.id, "receipt")).toBe(1);
1463 const outbox = await admin.page("/admin/mail");
1464 expect(outbox).toContain(`Your bazaar order ${number} is on its way`);
1465 expect(outbox).toContain("1Z&lt;999");
1466
1467 // The buyer's page: three steps, what is next in words, and the reference
1468 // escaped — it reached the page as text and never as markup.
1469 const theirs = await shopper.page(`/orders/${number}`);
1470 expect(theirs).toContain("Where this order is");
1471 expect(theirs).toContain("Dispatched");
1472 expect(theirs).toContain("1Z&lt;999");
1473 expect(theirs).not.toContain("1Z<999");
1474 expect(theirs).toContain("Nothing physically ships");
1475
1476 // Dispatched is told from paid at a glance, on the list and on the order.
1477 expect(await admin.page("/admin/orders")).toContain('<span class="pill ok">Dispatched</span>');
1478 const gone = await admin.page(`/admin/orders/${number}`);
1479 expect(gone).toContain("Went out");
1480 expect(gone).toContain("Royal Mail, tracking 1Z&lt;999");
1481 // The form is offered once and not twice.
1482 expect(gone).not.toContain(`/admin/orders/${number}/fulfil`);
1483
1484 // Cancelling one that has gone is refused in words.
1485 const cancelled = await admin.post(`/admin/orders/${number}/cancel`, { reason: "changed my mind" }, csrf);
1486 expect(cancelled.headers.get("location")).toContain("m=gone-already");
1487 expect(orderNamed(number).status).toBe("paid");
1488 expect(await admin.page(`/admin/orders/${number}?m=gone-already`)).toContain(
1489 "it can be refunded, but not cancelled",
1490 );
1491
1492 // Refunding one still works, restocking still the choice the admin makes.
1493 const refunded = await admin.post(
1494 `/admin/orders/${number}/refund`,
1495 { amount: (order.total_cents / 100).toFixed(2), reason: "it came back", restock: "1" },
1496 csrf,
1497 );
1498 expect(refunded.headers.get("location")).toContain("m=refunded");
1499 expect(orderNamed(number).status).toBe("refunded");
1500 // And it does not un-dispatch it: the goods went, whatever the money did.
1501 expect(orderNamed(number).fulfilled_at).toBe(order.fulfilled_at);
1502 });
1503
1504 test("an unpaid order is not dispatched, and dispatching is an admin's", async () => {
1505 limit.forget();
1506 const shopper = asCustomer();
1507 await addToCart(shopper, "COF-GUJI-250", 1);
1508 const placed = await submitCheckout(shopper);
1509 const { number } = orderAt(placed.headers.get("location")!);
1510 const unpaid = orderNamed(number);
1511
1512 const admin = asAdmin();
1513 const csrf = Shopper.tokenIn(await admin.page(`/admin/orders/${number}`));
1514 const refused = await admin.post(`/admin/orders/${number}/fulfil`, { carrier: "Royal Mail" }, csrf);
1515 expect(refused.headers.get("location")).toContain("m=not-dispatched");
1516 expect(orderNamed(number).fulfilled_at).toBeNull();
1517 expect(eventsNamed(unpaid.id, "dispatched")).toBe(0);
1518 expect(lettersNamed(unpaid.id, "dispatch")).toBe(0);
1519 // The refusal is on the order's own history, in words.
1520 expect(eventsNamed(unpaid.id, "dispatch-refused")).toBe(1);
1521
1522 // A shopper with a session and their own CSRF token is still not an admin.
1523 const theirCsrf = Shopper.tokenIn(await shopper.page("/cart"));
1524 const forbidden = await shopper.post(`/admin/orders/${number}/fulfil`, { carrier: "mine" }, theirCsrf);
1525 expect(forbidden.status).toBe(403);
1526 expect(orderNamed(number).fulfilled_at).toBeNull();
1527 });
1528
14091529 test("no link to the forge carries a referrer, so the page's query stays here", async () => {
14101530 // Referrer-Policy is per origin and /__forge/* shares ours, so without this
14111531 // the Referer on the way to the sign-in would be the whole current URL —
@@ -2209,6 +2329,56 @@ test("paying and refunding an order reach the stream, once each", async () => {
22092329 stop.abort();
22102330 });
22112331
2332 /**
2333 * Dispatch and the live pages meet here: the goods moving is an event on the
2334 * bus like the money moving, and the page about one order is keyed to that
2335 * order. So the event has to name the order, the region has to ask for the
2336 * type, and the region the script would swap in has to say the parcel went.
2337 */
2338 test("dispatching an order reaches the page about that order", async () => {
2339 limit.forget();
2340 const shopper = asCustomer();
2341 const { number } = await buy(shopper, "COF-GUJI-250", 1);
2342 const admin = asAdmin();
2343
2344 const stop = new AbortController();
2345 const stream = await watch(stop);
2346 const before = await admin.page(`/admin/orders/${number}`);
2347 const region = /id="order-detail" data-live="([^"]+)" data-live-key="([^"]+)"/.exec(before)!;
2348 expect(region[1]).toContain("order.fulfilled");
2349 expect(region[2]).toBe(number);
2350
2351 const sent = await admin.post(
2352 `/admin/orders/${number}/fulfil`,
2353 { carrier: "Royal Mail", tracking: "1Z999" },
2354 Shopper.tokenIn(before),
2355 );
2356 expect(sent.headers.get("location")).toContain("m=dispatched");
2357
2358 const heard = await until(stream, "order.fulfilled");
2359 const events = [...heard.matchAll(/^data: (.*)$/gm)].map((line) => JSON.parse(line[1]!));
2360 const dispatched = events.filter((event) => event.type === "order.fulfilled" && event.data?.number === number);
2361 // Once, and carrying an id and a number: not the carrier, not the tracking
2362 // reference, nothing about the person who bought it.
2363 expect(dispatched).toEqual([{ type: "order.fulfilled", data: { id: orderNamed(number).id, number } }]);
2364 stop.abort();
2365
2366 // The shipped predicate, held up against the shipped region and the event
2367 // that actually travelled: this is the refetch the browser would make.
2368 const { wants } = (await import("../static/live.js")) as {
2369 wants: (element: { dataset: { live: string; liveKey?: string } }, event?: { type: string; data: unknown }) => boolean;
2370 };
2371 expect(wants({ dataset: { live: region[1]!, liveKey: region[2]! } }, dispatched[0])).toBe(true);
2372
2373 // And what it would swap in says the parcel went.
2374 const page = await admin.page(`/admin/orders/${number}`);
2375 const inside = page.slice(page.indexOf('id="order-detail"'));
2376 expect(inside).toContain("Dispatched");
2377 expect(inside).toContain("Went out");
2378 expect(inside).toContain("Royal Mail, tracking 1Z999");
2379 expect(inside).not.toContain("Mark dispatched");
2380 });
2381
22122382 test("the stream is closed to anyone who is not an admin", async () => {
22132383 for (const person of [undefined, { id: "u_2", name: "bo", role: "user" as const }]) {
22142384 const response = await fetch(base + "/admin/events", { headers: identityHeaders(person) });
src/shop.ts +9 −1
@@ -445,7 +445,15 @@ export async function orderPage(ctx: Ctx): Promise<Response> {
445445 return render(
446446 ctx,
447447 { title: `Order ${order.number}` },
448 pages.confirmation(order, orders.itemsOf(order.id), refunded, forOrder(order.id), ctx.who, orders.shortfall(order)),
448 pages.confirmation(
449 order,
450 orders.itemsOf(order.id),
451 refunded,
452 forOrder(order.id),
453 ctx.who,
454 orders.shortfall(order),
455 orders.dispatchOf(order),
456 ),
449457 );
450458 }
451459
src/style.ts +7
@@ -79,6 +79,13 @@ table.steady th, table.steady td { overflow-wrap: anywhere; }
7979 .fields .wide { grid-column: 1 / -1; }
8080 .qty { width: 4.5rem; }
8181 .inline { display: inline; }
82 /* An order's life: placed, paid, dispatched. Three boxes of a fixed shape, so
83 a step that fills in later does not move the page under anybody. */
84 ol.steps { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .75rem; margin: 0 0 1rem; padding: 0; }
85 ol.steps li { border: 1px solid var(--line); border-radius: .7rem; padding: .7rem .85rem; overflow-wrap: anywhere; }
86 ol.steps li.done { border-color: currentColor; color: var(--ok); }
87 ol.steps .what { font-weight: 600; }
88 @media (max-width: 40rem) { ol.steps { grid-template-columns: 1fr; } }
8289 .receipt { max-width: 40rem; margin: 0 auto; border: 1px solid var(--line); border-radius: .9rem; overflow: hidden; }
8390 .receipt .head { padding: 1rem 1.25rem; border-bottom: 1px solid var(--line); font-size: .88rem; color: var(--dim); background: #8881; }
8491 .receipt pre { margin: 0; padding: 1.25rem; white-space: pre-wrap; font: .85rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
src/tty.ts +23 −3
@@ -22,7 +22,8 @@
2222 * the tax table, the changes an admin made to any of it and the audit trail —
2323 * everything /admin can see. j/k move, and each tab says at its foot what it
2424 * 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
25 * number, name or email — or, on the audit tab, the trail to one order — d
26 * sends a paid order out with a carrier and a tracking reference, r
2627 * refunds what is left of an order and puts it back on the shelf, n writes a
2728 * code, i issues a card, e sets a tax rate, c looks a code up. q leaves.
2829 * Plain ANSI, no dependencies.
@@ -37,7 +38,7 @@ import { db } from "./db.ts";
3738 import { chosen } from "./gateway.ts";
3839 import { formatCents } from "./money.ts";
3940 import { history, addStock, setStock } from "./inventory.ts";
40 import { itemsOf, paymentsOf, refund, refundableCents, refundedCents, shortfall } from "./orders.ts";
41 import { dispatchOf, itemsOf, paymentsOf, refund, refundableCents, refundedCents, shortfall } from "./orders.ts";
4142 import { outbox } from "./mail.ts";
4243 import { atDesk } from "./actor.ts";
4344 import { atKeyboard } from "./who.ts";
@@ -182,6 +183,14 @@ function detail(order: Order): string[] {
182183 if (split !== "") lines.push(` ${dim(split)}`);
183184 for (const payment of paymentsOf(order.id))
184185 lines.push(` ${dim(payment.created_at)} ${payment.kind} ${formatCents(payment.amount_cents)} ${dim(payment.gateway_ref)}`);
186 // Where the goods are, beside where the money is: the same two facts the
187 // admin's list shows as two pills.
188 const sent = dispatchOf(order);
189 lines.push(
190 sent === null
191 ? ` ${dim("dispatched")} not yet`
192 : ` ${dim("dispatched")} ${sent.at}${sent.detail === "" ? "" : ` ${sent.detail}`}`,
193 );
185194 // The same fact the admin page shows: what came in did not cover the order.
186195 const short = shortfall(order);
187196 if (short)
@@ -216,7 +225,12 @@ function draw(): void {
216225 const mark = i === cursor ? "▸" : " ";
217226 const text = `${order.number.padEnd(12)} ${order.created_at.padEnd(20)} ${order.name.padEnd(20).slice(0, 20)} ${order.status.padEnd(18)} ${formatCents(
218227 order.total_cents,
219 ).padStart(10)}`;
228 ).padStart(10)} ${(order.fulfilled_at === null
229 ? order.status === "paid"
230 ? "not dispatched"
231 : ""
232 : "dispatched"
233 ).padEnd(14)}`;
220234 lines.push(`${mark} ${i === cursor ? bold(text) : text}`);
221235 if (i === cursor) lines.push(...detail(order));
222236 });
@@ -403,6 +417,12 @@ process.stdin.on("data", (key: string) => {
403417 prompt = { label: "Find an order (number, name or email):", text: filter, run: findOrders };
404418 if (key === "/" && tab === "audit" && may("/"))
405419 prompt = { label: "The trail of one order (its number, or nothing for all):", text: trailFilter, run: findTrail };
420 if (key === "d" && tab === "orders" && may("d"))
421 prompt = {
422 label: `Dispatch (${desk.DISPATCH_SHAPE}):`,
423 text: "",
424 run: (value) => say(desk.dispatch(orderRows()[cursor], value)),
425 };
406426 if (key === "r" && tab === "orders" && may("r")) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest };
407427 if (key === "c" && (tab === "codes" || tab === "cards") && may("c"))
408428 prompt = { label: "Check a code:", text: "", run: (value) => say(desk.look(value)) };
src/units.test.ts +50
@@ -1027,3 +1027,53 @@ test("the audit timeline is one order of rows, whether it is read whole or paged
10271027 expect(audit.readCursor("' OR 1=1 --")).toBeUndefined();
10281028 expect(audit.readCursor("2026-01-01 00:00:00|9|1")).toBeUndefined();
10291029 });
1030
1031 test("the terminal dispatches a paid order, once, and only a paid one", () => {
1032 const paid = orders.settleFromGateway(
1033 pendingOrder("BZ-SENT-1", 5000),
1034 { gateway: "mock", ref: "ref_sent", amount_cents: 5000 },
1035 ADA,
1036 );
1037 // Whoever the forge named at the keyboard is who the row says did it.
1038 const before = { ...process.env };
1039 process.env.FORGE_APP_USER = "bo";
1040 process.env.FORGE_APP_USER_ID = "u_7";
1041 process.env.FORGE_APP_USER_ROLE = "admin";
1042 let done;
1043 try {
1044 done = desk.dispatch(paid, "Royal Mail / 1Z999");
1045 } finally {
1046 if (before.FORGE_APP_USER === undefined) delete process.env.FORGE_APP_USER;
1047 if (before.FORGE_APP_USER_ID === undefined) delete process.env.FORGE_APP_USER_ID;
1048 if (before.FORGE_APP_USER_ROLE === undefined) delete process.env.FORGE_APP_USER_ROLE;
1049 }
1050 expect(done.ok).toBe(true);
1051 expect(done.message).toContain("Royal Mail, tracking 1Z999");
1052
1053 const after = orders.byNumber("BZ-SENT-1")!;
1054 expect(after.fulfilled_at).not.toBeNull();
1055 expect(orders.dispatchOf(after)).toEqual({ at: after.fulfilled_at!, detail: "Royal Mail, tracking 1Z999" });
1056 const event = orders.eventsOf(after.id).find((one) => one.kind === "dispatched")!;
1057 expect([event.actor_id, event.actor_name, event.actor_role]).toEqual(["u_7", "bo", "admin"]);
1058
1059 // A second go writes nothing and says so.
1060 expect(desk.dispatch(after, "DHL / other").ok).toBe(false);
1061 expect(orders.eventsOf(after.id).filter((one) => one.kind === "dispatched").length).toBe(1);
1062 expect(orders.byNumber("BZ-SENT-1")!.fulfilled_at).toBe(after.fulfilled_at);
1063
1064 // An unpaid one does not go at all, and neither does nothing.
1065 const unpaid = pendingOrder("BZ-SENT-2", 5000);
1066 expect(desk.dispatch(unpaid, "Royal Mail / 1Z999").ok).toBe(false);
1067 expect(orders.byNumber("BZ-SENT-2")!.fulfilled_at).toBeNull();
1068 expect(desk.dispatch(undefined, "Royal Mail").ok).toBe(false);
1069
1070 // Both halves are optional, and the words say which were given.
1071 expect(orders.describeDispatch("", "")).toBe("");
1072 expect(orders.describeDispatch("Royal Mail", "")).toBe("Royal Mail");
1073 expect(orders.describeDispatch("", "1Z999")).toBe("tracking 1Z999");
1074
1075 // Dispatching is the desk's work, so it is an admin's key and nobody else's.
1076 expect(desk.mayPress("orders", "d", { role: "admin", id: "u_a", name: "root" })).toBe(true);
1077 expect(desk.mayPress("orders", "d", { role: "user", id: "u_b", name: "nora" })).toBe(false);
1078 expect(desk.offered("orders", { role: "user", id: "u_b", name: "nora" })).not.toContain("d dispatch");
1079 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs