Case study

Institutional Finance & Research Platform

A multi-tenant ERP that runs budgeting, expenditure, settlement and research operations for a 39-department engineering institution. Live for 14+ months, 330+ active users, and ₹43.4 Cr of budget processed through workflows I designed and built.

Role
Architect and lead engineer
Team
Small team, founder-led
Period
2024 — present
Status
14+ months in production

React/TypeScript/Node.js/Express/MySQL/CASL/JWT/Google OAuth/Azure MSAL/Vitest

₹43.4 Cr

Budget processed

across 1,000+ proposals

330+

Active users

across 39 departments

570+

Finance requests

advances, claims, settlements

14 mo

In production

continuously, no rewrite

01 Context

Paper forms moving crores.

A 39-department institution ran its budgeting, expenditure and research funding on paper forms, spreadsheets and email. A single proposal could touch four desks before anyone committed money, and none of those handoffs left a record anyone could query later.

The consequences were mundane and expensive. Nobody could answer “how much of this head is already committed?” without phoning around. Approvals stalled with no way to see where. At audit time, reconstructing the trail for a single settlement meant digging through folders.

The brief was not “digitise the forms”. It was to make the money traceable — every rupee attributable to a head, a proposal, an approver and a date.

02 Approval engine

One engine, many workflows.

Budget proposals, expenditure claims, advances, settlements and journal submissions all look like different forms to a user. Structurally they are the same thing: a document that moves through ordered stages, where each stage can approve, return or reject, and where returning must not lose the history.

Building five separate workflows would have meant five places to fix every bug. I built one engine and configured the stages per document type.

A document moves through four ordered stages. Any stage can return it to the originator, which appends to its history rather than starting a new document.ORIGINATORraisesDEPT HEADverifiesFINANCEchecks headsAUTHORITYsanctionsreturned — appends an event, keeps one history
Any stage can return a document. It goes back as the same record with an added event, so a proposal returned twice still reads as one continuous trail.
  • OriginatorRaises a proposal against a budget head
  • Department headVerifies scope and available balance
  • FinanceChecks heads, ceilings and prior commitments
  • Sanctioning authorityApproves, returns or rejects with reason

Two decisions did most of the work. First, a returned document goes back to the originator as the same document with an added event, not a fresh copy — so a proposal rejected twice still reads as one continuous history. Second, the balance check runs against committed funds rather than spent funds, so two proposals racing against the same head cannot both pass.

03 Access control

Roles the institution owns.

The obvious implementation is an enum of roles in the codebase. It is also the one that guarantees a deploy every time the institution reshuffles a committee — and they reshuffle often.

So roles are rows, not constants. Each carries a code, a display name, and flags marking whether it is system-owned or administrator-assignable. An administrator composes roles and scopes them to an organisational unit — a department, centre, cell or professional body — without an engineer being involved.

Permissions are expressed with CASL and shared between server and client, so the same rules that authorise a request also decide whether a button renders. The UI never offers an action the API would refuse.

Authentication runs through two identity providers — Google and Microsoft — because staff and students were already split across both. Sessions are JWT; authorisation is always re-derived server-side.

04 Data model

Money needs an audit trail.

Finance software is judged on whether it can explain itself a year later. The schema is built around that: allocations, commitments and disbursements are separate concerns, and state changes are recorded as events rather than overwritten columns.

Allocation, commitment and disbursement are modelled separately, so available balance is allocation minus commitment rather than minus spend.ALLOCATION what a head was given for the yearCOMMITMENT what approved proposals have claimedDISBURSEMENT what actually left the accountavailable = allocation − commitment (not − disbursement)
Checking against committed rather than spent funds is what stops two proposals racing the same budget head from both being approved.

Every schema change ships as a forward migration — 33 of them so far — because a live system holding real financial records cannot be reset. Supporting documents (bank statements, annexures, claim evidence) are stored per category with validation on type and size, and referenced rather than embedded.

The parts most likely to cause a quiet financial error — annual rollups, claim ownership, workflow transitions, notification de-duplication — are covered by 35 test suites. That is where the tests are, because that is where a bug costs someone real money.

288

REST endpoints

across 23 route modules

118

MySQL tables

33 forward migrations

18

Domain modules

budget, finance, journal, events…

35

Test suites

workflow, claims, auth, rollups

05 Outcome

What it does now.

The platform has run continuously for 14+ months without a rewrite. It serves 330+ active users across 39 departments and has processed ₹43.4 Cr across 1,000+ budget proposals, plus 570+ finance requests covering advances, reconciliation and settlements.

The outcome I care about most is not a number. Questions that used to require a phone call — where is my proposal, what is left in this head, who approved this and when — are now answered by a page.

06 Retrospective

What I would change.

I would put the workflow engine behind a state machine definition sooner. The stages were configurable from early on, but the transition rules lived in code longer than they should have. Every new document type meant re-reading that code to be sure it was safe.

I would add the audit trail on day one, not after the first audit question. Retrofitting event history onto tables that already had rows is meaningfully harder than designing for it, and some early records are thinner than I would like.

I would introduce typed API contracts earlier. The client is TypeScript and the server is not, so the boundary was hand-maintained for too long. Generating clients from the OpenAPI specs — which exist for ten domains — would have removed a whole category of integration bug.

Want the detail?

The source is closed, but I am happy to walk through the architecture, the schema decisions or the approval engine in conversation.