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:

ComponenteTecnologiaCartellaStato
**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:


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:

⚠️ Regola vincolante: non bypassare il middleware chiamando i metodi di services/firebase/firestore.ts dalle 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:

  1. 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.
  2. Listener real-time (onSnapshot) su transactions, accounts e recurring_rules. I Timestamp Firestore 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:


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


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.md documenta 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:

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):

PaginaContenuto
**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

LivelloMeccanismo
AutenticazioneFirebase Auth: email/password, Google Sign-In, reset password
Autorizzazione datiFirestore rules per-utente: `resource.data.userId == request.auth.uid` su `create`, `list`, `get`, `update`, `delete` di ogni collezione
Lock localePIN a 6 cifre, SHA-256 + salt casuale a 16 byte via Web Crypto API
Brute force5 tentativi, poi lockout di 30 minuti
Sessionetimeout di inattività a 5 minuti → schermata PIN
Credenziali PSD2chiave privata solo lato Cloud Functions, mai nel bundle
TrasportoHTTPS 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


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/:

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:

  1. Filtro di direzione — fattura emessa ↔ entrata, fattura ricevuta ↔ uscita. Direzione incompatibile → scarto immediato.
  2. 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.
  3. Finestra temporale (±5 giorni) rispetto alla data o alla scadenza della fattura.
  4. Similarità testuale tra descrizione del movimento e denominazione della controparte, su token comuni normalizzati (minuscole, senza accenti né punteggiatura, token di almeno 3 caratteri).
  5. 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
AltaPush FCM reali: VAPID key + Cloud Function di scheduling
AltaRiconciliazione fatture sulla linea principale (§11.1)
MediaPrevisione di cashflow a 90 giorni da scadenze + ricorrenze
MediaCategorizzazione via LLM (Claude) al posto del parser euristico, da backend
MediaSuite di test Vitest a partire dalle funzioni pure
MediaF24 precompilato dai dati delle fatture *(linea Business)*
Bassa"Consulente connesso": accesso read-only per il commercialista
BassaPagamenti reali via PIS (Nexi PIS-as-a-service o embedded banking Swan)
BassaMulti-utente e ruoli, API key

12. Convenzioni per chi contribuisce

Da non fare: aggiungere dipendenze react-native-*; bypassare il middleware Firestore; hardcodare valori Firebase nei sorgenti TS; committare .env.


13. Riferimenti

CosaDove
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 .md nella root expense-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.