Documentazione tecnica
Come è fatta la piattaforma
Architettura, stack e scelte progettuali di Nerva e Nerva Business. Se cerchi come si usa l'app, vai alla guida.
Versione documento: 1.0 · Data: 27 luglio 2026 Destinatari: sviluppatori, architetti, chiunque debba mettere le mani nel codice o valutarlo.
1. Cos'è Nerva
Nerva è una piattaforma di gestione delle finanze personali costruita attorno a un'idea semplice: i dati bancari reali dell'utente entrano da soli, e l'app li rende leggibili.
Il prodotto è composto da tre applicazioni che condividono lo stesso backend:
| Componente | Tecnologia | Cartella | Stato |
|---|---|---|---|
| **Nerva Web** (oggetto di questo documento) | React 19 + Vite 8, PWA | `expense-tracker/web/` | In produzione su Cloudflare Pages |
| **Nerva Mobile** | Expo / React Native | `expense-tracker/` | Sviluppo, condivide lo stesso Firestore |
| **Nerva Business** | Fork B2B della web | `business-web/` | Beta interna |
La web app è standalone: non è un wrapper della mobile, ha il proprio routing, il proprio store e le proprie pagine. Ciò che condivide con la mobile è il dato, non il codice.
Numeri della codebase web, al 27 luglio 2026:
- ~21.500 righe di TypeScript/TSX
- 38 route, tutte in lazy loading
- 38 componenti-pagina, 7 slice Redux, 5 collezioni Firestore
npm run build(tsc -b && vite build) verde
2. Stack
React 19 UI
TypeScript ~6 tipizzazione (build gate: tsc -b)
Vite 8 dev server + bundling (manualChunks: firebase / recharts / vendor)
Tailwind CSS v4 styling via @tailwindcss/vite, token semantici custom
Redux Toolkit stato applicativo + redux-persist su localStorage
React Router v7 routing, tutte le route lazy
Firebase v12 Auth, Firestore, Cloud Functions, Cloud Messaging
Recharts grafici
jsPDF export PDF
Formik + Yup form auth/onboarding
Sonner toast
Lucide React icone
vite-plugin-pwa manifest + service worker Workbox
Nessuna dipendenza react-native-* nella web: se serve un'utility condivisa con la
mobile viene duplicata. Non esiste (ancora) un package shared.
3. Architettura
3.1 Vista d'insieme
flowchart TB
subgraph Client
W["Nerva Web<br/>(PWA, Vite)"]
M["Nerva Mobile<br/>(Expo)"]
end
subgraph Firebase["Firebase — progetto expensetracker-5056f"]
AUTH["Auth<br/>email/password + Google"]
FS[("Firestore<br/>transactions, accounts,<br/>budgets, category_rules,<br/>recurring_rules")]
CF["Cloud Functions<br/>Node 22"]
FCM["Cloud Messaging"]
end
EB["Enable Banking<br/>API PSD2"]
BANK["Banca dell'utente"]
W <--> AUTH
M <--> AUTH
W <-- "write-through + onSnapshot" --> FS
M <-- "write-through + onSnapshot" --> FS
W -- "httpsCallable" --> CF
CF -- "JWT RS256" --> EB
EB -- "OAuth / consenso 90gg" --> BANK
FCM -.-> W
Il punto chiave: il client non parla mai direttamente con Enable Banking. La chiave privata PSD2 vive nelle Cloud Functions, che firmano i JWT e fanno da proxy.
3.2 Catena dei provider
App.tsx monta, dall'esterno verso l'interno:
ErrorBoundary
└─ redux Provider
└─ PersistGate (attende la reidratazione da localStorage)
└─ ThemeProvider (light / dark / system)
└─ BrowserRouter
└─ AppRouter (Suspense + 38 route lazy)
Allo stesso livello girano tre hook globali: useFirestoreSync (sync dati),
useRecurringScheduler (notifiche ricorrenze) e useSessionTimeout (lock PIN).
3.3 Struttura delle cartelle
web/src/
├── App.tsx, main.tsx
├── router/ index.tsx (38 route lazy), ProtectedRoute.tsx
├── layouts/ AppLayout (nav responsive), AuthLayout, OnboardingLayout
├── pages/
│ ├── auth/ Login, Register, ForgotPassword, PinLock
│ ├── onboarding/ Welcome → PinSetup → ConnectBank → BudgetSetup
│ ├── home/ HomePage
│ ├── transactions/ List, Add, Detail, Categorize, CategoryTransactions
│ ├── income/ IncomePage (dashboard entrate)
│ ├── flows/ FlowsPage (entrate vs uscite, tasso di risparmio)
│ ├── analytics/ Analytics, BudgetTab, CategoryDetail
│ ├── calendar/ Calendar, RecurringList, AddRecurring
│ └── settings/ 17 pagine (categorie, budget, banche, sicurezza, export, …)
├── components/ ui/ (design system), home/, transactions/, calendar/, sync/
├── hooks/ useFirestoreSync, useRedux, useTheme, useNotifications,
│ useInstallPrompt, useOnlineStatus, useSessionTimeout,
│ useSwipeNavigation, useGoogleSignIn
├── store/ index.ts (persist v5), firestoreMiddleware.ts, slices/ (7)
├── services/
│ ├── firebase/ config, auth, firestore (CRUD)
│ ├── enablebanking/ api (httpsCallable), converter, logger, types
│ ├── categorization/textParser (regole in linguaggio naturale)
│ └── notifications/ FCM helper + recurringScheduler
├── utils/ constants, formatters, validators, security (PIN),
│ recurring (occorrenze, detection, ICS, proiezione saldo),
│ iconMapper, debugLog
└── types/ budget, category, categoryRule
Path alias: @/ → src/. Va usato in tutti gli import.
4. Il cuore del prodotto: la sincronizzazione
È la parte che vale la pena capire per prima, perché tutto il resto ci si appoggia.
4.1 Scrittura — write-through middleware
Le pagine non chiamano mai Firestore direttamente per le mutazioni standard.
Fanno dispatch(azione); il firestoreMiddleware intercetta l'azione dopo che il
reducer l'ha applicata e propaga la scrittura sul cloud.
// store/firestoreMiddleware.ts (semplificato)
const firestoreMiddleware: Middleware = (store) => (next) => (action) => {
const result = next(action) // 1. Redux aggiorna: UI istantanea
const userId = store.getState().auth?.user?.uid
if (!userId) return result
switch (action.type) {
case 'transactions/addTransaction':
fsCreateTransaction({ ...action.payload, userId }).catch(console.error)
break
case 'transactions/updateTransactionCategory': {
const { id, category } = action.payload
fsUpdateTransaction(id, { category }).catch(console.error)
break
}
// … conti, budget, regole categoria, regole ricorrenti
}
return result
}
Conseguenze pratiche:
- UI ottimistica per costruzione. Redux è già aggiornato quando parte la scrittura.
- Un solo punto da controllare quando una modifica "non arriva sulla mobile".
- Il budget è debounced a 500 ms: cambia troppo spesso durante l'editing degli slider.
- Le azioni non elencate nello
switchrestano volutamente locali. È il caso disetAutoCategorizeRules, usata per l'hydration: se fosse intercettata, ogni login riscriverebbe sul cloud le regole appena lette.
⚠️ Regola vincolante: non bypassare il middleware chiamando i metodi di
services/firebase/firestore.tsdalle pagine. Il dato finirebbe in Redux ma non su Firestore → desync silenzioso con la mobile.
4.2 Lettura — snapshot + listener
useFirestoreSync, montato una sola volta in App.tsx, fa due cose in sequenza:
- Load iniziale in parallelo (
Promise.all) di transazioni, conti, budget, regole categoria e regole ricorrenti. Ogni fetch ha il suo.catch(() => []): se una collezione fallisce, le altre entrano comunque. - Listener real-time (
onSnapshot) sutransactions,accountserecurring_rules. ITimestampFirestore vengono serializzati in stringhe ISO prima di entrare in Redux — Redux Toolkit vuole stato serializzabile.
Il ciclo si chiude qui: mobile scrive → Firestore notifica → il listener della web aggiorna Redux → la UI si aggiorna. Senza polling e senza refresh. Latenza tipica: sotto il secondo.
4.3 Persistenza locale
redux-persist su localStorage, persistConfig versione 5:
- whitelist:
auth,settings,categories,budget,accounts,recurring - blacklist:
transactions— arrivano sempre da Firestore, non ha senso duplicarle in localStorage (e sono il volume maggiore) - migrate: funzione che aggiunge i campi introdotti dopo, con default sensati
(
calendarsettings,budgetAlertThreshold: 80, slicerecurring, …). Va estesa a ogni cambio di forma dello stato persistito.
5. Open Banking (PSD2)
Integrazione con Enable Banking come aggregatore.
5.1 Flusso
sequenceDiagram
participant U as Utente
participant W as Web (BankAccountsPage)
participant CF as Cloud Functions
participant EB as Enable Banking
participant B as Banca
U->>W: "Collega banca"
W->>CF: getASPSPs({ country: 'IT' })
CF->>EB: GET /aspsps (JWT firmato RS256)
EB-->>W: elenco banche
U->>W: sceglie la banca
W->>CF: startAuth(aspsp, validDays: 90, redirectUri)
CF->>EB: POST /auth
EB-->>W: URL di autorizzazione
W->>B: redirect (login + SCA)
B-->>W: /app/settings/bank-callback?code=…
W->>CF: createSession(code)
CF->>EB: POST /sessions
EB-->>W: session_id + elenco conti
W->>CF: getTransactions / getBalances
CF-->>W: movimenti + saldi
W->>W: convertBankTransaction() + auto-categorizzazione
W->>W: dispatch(addMultipleTransactions) → writeBatch su Firestore
5.2 Dettagli implementativi
- Redirect URI:
/app/settings/bank-callback. È registrato lato Enable Banking: non va cambiato senza aggiornare la registrazione. - Consenso: 90 giorni (limite PSD2), poi va rinnovato.
- Conversione (
services/enablebanking/converter.ts, implementazione unica condivisa daSyncButtoneBankAccountsPage):credit_debit_indicator: 'CRDT'→income,'DBIT'→expense- importi salvati sempre positivi; il verso è dato solo da
type(questo ha già causato un bug: il budget filtravaamount < 0e mostrava 0) - merchant estratto in cascata:
creditor_name→debtor_name→remittance_information[0]→additional_information→ fallback - id transazione =
transaction_idoentry_referencedella banca → idempotenza: un ri-sync dello stesso periodo non duplica i movimenti - i campi opzionali vuoti vengono omessi (Firestore rifiuta
undefined)
- Scrittura in batch: l'import usa
writeBatcha blocchi da 400 (il limite Firestore è 500). - Export CSV del sync su Cloud Storage, via la Function
saveCSV, come traccia di audit.
6. Funzionalità applicative
6.1 Categorizzazione automatica in linguaggio naturale
L'utente scrive regole in italiano corrente, l'app le trasforma in regole eseguibili:
Esselunga, Conad → Spesa
Zara e H&M vanno in Abbigliamento
Netflix -> Intrattenimento
services/categorization/textParser.ts gestisce forma a freccia (→, ->, =>) e
forma discorsiva ("… vanno in …"), separa le keyword su virgola o " e ", e associa la
categoria con match fuzzy a cascata (esatto → prefisso → contenuto → contenuto inverso).
Le regole vengono poi applicate in fase di import bancario da
findCategoryForMerchant. Documento Firestore con id deterministico
rule_{userId}_{pattern}: niente duplicati anche se l'hydration parte più volte;
la cancellazione avviene per pattern, così ripulisce anche i vecchi doc a id casuale.
Il parser è euristico per scelta.
README.mddocumenta il punto di innesto per sostituirlo con una chiamata a Claude quando servirà gestire frasi ambigue.
6.2 Calendario e pagamenti ricorrenti
Il modello è RecurringRule (collezione recurring_rules). Le occorrenze future non
sono transazioni: vengono calcolate al volo da utils/recurring.ts:expandOccurrences.
Solo quando l'utente segna un'occorrenza come pagata viene materializzata una
Transaction reale e nextDate avanza tramite computeNextOccurrence.
Questa scelta evita di riempire Firestore di transazioni fantasma e rende banale modificare una regola a ritroso.
Sopra ci sono costruite quattro cose:
- Auto-detection: stesso merchant + importo identico + intervallo 25–35 giorni →
banner "Sembra un pagamento ricorrente, creo la regola?". I dismiss sono persistiti
in
dismissedSuggestionIds. Default OFF, perché percepito come rumoroso. - Proiezione saldo:
projectAccountBalancesomma le transazioni già presenti nel range e le occorrenze previste. Se alla data di scadenza il conto andrebbe sotto, compare unBalanceWarningBadge. - Notifiche in-app:
useRecurringSchedulerscansiona al mount e ogni 5 minuti, mostra toast Sonner per le scadenze entronotifyDaysBeforee per i saldi insufficienti. Anti-duplicazione con chiavi inlocalStorage. - Export ICS: genera un
.icsconRRULE+VALARM, importabile in Google Calendar o Apple Calendar.
La MonthGrid ha tre viste (mese / settimana / lista), heatmap della spesa giornaliera,
pillole colorate per le ricorrenti e drag & drop delle transazioni per cambiarne la data.
6.3 Dashboard
Quattro tagli sugli stessi dati, con lo stesso selettore di periodo (mese / anno / personalizzato):
| Pagina | Contenuto |
|---|---|
| **Home** | saluto contestuale, QuickStats, progresso budget, ultime transazioni, prossimi pagamenti, pulsante sync |
| **Analytics** | torta per categoria, andamento giornaliero, confronto con il periodo precedente, drill-down per categoria, tab Budget |
| **Entrate** | solo `type === 'income'`, raggruppate per fonte (`merchant`) invece che per categoria |
| **Flussi** | entrate vs uscite, saldo netto, **tasso di risparmio**, trend mensile a barre affiancate, top fonti e top categorie |
6.4 Budget
Limite globale + limiti per categoria, periodo configurabile, soglia di alert (default 80%). Le mutazioni passano dal middleware con debounce; il documento budget è uno solo per utente.
6.5 Export
ExportDataPage produce tre formati: JSON (backup completo, reimportabile),
CSV (Excel / Google Sheets) e PDF (report con riepilogo, via jsPDF +
jspdf-autotable). Condivisione tramite Web Share API dove supportata.
7. Sicurezza
| Livello | Meccanismo |
|---|---|
| Autenticazione | Firebase Auth: email/password, Google Sign-In, reset password |
| Autorizzazione dati | Firestore rules per-utente: `resource.data.userId == request.auth.uid` su `create`, `list`, `get`, `update`, `delete` di ogni collezione |
| Lock locale | PIN a 6 cifre, SHA-256 + salt casuale a 16 byte via Web Crypto API |
| Brute force | 5 tentativi, poi lockout di 30 minuti |
| Sessione | timeout di inattività a 5 minuti → schermata PIN |
| Credenziali PSD2 | chiave privata solo lato Cloud Functions, mai nel bundle |
| Trasporto | HTTPS obbligatorio (requisito di Firebase Auth, FCM e PWA) |
| Header HTTP | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` in `public/_headers` |
Nota onesta sul PIN: hash SHA-256 + salt in localStorage è adeguato per un lock
locale, non per un segreto. Chi ha accesso a localStorage può comunque rimuovere
l'hash. La protezione reale del dato è lato Firestore rules. PBKDF2 sarebbe un
miglioramento a basso costo, in backlog.
8. PWA e comportamento offline
- Manifest:
display: standalone,start_url: /app/home, theme#1E88E5, icone 192/512 incluse maskable. - Service worker generato da Workbox,
registerType: 'autoUpdate'. - Runtime caching:
- Firestore →
NetworkFirst, 50 entry, TTL 24 h - Identity Toolkit (auth) →
NetworkFirst, 10 entry, TTL 1 h - immagini →
StaleWhileRevalidate, 100 entry, TTL 30 giorni
- Firestore →
- Install prompt:
useInstallPromptintercettabeforeinstallprompte mostra un banner, con dismiss persistito. - Stato connessione:
useOnlineStatus+OfflineBanner. - Nav responsive: bottom bar sotto 768 px, sidebar collassabile 768–1024 px, sidebar fissa da 1024 px. Su mobile funziona anche lo swipe tra sezioni.
9. Build e deploy
cd expense-tracker/web
npm install
npm run dev # http://localhost:5173
npm run build # tsc -b && vite build → dist/
npm run preview
npm run lint
Deploy: Cloudflare Pages. In public/:
_redirects→/* /index.html 200(fallback SPA per React Router)_headers→ header di sicurezza
Code splitting via manualChunks: firebase, recharts (+ d3), vendor
(react-dom + react-router). Tutte le pagine sono comunque già in chunk separati
grazie al lazy routing.
Variabili d'ambiente (tutte prefisso VITE_): FIREBASE_API_KEY, AUTH_DOMAIN,
PROJECT_ID, STORAGE_BUCKET, MESSAGING_SENDER_ID, APP_ID, VAPID_KEY.
Non vanno hardcodate nei sorgenti. Unica eccezione nota:
public/firebase-messaging-sw.js, perché un service worker non vede
import.meta.env (i valori sono comunque pubblici per definizione).
11. Roadmap
11.1 Riconciliazione bancaria ↔ fatture
È il prossimo passo principale, e non parte da zero: il motore esiste già in
Nerva Business (business-web/src/services/reconciliation/engine.ts) e va portato
sulla linea principale.
Come funziona oggi:
- Filtro di direzione — fattura emessa ↔ entrata, fattura ricevuta ↔ uscita. Direzione incompatibile → scarto immediato.
- Importo (peso 0,5) — tolleranza
max(0.01, totale × 0.001)per match esatto, fino a ±2 € per match "quasi esatto". Senza match di importo non si suggerisce nulla. - Finestra temporale (±5 giorni) rispetto alla data o alla scadenza della fattura.
- Similarità testuale tra descrizione del movimento e denominazione della controparte, su token comuni normalizzati (minuscole, senza accenti né punteggiatura, token di almeno 3 caratteri).
- Output: confidence 0..1, soglia minima 0,5, con l'elenco dei motivi del match. La conferma resta sempre all'utente — l'app propone, non decide.
Alimentazione delle fatture: parser FatturaPA già scritto (DOMParser,
namespace-agnostic, direzione dedotta dalla P.IVA dell'azienda). Il passo successivo è
un provider SDI accreditato per la ricezione automatica: l'interfaccia client è già
astratta in services/sdi/index.ts (SdiProvider), manca la scelta del fornitore e il
backend su Cloud Functions.
11.2 Resto della roadmap
| Priorità | Voce |
|---|---|
| Alta | Push FCM reali: VAPID key + Cloud Function di scheduling |
| Alta | Riconciliazione fatture sulla linea principale (§11.1) |
| Media | Previsione di cashflow a 90 giorni da scadenze + ricorrenze |
| Media | Categorizzazione via LLM (Claude) al posto del parser euristico, da backend |
| Media | Suite di test Vitest a partire dalle funzioni pure |
| Media | F24 precompilato dai dati delle fatture *(linea Business)* |
| Bassa | "Consulente connesso": accesso read-only per il commercialista |
| Bassa | Pagamenti reali via PIS (Nexi PIS-as-a-service o embedded banking Swan) |
| Bassa | Multi-utente e ruoli, API key |
12. Convenzioni per chi contribuisce
- Import sempre con l'alias
@/. - UI in italiano (label, toast, header); codice in inglese (variabili, funzioni, commenti tecnici).
- Tailwind: usare i token semantici —
bg-surface,text-text-primary,text-primary,border-border,bg-surface-variant. Non valori arbitrari. - Toast:
import { toast } from 'sonner', già montato globalmente. - Icone:
lucide-react. - Form: Formik + Yup nelle pagine auth/onboarding;
useStatesemplice nelle settings, dove il dominio è piccolo. - Nuove pagine: sempre
lazy(() => import(...))inrouter/index.tsx. - Gate prima del push:
npm run builddeve restare verde.
Da non fare: aggiungere dipendenze react-native-*; bypassare il middleware
Firestore; hardcodare valori Firebase nei sorgenti TS; committare .env.
13. Riferimenti
| Cosa | Dove |
|---|---|
| Guida operativa web | `expense-tracker/web/CLAUDE.md` |
| Setup utente | `expense-tracker/web/README.md` |
| Storico e punch list | `expense-tracker/web/NEXT_SESSION.md` |
| Firestore rules | `expense-tracker/firestore.rules` |
| Indici Firestore | `expense-tracker/firestore.indexes.json` |
| Backend Enable Banking | `expense-tracker/firebase/cloud-functions/` |
| Setup PSD2 | `expense-tracker/ENABLE_BANKING_SETUP.md` |
| Linea B2B | `business-web/CLAUDE.md`, `business-web/NEXT_SESSION.md` |
I
.mdnella rootexpense-tracker/(SESSIONE_NEXT.md,DEVELOPMENT_STATUS.md,ANALYTICS_FEATURE.md, …) sono datati gennaio 2026 e descrivono la mobile. Non riflettono lo stato della web: usali solo per le specifiche di dominio condivise.