More Projects
59 in total
Saturnalia Platform
Lead Backend Engineer · Saturnalia 2025 - Thapar Institute · 2025
Express/TypeScript API on the Bun runtime for the Saturnalia festival: user and team registration, event and merch-store orders, Easebuzz payments with idempotent webhook reconciliation and a polling path that rescues a closed tab, Cloudflare R2 uploads, and templated transactional mail written into an outbox collection that a separate worker — Sat Mailer — drains. A single payment can settle a registration, six days of accommodation, six days of food and a merch cart for a team who are not the payer, so the transaction document carries a plan and settlement is the act of executing it. Eight LRU caches tiered by volatility sit under a request-scoped cache on top. The repo carries two deployment stories that disagree — Kubernetes manifests for a three-replica workload behind an nginx ingress, and a GitHub Action that rebuilds a docker-compose stack on a single VM. It replaced an earlier Go/Gin draft of the same service, where the sixteen-collection schema was designed as a text file before any handler was written.
10,000+ users
Project Details
Lead Backend Engineer
Saturnalia 2025 - Thapar Institute
2025
10,000+ users
Society · Infra
A festival backend is idle most of the year and then absorbs every registration, payment and merch order it will ever handle inside the run of the event. The interesting problems are all failure modes you only meet at that peak: a payment webhook that arrives twice, a user who closes the tab mid-checkout, a cart page that must not read a stale price.
What makes it harder than a normal storefront is that a festival order is not one thing. A single payment can settle an event registration, six days of accommodation, six days of food and a merch cart, for a team of people who are not the payer. So the money and the fulfilment cannot be the same object. The transaction document carries a plan — which registration to confirm, which users get which days flipped on, which cart lines become orders — and settlement is the act of executing that plan. Everything else in the design follows from separating the two.
Feature set
- Firebase ID token auth, accepted from a bearer header, a cookie or the body, with the resolved user record held in an LRU cache so a burst from one client costs one lookup. The profile read behind it is deliberately not cached, because role and registration state are the two things a stale answer would get wrong.
- Event registration for individuals and teams, with 6-character invite codes minted in a collision-check loop against Firestore, plus member add, remove and team deletion. Team creation and joining both run inside a Firestore transaction so a capacity check and the write that consumes the capacity cannot be separated.
- Custom registration fields validated per event against a schema held on the event document:
text, link (parsed with
new URL), number with min and max, single select and multi-select against a fixed option list. - A merch store: catalogue, cart, coupon validation, coin and discount calculation, checkout, order history, and cache introspection endpoints. Inventory is size-aware, and a negative inventory value means unlimited rather than an error.
- Payments across event registration, accommodation, food and store orders, with order ids
shaped
SAT25-YYYYMMDD-HHMMSS-RRRand a 1.5% transaction fee on the subtotal. The PhonePe SDK is pulled from PhonePe's own package repository rather than npm; the live path runs on Easebuzz and signs SHA-512 over a pipe-delimited string whose middle is ten empty udf slots. - Accommodation and food as a fixed six-day boolean struct written through Firestore batches, so one order updates a document set atomically. Pricing keys off event category, which is why a technical event and a cultural one produce different accommodation quotes from the same request.
- Cloudflare R2 uploads over the S3 multipart client, with multer spooling to a temp directory instead of memory and video transcoded in-process by a spawned ffmpeg before it leaves the box.
- 17 transactional mail templates as plain TypeScript functions, plus PDF invoices built with jsPDF by a layout class managing its own cursor and page breaks.
- Crash reporting on the cluster primary and each worker, rendering an HTML report and mailing it before exit.
The surface
Six routers under /api, plus a log viewer mounted outside it. Events carries six endpoints:
create team, register participant into an existing team by invite code, submit team, register
individual, remove team member, delete team. Users carries six: register, update details,
ambassador application, FCM token update, broadcast notification, delete user — where deletion
copies the document to a deletedUsers archive before removing it from users and from Firebase
Auth. Payments carries six: an accommodation quote, a coupon dry-run quote, initiation, status,
invoice download and the gateway webhook.
The store is the largest at seventeen endpoints, and it is the only router where auth is per handler rather than per router, because the catalogue reads are public and everything else is not. It covers item CRUD for admin and finance roles, five cart mutations, coupon validation, coin maths, a combined discount calculator, checkout, order history and per-order reads, and two cache introspection endpoints that exist purely so somebody can answer "is this stale" during the event.
The data model
Fourteen Firestore collections, and the document ids do most of the work. Team registrations are
keyed {eventId}_{inviteCode} and per-user registrations {eventId}_{userId}, which means the
uniqueness constraint that matters — one registration per person per event — is enforced by the
key rather than by a query. Transactions are keyed on the merchant order id, so the webhook and
the status poll converge on the same document without a lookup.
The transaction document holds a paymentIntentions object with four slots: event registration,
accommodation, food and store order. Each is either null or a description of exactly what to do
on success, down to which user gets which days and how much of the total is theirs. That is the
plan mentioned above, and it is what makes fulfilment replayable from the stored record instead of
from whatever the gateway happened to send back.
Around those sit users with a coins balance and an append-only coins history, ambassadors, campuses, events, coupons keyed on the uppercased code, coupon usage records, store items with per-size inventory, carts keyed on user id, orders written one document per cart line, an archive of completed payments, and a mail queue collection.
Reconciliation, not just webhooks
The webhook handler is idempotent by design. It reads the transaction document first, returns
immediately if statusFinalized is set, and fires side effects only when the gateway status has
actually changed. Fulfilment fans out to three handlers in parallel, each writing a Firestore batch.
The webhook alone is not enough, because the browser can vanish before it lands. A status endpoint
re-queries the gateway's retrieve API, maps the last transaction through the same status table and
invokes the same handlers. That polling path is what rescues a closed tab, and it verifies the
caller owns the order before answering. The status table is worth a look for what it says about
integrating with a real gateway: it maps seven vendor strings onto five internal ones, and one of
the keys is spelled initated, because that is how the gateway spells it.
Caches, tiered by volatility
Eight LRU caches sit in process with TTLs chosen per data type: inventory at 2 minutes, carts and
static data at 5 to 10, single store items at 30. On top of that a per-request Map is injected as
req.cache, so a handler touching the same document from four code paths pays for one read. This is
the sharpest edge in the design, and worth naming: the caches are per process, and the service forks
a worker per CPU behind three replicas, so an invalidation on one worker leaves stale copies until
they expire. The tiering was chosen so the stale window is short where it matters.
The request-scoped cache is the part that generalises. A payment quote for a team touches the team document, every member's user document and their accommodation state, and several of those get read again by the pricing helper and again by the validator. Memoising per request turned a double-digit read count into a single-digit one without any of the coherence problems the process-level caches have, because it lives and dies inside one handler.
Running it
Bun on alpine, running TypeScript directly in production with ffmpeg alongside. Kubernetes runs
three replicas behind an nginx ingress with request buffering off, which the streaming uploads
require, and probes on /health. Shutdown drains on SIGTERM with a 30-second force-exit timer, and
the primary refork on worker exit means a crashed worker is replaced before the load balancer
notices. A monitoring stack is configured as Helm values for Prometheus, Grafana and Alertmanager
on their own subdomains, and a Nix flake pins the dev shell.
The Go draft, and what the design survived
Before any of this existed in TypeScript there was a Gin service, and before that there was a text
file. schema.txt is a design document written ahead of any handler: users, events, registrations,
transactions, teams, banners, store items, redemptions, stories, map locations, reels, inventory
with movement logs, and bills. Its numbering says fourteen and the file actually carries sixteen
headings, which is a fair signal of how it was written. Events carry a Customfields array typed as
text, number, link, select, radio, date or time, so a coordinator defines a registration form
without a schema migration. Registrations carry a payment block with method, transaction id, order
id, amount and paid-at, alongside a QR string and a check-in status, so a gate scan and a refund
query hit the same row. Inventory logs record a signed quantity change with a distributor, a
receiver and a type of restock, distribution, return or correction, which is the shape you need if
you want to answer where forty T-shirts went. Bills carry a department, a submitter, receipt image
URLs and an approver that stays null until someone signs off.
The Go service written against that document got as far as a test route plus user create and read
through the Firebase Admin SDK, writing to the Realtime Database rather than Firestore. The event,
registration and payment route groups are checked in commented out, and user ids are "user_" + timestamp at one-second resolution, which does not survive two signups in the same second. Two
hundred and thirty lines of hand-written Go against a three-hundred-line schema document, with an
empty frontend/ directory beside it. A prototype, plainly.
Comparing the schema against what actually shipped here is the more useful exercise. Eight of the
fourteen numbered collections were never implemented at all: stories, reels, the coin store and its
redemptions, inventory and its movement logs, the notifications entry that never got fields, and the
opening screen. The custom-field types shipped as five rather than seven, dropping radio, date and
time and adding multi-select. Collections got renamed on the way — registrations became
userEventRegistrations, map locations became venues and lost their category and description — and
the banner model was rewritten so completely that it shares no field names with its own
specification. Several of the schema's typos also survived into production field names, which is the
small tax you pay for writing the design in a text file and then copying it into three languages.
Honest scope
The mail path is a gap in this repository rather than in the system. sendMail does not send
anything: it writes a pending document into an emails collection, with a comment saying a
background worker should drain it. Nothing here drains it. That consumer was built a month later as
Sat Mailer, a separately deployed worker with its own retry and backoff policy, so the durable queue
and the thing that empties it live in two repositories and meet only at the collection. The
in-process queue that sits in front of it is a plain array with no retry and no persistence, which
means it dies with the worker.
Order ids draw three random digits per second-resolution timestamp with no cross-worker coordination, which is 900 slots a second across three replicas — narrow enough to matter at a peak that only lasts an hour. Store inventory is decremented with a read-modify-write outside a transaction, so concurrent checkouts of the last few units can oversell. The reconciliation guard compares the stored raw gateway status against the mapped internal one, two vocabularies that never match, so it tends to fail open and re-run work the webhook already did.
Clustering only engages when NODE_ENV is production, which the container image does not set on
its own. There are two deployment stories in the repo and they disagree: a GitHub Action that
rebuilds and restarts a docker-compose stack on a single VM, and the Kubernetes manifests above.
The Prometheus stack is configured against an application that exposes no metrics endpoint. And
there is real dead weight in the tree — an optimised events router that is never mounted, the
PhonePe controller that nothing imports, three separate implementations of token verification and
two of the transaction fee formula.
Project Details
Lead Backend Engineer
Saturnalia 2025 - Thapar Institute
2025
10,000+ users
Society · Infra