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.
| Capa | Tecnología | Razón |
| Runtime | React Native 0.76+ | Un codebase para iOS y Android. Familiar para el equipo (React) |
| Framework | Expo SDK 52+ (Managed) | Abstrae la config nativa. EAS para builds. OTA updates sin store review |
| Navegación | Expo Router v4 | File-based routing (como Next.js). Deep links automáticos |
| Estado servidor | TanStack Query v5 | Mismo que el frontend web. Cache, retry, invalidation automática |
| Estado local | Zustand | Ligero. Para auth state, preferencias locales, offline queue |
| HTTP client | Axios | Mismo que el web. Interceptors para JWT refresh automático |
| Almacenamiento seguro | Expo SecureStore | JWT tokens, PIN cifrado. Backed by Keychain (iOS) / Keystore (Android) |
| Almacenamiento local | MMKV (react-native-mmkv) | Para preferencias y offline queue. 10x más rápido que AsyncStorage |
| Push Notifications | Expo Notifications | SDK unificado para FCM + APNs. No requiere código nativo |
| Biometría | expo-local-authentication | Face ID, Touch ID, Fingerprint. API unificada iOS/Android |
| Gmail OAuth | expo-auth-session | OAuth 2.0 PKCE nativo. Evita WebView genérico (requerido por Google en 2025) |
| Formularios | React Hook Form + Zod | Validación consistente con el backend. Performance en listas largas |
| UI Components | Custom (no UI lib) | Design system propio basado en el web. Más control sobre animaciones nativas |
| Animaciones | React Native Reanimated 3 | Animaciones en el thread de UI. Swipe-to-delete, drag & drop fluido |
| Iconos | @expo/vector-icons | Lucide React Native / MaterialIcons. Mismo set que el web |
| Builds | EAS Build | Builds en la nube. Sin Xcode/Android Studio local requerido |
| Deploy / OTA | EAS Update | Actualizaciones JS instantáneas sin pasar por App Store |
| Analytics | PostHog React Native | Mismo que el web. Continuidad de métricas |
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
app/ # Expo Router root
├── _layout.tsx # Root layout: ThemeProvider, QueryClient, AuthGuard
├── index.tsx # Redirect → /auth o /dashboard
│
├── (auth)/ # Grupo público (sin bottom tab)
│ ├── login.tsx
│ ├── register.tsx
│ ├── forgot-password.tsx
│ ├── reset-password.tsx
│ └── google-callback.tsx
│
├── (app)/ # Grupo protegido (con bottom tab bar)
│ ├── _layout.tsx # Tab navigator + AuthGuard
│ │
│ ├── dashboard/
│ │ └── index.tsx # Home: resumen + transacciones recientes
│ │
│ ├── budgets/
│ │ ├── index.tsx # Lista de presupuestos
│ │ ├── [id].tsx # Detalle / edición de presupuesto
│ │ └── new.tsx # Crear presupuesto
│ │
│ ├── transactions/
│ │ ├── index.tsx # Lista + búsqueda paginada
│ │ ├── [id].tsx # Editar transacción
│ │ └── new.tsx # Agregar transacción manual
│ │
│ └── settings/
│ ├── index.tsx # Menú principal de configuración
│ ├── gmail.tsx # Gmail Settings (conectar/gestionar)
│ ├── period.tsx # Configuración de período
│ ├── notifications.tsx # Config de push + umbrales por categoría
│ └── security.tsx # Biometría / PIN
│
└── gmail-callback.tsx # Deep link handler para OAuth Gmail
Bottom Tab Bar — 4 tabs principales
🏠 Inicio
🎯 Presupuestos
🧾 Transacciones
⚙️ Configuración
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 ✓
| Token | Storage | TTL | Uso |
| accessToken (JWT) | Expo SecureStore | 15 min | Cada request a la API |
| refreshToken | Expo SecureStore | 7 días | Renovar accessToken silenciosamente |
| PIN hash | Expo SecureStore | ∞ | Fallback a biometría |
| pushToken | SecureStore + Backend | ∞ | Recibir 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.
// 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
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
| Componente | Tecnología | Responsabilidad |
| SDK en app | Expo Notifications | Pide permisos, obtiene pushToken del device, recibe notificaciones |
| Token storage | SecureStore + Backend | Token guardado localmente y enviado al backend al login/start |
| Trigger | FinPal Backend (Node.js) | Al procesar webhook de Penny, revisa si hay que notificar |
| Delivery | Expo Push Service | Middleware unificado que rutea a FCM (Android) o APNs (iOS) |
| Umbrales | DB: user_notification_settings | Threshold % 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'
});
}
}
🚫
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" }]
}]
}
}
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) }
)
);
✅
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.
🔧 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
}
}
}
| Etapa | Comando | Destino |
| Dev local | npx expo start | Expo Go / Dev Client en device |
| QA / Testing | eas build --profile preview | TestFlight (iOS) · Internal Track (Android) |
| Hotfix JS | eas update --branch production | OTA — usuarios reciben update al abrir app |
| Release | eas build --profile production | App Store Connect + Google Play Console |
| Submit | eas submit | Publica 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
| Decisión | Elegido | Descartado | Razó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. |