Reproduce the judges-facing demo from a clean clone. No Daraja, USSD, or blockchain credentials required — every external call has a local simulator path.
- Node 18+ (tested on 25.9)
npm(no pnpm/yarn needed)- No Docker, no Postgres, no Redis required — SQLite is embedded.
git clone <this-repo>
cd Chama-Connect/chamapay
cp .env.example .env.local # leave Daraja/AT/Anchor keys blank for the offline demo
npm install
npm run db:migrate # creates var/chamapay.sqlite
npm run db:seed # inserts chama ACME + 5 members + April cycle
npm run dev # binds port 3100Open http://localhost:3100/chamas/ACME.
You should see:
Open a second shell. Simulate a correct payment — member's phone matches, account ref is well-formed:
Bash/macOS/Linux:
curl -sX POST http://localhost:3100/api/dev/simulate-c2b \
-H 'content-type: application/json' \
-d '{"msisdn":"254711223344","amount":500,"billRef":"ACME-202604"}' | jqPowerShell/Windows:
curl.exe -sX POST http://localhost:3100/api/dev/simulate-c2b -H 'content-type: application/json' `
-d '{"msisdn":"254711223344","amount":500,"billRef":"ACME-202604"}' | ConvertFrom-Json | ConvertTo-Json -Depth 10Expected response:
{
"payload": { "TransID": "SIM...", "TransAmount": "500.00", "MSISDN": "254711223344", ... },
"result": {
"status": "matched",
"confidence": 1.0,
"reason": "exact account-ref + msisdn match",
"userId": "...",
"chamaId": "...",
"cycleId": "..."
}
}Switch back to the dashboard tab. Within 3 seconds, Brian Otieno flips from pending to paid, and the cycle-progress bar jumps.
Three more simulated payments demonstrate the matcher:
Bash/macOS/Linux:
# Correct — matches Caroline by MSISDN even though ref has a typo
curl -sX POST http://localhost:3100/api/dev/simulate-c2b -H 'content-type: application/json' \
-d '{"msisdn":"254722334455","amount":750,"billRef":"ACMMM-202604"}' | jq
# Unknown MSISDN + unknown prefix → lands in admin review queue
curl -sX POST http://localhost:3100/api/dev/simulate-c2b -H 'content-type: application/json' \
-d '{"msisdn":"254799999999","amount":500,"billRef":"XYZ-202604"}' | jq
# Replay the first payment (same TransID) — idempotent, no double-credit
curl -sX POST http://localhost:3100/api/dev/simulate-c2b -H 'content-type: application/json' \
-d '{"msisdn":"254711223344","amount":500,"billRef":"ACME-202604","transId":"<paste-previous-TransID>"}' | jqPowerShell/Windows:
# Correct — matches Caroline by MSISDN even though ref has a typo
curl.exe -sX POST http://localhost:3100/api/dev/simulate-c2b -H 'content-type: application/json' `
-d '{"msisdn":"254722334455","amount":750,"billRef":"ACMMM-202604"}' | ConvertFrom-Json | ConvertTo-Json -Depth 10
# Unknown MSISDN + unknown prefix → lands in admin review queue
curl.exe -sX POST http://localhost:3100/api/dev/simulate-c2b -H 'content-type: application/json' `
-d '{"msisdn":"254799999999","amount":500,"billRef":"XYZ-202604"}' | ConvertFrom-Json | ConvertTo-Json -Depth 10
# Replay the first payment (same TransID) — idempotent, no double-credit
curl.exe -sX POST http://localhost:3100/api/dev/simulate-c2b -H 'content-type: application/json' `
-d '{"msisdn":"254711223344","amount":500,"billRef":"ACME-202604","transId":"<paste-previous-TransID>"}' | ConvertFrom-Json | ConvertTo-Json -Depth 10Narration points for the demo video:
- "The engine matched Caroline by MSISDN alone, at 90% confidence — the ref was mistyped but the phone number was a unique hit."
- "Random phone + unknown prefix — engine doesn't guess. It parks the payment in the admin review queue for the treasurer to resolve manually."
- "Same TransID twice — engine returns
duplicate, ledger unchanged. Safaricom's retry storms can't cause double-credits."
Africa's Talking sandbox isn't required — the USSD handler can be fed directly:
Bash/macOS/Linux:
curl -sX POST http://localhost:3100/api/ussd \
-d 'sessionId=demo&serviceCode=*384*1#&phoneNumber=%2B254708374149&text='
# → "CON ChamaPay — Acme Savers Chama
# 1. My balance
# 2. Contribute
# 3. Request loan
# 4. Recent payments
# 5. Exit"
curl -sX POST http://localhost:3100/api/ussd \
-d 'sessionId=demo&serviceCode=*384*1#&phoneNumber=%2B254708374149&text=1'
# → "END Hi Alice. Contributed this cycle: KES 0. Expected: KES 500."PowerShell/Windows:
curl.exe -sX POST http://localhost:3100/api/ussd `
-d 'sessionId=demo&serviceCode=*384*1#&phoneNumber=%2B254708374149&text='
# → "CON ChamaPay — Acme Savers Chama
# 1. My balance
# 2. Contribute
# 3. Request loan
# 4. Recent payments
# 5. Exit"
curl.exe -sX POST http://localhost:3100/api/ussd `
-d 'sessionId=demo&serviceCode=*384*1#&phoneNumber=%2B254708374149&text=1'
# → "END Hi Alice. Contributed this cycle: KES 0. Expected: KES 500."Every chama member — including those on feature phones who will never touch the web app — has full access.
npm test6 reconciliation tests pass: exact match, idempotency, MSISDN fallback, unmatched path, balanced double-entry, mixed-format period parsing.
Fill in .env.local:
DARAJA_CONSUMER_KEY=<from developer.safaricom.co.ke>
DARAJA_CONSUMER_SECRET=<...>
DARAJA_CALLBACK_BASE=https://<your-ngrok>.ngrok.appRegister callback URLs once:
Bash/macOS/Linux:
curl -X POST http://localhost:3100/api/mpesa/c2b/register
# (internal helper — available in the admin chamas page too)PowerShell/Windows:
curl.exe -X POST http://localhost:3100/api/mpesa/c2b/register
# (internal helper — available in the admin chamas page too)Then trigger a real STK Push from the dashboard "Trigger STK Push" card, or pay the sandbox paybill (174379) from an M-Pesa test account. The same reconciliation engine runs against live Daraja callbacks.
- Port 3100 in use —
next dev -p 3200and updatePUBLIC_BASE_URL. - SQLite locked — stop
npm run dev, deletevar/chamapay.sqlite-journal, restart. - Tests flaky — each test uses its own
os.tmpdir()SQLite file, so this shouldn't happen; if it does, the tmpdir is stale and can be cleared.
