Take-home assignment · Fintech · 2026
PayLedger
A digital wallet built in two days — deposits, transfers and withdrawals on a ledger that can't double-credit.
- Role
- Solo, in 2 days — backend API, database design, gateway simulator, tests and both web panels.
- Platforms
- Web user panel · Web admin · REST API · Payment gateway simulator
- Status
- Take-home assignment · not deployed
Illustration of the system — not a screenshot.
- From brief to finished build
- 2 days
- From brief to finished build
- Test cases, incl. concurrency
- 227
- Test cases, incl. concurrency
- Lines of TypeScript, 4 apps
- ~33k
- Lines of TypeScript, 4 apps
- PostgreSQL tables
- 12
- PostgreSQL tables
Overview
Overview
PayLedger is a take-home engineering assignment I built solo in two days for a job interview. Users add money through a simulated payment gateway, send money to each other and request withdrawals; admins approve withdrawals and reverse transfers. I treated the money-correctness guarantees as the real task — not the CRUD.
The challenge
A wallet looks simple until two webhooks arrive for the same payment, a user taps "send" twice, or an admin approves a withdrawal that holds the whole balance. In money systems those edge cases are the whole job — a retry must never credit twice, and history must never be rewritten.
The approach
A Turborepo monorepo: an Express + TypeScript API that owns every financial write, a separate fake payment gateway that talks back only through signed webhooks, and two Next.js panels. Money is integer paise guarded by database constraints, every balance change goes through one locked chokepoint inside a transaction, and the ledger is append-only.
Walkthrough
How it works

- 01
Add money through a real-shaped gateway
A deposit creates a payment, then an order on a separate gateway service — which reports back the way a real provider does: with a signed webhook. Only that webhook can change the payment's status, never a user-facing call.

- 02
Signatures checked on the raw bytes
The webhook's HMAC-SHA256 is computed over the exact request body captured before JSON parsing — re-serialised JSON can reorder keys and break the signature — and compared in constant time.
spec · 02
Signatures checked on the raw bytes
- ›express.json({ verify }) → raw body
- ›HMAC-SHA256(raw, secret)
- ›length check → timingSafeEqual
- ›status set only by the webhook
- 03
A duplicate webhook settles once
Settlement starts by locking the payment row. A retried or concurrent delivery waits at the database, re-reads the row, sees it's already settled and does nothing — no race window, unlike check-then-act in code.
spec · 03
A duplicate webhook settles once
- ›SELECT … FROM payments
- ›WHERE order_id = $1 FOR UPDATE
- ›status ≠ CREATED → no-op
- ›credit + txn + ledger: 1 DB transaction
- 04
Send money, exactly once
Transfers carry an idempotency key, checked before the lock, again after locking the sender's wallet, and finally by a unique constraint — so a racing duplicate rolls back and returns the original result.

- 05
Integer paise, enforced by the database
Every amount is a BIGINT in paise; decimals exist only at the API edge. CHECK constraints keep balances and holds valid even if application code had a bug.
spec · 05
Integer paise, enforced by the database
- ›balance_paise BIGINT
- ›CHECK balance_paise >= 0
- ›CHECK held_paise <= balance_paise
- ›limits & fees from env, not code
- 06
A ledger that is never edited
Each transaction owns its ledger entries — a transfer writes a sender debit, a recipient credit and a fee credit to the system wallet, and debits always equal credits. Users see it as a clean history.

- 07
Withdrawals: hold first, review after
A request holds the amount so it can't be spent twice. On approval the hold is released before the debit — otherwise a user who held their entire balance would be wrongly rejected.

- 08
Reversals are new transactions
An admin can reverse a completed transfer. That creates a brand-new reversal transaction with its own entries; the original stays untouched, so the books always explain themselves.

- 09
Every admin action, audited
Approvals, rejections and reversals land in an audit log. Visible IDs are 13-character Snowflake/Base32hex references — raw database UUIDs are never shown.

- 10
Two auth systems that can't mix
Users and admins have separate tables, sessions, JWT guards and HttpOnly cookie names, so a bug in one path can't grant the other's privileges. Every response uses one envelope — including translated Postgres errors.
spec · 10
Two auth systems that can't mix
- ›users ≠ admins (tables, sessions)
- ›requireUserAuth / requireAdminAuth
- ›argon2 · helmet · Redis rate limits
- ›23505 → 409 · { success, code, requestId }
Every screen
Gallery
Sample data · scroll → · click to enlarge
Built with
Stack
- Backend
- Node.jsExpressTypeScriptZodjose (JWT)Pino
- Data
- PostgreSQLDrizzle ORMRedis
- Security
- Argon2HMAC-SHA256 webhooksHelmetHttpOnly cookiesRate limiting
- Testing
- VitestSupertest
- Tooling
- Turborepopnpm workspacesESLintPrettier
- Web apps
- Next.jsReactTailwind CSSTanStack QueryZustandReact Hook FormRadix UI
Open to full-time roles & select freelance projects