Saturnalia Platform
Live
Society · Infra

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.

SCALE

10,000+ users

Built with
TypeScriptTypeScript
BunBun
ExpressExpress
GoGo
GinGin
FirebaseFirebase
FirestoreFirestore
EasebuzzEasebuzz
Cloudflare R2Cloudflare R2
FFmpegFFmpeg
KubernetesKubernetes
DockerDocker
Project Details

STATUS
Live
ROLE

Lead Backend Engineer

ORGANISATION

Saturnalia 2025 - Thapar Institute

YEAR

2025

SCALE

10,000+ users

TYPE

Society · Infra

TAGS
Platform
Payments
Kubernetes
Scale
Go
Backend

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-RRR and 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

STATUS
Live
ROLE

Lead Backend Engineer

ORGANISATION

Saturnalia 2025 - Thapar Institute

YEAR

2025

SCALE

10,000+ users

TYPE

Society · Infra

TAGS
Platform
Payments
Kubernetes
Scale
Go
Backend