RizTech Academy logo
RizTech Academy
Kirana Store: Cart and CheckoutLesson 4 of 540 min

The checkout flow

Checkout is where every part of the application meets at once: the cart, stock, the customer's identity, an address, a delivery slot, money, and a record that has to survive being argued about in six months.

It is also the place where a half-finished operation does the most damage.

Everything, or nothing

return this.prisma.$transaction(async (tx) => {
  const cart = await tx.cart.findFirst({ where: { userId }, include: { … } });
  if (!cart || cart.items.length === 0) throw AppException.cartEmpty();

  await this.claimStock(tx, cart.items);
  await this.claimSlot(tx, dto.slotId, slotDate);

  const order = await tx.order.create({ … });
  await tx.cartItem.deleteMany({ where: { cartId: cart.id } });

  return toOrderDetail(order);
});

Six steps in one transaction. Consider what each partial failure would mean if they were not:

  • Stock taken, order never created → the shop has items it cannot sell and no record of why.
  • Order created, stock never taken → oversold.
  • Order created, cart never emptied → the customer refreshes and orders the same basket twice.
  • Slot claimed, order failed → a delivery window held for nobody.

The order of the steps is not arbitrary either. Stock first, because it is the step most likely to fail. Failing early means less work to undo — and although the database undoes it either way, the reasoning matters the day somebody adds a step that talks to the outside world, like sending an SMS.

Which is the rule to take away: nothing inside a transaction may do anything the database cannot roll back. No emails, no SMS, no payment capture. The transaction can be retried or aborted, and the SMS cannot be unsent. Those go after the commit — module 13 puts the notification there.

What the API must not believe

The request body carries a name, a phone number, an address, a slot and a payment method. Notice what it does not carry: prices, quantities, a total.

const lines = cart.items.map((item) => ({
  variantId: item.variantId,
  productName: item.variant.product.name,
  variantLabel: item.variant.label,
  sku: item.variant.sku,
  unitPricePaise: item.variant.pricePaise,
  quantity: item.quantity,
  linePaise: item.variant.pricePaise * item.quantity,
}));

const totals = cartTotals(lines);

Every number comes from the database, inside the transaction, at the moment of the order. A client that could send a total could send 1.

This is not paranoia about hackers. It is the same discipline that stops honest bugs: a front end with a rounding error, a stale page from before a price change, a retry that replays an old body. The client says what it wants. The server says what it costs.

One implementation of the arithmetic

// packages/shared/src/cart.ts
export function cartTotals(lines: Pick<CartLine, "linePaise">[]): CartTotals {
  const subtotalPaise = lines.reduce((sum, line) => sum + line.linePaise, 0);
  const deliveryPaise = deliveryChargeFor(subtotalPaise);
  // …
}

The front end imports this to show the total. The API imports it to charge it. One function, in packages/shared, which is the clearest payoff yet for the monorepo from module 1.

Two implementations drift. The day they disagree, the customer sees one number and is billed another — and that ends in a refund and a bad review, not a stack trace.

export function deliveryChargeFor(subtotalPaise: Paise): Paise {
  if (subtotalPaise <= 0) return 0;
  return subtotalPaise >= FREE_DELIVERY_THRESHOLD_PAISE ? 0 : DELIVERY_CHARGE_PAISE;
}

That first line is not defensive clutter. Without it an empty cart displays "₹40 delivery", which looks like a bug because it is one.

The order snapshots everything

items: { create: lines },   // productName, variantLabel, sku, unitPricePaise…

The cart stored a variantId and read the name and price live. The order stores the name, the pack size, the SKU and the price as text and numbers on the order line.

This is decision 0002, and it is the difference between a wish and a contract. The shop renames "Toor Dal (Arhar)" to "Toor Dal 1kg Premium" next month, or raises the price, or stops selling it. The order from today must still say what was bought and what it cost. An order that renders through a live join is an order that changes after the fact.

The variantId is still there, nullable, for reporting — and it must never cascade on delete. An order that loses its lines because a product was removed is an accounting problem, not a data-modelling one.

The delivery address is snapshotted for the same reason. The customer moves house; the order still says where it went.

The order number

KS-20260927-7F3K2A
const ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";

No I, O, 0 or 1, because somebody will read this down a phone line to a shopkeeper.

And not sequential. A sequential number needs a counter, and a counter is either a race or a lock on every order. It also leaks: place two orders a week apart and you know exactly how much business the shop did in between.

The date prefix is for humans. The shop can find last Tuesday's orders by looking.

Status depends on how they are paying

status: dto.paymentMethod === "CASH_ON_DELIVERY" ? "PLACED" : "PENDING_PAYMENT",
placedAt: dto.paymentMethod === "CASH_ON_DELIVERY" ? new Date() : null,

Cash on delivery is a real order the moment it is made — nothing else has to happen for the shop to start packing.

An online payment is not. The order exists, it holds its stock, and it is not a sale until the gateway confirms. That confirmation arrives by webhook in module 14, and the single most important rule there is the one this lesson has been circling: never trust the browser to tell you the payment succeeded.

Holding stock against a PENDING_PAYMENT order that nobody ever pays for is a real cost, and module 14 deals with expiring them.

Reading an order back

const order = await this.prisma.order.findFirst({
  where: { orderNumber, userId },
  include: ORDER_INCLUDE,
});

if (!order) throw AppException.notFound("No such order.");

Both conditions in one where. The alternative — findUnique({ orderNumber }) and then an ownership check in an if — is the same query with one more chance to forget the check, and that check is the whole of the authorisation.

And a 404, not a 403, when the order belongs to somebody else. A 403 says "that order exists, but not for you", which confirms the number is real. The same response for "does not exist" and "not yours" tells an attacker nothing.

Guard the whole controller

@Controller()
@UseGuards(JwtAuthGuard)
export class CheckoutController { … }

On the controller, not on each method. A guard repeated per route is a guard that will be missing from the route somebody adds in six months.

The front end checks too, and the order matters:

const user = await fetchCurrentUser();
if (!user) redirect("/account/login?next=%2Fcheckout");

On the server, before anything renders. A client-side guard flashes the form and — more to the point — stops nobody.

Errors the customer can act on

{state.code === ErrorCode.INSUFFICIENT_STOCK && (
  <Link href="/cart">Back to the cart</Link>
)}

The stock problem cannot be fixed on the checkout form; the quantity lives in the cart. So the error says where to go. An error that says what is wrong but not what to do is half an error.

That is what the machine-readable code from module 7 buys. The front end switches on INSUFFICIENT_STOCK rather than pattern-matching English that somebody will reword.

Check your work

Why checkout is one transaction: every partial outcome — stock without an order, an order without stock, an order with the cart still full — is worse than a clean failure.

Why nothing inside it may send an SMS: the transaction can abort, and the message cannot be unsent.

Why the client sends no prices: a client that can send a total can send 1, and honest bugs cause the same damage as dishonest ones.

Why cartTotals lives in packages/shared: two implementations drift, and the day they disagree the customer is billed a number they were never shown.

Why deliveryChargeFor special-cases zero: an empty cart would otherwise display a delivery charge.

Why the order snapshots names and prices: a rename or a price change must not alter what an old order says was bought.

Why the order number is random: sequential needs a counter, and a counter is a race, a lock, and a leak of the shop's volume.

Why cash on delivery is PLACED but online is PENDING_PAYMENT: nothing else has to happen for cash; an online order is not a sale until the gateway confirms by webhook.

Why ownership goes in the where, and why 404: one query instead of a query plus a check that can be forgotten, and a 403 would confirm the order number is real.

Why the guard is on the controller: a per-method guard is one somebody will forget to add.

Practice

  1. Place an order with cash on delivery. Confirm the status is PLACED and placedAt is set.
  2. Check the cart_items table afterwards and confirm it is empty.
  3. Try to check out with an empty cart. Confirm a 400 with code CART_EMPTY.
  4. Add subtotalPaise: 1 to the request body. Confirm the API rejects the unknown field, then work out which ValidationPipe option did that.
  5. Make claimSlot throw unconditionally, place an order, and confirm the stock was not decremented and no order row exists.
  6. Rename a product in the database after ordering it. Reload the order page and confirm it still shows the old name.
  7. Read the order back as a second registered user. Confirm 404, not 403.
  8. Remove userId from the where in findForUser and try again. Note how small the change was.
  9. Place an order with the online payment method selected (enable the radio temporarily). Confirm the status is PENDING_PAYMENT and that stock was still taken.
  10. Put a console.log inside the transaction and one after it. Force a failure and confirm which one runs.

Next: addresses and delivery slots — the last thing between a cart and a van.

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