RizTech Academy logo
RizTech Academy
Kirana Store: Cart and CheckoutLesson 5 of 530 min

Addresses and delivery slots

Two small features that are both quietly full of traps: where the order goes, and when.

A date is not a moment

Start here, because this one cost a day of deliveries.

A delivery is on Tuesday. Not at a particular instant — on a day. Those are different kinds of value, and JavaScript has a type for only one of them.

const date = new Date("2026-09-30");
date.setHours(0, 0, 0, 0);

That looks like "midnight on the 30th". Here is what actually happens in Pune:

  1. new Date("2026-09-30") parses a date-only string as UTC midnight — 2026-09-30T00:00:00Z.
  2. In IST, that instant is 05:30 on the morning of the 30th.
  3. setHours(0, 0, 0, 0) moves it to local midnight, which is 2026-09-29T18:30:00Z.
  4. PostgreSQL casts that to a DATE by taking its UTC day: 2026-09-29.

Every delivery was scheduled one day early. Nothing threw. The API returned 201. The order page showed the right day, because it was formatting the value it had just sent rather than the value that was stored. It only showed up when a query against the bookings table disagreed with the slot list.

The fix is to pin a calendar date to UTC at both ends and never let local time near it:

/** A calendar date, as the instant of UTC midnight on that date. */
export function parseIsoDate(iso: string): Date {
  return new Date(`${iso}T00:00:00.000Z`);
}

/** Reads a DATE column back out. The counterpart to parseIsoDate. */
export function utcIsoDate(date: Date): string {
  return date.toISOString().slice(0, 10);
}

And the column is a DATE, not a timestamp:

date DateTime @db.Date

A DATE column cannot store a time, so it cannot store a wrong one.

There is still one place that must use local time, and telling them apart is the whole skill:

/** The local calendar date. Use this to ask "what day is it for the customer?" */
export function toIsoDate(date: Date): string {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return `${year}-${month}-${day}`;
}
Question Function
What day is it for the customer? toIsoDate — local
Store this calendar date parseIsoDate — UTC midnight
Read that column back utcIsoDate — UTC

At 2am in Pune, toISOString().slice(0, 10) names yesterday. At 6am, setHours on a UTC-parsed date names yesterday. Both are wrong in opposite directions, which is why you need both functions and a rule for choosing.

Slots that can actually run out

A slot is a window the shop can fill. Four windows a day, each with a capacity, because one person on a scooter can only do so many drops between 4pm and 7pm.

The obvious way to check whether a slot is full:

const taken = await tx.order.count({ where: { slotId, slotDate } });
if (taken >= slot.capacity) throw AppException.slotFull();

Read it again after the stock lesson. It is the same read-then-write race, in a different costume: two checkouts both count 19 of 20 and both proceed.

So slots get a counter row and the same conditional-update trick:

model SlotBooking {
  slotId   String
  date     DateTime @db.Date
  booked   Int      @default(0)
  capacity Int

  @@unique([slotId, date])
}
// Create the row if today is the first order for this slot. createMany with
// skipDuplicates rather than findFirst-then-create, which would race two
// first-orders-of-the-day against each other.
await tx.slotBooking.createMany({
  data: [{ slotId, date, capacity: slot.capacity, booked: 0 }],
  skipDuplicates: true,
});

const claimed = await tx.slotBooking.updateMany({
  where: { slotId, date, booked: { lt: slot.capacity } },
  data: { booked: { increment: 1 } },
});

if (claimed.count === 0) throw AppException.slotFull();

Two details worth pausing on.

capacity is copied onto the booking row. If the shop reduces a slot's capacity from 20 to 10 next week, a day that already has 14 bookings must not retroactively become overbooked.

The row is created on first use, not seeded for every slot and every future date. Four slots times a year is fifteen hundred rows nobody reads.

A slot in the past is not a slot

export function slotIsBookable(
  slot: { date: string; startHour: number; remaining: number },
  now: Date,
): boolean {
  if (slot.remaining <= 0) return false;

  const today = toIsoDate(now);
  if (slot.date > today) return true;
  if (slot.date < today) return false;

  // An hour of notice, so the shop is not packing an order that is already late.
  return slot.startHour > now.getHours() + 1;
}

Note that now is an argument, not new Date() called inside. That single choice is what makes this testable: a test can assert that at 8pm today has no slots left, without mocking the clock or waiting until evening.

Do that everywhere you can. A function that reads the clock itself is a function you can only test at the wrong time of day.

The hour of notice is a business rule, not a technical one. A 4pm slot booked at 3:55pm is an order the shop has no chance of packing.

Fetching the availability without N+1

Four days, four slots. The obvious loop makes sixteen queries.

const bookings = await this.prisma.slotBooking.findMany({
  where: {
    slotId: { in: slots.map((slot) => slot.id) },
    date: { gte: parseIsoDate(isoDates[0]), lte: parseIsoDate(isoDates.at(-1)) },
  },
});

const bookedFor = new Map(
  bookings.map((b) => [`${b.slotId}|${utcIsoDate(b.date)}`, b.booked]),
);

One query, then a map keyed on slot and date. This is the N+1 pattern from module 6, and it is worth recognising that the fix always looks the same: fetch the set, index it in memory, look up.

Days with nothing left are still returned, with an empty list, so the interface can say "nothing left today" rather than silently skipping Tuesday and leaving the customer to wonder.

The address

@IsString()
@Matches(PINCODE_PATTERN, { message: "pincode must be six digits" })
pincode!: string;
export const PINCODE_PATTERN = /^[1-9][0-9]{5}$/;

Six digits, never starting with zero. That catches a typo. It does not catch a perfectly valid pincode 400 kilometres away, which is a different check with a different message:

if (!DELIVERY_PINCODES.includes(dto.pincode)) {
  throw AppException.outsideDeliveryArea(dto.pincode);
}

"That is not a pincode" and "we do not deliver there" are different problems. One is a mistake to fix; the other is a fact to accept. Collapsing them into one message makes a customer retype a correct address.

On the form, the pincode is a <select> of the three the shop serves, not a text box. A free-text field for a value with three valid answers only invites an address you will have to refuse.

Phone numbers

export function normalisePhone(input: string): string | null {
  const digits = input.replace(/\D/g, "");
  const ten = digits.length > 10 ? digits.slice(-10) : digits;
  return /^[6-9][0-9]{9}$/.test(ten) ? ten : null;
}

People type +91 98765 43210, 098765 43210, 98765-43210. All the same number. Strip everything that is not a digit, take the last ten, check it starts 6–9 as Indian mobile numbers do, and store one canonical form.

Storing what was typed means you can never match two records, and the delivery person's phone cannot dial half of them.

Saving it for next time

if (dto.saveAddressLabel) {
  await tx.address.create({ data: { userId, label: dto.saveAddressLabel, … } });
}

Saved separately from the order, and the order still keeps its own snapshot of where it went. The customer editing "Home" next year must not rewrite the history of where last year's dal was delivered.

Check your work

Why new Date("2026-09-30") plus setHours is wrong: the string parses as UTC midnight and setHours moves it to local midnight, which in IST is 18:30 on the previous day — and a DATE cast takes the UTC day.

Why the column is @db.Date: it cannot store a time, so it cannot store a wrong one.

When to use local time: only to answer "what day is it for the customer?" — toIsoDate. Storing and reading a calendar date is always UTC.

Why counting orders per slot is broken: it is read-then-write, exactly like stock. Two checkouts both count 19 of 20.

Why capacity is copied onto the booking row: lowering a slot's capacity must not retroactively overbook a day already sold.

Why createMany with skipDuplicates: find-then-create races two first-orders-of-the-day against each other; the unique index settles it instead.

Why slotIsBookable takes now: so a test can assert the 8pm behaviour without mocking the clock.

Why availability is one query: four days times four slots is sixteen round trips the obvious loop would make.

Why pincode shape and delivery area are separate checks: a typo and a valid address outside the area need different messages.

Why phone numbers are normalised: three spellings of one number means you can never match records, and half of them cannot be dialled.

Practice

  1. Place an order for tomorrow, then read slotDate straight from PostgreSQL with psql. Confirm it is the day you chose.
  2. Put setHours(0,0,0,0) back into the date handling and repeat. Watch the stored date go back a day.
  3. Set your machine's timezone to something west of UTC and run both versions again. Note which direction the error goes.
  4. Order twice into the 7pm–9pm slot, which seeds with capacity 2. Confirm the third is a 409 with code SLOT_FULL.
  5. Reload the checkout page and confirm the full slot is no longer offered.
  6. Replace the conditional update with order.count() and fire two checkouts at once. Confirm you can overbook.
  7. Call slotIsBookable directly with a now of 8pm and confirm today's evening slot is excluded.
  8. Submit the pincode 012345 and then 560001. Confirm two different messages, and that only one of them suggests a typo.
  9. Submit the phone number as +91 98765 43210 and check what is stored.
  10. Count the queries GET /api/delivery-slots makes by watching the Prisma query log. Then write the naive loop version and count again.

Next: what happens after the customer pays — orders, statuses and the screens the shop owner lives in.

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