Simpl-Resultat/docs/architecture.md
le king fu e2b8eb8b22
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m38s
docs: architecture, ADR 0019, user guide and changelog for the import format
Last link of the ten-link import-format stack. Links 1-9 deliberately wrote
no changelog and no documentation, to avoid a conflict at every level of a
linear pile; this link owes all of it.

- ADR 0019 records the structuring decision: the import format is a fully
  persisted value, never re-inferred. It documents the three independent paths
  by which it used to be lost (hardcoded restore, header drift, data export),
  and why a single codec with a completeness test closes the class rather than
  a composed type -- the two carriers are structurally incompatible
  (`has_header` is boolean on one and number on the other). `template_id` is
  recorded as a provenance label, never re-read as format.
- `docs/architecture.md` gains a dedicated "Import CSV" section covering the
  codec, the lexical detection layer and its separate dictionary module, the
  bank signatures, the now-mandatory preview step, and sources/templates in
  the SREF envelope. Migration v17 and its four CHECK-guarded columns are
  listed in the migrations table.
- Stale counts corrected against the tree, not by arithmetic: 16 -> 17
  migrations (both files), `src/components/import/` 13 -> 14, `src/utils/`
  4 -> 13. Tables (20) and indexes (24) were re-measured and are NOT stale --
  v17 is an ALTER TABLE only -- so they are left as they are, with the reason
  written down. ADR 0018, missing from the ADR table, is added.
- The user guide and the `docs.*` keys in both locales carry the same new
  import journey: automatic detection, confidence score, mandatory preview
  with its signed recap, sign inversion, recognised bank formats, drift panel,
  and the safe repair path.
- Both changelogs carry the same entries, translated, verified section by
  section including issue references. The `public/` copies sync automatically
  via `syncChangelogs()`.

1176 vitest green, build clean, cargo check clean. No DB migration.

Resolves #332

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:13:25 -04:00

534 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-08-13 — Version 0.14.x (format d'import persisté, migration v17)
## Stack technique
| Couche | Technologie | Version |
|--------|------------|---------|
| Framework desktop | Tauri | v2 |
| Frontend | React | 19.1 |
| Langage frontend | TypeScript | 5.8 |
| Bundler | Vite | 6.4 |
| CSS | Tailwind CSS | v4 |
| Backend | Rust (via Tauri) | stable |
| Base de données | SQLite (tauri-plugin-sql) | — |
| Graphiques | Recharts | 3.7 |
| Icônes | Lucide React | 0.563 |
| i18n | i18next + react-i18next | 25.8 / 16.5 |
| Drag & Drop | @dnd-kit | 6.3 / 10.0 |
| CSV | PapaParse | 5.5 |
| Chiffrement | aes-gcm (Rust) | 0.10 |
| Hachage PIN | Argon2 (Rust) | 0.5 |
## Structure du projet
```
simpl-resultat/
├── src/ # Frontend React/TypeScript
│ ├── components/ # 58 composants organisés par domaine
│ │ ├── adjustments/ # 3 composants
│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
│ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 2 composants
│ │ ├── import/ # 14 composants (wizard d'import, dont FormatDriftPanel et RepairPathNotice)
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
│ │ ├── shared/ # 9 composants réutilisables (dont RequireFeature, UpsellGate)
│ │ └── transactions/ # 5 composants
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 14 services métier
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts)
│ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts)
│ ├── i18n/ # Config i18next + locales FR/EN
│ ├── App.tsx # Router principal
│ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust
│ ├── src/
│ │ ├── commands/ # 6 modules de commandes Tauri
│ │ │ ├── fs_commands.rs
│ │ │ ├── export_import_commands.rs
│ │ │ ├── profile_commands.rs
│ │ │ ├── license_commands.rs
│ │ │ ├── auth_commands.rs
│ │ │ └── entitlements.rs
│ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql
│ │ │ └── consolidated_schema.sql
│ │ ├── lib.rs # Point d'entrée, migrations, plugins
│ │ └── main.rs
│ ├── capabilities/ # Permissions Tauri
│ └── Cargo.toml
├── .forgejo/workflows/ # CI/CD (hôte primaire)
│ ├── check-rust.yml
│ ├── check-frontend.yml
│ ├── audit.yml
│ └── release.yml
├── .github/workflows/ # Miroir GitHub (dormant)
├── docs/ # Documentation technique
└── config/ # Configuration
```
## Base de données
### Tables (20)
| Table | Description |
|-------|-------------|
| `import_sources` | Configuration des sources d'import CSV. Porte le **format d'import en entier** depuis v17 : `amount_mode` (`CHECK ∈ {single, debit_credit, absolute_indicator}`) et `sign_convention` (`CHECK ∈ {negative_expense, positive_expense}`) s'ajoutent aux réglages mécaniques (délimiteur, encodage, format de date, `column_mapping`), plus `header_signature` (libellés normalisés du dernier import réussi, détection de dérive) et `template_id` (**étiquette de provenance**, `ON DELETE SET NULL`, jamais relue comme format) — voir [ADR 0019](adr/0019-format-import-persiste.md) et la section « Import CSV » |
| `imported_files` | Suivi des fichiers importés (hash anti-doublons) |
| `categories` | Catégories hiérarchiques (dépenses/revenus) |
| `suppliers` | Fournisseurs avec auto-catégorisation |
| `keywords` | Mots-clés pour catégorisation automatique |
| `transactions` | Transactions individuelles |
| `adjustments` | Ajustements manuels (ponctuels ou récurrents) |
| `adjustment_entries` | Montants par catégorie pour chaque ajustement |
| `budget_entries` | Allocations budgétaires mensuelles par catégorie |
| `budget_templates` | Modèles de budget réutilisables |
| `budget_template_entries` | Catégories et montants dans les modèles |
| `import_config_templates` | Modèles prédéfinis de config d'import |
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`**et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
| `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
| `balance_snapshot_lines` | Une ligne par `(snapshot, compte)`**source de vérité agrégée**. `simple` : `value` seul. `priced`/`detailed` : la ligne porte la valeur **totale** (`value = SUM(holdings.value)`, `quantity`/`unit_price` NULL pour un compte détaillé), le détail vit dans `balance_snapshot_holdings`. Les agrégateurs et Modified Dietz lisent uniquement cette `value` ([ADR 0015](adr/0015-balance-detail-par-titre.md)) |
| `balance_account_transfers` | Liaison `transactions ↔ balance_accounts` avec `direction ∈ {in, out}`. Utilisée par le calcul Modified Dietz pour séparer apports et gains |
| `balance_securities` | Table normalisée et **partagée** des titres (v14, [ADR 0015](adr/0015-balance-detail-par-titre.md)) : `symbol` UNIQUE `COLLATE NOCASE` (canonique upper/trim), `currency` (DEFAULT `CAD`, préparé multi-devise), `asset_type ∈ {stock, crypto}`, `name?`. Référencée en `ON DELETE RESTRICT` → un titre référencé est immortel (suppression masquée UI) |
| `balance_snapshot_holdings` | Détail par titre d'un compte détaillé, rattaché à sa **ligne de snapshot agrégée** (v14, [ADR 0015](adr/0015-balance-detail-par-titre.md)) : `snapshot_line_id` (FK CASCADE), `security_id` (FK RESTRICT), `quantity`, `unit_price`, `value` (= qty × prix, arrondi cent), `book_cost?` (gain latent = `value book_cost`), `price_source?`, `price_fetched_at?`. `UNIQUE(snapshot_line_id, security_id)` |
### Index (24)
Index existants (15) : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source).
Index Bilan (9) — 7 ajoutés en v9 :
- `idx_balance_accounts_category` (FK lookup catégorie → comptes)
- `idx_balance_accounts_active` partiel `WHERE is_active = 1` (filtre liste active)
- `idx_balance_snapshot_lines_snapshot` (chargement d'un snapshot)
- `idx_balance_snapshot_lines_account` (historique par compte)
- `idx_balance_account_transfers_account` (cash flows Modified Dietz par compte)
- `idx_balance_account_transfers_transaction` (lookup icône d'attribution dans `TransactionTable`)
- `idx_balance_snapshots_date` (sélecteur de période + agrégation chronologique)
2 ajoutés en v14 (Étape 2 — détail par titre) :
- `idx_balance_snapshot_holdings_line` (chargement des positions d'une ligne de snapshot)
- `idx_balance_snapshot_holdings_security` (FK lookup titre → positions, garde `ON DELETE RESTRICT`)
### Invariants Bilan (CHECK + FK)
- `balance_categories.kind``('simple','priced')` (défaut suggéré pour les nouveaux comptes ; l'axe agrégé/détaillé est désormais porté par `balance_accounts.kind`)
- `balance_accounts.currency = 'CAD'` (verrou MVP — v2 lèvera ce CHECK avec table de taux)
- `balance_accounts.vehicle_type``('unregistered','tfsa','rrsp','rrif','fhsa','resp')` ou NULL (enveloppe fiscale, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md))
- `balance_accounts.kind``('simple','detailed')` (v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — un compte `detailed` à/après `detailed_since` doit porter des holdings (validation TS `validateDetailedSnapshot`, pivot faisant autorité)
- `balance_snapshot_lines` : `(quantity, unit_price)` doivent être tous deux NULL (kind simple) OU tous deux NOT NULL (kind priced) ; pour un compte `detailed`, la ligne agrégée porte `value = SUM(holdings.value)` (comparaison exacte au cent), `quantity`/`unit_price` NULL
- `balance_securities.symbol` UNIQUE `COLLATE NOCASE` ; `asset_type``('stock','crypto')`
- `balance_snapshot_holdings` : `UNIQUE (snapshot_line_id, security_id)` (un titre une seule fois par ligne)
- `balance_account_transfers.direction``('in','out')` ; UNIQUE `(transaction_id, account_id)` (une transaction ne peut pas être liée deux fois au même compte)
- FK `balance_accounts.balance_category_id``balance_categories(id)` `ON DELETE RESTRICT` (empêche suppression de catégorie avec comptes liés)
- FK `balance_snapshot_lines.snapshot_id``balance_snapshots(id)` `ON DELETE CASCADE` (supprimer un snapshot supprime ses lignes)
- FK `balance_snapshot_lines.account_id``balance_accounts(id)` `ON DELETE RESTRICT` (préserve l'historique)
- FK `balance_snapshot_holdings.snapshot_line_id``balance_snapshot_lines(id)` `ON DELETE CASCADE` (supprimer une ligne/snapshot emporte ses holdings)
- FK `balance_snapshot_holdings.security_id``balance_securities(id)` `ON DELETE RESTRICT` — un titre référencé est immortel (préserve l'historique, miroir de la règle transferts), voir [ADR 0015](adr/0015-balance-detail-par-titre.md)
- FK `balance_account_transfers.account_id``balance_accounts(id)` `ON DELETE CASCADE`
- FK `balance_account_transfers.transaction_id``transactions(id)` `ON DELETE RESTRICT` — décision structurante pour la reproductibilité Modified Dietz, voir [ADR 0010](adr/0010-fk-restrict-balance-transfers.md)
## Système de migrations
Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plugin_sql::Migration` :
| # | Version | Description |
|---|---------|-------------|
| 1 | v1 | Schéma initial (13 tables) |
| 2 | v2 | Seed des catégories et mots-clés |
| 3 | v3 | Ajout `has_header` sur `import_sources` |
| 4 | v4 | Ajout `is_inputable` sur `categories` |
| 5 | v5 | Création de `import_config_templates` |
| 6 | v6 | Changement contrainte unique `imported_files` (hash → filename) |
| 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) |
| 8 | v8 | Migration de catégories (cf. release 0.8.x) |
| 9 | v9 | Schéma Bilan : 5 tables + 7 index + seed des 7 catégories standard (cash, TFSA, RRSP, fund, stock, crypto, other) |
| 10 | v10 | Ajout `asset_type` sur `balance_categories` (stock/crypto) + backfill des 2 catégories cotées |
| 11 | v11 | Nettoyage des snapshots Bilan orphelins |
| 12 | v12 | Étape 1 : `balance_accounts.vehicle_type` (+ CHECK, backfill ex-CELI/REER) + `balance_categories.custom_label` (+ backfill défensif du bug i18n) — [ADR 0014](adr/0014-balance-vehicule-attribut.md) |
| 13 | v13 | Étape 1 : reclasse les comptes ex-tfsa/rrsp vers « Autres », désactive les seeds enveloppes (idempotente) — [ADR 0014](adr/0014-balance-vehicule-attribut.md) |
| 14 | v14 | Étape 2 : `balance_securities` + `balance_snapshot_holdings` + 2 index (additive) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
| 15 | v15 | Étape 2 : `balance_accounts.kind` (`simple`/`detailed`) + `detailed_since` + backfill depuis `category.kind` (`priced` → `detailed`) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
| 16 | v16 | Étape 2 : conversion des comptes cotés existants en détaillés 1-position (security + holding miroir, gardée anti-perte, idempotente) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
| 17 | v17 | Import : `import_sources.amount_mode` + `sign_convention` (les deux avec `CHECK`) + `header_signature` + `template_id` (FK `ON DELETE SET NULL`), backfill `amount_mode = 'debit_credit' WHERE column_mapping LIKE '%debitAmount%'` (additive, aucun changement de comportement observable) — [ADR 0019](adr/0019-format-import-persiste.md) |
v17 est purement additive : elle n'ajoute **ni table ni index** (20 tables / 24 index inchangés). Le `CHECK` d'`amount_mode` admet `absolute_indicator` dès maintenant, pour que le troisième mode de montant puisse être livré sans nouvelle migration, alors que l'application le **refuse** encore explicitement (voir plus bas). `LIKE` plutôt que `json_extract` : la migration ne dépend d'aucune extension JSON1 dans le SQLite embarqué. `sign_convention` n'est délibérément pas backfillée — son `DEFAULT` restitue la valeur que le code codait en dur, la seule convention passée inférable.
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
## Services TypeScript (18)
| Service | Responsabilité |
|---------|---------------|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql) |
| `profileService.ts` | Gestion des profils |
| `categoryService.ts` | CRUD catégories hiérarchiques |
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) |
| `importSourceService.ts` | Configuration des sources d'import — lit et écrit le format complet via le codec `importFormat.ts` ([ADR 0019](adr/0019-format-import-persiste.md)) |
| `importedFileService.ts` | Suivi des fichiers importés |
| `importConfigTemplateService.ts` | Modèles de configuration d'import |
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
| `adjustmentService.ts` | Gestion des ajustements |
| `budgetService.ts` | Gestion budgétaire |
| `dashboardService.ts` | Agrégation données tableau de bord : `getExpensesByCategory` (barres classées, `accountIds`), `getDashboardSummary` (non consommé depuis #279), `deriveNetWorthTile` (pur — tuile valeur nette du Bilan, réutilise `deriveLandingState`) |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
| `userPreferenceService.ts` | Stockage préférences utilisateur |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
### Service Bilan — `balance.service.ts`
Un seul service par convention projet (1 service par domaine, splitter seulement > ~400 lignes). Quatre sections logiques distinctes :
1. **CRUD catégories + comptes + titres**`listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount` (garde `detailed → simple` refusée si des holdings existent, erreur typée), `archiveBalanceAccount`. **Securities (Étape 2)** : `listSecurities`, `getSecurity`, `findOrCreateSecurity` (UPSERT sur symbol normalisé upper/trim, `asset_type` requis), `updateSecurity`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, `account_kind_detailed_has_holdings`, etc.).
2. **Snapshots + lines + holdings**`listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne). **Save détaillé (Étape 2)** : pour un compte `detailed`, la ligne agrégée (value = somme des holdings) **et** ses holdings sont écrits dans la **même transaction** (`BEGIN/COMMIT`), `value` recalculée = `SUM(holdings.value)` (chaque holding arrondi au cent, comparaison exacte). `validateLineKindInvariants` (simple, inchangé, tolérance `PRICED_VALUE_TOLERANCE = 0.01`) + nouvelle passe `validateDetailedSnapshot(account.kind, line, holdings)` (detailed + holdings ⇒ ligne agrégée ET `value = SUM` ; detailed pré-pivot ⇒ agrégé toléré). `getHoldingsForLatestSnapshot` (pré-remplissage : titres + qty + book_cost reportés, qty-0 exclus), `listHoldingsBySnapshotLine` (drill-down), `computeUnrealizedGain` (gain latent `value book_cost` en valeur + %, garde-fou `book_cost = 0`/NULL → « N/A », agrégeable par classe/enveloppe). `deleteSnapshot`.
3. **Returns + transfers**`linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`).
4. **Prices***(Phase 5, livraison reportée à l'Issue #143)*. La forme prévue : `fetchPrice(symbol, date)` invoquant `fetch_price` (Tauri), avec rate-limit client (1/2s), backoff exponentiel et dedup in-flight. Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy.
Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`).
## Hooks (17+)
Chaque hook encapsule la logique d'état via `useReducer` :
| Hook | Domaine |
|------|---------|
| `useCategories` | Catégories avec hiérarchie |
| `useTransactions` | Transactions et filtrage |
| `useDataImport` | Import de données |
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md) |
| `useImportHistory` | Historique des imports |
| `useAdjustments` | Ajustements |
| `useBudget` | Budget |
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
| `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
| `useBalanceAccounts` | Bilan — état de la page `/balance/accounts` : CRUD comptes ET catégories (un seul hook pour les deux onglets, aligné sur la convention "1 hook par page") |
| `useSnapshotEditor` | Bilan — cycle de vie d'un snapshot unique (`/balance/snapshot`) : valeurs simple (string) + valeurs priced (`{quantity, unit_price}` strings), prefill depuis snapshot précédent, save (rewrite-all), delete avec double-confirmation par re-saisie de la date |
| `useBalanceOverview` | Bilan — page `/balance` : sélecteur de période (`3M / 6M / 1A / 3A / Tout`), série temporelle agrégée, mode chart (`line` / `stacked`), tableau des comptes avec valeurs courantes et Δ% sur la période. Les rendements multi-horizons sont chargés *lazily* dans `BalanceAccountsTable` (un appel `compute_account_return` par cellule) |
| `useDataExport` | Export de données |
| `useTheme` | Thème clair/sombre |
| `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` |
| `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext``ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») |
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
### Hook transverse — `useCollapsibleGroups`
`useCollapsibleGroups<T>(storageKey: string | null, accessors, { defaultExpanded })` est un hook **utilitaire d'état UI** (basé `useState`, hors convention `useReducer` par domaine) partagé par toutes les surfaces à hiérarchie repliable : les 3 tableaux de rapports (`ComparePeriodTable`, `BudgetVsActualTable`, `CategoryOverTimeTable`), la grille budget (`BudgetTable`) et les 2 arbres de catégories (`CategoryTree`, `CategoryTaxonomyTree`). Les helpers purs (marche des ancêtres pour la visibilité, polarité du `Set` selon `defaultExpanded`) vivent dans `src/utils/collapsibleRows.ts`.
- `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible.
- `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler).
## Import CSV — format persisté et détection
Le chantier #323-#332 a fait du **format d'import une donnée persistée intégralement, jamais re-devinée** — [ADR 0019](adr/0019-format-import-persiste.md) porte la décision, ses trois voies de perte et les alternatives rejetées. Quatre couches, du schéma à l'écran.
### 1. Le codec — `src/utils/importFormat.ts`
Point de conversion **unique** entre `ImportFormatRow` (persisté : snake_case, mapping en JSON, `has_header` normalisé 0/1) et `ImportFormat` (domaine). Il n'existe pas de type composé partagé par les deux porteurs — c'est structurellement impossible (`ImportSource.has_header` est `boolean`, `ImportConfigTemplate.has_header` est `number`), et la garantie de complétude vient du codec et de son test, pas d'une forme commune :
- `FORMAT_FIELD_PAIRS: Record<keyof ImportFormat, keyof ImportFormatRow>` — un champ ajouté au format **ne compile pas** tant qu'il n'y est pas listé ;
- le test compare les clés réellement produites par `formatToRow` / `formatFromRow` à cette table — un champ listé mais non câblé **fait échouer le test**.
`formatFromRow` lève (`ImportFormatError`, clé i18n) sur une valeur illisible plutôt que de retomber sur un défaut. Le module porte aussi `mapRow` (la règle de ligne, pure), `detectAmountSeparators`, `summarizeParsedRows` (le récapitulatif signé de l'aperçu), `flipSignFormat` et `clearMappingForMode`.
**La règle de montant** : `amount = crédit débit` sur des **magnitudes** (`Math.abs`), ce qui ne demande aucun cas particulier pour la colonne inutilisée remplie `0,00` (zéro est l'élément neutre de la soustraction) — le `isNaN(credit)` d'avant importait chaque débit d'un tel fichier à 0. Une ligne illisible dans les **deux** colonnes est une erreur de ligne, pas un 0,00 gratuit. `parseFrenchAmount` est **ancré** (plus de `parseFloat` rendant le plus long préfixe valide) : `100,00 CAD` rend NaN au lieu de 10000, `50,00-` rend 50, `(50,00)` rend 50. Le séparateur décimal est arbitré **par colonne** (`1.234` vaut 1234 en colonne française et 1.234 en anglaise ; aucune règle appliquée à la cellule seule ne peut trancher). L'écriture de la configuration se fait à `executeImport`, point d'écriture unique — un import abandonné à l'étape des doublons ne laisse aucune configuration derrière lui.
### 2. La détection — `csvAutoDetect.ts` + `headerDictionary.ts` + `bankSignatures.ts`
Trois modules distincts, évalués du plus spécifique au plus générique.
| Module | Rôle |
|---|---|
| `bankSignatures.ts` | Dispositions d'export documentées de **Desjardins, RBC, BNC et Tangerine** : un jeu de libellés normalisés + délimiteur + préambule. Évalué **avant** le dictionnaire générique. `MIN_SIGNATURE_LABELS = 4` (`Date;Description;Montant` est trop banal pour attribuer un nom de banque). Porte aussi la **signature d'en-tête stockée** (`buildHeaderSignature` / `parseHeaderSignature` / `detectHeaderDrift`) : même objet — une ligne d'en-tête réduite à ses libellés normalisés — vu deux fois |
| `headerDictionary.ts` | Dictionnaire lexical FR/EN des rôles de colonnes (date ; description/libellé/détail/transaction ; montant/amount ; débit/retrait/déboursé/withdrawal ; crédit/dépôt/encaissement/deposit ; solde/balance) + `normalizeHeaderCell` / `matchHeaderColumn` / `matchTransactionHeaders`. **Module séparé de `csvAutoDetect.ts` par nécessité** : `montant` est le mot-clé *primaire* du montant pour les transactions et un token d'*exclusion* pour l'import de titres (`VALUE_HEADER_KEYWORDS`, #245) — deux tables qui ne doivent pas se percuter. Les helpers ont été **déplacés** plutôt qu'exportés-puis-réimportés, pour que la dépendance reste à sens unique |
| `csvAutoDetect.ts` | Heuristiques de forme (délimiteur, encodage, en-tête, colonnes candidates) + `detectImportFormat`, l'entrée pleine fidélité qui rend soit une config soit une **raison** de refus, et le score |
**L'ordre débit/crédit vient du libellé, plus de la position.** La paire est toujours trouvée par la forme (colonnes creuses et complémentaires), mais laquelle est le débit est décidé par le dictionnaire — connaître l'une suffit. Avant, `Date;Description;Credit;Debit` inversait **tous** les signes, silencieusement (un total inversé ne fait que changer de signe). `detectHeader` gagne un **signal lexical** : une ligne que le test de forme rejette *uniquement* à cause d'un nombre est un en-tête si elle nomme ≥ 2 rôles (`MIN_HEADER_ROLE_MATCHES`). Chaque indice lexical reste une **préférence que les données peuvent contredire** ; sans en-tête, ou avec des libellés inconnus, tout retombe sur la forme exactement comme avant.
**Un format est explicitement refusé** : montants absolus accompagnés d'une colonne d'indicateur `D`/`C` (ou `DB`/`CR`) adjacente portant ≥ 2 valeurs distinctes. Il était auparavant indiscernable d'un fichier tout-positif, déduit `positive_expense`, et **chaque dépôt s'importait en dépense**. Message dédié (`import.errors.absoluteIndicatorFormat`) plutôt qu'un import de travers.
**Le score de confiance** (`DetectionScore` : `readRows`, `totalRows`, `ratio`, `confident`, seuil `CONFIDENCE_THRESHOLD = 0.9`) rejoue la configuration décidée sur **toutes** les lignes du fichier via `mapRow` — la fonction de production, pas un second mappeur — sous la même arbitration décimale. Il **colore, il ne bloque pas** : un score parfait ne dit rien du **signe** (le fixture `all-positive` score 100 % alors que chaque crédit s'importe en dépense). La détection se déclenche **seule** sur une source sans format enregistré, et **jamais** sur une source qui en a un (garde `!existing`) — le format stocké gagne. Le score est invalidé au changement de source, à l'édition manuelle du format et au chargement d'un modèle.
### 3. Le wizard — l'aperçu est obligatoire
L'étape `file-preview`, déclarée depuis l'origine dans `ImportWizardStep` sans qu'aucun dispatch ne la vise, est **rendue et traversée à chaque import** ; `FilePreviewModal` (l'aperçu optionnel qui l'avait supplantée) est supprimé. `checkDuplicates` en est le bouton « suivant », donc les lignes validées sont exactement les lignes vérifiées, sans second parsing entre les deux. L'étape n'est gatée par rien — surtout pas par le seuil de 90 %, puisque le cas qu'elle attrape est précisément le score parfait au signe inversé.
- **Récapitulatif signé** (`summarizeParsedRows`) : sorties et leur total, entrées et le leur, lignes en erreur. Les totaux restent **signés** — des magnitudes masqueraient la seule chose que le récap expose. Calculé sur tout le fichier, jamais sur les vingt lignes affichées.
- **« Inverser les signes »** (`flipSignFormat`) agit sur la **configuration**, donc la correction est mémorisée avec la source. En mode `single` elle bascule la convention ; en `debit_credit` elle **échange les deux indices de colonnes**, parce que `mapRow` y calcule `crédit débit` sur des magnitudes et ne lit jamais la convention — une bascule y aurait été inerte.
- **`FormatDriftPanel`** s'ouvre au-dessus de l'aperçu quand l'en-tête normalise différemment de `header_signature`, colonne par colonne (« Montant : colonne 3 → 4 »), avec les deux seules issues qui existent : **adopter** le format re-détecté ou **conserver** celui enregistré. Un renommage cosmétique normalise à l'identique et ne dit rien. Une source dont les fichiers n'ont pas d'en-tête garde `header_signature` NULL et la détection de dérive y est inopérante — documenté, pas contourné.
- **`RepairPathNotice`**, rendu dans le panneau de dérive **et** à côté du bouton d'inversion : `findDuplicates` apparie sur date + description + montant, donc ré-importer un fichier « maintenant qu'il se lit bien » ne corrige pas les lignes déjà écrites — il les **double**, et une inversion de signe produit des paires miroir qui s'annulent dans tous les rapports. La seule voie sûre est `deleteImportWithTransactions` puis rejeu. Aucun montant déjà écrit n'est jamais muté.
- **`ImportConfirmation`** énonce désormais le mode de montant, la convention de signe (seulement dans le mode qui l'applique) et le mapping nommé par en-tête. Le sélecteur de convention est **masqué** en mode débit/crédit, où le parsing ne le lit pas — masqué, pas réinitialisé : la valeur stockée est intacte.
### 4. Le format SREF porte les sources et les modèles
`dataExportService` sérialisait uniquement catégories, fournisseurs, mots-clés et transactions, puis exécutait `DELETE FROM import_sources` à la restauration en les remplaçant par une source synthétique « Data Import » — restaurer une sauvegarde détruisait toute configuration d'import. Depuis #331 : `import_sources` et `import_config_templates` sont dans l'enveloppe derrière un `format_version` explicite (`SREF_FORMAT_VERSION = 2` ; une enveloppe sans le champ est l'ancien format, ses tableaux absents traités comme vides). Les **modèles sont restaurés avant les sources** (`template_id` est une FK), upsertés par nom, et `template_id` remappé sur les identifiants résolus. Wipe et restauration des deux chemins sont enveloppés dans `withTransaction` — le service n'en avait aucune, et une violation de contrainte en cours de restauration détruisait l'historique financier sans rollback. `amount_mode` et `sign_convention` sont whitelistés à la frontière d'import avec un message lisible (le `CHECK` v17 refuse déjà les mauvaises valeurs, mais une erreur de contrainte SQLite n'est pas un message utilisateur).
### Corpus de fixtures — `src/__fixtures__/csv/`
15 fichiers CSV **synthétiques** (aucune donnée réelle, aucun relevé réel n'entre dans ce dépôt — les signatures de banques sont écrites depuis des dispositions documentées). Ils couvrent des formes qu'un vrai relevé ne réunit jamais toutes : montant signé, débit/crédit, débit/crédit inversés, colonne inutilisée à `0,00`, préambule, en-tête contenant un nombre, en-tête à libellé numérique, sans en-tête, tout-positif, Desjardins quoté, montant absolu + indicateur, plus les quatre dispositions de banque. Posés en #326 **avant** la refonte, avec le comportement fautif figé et marqué `KNOWN DEFECT` + l'issue devant le corriger ; chaque maillon suivant a mis à jour l'attente et retiré le marqueur, sans jamais supprimer un test.
## Gating par édition (licence)
Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de licence (`free` / `base` / `premium`) **côté interface uniquement** — un soft-paywall assumé (l'app est GPL ; le seul gate appliqué côté serveur reste la récupération de cours Premium via maximus-api). La matrice complète tier → modules, le rationale et les alternatives rejetées sont dans l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
| Brique | Fichier | Rôle |
|---|---|---|
| Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé copiée déclassée par le machine-binding ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell |
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status` |
| `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }``allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue |
| `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (`<Outlet/>`) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé |
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé : CTA « Obtenir \<tier\> » **désactivé** avec mention « bientôt » (câblage boutique par #270) + « J'ai déjà une clé » → `/settings/users` (prop `onNavigate?` pour les hôtes modaux) |
| Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) |
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell compact) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
| Rust | `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` réduit à `auto-update` → Base+ (la matrice UI vit en TS) ; `check_entitlement` = matrice OU `features[]` signés avec court-circuit Free (même CWE-863), entitlements résolus par `license_commands::current_entitlements` |
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
## Commandes Tauri (36)
### `fs_commands.rs` — Système de fichiers (6)
- `scan_import_folder` — Scan récursif de dossier pour fichiers CSV/TXT
- `read_file_content` — Lecture avec gestion de l'encodage
- `hash_file` — Hash SHA-256 (détection de doublons)
- `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15)
- `get_file_preview` — Aperçu des N premières lignes
- `pick_folder` — Dialogue de sélection de dossier
### `export_import_commands.rs` — Export/Import de données (5)
- `pick_save_file` — Dialogue de sauvegarde
- `pick_import_file` — Dialogue de sélection de fichier
- `write_export_file` — Écriture fichier chiffré (format SREF)
- `read_import_file` — Lecture fichier chiffré
- `is_file_encrypted` — Vérification magic SREF
### `profile_commands.rs` — Gestion des profils (7)
- `load_profiles` — Chargement depuis `profiles.json`
- `save_profiles` — Sauvegarde de la configuration
- `delete_profile_db` — Suppression du fichier de base de données
- `get_new_profile_init_sql` — Récupération du schéma consolidé
- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`)
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
### `license_commands.rs` — Licence et activation machine (10)
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
- `store_license` — Stockage de la clé dans le répertoire app data
- `store_activation_token` — Stockage du token d'activation
- `read_license` — Lecture de la licence stockée
- `get_edition` — Détection de l'édition active (free/base/premium)
- `get_machine_id` — Génération d'un identifiant machine unique
- `activate_machine` — Activation en ligne (appel API serveur de licences, issue #49)
- `deactivate_machine` — Désactivation d'une machine enregistrée
- `list_activated_machines` — Liste des machines activées pour la licence
- `get_activation_status` — État d'activation de la machine courante
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)
- `start_oauth` — Génère un code verifier PKCE et retourne l'URL d'authentification Logto
- `refresh_auth_token` — Rafraîchit l'access token via le refresh token
- `get_account_info` — Lecture du cache d'affichage (via `account_cache::load_unverified`, accepte les payloads legacy)
- `check_subscription_status` — Vérifie l'abonnement (max 1×/jour, fallback cache gracieux). Déclenche aussi la migration `tokens.json` → keychain via `token_store::load`
- `logout` — Efface tokens (`token_store`) + cache signé (`account_cache`) + clé HMAC du keychain
Note : `handle_auth_callback` n'est PAS exposée comme commande — elle est appelée depuis le handler deep-link `on_open_url` dans `lib.rs`. Voir section "OAuth2 et deep-link" plus bas.
### `token_store.rs` — Stockage des tokens OAuth (1)
- `get_token_store_mode` — Retourne `"keychain"`, `"file"` ou `null`. Utilisé par la bannière de sécurité `TokenStoreFallbackBanner` dans Settings pour alerter l'utilisateur quand les tokens sont dans le fallback fichier.
Module non-command : `save`, `load`, `delete`, `store_mode` — toute la logique de persistance passe par ce module, `auth_commands.rs` ne touche jamais directement `tokens.json`. Voir l'ADR 0006 pour la conception complète.
### `account_cache.rs` — Cache d'abonnement signé (aucune commande)
Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`. Expose :
- `save(app, &AccountInfo)` — écrit l'enveloppe signée `{data, sig}` dans `account.json`, avec clé HMAC-SHA256 stockée dans le keychain.
- `load_unverified(app)` — lecture pour affichage UI (accepte legacy et signé).
- `load_verified(app)` — lecture pour gating licence (refuse legacy, tampering, absence de clé). Utilisé par `license_commands::check_account_edition`.
- `delete(app)` — efface le fichier et la clé HMAC du keychain.
### `entitlements.rs` — Entitlements (1)
- `check_entitlement` — Vérifie si une feature est autorisée : `is_feature_allowed` (matrice statique) OU présence dans le `features[]` signé de la licence, avec **court-circuit fail-closed en Free** (CWE-863 : une clé copiée, déclassée `free` par le machine-binding, ne récupère jamais ses features signées)
- Source de vérité Rust : `FEATURE_TIERS` dans `entitlements.rs`, réduit depuis #301 à `auto-update``[base, premium]` (absorbe l'issue #271). Les gates UI (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`) vivent dans la matrice TS `src/shared/entitlements.ts` — voir la section « Gating par édition » et l'[ADR 0017](adr/0017-feature-gating-par-tier.md)
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin machine-binding que `get_edition`, tout échec de validation retourne `("free", [])`
- Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`)
### `balance_commands.rs` — Bilan (1)
- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif, lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md).
Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation).
**À venir Phase 5** (Issue #143, BLOCKED par maximus-api Phase 2) : commande `fetch_price(symbol, date)` pour le price-fetching premium via proxy maximus-api. L'architecture est documentée dans l'ADR 0009 ; la livraison est différée jusqu'à ce que le serveur de licences (`maximus-api`) expose l'endpoint `GET /v1/prices`.
## Plugins Tauri
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
| Plugin | Rôle | Contrainte |
|--------|------|-----------|
| `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL |
| `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — |
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — |
| `tauri-plugin-process` | Relaunch après mise à jour | — |
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` |
| `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
## OAuth2 et deep-link (Compte Maximus)
Flow complet (v0.7.3+) :
1. Frontend appelle `start_oauth` → génère un code verifier PKCE (64 chars), le stocke dans `OAuthState` (Mutex en mémoire du processus), retourne l'URL Logto
2. Frontend ouvre l'URL via `tauri-plugin-opener` → le navigateur système affiche la page Logto
3. L'utilisateur s'authentifie (ou Logto auto-consent si session existante) → redirection 303 vers `simpl-resultat://auth/callback?code=...`
4. L'OS route le custom scheme vers une nouvelle instance de l'app → `tauri-plugin-single-instance` (feature `deep-link`) détecte l'instance existante, **ne démarre PAS un nouveau processus**, et forwarde l'URL à l'instance vivante
5. Le callback `app.deep_link().on_open_url(...)` enregistré via `DeepLinkExt` reçoit les URLs. Pour chaque URL :
- Si un param `code` est présent → appelle `handle_auth_callback` (token exchange vers `/oidc/token`, fetch `/oidc/me`, écriture des tokens via `token_store::save` (keychain OS, fallback fichier 0600) + cache signé via `account_cache::save` (HMAC-SHA256), émission de l'event `auth-callback-success`)
- Si un param `error` est présent → émission de l'event `auth-callback-error` avec `error: error_description`
6. Le hook `useAuth` (frontend) écoute `auth-callback-success` / `auth-callback-error` et met à jour l'état
Pourquoi cet enchaînement est critique :
- **Sans `tauri-plugin-single-instance`** : une nouvelle instance démarre à chaque callback, le `OAuthState` est vide (pas de verifier), le token exchange échoue
- **Sans `on_open_url`** : l'ancien listener `app.listen("deep-link://new-url", ...)` ne recevait pas les URLs forwardées par single-instance. L'API canonique v2 via `DeepLinkExt` est nécessaire
- **Sans gestion des erreurs** : un callback `?error=...` laissait l'UI bloquée en état "loading" infini
Fichiers : `src-tauri/src/lib.rs` (wiring), `src-tauri/src/commands/auth_commands.rs` (PKCE + token exchange), `src-tauri/src/commands/token_store.rs` (persistance keychain + fallback), `src-tauri/src/commands/account_cache.rs` (cache signé HMAC), `src/hooks/useAuth.ts` (frontend), `src/components/settings/TokenStoreFallbackBanner.tsx` (UI de l'état dégradé).
## Pages et routing
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
Les modules payants sont enveloppés dans des **layout-routes pathless `RequireFeature`** (une par feature, soft paywall — voir la section « Gating par édition ») : `/adjustments` (`adjustments`), `/budget` (`budget`), `/reports/highlights` + `/reports/compare` + `/reports/category` + `/reports/cartes` (`reports-advanced` — le hub `/reports` et `/reports/trends` restent Free, hors de tout gate), `/balance` + `/balance/accounts` + `/balance/snapshot` (`balance`).
### Gestion d'erreurs
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
- **Retry au démarrage** : `connectActiveProfile()` réessaie jusqu'à 3 fois avec 1s de délai avant d'afficher l'erreur
- **Réparation de migrations** : `repair_migrations` (Rust/rusqlite) supprime les checksums invalides de `_sqlx_migrations` avant le chargement de la DB
- **Log viewer** : `logService.ts` capture les `console.log/warn/error` dans un buffer circulaire (500 entrées, persisté en `sessionStorage`), affiché dans la page Paramètres via `LogViewerCard`
| Route | Page | Description |
|-------|------|-------------|
| `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
| `/import` | `ImportPage` | Assistant d'import CSV : source → configuration (détection automatique + score) → sélection des fichiers → **aperçu obligatoire** (récap signé, inversion des signes, panneau de dérive) → doublons → confirmation |
| `/transactions` | `TransactionsPage` | Liste avec filtres |
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
| `/budget` | `BudgetPage` | Planification budgétaire |
| `/reports` | `ReportsPage` | Hub des rapports : panneau faits saillants + 4 cartes de navigation |
| `/reports/highlights` | `ReportsHighlightsPage` | Faits saillants détaillés (soldes, top mouvements, top transactions) |
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
| `/balance` | `BalancePage` | Bilan — vue d'ensemble : carte "Aujourd'hui" + Δ% + avertissement bilan pas à jour > 60j, graphique d'évolution (toggle ligne / aire empilée par catégorie), tableau des comptes avec rendements multi-horizons (3M / 1A / depuis création — Modified Dietz) côte-à-côte avec rendement non-ajusté |
| `/balance/snapshot` | `SnapshotEditPage` | Saisie / édition d'un snapshot daté. Mode `?date=today` (création) ou `?date=YYYY-MM-DD` (édition, date immutable). Lignes groupées par catégorie : `simple` = champ valeur, `priced` = `quantity` × `unit_price` (`value` calculé read-only). Bouton "Pré-remplir depuis le snapshot précédent". Suppression à double-confirmation par re-saisie de la date |
| `/balance/accounts` | `AccountsPage` | CRUD comptes + catégories de bilan (deux onglets). Catégories seedées (`is_seed = 1`) renommables mais non-supprimables ; refus de suppression d'une catégorie avec comptes liés (FK RESTRICT) |
| `/settings` | `SettingsLayout` (layout) + `SettingsHomePage` (index) | Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte `TokenStoreFallbackBanner` une seule fois, partagé par les 4 routes principales |
| `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) |
| `/settings/data` | `DataSettingsPage` | Catégories (avec liens vers `/settings/categories/standard` et `/settings/categories/migrate`), backup chiffré et confidentialité de la récupération de prix |
| `/settings/systems` | `SystemsSettingsPage` | Version, mise à jour (`UpdateCard`), historique des versions (`ChangelogContent`), journaux + commentaires (`LogViewerCard`) |
| `/settings/categories/standard` | `CategoriesStandardGuidePage` | Guide imprimable de la structure de catégories standard (route flat, hors `SettingsLayout`) |
| `/settings/categories/migrate` | `CategoriesMigrationPage` | Flux de migration v1→v2 (route flat, hors `SettingsLayout`) |
| `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) |
| `/changelog` | `ChangelogPage` | Redirige vers `/settings/systems` (rétrocompatibilité release notes) |
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).
## Internationalisation
- **Librairie** : i18next + react-i18next
- **Langue par défaut** : Français (`fr`)
- **Langue de fallback** : Anglais (`en`)
- **Fichiers** : `src/i18n/locales/fr.json`, `src/i18n/locales/en.json`
- **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.)
## CI/CD
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.
### `check-rust.yml` — Vérifications Rust sur PR
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même). Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`).
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`tar`, réellement présent) garantit que son silence prouve quelque chose. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que produit `/autopilot` — déclenche donc bien la CI. Aucune étape de cache non plus : le conteneur de job n'atteint pas le serveur de cache du runner ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)), le restore et le save échouent tous les deux. Le cache reviendra via `Swatinem/rust-cache` une fois #234 réglé.
### `check-frontend.yml` — Vérifications frontend sur PR
Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown (`paths-ignore`). Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Le cache npm est retiré pour la même raison que côté Rust.
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. Conséquence à connaître : `npm audit` remonte **2 high en permanence**, les deux clés d'une même advisory `react-router` ([GHSA-qwww-vcr4-c8h2](https://github.com/advisories/GHSA-qwww-vcr4-c8h2), mode React Server Components). Elle est acceptée : l'app est un client de bureau sans serveur — `App.tsx` monte un `BrowserRouter` client-only — et `react-router-dom` étant figé en 7.18.1, sortir de la plage vulnérable demanderait une migration vers react-router v8, pas un bump. Déclencheur de re-évaluation : [#317](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/317). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm.
### `audit.yml` — Audit RustSec quotidien
`schedule` quotidien (06:00 UTC) + `workflow_dispatch`. `check-rust.yml` ne tournant que sur les PR qui touchent le Rust — environ 1 PR sur 40 — ce workflow garantit qu'un avis de sécurité publié sur une dépendance inchangée ne passe pas inaperçu. Échec bloquant : c'est le canal de notification.
**Un run vert ne signifie pas « zéro advisory »** mais « zéro advisory hors de la liste acceptée ». Cette liste vit dans `.cargo/audit.toml`, à la racine du dépôt, avec pour chaque entrée sa preuve de non-atteignabilité et sa condition de retrait ; la politique qui l'encadre est l'[ADR 0018](adr/0018-suppression-advisories-non-atteignables.md). Au moment de sa mise en place, le `schedule` n'avait encore jamais déclenché ce workflow ([#314](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/314)) — un `workflow_dispatch` vert prouve que la commande sort en 0, pas que l'alarme quotidienne fonctionne.
Les deux workflows `check-*` doivent être verts avant tout merge. Ils évitent de découvrir des régressions au moment du tag de release.
> Le miroir GitHub (`.github/workflows/check.yml`) est laissé tel quel : aucune PR n'est ouverte côté GitHub, un push-mirror ne déclenche pas d'événement `pull_request`.
### `release.yml` — Build et publication
Déclenché par les tags `v*`. Deux jobs :
1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS)
2. **build-linux** (ubuntu-22.04) → `.deb` + `.rpm`
Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
## Architecture Decision Records (ADRs)
Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `docs/adr/`.
| # | Titre | Date | Statut |
|---|-------|------|--------|
| [0001](adr/0001-tauri-v2.md) | Choix de Tauri v2 comme framework desktop | 2024-01-01 | Accepted |
| [0002](adr/0002-useReducer-vs-redux.md) | useReducer plutôt que Redux | 2024-01-01 | Accepted |
| [0003](adr/0003-sqlx-migrations.md) | Migrations SQL inline via tauri-plugin-sql | 2024-01-01 | Accepted |
| [0004](adr/0004-aes-256-gcm-encryption.md) | Chiffrement AES-256-GCM pour l'export | 2024-01-01 | Accepted |
| [0005](adr/0005-multi-profile-db.md) | Multi-profils avec bases SQLite séparées | 2024-01-01 | Accepted |
| [0006](adr/0006-oauth-tokens-keychain.md) | Stockage des tokens OAuth via keychain | 2024-01-01 | Accepted |
| [0007](adr/0007-reports-hub-refactor.md) | Refactorisation du hub de rapports | 2024-01-01 | Accepted |
| [0008](adr/0008-modified-dietz-pour-rendement.md) | Modified Dietz pour le calcul de rendement | 2025-01-01 | Accepted |
| [0009](adr/0009-proxy-price-fetching-via-maximus-api.md) | Proxy price-fetching via maximus-api | 2025-01-01 | Accepted |
| [0010](adr/0010-fk-restrict-balance-transfers.md) | FK RESTRICT sur balance_account_transfers | 2025-01-01 | Accepted |
| [0011](adr/0011-providers-best-effort-yahoo.md) | Providers best-effort Yahoo | 2026-04-26 | Accepted |
| [0012](adr/0012-balance-two-level-model.md) | Modèle à deux niveaux pour le Bilan (véhicules × compositions) | 2026-05-01 | Rejected |
| [0013](adr/0013-stocks-provider-evaluation.md) | Évaluation provider stocks : Alpha Vantage retenu comme cible | 2026-05-09 | Accepted |
| [0014](adr/0014-balance-vehicule-attribut.md) | Bilan : le véhicule fiscal est un attribut du compte (Étape 1) | 2026-06-01 | Accepted |
| [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted |
| [0016](adr/0016-persistance-etat-ui-par-profil.md) | Persistance de l'état UI par profil : repli des catégories dans `user_preferences` | 2026-07-15 | Accepted |
| [0017](adr/0017-feature-gating-par-tier.md) | Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé | 2026-07-20 | Accepted |
| [0018](adr/0018-suppression-advisories-non-atteignables.md) | Suppression d'advisories non atteignables : admission sur preuve par cible livrée, clé par ID, garde-fou bloquant | 2026-07-27 | Accepted |
| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted |