TL;DR — I built PhysioFlow, a multi-tenant SaaS for physiotherapy clinics in India: appointments, attendance, prepaid session billing, GST invoicing, online payments, a patient portal, and WhatsApp marketing. One codebase serves every clinic, and the hard requirement is that Clinic A can never see Clinic B's patients — in healthcare, a tenant leak isn't a bug, it's a breach. This is the architecture behind it, the decisions that actually mattered, and what shipping it solo (in public) taught me.
The problem I was actually solving
Indian physio clinics run on paper registers, WhatsApp, and a cashbook. Patients buy a package of sessions, come in a few times a week, and the front desk tracks who has sessions left, who owes money, and who hasn't shown up in two weeks — by hand. Existing software is either enterprise hospital software (too heavy, too expensive) or a generic appointment app that knows nothing about prepaid session series or Indian GST.
So the product is opinionated: a physiotherapist signs up, gets a 14-day trial clinic, and runs their whole front desk from it. But the moment you put multiple clinics' patient data in one database, the entire engineering problem becomes one question: how do you make a cross-tenant leak structurally impossible, not just unlikely?
The 9 layers between a form submit and a patient's record
Every request — a receptionist marking attendance, a patient booking online — passes through nine layers before it touches data. Each one is a real decision, not boilerplate:
- Client request — one Next.js 16 app, server-rendered, edge-cached.
- Auth & session — email/password via Supabase Auth; sessions refreshed in middleware on every request.
- Tenant resolution — every request is scoped to exactly one clinic, derived from the authenticated user, never trusted from the client.
- Row-Level Security — Postgres filters every row by clinic. This is the real boundary (more below).
- Validated server actions — business logic runs server-side; every input is validated at the boundary before it reaches the database.
- Append-only billing — GST Bill of Supply with gap-free, financial-year-aware numbering.
- Online payments — Razorpay order created server-side; a signature-verified webhook is the source of truth.
- Idempotency & audit — a double-submit can't double-charge; every change is logged who/what/when by the database itself.
- Observe & ship — errors captured (PII-scrubbed), tests gate every merge, deploy on green.
Four architecture decisions that actually mattered
1. RLS is the tenant boundary — not application code
The tempting way to build multi-tenancy is to add WHERE business_id = ? to every query and be careful. That's a boundary made of discipline, and discipline fails the one time someone writes a query in a hurry.
Instead, the boundary lives in Postgres. Every tenant-scoped table carries a business_id, and a Row-Level Security policy keyed on auth.uid() — through helper functions like pf_current_user_business_ids() — filters rows before the application ever sees them. Forget the WHERE clause and you get zero rows, not someone else's patients. The database refuses to leak.
And because "we have RLS" is a claim, not a proof, isolation is verified by a test: sign in as Clinic A, query Clinic B's data, assert you get nothing. That test runs in CI. The boundary is enforced and demonstrated.
2. Issued invoices are immutable — corrections are credit notes
The naive billing model lets you edit an invoice. Under Indian GST that's illegal, and it quietly destroys your audit trail. So PhysioFlow's billing is append-only: once an invoice is issued it can never be edited or deleted. A mistake is fixed by issuing a credit note that references the original — exactly how the law expects it.
Invoice numbers are gap-free and financial-year-aware (India's FY runs April–March), generated by a SECURITY DEFINER Postgres function so two concurrent requests can't ever claim the same number. Boring, unglamorous, and the single most legally important thing in the app.
3. The payment webhook is the source of truth — the browser is not
When a patient pays online, the obvious flow is: browser says "payment succeeded," app marks it paid. That's also how you get robbed. The amount and the success state never come from the client.
The order is created server-side with the amount in integer paise. The browser only kicks off the Razorpay checkout. What actually marks a payment captured is a signature-verified webhook from Razorpay — HMAC-checked, replay-safe — landing on the server. The browser's opinion is a hint; the webhook is the truth.
4. The shared database is a deliberate, documented compromise
Here's the honest one. During the trial phase, PhysioFlow runs on a Supabase project shared with my other apps, and every PhysioFlow object is namespaced with a pf_ prefix — pf_businesses, pf_profiles, pf_provision_new_clinic(). It let me move fast without standing up dedicated infrastructure for clinics that don't exist yet.
Would I do this for real patient data at scale? No — and the plan says so explicitly: before the first real clinic goes live, PhysioFlow migrates to its own dedicated Supabase project for hard patient-data isolation. Writing the compromise and its exit into the docs is the difference between a shortcut and a liability. (This is the same instinct as a single-user tool storing timestamps in IST: right for now, wrong for later, and you should know which is which.)
The numbers
- 9 phases, each spec'd, planned, and shipped one at a time
- 218 tests gating every merge — domain unit tests, RLS isolation tests, and Playwright end-to-end
- 0 cross-tenant leaks, proven by an isolation test in CI (not asserted by hope)
- Append-only invoicing with gap-free, FY-aware GST numbering
- Money as integer paise end to end — no floats touch currency
- DPDP-aware — consent and retention built in, not bolted on
The tech stack
Next.js 16 (App Router, Server Actions), Postgres + Row-Level Security on Supabase, TypeScript in strict mode, validation at every boundary, Razorpay for payments with HMAC-verified webhooks, Sentry for PII-scrubbed error tracking, Vitest + Playwright for tests, Tailwind v4 and shadcn/ui on the frontend, deployed on Vercel. India-first throughout: ₹ in paise, GSTIN, +91, WhatsApp, DPDP consent.
What building it taught me
1. Put the boundary in the system, not in your discipline. Anything that depends on every future query being written carefully will eventually leak. RLS, immutable invoices, and webhook-as-truth all move the guarantee from "I'll be careful" to "the system won't let me."
2. Build the failure states first. Empty, loading, and error states before the happy path. A clinic's front desk doesn't care about your demo flow — it cares what happens when the network drops mid-payment.
3. The boring parts are the hard parts. Hybrid search and pretty UI are a weekend. Gap-free invoice numbering under concurrency, idempotent payment capture, and an RLS test that actually proves isolation — that's where the weeks went, and that's what "production-grade" actually means.
I'm open to work
PhysioFlow is my proof that I can take a real, regulated, multi-tenant problem from zero to shipped — solo, in public, with the tests to back it. I'm looking for full-stack / AI engineering work: Next.js, Postgres, TypeScript, MCP servers, and LLM integration.
Book a free 30-minute consult →
Or reach out directly: theamargupta.tech@gmail.com
