Saltar a contenido

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/balance lee 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 int64 en 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 .env de 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/status con 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