Skip to content

Commit ab1a237

Browse files
vedanshujainclaude
andcommitted
[Domain] Cancel order with reason
Cancelling an order from the admin now captures a structured reason (and optional free-text detail) on the order's mutable envelope, instead of a bare state flip (admin-UX Increment 1, "cancel with reason" slice, Increment 1 slice 4). Discovery: bare cancel (the existing transitionOrder path) has never released reserved stock in this domain — only pending→expired's guarded sweep and settle's failed-payment path do that (per the Phase 5 design doc). This slice preserves that behavior exactly; stock release on cancel is out of scope here. cancelOrder composes with the state machine exactly like recordFulfillment: a guarded fromState→cancelled flip (routed through the shared #flipAndEnqueue primitive, extraSet) writes the reason envelope and enqueues the cancelled email atomically. Legality is derived from isLegalOrderTransition (never a hardcoded state list) — covers pending/paid/processing today. A cancel-vs-recordFulfillment Postgres race (extending PR #63's record-vs-cancel race to the reasoned path) proves exactly one outcome wins. - Domain: Order.cancellation + OrderCancellation/CancellationReason; OrderStore.cancelOrder; cancelOrder use-case; buildOrderEmailData carries the cancellation. - Adapters: migration 0013_order_cancellation (four nullable columns); green on better-sqlite3 + pg; pg concurrency + cancel-vs-recordFulfillment races. - Service: POST /admin/orders/:id/cancel (internal token + write gate); serializeOrder + the order-cancelled email render the recorded reason. - Plugin: order-detail Cancellation section + danger-styled cancel form (sandbox-clean); bare "Mark cancelled" steered to the form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XhEjVemVDUk62ohJ5nkFYx
1 parent 1ed45bd commit ab1a237

27 files changed

Lines changed: 1680 additions & 25 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@urumi/domain": minor
3+
"@urumi/store-postgres": minor
4+
"@urumi/service": minor
5+
"@urumi/plugin": minor
6+
---
7+
8+
Cancel an order WITH a structured reason (detail optional), and make the cancelled-
9+
notification email carry WHY instead of a reason-free notice (admin-UX Increment 1,
10+
"cancel with reason" slice). Before this slice, cancelling was a bare `POST
11+
.../transition {toState:"cancelled"}` — no reason captured, and the cancelled email said
12+
only "Your order has been cancelled." Discovery: cancelling has never released reserved
13+
stock in this domain (only `pending → expired`'s guarded sweep and settle's failed-payment
14+
path do that, per the Phase 5 design doc); this slice does not change that — it is
15+
out of scope here and preserved exactly as-is.
16+
17+
The core design decisions:
18+
19+
- **Single-slot cancellation.** `cancelled` is terminal (no outbound transition in the
20+
state machine), so an order carries at most ONE `OrderCancellation` (reason / detail? /
21+
cancelledBy / cancelledAt) — a struct on the order, not a child table.
22+
- **Cancelling IS recording the reason.** `cancelOrder` composes with the state machine
23+
(the exact `recordFulfillment` shape): it writes the reason envelope AND drives the
24+
`{pending,paid,processing} → cancelled` transition AND enqueues the cancelled email, all
25+
atomically. So no reachable state is "cancelled with no reason" via this path. Legality is
26+
DERIVED from `isLegalOrderTransition(state, "cancelled")` — never a hardcoded state list —
27+
so it automatically covers every state the machine allows to cancel. Mutable-envelope
28+
only — it NEVER touches line items, prices, or totals (the snapshot invariant); it does
29+
NOT release inventory (that gap, if any, is unchanged and out of scope). The bare
30+
`POST .../transition` stays available for other callers/back-compat — a cancellation via
31+
that path carries no reason (`cancellation === null`), mirroring `recordFulfillment`'s
32+
shipped-without-tracking case.
33+
34+
- **Domain (`[Domain]`).** New `Order.cancellation` field + `OrderCancellation`/
35+
`CancellationReason` types (`customer_request | fraud_suspected | out_of_stock |
36+
pricing_error | other`); new `OrderStore.cancelOrder` port method (a guarded
37+
`WHERE state=:fromState` flip that writes the cancellation columns, cancels the order,
38+
and enqueues the cancelled outbox row in ONE transaction, routed through the SAME
39+
`#flipAndEnqueue` primitive as `transition`/`recordFulfillment` — never a parallel copy);
40+
new pure use-case `cancelOrder` (validate → derive legality → delegate; idempotent
41+
replay + the stale-race disambiguation mirror `recordFulfillment`/`transitionOrder`).
42+
`buildOrderEmailData` now carries the cancellation so the cancelled template can render it.
43+
- **Adapters (`[Adapters]`).** Forward-only migration `0013_order_cancellation` adds four
44+
nullable columns to `orders` (portable text DDL, identical on better-sqlite3 + pg). Both
45+
adapters green against the new `orderCancellationContract`; Postgres additionally runs the
46+
concurrency races — N concurrent cancels resolve to exactly one winner, and cancelling
47+
racing `recordFulfillment` resolves to exactly one outcome (the order is never both
48+
cancelled and shipped) — extending PR #63's record-vs-cancel race to the reasoned path.
49+
- **Service (`[Service]`).** `POST /admin/orders/:id/cancel` mirrors the use-case 1:1 under
50+
the internal-token guard + the X-Service-Token write gate (a non-GET); `serializeOrder`
51+
gains `cancellation` (additive). `renderEmail`'s `order-cancelled` template now renders a
52+
human-readable reason label + optional detail, degrading to the plain body when an order
53+
was cancelled without a reason.
54+
- **Plugin (`[Plugin]`).** The order detail gains a "Cancellation" section: a still-
55+
cancellable order shows a danger-styled alert + the cancel form (reason select, optional
56+
detail, cancelledBy) and the bare "Mark cancelled" one-click is HIDDEN from the transition
57+
buttons (UI steering, extending PR #63's shipped-steering precedent — cancelling goes
58+
through the form so an order is never cancelled without a reason; the service still
59+
accepts the bare transition for other callers); a cancelled order shows the recorded
60+
reason read-only; a cancelled-without-reason order gets an honest note. A
61+
`NOT_CANCELLABLE` conflict surfaces a "reload" notice, not a token-check error. Typed
62+
`ctx.http` client method threads both tokens like the transition; sandbox-clean
63+
(Block Kit only) — verified in the workerd-on-Node sandbox.

packages/domain/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export {
9595
type ReserveResult,
9696
} from "./ports/inventory-store.js";
9797
export type {
98+
CancelOrderInput,
99+
CancelOrderStoreResult,
98100
CreateOrderInput,
99101
CreateOrderLineInput,
100102
CreateOrderResult,
@@ -183,8 +185,10 @@ export type {
183185
X402Proof,
184186
} from "./ports/payment-gateway.js";
185187
export type {
188+
CancellationReason,
186189
FulfillmentKind,
187190
Order,
191+
OrderCancellation,
188192
OrderFulfillment,
189193
OrderLine,
190194
OrderTotals,
@@ -229,6 +233,13 @@ export {
229233
type RecordFulfillmentFailure,
230234
type RecordFulfillmentOutcome,
231235
} from "./orders/record-fulfillment.js";
236+
export {
237+
cancelOrder,
238+
type CancelOrderCommand,
239+
type CancelOrderDeps,
240+
type CancelOrderFailure,
241+
type CancelOrderOutcome,
242+
} from "./orders/cancel-order.js";
232243
export {
233244
DEFAULT_RECENT_ORDERS_LIMIT,
234245
getOrderCustomerContext,
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { IdempotencyKey, OrderId } from "../money/ids.js";
2+
import type { OrderStore } from "../ports/order-store.js";
3+
import { emailTemplateForState, isLegalOrderTransition } from "./state-machine.js";
4+
import type { CancellationReason, Order } from "./model.js";
5+
6+
export interface CancelOrderDeps {
7+
orderStore: OrderStore;
8+
}
9+
10+
export interface CancelOrderCommand {
11+
orderId: OrderId;
12+
reason: CancellationReason;
13+
/** Optional free-text elaboration — trimmed; an absent/blank value normalizes
14+
* to `null` (mirrors `recordFulfillment`'s optional `trackingUrl`). */
15+
detail?: string | null;
16+
/** Who cancelled it — trimmed + required non-empty (mirrors an order note's
17+
* `author`; the domain does not model admin identity). */
18+
cancelledBy: string;
19+
/** Every command carries one (CLAUDE.md); the store's guarded flip enforces
20+
* once-only. */
21+
idempotencyKey: IdempotencyKey;
22+
}
23+
24+
export type CancelOrderFailure =
25+
| "ORDER_NOT_FOUND"
26+
/** The order's current state cannot legally reach `cancelled` (per
27+
* `isLegalOrderTransition(state, "cancelled")` — today `pending`, `paid`, and
28+
* `processing`). A terminal order (shipped/delivered/completed/refunded/
29+
* already-cancelled-without-reason via a DIFFERENT race, or already failed/
30+
* expired) is rejected: cancellation is only meaningful pre-fulfillment. */
31+
| "NOT_CANCELLABLE"
32+
| "EMPTY_CANCELLED_BY";
33+
34+
export type CancelOrderOutcome =
35+
| { ok: true; cancelled: boolean; order: Order }
36+
| { ok: false; reason: CancelOrderFailure };
37+
38+
/**
39+
* Cancel an order WITH a structured reason (admin-UX Increment 1, "cancel with
40+
* reason" slice). Pure orchestration — no IO of its own: validate, confirm the
41+
* order's current state can legally reach `cancelled` (per the ONE state
42+
* machine), then delegate the guarded "flip + record" compose to the store.
43+
*
44+
* This is the SAME composition shape as `recordFulfillment`: cancelling records
45+
* the reason envelope AND drives the transition to `cancelled` AND enqueues the
46+
* cancelled email, atomically, so no reachable state is "cancelled with no
47+
* reason recorded" (via this path). Mutable-envelope only — it NEVER touches
48+
* line items, prices, or totals (the snapshot invariant); the bare
49+
* `transitionOrder` command remains available for other callers/back-compat
50+
* (a cancellation via that path has `cancellation === null`, an honest "no
51+
* reason on file" state).
52+
*
53+
* Legality + idempotency, mirroring `recordFulfillment`/`transitionOrder`:
54+
* - a state that can legally cancel (per `isLegalOrderTransition(state,
55+
* "cancelled")` — never a re-listing; today `pending`/`paid`/`processing`)
56+
* cancels once; the store's guarded `WHERE state=:fromState` flip makes
57+
* concurrent/replayed calls a 0-row no-op, so exactly one reason is ever
58+
* written and exactly one cancelled email enqueued;
59+
* - an **already-cancelled-WITH-a-reason** order is an idempotent no-op success
60+
* (`cancelled:false`) — a redelivery / double-submit is not an error;
61+
* - an **already-cancelled-WITHOUT-a-reason** order (cancelled via the bare
62+
* transition) is `NOT_CANCELLABLE` — this compose never back-fills a reason
63+
* onto a cancellation it didn't make (mirrors `recordFulfillment`'s
64+
* shipped-without-fulfillment case);
65+
* - any **other state** (shipped/delivered/completed/refunded/failed/expired,
66+
* or a concurrent transition that won the race first) is `NOT_CANCELLABLE`.
67+
*/
68+
export async function cancelOrder(
69+
deps: CancelOrderDeps,
70+
cmd: CancelOrderCommand,
71+
): Promise<CancelOrderOutcome> {
72+
const cancelledBy = cmd.cancelledBy.trim();
73+
if (cancelledBy.length === 0) return { ok: false, reason: "EMPTY_CANCELLED_BY" };
74+
// The detail is optional free text: trim and treat a blank/absent value as
75+
// "none" (null) — mirrors recordFulfillment's optional trackingUrl.
76+
const trimmedDetail = (cmd.detail ?? "").trim();
77+
const detail = trimmedDetail.length === 0 ? null : trimmedDetail;
78+
79+
const order = await deps.orderStore.getById(cmd.orderId);
80+
if (order === null) return { ok: false, reason: "ORDER_NOT_FOUND" };
81+
82+
// Already cancelled WITH a reason ⇒ benign idempotent no-op (a replay / double
83+
// submit). Already cancelled WITHOUT a reason (the bare transition path) is
84+
// not back-fillable via this compose — mirrors recordFulfillment's
85+
// shipped-without-fulfillment case.
86+
if (order.state === "cancelled") {
87+
if (order.cancellation !== null) return { ok: true, cancelled: false, order };
88+
return { ok: false, reason: "NOT_CANCELLABLE" };
89+
}
90+
// Legality is DERIVED from the one state machine (never a hardcoded state
91+
// list): cancellable ⇔ the current state can legally transition to
92+
// `cancelled` (today pending/paid/processing; if the machine ever widens
93+
// this, this — and the store's fromState guard below — follow automatically).
94+
if (!isLegalOrderTransition(order.state, "cancelled")) {
95+
return { ok: false, reason: "NOT_CANCELLABLE" };
96+
}
97+
98+
const res = await deps.orderStore.cancelOrder({
99+
orderId: cmd.orderId,
100+
// The guarded flip's from-state — the state we just validated as legally
101+
// able to cancel (the `transition`/`recordFulfillment` fromState precedent).
102+
fromState: order.state,
103+
reason: cmd.reason,
104+
detail,
105+
cancelledBy,
106+
idempotencyKey: cmd.idempotencyKey,
107+
// `cancelled` always has a template — symmetric with `transitionOrder`/
108+
// `recordFulfillment`.
109+
enqueueEmail: emailTemplateForState("cancelled") !== null,
110+
});
111+
if (res.cancelled) return { ok: true, cancelled: true, order: res.order ?? order };
112+
113+
// The guarded flip missed (0 rows) — someone moved the order out of
114+
// `fromState` between our read and the UPDATE. Disambiguate on the fresh row:
115+
// cancelled WITH a reason ⇒ a concurrent cancel won (benign no-op); anything
116+
// else (a concurrent ship/refund/etc., or cancelled-without-reason) ⇒ not
117+
// cancellable.
118+
const fresh = res.order;
119+
if (fresh !== null && fresh.state === "cancelled" && fresh.cancellation !== null) {
120+
return { ok: true, cancelled: false, order: fresh };
121+
}
122+
return { ok: false, reason: "NOT_CANCELLABLE" };
123+
}

packages/domain/src/orders/model.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,44 @@ export interface ReconciliationResolution {
6363
resolvedAt: string;
6464
}
6565

66+
/**
67+
* The structured reasons an admin may give when cancelling an order (admin-UX
68+
* Increment 1, "cancel with reason" slice). A small closed set — commerce
69+
* disposition, never free text alone — so cancellations are reportable; `other`
70+
* is the escape hatch and is where the optional `detail` free text matters most.
71+
*/
72+
export type CancellationReason =
73+
| "customer_request"
74+
| "fraud_suspected"
75+
| "out_of_stock"
76+
| "pricing_error"
77+
| "other";
78+
79+
/**
80+
* The cancellation recorded on an order (admin-UX Increment 1). A SINGLE-SLOT
81+
* record: this domain's state machine cancels an order exactly once (`cancelled`
82+
* is terminal — no outbound transition, `state-machine.ts`), so an order carries
83+
* at most one cancellation. Part of the mutable envelope — recording it NEVER
84+
* touches line items or prices (the snapshot invariant). Populated atomically
85+
* with the `{pending,paid,processing} → cancelled` transition by `cancelOrder`;
86+
* `null` until the order is cancelled through that path. A `cancelled` order
87+
* reached via the bare `transitionOrder` (back-compat callers) has `state
88+
* === "cancelled"` but `cancellation === null` — an honest "cancelled, no reason
89+
* on file" state, mirroring `fulfillment`'s shipped-without-tracking case.
90+
*/
91+
export interface OrderCancellation {
92+
reason: CancellationReason;
93+
/** Optional free-text elaboration (trimmed; `null` when the admin gave none).
94+
* Bounded by the service schema — the domain accepts whatever it is handed. */
95+
detail: string | null;
96+
/** Who cancelled it (free text, like a note author — the domain does not
97+
* model admin identity). */
98+
cancelledBy: string;
99+
/** Server-assigned ISO-8601 UTC timestamp the cancellation was recorded (from
100+
* the store's clock) — the presence witness that a reason is on file. */
101+
cancelledAt: string;
102+
}
103+
66104
/**
67105
* The shipping fulfillment recorded on an order (admin-UX Increment 1). A
68106
* SINGLE-SLOT record: this domain's state machine ships an order exactly once
@@ -176,4 +214,12 @@ export interface Order {
176214
* affects line items or totals (the snapshot invariant).
177215
*/
178216
fulfillment: OrderFulfillment | null;
217+
/**
218+
* The structured cancellation recorded on this order (admin-UX Increment 1);
219+
* `null` while never cancelled, OR cancelled via the bare `transitionOrder`
220+
* without a reason (back-compat). Single-slot (cancellation is terminal). Part
221+
* of the mutable envelope — it never affects line items or totals (the
222+
* snapshot invariant).
223+
*/
224+
cancellation: OrderCancellation | null;
179225
}

packages/domain/src/orders/transition.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,5 +188,18 @@ export function buildOrderEmailData(order: Order, toState: OrderState): Record<s
188188
},
189189
}
190190
: {}),
191+
// Same rationale as fulfillment above: the cancellation reason travels with
192+
// the data (never a store reach-back, §6) so the cancelled template can
193+
// render WHY, instead of the old reason-free "Your order has been
194+
// cancelled." Present only when cancelOrder recorded one (admin-UX
195+
// Increment 1) — a bare-transition cancellation carries none.
196+
...(order.cancellation !== null
197+
? {
198+
cancellation: {
199+
reason: order.cancellation.reason,
200+
detail: order.cancellation.detail,
201+
},
202+
}
203+
: {}),
191204
};
192205
}

packages/domain/src/ports/order-store.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
Sku,
99
} from "../money/ids.js";
1010
import type {
11+
CancellationReason,
1112
FulfillmentKind,
1213
Order,
1314
OrderState,
@@ -105,6 +106,34 @@ export interface OrderStore {
105106
*/
106107
recordFulfillment(input: RecordFulfillmentInput): Promise<RecordFulfillmentStoreResult>;
107108

109+
/**
110+
* Cancel an order WITH a structured reason, atomically (admin-UX Increment 1,
111+
* "cancel with reason"). Routed through the SAME guarded-flip primitive as
112+
* `transition`/`recordFulfillment` (`#flipAndEnqueue`'s `extraSet`, PR #63
113+
* review precedent — one guarded-flip implementation, never a parallel copy
114+
* that could drift): the cancellation columns ride the guarded `WHERE
115+
* id=:orderId AND state=:fromState` UPDATE that also flips `state='cancelled'`,
116+
* then — when `enqueueEmail` — the `cancelled` outbox row is inserted (`ON
117+
* CONFLICT (order_id, to_state) DO NOTHING`), all in ONE transaction on one
118+
* connection. So no reachable state is "cancelled with no reason recorded" via
119+
* this path, and the cancelled email that drains can carry the reason.
120+
*
121+
* The guard is `WHERE id = :orderId AND state = :fromState` — the SAME
122+
* fromState-equality guard as `transition`/`recordFulfillment` (the use-case
123+
* passes the state it validated via `isLegalOrderTransition(state,
124+
* "cancelled")`, so the port never hardcodes a state list): it makes the
125+
* cancellation once-only under concurrency (exactly one caller cancels +
126+
* records the reason) and composes with the state machine — an order a
127+
* concurrent `recordFulfillment` (or any other transition) already moved out
128+
* of `fromState` is a 0-row miss (`cancelled:false`), never cancelled behind a
129+
* concurrent ship's back (and vice versa — see `recordFulfillment`'s doc).
130+
* NEVER touches `order_items`/`order_totals` (the snapshot invariant) — only
131+
* the mutable cancellation envelope + the guarded state flip.
132+
* `idempotencyKey` is retained for command-shape consistency; dedup is
133+
* structural via the guard (mirrors `transition`/`recordFulfillment`, H4).
134+
*/
135+
cancelOrder(input: CancelOrderInput): Promise<CancelOrderStoreResult>;
136+
108137
// -- Phase 5 (§5/§7): order state machine + email outbox ------------------
109138

110139
/**
@@ -238,6 +267,39 @@ export interface RecordFulfillmentStoreResult {
238267
order: Order | null;
239268
}
240269

270+
/** The store-level cancel command. `reason`/`detail`/`cancelledBy` are already
271+
* validated (enum + trimmed) by the use-case; the store persists them verbatim
272+
* and stamps `cancelled_at` from its own clock. */
273+
export interface CancelOrderInput {
274+
orderId: OrderId;
275+
/** The guarded flip's from-state (the `transition`/`recordFulfillment`
276+
* fromState precedent). The use-case derives it from the state machine
277+
* (`isLegalOrderTransition(state, "cancelled")`) — the adapter guards `WHERE
278+
* state = :fromState` and never hardcodes a state list of its own. */
279+
fromState: OrderState;
280+
reason: CancellationReason;
281+
detail: string | null;
282+
cancelledBy: string;
283+
/** Every command carries one (CLAUDE.md). NOT the dedup mechanism here — dedup
284+
* is structural via the guarded `WHERE state=:fromState` flip plus the outbox
285+
* `UNIQUE(order_id, to_state)` (mirrors `transition`/`recordFulfillment`, H4).
286+
* Adapters accept but do not key off it. */
287+
idempotencyKey: IdempotencyKey;
288+
/** Enqueue the `cancelled` outbox row in the same transaction. Passed by the
289+
* use-case (`emailTemplateForState('cancelled') !== null`), for symmetry with
290+
* `OrderTransitionInput`/`RecordFulfillmentInput` — `cancelled` always has a
291+
* template. */
292+
enqueueEmail: boolean;
293+
}
294+
295+
/** `cancelled:false` ⇒ the guarded `fromState → cancelled` flip matched 0 rows
296+
* (no longer in `fromState` — already cancelled, shipped/refunded, or a lost
297+
* race). `order` is the current row either way, or null if the order is gone. */
298+
export interface CancelOrderStoreResult {
299+
cancelled: boolean;
300+
order: Order | null;
301+
}
302+
241303
export interface OrderTransitionInput {
242304
orderId: OrderId;
243305
fromState: OrderState;

0 commit comments

Comments
 (0)