← All projects

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
Simulation

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.

Duit
Duit corporate banking, retail mobile app, and back office sharing one core ledger
Three channels, one ledger: DCOR, DRET, and BBO all transact through the same core banking API.
The four Duit apps, with the back-office balance sheet, corporate payroll, and the DMT device-switch flow
Four apps, one core banking: switching phones requires the DMT token, so only the real owner can move the account.
GoPostgreSQLsqlcNext.jsReactTailwindFlutterDockerNginxMinIO

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

→