⚙️ Documento técnico · App Nativa

Arquitectura Técnica

Diseño técnico completo de la app móvil FinPal en React Native + Expo: stack, capas, flujos de datos, autenticación, push notifications y estrategia de deploy.

RN+Expo
Runtime
0
Cambios al backend
4
Endpoints nuevos
EAS
Build & Deploy
iOS+Android
Plataformas
🗺️

Overview del Sistema

El backend no cambia. Solo se agrega el cliente móvil y 4 endpoints nuevos.

Sistema completo
📱 React Native App
FinPal Backend (Node.js)
PostgreSQL + Prisma
📧 Gmail
Penny · Railway
FinPal Backend
🔔 Push → Device
FinPal Backend
Expo Push Service
FCM (Android)
/
APNs (iOS)
Principio clave: backend-first
El backend de FinPal (Node.js + Prisma + PostgreSQL) y Penny (Railway) no se tocan. El app móvil consume la misma REST API que el frontend web. Solo se agregan 4 endpoints nuevos para push tokens y notificaciones.
📦

Stack Tecnológico

Un codebase, dos plataformas.

CapaTecnologíaRazón
RuntimeReact Native 0.76+Un codebase para iOS y Android. Familiar para el equipo (React)
FrameworkExpo SDK 52+ (Managed)Abstrae la config nativa. EAS para builds. OTA updates sin store review
NavegaciónExpo Router v4File-based routing (como Next.js). Deep links automáticos
Estado servidorTanStack Query v5Mismo que el frontend web. Cache, retry, invalidation automática
Estado localZustandLigero. Para auth state, preferencias locales, offline queue
HTTP clientAxiosMismo que el web. Interceptors para JWT refresh automático
Almacenamiento seguroExpo SecureStoreJWT tokens, PIN cifrado. Backed by Keychain (iOS) / Keystore (Android)
Almacenamiento localMMKV (react-native-mmkv)Para preferencias y offline queue. 10x más rápido que AsyncStorage
Push NotificationsExpo NotificationsSDK unificado para FCM + APNs. No requiere código nativo
Biometríaexpo-local-authenticationFace ID, Touch ID, Fingerprint. API unificada iOS/Android
Gmail OAuthexpo-auth-sessionOAuth 2.0 PKCE nativo. Evita WebView genérico (requerido por Google en 2025)
FormulariosReact Hook Form + ZodValidación consistente con el backend. Performance en listas largas
UI ComponentsCustom (no UI lib)Design system propio basado en el web. Más control sobre animaciones nativas
AnimacionesReact Native Reanimated 3Animaciones en el thread de UI. Swipe-to-delete, drag & drop fluido
Iconos@expo/vector-iconsLucide React Native / MaterialIcons. Mismo set que el web
BuildsEAS BuildBuilds en la nube. Sin Xcode/Android Studio local requerido
Deploy / OTAEAS UpdateActualizaciones JS instantáneas sin pasar por App Store
AnalyticsPostHog React NativeMismo que el web. Continuidad de métricas
🧱

Capas de la Aplicación

Arquitectura en 5 capas bien separadas.

UI · Presentación
Screens (Expo Router) Components Animations (Reanimated) Themes (light/dark)
Business Logic
Custom Hooks TanStack Query Zustand stores Form validation (Zod)
Data · API
API Client (Axios) JWT interceptors Offline queue Cache (TanStack)
Infra nativa
SecureStore (tokens) MMKV (prefs/queue) Notifications LocalAuthentication AuthSession (OAuth)
Servicios externos
FinPal Backend API Expo Push Service Google OAuth FCM / APNs PostHog
🔐

Auth & Seguridad

JWT en SecureStore + biometría como segundo factor de acceso rápido.

Flow de login con biometría
App abre
¿JWT en SecureStore?
NO →
Pantalla login
POST /auth/login
Guardar JWT + refreshToken en SecureStore
SÍ →
¿Biometría activada?
SÍ →
Face ID / Touch ID
Dashboard ✓
NO →
¿PIN configurado?
Pantalla PIN
Dashboard ✓
TokenStorageTTLUso
accessToken (JWT)Expo SecureStore15 minCada request a la API
refreshTokenExpo SecureStore7 díasRenovar accessToken silenciosamente
PIN hashExpo SecureStoreFallback a biometría
pushTokenSecureStore + BackendRecibir push notifications
⚠️
JWT refresh automático
El Axios interceptor detecta respuesta 401, llama silenciosamente a POST /auth/refresh, reintenta el request original. Si el refresh también falla → logout automático + redirect a login. Idéntico al comportamiento del web.
🔐
Biometría — solo desbloquea la sesión local
La biometría no reemplaza el JWT. Solo desbloquea la pantalla cuando ya hay un token válido en SecureStore. Si el token expiró, debe hacer login completo. expo-local-authentication no envía nada al servidor.
📡

Capa de Datos

TanStack Query como fuente de verdad del estado del servidor.

// src/lib/api-client.ts — mismo interface que el web const apiClient = axios.create({ baseURL: process.env.EXPO_PUBLIC_API_URL, // FinPal Backend timeout: 10000, }); // Interceptor: attach JWT apiClient.interceptors.request.use(async (config) => { const token = await SecureStore.getItemAsync('accessToken'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); // Interceptor: JWT refresh en 401 apiClient.interceptors.response.use(null, async (error) => { if (error.response?.status === 401 && !error.config._retry) { error.config._retry = true; const newToken = await refreshToken(); error.config.headers.Authorization = `Bearer ${newToken}`; return apiClient(error.config); } throw error; });
Estrategia de caché con TanStack Query
budgets
staleTime: 30s · Refetch on focus
transactions
staleTime: 15s · Invalidate on add/edit/delete
gmail accounts
staleTime: 60s · Manual refetch
preferences
staleTime: ∞ · Invalidate on update
🔔

Push Notifications

La razón principal de la app nativa. Requiere trabajo en el backend.

Flow completo de notificación
📧 Email bancario
Penny extrae txn
Webhook → FinPal
Crea transacción
Recalcula budget.spent
¿spent ≥ umbral%?
SÍ →
Busca pushTokens del usuario
POST Expo Push API
📱 Notificación en device
ComponenteTecnologíaResponsabilidad
SDK en appExpo NotificationsPide permisos, obtiene pushToken del device, recibe notificaciones
Token storageSecureStore + BackendToken guardado localmente y enviado al backend al login/start
TriggerFinPal Backend (Node.js)Al procesar webhook de Penny, revisa si hay que notificar
DeliveryExpo Push ServiceMiddleware unificado que rutea a FCM (Android) o APNs (iOS)
UmbralesDB: user_notification_settingsThreshold % por categoría (default 80%), toggle on/off
// En el app — registrar push token al iniciar sesión async function registerPushToken() { const { status } = await Notifications.requestPermissionsAsync(); if (status !== 'granted') return; const token = (await Notifications.getExpoPushTokenAsync({ projectId: Constants.expoConfig.extra.eas.projectId })).data; await apiClient.post('/notifications/push-token', { token, platform: Platform.OS }); }
// En el backend — lógica de notificación (pseudo-código) async function checkBudgetAlerts(userId, categoryId, newSpent, budgetAmount) { const pct = (newSpent / budgetAmount) * 100; const settings = await db.notificationSettings.findFirst({ userId, categoryId }); const threshold = settings?.threshold ?? 80; if (pct >= threshold && pct < 100) { await sendPush(userId, { title: `⚠️ ${categoryName}`, body: `${pct.toFixed(0)}% del presupuesto gastado` }); } else if (pct >= 100) { await sendPush(userId, { title: `🚨 ${categoryName}: Presupuesto agotado`, body: `$${newSpent} / $${budgetAmount}`, priority: 'high' }); } }
📧

Gmail OAuth en Mobile

El mayor cambio respecto al web: Google exige flujo nativo desde 2025.

🚫
Web → Mobile: OAuth cambia
En el web, Gmail OAuth usa redirect a una URL (callback HTTP). En mobile, Google ya no permite WebViews genéricos para OAuth. Se debe usar expo-auth-session con PKCE, que abre el browser nativo del sistema (Chrome/Safari) y regresa a la app via deep link.
User toca "Conectar Gmail"
expo-auth-session inicia PKCE
Abre Chrome/Safari (browser nativo)
Google OAuth consent
Deep link: finpal://gmail/callback?code=...
POST /gmail/callback (code + verifier)
Backend intercambia por tokens → registra en Penny ✓
// app.json — configurar deep links { "expo": { "scheme": "finpal", // → finpal://gmail/callback "intentFilters": [{ // Android "action": "VIEW", "data": [{ "scheme": "finpal" }] }] } }
🔄

Soporte Offline

Registro de gastos sin conexión con sync automático.

Usuario agrega gasto
¿Hay internet?
SÍ →
POST /transactions directo ✓
NO →
Guarda en MMKV (offline queue)
UI: badge "Pendiente de sync"
Regresa internet →
NetInfo listener detecta
Flush queue → POST /transactions × N ✓
// stores/offlineQueue.ts — Zustand + MMKV const storage = new MMKV(); const useOfflineQueue = create( persist( (set, get) => ({ queue: [] as PendingTransaction[], addToQueue: (txn) => set(s => ({ queue: [...s.queue, { ...txn, _id: uuid() }] })), flush: async () => { for (const txn of get().queue) { await apiClient.post('/transactions', txn); } set({ queue: [] }); } }), { storage: createJSONStorage(() => storage) } ) );
🆕

Backend: Cambios Necesarios

Solo 4 endpoints nuevos. Todo lo demás ya existe.

El backend existente sirve el 95% de las necesidades
La REST API de FinPal ya tiene auth, budgets, transactions, categories, gmail, preferences. Solo falta el sistema de push notifications.
POST /notifications/push-token Registra el Expo push token del device del usuario nuevo
DEL /notifications/push-token Elimina push token al logout (evitar notificaciones post-sesión) nuevo
GET /notifications/settings Obtiene config de notificaciones por categoría (thresholds) nuevo
PUT /notifications/settings Actualiza umbrales de alerta por categoría + toggle on/off nuevo
Nuevas tablas en DB (Prisma)
// schema.prisma — modelos nuevos model PushToken { id String @id @default(cuid()) userId String token String @unique // Expo push token platform String // "ios" | "android" createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(...) } model NotificationSetting { id String @id @default(cuid()) userId String categoryId String? // null = configuración global enabled Boolean @default(true) threshold Int @default(80) // % para alertar user User @relation(...) category BudgetCategory? @relation(...) }
Trigger de notificación: dónde va el código
La lógica de push se agrega al handler del webhook de Penny (ya existe en el backend). Cuando Penny notifica una nueva transacción → se crea la txn → se llama checkBudgetAlerts() → si toca, se envía push. Sin cron, sin polling.
🚀

Build & Deploy

EAS Build en la nube + OTA updates para JS sin pasar por el store.

🔧 EAS Build
Compila los binarios (.ipa / .aab) en servidores de Expo. No necesitas Xcode ni Android Studio. Perfiles: development, preview, production.
⚡ EAS Update (OTA)
Actualiza el bundle JS sin nueva versión en el store. Para bugfixes y cambios de UI que no tocan código nativo. El binario nativo solo cambia cuando agregas módulos nativos.
# eas.json — perfiles de build { "build": { "development": { "developmentClient": true, "distribution": "internal" }, "preview": { "distribution": "internal" // TestFlight + Internal Track }, "production": { "autoIncrement": true // App Store + Google Play } } }
EtapaComandoDestino
Dev localnpx expo startExpo Go / Dev Client en device
QA / Testingeas build --profile previewTestFlight (iOS) · Internal Track (Android)
Hotfix JSeas update --branch productionOTA — usuarios reciben update al abrir app
Releaseeas build --profile productionApp Store Connect + Google Play Console
Submiteas submitPublica automáticamente en ambos stores
Variables de entorno
# .env.local (dev) / EAS Secrets (prod) EXPO_PUBLIC_API_URL=https://api.pocketpenny.site EXPO_PUBLIC_POSTHOG_KEY=... EXPO_PUBLIC_GOOGLE_CLIENT_ID=... # Para auth Google nativa EXPO_PUBLIC_EAS_PROJECT_ID=... # Para Expo push tokens
⚖️

Decisiones Técnicas

Por qué elegimos cada cosa y qué descartamos.

DecisiónElegidoDescartadoRazón
Framework mobile React Native + Expo Flutter / Nativo Reusar lógica del web (TanStack Query, Axios, hooks). Flutter requiere Dart. Nativo = doble trabajo.
Navegación Expo Router React Navigation bare File-based routing reduce boilerplate. Deep links automáticos para reset-password y gmail-callback.
Estado global TanStack Query + Zustand Redux / Context API TQ maneja server state perfectamente. Zustand para client state es minimalista y no requiere boilerplate de Redux.
Storage seguro Expo SecureStore AsyncStorage AsyncStorage no es cifrado. JWT y PIN deben estar en Keychain / Keystore.
Storage prefs MMKV AsyncStorage MMKV es sincrónico y 10x más rápido. Ideal para offline queue y preferencias frecuentes.
Push Expo Notifications Firebase Directly Expo abstrae FCM + APNs con una sola API. Expo Push Service maneja el routing. No requiere configuración nativa.
Gmail OAuth expo-auth-session WebView embedded Google bloqueó OAuth en WebViews desde 2023. expo-auth-session usa browser nativo con PKCE, que es el estándar actual.
UI Components Custom components NativeBase / Tamagui Las librerías de UI de RN añaden bloat y opiniones de diseño fuertes. Con el design system propio tenemos más control.