Home
Projects
Blog
Let's Talk
Back to Projects
Client work · Under NDA

Hospital Management System

Live in 2 Hospitals

One system where the bed board, the patient's running bill and the cash drawer agree, because every counter posts to a double-entry ledger.

Solo build: design, code, deployment and operations.

hospitals live, one deployment each
2
real patient records
10k+
staff roles, least privilege
14
tables in the Postgres schema
109
Hospital overview dashboard with KPI cards, a patient flow chart and a needs-attention list

The problem

A private general hospital ran its front desk, outpatient queue, wards, pharmacy, diagnostics and cash counters on separate registers, and the accounting happened afterwards in a separate tool. An admitted patient's running bill, the bed board and the cash drawer never agreed in real time, and nobody could say what the hospital had earned on a given day until the books were reconciled by hand.

What I built

  • Front desk and OPD

    Registration with a lifetime patient ID, appointments, a daily OPD queue with per-doctor tokens, vitals and consultation.

  • Emergency

    Intake and triage, converted into an OPD visit or an admission.

  • IPD and beds

    Admissions register, bed allocation and ward transfers, nightly room and nursing charges, discharge summary and final bill.

  • Clinical records

    SOAP notes, ICD-10 diagnoses, problems, allergies, consent templates and versioned documents.

  • Lab and radiology

    Test and procedure catalogs, orders, specimens, imaging appointments, results and verification.

  • Theatre and blood bank

    Surgery scheduling with a checklist and anaesthesia record; donors, screening, component shelf life, cross-match and issue.

  • Pharmacy and inventory

    Batch and expiry stock, suppliers, purchase orders, goods receipts, transfers, returns and counter sales.

  • Cashier

    Counters and shifts with an opening float, denomination count, variance approval and partial refunds.

  • Insurance

    TPA companies, pre-authorisation and the claims lifecycle on each admission.

  • Accounting

    Chart of accounts, vouchers, day book, trial balance, P&L, balance sheet and receivables, all built from operational postings.

  • Doctor finance

    Referrers, commissions and doctor earnings by period.

  • HR and payroll

    Staff directory, rosters, attendance, leave, payroll periods and payslips, visible to HR only.

Screenshots

Inpatient dashboard with bed occupancy, ward occupancy bars and bed status tiles
The inpatient dashboard reads live bed occupancy, ward occupancy, length of stay and outstanding dues from the same place.
Financial dashboard with a billed versus collected chart and a payment mode breakdown
Billed revenue against cash actually collected, by day and by payment mode, possible because every counter posts into the same ledger.
Trial balance table listing every ledger with equal debit and credit totals
The trial balance across the chart of accounts, the check that postings from every counter stay balanced.

System architecture

Scroll sideways to see the whole diagram.Open full size
System architecture: staff browsers and public visitors reach Caddy over HTTPS; Caddy routes the app domain to a Next.js web container and the API subdomain to an Express API with in-process jobs; the API uses PostgreSQL 17 on an internal network and uploads documents to Cloudflare R2, which browsers read through presigned URLs; GitHub Actions pushes commit-tagged images and deploys through a script that backs up the database, pushes the schema, smoke-tests and rolls back.

Each hospital runs its own deployment with its own database and domain. The codebase is single-tenant on purpose, so a second hospital is a second Docker Compose project, not a tenant column. Inside a deployment, Caddy terminates TLS and routes the app domain to the Next.js container and the API subdomain to the Express API, which reaches PostgreSQL 17 over an internal-only network. Nightly IPD charges and operational alerts run in-process, patient documents live in Cloudflare R2 behind five-minute presigned URLs, and receipts and bills print to PDF in the browser. GitHub Actions builds commit-tagged images and deploys them through a script that backs up the database first and rolls back if a smoke test fails.

Data model

Scroll sideways to see the whole diagram.Open full size
Entity relationship diagram of the hospital system: departments, doctors, users and cashier shifts; patients, OPD visits and diagnostic orders; wards, beds, admissions, admission charges, fee receipts, insurance claims and invoices; payments, refunds, vouchers, voucher lines and ledgers.

The schema is built around a patient spine: a lifetime patient record, OPD visits and admissions, with an optional one-to-one link when an outpatient visit is referred to a ward. Money is a double-entry ledger: each voucher has balanced lines against typed ledgers, and the voucher row carries one nullable unique foreign key per source document type (payment, invoice, IPD charge, admission fee, diagnostic order, refund and more), so a second voucher for the same document is a database error rather than an application check. Payments are reversed instead of deleted, refunds are their own rows that every balance query subtracts, and wards are retired rather than removed so old admissions keep their history.

Key problems and how I solved them

01Every counter has to reconcile with the books

Problem
OPD fees, admission fees, lab and radiology orders, pharmacy sales, IPD deposits, refunds and purchases were each collected in their own module, and the accountant needed a day book, trial balance and P&L they could trust.
Approach
One posting path for every module: it builds the voucher with Decimal arithmetic, rejects negative lines and refuses to save unless debits equal credits, against a chart of accounts with fixed ledger codes. The voucher table has a nullable unique foreign key per source document, so idempotency lives in the database.
Why it was hard
Every module had to move onto the same path without breaking live behaviour, a retried request must never post twice, and rounding on a multi-line reversal can leave a voucher a fraction out of balance.
Result
Each collection posts debit cash or bank and credit the right revenue or receivable ledger, a duplicate posting fails on a unique constraint, reversal lines are rounded to the paisa so they always balance, and the reports read straight from posted entries.

02Revenue recognised the day it is earned

Problem
An inpatient stay can run for weeks. Booking all revenue at discharge hides what the hospital earned each day and leaves the patient's live dues unknown.
Approach
Every IPD charge, manual or created by the nightly job from ward-type templates, is written and posted in the same transaction: debit patient receivable, credit revenue and GST payable. Deposits taken before discharge are payments against the admission.
Why it was hard
The nightly job has to catch up on a missed night without charging twice, and the day book had to separate revenue earned, cash collected and direct cost per patient.
Result
A daily performance view in the day book (revenue, collections, direct cost and contribution) and a live outstanding balance on every admission.

03Two receptionists, one bed

Problem
The front desk and the emergency desk admit patients at the same time, and a bed that shows as free may have been taken a second earlier.
Approach
Inside the admission transaction the bed is claimed with a conditional update that only matches while its status is AVAILABLE. If no row changes, the request returns 409. One admit helper is shared by direct admission, emergency conversion and OPD referral.
Why it was hard
The transaction makes about nine sequential round trips (admission number, fee receipt, insurance claim), which overran Prisma's five-second default under serverless Postgres latency in development, so it runs with an explicit longer timeout. Emergency conversion used to have its own divergent code path.
Result
A lost race gets a clean 409 instead of a double-booked bed, and all three entry points produce the same admission number format and pending fee receipt.

04Cash shifts that match the drawer

Problem
Cashiers close shifts against a physical drawer. Partial refunds were dropping whole receipts from expected cash, and document numbers stamped in UTC put early-morning activity on the previous day.
Approach
Expected cash is the opening float plus cash receipts minus cash refunds paid out in the shift, and any variance needs a reason. Refunds are their own rows and every balance query subtracts them. Document numbers are stamped in the hospital's local time zone, and OPD visit numbers are sequential per local day with a retry on a unique-constraint collision.
Why it was hard
These bugs were silent: the totals or the date were wrong and nothing crashed. The fix had to be consistent across shifts, refunds, receivables, discharge deposits and the day book.
Result
Unit tests pin shift cash, refunds, voucher numbering and OPD numbering, and the money views agree with each other.

05Least privilege, including from the admin

Problem
Clinical staff should not see money, and nobody except HR should see salaries, not even the system administrator.
Approach
The JWT only identifies the user: every request reloads the user, role and departments from the database, so a deactivation or role change applies immediately. Routes declare their allowed roles, HR and payroll use an exact-role check with no admin bypass, and money fields are stripped from responses for non-finance roles. On the frontend, one access map drives the sidebar, redirects and each role's home page.
Why it was hard
The frontend and the API had drifted apart, so some roles could open pages the API refused and the other way round; bringing them back in line took several passes.
Result
14 roles, each with a usable home page, and payroll data the admin role cannot reach through the API.

06Deploying to a live hospital

Problem
One developer shipping often to a system that handles live cash and patient records.
Approach
CI builds, type-checks and tests, then publishes images tagged by commit. The deploy is a forced SSH command that validates its input, takes a lock, dumps the database, applies the schema with a push that refuses data loss, swaps the Compose and Caddy config, and smoke-tests the API health, the web root and a CORS preflight over HTTPS. Any failure restores the previous image tag and Caddy site.
Why it was hard
Schema, container and proxy changes have to roll back together, and a broken CORS rule passes a plain health check while breaking every browser request.
Result
Push-to-main deploys with automatic rollback, the last ten pre-deploy dumps kept, and a one-step redeploy of any earlier commit.

A request, end to end

Admit a patient, then collect the admission fee

  1. 1The receptionist picks the patient, ward, bed, consulting doctor and optional insurance details in the admission drawer on the IPD register.
  2. 2The client posts to /api/ipd/admissions; an axios interceptor adds the Bearer token.
  3. 3Caddy routes the API subdomain to the Express container.
  4. 4Middleware assigns a request ID, verifies the JWT and reloads the active user, role and departments from Postgres.
  5. 5The route checks the admission roles and the controller opens a transaction with an extended timeout.
  6. 6The admit helper claims the bed with a conditional update (409 if it is taken), generates the admission number, inserts the admission, marks a referring OPD visit, opens an insurance claim if needed and creates a pending admission fee receipt.
  7. 7The controller re-reads the admission with its patient, bed, doctors and receipt and returns 201, and the register updates.
  8. 8At the counter the cashier sees the pending fee and collects it against the open shift; a balanced voucher posts debit cash, credit admission-fee revenue, and an audit row records the request ID.
  9. 9Every night the charge job posts that day's room and nursing charges for the admission, each with its own revenue voucher, so dues and earned revenue stay current.

Stack

Frontend

Next.js 16React 19TypeScript

Backend

ExpressPrisma 7

Data

PostgreSQL 17Cloudflare R2

Infra

DockerCaddyGitHub Actions

What's next

  • Finer-grained capability permissions on top of the 14 roles.
  • Payroll posting into the general ledger.
  • Versioned, reviewable database migrations.
  • Multi-hospital tenancy inside a single deployment.