Saturdays — Consumer food delivery

Saturdays — Food Delivery Platform

A consumer food delivery platform — customer app, restaurant API, rider layer and operator console on one Django backend.

Status
Live in production
Domain apps
Sixteen
Payments
PhonePe V2

What it had to solve

For a food delivery platform like Saturdays, ordering is a funnel with one honest measure of success: an order that is paid for. Every step between opening a menu and a confirmed payment is a place to lose someone — a slow catalogue, a cart that forgets itself, a checkout that fails silently on a bad connection. Four user classes then have to share that one record: the customer tracking it, the restaurant preparing it, the rider carrying it, and the platform accounting for it. The hard part is not any one of those screens. It is that all four read and write the same order, from different clients, at the same time — and only one of them can be trusted with the money.

The decisions that shaped it

Server-side truth for anything that matters

Prices, taxes, the dine-in charge waiver, payment amounts, rider-assignment signals and delivery-time limits are all enforced in the domain layer rather than the interface. The rule is uniform: if getting it wrong costs money or misleads a customer, the server decides and the client is told. An interface can only ever hide an option; it cannot stop a request.

One state machine, one payment gate

Every order moves through an explicit adjacency map — placed, confirmed, preparing, ready, picked up, out for delivery, delivered — and an illegal move raises a typed conflict carrying the attempted transition rather than silently doing nothing. A prepaid order cannot enter the fulfilment pipeline until its payment is settled, and because that check lives at the single transition point it holds for every actor alike: the partner portal, the POS integration and the payment webhook.

Money discipline in the payment path

Amounts are never accepted from the client — the create-payment endpoint takes an order id and computes the figure from the order’s own immutable total. The webhook logs the raw body before parsing it, verifies authentication, independently re-reads the gateway’s status API outside any transaction, then locks the row and re-checks under the lock; an amount mismatch flags for manual review instead of quietly succeeding. No database lock is ever held across a network call, and a refund commits its pending state before the call goes out.

A transactional outbox, not fire-and-forget

Cross-boundary events are written to an outbox table inside the same transaction as the state change that caused them, then drained by a scheduled worker with attempt counts, backoff and dead-lettering. That is what makes “the order was confirmed” and “the restaurant was told” either both true or both false, instead of a notification that fires just before a rollback.

The POS integration is an API-key boundary

DineGuru drives day-to-day restaurant operation over a separate versioned surface authenticated by an API key and scoped entirely to that key’s restaurant. Identity fields are read-only across it, so a restaurant cannot rename itself or clear a platform suspension; and the key lives encrypted on DineGuru’s server and never reaches a browser, which is also why no cross-origin surface had to be opened here.

A kill switch that cannot lock you out

Platform maintenance is a single config flag read by middleware mounted ahead of every view, returning a structured 503 with a Retry-After. The exempt paths are deliberately narrow — the admin control API, health checks, schema docs — so the switch that turns maintenance off is never behind the maintenance it turned on.

How the pieces sit together

Client
React 18 + TypeScript SPA on Vite; TanStack Query for server state, Zustand for client state
Contracts
Thirteen Zod schemas mirroring the backend, validated at the boundary
API
Django 5 + DRF, 16 domain apps, versioned at /api/v1 with a generated OpenAPI schema
Domain
Per-app service modules; one canonical implementation per decision, shared by every caller
Data
PostgreSQL; public UUIDs distinct from primary keys, so ids are never enumerable
Realtime
Django Channels and Daphne over a Redis channel layer for order and rider tracking
Async
Celery and Beat — webhook relay, payment reconciliation, notification dispatch
Payments
PhonePe Standard Checkout V2: hosted page, server-to-server webhook, reconciliation sweep
Partner
An API-key surface for the POS, plus an HMAC-signed webhook relay outward
Delivery
Front end on Vercel; backend Dockerised behind Gunicorn and Daphne, Sentry-instrumented

Built into the product

Live order tracking

A real state machine, an ETA, and a rider icon that appears only when the server reports an assignment — never a time-based guess.

Handover OTP

A hashed six-digit code verified at the door, with lockout after repeated wrong entries. One OTP system shared with the POS, not two.

Doorstep collection

A cash order can be collected on a platform payment QR, reusing the entire prepaid money path through a new entry point.

Offers and surge

A coupon rule engine with per-user and total caps, restaurant scoping, date windows, referral codes and surge windows.

Support with context

Tickets routed server-side to ops, finance or rider queues, with the payment reference auto-attached so finance never has to look it up.

Operator console

One read ledger over every order across all restaurants, two kinds of restaurant hold with different blast radius, and the platform switch.

What it is built out of

Front end
React 18, TypeScript, Vite, Tailwind CSS, Radix UI, MUI
State & forms
TanStack Query, Zustand, react-hook-form, Zod
API
Django 5, Django REST Framework, SimpleJWT, drf-spectacular
Data & cache
PostgreSQL, Redis
Async & realtime
Celery, Celery Beat, Django Channels, Daphne
Payments & ops
PhonePe V2, Sentry, structlog, WhiteNoise

What it looks like

Saturdays — Ordering starts here — one search across every kitchen on the platform.
Ordering starts here — one search across every kitchen on the platform.
Saturdays — The same entry point up close, before anything has been searched.
The same entry point up close, before anything has been searched.
Saturdays — Sign-in — one account across all four classes of user.
Sign-in — one account across all four classes of user.
Saturdays — The tracking layer, over the customer surface it reports into.
The tracking layer, over the customer surface it reports into.

What it produced

Live
Serving real traffic in production
16
Domain apps across 52 migrations
14
Pytest suites over payments, state and integration