From 792b8fa3e23d596773eb24e2cb0cc018c6c0d722 Mon Sep 17 00:00:00 2001 From: AngelPila Date: Fri, 5 Dec 2025 13:02:50 -0500 Subject: [PATCH 1/2] Se corrigio la db --- .env | 15 ++- App.tsx | 31 ++++++- README.firestoredb.md | 61 ++++++++++++ components/AddCuenta.tsx | 13 +-- components/ChartsScreen.tsx | 2 +- components/DetalleCuenta.tsx | 6 +- components/Estadisticas.tsx | 2 +- components/Formulario.tsx | 57 +++++++----- components/GraficoBalance.tsx | 8 +- components/Home.tsx | 40 +++++--- components/Inicio.tsx | 23 ++++- components/Login.tsx | 124 +++++++++++++++++++++++++ components/TransaccionList.tsx | 26 +++--- contexts/AuthContext.tsx | 44 +++++++++ firestore.indexes.json | 31 +++++++ firestore.rules | 39 ++++++++ package-lock.json | 132 +++++++++++++++++--------- package.json | 2 + services/firestore.ts | 163 +++++++++++++++++++++++++++++++++ utils/firebase.js | 93 ++++++++++++++++++- 20 files changed, 789 insertions(+), 123 deletions(-) create mode 100644 README.firestoredb.md create mode 100644 components/Login.tsx create mode 100644 contexts/AuthContext.tsx create mode 100644 firestore.indexes.json create mode 100644 firestore.rules create mode 100644 services/firestore.ts diff --git a/.env b/.env index a3b6ac6..f57cb21 100644 --- a/.env +++ b/.env @@ -1,8 +1,7 @@ -EXPO_PUBLIC_FIREBASE_API_KEY=AIzaSyCizNbDECCLXJyBimg1Q2UFalrguFdQ614 -EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=pre-taws.firebaseapp.com -EXPO_PUBLIC_FIREBASE_DATABASE_URL=https://pre-taws-default-rtdb.firebaseio.com -EXPO_PUBLIC_FIREBASE_PROJECT_ID=pre-taws -EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=pre-taws.firebasestorage.app -EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=787398838709 -EXPO_PUBLIC_FIREBASE_APP_ID=1:787398838709:web:1e6ebc76530cfa47b9b1fc -EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID=G-51P1KVVG6H +EXPO_PUBLIC_FIREBASE_API_KEY=AIzaSyBAyoohTHVBz3bZFmRiDb6s80D2hBeMSfs +EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=visuwallet.firebaseapp.com +EXPO_PUBLIC_FIREBASE_PROJECT_ID=visuwallet +EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=visuwallet.firebasestorage.app +EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=410749935369 +EXPO_PUBLIC_FIREBASE_APP_ID=11:410749935369:web:5d6e1ac0ff306ea26cfdd8 +EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID=G-KFVKWGVXD2 diff --git a/App.tsx b/App.tsx index 8b325fe..2c8b1b0 100644 --- a/App.tsx +++ b/App.tsx @@ -4,6 +4,7 @@ import "global.css"; import { useState } from "react"; import { SafeAreaProvider } from "react-native-safe-area-context"; +import { AuthProvider, useAuth } from "./contexts/AuthContext"; import Home from "components/Home"; import Inicio from "components/Inicio"; @@ -11,13 +12,15 @@ import Formulario from "components/Formulario"; import DetalleCuenta from "components/DetalleCuenta"; import Estadisticas from "components/Estadisticas"; import ChartsScreen from "components/ChartsScreen"; +import Login from "components/Login"; import { Cuenta } from "./types"; -export default function App() { +function AppContent() { // Estado para controlar qué pantalla mostrar const [currentScreen, setCurrentScreen] = useState<'home' | 'inicio' | 'formulario' | 'detalleCuenta' | 'estadisticas' | 'charts'>('home'); + const { user, isLoading } = useAuth(); // Estado para guardar la cuenta seleccionada const [selectedAccount, setSelectedAccount] = useState(null); @@ -33,11 +36,23 @@ export default function App() { setCurrentScreen('detalleCuenta'); }; + if (isLoading) { + return ( + + {/* loading state */} + + ); + } + + const isLoggedIn = !!user; + return ( - + {/* Renderizar la pantalla según el estado */} - {currentScreen === 'home' && ( + {!isLoggedIn ? ( + setCurrentScreen('home')} /> + ) : currentScreen === 'home' && ( navigateTo('inicio')} onPressAccount={navigateToAccountDetail} @@ -84,6 +99,14 @@ export default function App() { onPressEstadisticas={() => navigateTo('estadisticas')} /> )} - + + ); +} + +export default function App() { + return ( + + + ); } \ No newline at end of file diff --git a/README.firestoredb.md b/README.firestoredb.md new file mode 100644 index 0000000..032dec8 --- /dev/null +++ b/README.firestoredb.md @@ -0,0 +1,61 @@ +# Firestore Data Model for VisuWallet + +## Colecciones y documentos + +- `usuarios/{uid}` + - `nombre`: string + - `telefono`: string + - `providerId`: string (opcional) + - `createdAt`, `updatedAt`: timestamp + - Subcolección: `cuentas/{cuentaId}` + - `nombre`: string + - `numero`: string + - `tipo`: string + - `moneda`: string + - `saldoInicial`: number + - `notas`: string (opcional) + - `ownerUid`: string (igual a `uid`) + - `createdAt`, `updatedAt`: timestamp + +- `transacciones/{txId}` + - Datos del formulario: + - `monto`: number + - `tipo`: string + - `categoria`: string + - `fecha`: timestamp + - `descripcion`: string (opcional) + - Referencias: + - `refUsuario`: reference → `usuarios/{uid}` + - `refCuenta`: reference → `usuarios/{uid}/cuentas/{cuentaId}` + - Campos denormalizados para consultas: + - `ownerUid`: string + - `cuentaId`: string + - `cuentaNumero`: string (opcional) + - Auditoría: + - `createdAt`, `updatedAt`: timestamp + - `createdByUid`: string + +## Consultas recomendadas + +- Por usuario: `where('ownerUid','==',uid).orderBy('fecha','desc')` +- Por cuenta: `where('ownerUid','==',uid).where('cuentaId','==',cuentaId).orderBy('fecha','desc')` +- Por rango de fechas: `where('ownerUid','==',uid).where('fecha','>=',start).where('fecha','<=',end).orderBy('fecha')` + +## Índices sugeridos + +Ver `firestore.indexes.json` incluido en el proyecto. + +## Reglas de seguridad + +Ver `firestore.rules` incluido en el proyecto. En resumen: + +- `usuarios` y sus `cuentas` solo son accesibles por su dueño (`request.auth.uid`). +- `transacciones` solo pueden ser leídas/escritas por el dueño (`ownerUid`). +- En `create` de `transacciones` se valida que las referencias apunten al usuario y cuenta correctos. + +## Buenas prácticas + +- Usar subcolecciones para listas que crecen (cuentas por usuario). +- Mantener denormalización mínima en `transacciones` (`ownerUid`, `cuentaId`, `cuentaNumero`) para consultas rápidas. +- Acompañar referencias (`refUsuario`, `refCuenta`) con IDs planos para filtros. +- Añadir timestamps con `serverTimestamp()` y auditar con `createdByUid`. diff --git a/components/AddCuenta.tsx b/components/AddCuenta.tsx index 9fefccc..278cbde 100644 --- a/components/AddCuenta.tsx +++ b/components/AddCuenta.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { Modal, View, Text, TextInput, TouchableOpacity, Alert, Pressable } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { db } from 'utils/firebase.js'; -import { collection, addDoc, serverTimestamp } from 'firebase/firestore'; +import { addCuenta } from '../services/firestore'; +import { useAuth } from '../contexts/AuthContext'; interface Props { visible: boolean; @@ -11,6 +11,7 @@ interface Props { } export default function AddCuenta({ visible, onClose, onSaved }: Props) { + const { user } = useAuth(); const [tipo, setTipo] = useState<'corriente' | 'ahorros'>('corriente'); const [numero, setNumero] = useState(''); const [saldo, setSaldo] = useState('0'); @@ -42,16 +43,16 @@ export default function AddCuenta({ visible, onClose, onSaved }: Props) { cedula, propietario, email: email || null, - createdAt: serverTimestamp(), + ownerUid: user?.uid ?? null, } as any; setSaving(true); try { - const ref = await addDoc(collection(db, 'cuentas'), payload); - console.log('Cuenta creada', ref.id, payload); + const id = await addCuenta(payload, user?.uid); + console.log('Cuenta creada', id, payload); reset(); onClose(); - onSaved?.(ref.id); + onSaved?.(id); } catch (e) { console.warn('Failed to save account to Firestore, falling back to console', e); // Fallback: just log and close diff --git a/components/ChartsScreen.tsx b/components/ChartsScreen.tsx index 950a681..b80e809 100644 --- a/components/ChartsScreen.tsx +++ b/components/ChartsScreen.tsx @@ -91,7 +91,7 @@ export default function ChartsScreen({ onBack, onPressAdd, onPressHome, onPressE setCuentas(cuentasData); // Cargar transacciones - const snap = await getDocs(collection(db, 'registro')); + const snap = await getDocs(collection(db, 'transacciones')); const docs = snap.docs.map(d => { const data = d.data() as any; return { diff --git a/components/DetalleCuenta.tsx b/components/DetalleCuenta.tsx index bbd9d56..be8f510 100644 --- a/components/DetalleCuenta.tsx +++ b/components/DetalleCuenta.tsx @@ -34,8 +34,8 @@ export default function DetalleCuenta({ cuenta, onBack, onPressAdd, onPressHome, // If cuenta.numero is available, query by accountId field in Firestore if (cuenta && (cuenta as any).numero) { const accNum = (cuenta as any).numero; - // query registro where accountId == accNum ordered by date desc - const q = query(collection(db, 'registro'), where('accountId', '==', accNum), orderBy('date', 'desc')); + // query transacciones where cuentaNumero == accNum ordered by fecha desc + const q = query(collection(db, 'transacciones'), where('cuentaNumero', '==', accNum), orderBy('fecha', 'desc')); const snap = await getDocs(q); const docs = snap.docs.map(d => { const data = d.data() as any; @@ -52,7 +52,7 @@ export default function DetalleCuenta({ cuenta, onBack, onPressAdd, onPressHome, } // Fallback: load all and filter by name/id (best-effort) - const snap = await getDocs(collection(db, 'registro')); + const snap = await getDocs(collection(db, 'transacciones')); const docs = snap.docs.map(d => { const data = d.data() as any; return { diff --git a/components/Estadisticas.tsx b/components/Estadisticas.tsx index 5ef3d2d..8323e1b 100644 --- a/components/Estadisticas.tsx +++ b/components/Estadisticas.tsx @@ -84,7 +84,7 @@ export default function Estadisticas({ onBack, onPressAdd, onPressHome, onPressC setCuentas(cuentasData); // Cargar transacciones (patrón de Home.tsx) - const snap = await getDocs(collection(db, 'registro')); + const snap = await getDocs(collection(db, 'transacciones')); const docs = snap.docs.map(d => { const data = d.data() as any; return { diff --git a/components/Formulario.tsx b/components/Formulario.tsx index fa7dca8..99706a5 100644 --- a/components/Formulario.tsx +++ b/components/Formulario.tsx @@ -14,8 +14,9 @@ import { SafeAreaView } from "react-native-safe-area-context"; import AsyncStorage from '@react-native-async-storage/async-storage'; import Categorias from "./Categorias"; -import { db, ensureAnonymousSignIn } from "utils/firebase.js"; -import { collection, addDoc, getDocs, query, where, deleteDoc, doc, serverTimestamp } from "firebase/firestore"; +import { db } from "utils/firebase.js"; +import { collection, addDoc, getDocs, deleteDoc, doc, serverTimestamp } from "firebase/firestore"; +import { addTransaction, listCuentas } from "services/firestore"; interface FormularioProps { @@ -26,6 +27,7 @@ export default function Formulario({onBack}:FormularioProps) { const [type, setType] = useState<"expense" | "income" | "transfer">("expense"); const [amount, setAmount] = useState(""); const [account, setAccount] = useState("Cuenta transaccional"); + const [accountCode, setAccountCode] = useState(""); const [category, setCategory] = useState(null); const [dateTime, setDateTime] = useState(new Date().toString()); const [labels, setLabels] = useState(""); // could be an array later @@ -114,22 +116,18 @@ export default function Formulario({onBack}:FormularioProps) { const loadAccounts = async () => { try { - const snapAcc = await getDocs(collection(db, 'cuentas')); - const accs = snapAcc.docs.map(d => { - const data = d.data() as any; - return { - id: d.id, - tipo: data.tipo || 'corriente', - numero: data.numero || '', - saldo: data.saldo ? Number(data.saldo) : 0, - cedula: data.cedula || '', - propietario: data.propietario || data.titular || '', - email: data.email || '', - }; - }); - // sort by propietario then numero - accs.sort((a,b) => (a.propietario || '').localeCompare(b.propietario || '') || (a.numero || '').localeCompare(b.numero || '')); - setAccounts(accs); + const accs = await listCuentas(); + const mapped = accs.map((c) => ({ + id: c.id, + tipo: c.tipo, + numero: c.numero, + saldo: c.saldo, + cedula: c.cedula, + propietario: c.propietario, + email: c.email, + })); + mapped.sort((a,b) => (a.propietario || '').localeCompare(b.propietario || '') || (a.numero || '').localeCompare(b.numero || '')); + setAccounts(mapped); } catch (e) { console.warn('Failed to load cuentas', e); setAccounts([]); @@ -170,13 +168,24 @@ export default function Formulario({onBack}:FormularioProps) { const saveRecord = async (rec?: typeof record) => { const toSave = rec ?? record; try { - await addDoc(collection(db, 'registro'), { ...toSave }); - Alert.alert('Success', 'Record saved successfully!'); - console.log('Record saved:', toSave); - onBack(); // ← : llamar a la función onBack + await addTransaction({ + tipo: toSave.type as any, + categoria: toSave.category, + monto: Number(toSave.amount) || 0, + fecha: toSave.date, + descripcion: toSave.details, + account: toSave.account, + cuentaCodigo: (toSave as any).accountCode || accountCode || '', + // pass account id when available so refCuenta is created in transaction + cuentaId: accounts.find(a => `${a.propietario ? `${a.propietario} — ` : ''}${a.numero || ''}` === (toSave.account || account))?.id, + labels: Array.isArray(toSave.labels) ? toSave.labels : [], + payee: toSave.payee, + }); + Alert.alert('Success', 'Transaction saved successfully!'); + onBack(); } catch (e: unknown) { console.error(e); - Alert.alert('Error', 'Failed to save record.'); + Alert.alert('Error', 'Failed to save transaction.'); } }; const createAgain = () => { @@ -209,6 +218,7 @@ export default function Formulario({onBack}:FormularioProps) { const recToSave = { account, + accountCode, category: category ?? '', date: dateTime, details: note, @@ -338,6 +348,7 @@ export default function Formulario({onBack}:FormularioProps) { onPress={() => { const display = c.propietario ? `${c.propietario} — ${c.numero || ''}` : (c.numero || 'Cuenta'); setAccount(display); + setAccountCode(c.numero || ''); handleChange('account', display); setShowAccountsModal(false); }} diff --git a/components/GraficoBalance.tsx b/components/GraficoBalance.tsx index 85fb80d..53bd740 100644 --- a/components/GraficoBalance.tsx +++ b/components/GraficoBalance.tsx @@ -26,7 +26,9 @@ export default function GraficoBalance({ balance, transacciones = [], onPressSho const fechaStr = fecha.toISOString().split('T')[0]; const transaccionesDia = transacciones.filter(t => { - const fechaTransaccion = new Date(t.fecha).toISOString().split('T')[0]; + const ts = Date.parse(t.fecha as any); + if (isNaN(ts)) return false; + const fechaTransaccion = new Date(ts).toISOString().split('T')[0]; return fechaTransaccion === fechaStr; }); @@ -49,7 +51,9 @@ export default function GraficoBalance({ balance, transacciones = [], onPressSho const fechaStr = fecha.toISOString().split('T')[0]; const transaccionesDia = transacciones.filter(t => { - const fechaTransaccion = new Date(t.fecha).toISOString().split('T')[0]; + const ts = Date.parse(t.fecha as any); + if (isNaN(ts)) return false; + const fechaTransaccion = new Date(ts).toISOString().split('T')[0]; return fechaTransaccion === fechaStr; }); diff --git a/components/Home.tsx b/components/Home.tsx index 37e1832..ccc5a86 100644 --- a/components/Home.tsx +++ b/components/Home.tsx @@ -15,11 +15,14 @@ import KPICard from './KPICard'; import { mockTransactions } from "../datosPrueba"; import { db } from "utils/firebase.js"; +import { useAuth } from "../contexts/AuthContext"; +import { signOutUser, ensureAnonymousSignIn } from "../utils/firebase"; import { collection, getDocs } from "firebase/firestore"; import { Transaccion } from "../types"; import TransactionDetails from './TransactionDetails'; import { Cuenta } from "../types"; +import { listCuentas } from "../services/firestore"; interface HomeProps { onPressAdd: () => void; @@ -30,7 +33,17 @@ interface HomeProps { export default function Home({ onPressAdd, onPressAccount, onPressEstadisticas, onPressCharts }: HomeProps) { const insets = useSafeAreaInsets(); - const nombreUsuario = "Sebas"; + const { user } = useAuth(); + const nombreUsuario = user?.displayName || user?.email || 'Invitado'; + const handleLogout = async () => { + try { + await signOutUser(); + // optionally ensure anonymous session after logout + await ensureAnonymousSignIn(); + } catch (e) { + console.warn('No se pudo cerrar sesión'); + } + }; const [accounts, setAccounts] = useState<{id:string; nombre:string; balance:number}[]>([]); const [transactions, setTransactions] = useState(mockTransactions); const [allTransactions, setAllTransactions] = useState([]); // Todas las transacciones para gráficos @@ -43,7 +56,7 @@ export default function Home({ onPressAdd, onPressAccount, onPressEstadisticas, const loadAll = async () => { try { // transactions - const snap = await getDocs(collection(db, 'registro')); + const snap = await getDocs(collection(db, 'transacciones')); const docs = snap.docs.map(d => { const data = d.data() as any; return { @@ -63,16 +76,13 @@ export default function Home({ onPressAdd, onPressAccount, onPressEstadisticas, setTransactions(docs.slice(0,4)); // Solo las primeras 4 para mostrar // accounts - const snapAcc = await getDocs(collection(db, 'cuentas')); - const accs = snapAcc.docs.map(d => { - const data = d.data() as any; - return { - id: d.id, - nombre: data.propietario ?? data.nombre ?? `Cuenta ${d.id}`, - balance: Number(data.saldo ?? data.balance ?? 0), - numero: data.numero ?? '', - }; - }); + const accsRaw = await listCuentas(user?.uid || undefined); + const accs = accsRaw.map(d => ({ + id: d.id, + nombre: d.propietario ?? `Cuenta ${d.id}`, + balance: Number(d.saldo ?? 0), + numero: d.numero ?? '', + })); setAccounts(accs); } catch (e) { console.warn('Failed to load data from Firestore, using fallbacks', e); @@ -104,6 +114,9 @@ export default function Home({ onPressAdd, onPressAccount, onPressEstadisticas, Hola, {nombreUsuario} + + Auth: {user?.providerData?.[0]?.providerId ?? 'anonymous'} + @@ -115,10 +128,11 @@ export default function Home({ onPressAdd, onPressAccount, onPressEstadisticas, - + diff --git a/components/Inicio.tsx b/components/Inicio.tsx index 7309666..3ce4379 100644 --- a/components/Inicio.tsx +++ b/components/Inicio.tsx @@ -2,6 +2,8 @@ import React from "react"; import { Text, TouchableOpacity, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; +import { useAuth } from "../contexts/AuthContext"; +import { signInWithGoogle } from "../utils/firebase"; interface InicioProps { onPressManual: () => void; // ← NUEVA PROP @@ -9,7 +11,15 @@ interface InicioProps { } export default function Inicio({ onPressManual, onBack }: InicioProps) { // ← RECIBIR PROPS - + const { user } = useAuth(); + + const handleGoogleSignIn = async () => { + try { + await signInWithGoogle(); + } catch (e) { + // optionally show a toast + } + }; return ( + {/* Google Sign-In */} + + + {user?.providerData?.[0]?.providerId === 'google.com' ? 'Conectado con Google' : 'Iniciar sesión con Google'} + + + void; +}; + +export default function Login({ onContinue }: LoginProps) { + const { user } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [mode, setMode] = useState<'login' | 'signup'>('login'); + const [displayName, setDisplayName] = useState(''); + const [phone, setPhone] = useState(''); + const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); + + const handleAnon = async () => { + try { + await ensureAnonymousSignIn(); + onContinue(); + } catch { + setError('Fallo al continuar como invitado'); + } + }; + + const handleEmailAuth = async () => { + setError(null); + try { + if (mode === 'login') { + await signInWithEmail(email.trim(), password); + } else { + const nameTrim = displayName.trim(); + const phoneTrim = phone.trim(); + if (!nameTrim) { setError('El nombre es obligatorio'); return; } + if (!phoneTrim) { setError('El número de celular es obligatorio'); return; } + const u = await signUpWithEmail(email.trim(), password); + try { + await upsertUser({ uid: u.uid, email: u.email ?? undefined, displayName: nameTrim, phone: phoneTrim, providerId: 'password' }); + } catch {} + } + onContinue(); + } catch (e: any) { + setError(e?.message || 'Error de autenticación'); + } + }; + + return ( + + + + + + + VisuWallet + {mode==='login' ? 'Bienvenido de vuelta' : 'Crear una nueva cuenta'} + + + + + setMode('login')} className={`flex-1 rounded-lg py-2 items-center justify-center ${mode==='login' ? 'bg-neutral-700' : ''}`}> + Ingresar + + setMode('signup')} className={`flex-1 rounded-lg py-2 items-center justify-center ${mode==='signup' ? 'bg-neutral-700' : ''}`}> + Crear cuenta + + + + + Email + + + + Contraseña + + + setShowPassword(!showPassword)} className="px-3"> + + + + + {error && ( + + {error} + + )} + + + {mode==='login' ? 'Entrar' : 'Registrarse'} + + + {mode==='signup' && ( + <> + + Nombre + + + + Número de celular + + + + )} + + + + + + + Continuar como invitado + + + + + + ); +} + + diff --git a/components/TransaccionList.tsx b/components/TransaccionList.tsx index 2a90805..1a7c868 100644 --- a/components/TransaccionList.tsx +++ b/components/TransaccionList.tsx @@ -3,8 +3,7 @@ import { Modal, View, Text, TouchableOpacity, ScrollView } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import TransaccionItem from './ItemTransaccion'; import { Transaccion } from '../types'; -import { db } from 'utils/firebase.js'; -import { collection, getDocs } from 'firebase/firestore'; +import { listTransactions } from 'services/firestore'; interface Props { visible: boolean; @@ -17,23 +16,20 @@ export default function TransaccionList({ visible, onClose }: Props) { useEffect(() => { const load = async () => { try { - const snap = await getDocs(collection(db, 'registro')); - const docs = snap.docs.map(d => { - const data = d.data() as any; - return { - id: d.id, - tipo: data.type ?? data.tipo ?? 'expense', - categoria: data.category ?? data.categoria ?? '', - monto: Number(data.amount ?? data.monto ?? 0), - fecha: data.date ?? data.fecha ?? (data.createdAt ? data.createdAt.toDate().toString() : ''), - } as Transaccion; - }); - docs.sort((a,b) => { + const docs = await listTransactions(); + const mapped = docs.map((d) => ({ + id: d.id, + tipo: (d.tipo as any) ?? 'expense', + categoria: d.categoria ?? '', + monto: Number(d.monto ?? 0), + fecha: d.fecha ?? (d.createdAt && d.createdAt.toDate ? d.createdAt.toDate().toString() : ''), + })) as Transaccion[]; + mapped.sort((a, b) => { const ta = a.fecha ? new Date(a.fecha).getTime() : 0; const tb = b.fecha ? new Date(b.fecha).getTime() : 0; return tb - ta; }); - setTransactions(docs); + setTransactions(mapped); } catch (e) { console.warn('Failed to load transactions for list', e); } diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx new file mode 100644 index 0000000..7c73303 --- /dev/null +++ b/contexts/AuthContext.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { onAuthStateChanged, User } from 'firebase/auth'; +import { auth, ensureAnonymousSignIn } from '../utils/firebase'; +import { upsertUser } from '../services/firestore'; + +type AuthContextValue = { + user: User | null; + isLoading: boolean; +}; + +const AuthContext = createContext({ user: null, isLoading: true }); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Ensure there's at least an anonymous session + ensureAnonymousSignIn().catch(() => {}); + + const unsub = onAuthStateChanged(auth, async (u) => { + setUser(u); + setIsLoading(false); + if (u) { + try { + await upsertUser({ + uid: u.uid, + email: u.email ?? undefined, + displayName: u.displayName ?? undefined, + photoURL: u.photoURL ?? undefined, + providerId: u.providerData?.[0]?.providerId ?? 'anonymous', + }); + } catch (err) { + // swallow errors to avoid blocking the app + } + } + }); + return () => unsub(); + }, []); + + return {children}; +}; + +export const useAuth = () => useContext(AuthContext); diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..26fbfbe --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,31 @@ +{ + "indexes": [ + { + "collectionGroup": "transacciones", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "ownerUid", "order": "ASCENDING" }, + { "fieldPath": "fecha", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "transacciones", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "ownerUid", "order": "ASCENDING" }, + { "fieldPath": "cuentaId", "order": "ASCENDING" }, + { "fieldPath": "fecha", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "transacciones", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "ownerUid", "order": "ASCENDING" }, + { "fieldPath": "categoria", "order": "ASCENDING" }, + { "fieldPath": "fecha", "order": "DESCENDING" } + ] + } + ], + "fieldOverrides": [] +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..79d14c4 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,39 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + + // Usuarios profile + match /usuarios/{uid} { + allow read, write: if request.auth != null && request.auth.uid == uid; + + // Subcollection cuentas under user + match /cuentas/{cuentaId} { + allow read, write: if request.auth != null && request.auth.uid == uid; + + // Optional: basic field validations on create/update + function isValidCuenta() { + return request.resource.data.keys().hasAll(['nombre','numero']) && + (request.resource.data.ownerUid == uid); + } + + allow create: if isValidCuenta(); + allow update: if isValidCuenta(); + } + } + + // Transacciones top-level + match /transacciones/{txId} { + // Read/write only by owner + allow read, write: if request.auth != null && request.auth.uid == resource.data.ownerUid; + + // Create must set ownerUid to caller and refs to caller's path + allow create: if request.auth != null + && request.resource.data.ownerUid == request.auth.uid + && request.resource.data.refUsuario.path == '/databases/' + __database__ + '/documents/usuarios/' + request.auth.uid + && request.resource.data.refCuenta.path.matches('^/databases/' + __database__ + '/documents/usuarios/' + request.auth.uid + '/cuentas/.+$'); + + // Update keeps ownership invariant + allow update: if request.auth != null && request.auth.uid == resource.data.ownerUid; + } + } +} diff --git a/package-lock.json b/package-lock.json index cb234f7..2d14b78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,10 @@ "@react-native-async-storage/async-storage": "^1.24.0", "@shopify/react-native-skia": "2.2.12", "expo": "^54.0.0", + "expo-auth-session": "^7.0.9", "expo-linear-gradient": "~15.0.7", "expo-status-bar": "~3.0.8", + "expo-web-browser": "^15.0.9", "firebase": "^12.5.0", "nativewind": "^4.2.1", "react": "19.1.0", @@ -1380,7 +1382,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1451,6 +1452,7 @@ "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", "license": "MIT", + "peer": true, "dependencies": { "@types/hammerjs": "^2.0.36" }, @@ -2286,7 +2288,6 @@ "version": "0.14.5", "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.5.tgz", "integrity": "sha512-zyNY77xJOGwcuB+xCxF8z8lSiHvD4ox7BCsqLEHEvgqQoRjxFZ0fkROR6NV5QyXmCqRLodMM8J5d2EStOocWIw==", - "peer": true, "dependencies": { "@firebase/component": "0.7.0", "@firebase/logger": "0.5.0", @@ -2348,7 +2349,6 @@ "version": "0.5.5", "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.5.tgz", "integrity": "sha512-lVG/nRnXaot0rQSZazmTNqy83ti9O3+kdwoaE0d5wahRIWNoDirbIMcGVjDDgdmf4IE6FYreWOMh0L3DV1475w==", - "peer": true, "dependencies": { "@firebase/app": "0.14.5", "@firebase/component": "0.7.0", @@ -2363,8 +2363,7 @@ "node_modules/@firebase/app-types": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "peer": true + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==" }, "node_modules/@firebase/auth": { "version": "1.11.1", @@ -2783,7 +2782,6 @@ "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", "hasInstallScript": true, - "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -2979,9 +2977,10 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -3296,7 +3295,6 @@ "version": "1.24.0", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.24.0.tgz", "integrity": "sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==", - "peer": true, "dependencies": { "merge-options": "^3.0.4" }, @@ -3553,7 +3551,6 @@ "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.2.12.tgz", "integrity": "sha512-P5wZSMPTp00hM0do+awNFtb5aPh5hSpodMGwy7NaxK90AV+SmUu7wZe6NGevzQIwgFa89Epn6xK3j4jKWdQi+A==", "license": "MIT", - "peer": true, "dependencies": { "canvaskit-wasm": "0.40.0", "react-reconciler": "0.31.0" @@ -3661,7 +3658,8 @@ "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -3708,7 +3706,6 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3783,7 +3780,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -4315,7 +4311,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5011,7 +5006,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -5692,7 +5686,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -6314,7 +6307,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6517,7 +6509,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6740,7 +6731,6 @@ "version": "54.0.22", "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.22.tgz", "integrity": "sha512-w8J89M9BdVwo6urwvPeV4nAUwykv9si1UHUfZvSVWQ/b2aGs0Ci/a5RZ550rdEBgJXZAapIAhdW2M28Ojw+oGg==", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.15", @@ -6802,6 +6792,45 @@ "react-native": "*" } }, + "node_modules/expo-auth-session": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.9.tgz", + "integrity": "sha512-mPSwaRWOJYas160lXi5P/7BkLy0xbh+er+IMmAYHqf2+iz2WWs9W/4lMAklQVJG2mCyOZi24XrkffvB2izCa1g==", + "license": "MIT", + "dependencies": { + "expo-application": "~7.0.7", + "expo-constants": "~18.0.10", + "expo-crypto": "~15.0.7", + "expo-linking": "~8.0.9", + "expo-web-browser": "~15.0.9", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-auth-session/node_modules/expo-application": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.7.tgz", + "integrity": "sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-auth-session/node_modules/expo-crypto": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.7.tgz", + "integrity": "sha512-FUo41TwwGT2e5rA45PsjezI868Ch3M6wbCZsmqTWdF/hr+HyPcrp1L//dsh/hsrsyrQdpY/U96Lu71/wXePJeg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.10", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz", @@ -6828,7 +6857,6 @@ "version": "14.0.9", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz", "integrity": "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==", - "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -6852,13 +6880,26 @@ "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz", "integrity": "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA==", "license": "MIT", - "peer": true, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, + "node_modules/expo-linking": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.9.tgz", + "integrity": "sha512-a0UHhlVyfwIbn8b1PSFPoFiIDJeps2iEq109hVH3CHd0CMKuRxFfNio9Axe2BjXhiJCYWR4OV1iIyzY/GjiVkQ==", + "license": "MIT", + "dependencies": { + "expo-constants": "~18.0.10", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.20", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.20.tgz", @@ -6906,6 +6947,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "15.0.9", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.9.tgz", + "integrity": "sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -7390,9 +7441,10 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -7606,6 +7658,7 @@ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "react-is": "^16.7.0" } @@ -7614,7 +7667,8 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/hosted-git-info": { "version": "7.0.2", @@ -8496,7 +8550,6 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8507,9 +8560,10 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -9615,9 +9669,10 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" } @@ -10211,7 +10266,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10357,7 +10411,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10630,7 +10683,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10668,7 +10720,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10691,7 +10742,6 @@ "version": "0.81.5", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -11066,7 +11116,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz", "integrity": "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg==", - "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" @@ -11104,7 +11153,6 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", "license": "MIT", - "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -11250,7 +11298,6 @@ "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12398,7 +12445,6 @@ "version": "3.4.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12766,7 +12812,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13492,7 +13537,6 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 05ae394..d9b1a87 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "@react-native-async-storage/async-storage": "^1.24.0", "@shopify/react-native-skia": "2.2.12", "expo": "^54.0.0", + "expo-auth-session": "^7.0.9", "expo-linear-gradient": "~15.0.7", "expo-status-bar": "~3.0.8", + "expo-web-browser": "^15.0.9", "firebase": "^12.5.0", "nativewind": "^4.2.1", "react": "19.1.0", diff --git a/services/firestore.ts b/services/firestore.ts new file mode 100644 index 0000000..a6663b9 --- /dev/null +++ b/services/firestore.ts @@ -0,0 +1,163 @@ +import { db, ensureAnonymousSignIn } from 'utils/firebase.js'; +import { + addDoc, + collection, + doc, + getDoc, + getDocs, + onSnapshot, + query, + serverTimestamp, + setDoc, + where, +} from 'firebase/firestore'; + +export type FirestoreTransaction = { + tipo: 'income' | 'expense' | 'transfer'; + categoria: string; + monto: number; + fecha: string; // stored as string per current UI + descripcion?: string; + cuentaCodigo?: string; // account code (numero) to link transaction to user's account + account?: string; // display account string from UI + labels?: string[]; + payee?: string; + createdAt?: any; + createdByUid?: string; + ownerUid?: string; +}; + +export type FirestoreUser = { + uid: string; + email?: string; + displayName?: string; + phone?: string; + photoURL?: string; + providerId?: string; +}; + +export type FirestoreCuenta = { + tipo?: string; + numero?: string; + saldo?: number; + cedula?: string; + propietario?: string; // or titular + email?: string; + ownerUid?: string; +}; + +// Add a transaction to 'transacciones' with audit fields +export async function addTransaction(tx: FirestoreTransaction & { cuentaId?: string }) { + const user = await ensureAnonymousSignIn(); + const ownerUid = user?.uid ?? undefined; + const payload = { + ...tx, + ownerUid, + createdByUid: ownerUid, + createdAt: serverTimestamp(), + } as any; + + // Add strong references for ease of navigation from app + if (ownerUid) { + payload.refUsuario = doc(db, 'usuarios', ownerUid); + } + if (ownerUid && tx.cuentaId) { + payload.refCuenta = doc(db, 'usuarios', ownerUid, 'cuentas', tx.cuentaId); + } + + const ref = await addDoc(collection(db, 'transacciones'), payload); + return ref.id; +} + +// Fetch all transactions (simple one-shot) +export async function listTransactions(): Promise<(FirestoreTransaction & { id: string })[]> { + const snap = await getDocs(collection(db, 'transacciones')); + return snap.docs.map((d) => ({ id: d.id, ...(d.data() as any) })); +} + +// Subscribe to transactions in real time +export function onTransactions(cb: (items: (FirestoreTransaction & { id: string })[]) => void) { + const q = query(collection(db, 'transacciones')); + return onSnapshot(q, (snap) => { + const items = snap.docs.map((d) => ({ id: d.id, ...(d.data() as any) })); + cb(items); + }); +} + +// Upsert user document in 'usuarios/{uid}' +export async function upsertUserDoc(uid: string, data: FirestoreUser) { + const ref = doc(db, 'usuarios', uid); + await setDoc(ref, { ...data, createdAt: serverTimestamp() }, { merge: true }); +} + +export async function getUser(uid: string): Promise { + const ref = doc(db, 'usuarios', uid); + const snap = await getDoc(ref); + return snap.exists() ? (snap.data() as any) : null; +} + +// Convenience upsert accepting FirestoreUser shape with uid included +export async function upsertUser(user: FirestoreUser) { + const ref = doc(db, 'usuarios', user.uid); + await setDoc(ref, { ...user, updatedAt: serverTimestamp() }, { merge: true }); +} + +// List 'cuentas' collection +export async function listCuentas(ownerUid?: string): Promise<({ id: string } & FirestoreCuenta)[]> { + if (!ownerUid) { + const u = await ensureAnonymousSignIn(); + ownerUid = u?.uid; + } + const base = collection(db, 'usuarios', ownerUid as string, 'cuentas'); + const snap = await getDocs(base); + const items = snap.docs.map((d) => ({ id: d.id, ...(d.data() as any) })); + // normalize saldo to number + return items.map((c) => ({ + ...c, + saldo: c.saldo != null ? Number(c.saldo) : 0, + propietario: c.propietario ?? c.titular ?? '', + tipo: c.tipo ?? 'corriente', + numero: c.numero ?? '', + })); +} + +// Subscribe to cuentas changes +export function onCuentas(cb: (items: ({ id: string } & FirestoreCuenta)[]) => void, ownerUid?: string) { + const base = collection(db, 'usuarios', (ownerUid || 'unknown') as string, 'cuentas'); + return onSnapshot(base, (snap) => { + const items = snap.docs.map((d) => ({ id: d.id, ...(d.data() as any) })); + cb(items.map((c) => ({ + ...c, + saldo: c.saldo != null ? Number(c.saldo) : 0, + propietario: c.propietario ?? c.titular ?? '', + tipo: c.tipo ?? 'corriente', + numero: c.numero ?? '', + }))); + }); +} + +// Create a cuenta inside user's embedded subcollection `usuarios/{uid}/cuentas` +export async function addCuenta(data: FirestoreCuenta, ownerUid?: string) { + let uid = ownerUid; + if (!uid) { + const u = await ensureAnonymousSignIn(); + uid = u?.uid; + } + if (!uid) throw new Error('No authenticated user'); + const base = collection(db, 'usuarios', uid as string, 'cuentas'); + const payload: any = { + ...data, + ownerUid: uid, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }; + // Firestore does not accept undefined; ensure optional fields are null or removed + Object.keys(payload).forEach((k) => { + if (payload[k] === undefined) { + delete payload[k]; + } + }); + if (payload.email === undefined) payload.email = null; + const ref = await addDoc(base, payload); + return ref.id; +} diff --git a/utils/firebase.js b/utils/firebase.js index 067194c..ed6d578 100644 --- a/utils/firebase.js +++ b/utils/firebase.js @@ -1,6 +1,8 @@ import { initializeApp, getApps } from 'firebase/app'; import { getFirestore } from 'firebase/firestore'; -import { getAuth, signInAnonymously } from 'firebase/auth'; +import { getAuth, signInAnonymously, GoogleAuthProvider, signInWithCredential, createUserWithEmailAndPassword, signInWithEmailAndPassword, signOut } from 'firebase/auth'; +import * as AuthSession from 'expo-auth-session'; +import * as WebBrowser from 'expo-web-browser'; // Read Firebase config from environment variables (EXPO_PUBLIC_*) const firebaseConfig = { @@ -31,5 +33,92 @@ async function ensureAnonymousSignIn() { } } +/** + * Sign in with Google using Expo AuthSession and Firebase Auth. + * Requires setting EXPO_PUBLIC_GOOGLE_CLIENT_ID (web) and/or platform client IDs in env. + * Returns the Firebase user on success or null on failure. + */ +async function signInWithGoogle() { + try { + // Use the web client id by default (set in app env) + const clientId = process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID; + if (!clientId) { + console.warn('EXPO_PUBLIC_GOOGLE_CLIENT_ID not set in environment'); + return null; + } + + const redirectUri = AuthSession.makeRedirectUri({ useProxy: true }); + const scopes = ['openid', 'profile', 'email']; + + const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${encodeURIComponent( + clientId + )}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=id_token&scope=${encodeURIComponent( + scopes.join(' ') + )}&nonce=${encodeURIComponent('nonce')}`; + + let result; + if (typeof AuthSession.startAsync === 'function') { + result = await AuthSession.startAsync({ authUrl }); + } else { + // Fallback: open auth session with expo-web-browser and parse returned url + const webRes = await WebBrowser.openAuthSessionAsync(authUrl, redirectUri); + if (webRes.type === 'success' && webRes.url) { + // extract params from fragment or querystring + const raw = webRes.url; + const hashOrQuery = raw.split('#')[1] || raw.split('?')[1] || ''; + const params = new URLSearchParams(hashOrQuery); + result = { type: webRes.type, params: Object.fromEntries(params.entries()) }; + } else { + result = { type: webRes.type }; + } + } + + if (result.type === 'success' && result.params && result.params.id_token) { + const idToken = result.params.id_token; + const credential = GoogleAuthProvider.credential(idToken); + const userCred = await signInWithCredential(auth, credential); + return userCred.user; + } + + console.warn('Google sign-in cancelled or failed', result); + return null; + } catch (e) { + console.error('signInWithGoogle failed', e); + return null; + } +} + export default app; -export { db, auth, ensureAnonymousSignIn }; \ No newline at end of file +async function signUpWithEmail(email, password) { + try { + const cred = await createUserWithEmailAndPassword(auth, email, password); + return cred.user; + } catch (e) { + console.error('signUpWithEmail failed', e); + throw e; + } +} + +async function signInWithEmail(email, password) { + try { + const cred = await signInWithEmailAndPassword(auth, email, password); + return cred.user; + } catch (e) { + console.error('signInWithEmail failed', e); + throw e; + } +} + +export { db, auth, ensureAnonymousSignIn, signInWithGoogle, signUpWithEmail, signInWithEmail }; + +async function signOutUser() { + try { + await signOut(auth); + return true; + } catch (e) { + console.error('signOut failed', e); + throw e; + } +} + +export { signOutUser }; \ No newline at end of file From 3b2f4212be13e02a4885544fd9864731ae4320bb Mon Sep 17 00:00:00 2001 From: AngelPila Date: Sun, 7 Dec 2025 20:11:27 -0500 Subject: [PATCH 2/2] DBCorregida --- components/DetalleCuenta.tsx | 17 +++++++++++++++++ components/Home.tsx | 11 ++++++----- components/Login.tsx | 20 +++----------------- contexts/AuthContext.tsx | 4 ++-- services/firestore.ts | 13 +++++-------- types/index.ts | 1 + utils/firebase.js | 4 ++-- 7 files changed, 36 insertions(+), 34 deletions(-) diff --git a/components/DetalleCuenta.tsx b/components/DetalleCuenta.tsx index be8f510..312e6bb 100644 --- a/components/DetalleCuenta.tsx +++ b/components/DetalleCuenta.tsx @@ -108,6 +108,23 @@ export default function DetalleCuenta({ cuenta, onBack, onPressAdd, onPressHome, + {/* Detalles de la cuenta */} + + DETALLES DE LA CUENTA + + Número + {(cuenta as any).numero || '—'} + + + Tipo + {(cuenta as any).tipo ? ((cuenta as any).tipo as string).toUpperCase() : '—'} + + + Balance + ${cuenta.balance.toFixed(2)} + + + ([]); + const [accounts, setAccounts] = useState([]); const [transactions, setTransactions] = useState(mockTransactions); const [allTransactions, setAllTransactions] = useState([]); // Todas las transacciones para gráficos const balanceTotal = accounts.reduce((s, a) => s + (a.balance || 0), 0); @@ -77,11 +77,12 @@ export default function Home({ onPressAdd, onPressAccount, onPressEstadisticas, // accounts const accsRaw = await listCuentas(user?.uid || undefined); - const accs = accsRaw.map(d => ({ + const accs: Cuenta[] = accsRaw.map(d => ({ id: d.id, nombre: d.propietario ?? `Cuenta ${d.id}`, balance: Number(d.saldo ?? 0), numero: d.numero ?? '', + tipo: d.tipo ?? 'corriente', })); setAccounts(accs); } catch (e) { @@ -158,7 +159,7 @@ export default function Home({ onPressAdd, onPressAccount, onPressEstadisticas, {accounts.map((cuenta) => ( onPressAccount(cuenta)} /> ))} diff --git a/components/Login.tsx b/components/Login.tsx index 66e76ca..096371c 100644 --- a/components/Login.tsx +++ b/components/Login.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { View, Text, TouchableOpacity, TextInput } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; -import { ensureAnonymousSignIn, signInWithEmail, signUpWithEmail } from '../utils/firebase'; +import { signInWithEmail, signUpWithEmail } from '../utils/firebase'; import { upsertUser } from '../services/firestore'; import { useAuth } from '../contexts/AuthContext'; @@ -20,14 +20,7 @@ export default function Login({ onContinue }: LoginProps) { const [error, setError] = useState(null); const [showPassword, setShowPassword] = useState(false); - const handleAnon = async () => { - try { - await ensureAnonymousSignIn(); - onContinue(); - } catch { - setError('Fallo al continuar como invitado'); - } - }; + // Anonymous login removed per policy; users must authenticate. const handleEmailAuth = async () => { setError(null); @@ -108,14 +101,7 @@ export default function Login({ onContinue }: LoginProps) { )} - - - - - Continuar como invitado - - - + {/* Anonymous access removed */} ); diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx index 7c73303..12a9cae 100644 --- a/contexts/AuthContext.tsx +++ b/contexts/AuthContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { onAuthStateChanged, User } from 'firebase/auth'; -import { auth, ensureAnonymousSignIn } from '../utils/firebase'; +import { auth } from '../utils/firebase'; import { upsertUser } from '../services/firestore'; type AuthContextValue = { @@ -16,7 +16,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children useEffect(() => { // Ensure there's at least an anonymous session - ensureAnonymousSignIn().catch(() => {}); + // Anonymous sign-in removed; user must sign in explicitly. const unsub = onAuthStateChanged(auth, async (u) => { setUser(u); diff --git a/services/firestore.ts b/services/firestore.ts index a6663b9..e98e745 100644 --- a/services/firestore.ts +++ b/services/firestore.ts @@ -1,4 +1,4 @@ -import { db, ensureAnonymousSignIn } from 'utils/firebase.js'; +import { db, auth } from 'utils/firebase.js'; import { addDoc, collection, @@ -48,8 +48,7 @@ export type FirestoreCuenta = { // Add a transaction to 'transacciones' with audit fields export async function addTransaction(tx: FirestoreTransaction & { cuentaId?: string }) { - const user = await ensureAnonymousSignIn(); - const ownerUid = user?.uid ?? undefined; + const ownerUid = auth.currentUser?.uid; const payload = { ...tx, ownerUid, @@ -105,8 +104,7 @@ export async function upsertUser(user: FirestoreUser) { // List 'cuentas' collection export async function listCuentas(ownerUid?: string): Promise<({ id: string } & FirestoreCuenta)[]> { if (!ownerUid) { - const u = await ensureAnonymousSignIn(); - ownerUid = u?.uid; + ownerUid = auth.currentUser?.uid; } const base = collection(db, 'usuarios', ownerUid as string, 'cuentas'); const snap = await getDocs(base); @@ -123,7 +121,7 @@ export async function listCuentas(ownerUid?: string): Promise<({ id: string } & // Subscribe to cuentas changes export function onCuentas(cb: (items: ({ id: string } & FirestoreCuenta)[]) => void, ownerUid?: string) { - const base = collection(db, 'usuarios', (ownerUid || 'unknown') as string, 'cuentas'); + const base = collection(db, 'usuarios', (ownerUid || auth.currentUser?.uid || 'unknown') as string, 'cuentas'); return onSnapshot(base, (snap) => { const items = snap.docs.map((d) => ({ id: d.id, ...(d.data() as any) })); cb(items.map((c) => ({ @@ -140,8 +138,7 @@ export function onCuentas(cb: (items: ({ id: string } & FirestoreCuenta)[]) => v export async function addCuenta(data: FirestoreCuenta, ownerUid?: string) { let uid = ownerUid; if (!uid) { - const u = await ensureAnonymousSignIn(); - uid = u?.uid; + uid = auth.currentUser?.uid; } if (!uid) throw new Error('No authenticated user'); const base = collection(db, 'usuarios', uid as string, 'cuentas'); diff --git a/types/index.ts b/types/index.ts index 280e3b3..9d43fec 100644 --- a/types/index.ts +++ b/types/index.ts @@ -5,6 +5,7 @@ export interface Cuenta { nombre:string; balance:number; numero?: string; + tipo?: string; } // se podría colocar tambien una interface Transaccoin con id, tipo, categoria, monto, date. diff --git a/utils/firebase.js b/utils/firebase.js index ed6d578..ea592f7 100644 --- a/utils/firebase.js +++ b/utils/firebase.js @@ -32,7 +32,7 @@ async function ensureAnonymousSignIn() { return null; } } - +// Anonymous sign-in removed per project policy. /** * Sign in with Google using Expo AuthSession and Firebase Auth. * Requires setting EXPO_PUBLIC_GOOGLE_CLIENT_ID (web) and/or platform client IDs in env. @@ -109,7 +109,7 @@ async function signInWithEmail(email, password) { } } -export { db, auth, ensureAnonymousSignIn, signInWithGoogle, signUpWithEmail, signInWithEmail }; +export { db, auth, signInWithGoogle, signUpWithEmail, signInWithEmail }; async function signOutUser() { try {