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
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/40-a-paid-order-is-dispatched-the-buyer-see → main 5 commits fork
14 files
changed +626
−40
README.md +60 −12
| @@ -3,8 +3,9 @@ | ||
| 3 | 3 | A small shop: a catalog with categories and search, product pages with |
| 4 | 4 | variants and stock, a cart in a signed cookie, discount codes and gift cards, |
| 5 | 5 | 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 | |
| 8 | 9 | need no account; buying needs one, because an order belongs to the account |
| 9 | 10 | that placed it and `/orders` is that account's list of them. Every price is an |
| 10 | 11 | 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 | ||
| 54 | 55 | - **Simulated unless you configure it**: the payment. With no Stripe keys the |
| 55 | 56 | store uses a **mock gateway** — a page labelled as a mock, with Approve and |
| 56 | 57 | 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 | |
| 58 | 59 | 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. | |
| 62 | 70 | |
| 63 | 71 | ## Stock, and who gets the last one |
| 64 | 72 | |
| @@ -335,6 +343,45 @@ return and a webhook that pay less than the order owes, a refund, Stripe | ||
| 335 | 343 | answering 500, Stripe stopped, and a live key refused with no request made at |
| 336 | 344 | all. No test in this repository talks to the network. |
| 337 | 345 | |
| 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 | ||
| 338 | 385 | ## Environment |
| 339 | 386 | |
| 340 | 387 | | Variable | Meaning | |
| @@ -410,9 +457,9 @@ and an anonymous keyboard cannot open the tab at all. | ||
| 410 | 457 | `desk.ordersFor(who, filter)` is the one place those rows are chosen — a |
| 411 | 458 | function from `Who` to rows, so the rule is tested without a pty. |
| 412 | 459 | |
| 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 | |
| 416 | 463 | prints the stock ledger. The exception is `/`, which narrows the rows already |
| 417 | 464 | in front of whoever pressed it: it reaches no further than they can already |
| 418 | 465 | 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. | ||
| 465 | 512 | - `emit` is called where a ledger row is written and nowhere else: an order |
| 466 | 513 | placed, an order paid — through the gateway, through a gift card that |
| 467 | 514 | 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 | |
| 470 | 518 | charge that falls short of what the order owes is written to the ledger and |
| 471 | 519 | leaves the order awaiting payment, and the admin's screen is told nothing, |
| 472 | 520 | because a screen that said paid there would be lying about the money. Every call |
| @@ -609,7 +657,7 @@ they are on: | ||
| 609 | 657 | | Tab | Keys | |
| 610 | 658 | | --- | --- | |
| 611 | 659 | | 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 | | |
| 613 | 661 | | 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 | |
| 614 | 662 | | 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 | |
| 615 | 663 | | 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 }> = { | ||
| 34 | 34 | stocked: { text: "Stock moved and written to the ledger." }, |
| 35 | 35 | refunded: { text: "Refunded." }, |
| 36 | 36 | 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 }, | |
| 37 | 41 | "refund-failed": { text: "The gateway refused the refund. Nothing was recorded.", bad: true }, |
| 38 | 42 | "bad-amount": { text: "That amount is not a number of dollars and cents.", bad: true }, |
| 39 | 43 | "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 { | ||
| 377 | 381 | ctx.session, |
| 378 | 382 | messageFrom(ctx), |
| 379 | 383 | orders.shortfall(order), |
| 384 | orders.dispatchOf(order), | |
| 380 | 385 | ), |
| 381 | 386 | ); |
| 382 | 387 | } |
| @@ -424,11 +429,42 @@ export async function orderRefund(ctx: Ctx): Promise<Response> { | ||
| 424 | 429 | return redirect(`/admin/orders/${order.number}?m=refunded`); |
| 425 | 430 | } |
| 426 | 431 | |
| 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 | */ | |
| 427 | 462 | export function orderCancel(ctx: Ctx): Response { |
| 428 | 463 | const blocked = guard(ctx); |
| 429 | 464 | if (blocked) return blocked; |
| 430 | 465 | const order = orders.byNumber((ctx.params.number ?? "").slice(0, 20)); |
| 431 | 466 | if (!order) return notFound(ctx); |
| 467 | if (order.fulfilled_at !== null) return redirect(`/admin/orders/${order.number}?m=gone-already`); | |
| 432 | 468 | orders.cancel(order, field(ctx.form, "reason", 200) || "cancelled in admin", actor(ctx)); |
| 433 | 469 | return redirect(`/admin/orders/${order.number}?m=cancelled`); |
| 434 | 470 | } |
src/db.ts +13
| @@ -277,6 +277,17 @@ function migrate(handle: Database): void { | ||
| 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 | 279 | |
| 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 | ||
| 280 | 291 | // Who caused the row. The ledgers already said what happened and why; these |
| 281 | 292 | // three columns say whose hand it was — see src/actor.ts for the five kinds |
| 282 | 293 | // of hand there are. The rows written before the column tell the truth about |
| @@ -398,6 +409,8 @@ export type Order = { | ||
| 398 | 409 | idempotency_key: string; |
| 399 | 410 | created_at: string; |
| 400 | 411 | paid_at: string | null; |
| 412 | /** When it was dispatched, or null while it is still with the shop. */ | |
| 413 | fulfilled_at: string | null; | |
| 401 | 414 | }; |
| 402 | 415 | export type OrderItem = { |
| 403 | 416 | id: number; |
src/desk.ts +21
| @@ -82,6 +82,7 @@ const DESK: Record<Tab, { read: "anyone" | "own" | "admin"; keys: Offer[] }> = { | ||
| 82 | 82 | read: "own", |
| 83 | 83 | keys: [ |
| 84 | 84 | { key: "/", label: "/ find", press: "reader" }, |
| 85 | { key: "d", label: "d dispatch", press: "admin" }, | |
| 85 | 86 | { key: "r", label: "r refund the rest and restock", press: "admin" }, |
| 86 | 87 | ], |
| 87 | 88 | }, |
| @@ -311,6 +312,26 @@ export function look(typed: string): Done { | ||
| 311 | 312 | return no("That is not a code this shop knows."); |
| 312 | 313 | } |
| 313 | 314 | |
| 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 | ||
| 314 | 335 | /** |
| 315 | 336 | * What a gift card paid on an order, in one line, or "" when no card was on |
| 316 | 337 | * 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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 | |
| 4 | 4 | * and shown as an email-style page. /admin/mail is the outbox — what a |
| 5 | 5 | * 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. | |
| 6 | 11 | */ |
| 7 | 12 | |
| 8 | 13 | import { db, type Order, type OrderItem } from "./db.ts"; |
| @@ -11,6 +16,8 @@ import { dueCents, formatCents } from "./money.ts"; | ||
| 11 | 16 | export type Email = { |
| 12 | 17 | id: number; |
| 13 | 18 | order_id: number; |
| 19 | /** Which letter this is: the receipt, or the one saying it went out. */ | |
| 20 | kind: "receipt" | "dispatch"; | |
| 14 | 21 | to_address: string; |
| 15 | 22 | subject: string; |
| 16 | 23 | body: string; |
| @@ -75,21 +82,63 @@ export function receiptText(order: Order, items: OrderItem[], giftCardCodes: str | ||
| 75 | 82 | ].join("\n"); |
| 76 | 83 | } |
| 77 | 84 | |
| 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 | |
| 80 | 87 | .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 *`, | |
| 82 | 90 | ) |
| 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)); | |
| 89 | 95 | } |
| 90 | 96 | |
| 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. */ | |
| 91 | 132 | 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); | |
| 93 | 142 | |
| 94 | 143 | export const outbox = (limit = 50): (Email & { number: string })[] => |
| 95 | 144 | db |
src/orders.ts +91 −3
| @@ -25,9 +25,10 @@ import { db, type ActorColumns, type Order, type OrderItem, type Payment } from | ||
| 25 | 25 | import { addStock, stockOf, takeStock } from "./inventory.ts"; |
| 26 | 26 | import { dueCents, sumLines } from "./money.ts"; |
| 27 | 27 | import { emit } from "./live.ts"; |
| 28 | import * as mail from "./mail.ts"; | |
| 28 | 29 | import { LINE_GONE, onlyLeft, outOfStock, quote, type CartLine, type Quote } from "./cart.ts"; |
| 29 | import { writeReceipt } from "./mail.ts"; | |
| 30 | 30 | import type { Applied } from "./codes.ts"; |
| 31 | import type { Email } from "./mail.ts"; | |
| 31 | 32 | |
| 32 | 33 | export type Address = { |
| 33 | 34 | email: string; |
| @@ -484,12 +485,99 @@ function settle(order: Order): Order { | ||
| 484 | 485 | // The cards an order bought were caused by whoever bought it, not by the |
| 485 | 486 | // gateway that happened to say the money arrived. |
| 486 | 487 | 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)); | |
| 488 | 489 | return order; |
| 489 | 490 | } |
| 490 | 491 | |
| 491 | 492 | 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 | } | |
| 493 | 581 | |
| 494 | 582 | export type RefundResult = { ok: true; payment: Payment | null; order: Order } | { ok: false; error: string }; |
| 495 | 583 |
src/pages/admin.ts +28 −9
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | import { csrfField, escape, variantRowStyle } from "../html.ts"; |
| 15 | 15 | import { FORGE_LINK_ATTRS, SIGN_OUT_HREF, signInHref } from "../who.ts"; |
| 16 | 16 | import { formatCents } from "../money.ts"; |
| 17 | import { itemsTable, orderTotals, statusPill } from "./checkout.ts"; | |
| 17 | import { dispatchPill, itemsTable, orderTotals, statusPill } from "./checkout.ts"; | |
| 18 | 18 | import { describe } from "../discounts.ts"; |
| 19 | 19 | import { href, nameOf, type Reading } from "../changes.ts"; |
| 20 | 20 | import type { Balance, Entry } from "../audit.ts"; |
| @@ -24,10 +24,10 @@ import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant } | ||
| 24 | 24 | import type { StockEntry } from "../inventory.ts"; |
| 25 | 25 | import type { Email } from "../mail.ts"; |
| 26 | 26 | import type { Session } from "../session.ts"; |
| 27 | import type { OrderEvent, Shortfall } from "../orders.ts"; | |
| 27 | import type { Dispatch, OrderEvent, Shortfall } from "../orders.ts"; | |
| 28 | 28 | |
| 29 | 29 | /** 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"; | |
| 31 | 31 | const STOCK_EVENTS = "stock.moved"; |
| 32 | 32 | |
| 33 | 33 | /** |
| @@ -123,7 +123,7 @@ export function orderRows(orders: Order[]): string { | ||
| 123 | 123 | // Fixed columns: a row arriving at the top cannot move the ones below it |
| 124 | 124 | // sideways, whatever length the name in it is. |
| 125 | 125 | 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> | |
| 127 | 127 | <thead><tr><th>Order</th><th>Placed</th><th>Customer</th><th>Status</th><th class="num">Total</th></tr></thead> |
| 128 | 128 | <tbody>${orders |
| 129 | 129 | .map( |
| @@ -133,7 +133,7 @@ export function orderRows(orders: Order[]): string { | ||
| 133 | 133 | <td>${escape(order.name)}<div class="muted" style="font-size:.85rem">${escape(order.email)} · ${account( |
| 134 | 134 | order, |
| 135 | 135 | )}</div></td> |
| 136 | <td>${statusPill(order.status)}</td> | |
| 136 | <td>${statusPill(order.status)}${dispatchPill(order)}</td> | |
| 137 | 137 | <td class="num price">${escape(formatCents(order.total_cents))}</td> |
| 138 | 138 | </tr>`, |
| 139 | 139 | ) |
| @@ -172,12 +172,14 @@ export function order( | ||
| 172 | 172 | session: Session, |
| 173 | 173 | message?: { text: string; bad?: boolean }, |
| 174 | 174 | short: Shortfall | null = null, |
| 175 | dispatch: Dispatch | null = null, | |
| 175 | 176 | ): string { |
| 176 | 177 | // 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> | |
| 181 | 183 | <p class="lede">${escape(found.name)} · ${escape(found.email)} · ${account(found)} · ${escape(found.gateway)}${ |
| 182 | 184 | found.payment_ref ? ` · ${escape(found.payment_ref)}` : "" |
| 183 | 185 | }</p> |
| @@ -211,6 +213,23 @@ ${orderTotals(found, refunded)} | ||
| 211 | 213 | </form>` |
| 212 | 214 | : '<p class="muted">Nothing left to refund.</p>' |
| 213 | 215 | } |
| 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 | } | |
| 214 | 233 | </div> |
| 215 | 234 | <div> |
| 216 | 235 | <h2>Payments</h2> |
src/pages/checkout.ts +57 −1
| @@ -13,7 +13,7 @@ import { tokenQuery } from "../orderlink.ts"; | ||
| 13 | 13 | import { FORGE_LINK_ATTRS, signInHref } from "../who.ts"; |
| 14 | 14 | import type { Quote } from "../cart.ts"; |
| 15 | 15 | import type { Order, OrderItem, Payment } from "../db.ts"; |
| 16 | import type { Shortfall } from "../orders.ts"; | |
| 16 | import type { Dispatch, Shortfall } from "../orders.ts"; | |
| 17 | 17 | import type { GatewayInfo } from "../gateway.ts"; |
| 18 | 18 | import type { Email } from "../mail.ts"; |
| 19 | 19 | import type { Session } from "../session.ts"; |
| @@ -227,6 +227,18 @@ const STATUS_WORDS: Record<string, string> = { | ||
| 227 | 227 | cancelled: "Cancelled", |
| 228 | 228 | }; |
| 229 | 229 | |
| 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 | ||
| 230 | 242 | export const statusPill = (status: string): string => |
| 231 | 243 | `<span class="pill ${status === "paid" ? "ok" : status === "pending_payment" ? "" : "warn"}">${escape( |
| 232 | 244 | STATUS_WORDS[status] ?? status, |
| @@ -244,6 +256,48 @@ export const shortPayment = (order: Order, short: Shortfall): string => | ||
| 244 | 256 | The order is not marked paid and nothing has shipped. The shop is looking at it; nothing more |
| 245 | 257 | has been taken, and if you need to talk to someone, quote ${escape(order.number)}.</p>`; |
| 246 | 258 | |
| 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 | ||
| 247 | 301 | export function confirmation( |
| 248 | 302 | order: Order, |
| 249 | 303 | items: OrderItem[], |
| @@ -251,6 +305,7 @@ export function confirmation( | ||
| 251 | 305 | email: Email | null, |
| 252 | 306 | who: Who, |
| 253 | 307 | short: Shortfall | null = null, |
| 308 | dispatch: Dispatch | null = null, | |
| 254 | 309 | ): string { |
| 255 | 310 | const paid = order.status === "paid" || order.status === "refunded" || order.status === "partially_refunded"; |
| 256 | 311 | // A card's code is printed once, on the receipt. Say so here: a buyer who |
| @@ -284,6 +339,7 @@ ${ | ||
| 284 | 339 | and <a href="/gift-cards">/gift-cards</a> says what is left on one.</p>` |
| 285 | 340 | : "" |
| 286 | 341 | } |
| 342 | ${life(order, dispatch)} | |
| 287 | 343 | ${itemsTable(items)} |
| 288 | 344 | ${orderTotals(order, refunded)} |
| 289 | 345 | <h2>Shipping to</h2> |
src/routes.ts +1
| @@ -72,6 +72,7 @@ export const routes: Route[] = [ | ||
| 72 | 72 | { method: "GET", pattern: "/admin/orders", handler: admin.orderList }, |
| 73 | 73 | { method: "GET", pattern: "/admin/orders/:number", handler: admin.orderDetail }, |
| 74 | 74 | { method: "POST", pattern: "/admin/orders/:number/refund", handler: admin.orderRefund }, |
| 75 | { method: "POST", pattern: "/admin/orders/:number/fulfil", handler: admin.orderFulfil }, | |
| 75 | 76 | { method: "POST", pattern: "/admin/orders/:number/cancel", handler: admin.orderCancel }, |
| 76 | 77 | { method: "GET", pattern: "/admin/mail", handler: admin.mailbox }, |
| 77 | 78 | { 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 | ||
| 1406 | 1406 | expect(await admin.page("/admin/mail")).toContain(`Your bazaar order ${number}`); |
| 1407 | 1407 | }); |
| 1408 | 1408 | |
| 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<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<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<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 | ||
| 1409 | 1529 | test("no link to the forge carries a referrer, so the page's query stays here", async () => { |
| 1410 | 1530 | // Referrer-Policy is per origin and /__forge/* shares ours, so without this |
| 1411 | 1531 | // 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 () => { | ||
| 2209 | 2329 | stop.abort(); |
| 2210 | 2330 | }); |
| 2211 | 2331 | |
| 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 | ||
| 2212 | 2382 | test("the stream is closed to anyone who is not an admin", async () => { |
| 2213 | 2383 | for (const person of [undefined, { id: "u_2", name: "bo", role: "user" as const }]) { |
| 2214 | 2384 | 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> { | ||
| 445 | 445 | return render( |
| 446 | 446 | ctx, |
| 447 | 447 | { 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 | ), | |
| 449 | 457 | ); |
| 450 | 458 | } |
| 451 | 459 |
src/style.ts +7
| @@ -79,6 +79,13 @@ table.steady th, table.steady td { overflow-wrap: anywhere; } | ||
| 79 | 79 | .fields .wide { grid-column: 1 / -1; } |
| 80 | 80 | .qty { width: 4.5rem; } |
| 81 | 81 | .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; } } | |
| 82 | 89 | .receipt { max-width: 40rem; margin: 0 auto; border: 1px solid var(--line); border-radius: .9rem; overflow: hidden; } |
| 83 | 90 | .receipt .head { padding: 1rem 1.25rem; border-bottom: 1px solid var(--line); font-size: .88rem; color: var(--dim); background: #8881; } |
| 84 | 91 | .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 @@ | ||
| 22 | 22 | * the tax table, the changes an admin made to any of it and the audit trail — |
| 23 | 23 | * everything /admin can see. j/k move, and each tab says at its foot what it |
| 24 | 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 | |
| 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 | |
| 26 | 27 | * refunds what is left of an order and puts it back on the shelf, n writes a |
| 27 | 28 | * code, i issues a card, e sets a tax rate, c looks a code up. q leaves. |
| 28 | 29 | * Plain ANSI, no dependencies. |
| @@ -37,7 +38,7 @@ import { db } from "./db.ts"; | ||
| 37 | 38 | import { chosen } from "./gateway.ts"; |
| 38 | 39 | import { formatCents } from "./money.ts"; |
| 39 | 40 | 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"; | |
| 41 | 42 | import { outbox } from "./mail.ts"; |
| 42 | 43 | import { atDesk } from "./actor.ts"; |
| 43 | 44 | import { atKeyboard } from "./who.ts"; |
| @@ -182,6 +183,14 @@ function detail(order: Order): string[] { | ||
| 182 | 183 | if (split !== "") lines.push(` ${dim(split)}`); |
| 183 | 184 | for (const payment of paymentsOf(order.id)) |
| 184 | 185 | 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 | ); | |
| 185 | 194 | // The same fact the admin page shows: what came in did not cover the order. |
| 186 | 195 | const short = shortfall(order); |
| 187 | 196 | if (short) |
| @@ -216,7 +225,12 @@ function draw(): void { | ||
| 216 | 225 | const mark = i === cursor ? "▸" : " "; |
| 217 | 226 | const text = `${order.number.padEnd(12)} ${order.created_at.padEnd(20)} ${order.name.padEnd(20).slice(0, 20)} ${order.status.padEnd(18)} ${formatCents( |
| 218 | 227 | 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)}`; | |
| 220 | 234 | lines.push(`${mark} ${i === cursor ? bold(text) : text}`); |
| 221 | 235 | if (i === cursor) lines.push(...detail(order)); |
| 222 | 236 | }); |
| @@ -403,6 +417,12 @@ process.stdin.on("data", (key: string) => { | ||
| 403 | 417 | prompt = { label: "Find an order (number, name or email):", text: filter, run: findOrders }; |
| 404 | 418 | if (key === "/" && tab === "audit" && may("/")) |
| 405 | 419 | 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 | }; | |
| 406 | 426 | if (key === "r" && tab === "orders" && may("r")) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest }; |
| 407 | 427 | if (key === "c" && (tab === "codes" || tab === "cards") && may("c")) |
| 408 | 428 | 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 | ||
| 1027 | 1027 | expect(audit.readCursor("' OR 1=1 --")).toBeUndefined(); |
| 1028 | 1028 | expect(audit.readCursor("2026-01-01 00:00:00|9|1")).toBeUndefined(); |
| 1029 | 1029 | }); |
| 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 | }); |