Skip to content

Gerege Wallet

Partial · Layer 4 — Vertical product · Repo: wallet-gerege-mn · wallet.gerege.mn · api.wallet.gerege.mn

The citizen's digital wallet — a product where you sign in with eID, see your balance and make IBAN transfers. Apache Fineract runs as the financial core.

The mobile apps (iOS SwiftUI, Android Compose) are the primary surface; the web is a secondary console.

Money architecture — the most important rule

Fineract is the single source of truth for money. Balances, transactions and the ledger all live there. PostgreSQL stores ONLY the citizen ↔ Fineract ID mapping, idempotency records and user preferences.

Layer Responsible for
Wallet backend (Go) Authentication, permissions, flows, auditing
Apache Fineract 1.15 Accounts, transactions, balances, double-entry, the ledger
PostgreSQL Only mapping + preferences — balances are NEVER here

Three rules follow from that:

  • Never cache a balance. /accounts/balance reads straight from Fineract — the conditions for two systems diverging simply never arise.
  • Never make money a float. The JSON text form is converted directly to an int64 minor unit (for ₮, money = ₮×100). The route to rounding errors is closed off.
  • Every transaction is idempotent. Detail below.

Why an off-the-shelf core banking system?

A financial ledger is a domain that is hard to get right and expensive to get wrong: double-entry bookkeeping, balancing, period closing, audit trails. Fineract has solved these over years of production use. We add only the identity and service layers on top.

Citizen ↔ account ↔ IBAN

Each citizen gets exactly one Fineract client, one savings account and one IBAN. The linking key is the citizen's civil_id:

civil_id ──► externalId = PNOMN-<CIVIL_ID> ──► Fineract client + account
                                              savings account ID
                                                      IBAN

The Mongolian IBAN is 20 characters:

MN | kk | bbbb | aaaaaaaaaaaa
 2 |  2 |    4 |           12
 │    │     │      └─ account number (Fineract savings ID, zero-padded)
 │    │     └──────── bank/institution code (4 digits)
 │    └────────────── mod-97 check digits
 └─────────────────── country code

The 12 account digits derive from the Fineract ID, so no extra sequence is needed and the reverse mapping is pure arithmetic.

The key is civil_id, NOT the internal user_id

Deriving Fineract's externalId from PostgreSQL's user_id (a fresh UUID on every row creation) has been corrected. Under that scheme, rebuilding the database severed the citizen ↔ account link for good: signing in again created a new account and a new IBAN, orphaning the old balance.

civil_id is a lifelong identifier (every eID user has one), so a key derived from it does not depend on the database. BEFORE opening an account, an existing Fineract client and account are looked up by this key and reused if found — so even if the mapping is lost, the citizen gets their same IBAN back.

Transfer authorisation

Transfers are authorised by the JWT session. The app calls /transfer/iban with the access token it obtained at sign-in.

Protection against duplication: every request carries an Idempotency-Key header. A second request with the same key does not create a new transaction but returns the result of the FIRST one — so if the network drops and the app resends, the money does not move twice. The guarantee is ultimately held by a UNIQUE (user_id, idempotency_key) constraint in the database.

Signature binding has been removed

Previously every transfer was re-hashed in the canonical GWT format and matched against the citizen's eID PIN2 signature (WYSIWYS — "what you see is what you sign"). Byte-equal implementations across three ports (Go/Kotlin/ Swift) and a CI check on golden fixtures came with it.

All of that was removed as a product decision. The consequence: any party holding a valid access token can move money — whereas previously, even a stolen token could not perform a transfer without PIN2.

eID sign-in remains — only the TRANSFER signature was removed.

API surface

Authentication comes from the foundation layer of Gerege Platform; the wallet endpoints live in this repository.

Method Path What it does
POST /api/v1/auth/initiate Sends an eID push by national ID number
GET /api/v1/auth/status/{sid} Status + token + IBAN (the wallet opens here)
GET /api/v1/accounts/balance Balance (straight from Fineract)
GET /api/v1/accounts/transactions Account statement
GET /api/v1/accounts/lookup Verify a recipient's IBAN
POST /api/v1/transfer/iban Transfer (requires Idempotency-Key)
GET/DELETE /api/v1/beneficiaries Saved recipients
POST/DELETE /api/v1/devices/register Push token registration
POST /api/v1/pay/code/initiate One-time payment QR token

Two response shapes

  • Flat JSON (no envelope) — for the mobile apps. The wallet and the mobile auth endpoints use this shape.
  • A {status, message, data} envelope — for the web BFF.

When adding a new app endpoint, keep the flat shape. The web BFF wraps the flat response in its own client envelope before passing it on.

The apps' status vocabulary

The apps treat only CONFIRMED / REFUSED / TIMEOUT as terminal. The backend's internal eID names (COMPLETE/EXPIRED/…) are mapped outwards in a single place.

The apps define the contract

The iOS and Android apps were built BEFORE the backend and expect the shapes above. Where app and backend disagree, the backend is corrected.

Mobile apps

iOS Android
Technology SwiftUI Kotlin + Compose
Sign-in
Balance / statement
Transfer ⏳ screen not yet built
QR (EMVCo) payment
Push registration

The apps never reach the eID domain directly — all traffic goes through api.wallet.gerege.mn. To sign in, the citizen enters their PIN in response to a push that arrives in the eID app.

Security

  • Row-Level Security. The API connects to the database with a role that is NOT superuser (a boot guard checks this in production), so RLS policies really do apply. Every per-user table has its own policy. Trusted server-side writes — opening an account, creating a transfer record — are performed separately under a service role; the citizen is granted no write access to those tables.
  • DB TLS. PostgreSQL has TLS via a private CA, which makes sslmode=verify-full genuinely meaningful.
  • Where secrets live. All secrets are in /etc/gerege-wallet/*.env. The app's .env is REGENERATED on every deploy, so editing it by hand means the next deploy erases the change.
  • Rate limits. /auth/* ~5 requests/min (4 KiB body cap), /auth/status long-poll on its own looser limit, money-moving endpoints ~30/min.
  • Idempotency. Every transfer requires an Idempotency-Key — if the network drops and the app resends, the money does not move twice. With signature binding gone, this is the PRIMARY protection against duplication.
  • Fineract isolation. It listens only on 127.0.0.1:8090 — there is no path to it from outside.

Deployment

NOT Docker — native systemd:

gerege-wallet.slice
├── gerege-wallet-fineract.service   # Fineract 1.15 (JAR, 127.0.0.1:8090)
├── gerege-wallet-api.service        # Go API (127.0.0.1:8080)
└── gerege-wallet-web.service        # Next.js BFF (127.0.0.1:3000)

PostgreSQL, Redis and nginx are host services. nginx exposes wallet.gerege.mn and api.wallet.gerege.mn over Let's Encrypt TLS.

CD: after a merge into main, the Deploy workflow runs as soon as CI is green. Every build happens on the runner and only the result reaches the server — no Go/Node toolchain is needed there and the window of disruption is short. A deploy is recorded as successful only once it has verified not just /health but that a protected path responds correctly.

Current state

Capability State
eID sign-in (with RP credentials) Working
Wallet opens automatically + IBAN issued Working
Balance / statement Working
Welcome bonus for new wallets Working
IBAN transfer (authorised by JWT) Implemented, under-tested
Android transfer / QR Planned
App Store / TestFlight In preparation

The IBAN bank code is provisional

The current bank/institution code is a placeholder until a real code is obtained from the Bank of Mongolia. An IBAN issued to a citizen must not change, so this value will not be swapped once real users are on board.

Full documentation

The ARCHITECTURE, DEVELOPMENT, API_CONTRACT and SECURITY documents are in the backend/docs/ directory of the wallet-gerege-mn repository (as EN/MN pairs). Mobile build instructions are in ios/README.md, deployment in docs/DEPLOYMENT.md.

Related platforms: eID Mongolia · G-Sign · Gerege Platform · Gerege Verify