RizTech Academy logo
RizTech Academy
Kirana Store: Orders and AdminLesson 1 of 535 min

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

  1. Place an order and try to move it straight from PLACED to DELIVERED. Read the 409 and the details object.
  2. Mark it PACKED twice. Confirm the second attempt gets a different message from an illegal transition.
  3. Cancel a PLACED order and check the variants table before and after. Confirm the stock came back exactly.
  4. Check slot_bookings too, and confirm booked went down by one.
  5. Cancel the same order again. Confirm the stock did not go up a second time.
  6. Remove booked: { gt: 0 } from the slot release, then cancel twice by calling the service directly. Watch the counter go negative.
  7. Add a RETURNED status to the enum and the flow. See what fails to compile before you touch anything else.
  8. Try to cancel a DELIVERED order as the shop. Confirm even the admin cannot.
  9. Read the order_events rows for an order you have moved three times. Confirm the trail matches, and that byUserId is set for admin actions and null for the customer's own.
  10. Write the transition check as if statements 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