
Saturdays — Food Delivery Platform
A consumer food delivery platform — customer app, restaurant API, rider layer and operator console on one Django backend.
- Live in production
- Sixteen
- 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
- React 18 + TypeScript SPA on Vite; TanStack Query for server state, Zustand for client state
- Thirteen Zod schemas mirroring the backend, validated at the boundary
- Django 5 + DRF, 16 domain apps, versioned at /api/v1 with a generated OpenAPI schema
- Per-app service modules; one canonical implementation per decision, shared by every caller
- PostgreSQL; public UUIDs distinct from primary keys, so ids are never enumerable
- Django Channels and Daphne over a Redis channel layer for order and rider tracking
- Celery and Beat — webhook relay, payment reconciliation, notification dispatch
- PhonePe Standard Checkout V2: hosted page, server-to-server webhook, reconciliation sweep
- An API-key surface for the POS, plus an HMAC-signed webhook relay outward
- 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
- React 18, TypeScript, Vite, Tailwind CSS, Radix UI, MUI
- TanStack Query, Zustand, react-hook-form, Zod
- Django 5, Django REST Framework, SimpleJWT, drf-spectacular
- PostgreSQL, Redis
- Celery, Celery Beat, Django Channels, Daphne
- PhonePe V2, Sentry, structlog, WhiteNoise
What it looks like




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