Simpl-Resultat/docs/architecture.md
le king fu 08c2f57559
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m4s
PR Check — Rust / rust (pull_request) Successful in 3m41s
docs: wording of the read paragraph and of the write errors (#384)
Self-review findings, no behaviour change. architecture.md: the mount reads
paragraph no longer plays on "compte" (count and account). fs_commands.rs:
the doc comment of create_sample_import_folders said a write error carries
"never a path", while it names the sample folder and its file; it now says
it never carries the import folder's path.

Resolves #384

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:48:03 -04:00

773 lines
143 KiB
Markdown
Raw Permalink 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/ # 61 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/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
│ │ ├── import/ # 18 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice, ImportEmptyState, ImportFolderStructure, RootFilesWarning et SourceNameDisplay)
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398)
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
│ │ ├── shared/ # 14 composants réutilisables (dont RequireFeature, UpsellGate, UpsellDialog, PurchaseButton, EmptyState)
│ │ └── transactions/ # 6 composants (dont SplitButton, #369)
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 20 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage, historique des versions, hiérarchie des catégories)
│ ├── i18n/ # Config i18next + locales FR/EN
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
│ ├── App.tsx # Router principal
│ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust
│ ├── src/
│ │ ├── commands/ # 10 modules de commandes Tauri (45 commandes), plus account_cache.rs et return_calculator.rs, sans commande
│ │ │ ├── fs_commands.rs
│ │ │ ├── export_import_commands.rs
│ │ │ ├── profile_commands.rs
│ │ │ ├── license_commands.rs
│ │ │ ├── auth_commands.rs
│ │ │ ├── token_store.rs
│ │ │ ├── entitlements.rs
│ │ │ ├── feedback_commands.rs
│ │ │ ├── backup_commands.rs
│ │ │ └── balance_commands.rs
│ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql
│ │ │ ├── consolidated_schema.sql
│ │ │ ├── profile_db.rs # Rattrapage des profils secondaires (#390, ADR 0021)
│ │ │ └── fixtures/ # Schémas de création des profils publiés (tests)
│ │ ├── 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)
├── scripts/ # build-latest-json.sh (latest.json de l'updater, appelé par release.yml, #320) et son test vitest
├── 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`, `onboarding_checklist_hidden` (« 1 » quand le parcours de démarrage est masqué ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352, #397), `onboarding_skipped_<clé>` (« 1 » par étape passée, #396), `onboarding_guided_initialized` (« 1 » une fois la règle D9 appliquée au profil, #396), `onboarding_acknowledged_<clé>` (« 1 » par étape de visite cochée par « J'ai compris », #425), `onboarding_visit_steps_initialized` (« 1 » une fois les étapes de visite initialisées pour le profil, #425) — voir la section « Onboarding »… **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)
### Accès DB et transactions
`src/services/db.ts` fait passer **toute** opération sur la base par un verrou FIFO unique (`runExclusive`). tauri-plugin-sql charge SQLite dans un pool sqlx multi-connexions et n'expose aucune transaction côté JS : un `BEGIN` et son `COMMIT` envoyés par deux `execute` séparés pouvaient partir sur deux connexions et laisser une transaction d'écriture ouverte (le « database is locked » de la 0.10.0). Une lecture ou une écriture isolée prend le verrou le temps d'une instruction ; `withTransaction` le tient pour tout son rappel.
- **Verrou strict, non réentrant (#408).** Le rappel de `withTransaction` reçoit un **handle de transaction** (typé `Database`) qui exécute directement sur la connexion. Toute autre requête attend derrière la transaction, y compris un `getDb()` appelé depuis le rappel ou depuis une fonction qu'il appelle : celui-ci attend la transaction qui l'attend, un interblocage. Règle : dans un rappel, n'utiliser **que** le handle reçu et le passer aux fonctions appelées (par exemple `exec`, obligatoire sur `findOrCreateSecurity`). Avant #408, un drapeau global laissait passer hors verrou toute requête lancée pendant un rappel, d'où qu'elle vienne.
- **Handle révoqué.** À la fin du rappel (succès ou erreur), le handle est révoqué : un appel tardif depuis une promesse orpheline lève `TransactionHandleRevokedError` au lieu de passer hors verrou. `close()` sur le handle lève toujours `TransactionHandleCloseError`.
- **Changement de profil dans une seule section.** `connectToProfile`, `initializeNewProfileDb` et `closeDb` sont chacun **un seul** `runExclusive` : fermeture de l'ancien pool, puis `repair_migrations` et `prepare_profile_db` (pour `connectToProfile`, appelé seulement après la fin du `close()`), puis `Database.load` et la bascule. Ils attendent donc les opérations déjà en file et une transaction en cours, et rien ne s'exécute entre la fermeture et l'ouverture. Les helpers internes (`closeUnlocked`, `loadUnlocked`) ne prennent jamais le verrou. Pendant un rattrapage long, toute la file attend : rien d'utile ne peut tourner pendant la bascule. Un `close()` de l'ancien pool qui échoue ne bloque pas la bascule : l'état est vidé et la génération incrémentée avant le `close()`, l'échec part au Journal (`logWarn`) et le profil suivant s'ouvre quand même. `closeDb()` seul se résout aussi dans ce cas, sans connexion, et tout ancien handle refuse de tourner.
- **Génération de connexion.** Chaque ouverture et chaque fermeture incrémentent un numéro de génération. Le proxy de `getDb()` et le handle de transaction le vérifient au moment où l'opération sort de la file : une opération mise en file pour une connexion fermée entre-temps lève `DbConnectionClosedError` (« connection closed by profile switch »), au lieu de tourner sur la base du profil suivant ou sur un pool fermé. Un handle obtenu par `getDb()` avant un changement de profil ne sert donc plus après : le reprendre par `getDb()`. Son `close()` est vérifié de la même façon et lève `DbConnectionClosedError`, au lieu de fermer la connexion du profil courant. Dans le handle de transaction, la vérification est une défense, inatteignable aujourd'hui : rien ne ferme la connexion pendant qu'une transaction tient le verrou.
- **Alerte de blocage.** Une opération qui attend le verrou depuis plus de `BLOCKED_ALERT_MS` (15 s) écrit **un** `logWarn` au Journal, qui nomme ce qui tient le verrou (`transaction`, `query`, `profile opening`, `profile initialization`, `connection close`) et depuis quand. L'alerte cite un `getDb()` dans un rappel comme cause possible, pas comme diagnostic : une transaction longue (grosse restauration, migration de catégories sur un gros profil) dépasse aussi 15 s. Un rattrapage de migrations de plus de 15 s produit une alerte « profile opening », attendue. `logService` n'écrit qu'en mémoire et en `sessionStorage`, jamais en base : l'appel est sûr pendant que le verrou est tenu.
- **Tests.** Seul `src/services/db.test.ts` voit le verrou (un `Database` factice qui journalise le début et la fin de chaque opération). Les tests de services mockent `db.ts` (`withTransaction: fn => fn(await getDb())`) et ne le voient pas.
## 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.
La liste est rendue par `migrations()` (lib.rs) ; tauri-plugin-sql ne l'applique qu'à la base du **profil par défaut** (`simpl_resultat.db`, seule URL passée à `add_migrations`). Un test épingle le SHA-384 de chaque version.
Les **profils secondaires** (`profile_<id>.db`) sont créés par `get_new_profile_init_sql` : le fichier `consolidated_schema.sql`, qui porte le schéma complet, puis `history_sql`, qui inscrit toutes les versions dans `_sqlx_migrations` au format sqlx. Le plugin ne les migre jamais. Depuis #390, `prepare_profile_db` les rattrape à chaque ouverture, avant `Database.load` ([ADR 0021](adr/0021-migrations-profils-secondaires.md), `src-tauri/src/database/profile_db.rs`) :
- une base sans historique (créée avant #390) reçoit un **plancher** déduit de son schéma (`infer_floor`) : 6, 8, 10, 16 ou 17 selon la version qui l'a créée, tous rejoués en test depuis `database/fixtures/`. Aucune version au plus égale au plancher n'est rejouée ;
- avant la première version à appliquer, une copie `VACUUM INTO` est prise dans `migration-backups/` à côté des bases ; elle est refaite à chaque rattrapage, une seule est gardée par profil (les plus anciennes sont supprimées après un rattrapage réussi), elle est nommée au Journal même quand le rattrapage s'arrête (`catch_up_backup`), et supprimée avec le profil (#395) ;
- chaque version s'applique dans sa transaction, clés étrangères actives comme sous sqlx. Une version qui bute sur une clé est reprise une fois sans elles, et ses lignes sans parent repassent à NULL si la clé déclare `ON DELETE SET NULL` ;
- la même commande répare dans tous les profils les drapeaux `is_manually_categorized` stockés en texte (`'true'` → 1).
Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`).
## Services TypeScript (21)
| Service | Responsabilité |
|---------|---------------|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql), verrou FIFO de tous les accès et `withTransaction` (voir « Accès DB et transactions »). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même |
| `profileService.ts` | Gestion des profils |
| `categoryService.ts` | CRUD catégories hiérarchiques. `updateCategory` vérifie un nouveau parent et `reattachCategory` rattache une catégorie orpheline, en une transaction par son handle ; `CategoryHierarchyError` typée ; `findOrphanCategories` (diagnostic, requête dédiée sur toutes les catégories) ; `getCategoryDepth` s'arrête sur un cycle — voir « Hiérarchie des catégories » |
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `getTransactionCount` (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) |
| `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 |
| `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden`, les étapes passées (une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les huit clés), les étapes de visite reconnues (`onboarding_acknowledged_<clé>` = `"1"`, lues en une requête et filtrées sur `VISIT_STEP_KEYS`, #425) et le drapeau `onboarding_visit_steps_initialized` ; `initializeGuidedPath` applique la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `initializeVisitSteps(acknowledge)` initialise les étapes de visite une fois par profil, **sans transaction** (les deux reconnaissances si `acknowledge`, puis le drapeau, par écritures idempotentes) ; `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep` et `acknowledgeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en huit lignes — voir la section « Onboarding » |
| `dataEvents.ts` | Canal d'événements de données (#396, [ADR 0022](adr/0022-canal-evenements-donnees.md)) : `notifyDataChanged()` après chaque écriture réussie d'une table que lit un compteur du parcours de démarrage (liste dans l'ADR), `subscribeDataChanged(listener)` rend le désabonnement. Sans React ni charge utile ; un abonné qui lève est écrit au Journal et n'empêche pas les autres |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `confirmService.ts` | `confirmAction(message)` : seule voie de confirmation de l'app ([#421](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/421)), par `ask` de `@tauri-apps/plugin-dialog` (icône d'avertissement, boutons `common.confirm` / `common.cancel` dans la langue de l'app). Résout `true` sur la confirmation seulement ; un refus, la fermeture de la boîte ou un échec de l'appel (consigné par `logWarn`) résolvent `false`. Toujours attendu (`await`) : sur desktop, le plugin remplace `window.confirm` par une fonction asynchrone (une `Promise`, toujours vraie) et `window.alert` par une fonction qui ne bloque pas. `src/__integration__/native-dialog-guard.test.ts` refuse tout `confirm` / `alert` natif et toute confirmation non attendue. Seul service qui traduit, par l'instance i18next partagée |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine |
| `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** — `prices.fetchPrice(symbol, date)` invoque `fetch_price` (Tauri, voir « Commandes Tauri »), avec rate-limit client (1/2s), dedup in-flight, backoff exponentiel sur les erreurs de classe 5xx et plafond de 100 cours réussis par session ; un refus `license_revoked` est écrit au Journal (`ACTIVATION_TOKEN_LOG`). 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, aux cours (`fetch_price`, appel réseau qui porte le jeton d'activation) et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`).
## Hooks (29)
Les 29 fichiers `.ts` de `src/hooks/`, hors tests (`ls src/hooks/*.ts | grep -v '\.test\.ts$' | wc -l`, #384) : les 28 du tableau et `useCollapsibleGroups`, décrit plus bas. Chaque hook de domaine encapsule la logique d'état via `useReducer` :
| Hook | Domaine |
|------|---------|
| `useCategories` | Catégories avec hiérarchie. Erreurs typées `CategoriesError` (`{ key }` d'un refus de la hiérarchie, `{ message }` sinon, `categoriesErrorOf`). Fenêtre « Vérifier la hiérarchie » : `hierarchyCheck`, `checkHierarchy`, `reattachOrphan` (une ligne de Journal, puis rechargement de la liste et de l'arbre), `closeHierarchyCheck` (#393) |
| `useCategoryMigration` | Migration vers la taxonomie standard (`/settings/categories/migrate`, `CategoriesMigrationPage`) : machine d'états des étapes découvrir, simuler et consentir, puis exécution et résultat. Reducer sans appel de service : la page calcule le plan, écrit la sauvegarde préalable (SREF) et applique la migration, puis dispatche |
| `useCategoryTaxonomy` | Taxonomie standard v1 en lecture (`getTaxonomyV1`, `findById`, `findByPath`, `getLeaves`, `getParentById` de `categoryTaxonomyService`), mémorisée une fois : guide des catégories standard (`CategoriesStandardGuidePage`) et étapes de la migration |
| `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) |
| `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). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) |
| `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 |
| `useOnboardingChecklist` | Parcours de démarrage (#351, #396), appelé une seule fois par `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`, monté dans `AppShell` autour de `<Outlet/>`) ; les pages le lisent par `useOnboarding()`. Reducer exporté (`onboardingChecklistReducer`) : chargements numérotés (la réponse d'un chargement autre que le dernier lancé est ignorée), actions locales (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prioritaires sur le snapshot d'un chargement parti avant elles (`ACKNOWLEDGE` garde le verrou de carte, contrairement à `SKIP`), rafraîchissement qui garde `ready` et les compteurs, échec après un premier succès qui garde le dernier état. `createOnboardingLoader` attend la règle D9 au premier chargement, une fois (promesse partagée contre le double montage de StrictMode), et n'écrit au Journal qu'au passage en échec. `createVisitStepsInitializer` lance `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` une fois par fournisseur (promesse partagée), après le premier chargement réussi où `budgetReady` est vrai et le drapeau absent (`needsVisitStepsInitialization`), puis relit (#425). Relecture au montage, à chaque changement de route et 150 ms après une rafale d'événements de données (`followDataChanges`, [ADR 0022](adr/0022-canal-evenements-donnees.md)). `computeChecklist` reçoit `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total), les étapes passées et reconnues et `visitStepsInitialized` ; `deriveOnboardingView` (pur) en tire l'étape suivante, le verrou de carte (`latchCardPage`) et le mode (`bannerMode`, `utils/onboardingPath.ts`, la fin attendant `budgetReady && visitStepsInitialized`). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, `hide()` / `show()` / `skip()` / `resume()` / `acknowledge()` / `refresh()` |
| `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) |
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait. Après chaque chargement réussi de la liste, `loadMachines` relit l'état d'activation (lecture locale, aucune requête) : Rust a pu supprimer un jeton qu'aucune ligne ne représente, et « Activer » revient sans recharger la page ; une ligne au Journal à code fixe le dit quand `token_dropped` vaut `not_listed` (#367) |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
| `useFeedback` | Envoi d'une rétroaction au Feedback Hub (`FeedbackDialog`) : `idle`, `sending`, puis `success` ou `error` avec le code stable rendu par `send_feedback` (`invalid`, `rate_limit`, `server_error`, `network_error`) ; toute autre erreur vaut `network_error` |
### 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).
- L'historique des versions de Paramètres → Système (`ChangelogContent`, #372) n'utilise pas ce hook : c'est une liste plate dont une seule version s'ouvre au départ, choisie par une règle, alors que le hook n'a qu'un `defaultExpanded` global, sans amorce par clé. Son état est un `Set` local, en mémoire, amorcé à chaque lecture du fichier par `initiallyExpandedVersions` (`src/utils/changelog.ts`, pur, avec `parseChangelog`) : la version installée, ou la première version publiée quand la version installée est inconnue ou absente du fichier.
## Hiérarchie des catégories (#393)
Un `parent_id` en boucle (A sous B, B sous A, ou une catégorie sous elle-même) cache la boucle et tout ce qui pend dessous partout où l'on part des racines : arbre, sélecteurs de catégorie, budget, rapports. Les données restent intactes ; seul `parent_id` est en cause.
- **Module pur unique** — `src/utils/categoryHierarchy.ts` : `depthsOf` (une catégorie prise dans une boucle, ou dessous, n'a **pas** de profondeur), `subtreeHeight` et `descendantsOf` (une seule descente, `levelsBelow`), `fitsUnder` (trois niveaux au plus, `MAX_CATEGORY_DEPTH = 2`, sous-arbre compris), `parentChangeRefusal`, `findOrphans`, `displaysAsRoot`. Chaque remontée et chaque descente garde l'ensemble des catégories vues : une boucle termine le parcours. Les parcours ont trois consommateurs, sans autre implémentation : `buildParentOptions` (liste des parents du formulaire, #423, et de la fenêtre), le diagnostic et la validation du service ; le budget lit `displaysAsRoot`. `computeDepths` de `CategoryCombobox` ne sert qu'à l'indentation et reste à part.
- **Diagnostic** — `findOrphanCategories` lit **toutes** les catégories, actives ou non (`SELECT id, parent_id, is_active FROM categories`) : les autres requêtes ne gardent que les actives, ce qui ferait d'un parent désactivé un parent absent. Motifs : `cycle` (le membre de plus petit `id`, une entrée par boucle), `inactive_parent`, `missing_parent` ; seule la tête de chaque groupe est listée, sa descendance la suit.
- **Validation** — `updateCategory` (seulement quand le parent change : un renommage d'une catégorie enregistrée avant ces règles n'est jamais refusé) et `reattachCategory` (toujours) passent par `checkNewParent` : `parentChangeRefusal` sur les catégories actives (`self_parent` ; `cycle` pour un parent pris dans la descendance ou sans profondeur ; `too_deep`), puis `parent_has_transactions` pour un parent qui porte des transactions directes (devenu parent, il ne serait plus saisissable). La racine est toujours acceptée : c'est la réparation de dernier recours d'une boucle. Un refus lève `CategoryHierarchyError` (`code`), que `useCategories` reconnaît par `instanceof` et affiche par la clé `categories.hierarchy.errors.<code>`. Lectures et écritures (la ligne, puis `is_inputable = 0` sur le nouveau parent, comme `createCategory`) se font dans **un** `withTransaction`, par son handle. Un parent inactif ou inexistant lève une erreur simple, hors d'atteinte de l'interface.
- **`getCategoryDepth`** garde les `id` vus et rend `Infinity` sur une boucle : `createCategory` refuse alors ce parent.
- **Fenêtre** — `HierarchyCheckModal` (présentationnelle) : nombre d'orphelines ou « aucune », une ligne par orpheline (nom, motif traduit, `CategoryCombobox` du nouveau parent alimenté par `buildParentOptions` avec l'orpheline en catégorie éditée, « Racine » présélectionnée en `extraOptions`, bouton « Rattacher »), bouton « Fermer ». La liste du combobox sort de la fenêtre par son portail (#429). Après un rattachement, une ligne `logInfo` donne le motif et le nombre de catégories remises en place, sans nom de catégorie, puis la liste et l'arbre se rechargent.
- **Budget** — `buildBudgetYearRows` (`useBudget.ts`) et `getBudgetVsActualData` (`budgetService.ts`) placent au premier niveau une catégorie dont le parent n'est pas dans la liste active (`displaysAsRoot`), comme l'arbre et les sélecteurs ; une boucle y reste cachée, la fenêtre la répare.
- **Hors portée** — le glisser-déposer : `updateCategorySortOrders` écrit `parent_id` sans cette validation, gardé par l'interface (`moveCategory` ne peut pas déposer une catégorie dans son propre sous-arbre) ; il ne refuse pas non plus un parent avec transactions et ne touche pas `is_inputable`. La restauration d'une sauvegarde (`restoreCategories`) réinsère les `parent_id` tels quels, boucles comprises : la fenêtre répare après coup.
## 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.
### Gabarits du dossier d'exemple — `src/__fixtures__/samples/`
Deux relevés **inventés** (`exemple-desjardins.csv` : compte chèque Desjardins ; `exemple-tangerine.csv` : carte de crédit Tangerine ; 41 lignes chacun), avec les jetons `{{M1}}` / `{{M2}}` que la commande Rust `create_sample_import_folders` remplace par les deux mois complets précédant le clic (`MM/YYYY`). Les fichiers sont embarqués dans le binaire par `include_str!` : le dépôt est la seule source, il n'y a rien à copier au build. Le module est **séparé** de `csv/index.ts` à dessein — ce corpus est figé à quinze cas par `csvAutoDetect.test.ts` et six boucles y passent chaque fixture ; y ajouter les gabarits élargirait le contrat figé en silence. `src/__integration__/onboarding-samples.test.ts` lit les **mêmes** fichiers et fige ce que l'assistant en fera : chaque fichier reconnu par la signature de la banque qu'il imite, toutes les lignes lues, aucune ligne en erreur, totaux signés attendus, et la part catégorisée par les 180 mots-clés du seed — en laissant exprès des lignes à classer à la main (dont « GARDERIE LES PETITS PAS »), pour que la cinquième case de la liste de démarrage ait quelque chose à cocher (#354).
## Onboarding — états vides et parcours de démarrage
Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`) : les états vides, la liste de démarrage et le dossier d'exemple. Phase 2 (#396-#401) : le parcours guidé, un bandeau en haut de chaque page et une carte d'étape sur la page de l'étape en cours (voir « Parcours guidé », plus bas). Aucune migration ni table : tout se lit dans les données existantes, et le parcours n'écrit que des préférences par profil.
**Les quatre états vides** rendent le composant partagé `EmptyState` (`src/components/shared/` : icône, titre, description, action primaire en `<Link>` ou `<button>`, action secondaire optionnelle, enfants libres). Leur signal est le **compte brut** de transactions du profil, jamais le résultat d'un filtre — un profil filtré jusqu'à zéro ligne garde sa page normale :
| Page | Signal | Composant | Action proposée |
|---|---|---|---|
| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` |
| Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, une fois le dossier lu (`importFolderLoaded`, #384) | `ImportEmptyState` (deux variantes, chacune avec l'encadré « Structure attendue » d'`ImportFolderStructure` : dossier parent, un sous-dossier par compte, fichiers à la racine ignorés, #422) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas |
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0`, `totalInDb` via `getTransactionCount`, lu au montage : la carte vient dès que le compte est lu, sans attendre la première page (#384, D1) | `EmptyState` dans la page | « Importer des transactions » → `/import` |
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
Sur ces deux pages, le compte se lit au montage à côté des autres lectures de la page (catégories et sources de Transactions, comptes du filtre de Budget), chaque lecture indépendante des autres (`Promise.allSettled`, #384) : un compte en échec laisse `totalInDb` à `null`, donc la mise en page normale, jamais l'état vide, sans priver la page de ses filtres ; le premier échec devient l'erreur de la page. Le compte n'est relu qu'au montage, aucun abonnement au canal d'événements de données : un import se fait sur `/import`, et revenir sur la page la remonte (un changement de profil remonte toutes les routes, `BrowserRouter key={refreshKey}`).
**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a huit lignes dans un ordre fixe (#425). Les six étapes de données se cochent par une **donnée**, jamais par un clic ; les deux étapes de visite par « J'ai compris » (et, pour Rapports, l'ouverture de la carte Tendances mise en évidence). La source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped, acknowledged, visitStepsInitialized })` (`utils/onboardingChecklist.ts`, pur) :
| Case | Source de vérité |
|---|---|
| Créer un profil | toujours cochée — la liste n'existe que dans un profil ouvert |
| Choisir un dossier d'import | préférence `import_folder` non vide |
| Configurer une source d'import | `COUNT(*) FROM import_sources` ≥ 1 |
| Importer un premier fichier | `COUNT(*) FROM imported_files f WHERE f.status = 'completed' AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)` ≥ 1 — un fichier qui n'a amené aucune transaction (toutes ses lignes en erreur, ou sa seule ligne d'en-tête) ne compte pas : `executeImport` écrit la ligne `completed` avant d'insérer |
| Parcourir les catégories et les mots-clés | étape de visite (#425) : préférence `onboarding_acknowledged_categories` = `"1"`, posée par « J'ai compris » sur sa carte ; verrouillée tant que `visitStepsInitialized` est faux, alors exclue du total |
| Classer une transaction à la main | `COUNT(*) FROM transactions WHERE is_manually_categorized = 1` ≥ 1 — ni « règle créée » ni « transactions catégorisées » : le seed IPC pose 180 mots-clés dans tout profil neuf et le wizard catégorise pendant l'import, ces deux cases seraient donc cochées d'avance |
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (7 au lieu de 8) et renvoyée vers `/settings/users` |
| Parcourir les rapports | étape de visite (#425) : préférence `onboarding_acknowledged_reports` = `"1"` ; même verrou que la ligne Catégories avant l'initialisation |
`computeChecklist` rend `{ items, done, total, complete }`. Chaque ligne porte `done` (la donnée), `skipped` (l'utilisateur l'a passée) et `locked` ; au niveau de la liste, `done` compte les lignes déverrouillées faites ou passées, une fois chacune, et `complete` vaut vrai quand toutes le sont. Une ligne verrouillée reste hors du total, quel que soit son état. Les deux lignes de visite sont verrouillées tant que le profil n'a pas initialisé ses étapes de visite : un profil au parcours fini ne passe pas un instant par « Étape suivante : Parcourir les catégories » ; la liste les montre alors « en attente », jamais « verrouillées » (le badge de l'édition Base ne les concerne pas), et son compteur attend `budgetReady && visitStepsInitialized`. Dans la liste, une ligne faite n'a plus de lien, une ligne passée montre le badge « Passée » et « Reprendre », une ligne verrouillée mène à la carte Licence ; chaque état sauf « à faire » est lu par un texte masqué visuellement (`onboarding.state.*`), les icônes étant cachées aux technologies d'assistance.
**Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; ce bandeau de réussite tombe au changement de dossier. Il ne vient qu'après un rescan réussi : `scanFolderInternal` rend `false` sur un échec, dont le bandeau d'erreur reste seul affiché (#384). La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; ses refus sont des codes stables que `sampleFolderError` (pur) traduit en clés `import.empty.sampleErrors.*`, le nom du dossier d'exemple passé par `errorValues` de l'état, que la page interpole au rendu ; tout autre message reste affiché brut. Le message brut part aussi au Journal en `logWarn` : un code, ou une erreur d'écriture qui nomme un dossier d'exemple, son fichier et l'erreur du système, jamais un chemin ; l'erreur du rescan, qui peut porter le chemin, n'y va pas. Les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
### Parcours guidé
**Le fournisseur.** `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`), monté dans `AppShell` autour du bandeau et de `<Outlet/>`, appelle une seule fois `useOnboardingChecklist` : l'état garde son `useReducer` de domaine ([ADR 0002](adr/0002-useReducer-vs-redux.md)) et toute l'application le partage. Les pages et les composants le lisent par `useOnboarding()`, qui lève hors du fournisseur : `status`, `checklist`, `counters`, `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, et les actions `hide()`, `show()`, `skip(clé)`, `resume(clé)`, `acknowledge(clé)`, `refresh()`. Le fournisseur survit à la navigation ; un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`), donc un fournisseur neuf, avec son propre chargeur. Il relit son état au montage, à chaque changement de route et après les écritures annoncées sur le canal de données. Ses chargements sont numérotés et seule la réponse du dernier lancé s'applique ; une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prime sur le snapshot d'un chargement parti avant elle ; une relecture qui échoue après un premier succès garde l'état affiché.
**Le canal de données.** `src/services/dataEvents.ts` ([ADR 0022](adr/0022-canal-evenements-donnees.md)) : chaque écriture qui peut changer un compteur du parcours appelle `notifyDataChanged()` une fois réussie (la liste des écritures et la règle d'ajout sont dans l'ADR), et le fournisseur, seul abonné, relit son état 150 ms après la dernière écriture d'une rafale (`followDataChanges`). C'est ce qui coche une étape pendant que sa page est ouverte.
**Les préférences** (`user_preferences`, par profil, donc détruites avec lui et sans effet sur les autres profils, [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Le fournisseur en est le seul écrivain, et aucune de ces écritures ne passe par le canal : il met son état à jour lui-même, une fois l'écriture faite.
| Clé | Valeur | Écrite par |
|---|---|---|
| `onboarding_checklist_hidden` | `"1"` quand le parcours est masqué ; un seul état pour le bandeau et la liste. Supprimée au réaffichage | `hideChecklist` (« Masquer », et « Fermer » à la fin du parcours) et la règle D9 ; `showChecklist` la supprime |
| `onboarding_skipped_<clé>` | `"1"` par étape passée, `<clé>` étant l'une des huit clés de `CHECKLIST_ITEM_KEYS`. Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `skipStep` (« Passer cette étape ») ; `resumeStep` (« Reprendre ») la supprime |
| `onboarding_guided_initialized` | `"1"` une fois la règle D9 appliquée au profil | `initializeGuidedPath` |
| `onboarding_acknowledged_<clé>` | `"1"` par étape de visite faite (#425), `<clé>` étant l'une des deux clés de `VISIT_STEP_KEYS` (`categories`, `reports`). Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `acknowledgeStep` (« J'ai compris ») et `initializeVisitSteps` |
| `onboarding_visit_steps_initialized` | `"1"` une fois les étapes de visite initialisées pour le profil (#425) | `initializeVisitSteps` |
**La règle D9.** Au premier chargement, `createOnboardingLoader` attend `initializeGuidedPath`, une fois par fournisseur (promesse partagée contre le double montage de StrictMode). Le drapeau `onboarding_guided_initialized` est d'abord lu hors transaction : une fois posé, chaque ouverture suivante coûte cette lecture et n'ouvre aucune transaction. S'il manque, une transaction le relit, puis un profil qui a déjà des transactions reçoit `onboarding_checklist_hidden = "1"`, et le drapeau est posé. Après le `COMMIT`, et seulement si le parcours vient d'être masqué, une ligne est écrite au Journal. Un échec annule les deux écritures et le chargement continue sur l'état stocké ; le drapeau absent fait réessayer à l'ouverture suivante. Un profil vide ou neuf garde donc le parcours, et un profil déjà actif le trouve masqué, réaffichable depuis Paramètres → Utilisateurs.
**Le cochage d'office des étapes de visite** (#425). Une seule fois par profil, après le premier chargement réussi où le droit au Budget est connu et le drapeau `onboarding_visit_steps_initialized` absent, le fournisseur appelle `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` (promesse partagée contre le double montage de StrictMode), puis relit. `shouldAcknowledgeVisitSteps` (pur) est vrai quand chaque ligne déverrouillée hors des étapes de visite est faite ou passée : un profil qui avait terminé le parcours les trouve faites et garde « Démarrage terminé », un profil au parcours incomplet les garde à faire. L'initialisation n'ouvre **aucune transaction** : sous le verrou strict de `db.ts` (#408), un appel de service lancé depuis le rappel d'une transaction attendrait derrière ce même verrou. Les écritures sont idempotentes, les reconnaissances d'abord, le drapeau en dernier : un arrêt entre deux refait le même calcul à l'ouverture suivante. Elle ne notifie pas le canal ([ADR 0022](adr/0022-canal-evenements-donnees.md), §5).
**Les modèles purs** (`src/utils/onboardingPath.ts`, sans React, routeur ni base) :
- `nextStep` : la première ligne déverrouillée ni faite ni passée ;
- `CARD_PAGES` (`/import`, `/categories`, `/transactions`, `/budget`, `/reports`, ces deux pages pour les étapes de visite, #432) et `stepsOfPage` : les pages qui portent une carte d'étape, et les étapes que chacune guide ;
- `latchCardPage` : le verrou de carte, décrit ci-dessous ;
- `bannerMode` : `none` avant le premier chargement réussi, quand le parcours est masqué et sur le tableau de bord d'un profil sans transaction (sa carte d'accueil porte déjà la liste) ; `card` sur la page verrouillée ; `complete` quand toutes les lignes déverrouillées sont faites ou passées, une fois l'entitlement Budget connu (`budgetReady`) ; `banner` partout ailleurs ;
- `stepNavState` et `readOnboardingNavState` : l'état de navigation de tout lien vers une étape (`{ onboarding: { focus: true } }`, plus `guided: true` pour « Classer une transaction à la main »), et la seule lecture admise de `location.state` (CWE-476) ;
- `showsStepCard`, `cardStep` (l'étape montrée et « Étape n sur N ») et `cardNext` (après « Fait. » : le lien vers l'étape suivante, la fin, ou rien tant que la relecture n'est pas arrivée) ;
- les modèles de carte des pages, qui rendent la phrase, l'état « fait » et la cible : `importCardModel` (suit l'assistant ; « Fait. » seulement pour un import qui a ajouté des transactions, sur le rapport comme au repos, où le compteur ne retient que les fichiers qui en ont amené), `transactionsCardModel` (phrase selon le filtre « Non catégorisées ») `budgetCardModel` (« Tout déplier » ou la cellule du mois, selon la cible que la grille remonte, calculée par `budgetOnboardingTarget`, `src/components/budget/`), `categoriesCardModel` (les mots-clés, cible « Tous les mots-clés », jamais « Réinitialiser ») et `reportsCardModel(checklist, advancedLocked)` (le hub, cible la carte Tendances ; variante qui mentionne le cadenas quand les rapports avancés sont verrouillés, licence connue) ;
- `onboardingTargetAttributes` : les attributs du contrôle mis en évidence.
`deriveOnboardingView` (`useOnboardingChecklist.ts`, pur) les compose à chaque rendu du fournisseur : étape suivante, verrou de carte et mode.
**Le bandeau.** `OnboardingBanner` (`src/components/onboarding/`, piloté par props) est rendu par `AppShell` au-dessus de chaque page, dans le flux de la page, avec `key={pathname}` : chaque page commence liste repliée. En mode `banner` : « Démarrage n/N », qui attend `budgetReady && visitStepsInitialized`, « Étape suivante » avec le lien de l'étape, « Voir la liste » / « Fermer la liste » (`aria-expanded`, `aria-controls`) et « Masquer ». En mode `complete` : le message de fin, « Ouvrir le guide » (`/settings/users#gettingStarted`) et « Fermer », qui masque le parcours. Dans les modes `none` et `card`, il ne rend rien.
**Le verrou de carte.** `latchCardPage(previous, { pathname, nextStep, hidden })` rend la page qui porte la carte, ou null. Il est posé quand la route courante est celle de l'étape suivante et figure dans `CARD_PAGES`. Il tient quand l'étape se coche : la carte reste pour annoncer « Fait. » et proposer la suite, même quand l'étape suivante vit sur une autre page. Il est relâché au changement de route, quand le parcours est masqué, et par « Passer » (`SKIP`) ; le rendu suivant le repose si la nouvelle étape suivante est sur la même page. La fonction est idempotente : le fournisseur la dérive pendant le rendu et range le résultat dans son reducer (`SET_CARD_PAGE`), ce qui évite l'image de bandeau qu'un effet laisserait passer sur une page à carte.
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « J'ai compris » avant « Passer cette étape » pour une étape de visite (`onAcknowledge`, passé par `OnboardingStepCard` quand `VISIT_STEP_KEYS` contient l'étape de la carte ; `ACKNOWLEDGE` garde le verrou de carte, qui annonce « Fait. » puis l'étape suivante), « Passer cette étape » tant que l'étape n'est pas faite ; le rang « Étape n sur N » et la fin attendent `budgetReady && visitStepsInitialized` ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, sur « Tous les mots-clés » de `CategoriesPage` et sur la carte Tendances du hub (`HubReportNavCard`), une seule cible à la fois. Sur le hub, la carte Tendances reçoit aussi `onNavigate`, appelé au clic avant la navigation, tant que la carte d'étape s'affiche et que l'étape n'est pas faite : il reconnaît l'étape des rapports (`acknowledge("reports")`, D17), car suivre l'anneau quitte la page et relâche le verrou de carte. Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
**Paramètres → Utilisateurs** lit `hidden` dans `useOnboarding()` et montre « Afficher la liste de démarrage » tant que le parcours est masqué, y compris quand le bandeau de cette même page vient de le masquer. Le clic passe par `show()` du fournisseur, et une confirmation remplace le bouton (#397).
## 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é résolue en `free` (invalide, expirée ou révoquée) 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`. Porte aussi `revoked` (#365, qui remplace le drapeau de session de #347) : l'état vient de Rust (`info.revoked`), persiste à côté de la clé, et l'édition résolue est alors `free` ; le contexte ne décide rien. Il lance le contrôle de révocation au démarrage et à la saisie d'une clé (#373), par un seul lanceur (`revocationCheckLauncher`), qui rend la main aussitôt : `createRevocationCheckStarter` est consommé par le premier état `ready` et ne lance le contrôle que si une clé est enregistrée à ce moment ; `performSubmitKey` le lance après `VALIDATE_DONE` pour la clé qu'il vient d'enregistrer, sans l'attendre, et jamais après un refus ; `performRevocationCheck` écrit l'issue dans le Journal (`REVOCATION_LOG`) et ne recharge le contexte que si l'état a changé. Une clé saisie avant le premier état `ready` reçoit les deux lancements : Rust ne laisse passer qu'un contrôle à la fois, et le second lit la tentative du premier. `performSubmitKey` relit aussi l'édition depuis Rust après l'enregistrement d'une clé, pour qu'une clé révoquée ressaisie reste en Gratuite. `licenseIdentity(info)` (courriel, édition, dates) sert aussi de clé React à la carte Licence, qui se remonte pour une autre clé et relit ainsi son activation |
| `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 décidé par `upsellCtaFor` (#346) : « Obtenir Base » actif (`PurchaseButton`, ouvre la page produit) ; pour Premium, bouton désactivé avec la note « Premium n'est pas encore offert en ligne ». Plus « J'ai déjà une clé » → `/settings/users` (`HaveKeyButton`, seulement sous un routeur). La prop `onNavigate?` ferme les hôtes modaux après « J'ai déjà une clé » ; le lien d'achat ne l'appelle pas. Quand la clé enregistrée est révoquée, une ligne (`upsell.revoked`) le dit au-dessus des CTA : le verrou apparaîtrait sinon sans raison (#365). La carte Mises à jour reprend la même ligne dans sa branche Gratuite |
| CTA partagés | `src/components/shared/PurchaseButton.tsx`, `DownloadLatestButton.tsx`, `HaveKeyButton.tsx` | « Obtenir Base » et « Télécharger la dernière version » ouvrent `purchaseUrl` dans le navigateur, sans routeur ; ils servent aussi la carte Mises à jour et `ErrorPage` en édition Gratuite, sans aucune vérification de mise à jour. « J'ai déjà une clé » ne rend rien hors `BrowserRouter` (#346) |
| Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) |
| 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, qui réutilise `UpsellGate`) ; `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 |
| Gate de la Répartition | `src/shared/splitGate.ts`, `src/components/transactions/SplitButton.tsx` | `splitAccessFor(gate)` → `open` / `locked` / `pending` sur `useEntitlement("adjustments")`, lu par `TransactionsPage` et passé à `TransactionTable` en prop obligatoire (#369). `locked` : cadenas sur le bouton et `UpsellDialog` au lieu de la fenêtre de répartition, pour toute ligne ; `pending` (licence pas `ready`, chargement ou erreur) : ni cadenas ni fenêtre, bouton inactif, `allowed` ne court-circuitant pas `ready`. La fenêtre de répartition n'est rendue que hors `locked`, l'incitation que hors `open`. Non destructif : les répartitions existantes restent comptées et gardent leur icône orange |
| `UpsellDialog` | `src/components/shared/UpsellDialog.tsx` | `UpsellGate` dans une fenêtre (`role="dialog"`, `aria-modal`, nom accessible `upsell.title`), pour une action gatée dans une page Free ; `onNavigate` ferme la fenêtre après « J'ai déjà une clé ». Même balisage que la fenêtre d'incitation de `ProfileSwitcher`, pas encore migrée dessus |
| 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. Seule exception à l'intérieur d'une page Free : le bouton Répartition de la page Transactions, gaté par `adjustments` (#369). 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 (45)
Les 45 commandes de `generate_handler!` (`src-tauri/src/lib.rs`), réparties par module ; chaque compteur de section est le nombre de ses entrées (recompté pour #384).
### `fs_commands.rs` — Système de fichiers (7)
- `scan_import_folder` — Liste les sous-dossiers directs du dossier d'import (une source chacun) avec leurs fichiers CSV/TXT directs, et les CSV/TXT posés à la racine, ignorés (`root_files`)
- `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
- `create_sample_import_folders` — Dossier d'exemple (#354). Contrat : reçoit le dossier d'import et la date du jour (`YYYY-MM-DD`) ; écrit « Exemple Desjardins » et « Exemple Tangerine », un relevé `releve-<AAAA-MM>.csv` chacun daté des deux mois complets précédents ; rend les deux **noms** créés, pas les chemins. Refuse si le dossier d'import n'existe pas ou si l'un des deux sous-dossiers existe déjà — les deux existences sont vérifiées **avant** la première écriture, donc rien n'est écrasé ni créé à moitié. Ses refus sont des codes stables (constantes `SAMPLE_ERROR_*`, #384), traduits par le front : `invalid_date`, `import_folder_missing` (sans le chemin) et `sample_folder_exists:<nom du dossier d'exemple>` ; une erreur d'écriture garde son texte anglais (dossier d'exemple, fichier, erreur du système). Gabarits embarqués par `include_str!` depuis `src/__fixtures__/samples/`
### `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 (8)
Les commandes qui ouvrent une base passent par `profile_db_path` : dossier du plugin (`app_config_dir()`) et nom de fichier validé (`simpl_resultat.db` ou `profile_<hex>.db`, #390).
- `load_profiles` — Chargement depuis `profiles.json`
- `save_profiles` — Sauvegarde de la configuration
- `delete_profile_db` — Suppression de la base d'un profil secondaire, de ses fichiers annexes SQLite (`-wal`, `-shm`, `-journal`) et de ses copies `migration-backups/` (#390)
- `get_new_profile_init_sql` — Schéma consolidé suivi de l'historique complet des migrations (`history_sql`, #390)
- `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)
- `prepare_profile_db` — Avant chaque ouverture : rattrapage des migrations d'un profil secondaire (copie préalable) et réparation des drapeaux de classement manuel de tout profil. Rend un rapport sans chemin (`ProfilePreparation`). Synchrone, donc sérialisée sur le fil principal — [ADR 0021](adr/0021-migrations-profils-secondaires.md)
### `license_commands.rs` — Licence et activation machine (11)
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
- `store_license` — Stockage de la clé dans le répertoire app data
- `remove_license` — Retrait de la clé enregistrée (#370) : `license.key` d'abord, puis `activation.token` ; `license.check` est gardé. Avec `release_slot`, libère d'abord la place de cette machine (`/v1/licenses/deactivate`, identifiant du jeton) quand le jeton active ce poste et que la clé n'est pas connue comme révoquée. `no_active_activation`, `license_not_found` et `license_revoked` (enregistré pour la clé envoyée) valent place libre ; toute autre erreur de la libération est rendue sans rien supprimer. Idempotente sans clé
- `store_activation_token` — Stockage du token d'activation, qui doit avoir été émis pour l'identifiant dérivé de cette machine
- `read_license` — Lecture de la licence stockée, avec `revoked` : l'état de révocation enregistré pour cette clé (#365)
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347) et son état de révocation (#365)
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270). Envoie l'identifiant machine **dérivé**, jamais celui du système (#350)
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès. Supprime le jeton local quand il représentait la place libérée
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide. Chaque ligne porte `is_this_machine`, calculé côté Rust. Rend `{ machines, token_dropped }` depuis #367 : une liste complète qui ne nomme pas ce poste supprime le jeton d'activation de ce poste (`token_dropped: "not_listed"`, sinon `null`)
- `get_activation_status` — État d'activation de la machine courante (`is_activated` seul, sans identifiant). Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
- `check_license_revocation` — Contrôle de révocation, au démarrage (`POST /v1/licenses/verify`, #365) et à la saisie d'une clé (#373), un à la fois. Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license` et `not_due` signifient qu'aucune requête n'est partie ; `storage`, que la tentative n'a pas pu être écrite, et rien n'est parti, ou que le verdict du serveur ne l'a pas pu, et rien n'a changé
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ». Le retrait de la clé (#370) vit dans `remove_license_in`, sans `AppHandle` : la libération préalable (`release_this_machine_slot`) passe par `deactivate_idempotent` puis `note_revocation`, et non par `deactivate_in`, qui supprimerait le jeton avant la clé. Toute suppression du jeton passe par `remove_activation_token_in`, qui tolère un jeton absent et rend `storage` sinon ; `store_license_in` et la désactivation l'emploient aussi. Les deux suppressions de #367, décidées par la réponse d'un serveur, passent par `remove_activation_token_if_unchanged_in`, qui ne supprime le fichier que s'il vaut encore le jeton dont parle la réponse : une activation arrivée pendant la requête écrit un jeton neuf, qui reste (CWE-367).
Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`, sauf si `license.check` la dit révoquée (#365, paragraphe suivant). Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. L'état « Activée » est décidé par `activation_status_in` (signature, `machine_id`, `exp`), testée dans un dossier temporaire. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. Une clé connue comme révoquée reste contrôlée au même rythme, pour reprendre une correction faite côté serveur (décision du 2026-10-03), tant qu'elle est enregistrée : `remove_license` (#370) la retire et garde `license.check`, si bien qu'une clé révoquée retirée puis ressaisie reste en Gratuite. La requête porte `product` et `license_key`, rien d'autre. Le front la lance au démarrage et à la saisie d'une clé (#373), donc deux contrôles peuvent se chevaucher : la commande passe par `check_revocation_in_turn`, qui prend le verrou statique `REVOCATION_CHECK_TURN` (`tauri::async_runtime::Mutex`) avant de lire l'état et le garde jusqu'au verdict, et ne lit l'heure qu'une fois son tour venu. Un second contrôle de la même clé lit la tentative du premier et n'envoie rien ; celui d'une clé saisie pendant le contrôle d'une autre attend ce verdict au lieu de l'écraser dans `license.check`. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, et `fetch_price_in` supprime alors le jeton d'activation refusé, s'il n'a pas changé pendant la requête, mais n'écrit pas l'état de la clé : le serveur y juge la licence du jeton, que le client ne sait pas relier à la clé enregistrée, et ce jeton ne sert plus à rien quelle que soit la clé (#367). `fetch_price_in` lit le jeton dans le dossier de données (`read_stored_activation_token_in`, constante `ACTIVATION_FILE`) et se teste dans un dossier temporaire. Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. `drop_token_of_unlisted_machine_in` (#367), appelée par `list_machines_in`, supprime le jeton quand la liste de la clé enregistrée ne nomme ce poste sous aucune des deux formes, si l'identifiant du système est lisible, si la liste est complète (`machines_from_verify` compte les lignes écartées, que l'affichage tolère toujours), si le jeton active ce poste et s'il n'a pas changé pendant la requête ; une erreur, une clé révoquée ou un 429 ne suppriment rien. Elle suppose que le serveur, s'il rehache un jour ses lignes brutes (maximus/maximus-api#82), produit exactement le dérivé ci-dessus, ce qu'un test épingle. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR.
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
### `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é résolue en `free`, invalide, expirée ou révoquée, 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 que `get_edition`, depuis la seule clé (#347) et son état de révocation (#365), 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`)
### `feedback_commands.rs` — Feedback Hub (2)
- `send_feedback(content, user_id, context)` — Envoie une rétroaction au Feedback Hub (`POST /api/feedback` sur `feedback.lacompagniemaximus.com`, `FEEDBACK_HUB_URL` en développement), délai de 15 s : `app_id` `simpl-resultat`, le texte (celui saisi, suivi au choix des dernières erreurs du Journal, ajoutées par `FeedbackDialog`), refusé s'il est vide, un `user_id` facultatif et le contexte borné par `FeedbackContext` (page, langue, thème, fenêtre, user-agent, horodatage ; un champ absent n'est pas envoyé). Rend `{ id, created_at }`, ou un code stable que `useFeedback` traduit : `invalid`, `rate_limit`, `server_error`, `network_error`
- `get_feedback_user_agent` — Rend `Simpl'Résultat/<version> (<système>)` pour le contexte
### `backup_commands.rs` — Sauvegarde avant la migration des catégories (3)
Appelées par `categoryBackupService` (sauvegarde SREF vérifiée avant la migration vers la taxonomie standard) et `categoryRestoreService` (restauration) :
- `ensure_backup_dir` — Crée au besoin `Simpl-Resultat/backups/` dans le dossier Documents du système et rend son chemin ; un échec rend `permission_denied: <chemin>` ou `create_dir_failed: <chemin>: <erreur>`, que le service traduit en code
- `get_file_size(file_path)` — Taille en octets d'un fichier : celle de la sauvegarde qui vient d'être écrite
- `file_exists(file_path)` — Vrai si le chemin désigne un fichier existant, `false` sans erreur s'il n'existe pas. Lue avant la fenêtre de restauration : une sauvegarde déplacée ou supprimée renvoie au choix manuel d'un fichier
### `balance_commands.rs` — Bilan (2)
- `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 (résolu par `profile_db_path` ; sous Linux, l'ancien chemin `app_data_dir()` faisait échouer le calcul, #390), 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).
- `fetch_price(symbol, date)` — Cours d'un titre à une date (`YYYY-MM-DD`) par `maximus-api` (`GET /v1/prices`, [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md)) : lit le jeton d'activation du dossier de données et l'envoie en `Authorization`, avec les seuls en-têtes `Accept` et `User-Agent` (posé sur le client). Rend `PriceResponse`, ou une `FetchPriceError` sérialisée en JSON (`{"code":"rate_limit","retry_after_s":42}`…) : `auth` (sans jeton, ou sur un 401), `premium_required`, `license_revoked` sur un 403 qui le dit (`error.code`), `symbol_not_found`, `rate_limit`, `provider_unavailable`, `network`, `internal`. Sa logique vit dans `fetch_price_in`, sans `AppHandle`, testée dans un dossier temporaire contre un serveur mockito : sur `license_revoked`, le jeton refusé est supprimé s'il n'a pas changé pendant la requête, sans écrire l'état de la clé (#367, voir « Révocation » dans la section licence). Le consentement aux cours n'est vérifié que côté front (`PriceFetchControl`, `PriceFetchConsentToggle`)
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).
## 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 ; confirmations par `ask` (`confirmService.ts`, paquet JS `@tauri-apps/plugin-dialog`, #421) | Sur desktop, son script d'initialisation remplace `window.confirm` par une fonction asynchrone et `window.alert` par une fonction non bloquante : jamais de `confirm` / `alert` natif (garde `native-dialog-guard.test.ts`) ; paquet JS à la version du crate |
| `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)]` ; compilé sans ses features par défaut (`rustls-tls` seule, #455) : aucune extraction d'archive sous Windows et Linux |
| `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` appelle `startProfileConnection` (`src/utils/startupConnection.ts`), qui applique un timeout de 10 secondes (`STARTUP_TIMEOUT_MS`) sur `connectActiveProfile()` et réessaie trois fois — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue ; une ouverture qui aboutit après le délai efface l'erreur et affiche l'application (#395, mesure dans l'ADR 0021)
- **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
- **Préparation du profil** : `prepare_profile_db` (Rust/rusqlite) rattrape les migrations d'un profil secondaire et répare les drapeaux texte avant le chargement ; un échec est écrit au Journal, le profil s'ouvre quand même (#390)
- **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 ; le bouton « Vérifier la hiérarchie » ouvre `HierarchyCheckModal` (#393) |
| `/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`), journaux + commentaires (`LogViewerCard`), puis historique des versions (`ChangelogContent`), dans cet ordre : le Journal ne descend plus sous l'historique, qui s'allonge à chaque release (#372). `ChangelogContent` n'est monté qu'une fois `getVersion()` résolu (ou en échec : version inconnue, écrite au Journal) ; il ouvre les notes de la version installée et replie les autres, chaque titre de version étant un bouton (`aria-expanded`, chevron), avec un bouton « Tout déplier / Tout replier » ; l'en-tête non publié (« Unreleased », « Non publié ») est masqué quand il est vide. La carte Système de `SettingsHomePage` liste ces sections dans le même ordre |
| `/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.)
- **Formateur `lowercase`** (`src/i18n/config.ts`) : `{{valeur, lowercase}}` met en minuscules, selon la langue, un libellé interpolé dans une phrase depuis sa propre clé (le filtre « Non catégorisées » dans la carte de démarrage de la page Transactions, #399). La phrase décide de la casse, pas le code.
## Tests (vitest sans jsdom)
Le dépôt n'a pas de jsdom : les tests rendent les composants par `renderToStaticMarkup`, ou appellent hooks et composants comme des fonctions pour lire l'arbre rendu et les props. Un rendu statique ne lance aucun effet et aucun clic ; ce qu'un composant fait avec React entre deux rendus passe par un faux module `react`.
### Harnais de hooks — `src/test/hookHarness.ts` (#409)
Un seul harnais pour tous les tests qui simulent React. Motif d'import, unique, en tête du fichier de test :
```ts
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
import { hookHarness } from "../test/hookHarness";
const harness = hookHarness();
```
Puis `harness.clear()` dans `beforeEach` (un montage neuf), `harness.render(() => Composant(props))` pour un rendu du composant monté, `harness.commit()` pour jouer ses effets. Les valeurs que le test change entre deux rendus (route, valeur de contexte) vivent dans un objet `vi.hoisted` du test. `hookHarness.ts` n'importe React qu'en `import type` : un import de valeur, depuis la fabrique qui construit le faux `react`, chargerait ce faux.
Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
- emplacements par ordre d'appel, curseur remis à zéro à chaque `render()` ; un test peut prérégler l'état d'un `useState` ou d'un `useReducer` avant le premier rendu ;
- `useState` : initialiseur appelé une fois, setter stable, mises à jour fonctionnelles ; **aucun nouveau rendu automatique**, le test rend de nouveau quand il veut lire l'état ;
- `useReducer` : le reducer est appliqué, `dispatch` est stable, et chaque action est aussi enregistrée par l'espion `harness.dispatch` ;
- `useRef` : un objet par emplacement, gardé d'un rendu à l'autre ;
- `useMemo` / `useCallback` : recalculés seulement quand une dépendance change (`Object.is`) ;
- `useEffect` : capturé pendant le rendu, joué seulement par `commit()` s'il a une dépendance changée, nettoyages d'abord ; `replayEffects()` joue le double passage de StrictMode au montage, `unmount()` les nettoyages ;
- tout autre hook (`useContext`, `useLayoutEffect`, `useSyncExternalStore`, `useId`, `use`…) **lève** : un test rougit sur son assertion, jamais par « Invalid hook call ». Mocker le module qui l'appelle (`useOnboarding`, `useLocation`, `useTranslation`…).
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit), et un reducer qui change d'un rendu à l'autre : le `dispatch` d'un `useReducer` garde le reducer du premier rendu (il est créé une fois, avec l'emplacement), là où React applique celui du dernier rendu. Un reducer défini au niveau du module, seul cas utilisé, n'est pas touché.
Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants ; `useImportWizard.samples.test.ts` (« Créer un dossier d'exemple », #384) passe par le harnais partagé.
### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
**Jamais de `t` brut dans un test.** Un faux `t` qui rend la clé telle quelle cache un appel à `t` oublié : un composant qui affiche `license.removeKey.confirmValid` au lieu du texte traduit passe toute la suite. Dans les fichiers de test, le seul faux `t` est `markedT` (`src/test/i18nMock.ts`), servi par la fabrique `i18nMock` ou passé tel quel à une fonction qui prend `t` en paramètre (par exemple `renderCategoryLabel`) : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. La garde ci-dessous ne refuse que les formes qu'elle sait reconnaître : un faux `t` écrit autrement lui échapperait. Motif unique, en tête du fichier de test :
```ts
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
```
Options de la fabrique : `language` (« fr » par défaut ; une fonction est relue à chaque accès, pour un test qui change de langue entre deux rendus : `i18nMock({ language: () => language })`) et `fixture` (valeurs rendues telles quelles pour certaines clés, comme ce que déclare la locale, un objet sous `returnObjects` par exemple ; une valeur lue par la fabrique au chargement du module se déclare par `vi.hoisted`). Une fonction qui prend `t` en paramètre reçoit `markedT` lui-même.
Les assertions qui nomment un texte affiché s'écrivent sous la forme marquée (`toContain(">t(dashboard.title)<")`, `toEqual(["t(onboarding.state.done)"])`) : elles rougissent sur une chaîne qui a sauté `t`. Une assertion par sous-chaîne sur une clé nue reste valide, la clé figurant dans la marque, mais ne prouve pas l'appel à `t`. Dans une expression régulière, échapper les parenthèses et crochets de la marque.
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche).
### Portails (#429)
`renderToStaticMarkup` ne rend pas un portail (« Portals are not currently supported by the server renderer »), et l'environnement node de vitest n'a pas de `document` (sans élément DOM, `createPortal` lève « Target container is not a DOM element »). Une fenêtre rendue par `createPortal(…, document.body)` (`LinkTransfersModal`, et de même `StarterAccountsModal`, `DetailAccountWizard`…) ne se teste donc qu'en remplaçant `createPortal` par l'identité (`vi.mock("react-dom", …)`, le reste du module gardé) et en posant un `document` factice (`vi.stubGlobal`), puisque `document.body` est lu avant l'appel : modèle dans `LinkTransfersModal.render.test.tsx` et, sous le harnais, `LinkTransfersModal.wiring.test.ts`.
`CategoryCombobox` rend sa liste ouverte dans un portail sur `document.body`, en position fixe au-dessus de toutes les fenêtres (`z-[300]`) : aucun conteneur défilant ne la coupe. Fermé, il ne rend que `<div class="relative"><input …/></div>`, sans portail, et son rendu statique n'en demande rien. La règle de placement (sous le champ, au-dessus quand la place manque en dessous et suffit au-dessus) et les écouteurs qui gardent la liste sur son champ pendant qu'elle est ouverte (`scroll` en capture, `resize`, retirés avec elle) sont des fonctions pures de `src/components/shared/placeListbox.ts`, testées sans DOM. Le composant ouvert, lui, ne se rend pas sans DOM : `renderToStaticMarkup` le rend fermé et le harnais refuse ses `useId` et `useLayoutEffect` ; le reste (portail, clic dans la liste, clavier) se vérifie dans l'application. La liste n'étant plus dans le DOM de son conteneur, un écouteur natif de clic extérieur posé par un conteneur la verrait dehors : celui du combobox la compte comme intérieure.
### Limites connues (#437)
- **Emplacements préréglés du harnais.** Le harnais donne un emplacement à chaque appel de hook, tous types confondus (`useState`, `useReducer`, `useRef`, `useMemo`, `useCallback`, `useEffect`), dans l'ordre des appels : c'est le helper `slot` de `fakeReact`. Un test qui prérègle un emplacement par sa position (`harness.slots[0] = …`) dépend donc de l'ordre des hooks du composant : un hook ajouté avant, de n'importe quel type, décale les emplacements sans rien dire, et la valeur préréglée tombe sur un autre hook. Préréglages actuels, chacun commenté dans son fichier : `PriceFetchConsentToggle.test.tsx` (emplacements 0 à 2), `PriceFetchControl.test.tsx` (la fenêtre de consentement ouverte), `useOnboardingChecklist.wiring.test.ts` (`mountWith` et deux écritures directes) et `SystemsSettingsPage.test.tsx` ; `OnboardingStepCard.test.tsx` lit le `useRef` de l'emplacement 0. Qui ajoute un hook avant ces emplacements met le test à jour. Un test qui ouvre un état par un clic dans l'arbre rendu (`TransactionTable.wiring.test.ts`) n'a pas cette dépendance.
- **`withoutFolders`.** Ses arrêts et ses deux limites (chemin relatif sans début reconnu ; nom de dossier qui contient l'un des arrêts) sont dans l'[ADR 0021](adr/0021-migrations-profils-secondaires.md), § 7.
- **Mesure du rattrapage.** Le test `#[ignore]` `a_large_profile_catch_up_duration` (`profile_db.rs`) ne tourne pas en CI et n'asserte rien : il imprime une durée. Sa mesure, prise sur un SSD NVMe, et le risque qu'elle ne couvre pas (disque lent, antivirus qui inspecte la copie) sont dans l'[ADR 0021](adr/0021-migrations-profils-secondaires.md), « Négatives et points de vigilance », point « Le délai de démarrage couvre le rattrapage du profil actif ».
## 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 et chaque semaine
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check --locked` puis `cargo test --locked`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`). Avec `--locked` ([#449](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/449)), un `Cargo.lock` qui ne correspond plus à `Cargo.toml` (édition à la main, conflit mal résolu, dépendance ajoutée sans son lock) fait échouer le job à `cargo check` au lieu d'être réécrit dans le checkout : les deux gardes et l'audit qui suivent lisent le lock commité.
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 (`webkit2gtk`, la webview Linux de Tauri, présente sous Linux et absente sous Windows ; `tar` jusqu'à #455) garantit que son silence prouve quelque chose, et que `--target` résout bien les dépendances conditionnelles. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
Juste après, une seconde étape **bloquante**, `Verify windows-sys pinning` ([#345](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/345)), lit `src-tauri/Cargo.lock` avec `awk`. Les sept crates qui déclarent `windows-sys` par une borne couvrant plusieurs mineures (`colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile`, `winapi-util`) doivent tous pointer `0.61.2`, l'état de chaque release Windows depuis v0.14.0, et l'ensemble des versions de `windows-sys` du lock ne doit pas changer. Une mise à jour comme `cargo update -p hyper` re-résout ces arêtes sans que cargo l'annonce, et le build Windows compile alors cinq versions de `windows-sys` au lieu de trois. Chaque vérification est loggée avec un bilan, un canari (`tempfile`) doit être lu et vérifié, et une panne d'`awk` fait échouer l'étape. Quand elle rougit, on remet les arêtes à la main : procédure et condition de sortie dans l'[ADR 0023](adr/0023-windows-sys-pinning.md).
Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que produit `/autopilot` — déclenche donc bien la CI.
**Cache** ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)) : `Swatinem/rust-cache` garde `~/.cargo` et les dépendances compilées de `src-tauri/target`, sous une clé faite de la version de rustc, des variables `CARGO*` et `RUST*` du job, des dépendances déclarées dans les manifestes et le lockfile Cargo (la version de l'app n'en fait pas partie), et du job. Il passe par le proxy de cache du runner, sur le port 28100 qu'ufw n'ouvre qu'aux réseaux Docker des jobs (procédure et retour arrière dans `docs/forgejo-ops.md` de la-compagnie-maximus). Le runner (v12.7.0) garde ce qu'écrit un run de PR pour cette seule PR : ses pushes suivants le relisent, les autres PR non. Le run du lundi et `workflow_dispatch` sont donc les seuls à écrire le cache partagé, sur lequel une PR se rabat quand rien ne correspond dans sa propre portée. Le runner supprime une entrée inutilisée depuis 7 jours ou créée il y a plus de 30 jours, et ne garde qu'une entrée par clé, toutes portées confondues. Un run à froid est donc normal dans les cas suivants ; seul un délai dépassé signale un retour de #234 :
- après une nouvelle version stable de Rust ou une modification de l'`env:` du job, jusqu'au lundi suivant ;
- quand une PR sauvegarde sous la clé de l'entrée partagée : elle l'évince pour toutes les PR jusqu'au lundi suivant. Ça arrive quand sa propre portée contient une entrée plus ancienne pour un autre `Cargo.lock`, servie avant l'entrée partagée, par exemple une PR qui a monté une dépendance puis est revenue au lockfile de `main`. Deux PR ouvertes sur un même lockfile, différent de celui de `main`, s'évincent l'une l'autre ;
- quand une entrée qui n'a eu que des correspondances exactes, donc jamais re-sauvegardée, atteint 30 jours ;
- un lundi après une semaine sans requête au cache, la restauration peut échouer en 404 : la recherche ne rafraîchit pas l'entrée, et le ménage qu'elle déclenche peut la supprimer ([forgejo/runner#1741](https://code.forgejo.org/forgejo/runner/issues/1741)). Le run repart alors à froid et re-sauvegarde.
### `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, du markdown, `check-rust.yml` ou `audit.yml` (`paths-ignore`). `.claude/**` et `release.yml` le déclenchent ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)) : `scripts/build-latest-json.test.mjs` lit la liste de clés de `verify-release.sh` et exécute le texte de l'étape « Publish latest.json to package registry ». Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Installe aussi `jq`, dont a besoin `scripts/build-latest-json.test.mjs` : ce test lance le script de génération de `latest.json` de `release.yml` ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). Pas de cache npm : ce workflow ne tourne que sur des PR, donc un cache ne servirait qu'aux pushes suivants d'une même PR, et `npm ci` y prend 9 s.
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. `npm audit` est à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), après un rafraîchissement du lock dans les bornes de `package.json`. Ce 0 est un constat daté, pas un état garanti : aucun job ne le fait échouer, mais l'étape `npm ci` de `check-frontend` audite à chaque run et en imprime le compte (`found 0 vulnerabilities`, ou le nombre trouvé), et le Défenseur relève les advisories de son côté (#358 en vient). `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 et une fois par semaine, avec un `cargo audit` non bloquant, 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*`. Un seul job, `build-and-release`, dans un conteneur `ubuntu:22.04` :
1. Linux → `.deb` + `.rpm`
2. Windows, compilé depuis Linux par `cargo-xwin` → installeur `.exe` (NSIS)
Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater (`latest.json`) écrit par `scripts/build-latest-json.sh` à partir des assets ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). `tauri-plugin-updater` cherche `{os}-{arch}-{installer}`, puis `{os}-{arch}` : le fichier porte `linux-x86_64-deb` et `linux-x86_64-rpm` (une clé par paquet Linux) et `windows-x86_64` (l'installeur NSIS). Pas de clé générique `linux-x86_64` depuis le 2026-10-11 : elle servait le `.deb` à des installations qui ne savaient pas l'installer (anciennes AppImage, binaire hors paquet), qui reçoivent désormais `TargetsNotFound` à la vérification (raisons et lecteurs dans `docs/qa-update-cycle.md`). Le script échoue s'il ne trouve pas exactement un `.deb`, un `.rpm` et un `-setup.exe`, chacun avec un `.sig` non vide ; le JSON vient de `jq` seul (notes lues comme données par `--rawfile`), avec les noms de fichiers encodés par `@uri`, comme à l'envoi des assets. Testé par `scripts/build-latest-json.test.mjs` (vitest, lancé par `check-frontend`), qui l'exécute sous `bash -e -o pipefail` comme `release.yml`.
- JSON publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` (registre de paquets, endpoint de `tauri.conf.json`). Avant le `DELETE` de l'ancienne version, le filtre `scripts/check-latest-json.jq` (version égale au tag, exactement les trois clés, signatures non vides, extension de chaque URL) arrête le job si le fichier ne le passe pas : le registre reste intact. Il est appelé avec `-s` et n'accepte qu'un seul document, si bien qu'un fichier vide ou à deux documents est refusé sous jq 1.6 comme sous 1.7 ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)). Le test du script applique ce même filtre à chaque PR, compare sa liste de clés à celles du script et de `verify-release.sh`, et exécute le texte de l'étape « Publish » avec un `curl` factice (`DELETE` puis `PUT` sur un manifeste valide, aucun appel sur un manifeste refusé), si bien qu'un écart se voit avant le tag.
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md, transmises par `GITHUB_OUTPUT` sous un délimiteur aléatoire (une ligne du CHANGELOG égale à un délimiteur fixe terminait la valeur)
**Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`).
**Lock commité, cargo-xwin épinglé** ([#449](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/449)). Les deux `npx tauri build` passent `-- --locked` : la CLI de Tauri transmet ce qui suit `--` à son runner, cargo pour Linux, cargo-xwin pour Windows, qui le passe à `cargo build`. Les binaires livrés sont donc bâtis sur le `Cargo.lock` commité, ou le build échoue au lieu de le réécrire ; l'étape 2 de `/release`, qui édite le lock à la main sur `main` sans PR, vérifie ce lock par `cargo metadata --locked` avant le tag. Le journal du run n'imprime jamais la ligne de commande de cargo : la preuve que le drapeau arrive est un lock désaccordé qui fait échouer le build, faite en local dans la PR de #449 pour les deux runners (cargo-xwin 0.23.1 lance `cargo build … --locked …`, et les autres appels à cargo de la CLI et de cargo-xwin sont des `cargo metadata --no-deps`, qui ne touchent pas au lock). cargo-xwin, qui tourne avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement, est installé en version fixe (`cargo install --locked cargo-xwin --version 0.23.1`, celle qu'avait installée la release v0.17.2), et l'étape affiche `cargo-xwin --version` ; une nouvelle version se décide, après lecture de ce qu'elle change.
## 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 |
| [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted |
| [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted |
| [0022](adr/0022-canal-evenements-donnees.md) | Canal d'événements de données : les écritures signalent, le parcours de démarrage relit | 2026-10-08 | Accepted |
| [0023](adr/0023-windows-sys-pinning.md) | Épinglage de `windows-sys` 0.61.2 : garde-fou CI sur la version résolue, condition de sortie écrite | 2026-10-10 | Accepted |
| [0024](adr/0024-serveur-mcp.md) | Serveur MCP : un mode `--mcp` du binaire, stdio seul, accès par profil, Premium | 2026-10-12 | Proposed |