Skip to content
All work

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
retry · same order_id → waits on lock → no-opsettle · 1 DB txnpayment gatewayorder · SUCCESSPOST /webhookHMAC ✓ raw bytespayments rowFOR UPDATEwalletsbalance_paise BIGINTCHECK ≥ 0 · held ≤ balanceledger_entries · append-only · transferDEBITsender−1,010.00CREDITrecipient+1,000.00CREDITsystem · fee+10.00Σ debits = Σ credits

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

  1. 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.

    Add money through the simulated payment gateway
  2. 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
  3. 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
  4. 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.

    Send money to another user
  5. 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
  6. 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.

    Transaction history
  7. 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.

    Admin withdrawal review — approve or reject
  8. 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.

    Admin transfer reversal
  9. 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.

    Admin audit log
  10. 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

Sample data · scroll → · click to enlarge

  • PayLedger user sign-in

  • User dashboard with wallet balance

  • Add money through the simulated payment gateway

  • Send money to another user

  • Transaction history

  • Withdrawal request with held amount

  • Admin dashboard

  • Admin withdrawal review — approve or reject

  • Admin transfer reversal

  • Admin audit log

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

Need something like this?

Email

Local time

Giridih, IN · IST

Replies within 24 hours on weekdays