Skip to content
Fintech2026

ChamaTrust

Chama savings and loans on a double-entry ledger that PostgreSQL itself refuses to unbalance.

chamatrust.netlify.app
Screenshot of ChamaTrust: Fintech, built with React, Express, PostgreSQL.

The problem

A chama runs on trust in whoever keeps the book. The treasurer records who paid, who borrowed and what the pot holds, and when the figures stop adding up nobody can tell a slip from a theft, because a notebook or a spreadsheet can be corrected quietly. Moving the book online does not fix that by itself. Application code that keeps accounts balanced is only as honest as its last bug, and M-Pesa does not deliver its messages once, in order, or always.

Decisions

01

The database enforces the accounting, not the application

Every shilling is a journal entry of debit and credit lines, and the rules that make it trustworthy are PostgreSQL triggers and constraints. A deferred trigger refuses any transaction whose entry does not balance; entries and lines are append-only; guarded accounts cannot go negative; a reversal must mirror the entry it cancels and can happen once; and composite foreign keys stop a line naming another chama’s account. A mistake is corrected by posting a reversing entry, never by editing, so the history members see is the history that happened. A mutation test drops each of the 17 rules in turn and proves the suite fails without it, so none of them is decoration.

02

Money is credited on Safaricom’s answer, not on its message

A payment callback can arrive twice, late, out of order or not at all, and anyone can post a fake one. ChamaTrust treats a callback as a reason to ask: a prompt is credited only when Safaricom’s STK query API confirms it, every posting carries an idempotency key built from the M-Pesa reference, and an unanswered prompt expires on its own. The domain test runs 10,000 random operations through the real services with callbacks delivered twice, twenty at once, out of order, forged and never, then checks seven invariants against the payments table, from a zero trial balance to no forged callback having credited anything.

03

A feature phone is a first-class client

Many members have no smartphone or no data, so the same services sit behind a USSD menu in English and Kiswahili: balance, paying a contribution, the merry-go-round, the loan and a mini statement, behind a PIN that locks after repeated misses. Every screen stays within the 182 characters a USSD gateway allows, which a test checks, and the slow work (the M-Pesa prompt, the SMS receipt) runs after the reply so a session never times out waiting. The landing page carries a working phone bound to the demo member, so a visitor can dial it without a SIM.

04

Two officials for every loan

A treasurer acting alone is the failure a chama fears most, so approvals are an append-only table with a CHECK that the approver is not the requester, and a loan needs two distinct officials before money leaves. Turning a loan down, sending it and resetting a PIN each ask for confirmation; approving does not, because it is already the second key.

The hard part

Twenty copies of one M-Pesa confirmation deadlocked the database

Safaricom retries a paybill confirmation it believes went unanswered, so the domain test sends the same one twenty times at once. Each copy tried to insert the payment, and the row has two unique indexes, one on the provider reference and one on the receipt number. Postgres checks those one at a time, so two transactions could each claim one index and wait on the other. That is a deadlock, which Postgres resolves by cancelling one of them, and a cancelled confirmation is a payment the chama never hears about.

The fix serialises copies of the same receipt before either touches the table, with a transaction-scoped advisory lock keyed on the M-Pesa reference. Copies of one confirmation now queue, and each one after the first finds the payment already recorded. Different receipts hash to different locks, so the paybill as a whole never waits.

const outcome = await withTransaction(async (client) => {
  // Twenty copies of one confirmation must queue behind each other rather
  // than race their inserts: with two unique indexes on the row, racing
  // speculative inserts deadlock. Different receipts do not wait.
  await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`mpesa:${transId}`]);
  const target = await resolveAccountRef(client, body.BillRefNumber);
  // ...insert ... ON CONFLICT (provider, external_ref) DO NOTHING
backend/services/payments.js. Copies of one confirmation queue; different receipts never wait for each other.