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/balancereads 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
int64minor 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
servicerole; the citizen is granted no write access to those tables. - DB TLS. PostgreSQL has TLS via a private CA, which makes
sslmode=verify-fullgenuinely meaningful. - Where secrets live. All secrets are in
/etc/gerege-wallet/*.env. The app's.envis 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/statuslong-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