Co-operative savings and lending ledger
Three partners ran a member-owned savings-and-loan co-operative from one spreadsheet. I hold half of it and built the system that replaced the spreadsheet: exact money, a ledger that never erases, and a test that holds it to the old workbook.
A case study by Arif Hossen
Source: https://arifhossen.dev/work/co-operative-savings-and-lending-ledger
- Role: Partner and sole engineer
- Team: Three partners, one engineer
- Duration: About 3.5 months
- Stack: Laravel 13, Inertia, React 19, MySQL
- Period: May to Aug 2026
- Engagement: Partner, 50 percent
- 3 Partners the system was built for
- 85 Test files, about 447 test blocks by count
- 14 Unit test files, one per calculator or report
- 1 Old workbook replayed through the live code
A fund that lived in one workbook
Context
The cooperative is a member-owned savings and lending fund run by three partners. Members pay monthly subscriptions and hold fixed deposits. The pooled money is lent back to members. When the fund runs short, the partners borrow from outside lenders to top it up. Once a year the profit is split between the partners and accrued to every member.
All of it lived in one Excel workbook that three people reconciled by hand. I am one of the three. I hold half of the cooperative and all of its technology, which makes this the one case study where the client and the engineer are the same person. It is also why I held the system to a stricter standard than a ticket would have asked for. A wrong number here lands on someone's savings.
Exact decimals, enforced by the build
The money rule
Everything that touches money goes through one small helper. It does arithmetic on decimal strings at a fixed scale of two, and it normalises whatever number it is given before it calculates. The schema stores money in fixed-point columns and rates in their own columns at four places. No finance service does its own arithmetic.
A convention like that decays, so the rule is executable. Architecture tests fail the build if a finance service skips the helper, if a controller queries the database directly, or if a model calls a service. Breaking a layering rule is a failed run, not a review comment.
| Bug class |
What removes it |
| Floating-point drift in a balance |
One fixed-scale decimal helper, required by an architecture test |
| A stored total that disagrees with the ledger |
No stored running balances. Every figure is recomputed from the entries |
| Duplicate document numbers under simultaneous entry |
Numbers drawn from a locked sequence row inside a transaction, not count plus one |
| A year closed twice |
A check in code and a unique constraint in the schema |
The second row is a trade I made on purpose. Caching is allowed only once a calculation is proven correct and demonstrably slow. For three users, correct comes first.
Nothing is deleted, only voided
The ledger
Partners enter payments on a phone, in a hurry, and some entries will be wrong. In a spreadsheet the fix is to type over the cell, and the record of the mistake goes with it. Here every financial row has a void path instead of a delete path. Voiding records who did it, when, and a written reason. The row stays in the table and one shared query scope keeps it out of the active totals.
The behaviour lives in one reusable trait, so subscriptions, deposits, withdrawals, cash-book entries and loan installments all work the same way. Members, loans and fiscal years have no delete route at all. Changes to the records that matter are logged field by field with the person responsible, and removing a user never removes the rows they touched.
Nothing gets erased. It gets marked cancelled, with a reason and a name. That is a sentence a partner can say to a member.
A profit split that always adds up
The year close
Once a year the partners divide the profit between themselves and accrue profit to every member. It runs once a year, so a bug has eleven months to hide. I split the close into a preview and an execution that share one computation. What the three partners approve on screen is exactly what gets written.
The partner shares use a remainder-absorbing rule. Each of the first partners takes their percentage of net profit, and the last takes whatever is left. The decimal helper truncates instead of rounding, so without the rule a fraction would vanish at every step. With it the shares always sum to the profit.
<!-- diagram:year_close -->
Diagram: The year close
Every figure is recomputed from the entries. The partners approve a preview, then one transaction writes the shares and marks the year closed.
| Partner |
Share |
Amount |
| First |
50 percent |
50.00 |
| Second |
25 percent |
25.00 |
| Third, takes the remainder |
25 percent |
25.01 |
Net profit of 100.01. Illustrative numbers, not the partners' real shares.
Partners and members can forfeit their share. Forfeited amounts roll into a retained total instead of leaving a hole in the books, and the profit and loss does not move. The execution runs in one transaction that also marks the year closed. It refuses to start on a year that is already closed, and a unique constraint backs that check against two people pressing the button together.
The old workbook as an acceptance test
Proof
Asking three people to trust a program with other people's money because it looks right is not a migration plan, so the cutover became a test. A fixture holds opening balances in the workbook's shape. The test replays a month of activity through the real services, not stubs: subscriptions, deposits, withdrawals, a loan, its repayments and an outside borrowing.
It then asserts cash on hand, the balance sheet, the active-loan summary and the month's collections against exact decimal values, the same numbers a partner would reach by tallying by hand. A second case adds an outside borrowing and asserts that the balance sheet still balances. A one-paisa regression fails the build. A comment at the top of the file says what is at stake: if this fails, the partners cannot trust the app with real data.
[!CAUTION]
The fixture uses round stand-in figures, not the cooperative's real balances. What the test proves is that the live code reproduces a hand tally for a worked month. It does not prove that every historical figure was checked.
Moving rows I had booked wrongly
Changing live data
The first version had no concept of money the partners borrowed. When the fund ran short, those receipts were entered as income. That overstated profit, and profit is the number the year-end split divides. I added outside borrowings as a real liability, with the lender, a rate or flat interest, and repayments split into principal and interest.
For the rows already in the ledger I wrote an interactive console command instead of a bulk update. It shows each open income row, asks whether it is really a borrowing, then asks whether the lender was a partner. Both prompts default to no, so pressing Enter changes nothing, and it reports every row it converts. It can also be pointed at specific entries. It is slower than a script, and it is the only version I would run against a ledger with real balances in it.
Statements a member can read
Members
The members read Bengali, so a statement in English digits is a document they cannot check, and checking is the point of handing one over. Reports print in Bengali or English on request, independent of the language a partner is working in. Digits become Bengali numerals, money is formatted for the chosen language, and names are stored in both scripts from the start.
The balance sheet, profit and loss, member and loan statements, the profit projection and payment receipts all share one code path and one translation file.
What this does not have yet
What I would do differently
There is no authorization layer. Every signed-in user is one of three partners and can read and write everything, a deliberate simplification for three trusted people. It stops being acceptable the day a member gets a login, and I would add policies before that day, not after.
Everything is computed live and the PDFs render inside the request. That is right at this size and would not survive thousands of members. I have not recorded how many members or transactions the system carries, so this page makes no usage claim.