
Manav — Astrology Booking Platform (Freelance)
- React
- TypeScript
- Bun
- Express
- PostgreSQL
- Prisma
- Redis
- Razorpay
Overview
A full-stack, production booking platform for a professional astrologer. Customers browse consultation types, pick a time slot, pay, and receive a confirmed booking — including an auto-generated Google Meet link for video readings. The owner runs the entire business from a self-service admin dashboard: catalog, pricing, availability, calendar, revenue analytics, blog, and testimonials.
I was the sole full-stack engineer — design, build, deploy, and ops.
The hard problems here aren't the UI. They are never losing a paid booking and never double-selling the same time slot:
- Money is involved. If a browser dies between "payment captured" and "booking saved," the customer has paid for nothing. That has to be engineered out, not hoped away.
- Slots are a single, scarce resource. There is one astrologer, so two people must never pay for the same 3:00 PM slot. Under concurrency, naive check-then-insert logic double-books.
Architecture
Overview
Two independent deployables sharing a typed contract: a React 19 + Vite SPA and an Express 5 on Bun API, organized in routes → controllers → services layers with all business logic in individually unit-tested services.
Never lose a paid booking
- The booking is not created optimistically in the browser. The moment a Razorpay order is created — before the user pays — the entire intake is persisted as a
PendingBookingrow with the raw request body as JSON. - After capture, the real booking can be finalized by either the browser callback or the server-side
payment.capturedwebhook, whichever arrives first. Because the stored payload is verbatim, both paths run the exact same validated creation logic. - Idempotency is enforced in the database: a unique index on
razorpayOrderIdmakes the second finalizer a harmless no-op instead of a duplicate booking. - The webhook is registered before
express.json()and reads the raw body buffer, since Razorpay's signature is an HMAC over the exact bytes — parsing first would break verification. - If payment is captured but the booking can't be created, the pending row is marked
FAILEDwith a reason so an admin can refund or reschedule, and a reconciliation script sweeps for orphaned checkouts.
Never double-sell a slot
- Layer 1 — Redis hold: reaching the payment screen takes an atomic
SET NXwith a 10-minute TTL in Upstash Redis, so abandoned checkouts auto-expire with no cron. Release is a compare-and-delete Lua script, so a stale caller can never wipe a hold that has since been re-taken. - Layer 2 — DB constraint: a Postgres composite unique key on
(bookingDate, timeSlot, bookingType)is the ultimate backstop; even if the cache layer is bypassed or degraded, the database physically rejects the second insert. - Fail-open by design: if Redis is unreachable the hold fails open rather than blocking all bookings, and logs loudly that the lock is degraded. Availability degrades; correctness does not.
Engineering trade-offs
- Denormalized booking snapshots over foreign keys. Bookings store a copy of the service slug and name rather than an FK, so editing or deleting a service can never rewrite or break a historical record. Some duplication, in exchange for historical integrity.
- Services as data, not code. No fixed enum of service types — per-service behavior (question count, slot window, whether it generates a Meet link) lives in the row, so the owner restructures the catalog without a redeploy.
- OAuth2 refresh token over a service account. Consumer Gmail service accounts can't mint Meet links or invite attendees, so the owner connects their account once and the refresh token is persisted as a singleton.
- IST everywhere. Financial metrics key off
createdAt(when money moved) while schedule views key offbookingDate(when the consultation happens) — getting both date semantics right is what makes the analytics trustworthy. - Additive, transactional production migrations. Schema changes ship as additive, verified scripts — never destructive pushes against prod — with environment-isolated Neon databases for dev, test, and prod.