Orders, statuses and the state machine
An order has a status. That sentence hides more trouble than any other in this course.
A status column with no rules beside it drifts within a month. One endpoint
marks an order DELIVERED that was cancelled last Tuesday. Another lets a
PENDING_PAYMENT order skip to PACKED because a payment webhook arrived out
of order. Each change was reasonable on its own. Together they produce a table
nobody can report on.
Write the rules down as data
// packages/shared/src/order.ts
export const ORDER_STATUS_FLOW: Record<OrderStatus, readonly OrderStatus[]> = {
PENDING_PAYMENT: ["PLACED", "CANCELLED"],
PLACED: ["PACKED", "CANCELLED"],
PACKED: ["OUT_FOR_DELIVERY", "CANCELLED"],
OUT_FOR_DELIVERY: ["DELIVERED", "CANCELLED"],
DELIVERED: [],
CANCELLED: [],
} as const;
export function canTransition(from: OrderStatus, to: OrderStatus): boolean {
return ORDER_STATUS_FLOW[from].includes(to);
}
Six statuses, and the whole of the shop's process in eleven lines.
The two empty arrays are the important part. DELIVERED: [] is the entire
implementation of "you cannot un-deliver an order". No extra check, no special
case, no comment reminding somebody. A terminal state is a state with nowhere to
go.
Record<OrderStatus, …> must be exhaustive. Add a seventh status and this
object fails to compile until you say where it can go. That is the difference
between a type that describes your data and a type that enforces something.
Why it is in packages/shared
Because two places need it, and they must never disagree.
The API validates against it. The shop's admin screen renders one button per allowed move:
<AdvanceOrder
orderNumber={order.orderNumber}
next={[...ORDER_STATUS_FLOW[order.status]]}
/>
A dropdown listing all six statuses would let the shopkeeper pick something the API refuses, and the refusal would look like a bug in the app rather than a rule of the shop. An interface that cannot express an illegal move is better than one that catches it afterwards.
One place writes the column
async setStatus(
orderNumber: string,
to: OrderStatus,
options: { note?: string; byUserId?: string } = {},
): Promise<OrderDetail> {
Customer cancellations go through it. The shopkeeper's buttons go through it. Module 14's payment webhook will go through it. One function, so the state machine has exactly one implementation and the stock release cannot be forgotten by whoever adds the next caller.
Three checks, in this order:
if (!order) throw AppException.notFound("No such order.");
if (order.status === to) {
throw new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.CONFLICT,
`That order is already ${to.toLowerCase().replace(/_/g, " ")}.`);
}
if (!canTransition(order.status, to)) {
throw new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.CONFLICT,
`An order that is ${order.status} cannot become ${to}.`,
{ from: order.status, to });
}
The already-in-that-status check is separate, and worth having. It is what happens when somebody double-taps, or when a webhook is delivered twice. "That order is already packed" is a much better message than "an order that is PACKED cannot become PACKED", and the two mean genuinely different things to whoever reads the log.
The details carry from and to as machine-readable fields, so a client can
tell the shopkeeper what went wrong without parsing a sentence.
The customer's rules are narrower
const CUSTOMER_CANCELLABLE: readonly OrderStatus[] = ["PENDING_PAYMENT", "PLACED"];
export function customerCanCancel(status: OrderStatus): boolean {
return CUSTOMER_CANCELLABLE.includes(status);
}
The shop may cancel at any point before delivery. A customer may cancel only before it is packed.
This is not a technical constraint, and it should not be presented as one. Once the shopkeeper has bagged the order, cancelling costs somebody a re-shelving job; once it is on a scooter, it means turning the delivery person around. That is a phone call, not a button.
So the refusal says what to do instead:
existing.status === "CANCELLED"
? "That order is already cancelled."
: "This order has left the shop. Please ring us on 020 1234 5678."
An error that says what is wrong but not what to do is half an error.
And the decision is made on the server:
canCancel: customerCanCancel(row.status),
canCancel is part of the order the API returns. The interface renders the
button when it is true and never works it out for itself, so the button cannot
offer something the API will refuse.
What the status column cannot tell you
status is where the order is now. The question that actually gets asked —
always about the one order that went wrong — is how did it get here, and who
did that?
model OrderEvent {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
status OrderStatus
note String?
byUserId String?
createdAt DateTime @default(now())
@@index([orderId, createdAt])
}
Append-only. Nothing updates a row in this table, ever. A log you can edit is a log nobody can rely on.
And it is written in the same transaction as the change it describes:
return tx.order.update({
where: { orderNumber },
data: {
status: to,
...timestamps,
events: {
create: { status: to, note: options.note ?? null, byUserId: options.byUserId ?? null },
},
},
include: ORDER_INCLUDE,
});
Two statements outside a transaction would eventually produce an order whose history disagrees with its status, and you would never work out which one lied.
The order also gets its opening event at checkout, in module 12's transaction. An order whose history starts at its second status is an order nobody can reconstruct.
Cancelling has to undo things
This is the part people forget, and the shop notices within a day.
if (to === "CANCELLED") {
await this.releaseStock(tx, order.items);
await this.releaseSlot(tx, order.slotId, order.slotDate);
}
await tx.variant.updateMany({
where: { id: item.variantId },
data: { stock: { increment: item.quantity } },
});
An increment, not a write of a number the application worked out. Module
12's lesson on stock, running in the opposite direction: stock = stock + n is
arithmetic the database does on the current value, so two cancellations
processed at the same moment cannot lose one of them.
The slot needs a guard the stock does not:
await tx.slotBooking.updateMany({
where: { slotId, date, booked: { gt: 0 } },
data: { booked: { decrement: 1 } },
});
booked > 0 in the WHERE clause, so a double cancellation cannot drive the
counter negative. A negative counter is worse than a wrong one: it silently
creates delivery capacity the shop does not have, and the first anybody knows is
a scooter with more drops than hours.
Timestamps, not just a status
if (to === "PLACED") timestamps.placedAt = new Date();
if (to === "PACKED") timestamps.packedAt = new Date();
if (to === "DELIVERED") timestamps.deliveredAt = new Date();
if (to === "CANCELLED") timestamps.cancelledAt = new Date();
Denormalised on purpose. All of this is derivable from the events table, and "how long between placed and delivered?" is a question the shop will ask every week. A column is an index away from being fast; a scan over an event log is not.
Check your work
Why the flow is data rather than if statements: two consumers need it —
the API and the admin screen — and they must never disagree.
What the empty arrays do: they are the entire implementation of a terminal status.
Why Record<OrderStatus, …> matters: adding a status fails to compile until
every case is handled.
Why the admin screen renders buttons from the flow: an interface that cannot express an illegal move beats one that catches it afterwards.
Why one function writes the column: cancellation must release stock, and a second write path is a second chance to forget.
Why "already in that status" is its own check: double taps and repeated webhooks are normal, and deserve a message that says so.
Why the customer's rules are narrower: cancelling a packed order costs somebody real work, and turning a scooter around is a phone call.
Why canCancel comes from the API: so the button cannot offer something the
server will refuse.
Why events are append-only and written in the same transaction: a log you can edit is worthless, and two statements outside a transaction eventually disagree.
Why booked: { gt: 0 } guards the slot release: a negative counter invents
delivery capacity the shop does not have.
Practice
- Place an order and try to move it straight from
PLACEDtoDELIVERED. Read the 409 and thedetailsobject. - Mark it
PACKEDtwice. Confirm the second attempt gets a different message from an illegal transition. - Cancel a
PLACEDorder and check thevariantstable before and after. Confirm the stock came back exactly. - Check
slot_bookingstoo, and confirmbookedwent down by one. - Cancel the same order again. Confirm the stock did not go up a second time.
- Remove
booked: { gt: 0 }from the slot release, then cancel twice by calling the service directly. Watch the counter go negative. - Add a
RETURNEDstatus to the enum and the flow. See what fails to compile before you touch anything else. - Try to cancel a
DELIVEREDorder as the shop. Confirm even the admin cannot. - Read the
order_eventsrows for an order you have moved three times. Confirm the trail matches, and thatbyUserIdis set for admin actions and null for the customer's own. - Write the transition check as
ifstatements instead of a table, then add one status and count how many places you had to touch.
Next: the screens the customer sees — order history and tracking.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship