Case study

Digitising the paper ledger of village savings groups

A Phoenix LiveView platform that replaces the paper ledgers of village savings and loan associations in rural Kenya. Savings, loans, fines, welfare funds and the end-of-cycle share-out become a shared, auditable record that field officers and group officials both work from.

Role
Lead engineer
Sector
Financial inclusion
Timeline
2025 to present
Client
Under wraps
  • Elixir
  • Phoenix LiveView
  • PostgreSQL
  • Vite
  • Tailwind CSS
  • Playwright
  • Ansible

The problem

A village savings and loan association is a community bank with no building. Fifteen to thirty neighbours meet weekly, buy shares (their savings), borrow from the pooled fund, repay with interest, and pay into a welfare fund for emergencies. At the end of a fixed cycle the fund is shared out, every shilling distributed back to members in proportion to what they saved.

All of it runs on paper. Three ledger books, a metal cash box with three padlocks, and a treasurer doing compound interest by hand in front of thirty people. The arithmetic is genuinely hard: interest per loan type, recurring late penalties that accrue per overdue period, a share-out that has to settle every outstanding loan before it can divide the remainder fairly. A single arithmetic slip compounds silently for months and surfaces at share-out, when the money is supposed to come out, which is the worst possible moment for a group whose entire operating capital is the trust between its members.

The sponsoring foundation had a second problem stacked on the first: no visibility. Field officers supporting dozens of groups across several counties had no way to know which groups were still meeting, which had quietly stopped, or where the numbers had drifted, short of driving out and reading the books.

What I built

One Phoenix LiveView application that is the group's ledger, the officer's caseload tool and the foundation's oversight dashboard. One system, three audiences, with the business logic held in contexts and never in the view layer.

For the group

A meeting walked through step by step: attendance, savings, loan applications, repayments, welfare fund, late fines, expenses, close. Each section moves through the same enter, approve, report flow, so the app mirrors the sitting rather than fighting it, and nothing is committed until the meeting approves it out loud. Cycle setup covers share price, loan types, eligibility, welfare rules and the penalty regime. Share-out settles every outstanding loan, then computes and distributes each member's payout against a checklist, so a group can stop halfway and resume.

For the field officer

A caseload dashboard of the groups they look after, each group's meeting schedule and a calendar of who is meeting when. Exceptions to the pattern, missed meetings, moved meetings and one-off sittings, are recorded rather than silently absent. Visit notes are written up against a group and a date, and an adoption summary per group lets an officer see which groups have stopped entering data before the foundation asks.

For the foundation

Adoption analytics measured by the date a group met, not the date a row reached the database, so importing a year of history does not register as a year of usage. An audit trail covering every action by a signed-in user with field-level diffs. Bulk SMS with templating and a draft-and-approve path. Bulk import for groups joining with years of paper behind them. A county, subcounty, ward and village hierarchy with meeting points drawn over administrative boundaries.

Decisions worth talking about

The money maths is a pure module

Every interest, penalty, overdue-period and balance calculation is side-effect free, with no database access, so the same inputs always give the same outputs. The context modules supply the lookups that feed it.

This is the least fashionable decision in the codebase and the one that mattered most. The arithmetic is the part where a bug costs someone real money, and pulling it away from the database turned it into something that can be exhaustively tested, including with property-based tests that assert invariants across generated inputs rather than a handful of examples.

That discipline found a real defect. The one-time login code was generated from two random bytes reduced modulo 10,000. Two bytes give a uniform value over 65,536 possibilities, and 65,536 is not a multiple of 10,000, so codes 0000 to 5535 were about 17% more likely than the rest. Invisible in every example-based test; obvious the moment you assert uniformity over a generated distribution.

Loans are an append-only ledger, with immutability enforced in Postgres

Loans began as mutable summary rows: a balance column that got updated. That is fine until you need to answer "why does this member owe this much?", and a mutable column cannot answer that.

The current model is an append-only ledger. Each row is one money movement, whether disbursement, interest, late charge, repayment, share-out settlement or reversal, with a monotonic sequence number and the balance after it, both assigned under a row lock on the loan so concurrent appends stay totally ordered. Current state is a database view that folds the events, so the balance is derived, never stored twice.

The part I would defend hardest: a Postgres trigger rejects every update and delete on that table. Immutability that lives only in application code is a convention, and conventions lose to a hurried migration script at 2am. A correction is made by appending a reversal event, and there is no other option available to anyone, including me. Imported events carry a fingerprint, so re-running an import is idempotent rather than duplicating a group's history.

The meeting row is the workflow state machine

Rather than a status enum, a meeting carries one boolean per approvable section: attendance marked, savings confirmed, loans approved, repayments approved, welfare approved, fines approved, expenses approved, then closed.

Unconventional, and right for this domain. A real meeting does not progress through linear states. The group approves savings, gets sidetracked into a loan argument, comes back. Booleans let sections be completed in whatever order the room takes them while still making "can this meeting close?" a single, honest question.

Auditing without touching a single call site

The audit trail had to cover everything, and everything meant every screen in the application. Instrumenting them by hand would have been a sprawling diff that decays the moment someone forgets. Two mechanisms instead.

Actions are captured from the telemetry spans LiveView already emits. Crucially that covers component events too: component-targeted forms and buttons never reach a LiveView event handler, so a hook-based approach would have silently missed a large share of the app. Data changes go through a drop-in stand-in for the repo that records the changeset's real before and after diff and delegates everything else, so a context opts in with one alias line and every existing insert, update and delete in it becomes audited with no other change.

Writes happen off the request path, and failures are logged and swallowed: failing to audit an action must never break the action. Passwords and national ID numbers are stripped from every recorded diff. Form validation events fire on every keystroke, so those are skipped, along with the audit dashboard's own filter events, which would otherwise make reading the audit trail generate audit trail.

Importing history is two-phase, and partial by design

Groups arrive with years of paper. The importer analyses first: parse and resolve the file against the group, write nothing, and report valid rows, per-row failures, and which backdated meetings would have to be created. Only after an admin approves does it commit.

Commit is deliberately partial. Bad rows are collected and reported, not rolled back. An all-or-nothing import of 900 rows fails on row 847 and teaches the user nothing; a partial import gets 846 rows in and hands back a file of what to fix, in the same shape it was uploaded, so the fix-and-retry loop is edit and reupload. Each template is generated from the parser's own header list, so template and parser cannot drift apart.

Decisions that came from the field, not the whiteboard

  • Login is a phone code, not a password. Group treasurers own basic phones and share them. A one-time code sent by SMS is the only credential that survives contact with that reality.
  • Field officer screens are pages, not modals. Every step is its own URL, so it works on a phone and can be linked to directly from a message.
  • A village gazetteer instead of a dropdown. Villages are the one level of the location hierarchy nobody can pre-seed, so the same place arrives spelled three ways from three groups. The app ships a compile-time gazetteer of populated places per ward and offers the established spelling as a hint. It covers about two thirds of wards and never pretends to be exhaustive, so the field stays free text: an unlisted village is a real village, not a validation error.
  • Money is stored as integers. Whole shillings, everywhere. No floats anywhere near a balance.

Testing and the quality bar

The suite runs unit, property-based and end-to-end tests, with a 100% coverage floor enforced in CI.

A 100% coverage floor is a blunt instrument and I would not reach for it on every project. Here the argument is specific: this codebase moves other people's savings, the users cannot inspect the arithmetic, and there is no support desk between a bug and a meeting where thirty people are told their numbers are wrong. A hard floor makes "I'll add the test later" impossible rather than merely discouraged.

Alongside it, a habit I have kept: a catalogue of known defects found by systematic read-through, each with a test that asserts the correct behaviour and is excluded from the default run. The suite stays green, the defects stay written down and reproducible, and fixing one is a matter of deleting a tag. Eleven such tests exist today. A known bug with a failing test attached is a task; a known bug in someone's head is a rumour.

Making CI honest again

The test job took about eleven minutes, nearly all of it compilation, and everyone had stopped reading it. The causes were layered. The lint job ran a cold build of every dependency and the whole tree, four and a half minutes of which the linter itself was eleven seconds, and the test job then compiled the whole tree again under a different environment. The build cache was never being written, because the cache action saves in a post step that is skipped when a job fails, and the main branch had been red, so no run ever seeded a cache for the next one to restore. Coverage ran through an alias that rebuilt the entire front-end bundle every time.

The fixes: one environment for the whole workflow so lint and test share a build directory; split cache restore and save so even a failing run seeds the cache; content-hash the built assets into their own cache and skip the front-end toolchain entirely on a hit; start the runner image's own PostgreSQL in about three seconds instead of pulling a service container; and drop a forced recompile that has been unnecessary since the compiler started storing warnings in its manifest.

Then the suite itself. Well over a third of the test files declared no async setting and ran serially, more than half the wall-clock time of the run. Rather than tag them one by one I flipped the default, so the shared test cases run asynchronously unless a case explicitly opts out and synchronous is the thing you have to ask for. That left a handful of honest opt-outs, all genuine global state. Two real bugs had to be fixed before that was safe, plus a memorable one where a property test built dates from a process-wide unique integer that climbed into the millions under parallel load and generated the year 878871.

The local suite went from 23.3 seconds to 14.2, of which synchronous time went from 18.4 seconds to 1.6, green across eight random seeds. CI went from eleven minutes to a single compile, and the lint job, which needs no database and no assets, now runs alongside the tests and finishes in about fifteen seconds, off the critical path entirely.

Shipping it

Deployment is Ansible, not a platform-as-a-service. The playbook builds the release inside an image matching the server's architecture, ships the tarball, renders a locked-down environment file, runs migrations and manages a systemd unit, with release backups and pruning, and a preflight step that asserts every required secret is present so a deploy fails in the first ten seconds rather than halfway.

CI runs that same playbook: staging on every push to the main branch, production on a version tag. Because CI runs the identical playbook, a deploy from a laptop and a deploy from CI are the same deploy, which matters on a project where the fallback path gets used. Locally, a versioned pre-commit hook mirrors exactly the CI lint checks so they fail on your machine in seconds instead of in CI in minutes. Tests are deliberately left out of the hook, because a slow hook is a bypassed hook.

What this bought

A balance you can explain

Every shilling of a member's loan is the sum of events that produced it, and the database refuses to let any of them be edited. "Why do I owe this?" has an answer that does not depend on trusting the software.

Arithmetic that can be proved

Interest, penalties and share-out are pure functions, so they are tested against generated inputs rather than a few examples. That is how the login code's distribution bias was found, and it is the reason a treasurer is not the last line of defence.

Auditing nobody has to remember

Coverage came from telemetry and a repo stand-in rather than instrumented call sites, so a feature written next year is audited by default instead of whenever the author remembers.

CI people read again

Eleven minutes down to a single compile on the pull-request path, and the local suite's synchronous time from 18.4 seconds to 1.6. A suite that is quick is a suite that gets run.

Threads still open

Written honestly, because a case study that claims everything is finished is a case study nobody believes.

  • Two loan models coexist. The mutable balance and the append-only ledger are double-written during migration, with the legacy path still authoritative for the UI and a parity gate allowing a known offset for outstanding penalties. Retiring the legacy path is the next significant piece of work.
  • The service worker is disabled. The generated worker cached HTML navigations, which served stale session-bound tokens and put LiveView into a reload loop, most visibly right after logout. The registration code now actively unregisters any previously installed worker and clears its caches, so browsers that already had it recover on their next load. Offline caching is parked until the caching story is reworked around the auth lifecycle, because shipping a broken offline mode to users on intermittent connections is worse than shipping none.
  • Local-first sync is scaffolded, not shipped. Wired up behind an experimental screen and evaluated, but not on any user-facing path. It stays clearly fenced off rather than half-integrated.
  • Some early tables store bare UUID columns rather than proper associations, so no foreign key is enforced at that layer. Newer tables use real associations. A tidy-up with real consequences and no visible feature, which is exactly why it is still on the list.

Think your organisation has outgrown its systems?

Let's figure out what is actually broken.