Weekend 3: A Micro-Loan Portfolio Simulator
The original brief was a public sandbox with sliders. The honest version is a private tracker. I built the private version first — without the auth — and learned more from that small inversion than from anything else this weekend.
The fourth prototype of the Mridula Initiative is live at portfolio-simulator.adityaksingh.dev: a prototype for managing a multi-SHG micro-loan portfolio. KPI dashboard, per-SHG drill-downs, a repayment ledger you can mark paid or restructure, a what-if scenarios page with sliders for default rate, interest haircut, and reinvestment.
The original plan called this a public "simulator" — sliders on a fictional dataset, the math made legible. Two conversations in, the framing flipped. This is the tool I'll use to track real capital when I start deploying it. The simulator is the prototype of that tracker. The scenarios page is the bonus.
What follows isn't a build log. It's a note on the framing flip, the one design decision that shaped everything downstream, and the question I'm leaving with.
The framing flip
For three projects in a row, "what is this thing?" has been the hardest question, and it has been answered the same way three times: by holding the build to a single, narrow contract and refusing to let it grow into the thing it sort of resembles. Mandi explorer: a discovery surface, not a price-prediction tool. SHG chatbot: a citations engine, not a regulatory assistant. Organic listings: an inquiry handoff, not a marketplace.
Project 4 wanted to be a "public sandbox" — anyone could land on it, fiddle the sliders, see how a ₹5L revolving fund behaves under different assumptions. Educational. Demo-able. Honest about being a model rather than a tool.
Then I sat with what I actually wanted from this. I wanted somewhere to track real loans. To see which SHG was current and which was slipping. To know what the portfolio's effective yield was after defaults, not in the abstract. The "public sandbox" framing was the convenient version. The "private tracker that I happen to be building before I have real data" framing was the honest one.
So I rebuilt the brief: this is the v0 of a private portfolio tracker. The simulator-with-sliders is one screen of that tracker, run against a curated baseline dataset. Anyone visiting the URL sees the same baseline; anyone editing edits a local copy in their own browser; nobody sees anyone else's edits because there's no backend.
That last sentence is the one that did the work.
Why localStorage, not auth
Once "this will eventually hold real capital" was the frame, the obvious next move was Supabase Auth + Row-Level Security. Lock it behind a login, give myself a single account, deploy.
I started down that path and stopped. There's no real capital deployed yet. Building auth + RLS for a tool that has nothing to track is over-engineering with no feedback loop. The whole point of a v0 is to find out what's awkward about the data model and the UX before either gets cemented. Wrapping that in a login wall would mostly mean nobody else — me from six months ago included — can poke at it and tell me what's wrong.
So the prototype lives in localStorage. A zustand store with persist middleware writes every change to a single key (mridula-portfolio-v1). The seed dataset is hardcoded in lib/seed.ts — four fictional SHGs in pseudo-villages in Kanpur Dehat, four loans across them, a couple of repayment ledgers, an active amendment, a write-off, a catalytic grant. Anyone visiting the URL starts from that baseline. "Reset to baseline" in the header wipes their key and rehydrates.
The cost is that the storage layer gets thrown away when v2 starts. The benefit — and it's the real one — is that the entire data model, the finance lib, the charts, and the UX get one round of honest use against realistic data before any of it gets cemented behind auth. The TypeScript types in lib/types.ts map one-to-one to the Postgres tables v2 will use. The migration, when it happens, is mechanical. I've sketched the SQL with the RLS policies already, so I'm not pretending the v2 work is free.
The framing flip is what made the localStorage choice obvious. If this were a public sandbox, localStorage would be a hack. As the v0 of a single-user tracker with no real data to protect yet, it's the smallest thing that lets the rest of the prototype get exercised.
What was actually hard
Not the charts. Not the forms. The genuinely hard parts were three quiet data-modelling decisions that each looked small and each shaped everything downstream.
Pre-generate the repayment schedule at disbursement. Two options for tracking a loan's installments: compute the schedule on the fly from principal × rate × tenure whenever a page needs it, or pre-generate one row per scheduled installment when the loan is created. I went with the second. The reason is PAR — "portfolio at risk," the percentage of outstanding principal on loans where a scheduled payment is overdue. PAR is the only number on the dashboard that actually says something about the portfolio's health, and it requires comparing scheduled-to-this-date against received-to-this-date. With pre-generated rows, "actual vs scheduled" is a row filter on amount_received_paise IS NULL. With on-the-fly schedules, it's a virtual join every time. Partial payments survive naturally. The downside is that rescheduling becomes a write event — a LoanAmendment row plus a function that deletes only future-unpaid rows and rebuilds them. Paid history stays untouched. That tradeoff is worth it.
All money in integer paise. 1 INR = 100 paise. Every amount in the system is stored as an integer count of paise. Floats and rupee decimals diverge silently inside the XIRR, PAR, and projection math — drift of even ₹0.30 across a year's repayments shows up as visibly wrong totals on the dashboard. Integer arithmetic is the only reliable choice. Formatting back to ₹1,23,456 happens at the edge via a formatINR() helper that uses Intl.NumberFormat("en-IN") for the lakh/crore grouping automatically. That's one of those standard-library wins where the right answer is "trust the locale" and not "implement Indian number grouping by hand."
ISO date strings end-to-end. localStorage serialises Date objects to strings on write and never restores them. Round-tripping JSON.parse(JSON.stringify(seed)) gives you string where Date used to be, and downstream date-fns calls throw. The fix is to never use Date in the persisted shape at all. Dates are ISODate strings (YYYY-MM-DD) throughout the codebase, parsed via parseDate() only at the moment date-fns needs to do math. This is the kind of bug that doesn't surface until your serialised data round-trips through storage, which is to say, the moment a user reloads the page. The fix is small, but I didn't expect to need it.
A fourth decision, less hard but worth flagging: catalytic grants are modelled as loans with 0% interest. A catalytic grant doesn't get repaid — it's a one-shot capital outlay for a skills program or equipment that won't generate loan revenue. Representing it as a separate entity would have doubled the table count and the chart code. Instead, the SHG carries a capital_bucket field ('recoverable' | 'catalytic'), and catalytic loans flow through the same lifecycle with interest_rate_pct = 0. The dashboard separates "catalytic deployed" from "recoverable outstanding" so the bucket distinction stays legible. v2 keeps this pattern.
XIRR via bisection, not Newton-Raphson
One more thing worth a note for anyone building something similar. The portfolio's effective annualised yield is XIRR — internal rate of return on the irregular cashflows of disbursements out and repayments in. The standard implementation is Newton-Raphson on the NPV function.
Newton's method on a portfolio with even modest defaults oscillates badly. The NPV curve is monotonic on the relevant range but the first derivative is small near the root for any non-trivial portfolio, and Newton happily shoots off to negative infinity given the chance. I tried it. It failed on three of the four seeded scenarios.
Bisection on [-0.99, 10.0] of the daily-NPV root, annualised via (1 + r)^365 − 1, always converges and is fast enough at sub-millisecond per portfolio. The implementation is maybe twenty lines. There's a finance-engineer's instinct that says Newton is "the proper way" — it isn't, not for this. Bisection is what works.
I show the money multiple (total_received / total_disbursed) alongside XIRR for the same reason. Money multiple is what gets quoted in actual partner conversations. XIRR is the academic anchor. Showing both keeps the dashboard honest about which question is being answered.
What I cut
- Authentication. The whole point of v2. Cut from v0 because there's no real data to protect yet, and the feedback loop on UX is worth more than the security theatre would be.
- Document upload. Loan agreements, signed MoUs, monitoring photos. Needs Supabase Storage with per-user prefix RLS — out of scope without a backend. Specced but not built.
- Multi-user sharing with NGO partners. A read-only view into specific SHGs for the partner managing the field relationship. This is probably the most important v2 feature for actually using the tool with anyone else.
- Audit trail. "Who marked this repayment paid and when?" There's currently no notion of identity, so there's nothing to log. v2 with auth gets this for free from
auth.uid(). - Real bank reconciliation. UPI and bank transactions reconciled against the repayment ledger. Big problem in its own right, and the kind of thing that wants its own design pass before it gets built.
- A WhatsApp share path. Generate a monthly statement image or PDF and share it with the NGO partner. Nice-to-have, not on the critical path.
- Streaming charts, animations, gradient fills. The charts are Recharts defaults. They could look prettier. They're already legible, which is the bar that matters at the prototype stage.
What I learned
The framing question was the work. "What is this thing?" — public sandbox or private tracker — looked like a thirty-second decision and was actually the central design move of the project. Once it flipped, everything downstream simplified: no demo-friendly features that wouldn't have survived to v2, no public-trust UX (anonymous feedback widgets, share buttons) that the real version wouldn't want. The localStorage choice, the seed dataset's scope, the absence of auth — all of it follows.
The data model is the spec. This is the third Mridula prototype where the v0 explicitly carries a v2 data model. The TypeScript types in lib/types.ts are the schema v2 will inherit. Catching awkwardness now — "do amendments need their own row, or can I shoehorn them into the loan?" (own row), "should partial payments be a separate entity or a column on the installment?" (column) — is what the prototype is for. Once auth lands, schema changes get more expensive.
Integer arithmetic and stringly-typed dates are not a code-smell. They're both responses to specific failures (float drift, Date round-tripping through localStorage) that I would not have predicted from a clean-room design. The cleanest API has Date objects and number rupees; the cleanest implementation has ISODate strings and Paise integers. Trust the implementation constraints when they push back on the API.
Recharts is fine. I expected to want d3 again, like in the mandi explorer. For a dashboard that just needs line + area + stacked bar + a tooltip, Recharts gets out of the way faster. The mental cost of leaving d3 was lower than I'd expected and the wall-clock saving was real.
The question I can't answer from Bengaluru
Is a dashboard the right shape of tool for someone running a five-SHG portfolio?
I've built this assuming yes — that the right interface is a KPI dashboard with drill-downs, a ledger view, a scenarios page. That assumption comes from the world I know: SaaS, enterprise dashboards, investor reporting, the language of "portfolio at risk" and "money multiple."
The honest answer is that I don't know how someone actually managing a five-SHG portfolio at the field level thinks about it. They might not think in dashboards at all. They might think in a notebook, or in a WhatsApp group, or in a spreadsheet a friend sent them in 2019. The vocabulary I've baked into this — PAR-30, PAR-90, XIRR, recoverable vs catalytic — is the vocabulary of microfinance reporting, not necessarily of the people doing the work.
That's the gap a Kanpur visit closes. Not "show them the dashboard and ask if they like it" — that produces polite nods. The question is "show me how you keep track of what's owed to you today, and what's late, and what's gone bad." Whatever vocabulary they reach for, that's the vocabulary the v2 tool needs to speak.
Until then, the simulator is a prototype of a tool I think I want, built against data I think is realistic, with assumptions calibrated from NABARD and Sa-Dhan reports. Defensible, but not yet verified by anyone whose hands have been in the actual work.
What's next
The fifth project. The skill-gap visualiser for UP districts, using NSDC and NCS data, has been on the bench since the start. Whether it earns its place depends on whether the underlying data turns out to be usable when I actually go look — a question the build will answer.
After that, the Kanpur trip in October. The conversations that have been deferred for four prototypes happen there. Whatever I learn will probably retire some of these prototypes and reshape the others. That's the point.