Simpl-Resultat/docs/architecture.md
le king fu c859405deb
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m4s
fix(release): refuse an empty or multi-document latest.json, test the Publish step
Follow-ups of the review of PR #418.

- check-latest-json.jq wants exactly one document and release.yml calls it
  with -s: under jq 1.6, the release container's, an empty file passed the
  check (no output, status 0) and two documents were judged on the last one,
  so the job would have deleted the published manifest and uploaded the bad
  one. Two tests cover both cases.
- build-latest-json.test.mjs runs the text of release.yml's "Publish
  latest.json to package registry" step against a fake curl: DELETE then PUT
  on a valid manifest, exit 1 and no request on a refused one.
- check-frontend no longer ignores .claude/** and release.yml (decision of
  Max): the test reads verify-release.sh and runs the Publish step, so "on
  every pull request" holds. REVIEW.md, CLAUDE.md and architecture.md follow.
- release.yml: stale ".deb fallback" comment fixed.
- qa-update-cycle.md: a tauri dev build on Linux shows TargetsNotFound,
  expected since #418.

Refs #461

Resolves #419

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

125 KiB
Raw Blame History

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/                 # 26 services métier (20 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/                    # 16 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)
│   ├── 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
│   │   │   ├── 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 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
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)
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). kind ∈ {simple, detailed} + detailed_since (pivot faisant autorité, v15, ADR 0015) — 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)
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) : 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) : 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)
  • balance_accounts.kind ∈ ('simple','detailed') (v15, ADR 0015) — 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
  • 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

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
13 v13 Étape 1 : reclasse les comptes ex-tfsa/rrsp vers « Autres », désactive les seeds enveloppes (idempotente) — ADR 0014
14 v14 Étape 2 : balance_securities + balance_snapshot_holdings + 2 index (additive) — ADR 0015
15 v15 Étape 2 : balance_accounts.kind (simple/detailed) + detailed_since + backfill depuis category.kind (priced → detailed) — ADR 0015
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
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

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, 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 (20)

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
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)
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) : 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)
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 — (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 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. 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. 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). 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)

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). 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 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 && !isLoading, totalInDb via getTransactionCount 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

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. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; 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) 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) : 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). 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, §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.

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 (39)

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

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.

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.

Identifiant machine (#350, ADR 0020) : 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
    • É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)

balance_commands.rs — Bilan (1)

  • compute_account_return(account_id, period_start, period_end, db_filename) — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion rusqlite courte sur le fichier DB du profil actif (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.

Le module privé return_calculator.rs (déclaré dans commands/mod.rs mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests #[cfg(test)] mod tests co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation).

À venir Phase 5 (Issue #143, BLOCKED par maximus-api Phase 2) : commande fetch_price(symbol, date) pour le price-fetching premium via proxy maximus-api. L'architecture est documentée dans l'ADR 0009 ; la livraison est différée jusqu'à ce que le serveur de licences (maximus-api) expose l'endpoint GET /v1/prices.

Plugins Tauri

Ordre d'initialisation dans lib.rs (certains plugins ont des contraintes d'ordre) :

Plugin Rôle Contrainte
tauri-plugin-single-instance Empêche les doubles lancements et forwarde les URLs deep-link au processus existant Doit être le premier plugin ; feature deep-link requise pour le forwarding d'URL
tauri-plugin-opener Ouverture d'URLs externes et de fichiers —
tauri-plugin-dialog Dialogues de sélection de fichier/dossier —
tauri-plugin-process Relaunch après mise à jour —
tauri-plugin-deep-link Gère le scheme custom simpl-resultat:// Doit être initialisé avant setup() pour que on_open_url soit disponible
tauri-plugin-updater Mise à jour auto (gated par entitlement auto-update) Initialisé dans setup() derrière #[cfg(desktop)] ; 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

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
/adjustments AdjustmentsPage Ajustements manuels
/budget BudgetPage Planification budgétaire
/reports ReportsPage Hub des rapports : panneau faits saillants + 4 cartes de navigation
/reports/highlights ReportsHighlightsPage Faits saillants détaillés (soldes, top mouvements, top transactions)
/reports/trends ReportsTrendsPage Tendances (flux global + par catégorie)
/reports/compare ReportsComparePage Comparables (MoM / YoY / Réel vs budget)
/reports/category ReportsCategoryPage Zoom catégorie avec rollup + édition contextuelle de mots-clés
/reports/cartes ReportsCartesPage Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité
/balance BalancePage Bilan — vue d'ensemble : carte "Aujourd'hui" + Δ% + avertissement bilan pas à jour > 60j, graphique d'évolution (toggle ligne / aire empilée par catégorie), tableau des comptes avec rendements multi-horizons (3M / 1A / depuis création — Modified Dietz) côte-à-côte avec rendement non-ajusté
/balance/snapshot SnapshotEditPage Saisie / édition d'un snapshot daté. Mode ?date=today (création) ou ?date=YYYY-MM-DD (édition, date immutable). Lignes groupées par catégorie : simple = champ valeur, priced = quantity × unit_price (value calculé read-only). Bouton "Pré-remplir depuis le snapshot précédent". Suppression à double-confirmation par re-saisie de la date
/balance/accounts AccountsPage CRUD comptes + catégories de bilan (deux onglets). Catégories seedées (is_seed = 1) renommables mais non-supprimables ; refus de suppression d'une catégorie avec comptes liés (FK RESTRICT)
/settings SettingsLayout (layout) + SettingsHomePage (index) Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte TokenStoreFallbackBanner une seule fois, partagé par les 4 routes principales
/settings/users UsersSettingsPage Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis DocsContent)
/settings/data DataSettingsPage Catégories (avec liens vers /settings/categories/standard et /settings/categories/migrate), backup chiffré et confidentialité de la récupération de prix
/settings/systems SystemsSettingsPage Version, mise à jour (UpdateCard), historique des versions (ChangelogContent), journaux + commentaires (LogViewerCard)
/settings/categories/standard CategoriesStandardGuidePage Guide imprimable de la structure de catégories standard (route flat, hors SettingsLayout)
/settings/categories/migrate CategoriesMigrationPage Flux de migration v1→v2 (route flat, hors SettingsLayout)
/docs DocsPage Redirige vers /settings/users (rétrocompatibilité bookmarks)
/changelog ChangelogPage Redirige vers /settings/systems (rétrocompatibilité release notes)

Page spéciale : ProfileSelectionPage (affichée quand aucun profil n'est actif).

Internationalisation

  • Librairie : i18next + react-i18next
  • Langue par défaut : Français (fr)
  • Langue de fallback : Anglais (en)
  • Fichiers : src/i18n/locales/fr.json, src/i18n/locales/en.json
  • Clés organisées hiérarchiquement par domaine (nav.*, dashboard.*, import.*, etc.)
  • 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 :

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.

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 :

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

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 puis cargo test, et un cargo audit informatif (non bloquant, binaire pré-buildé via taiki-e/install-action).

Entre cargo check et cargo test, une étape bloquante rejoue la justification des advisories acceptées dans .cargo/audit.toml : elle vérifie par cargo tree --locked que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (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.

Juste après, une seconde étape bloquante, Verify windows-sys pinning (#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.

Aucun branches: : une PR stackée sur une autre branche de feature — ce que produit /autopilot — déclenche donc bien la CI.

Cache (#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). 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) : 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). 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), 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. Au moment de sa mise en place, le schedule n'avait encore jamais déclenché ce workflow (#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). 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). 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), 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).

Architecture Decision Records (ADRs)

Les ADRs documentent les décisions techniques structurantes. Ils vivent dans docs/adr/.

# Titre Date Statut
0001 Choix de Tauri v2 comme framework desktop 2024-01-01 Accepted
0002 useReducer plutôt que Redux 2024-01-01 Accepted
0003 Migrations SQL inline via tauri-plugin-sql 2024-01-01 Accepted
0004 Chiffrement AES-256-GCM pour l'export 2024-01-01 Accepted
0005 Multi-profils avec bases SQLite séparées 2024-01-01 Accepted
0006 Stockage des tokens OAuth via keychain 2024-01-01 Accepted
0007 Refactorisation du hub de rapports 2024-01-01 Accepted
0008 Modified Dietz pour le calcul de rendement 2025-01-01 Accepted
0009 Proxy price-fetching via maximus-api 2025-01-01 Accepted
0010 FK RESTRICT sur balance_account_transfers 2025-01-01 Accepted
0011 Providers best-effort Yahoo 2026-04-26 Accepted
0012 Modèle à deux niveaux pour le Bilan (véhicules × compositions) 2026-05-01 Rejected
0013 Évaluation provider stocks : Alpha Vantage retenu comme cible 2026-05-09 Accepted
0014 Bilan : le véhicule fiscal est un attribut du compte (Étape 1) 2026-06-01 Accepted
0015 Bilan : détail par titre (holdings par snapshot, Étape 2) 2026-06-06 Accepted
0016 Persistance de l'état UI par profil : repli des catégories dans user_preferences 2026-07-15 Accepted
0017 Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé 2026-07-20 Accepted
0018 Suppression d'advisories non atteignables : admission sur preuve par cible livrée, clé par ID, garde-fou bloquant 2026-07-27 Accepted
0019 Le format d'import est une donnée persistée intégralement, jamais re-devinée 2026-08-13 Accepted
0020 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 Les profils secondaires rattrapent leurs migrations à l'ouverture 2026-10-06 Accepted
0022 Canal d'événements de données : les écritures signalent, le parcours de démarrage relit 2026-10-08 Accepted
0023 Épinglage de windows-sys 0.61.2 : garde-fou CI sur la version résolue, condition de sortie écrite 2026-10-10 Accepted
0024 Serveur MCP : un mode --mcp du binaire, stdio seul, accès par profil, Premium 2026-10-12 Proposed