Simulated digital bank, end to end
Duit
A digital-banking ecosystem I designed and built solo: a Go core-banking API with a double-entry ledger, retail and TOTP token mobile apps, corporate internet banking, a 7-role back office, a QRIS & Virtual Account payment gateway, and a mock government registry for KYC/KYB.
- Role
- Solo · System design, backend, web, mobile, deploy
- Year
- Oct – Nov 2026
- Type
- Personal project
Duit is a simulation built to demonstrate how financial systems work. All data is fictional, no real money is involved, and it is not connected to any real payment network. The QRIS payloads only mimic the standard structure for demo purposes.



8
Apps (2 Go APIs, 3 web, 2 mobile, 1 UI kit)
~164
HTTP endpoints
46
Tables across 7 schemas
205+
Tests (135 Go + 70 Flutter)
11
Roles with distinct rules
3.5 wk
Solo build, 68 commits
Problem
Most "banking app" portfolio projects stop at a balance screen and a transfer button that edits a number in one table. In a real bank the hard parts are elsewhere: money that must never appear or vanish because of a bug, double clicks and network retries, servers crashing mid-payroll, insiders approving their own transactions, government verification services going down, and protecting national ID numbers.
Solution
Duit answers those questions with running code, tests, and written design decisions. It's a modular Go monolith where the ledger's invariants are enforced by PostgreSQL itself, every money path is idempotent, four-eyes approvals are race-safe, third-party verification runs in background workers with retry, and personal data is encrypted. Around it sit six channels for customers, corporate clients, bank staff, merchants, and a mock external registry.
Problems Solved
How do you make sure money never appears or disappears because of a bug?
→A double-entry ledger whose balance is checked by the database at commit, not just by code
What if a user double-clicks, the network retries, or the server dies in the middle of a 500-row payroll?
→Idempotency at the API and ledger level, and payroll executed row by row with unique keys
How do you stop one insider from approving their own transaction?
→Maker-checker on every important decision, enforced inside a DB transaction with row locks
What if the government identity service is down?
→Verification runs in a background worker with retry and backoff, so the user never waits
How do you protect national IDs and prevent account takeover?
→AES-256-GCM encryption with a blind index, Ed25519 device binding, and a separate TOTP token app
How do merchants accept payments safely?
→HMAC-signed API, retried signed webhooks, and a sandbox that structurally cannot touch money
Key Features
DRET · Retail mobile (Flutter)
- Open an account fully from the phone with eKYC (ID card + selfie)
- Transfers with PIN, from the main account or a sub-account
- Poch sub-accounts with their own account numbers for goals like travel or rent
- Pay merchants by scanning QRIS or entering a VA number
DMT · Token app (Flutter)
- TOTP (RFC 6238) second factor for switching devices or phone numbers
- Activated with a code from customer service after an in-branch identity check
- Replaces SMS OTP and resists SIM swap
DCOR · Corporate banking (Next.js)
- Maker → approver → releaser flow: one person, one action per transaction
- Bulk payroll up to 500 rows via CSV through a clearing account
- Scheduled payments up to 30 days ahead
- Company sysadmins manage users, groups, and accounts but can't transact
BBO · Back office (Next.js)
- 7 staff roles: CS, reviewer, checker, teller, supervisor, admin, executive
- KYC/KYB review with reviewer + checker who must be different people
- Teller cash deposits with limits and supervisor escalation
- Merchant approval, MDR and limit settings, and the bank's balance sheet
DPAY · Payment gateway
- Merchant API for QRIS and Virtual Account invoices
- Instant sandbox; production requires bank review and checker approval
- Signed webhooks with tiered retries and manual resend
- Public docs, API reference, simulator, and downloadable integration guides
Registry · Mock external system
- Separate Go module and database, reached only over HTTP with an API key
- Answers per-field MATCH / PARTIAL / NOT_MATCH with a score, never full records
- Fictional 20-digit ID numbers so they can never collide with real people
System Design & Architecture
One Go process with modules for core banking, back office, corporate, retail, token, and payments, plus a shared platform layer (db, auth, audit, PII, storage, TOTP). Actions like releasing payroll touch channel data and the ledger at once, so keeping them in one database means one ACID transaction with no sagas. Schemas are already split per module, keeping a future split open. The web apps call the API only from the Next.js server with tokens in httpOnly cookies, and each channel has its own JWT audience and secret.
Channels
- DRET retail app (Flutter)
- DMT token app (Flutter)
- DCOR corporate web (Next.js)
- BBO back office (Next.js)
Duit API (Go)
- Modular monolith: core · bbo · dcor · dret · dmt · dpay
- In-process workers, queue in PostgreSQL
- Merchant API (HMAC-signed)
Data
- PostgreSQL 17, one schema per module + audit
- S3 object storage for KYC/KYB documents
Outside the bank
- Registry API + web (Go, Next.js)
- Merchant backends ← signed webhooks
Tech Stack
- Backend
- Go modular monolith, PostgreSQL 17, sqlc (294 typed queries), goose (23 migrations)
- Web
- Next.js 16 (Server Components & Actions), React 19, Tailwind 4, TanStack Table
- Mobile
- Flutter with a shared duit_ui package, mobile_scanner, Keychain/Keystore, biometrics
- Security
- AES-256-GCM + HMAC blind index, Ed25519 device binding, TOTP, bcrypt, HMAC-SHA256 request signing
- Storage
- S3-compatible (MinIO / SeaweedFS), presigned URLs
- Deploy
- Docker (distroless Go, standalone Next.js), Docker Compose, Nginx + Let's Encrypt, VPS
Technical Deep Dive
01A double-entry ledger guarded by the databaseEven a bug in application code can't break the ledger's rules, because PostgreSQL enforces them.+
CREATE CONSTRAINT TRIGGER ledger_entries_balanced
AFTER INSERT ON core.ledger_entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION core.check_transaction_balanced();- Debit = credit per transaction with at least two entries, checked once at commit by a deferred trigger
- Transactions and entries are append-only: corrections are always new entries
- Customer balances can't go negative, closed accounts must be at zero, and money is stored as BIGINT rupiah, never float
- Posting locks every touched account with SELECT … FOR UPDATE ordered by ID to avoid deadlocks
- The cached balance column is reconciled against entry totals, and even seed balances come from a genesis transaction
02Idempotency on every money pathRetries, double clicks, and crashes never move money twice.+
- Unique ledger idempotency keys namespaced per action, e.g. dret-transfer:<id>, payroll-item:<id>, dpay-payment:<id>
- API-level table storing request hash + response; same key with a different body is rejected with 409
- Every cash-deposit form carries its own key, and DPAY requires Idempotency-Key on invoice creation
- Self-found bug: Poch keys weren't namespaced per customer and action, so closing a Poch with an old key could strand funds. Reproduced with an integration test first, then fixed without a new table
03Race-safe maker-checkerChecking "checker ≠ reviewer" in code alone lets two simultaneous clicks both pass.+
BEGIN SELECT … FROM core.kyc_submissions WHERE id = $1 FOR UPDATE -- lock the row check status is still pending_approval check checker ≠ last reviewer write review (append-only), update status, open savings account COMMIT
- The same pattern covers deposit approvals, current-account opening, corporate activation, config changes, and MDR/limit changes
- Critical separations are DB constraints too: teller and supervisor can't be the same account
- Teller daily limits use a per-teller advisory lock so two simultaneous deposits can't both slip under the cap
04Crash-safe bulk payrollA 500-row batch can be interrupted and resumed without paying anyone twice.+
Maker upload CSV, name mismatches decided per row (recorded)
Approver → Releaser (same group, three different people)
Release batch total: Current account → Payroll Clearing (2100)
Worker each row: Clearing → employee account (re-check active + KYC)
failed rows: Clearing → Current account
Done clearing balance for this batch returns to zero- One DB transaction per row with key payroll-item:<id>
- Held funds show up on the balance sheet as a liability, not a hidden flag
- Name mismatches go to the maker, because HR data often spells names differently from bank records
05Resilient third-party verificationKYC/KYB never blocks on the registry being up.+
- The app gets "being verified" immediately; a worker calls the registry in the background
- Retry with backoff from 5 seconds up to 5 minutes; workers claim jobs with FOR UPDATE SKIP LOCKED
- Each decision is stored as a snapshot (request, per-field result, score, threshold, attempt), so today's decision stays explainable if the rules change tomorrow
- Name matching normalizes titles and name variants, then scores with Jaro-Winkler (≥ 0.95 match, ≥ 0.80 partial)
06Protecting personal dataNational IDs are encrypted, searchable without decryption, and every full view is logged.+
- AES-256-GCM encryption with an HMAC-SHA256 blind index for duplicate checks (one ID = one customer)
- Lists and snapshots only show masked IDs; full IDs from the registry are masked before storing
- Opening full KYC details writes a kyc.viewed audit event
- ID photos live in object storage behind 10-minute presigned URLs; the app key can't delete
07Mobile account securityOne account, one device, with a separate token app to move it.+
New phone correct phone + PIN → "new device, enter DMT code" Old phone warned about a login attempt from a new device DMT unlocked by PIN/biometrics → 6-digit TOTP code New phone code accepted → becomes the bound device, old sessions revoked Old phone 401 device_replaced
- Each install creates an Ed25519 key pair in Keychain/Keystore; the server only stores the public key and verifies signed, timestamped challenges
- TOTP: 6 digits, 30 seconds, ±1 step, no code reuse; secrets encrypted at rest
- Wrong PIN returns 422 instead of 401 so the app doesn't log out; weak PINs are rejected
- Refresh tokens rotate with reuse detection, isolated per channel
08DPAY: a sandbox that can't touch moneyThe payment module doesn't import core banking at all, so the compiler guarantees isolation.+
BEGIN dpay.BeginPaymentTx lock invoice, check status, expiry, merchant daily limit core.DPAYPaymentTx debit payer, credit merchant (amount − MDR), credit MDR income dpay.CompletePaymentTx mark paid, enqueue payment.paid webhook (outbox) COMMIT
- An invoice can't be paid without a journal, a journal can't exist without a paid invoice, and webhooks only go out if both commit
- MDR (0–5% per merchant) is set by the bank through maker-checker and booked to its own income account, rounded down
- The back-office balance sheet shows current-period profit so it still balances once the bank earns revenue
09Gateway-grade API and webhook securityBuilt with the same patterns as real payment gateways.+
signature = hex(HMAC-SHA256(secret,
METHOD \n PATH?QUERY \n TIMESTAMP \n hex(SHA256(body))))- ±5 minute timestamp window against replay, 120 req/min per key, 429 after 20 bad signatures per IP
- Production keys need a second sysadmin's approval, and the secret can be retrieved once, only by the requester, within 24 hours
- Key rotation with a 24-hour grace period; webhooks carry two signatures during rotation
- Webhook retries at 1m, 5m, 30m, 2h, 6h, 12h, then manual resend
- SSRF protection when the URL is saved and again when connecting (against DNS rebinding); redirects aren't followed
10Compiler-enforced module boundariesCore banking tables and queries live in Go's internal/ directory.+
- Other modules can only go through public functions like core.TransferTx
- Breaking the boundary fails the build instead of just breaking a convention
- The registry is a fully separate Go module and database, reached only over HTTP
11Self-audit: fraud and forensicsTwo audits of my own system, assuming it ran in production with real money: 13 fraud findings and 11 logging/forensics findings.+
- Approached from both an outside attacker's and an insider's point of view
- Fixes prioritized by impact; some critical findings are mitigated, others are explicitly accepted for the simulation and tracked as open
- Key insight: database constraints stop bugs, but not people with database credentials, which leads to separate migration/app users, hash chains, and off-DB log copies
Trade-offs
Modular monolith + one database
over Microservice per channel
ACID transactions across channels and the ledger without sagas; schemas split so it can be broken up later
Queue in PostgreSQL (SKIP LOCKED)
over Kafka / RabbitMQ / River
One less component, and jobs are created in the same transaction as their data (outbox)
Ledger integrity in DB triggers
over Validation in code only
Code can be wrong; the database is the last line of defense
Cached balance + reconciliation
over Always SUM the entries
Fast reads that remain verifiable
Payroll through a clearing account
over A "hold" flag on the balance
Held funds are visible on the balance sheet as a liability, not hidden state
TOTP token app
over SMS OTP
No SMS gateway in a simulation, and TOTP resists SIM swap
Challenges & Lessons
Keeping money rules correct
Constraints and triggers right next to the data
Put money rules as close to the data as possible, but know their limit: they don't stop people with DB credentials
Retries that silently returned stale results
Namespaced keys and request-body matching
Same key with a different body must be rejected, not quietly answered with the old result
Two approvers clicking at once
Identity checks inside a transaction that locks the row
"Different person" checks outside the transaction always leave a race
Keeping test payments away from real money
Sandbox module that doesn't import core
Separate by structure, not by discipline: no import beats if mode == sandbox
Business rules changing during the build
800+ lines of decision records with status and reasoning
Write decisions down so rule changes stay traceable and explainable
Ongoing Improvements
- Interbank transfers
- Corporate approval matrix and daily limits
- DPAY refunds and static QRIS/VA
- Deposits and loans on the ledger
- Teller end-of-day cash closing
- Hash-chained audit log
Next project
Hospitality Booking Platform