Gerege Wallet¶
Parcial · Capa 4 — Producto vertical ·
Repositorio: wallet-gerege-mn · wallet.gerege.mn · api.wallet.gerege.mn
La cartera digital del ciudadano: un producto en el que se accede con eID, se consulta el saldo y se hacen transferencias por IBAN. Como núcleo financiero funciona Apache Fineract.
Las aplicaciones móviles (iOS SwiftUI, Android Compose) son la superficie principal; la web es una consola auxiliar.
La arquitectura del dinero: la regla más importante¶
Fineract es la única fuente de verdad del dinero. Saldos, movimientos y el libro mayor residen allí. PostgreSQL guarda SOLO la correspondencia «ciudadano ↔ IDs de Fineract», los registros de idempotencia y las preferencias de usuario.
| Capa | De qué responde |
|---|---|
| Backend de Wallet (Go) | Autenticación, permisos, flujos, auditoría |
| Apache Fineract 1.15 | Cuentas, movimientos, saldos, partida doble, libro mayor |
| PostgreSQL | Solo correspondencia y preferencias — el saldo NUNCA está aquí |
De ahí se derivan tres reglas:
- Nunca cachear un saldo.
/accounts/balancelee directamente de Fineract: las condiciones para que dos sistemas divergan simplemente no se dan. - Nunca representar el dinero como float. La forma textual del JSON se
convierte directamente a
int64en unidades menores (para el ₮, dinero = ₮×100). La vía a los errores de redondeo queda cerrada. - Cada movimiento es idempotente. Detalle más abajo.
¿Por qué un core banking ya hecho?
Un libro mayor financiero es un dominio difícil de hacer bien y caro de hacer mal: partida doble, cuadre, cierres, pista de auditoría. Fineract lo ha resuelto a lo largo de años de uso en producción. Nosotros solo añadimos encima las capas de identidad y de servicio.
Ciudadano ↔ cuenta ↔ IBAN¶
A cada ciudadano le corresponde exactamente un cliente en Fineract, una
cuenta de ahorro y un IBAN. La clave del enlace es el civil_id del
ciudadano:
civil_id ──► externalId = PNOMN-<CIVIL_ID> ──► cliente + cuenta en Fineract
│
savings account ID
│
IBAN
El IBAN mongol tiene 20 caracteres:
MN | kk | bbbb | aaaaaaaaaaaa
2 | 2 | 4 | 12
│ │ │ └─ número de cuenta (Fineract savings ID, con ceros a la izquierda)
│ │ └──────── código de banco/entidad (4 dígitos)
│ └────────────── dígitos de control mod-97
└─────────────────── código de país
Los 12 dígitos de la cuenta se derivan del ID de Fineract, así que no hace falta ninguna secuencia adicional y la correspondencia inversa es aritmética pura.
La clave es civil_id, NO el user_id interno
Se ha corregido el hecho de derivar el externalId de Fineract del user_id
de PostgreSQL (un UUID nuevo en cada creación de fila). Con aquel esquema,
reconstruir la base rompía para siempre el enlace ciudadano ↔ cuenta: al
volver a entrar se creaba una cuenta nueva y un IBAN nuevo, y el saldo
anterior quedaba huérfano.
El civil_id es un identificador de por vida (todo usuario de eID tiene uno),
de modo que una clave derivada de él no depende de la base de datos. ANTES de
abrir una cuenta se busca por esa clave un cliente y una cuenta ya existentes
en Fineract y, si aparecen, se reutilizan: así, aun perdiéndose la
correspondencia, el ciudadano recupera su mismo IBAN.
Autorización de la transferencia¶
La transferencia la autoriza la sesión JWT. La aplicación llama a
/transfer/iban con el access token obtenido al iniciar sesión.
Protección frente a duplicados: cada petición lleva una cabecera
Idempotency-Key. Una segunda petición con la misma clave no crea un movimiento
nuevo, sino que devuelve el resultado de la PRIMERA; así, si se corta la red y la
aplicación reenvía, el dinero no sale dos veces. La garantía la sostiene en última
instancia una restricción UNIQUE (user_id, idempotency_key) en la base.
Se ha eliminado la vinculación con la firma
Antes, cada transferencia se volvía a hashear en el formato canónico GWT y
se contrastaba con la firma eID PIN2 del ciudadano (WYSIWYS: «firmas lo que
ves»). Con ello venían implementaciones idénticas byte a byte en tres ports
(Go/Kotlin/Swift) y una comprobación en CI sobre golden fixtures.
Todo eso se retiró por decisión de producto. La consecuencia: quien posea un access token válido puede mover dinero, mientras que antes ni siquiera un token robado permitía transferir sin el PIN2.
El acceso con eID se mantiene; solo se eliminó la firma de la TRANSFERENCIA.
Superficie de API¶
La autenticación llega de la capa base de Gerege Platform; los endpoints de la cartera están en este repositorio.
| Método | Ruta | Qué hace |
|---|---|---|
POST |
/api/v1/auth/initiate |
Envía un push de eID por número de registro |
GET |
/api/v1/auth/status/{sid} |
Estado + token + IBAN (aquí se abre la cartera) |
GET |
/api/v1/accounts/balance |
Saldo (directo de Fineract) |
GET |
/api/v1/accounts/transactions |
Extracto de la cuenta |
GET |
/api/v1/accounts/lookup |
Verificar el IBAN del destinatario |
POST |
/api/v1/transfer/iban |
Transferencia (exige Idempotency-Key) |
GET/DELETE |
/api/v1/beneficiaries |
Destinatarios guardados |
POST/DELETE |
/api/v1/devices/register |
Registro del token de push |
POST |
/api/v1/pay/code/initiate |
Token QR de pago de un solo uso |
Dos formas de respuesta¶
- JSON plano (sin envoltorio) — para las aplicaciones móviles. La cartera y los endpoints de auth móvil usan esta forma.
- Un envoltorio
{status, message, data}— para el BFF web.
Al añadir un endpoint para la aplicación, mantenga la forma plana. El BFF web envuelve la respuesta plana en su propio envoltorio de cliente antes de pasarla.
El vocabulario de estados de las apps¶
Las aplicaciones consideran terminales solo CONFIRMED / REFUSED / TIMEOUT.
Los nombres internos de eID del backend (COMPLETE/EXPIRED/…) se proyectan
hacia fuera en un único lugar.
El contrato lo definen las apps
Las aplicaciones de iOS y Android se construyeron ANTES del backend y esperan las formas anteriores. Cuando la aplicación y el backend discrepan, se corrige el backend.
Aplicaciones móviles¶
| iOS | Android | |
|---|---|---|
| Tecnología | SwiftUI | Kotlin + Compose |
| Acceso | ✅ | ✅ |
| Saldo / extracto | ✅ | ✅ |
| Transferencia | ✅ | ⏳ pantalla aún sin hacer |
| Pago por QR (EMVCo) | ✅ | ⏳ |
| Registro de push | ✅ | ✅ |
Las aplicaciones nunca acceden directamente al dominio del eID: todo el
tráfico pasa por api.wallet.gerege.mn. Para entrar, el ciudadano introduce su
PIN en respuesta a un push que llega a la aplicación de eID.
Seguridad¶
- Row-Level Security. La API se conecta a la base con un rol que NO es
superuser (en producción lo comprueba un boot guard), de modo que las policies
de RLS se aplican de verdad. Cada tabla «por usuario» tiene su propia policy.
Las escrituras de servidor de confianza —abrir una cuenta, crear el registro
de una transferencia— se realizan aparte bajo un rol
service; al ciudadano no se le concede permiso de escritura en esas tablas. - TLS en la base. PostgreSQL tiene TLS mediante una CA privada, lo que da
sentido real a
sslmode=verify-full. - Dónde viven los secretos. Todos los secretos están en
/etc/gerege-wallet/*.env. El.envde la aplicación se REGENERA en cada despliegue, así que editarlo a mano significa que el siguiente despliegue borra el cambio. - Rate limits.
/auth/*~5 peticiones/min (cuerpo limitado a 4 KiB), el long-poll de/auth/statuscon su propio límite más laxo, y los endpoints que mueven dinero ~30/min. - Idempotencia. Cada transferencia exige
Idempotency-Key: si se corta la red y la aplicación reenvía, el dinero no sale dos veces. Al haber desaparecido la vinculación con la firma, este es el mecanismo PRINCIPAL de protección frente a duplicados. - Aislamiento de Fineract. Escucha solo en
127.0.0.1:8090: no hay vía de acceso desde fuera.
Despliegue¶
NO es Docker, sino systemd nativo:
gerege-wallet.slice
├── gerege-wallet-fineract.service # Fineract 1.15 (JAR, 127.0.0.1:8090)
├── gerege-wallet-api.service # API en Go (127.0.0.1:8080)
└── gerege-wallet-web.service # BFF de Next.js (127.0.0.1:3000)
PostgreSQL, Redis y nginx son servicios del host. nginx expone
wallet.gerege.mn y api.wallet.gerege.mn con TLS de Let's Encrypt.
CD: tras fusionar en main, el workflow de Deploy arranca en cuanto la CI se
pone verde. Toda la construcción ocurre en el runner y al servidor solo llega el
resultado: allí no hace falta toolchain de Go/Node y la ventana de interrupción es
corta. Un despliegue se registra como correcto solo cuando ha comprobado no solo
/health, sino que una ruta protegida responde correctamente.
Estado actual¶
| Capacidad | Estado |
|---|---|
| Acceso con eID (con credenciales de RP) | En marcha |
| La cartera se abre automáticamente + se asigna IBAN | En marcha |
| Saldo / extracto | En marcha |
| Bono de bienvenida para carteras nuevas | En marcha |
| Transferencia por IBAN (autorizada por JWT) | Implementada, poco probada |
| Transferencia / QR en Android | Planificado |
| App Store / TestFlight | En preparación |
El código de banco del IBAN es provisional
El código de banco/entidad actual es un valor temporal hasta obtener un código real del Banco de Mongolia. Un IBAN asignado a un ciudadano no debe cambiar, así que este valor no se sustituirá una vez haya usuarios reales.
Documentación detallada¶
Los documentos ARCHITECTURE, DEVELOPMENT, API_CONTRACT y SECURITY están en
el directorio backend/docs/ del repositorio wallet-gerege-mn (en parejas
EN/MN). Las instrucciones de build móvil están en ios/README.md y las de
despliegue en docs/DEPLOYMENT.md.
Plataformas relacionadas: eID Mongolia · G-Sign · Gerege Platform · Gerege Verify