# Architecture technique — Simpl'Résultat

> Document mis à jour le 2026-07-20 — Version 0.14.x (gating par édition)

## 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/               # 13 composants (wizard d'import)
│   │   ├── 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/                    # 4 utilitaires (parsing, CSV, 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 |
| `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) |

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 |
| `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é) |
| `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 |
| `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).

## 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 |
| `/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 |
