# Architecture technique — Simpl'Résultat > Document mis à jour le 2026-08-13 — Version 0.14.x (format d'import persisté, migration v17) ## Stack technique | Couche | Technologie | Version | |--------|------------|---------| | Framework desktop | Tauri | v2 | | Frontend | React | 19.1 | | Langage frontend | TypeScript | 5.8 | | Bundler | Vite | 6.4 | | CSS | Tailwind CSS | v4 | | Backend | Rust (via Tauri) | stable | | Base de données | SQLite (tauri-plugin-sql) | — | | Graphiques | Recharts | 3.7 | | Icônes | Lucide React | 0.563 | | i18n | i18next + react-i18next | 25.8 / 16.5 | | Drag & Drop | @dnd-kit | 6.3 / 10.0 | | CSV | PapaParse | 5.5 | | Chiffrement | aes-gcm (Rust) | 0.10 | | Hachage PIN | Argon2 (Rust) | 0.5 | ## Structure du projet ``` simpl-resultat/ ├── src/ # Frontend React/TypeScript │ ├── components/ # 58 composants organisés par domaine │ │ ├── adjustments/ # 3 composants │ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow) │ │ ├── budget/ # 5 composants │ │ ├── categories/ # 5 composants │ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist) │ │ ├── import/ # 15 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice et ImportEmptyState) │ │ ├── layout/ # AppShell, Sidebar │ │ ├── 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/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState) │ │ └── transactions/ # 5 composants │ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) │ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine) │ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── services/ # 25 services métier (19 détaillés dans la section « Services TypeScript ») │ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts) │ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts) │ ├── i18n/ # Config i18next + locales FR/EN │ ├── __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/ # 6 modules de commandes Tauri │ │ │ ├── fs_commands.rs │ │ │ ├── export_import_commands.rs │ │ │ ├── profile_commands.rs │ │ │ ├── license_commands.rs │ │ │ ├── auth_commands.rs │ │ │ └── entitlements.rs │ │ ├── database/ # Schémas SQL et migrations │ │ │ ├── schema.sql │ │ │ ├── seed_categories.sql │ │ │ └── consolidated_schema.sql │ │ ├── lib.rs # Point d'entrée, migrations, plugins │ │ └── main.rs │ ├── capabilities/ # Permissions Tauri │ └── Cargo.toml ├── .forgejo/workflows/ # CI/CD (hôte primaire) │ ├── check-rust.yml │ ├── check-frontend.yml │ ├── audit.yml │ └── release.yml ├── .github/workflows/ # Miroir GitHub (dormant) ├── docs/ # Documentation technique └── config/ # Configuration ``` ## Base de données ### Tables (20) | Table | Description | |-------|-------------| | `import_sources` | Configuration des sources d'import CSV. Porte le **format d'import en entier** depuis v17 : `amount_mode` (`CHECK ∈ {single, debit_credit, absolute_indicator}`) et `sign_convention` (`CHECK ∈ {negative_expense, positive_expense}`) s'ajoutent aux réglages mécaniques (délimiteur, encodage, format de date, `column_mapping`), plus `header_signature` (libellés normalisés du dernier import réussi, détection de dérive) et `template_id` (**étiquette de provenance**, `ON DELETE SET NULL`, jamais relue comme format) — voir [ADR 0019](adr/0019-format-import-persiste.md) et la section « Import CSV » | | `imported_files` | Suivi des fichiers importés (hash anti-doublons) | | `categories` | Catégories hiérarchiques (dépenses/revenus) | | `suppliers` | Fournisseurs avec auto-catégorisation | | `keywords` | Mots-clés pour catégorisation automatique | | `transactions` | Transactions individuelles | | `adjustments` | Ajustements manuels (ponctuels ou récurrents) | | `adjustment_entries` | Montants par catégorie pour chaque ajustement | | `budget_entries` | Allocations budgétaires mensuelles par catégorie | | `budget_templates` | Modèles de budget réutilisables | | `budget_template_entries` | Catégories et montants dans les modèles | | `import_config_templates` | Modèles prédéfinis de config d'import | | `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`, `onboarding_checklist_hidden` (« 1 » quand la liste de démarrage a été masquée ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352)… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) | | `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) | | `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` | | `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer | | `balance_snapshot_lines` | Une ligne par `(snapshot, compte)` — **source de vérité agrégée**. `simple` : `value` seul. `priced`/`detailed` : la ligne porte la valeur **totale** (`value = SUM(holdings.value)`, `quantity`/`unit_price` NULL pour un compte détaillé), le détail vit dans `balance_snapshot_holdings`. Les agrégateurs et Modified Dietz lisent uniquement cette `value` ([ADR 0015](adr/0015-balance-detail-par-titre.md)) | | `balance_account_transfers` | Liaison `transactions ↔ balance_accounts` avec `direction ∈ {in, out}`. Utilisée par le calcul Modified Dietz pour séparer apports et gains | | `balance_securities` | Table normalisée et **partagée** des titres (v14, [ADR 0015](adr/0015-balance-detail-par-titre.md)) : `symbol` UNIQUE `COLLATE NOCASE` (canonique upper/trim), `currency` (DEFAULT `CAD`, préparé multi-devise), `asset_type ∈ {stock, crypto}`, `name?`. Référencée en `ON DELETE RESTRICT` → un titre référencé est immortel (suppression masquée UI) | | `balance_snapshot_holdings` | Détail par titre d'un compte détaillé, rattaché à sa **ligne de snapshot agrégée** (v14, [ADR 0015](adr/0015-balance-detail-par-titre.md)) : `snapshot_line_id` (FK CASCADE), `security_id` (FK RESTRICT), `quantity`, `unit_price`, `value` (= qty × prix, arrondi cent), `book_cost?` (gain latent = `value − book_cost`), `price_source?`, `price_fetched_at?`. `UNIQUE(snapshot_line_id, security_id)` | ### Index (24) Index existants (15) : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source). Index Bilan (9) — 7 ajoutés en v9 : - `idx_balance_accounts_category` (FK lookup catégorie → comptes) - `idx_balance_accounts_active` partiel `WHERE is_active = 1` (filtre liste active) - `idx_balance_snapshot_lines_snapshot` (chargement d'un snapshot) - `idx_balance_snapshot_lines_account` (historique par compte) - `idx_balance_account_transfers_account` (cash flows Modified Dietz par compte) - `idx_balance_account_transfers_transaction` (lookup icône d'attribution dans `TransactionTable`) - `idx_balance_snapshots_date` (sélecteur de période + agrégation chronologique) 2 ajoutés en v14 (Étape 2 — détail par titre) : - `idx_balance_snapshot_holdings_line` (chargement des positions d'une ligne de snapshot) - `idx_balance_snapshot_holdings_security` (FK lookup titre → positions, garde `ON DELETE RESTRICT`) ### Invariants Bilan (CHECK + FK) - `balance_categories.kind` ∈ `('simple','priced')` (défaut suggéré pour les nouveaux comptes ; l'axe agrégé/détaillé est désormais porté par `balance_accounts.kind`) - `balance_accounts.currency = 'CAD'` (verrou MVP — v2 lèvera ce CHECK avec table de taux) - `balance_accounts.vehicle_type` ∈ `('unregistered','tfsa','rrsp','rrif','fhsa','resp')` ou NULL (enveloppe fiscale, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) - `balance_accounts.kind` ∈ `('simple','detailed')` (v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — un compte `detailed` à/après `detailed_since` doit porter des holdings (validation TS `validateDetailedSnapshot`, pivot faisant autorité) - `balance_snapshot_lines` : `(quantity, unit_price)` doivent être tous deux NULL (kind simple) OU tous deux NOT NULL (kind priced) ; pour un compte `detailed`, la ligne agrégée porte `value = SUM(holdings.value)` (comparaison exacte au cent), `quantity`/`unit_price` NULL - `balance_securities.symbol` UNIQUE `COLLATE NOCASE` ; `asset_type` ∈ `('stock','crypto')` - `balance_snapshot_holdings` : `UNIQUE (snapshot_line_id, security_id)` (un titre une seule fois par ligne) - `balance_account_transfers.direction` ∈ `('in','out')` ; UNIQUE `(transaction_id, account_id)` (une transaction ne peut pas être liée deux fois au même compte) - FK `balance_accounts.balance_category_id` → `balance_categories(id)` `ON DELETE RESTRICT` (empêche suppression de catégorie avec comptes liés) - FK `balance_snapshot_lines.snapshot_id` → `balance_snapshots(id)` `ON DELETE CASCADE` (supprimer un snapshot supprime ses lignes) - FK `balance_snapshot_lines.account_id` → `balance_accounts(id)` `ON DELETE RESTRICT` (préserve l'historique) - FK `balance_snapshot_holdings.snapshot_line_id` → `balance_snapshot_lines(id)` `ON DELETE CASCADE` (supprimer une ligne/snapshot emporte ses holdings) - FK `balance_snapshot_holdings.security_id` → `balance_securities(id)` `ON DELETE RESTRICT` — un titre référencé est immortel (préserve l'historique, miroir de la règle transferts), voir [ADR 0015](adr/0015-balance-detail-par-titre.md) - FK `balance_account_transfers.account_id` → `balance_accounts(id)` `ON DELETE CASCADE` - FK `balance_account_transfers.transaction_id` → `transactions(id)` `ON DELETE RESTRICT` — décision structurante pour la reproductibilité Modified Dietz, voir [ADR 0010](adr/0010-fk-restrict-balance-transfers.md) ## Système de migrations Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plugin_sql::Migration` : | # | Version | Description | |---|---------|-------------| | 1 | v1 | Schéma initial (13 tables) | | 2 | v2 | Seed des catégories et mots-clés | | 3 | v3 | Ajout `has_header` sur `import_sources` | | 4 | v4 | Ajout `is_inputable` sur `categories` | | 5 | v5 | Création de `import_config_templates` | | 6 | v6 | Changement contrainte unique `imported_files` (hash → filename) | | 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) | | 8 | v8 | Migration de catégories (cf. release 0.8.x) | | 9 | v9 | Schéma Bilan : 5 tables + 7 index + seed des 7 catégories standard (cash, TFSA, RRSP, fund, stock, crypto, other) | | 10 | v10 | Ajout `asset_type` sur `balance_categories` (stock/crypto) + backfill des 2 catégories cotées | | 11 | v11 | Nettoyage des snapshots Bilan orphelins | | 12 | v12 | Étape 1 : `balance_accounts.vehicle_type` (+ CHECK, backfill ex-CELI/REER) + `balance_categories.custom_label` (+ backfill défensif du bug i18n) — [ADR 0014](adr/0014-balance-vehicule-attribut.md) | | 13 | v13 | Étape 1 : reclasse les comptes ex-tfsa/rrsp vers « Autres », désactive les seeds enveloppes (idempotente) — [ADR 0014](adr/0014-balance-vehicule-attribut.md) | | 14 | v14 | Étape 2 : `balance_securities` + `balance_snapshot_holdings` + 2 index (additive) — [ADR 0015](adr/0015-balance-detail-par-titre.md) | | 15 | v15 | Étape 2 : `balance_accounts.kind` (`simple`/`detailed`) + `detailed_since` + backfill depuis `category.kind` (`priced` → `detailed`) — [ADR 0015](adr/0015-balance-detail-par-titre.md) | | 16 | v16 | Étape 2 : conversion des comptes cotés existants en détaillés 1-position (security + holding miroir, gardée anti-perte, idempotente) — [ADR 0015](adr/0015-balance-detail-par-titre.md) | | 17 | v17 | Import : `import_sources.amount_mode` + `sign_convention` (les deux avec `CHECK`) + `header_signature` + `template_id` (FK `ON DELETE SET NULL`), backfill `amount_mode = 'debit_credit' WHERE column_mapping LIKE '%debitAmount%'` (additive, aucun changement de comportement observable) — [ADR 0019](adr/0019-format-import-persiste.md) | v17 est purement additive : elle n'ajoute **ni table ni index** (20 tables / 24 index inchangés). Le `CHECK` d'`amount_mode` admet `absolute_indicator` dès maintenant, pour que le troisième mode de montant puisse être livré sans nouvelle migration, alors que l'application le **refuse** encore explicitement (voir plus bas). `LIKE` plutôt que `json_extract` : la migration ne dépend d'aucune extension JSON1 dans le SQLite embarqué. `sign_convention` n'est délibérément pas backfillée — son `DEFAULT` restitue la valeur que le code codait en dur, la seule convention passée inférable. Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations). ## Services TypeScript (19) | Service | Responsabilité | |---------|---------------| | `db.ts` | Wrapper de connexion (tauri-plugin-sql) | | `profileService.ts` | Gestion des profils | | `categoryService.ts` | CRUD catégories hiérarchiques | | `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `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` | Liste de démarrage (#351) : `getOnboardingCounters` 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 et la préférence `onboarding_checklist_hidden` ; `hideChecklist` / `showChecklist` posent et retirent la préférence. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | | `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) | | `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) | | `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) | ### Service Bilan — `balance.service.ts` Un seul service par convention projet (1 service par domaine, splitter seulement > ~400 lignes). Quatre sections logiques distinctes : 1. **CRUD catégories + comptes + titres** — `listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount` (garde `detailed → simple` refusée si des holdings existent, erreur typée), `archiveBalanceAccount`. **Securities (Étape 2)** : `listSecurities`, `getSecurity`, `findOrCreateSecurity` (UPSERT sur symbol normalisé upper/trim, `asset_type` requis), `updateSecurity`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, `account_kind_detailed_has_holdings`, etc.). 2. **Snapshots + lines + holdings** — `listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne). **Save détaillé (Étape 2)** : pour un compte `detailed`, la ligne agrégée (value = somme des holdings) **et** ses holdings sont écrits dans la **même transaction** (`BEGIN/COMMIT`), `value` recalculée = `SUM(holdings.value)` (chaque holding arrondi au cent, comparaison exacte). `validateLineKindInvariants` (simple, inchangé, tolérance `PRICED_VALUE_TOLERANCE = 0.01`) + nouvelle passe `validateDetailedSnapshot(account.kind, line, holdings)` (detailed + holdings ⇒ ligne agrégée ET `value = SUM` ; detailed pré-pivot ⇒ agrégé toléré). `getHoldingsForLatestSnapshot` (pré-remplissage : titres + qty + book_cost reportés, qty-0 exclus), `listHoldingsBySnapshotLine` (drill-down), `computeUnrealizedGain` (gain latent `value − book_cost` en valeur + %, garde-fou `book_cost = 0`/NULL → « N/A », agrégeable par classe/enveloppe). `deleteSnapshot`. 3. **Returns + transfers** — `linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`). 4. **Prices** — *(Phase 5, livraison reportée à l'Issue #143)*. La forme prévue : `fetchPrice(symbol, date)` invoquant `fetch_price` (Tauri), avec rate-limit client (1/2s), backoff exponentiel et dedup in-flight. Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy. Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`). ## Hooks (25+) Chaque hook encapsule la logique d'état via `useReducer` : | Hook | Domaine | |------|---------| | `useCategories` | Catégories avec hiérarchie | | `useTransactions` | Transactions et filtrage | | `useDataImport` | Import de données | | `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md) | | `useImportHistory` | Historique des imports | | `useAdjustments` | Ajustements | | `useBudget` | Budget | | `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte | | `useOnboardingChecklist` | Liste de démarrage (#351) : reducer exporté (`onboardingChecklistReducer`, états `loading` / `ready` / `error`), charge le snapshot d'`onboardingService` au montage et le passe à `computeChecklist` avec `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `hide()` / `show()` / `refresh()`. Un échec de lecture garde les compteurs précédents et laisse `hidden` intact — une lecture ratée ne ressuscite jamais une liste masquée ; le Dashboard rend alors la page sans liste et journalise | | `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 `` (#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 `` (#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 | | `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) | ### Hook transverse — `useCollapsibleGroups` `useCollapsibleGroups(storageKey: string | null, accessors, { defaultExpanded })` est un hook **utilitaire d'état UI** (basé `useState`, hors convention `useReducer` par domaine) partagé par toutes les surfaces à hiérarchie repliable : les 3 tableaux de rapports (`ComparePeriodTable`, `BudgetVsActualTable`, `CategoryOverTimeTable`), la grille budget (`BudgetTable`) et les 2 arbres de catégories (`CategoryTree`, `CategoryTaxonomyTree`). Les helpers purs (marche des ancêtres pour la visibilité, polarité du `Set` selon `defaultExpanded`) vivent dans `src/utils/collapsibleRows.ts`. - `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible. - `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler). ## Import CSV — format persisté et détection Le chantier #323-#332 a fait du **format d'import une donnée persistée intégralement, jamais re-devinée** — [ADR 0019](adr/0019-format-import-persiste.md) porte la décision, ses trois voies de perte et les alternatives rejetées. Quatre couches, du schéma à l'écran. ### 1. Le codec — `src/utils/importFormat.ts` Point de conversion **unique** entre `ImportFormatRow` (persisté : snake_case, mapping en JSON, `has_header` normalisé 0/1) et `ImportFormat` (domaine). Il n'existe pas de type composé partagé par les deux porteurs — c'est structurellement impossible (`ImportSource.has_header` est `boolean`, `ImportConfigTemplate.has_header` est `number`), et la garantie de complétude vient du codec et de son test, pas d'une forme commune : - `FORMAT_FIELD_PAIRS: Record` — 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 liste de démarrage Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`). Aucune migration ni table : tout se lit dans les données existantes, et une seule préférence est écrite. **Les quatre états vides** rendent le composant partagé `EmptyState` (`src/components/shared/` : icône, titre, description, action primaire en `` ou `