PurpleWallet started as a backend exercise: a Node.js wallet API with Prisma, Redis and a few queues. Over the past few months it grew into a complete product, with a React web app, Paystack payments, a double-entry ledger, an admin console, identity checks, AI spending insights and two layers of automated tests. It's live, and it costs nothing to run.
Here are the parts I found most interesting to build, and the surprises I hit putting it online.
Try it: walletpurple.netlify.app. Click "Use the demo account" on the login page, or sign up for your own. Payments run in Paystack's test mode, so no real money moves.
Code: github.com/tochi27/Wallet_System
The stack
| Layer | Choice |
|---|---|
| API | Node.js, Express 5, TypeScript, Zod |
| Data | PostgreSQL with Prisma; Redis with BullMQ |
| Web app | React 19, Vite, Tailwind CSS, shadcn/ui, TanStack Query |
| Payments and AI | Paystack (test mode), Google Gemini |
| Tests | Jest and Supertest, Playwright |
Both apps live in one npm workspaces monorepo, so a single pull request can change the API, the web app and the browser tests together.
1. Moving money, exactly once
The tempting shortcut is to credit the wallet when the user lands back from Paystack's checkout. That's wrong twice over: the redirect can be faked, and the same payment gets reported several times, by the redirect, by Paystack's webhook, and by retries of both.
So PurpleWallet never trusts the redirect. Whichever signal arrives first triggers a server-side check with Paystack, and the deposit is credited inside a database transaction that first claims it:
// Only one caller can move the deposit out of PENDING; everyone else gets count 0
const claimed = await tx.deposit.updateMany({
where: { id: deposit.id, status: DepositStatus.PENDING },
data: { status: DepositStatus.SUCCESSFUL, channel: payment.channel, paidAt: new Date() },
});
if (claimed.count === 0) return null; // already credited by the webhook, the redirect or a retry
The webhook, the redirect and a retry can all race, and the wallet is still credited once.
Withdrawals work the other way round: the wallet is debited first, so the money can't be spent twice while the bank transfer is in flight. A failed transfer is refunded exactly once with the same claim pattern, and a job every five minutes picks up anything a webhook missed. On top of that, an Idempotency-Key header and a Redis lock per wallet stop a double-tapped "Send" button from sending twice.
2. Books that always balance
A balance column works until a number looks wrong and you have nothing to check it against. So PurpleWallet also keeps a double-entry ledger. Every movement of money is a journal entry whose lines sum to zero. A ₦5,000 transfer from Ada to Bola:
| Account | Amount |
|---|---|
| Wallet: Ada | +5,000.00 (debit: we owe Ada less) |
| Wallet: Bola | −5,000.00 (credit: we owe Bola more) |
| Total | 0.00 |
The database enforces the rules, not just the code. A deferred constraint trigger rejects any transaction that leaves an entry unbalanced, and append-only triggers block edits and deletes, so mistakes are fixed with reversing entries, as an accountant would.
The admin console then runs a three-way reconciliation: for every wallet, it compares the stored balance, the sum of its transactions and the ledger. The trial balance should always read ₦0.00. It does.
3. Security that behaves like a bank's
- Transaction PIN. Transfers and withdrawals need a 4-digit PIN. Five wrong attempts lock money movement for 30 minutes.
-
Verified email before money moves. Unverified accounts can look around but get
403on anything that moves money. - Careful password resets. Links are single-use, expire in 30 minutes, and are stored only as a hash. Changing a password signs out every other session.
-
Never
401for a wrong PIN. The web app treats401as "session expired", and a typo must never sign someone out. - Limits and tiers. Tier 1 allows ₦50,000 per payment, ₦200,000 a day and a ₦500,000 balance. The checks run inside the payment's own transaction and lock, so two simultaneous payments can't both slip under the limit.
4. Identity checks, handled like sensitive data
To reach Tier 2 (₦500,000 per payment, ₦2,000,000 a day, no balance cap), users upload a photo of their ID and a selfie holding it, and staff approve or reject the request with a reason.
ID photos are the most sensitive data the app holds, so they're checked by their contents, not their file names, then re-encoded to strip GPS and camera details, and stored AES-256-GCM encrypted with a key that never touches the database. Only staff with a dedicated permission can open them, every opening is audit-logged, and photos from rejected requests are deleted after 30 days.
5. An admin console with checks and balances
Staff have four roles: Viewer, Support, Finance and Owner. Balance adjustments over ₦10,000 use maker-checker: a second admin must approve, and both steps require re-entering a password. Every admin action lands in an append-only audit log.
6. AI that can't make up numbers
PurpleWallet uses Google Gemini for monthly spending insights and an assistant you can ask about your money.
The rule: the AI never does arithmetic. My code computes every figure first, and Gemini only writes the sentences. Every ₦ amount and percentage in its text is then checked against the computed figures. If the model quotes a number the code didn't produce, the text is rejected and the app falls back to template sentences. Names are swapped for aliases like "Person A" before anything leaves the server.
The assistant uses read-only tools. It can't move money: when it suggests a transfer, it pre-fills the Send dialog, and the user still confirms with their PIN.
7. Tests at two levels
The API has 562 Jest tests, including the nasty cases: two deposits racing, a refund claimed twice, a limit hit by simultaneous payments. Playwright tests then drive the real app in Chrome against a throwaway database, covering sign-up, sending money, wrong PINs, limits, ID checks and admin approvals. GitHub Actions runs both on every pull request, and again before each deploy.
CI had its own bug. Change detection worked on pull requests, but on merge commits it decided nothing had changed, so after every merge the tests and the deploy were skipped, while the run still showed a green tick. Pushes to main now always test and deploy.
8. Hosting for ₦0, and what the free tiers taught me
The web app runs on Netlify, the API on Render, Postgres on Supabase and Redis on Redis Cloud, all on free plans. Each had a surprise.
A "free" deploy that wasn't. Because my Render Blueprint didn't say plan: free, Render used its paid default, and months later suspended the account over an unpaid invoice. Render kindly waived it, and the Blueprint now states the plan explicitly.
Free plans block outgoing email. Render's free tier blocks SMTP, so verification emails silently timed out. The fix was a short Google Apps Script deployed in the sending Gmail account: the API posts each email to it over HTTPS with a shared secret, and the script sends it through Gmail.
Supabase exposes your tables by default. Supabase emailed a critical warning: every table was reachable through its auto-generated Data API, because row-level security was off. My app never uses that API, but the door was open. A migration now enables row-level security and revokes the API roles' access, and a test fails CI if a new migration forgets.
Free services sleep. A scheduled ping every 10 minutes keeps the API awake, using about 730 of Render's 750 free hours a month.
9. A demo you can share safely
The live site has a one-click demo account with three months of realistic Nigerian spending (rent, Shoprite, Bolt rides, DSTV), so the insights and assistant have something to talk about. Since everyone shares that login, visitors can't change its password or PIN, wrong PINs never lock it, and it never receives email. There's also a sample ID card to download for trying the identity check, so nobody uploads a real one.
What I'd tell myself at the start
- Make the database enforce the rules that matter. Triggers caught mistakes that tests alone would have missed.
- Design for duplicates. Webhooks, retries and double-clicks will deliver the same event twice; a conditional "claim" update solves most of it.
- Let code do the maths and AI do the words, then check the words.
- Read the free-tier small print, and remember that a green CI tick only means something if the jobs actually ran.
PurpleWallet is live at walletpurple.netlify.app, and the code is on GitHub. Try the demo, send some test money, or ask the assistant where your money went last month. I'd love to hear what you think.
Top comments (0)