A license key entered during a session was only checked against the license server at the next start: a revoked key kept its edition until the app restarted, or until a machine activation was refused (#365 checked at start only). Front: performSubmitKey launches the revocation check after VALIDATE_DONE, without waiting for it, and never after a refused key. SubmitKeyDeps gets a single launcher, startRevocationCheck(wasRevoked), built once by the provider with revocationCheckLauncher and shared with the start check, whose starter is unchanged. Rust: the command runs the checks one at a time. A static tauri::async_runtime::Mutex (REVOCATION_CHECK_TURN) is held by check_revocation_in_turn over the whole check, request and verdict included, and the time is read once the turn has come. A key entered before the first ready state, checked by the entry and by the start, sends one request; the check of a key entered while another one is in flight waits for its verdict instead of overwriting license.check. check_revocation_in is unchanged, and so are Cargo.toml and Cargo.lock. Docs: ADR 0017 (amendment #365, point 5), user guide, in-app docs (docs.settings.tips, docs.editions.tips), architecture, and both changelogs under [Unreleased] (Fixed / Corrigé). Resolves #373 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 KiB
Architecture technique — Simpl'Résultat
Document mis à jour le 2026-08-13 — Version 0.14.x (format d'import persisté, migration v17)
Stack technique
| Couche | Technologie | Version |
|---|---|---|
| Framework desktop | Tauri | v2 |
| Frontend | React | 19.1 |
| Langage frontend | TypeScript | 5.8 |
| Bundler | Vite | 6.4 |
| CSS | Tailwind CSS | v4 |
| Backend | Rust (via Tauri) | stable |
| Base de données | SQLite (tauri-plugin-sql) | — |
| Graphiques | Recharts | 3.7 |
| Icônes | Lucide React | 0.563 |
| i18n | i18next + react-i18next | 25.8 / 16.5 |
| Drag & Drop | @dnd-kit | 6.3 / 10.0 |
| CSV | PapaParse | 5.5 |
| Chiffrement | aes-gcm (Rust) | 0.10 |
| Hachage PIN | Argon2 (Rust) | 0.5 |
Structure du projet
simpl-resultat/
├── src/ # Frontend React/TypeScript
│ ├── components/ # 58 composants organisés par domaine
│ │ ├── adjustments/ # 3 composants
│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
│ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
│ │ ├── import/ # 15 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice et ImportEmptyState)
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState)
│ │ └── transactions/ # 5 composants
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 25 services métier (19 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts)
│ ├── i18n/ # Config i18next + locales FR/EN
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
│ ├── App.tsx # Router principal
│ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust
│ ├── src/
│ │ ├── commands/ # 6 modules de commandes Tauri
│ │ │ ├── fs_commands.rs
│ │ │ ├── export_import_commands.rs
│ │ │ ├── profile_commands.rs
│ │ │ ├── license_commands.rs
│ │ │ ├── auth_commands.rs
│ │ │ └── entitlements.rs
│ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql
│ │ │ └── consolidated_schema.sql
│ │ ├── lib.rs # Point d'entrée, migrations, plugins
│ │ └── main.rs
│ ├── capabilities/ # Permissions Tauri
│ └── Cargo.toml
├── .forgejo/workflows/ # CI/CD (hôte primaire)
│ ├── check-rust.yml
│ ├── check-frontend.yml
│ ├── audit.yml
│ └── release.yml
├── .github/workflows/ # Miroir GitHub (dormant)
├── docs/ # Documentation technique
└── config/ # Configuration
Base de données
Tables (20)
| Table | Description |
|---|---|
import_sources |
Configuration des sources d'import CSV. Porte le format d'import en entier depuis v17 : amount_mode (CHECK ∈ {single, debit_credit, absolute_indicator}) et sign_convention (CHECK ∈ {negative_expense, positive_expense}) s'ajoutent aux réglages mécaniques (délimiteur, encodage, format de date, column_mapping), plus header_signature (libellés normalisés du dernier import réussi, détection de dérive) et template_id (étiquette de provenance, ON DELETE SET NULL, jamais relue comme format) — voir ADR 0019 et la section « Import CSV » |
imported_files |
Suivi des fichiers importés (hash anti-doublons) |
categories |
Catégories hiérarchiques (dépenses/revenus) |
suppliers |
Fournisseurs avec auto-catégorisation |
keywords |
Mots-clés pour catégorisation automatique |
transactions |
Transactions individuelles |
adjustments |
Ajustements manuels (ponctuels ou récurrents) |
adjustment_entries |
Montants par catégorie pour chaque ajustement |
budget_entries |
Allocations budgétaires mensuelles par catégorie |
budget_templates |
Modèles de budget réutilisables |
budget_template_entries |
Catégories et montants dans les modèles |
import_config_templates |
Modèles prédéfinis de config d'import |
user_preferences |
Préférences applicatives (clé-valeur) — import_folder, balance_show_returns, balance_starter_proposed, onboarding_checklist_hidden (« 1 » quand la liste de démarrage a été masquée ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352)… et l'état de repli des catégories (clés reports-compare-expanded, reports-bva-expanded, reports-trends-expanded, budget-grid-expanded) : persisté ici par profil plutôt qu'en localStorage, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir ADR 0016 |
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_activepartielWHERE 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 dansTransactionTable)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, gardeON 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é parbalance_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 comptedetailedà/aprèsdetailed_sincedoit porter des holdings (validation TSvalidateDetailedSnapshot, 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 comptedetailed, la ligne agrégée portevalue = SUM(holdings.value)(comparaison exacte au cent),quantity/unit_priceNULLbalance_securities.symbolUNIQUECOLLATE 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
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.
Pour les nouveaux profils, le fichier consolidated_schema.sql contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
Services TypeScript (19)
| Service | Responsabilité |
|---|---|
db.ts |
Wrapper de connexion (tauri-plugin-sql) |
profileService.ts |
Gestion des profils |
categoryService.ts |
CRUD catégories hiérarchiques |
transactionService.ts |
CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed TransactionLinkedToBalanceError) ; getTransactionCount (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) |
importSourceService.ts |
Configuration des sources d'import — lit et écrit le format complet via le codec importFormat.ts (ADR 0019) |
importedFileService.ts |
Suivi des fichiers importés |
importConfigTemplateService.ts |
Modèles de configuration d'import |
categorizationService.ts |
Catégorisation automatique + helpers édition de mot-clé (validateKeyword, previewKeywordMatches, applyKeywordWithReassignment) |
adjustmentService.ts |
Gestion des ajustements |
budgetService.ts |
Gestion budgétaire |
dashboardService.ts |
Agrégation données tableau de bord : getExpensesByCategory (barres classées, accountIds), getDashboardSummary (non consommé depuis #279), deriveNetWorthTile (pur — tuile valeur nette du Bilan, réutilise deriveLandingState) |
reportService.ts |
Génération de rapports : getMonthlyTrends, getCategoryOverTime, getHighlights, getCompareMonthOverMonth, getCompareYearOverYear, getCategoryZoom (CTE récursive bornée anti-cycle), getCartesSnapshot (snapshot dashboard Cartes, requêtes parallèles) |
dataExportService.ts |
Export de données (chiffré). L'enveloppe SREF porte un format_version explicite (SREF_FORMAT_VERSION = 2) et inclut import_sources + import_config_templates depuis #331 ; wipe et restauration sous withTransaction, modèles restaurés avant les sources avec remap de template_id |
userPreferenceService.ts |
Stockage préférences utilisateur |
onboardingService.ts |
Liste de démarrage (#351) : getOnboardingCounters lit en une passe parallèle les cinq compteurs (import_sources, imported_files terminés, transactions is_manually_categorized = 1, budget_entries, transactions), le dossier d'import et la préférence onboarding_checklist_hidden ; hideChecklist / showChecklist posent et retirent la préférence. Aucune UI ici : computeChecklist (utils/onboardingChecklist.ts, pur) transforme le snapshot en six lignes — voir la section « Onboarding » |
logService.ts |
Capture des logs console (buffer circulaire, sessionStorage) |
licenseService.ts |
Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé { ok, value } / { ok, error } ; parseLicenseError lit le JSON {code} de LicenseError et LICENSE_ERROR_I18N_MAP le relie à license.errors.* (#270). Les commandes de clé (store_license, read_license) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. LicenseInfo.revoked porte l'état de révocation que Rust tient pour la clé, et checkLicenseRevocation lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend { outcome, revoked } (#365) |
authService.ts |
OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
balance.service.ts |
Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
Service Bilan — balance.service.ts
Un seul service par convention projet (1 service par domaine, splitter seulement > ~400 lignes). Quatre sections logiques distinctes :
- CRUD catégories + comptes + titres —
listBalanceCategories,createBalanceCategory,updateBalanceCategory,archiveBalanceCategory(refus si comptes liés via FK RESTRICT, refus siis_seed = 1),listBalanceAccounts,createBalanceAccount,updateBalanceAccount(gardedetailed → simplerefusée si des holdings existent, erreur typée),archiveBalanceAccount. Securities (Étape 2) :listSecurities,getSecurity,findOrCreateSecurity(UPSERT sur symbol normalisé upper/trim,asset_typerequis),updateSecurity. Le service garde uneBalanceServiceErrortypée (BalanceErrorCode) pour des messages i18n distincts (currency_unsupported,category_seed_protected,category_has_accounts,account_kind_detailed_has_holdings, etc.). - 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 comptedetailed, la ligne agrégée (value = somme des holdings) et ses holdings sont écrits dans la même transaction (BEGIN/COMMIT),valuerecalculée =SUM(holdings.value)(chaque holding arrondi au cent, comparaison exacte).validateLineKindInvariants(simple, inchangé, tolérancePRICED_VALUE_TOLERANCE = 0.01) + nouvelle passevalidateDetailedSnapshot(account.kind, line, holdings)(detailed + holdings ⇒ ligne agrégée ETvalue = 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 latentvalue − book_costen valeur + %, garde-foubook_cost = 0/NULL → « N/A », agrégeable par classe/enveloppe).deleteSnapshot. - Returns + transfers —
linkTransfer,unlinkTransfer,listAccountTransfers,listAllLinkedTransfersForTooltip(un coup pour laMap.has(txId)consommée par l'icône d'attribution dansTransactionTable),computeAccountReturn(wrapper sur la commande Tauricompute_account_returnqui litdb_filenamedu profil actif vialoadProfiles()). - Prices — (Phase 5, livraison reportée à l'Issue #143). La forme prévue :
fetchPrice(symbol, date)invoquantfetch_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 |
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 |
useImportHistory |
Historique des imports |
useAdjustments |
Ajustements |
useBudget |
Budget |
useDashboard |
Tableau de bord (/) — convergé sur le modèle /reports/cartes (#279) : KPIs+deltas/top movers/adherence budget via getCartesSnapshot sur un mois de référence propre au Dashboard (referenceYear/referenceMonth, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (period/dates custom), filtre compte (accountIds) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
useOnboardingChecklist |
Liste de démarrage (#351) : reducer exporté (onboardingChecklistReducer, états loading / ready / error), charge le snapshot d'onboardingService au montage et le passe à computeChecklist avec useEntitlement("budget") (ligne Budget verrouillée hors Base+, exclue du total). Expose status, checklist, counters (le Dashboard lit transactions pour l'état vide), hidden, budgetReady, hide() / show() / refresh(). Un échec de lecture garde les compteurs précédents et laisse hidden intact — une lecture ratée ne ressuscite jamais une liste masquée ; le Dashboard rend alors la page sans liste et journalise |
useReportsPeriod |
Période de reporting synchronisée via query string (bookmarkable) + filtre compte (accountIds, import_sources.id) via le paramètre sources, même mécanique bookmarkable ; défaut [] = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via <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 |
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.
storageKeynon nul → l'état de repli est persisté par profil dansuser_preferences(viauserPreferenceService), donc détruit avec le profil, sans résidulocalStorage(ADR 0016). Quatre surfaces persistées (les 3 rapports + budget). Hydratation asynchrone : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, unuseEffecthydrate 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 modesingleelle bascule la convention ; endebit_creditelle échange les deux indices de colonnes, parce quemapRowy calculecrédit − débitsur des magnitudes et ne lit jamais la convention — une bascule y aurait été inerte. FormatDriftPanels'ouvre au-dessus de l'aperçu quand l'en-tête normalise différemment deheader_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 gardeheader_signatureNULL 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 :findDuplicatesapparie 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 estdeleteImportWithTransactionspuis rejeu. Aucun montant déjà écrit n'est jamais muté.ImportConfirmationénonce désormais le mode de montant, la convention de signe (seulement dans le mode qui l'applique) et le mapping nommé par en-tête. Le sélecteur de convention est masqué en mode débit/crédit, où le parsing ne le lit pas — masqué, pas réinitialisé : la valeur stockée est intacte.
4. Le format SREF porte les sources et les modèles
dataExportService sérialisait uniquement catégories, fournisseurs, mots-clés et transactions, puis exécutait DELETE FROM import_sources à la restauration en les remplaçant par une source synthétique « Data Import » — restaurer une sauvegarde détruisait toute configuration d'import. Depuis #331 : import_sources et import_config_templates sont dans l'enveloppe derrière un format_version explicite (SREF_FORMAT_VERSION = 2 ; une enveloppe sans le champ est l'ancien format, ses tableaux absents traités comme vides). Les modèles sont restaurés avant les sources (template_id est une FK), upsertés par nom, et template_id remappé sur les identifiants résolus. Wipe et restauration des deux chemins sont enveloppés dans withTransaction — le service n'en avait aucune, et une violation de contrainte en cours de restauration détruisait l'historique financier sans rollback. amount_mode et sign_convention sont whitelistés à la frontière d'import avec un message lisible (le CHECK v17 refuse déjà les mauvaises valeurs, mais une erreur de contrainte SQLite n'est pas un message utilisateur).
Corpus de fixtures — src/__fixtures__/csv/
15 fichiers CSV synthétiques (aucune donnée réelle, aucun relevé réel n'entre dans ce dépôt — les signatures de banques sont écrites depuis des dispositions documentées). Ils couvrent des formes qu'un vrai relevé ne réunit jamais toutes : montant signé, débit/crédit, débit/crédit inversés, colonne inutilisée à 0,00, préambule, en-tête contenant un nombre, en-tête à libellé numérique, sans en-tête, tout-positif, Desjardins quoté, montant absolu + indicateur, plus les quatre dispositions de banque. Posés en #326 avant la refonte, avec le comportement fautif figé et marqué KNOWN DEFECT + l'issue devant le corriger ; chaque maillon suivant a mis à jour l'attente et retiré le marqueur, sans jamais supprimer un test.
Gabarits du dossier d'exemple — src/__fixtures__/samples/
Deux relevés inventés (exemple-desjardins.csv : compte chèque Desjardins ; exemple-tangerine.csv : carte de crédit Tangerine ; 41 lignes chacun), avec les jetons {{M1}} / {{M2}} que la commande Rust create_sample_import_folders remplace par les deux mois complets précédant le clic (MM/YYYY). Les fichiers sont embarqués dans le binaire par include_str! : le dépôt est la seule source, il n'y a rien à copier au build. Le module est séparé de csv/index.ts à dessein — ce corpus est figé à quinze cas par csvAutoDetect.test.ts et six boucles y passent chaque fixture ; y ajouter les gabarits élargirait le contrat figé en silence. src/__integration__/onboarding-samples.test.ts lit les mêmes fichiers et fige ce que l'assistant en fera : chaque fichier reconnu par la signature de la banque qu'il imite, toutes les lignes lues, aucune ligne en erreur, totaux signés attendus, et la part catégorisée par les 180 mots-clés du seed — en laissant exprès des lignes à classer à la main (dont « GARDERIE LES PETITS PAS »), pour que la cinquième case de la liste de démarrage ait quelque chose à cocher (#354).
Onboarding — états vides et liste de démarrage
Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans docs/analyse-onboarding-2026-09.md). Aucune migration ni table : tout se lit dans les données existantes, et une seule préférence est écrite.
Les quatre états vides rendent le composant partagé EmptyState (src/components/shared/ : icône, titre, description, action primaire en <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 en mode expanded) |
« Choisir un dossier d'import » → /import |
Import /import |
aucun dossier d'import, ou dossier sans aucun sous-dossier | ImportEmptyState (deux variantes) |
« 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, mode expanded sur le tableau de bord vide, mode compact entre le titre et les filtres dès qu'il y a des transactions) a six lignes dans un ordre fixe. Chaque case est cochée par une donnée, jamais par un clic ; la source de vérité est computeChecklist(counters, { importFolderSet, budgetAllowed }) :
| 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 WHERE status = 'completed' ≥ 1 |
| 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 (5 au lieu de 6) et renvoyée vers /settings/users |
computeChecklist rend { items, done, total, complete } ; la liste compacte disparaît quand complete. « Masquer » écrit onboarding_checklist_hidden = "1" dans user_preferences — par profil, donc détruite avec lui (ADR 0016) et sans effet sur les autres profils. Paramètres → Utilisateurs montre « Afficher la liste de démarrage » tant que la clé existe et la supprime au clic (showChecklist), avec une confirmation en place du bouton.
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 ; le bandeau 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 ».
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 |
| Rust | src-tauri/src/commands/entitlements.rs |
FEATURE_TIERS réduit à auto-update → Base+ (la matrice UI vit en TS) ; check_entitlement = matrice OU features[] signés avec court-circuit Free (même CWE-863), entitlements résolus par license_commands::current_entitlements |
Modules Free — jamais gatés, aucune FeatureKey : Dashboard, Import, Transactions, Catégories, hub /reports + /reports/trends, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est non destructif : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
Commandes Tauri (37)
fs_commands.rs — Système de fichiers (7)
scan_import_folder— Scan récursif de dossier pour fichiers CSV/TXTread_file_content— Lecture avec gestion de l'encodagehash_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 lignespick_folder— Dialogue de sélection de dossiercreate_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>.csvchacun 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 parinclude_str!depuissrc/__fixtures__/samples/
export_import_commands.rs — Export/Import de données (5)
pick_save_file— Dialogue de sauvegardepick_import_file— Dialogue de sélection de fichierwrite_export_file— Écriture fichier chiffré (format SREF)read_import_file— Lecture fichier chiffréis_file_encrypted— Vérification magic SREF
profile_commands.rs — Gestion des profils (7)
load_profiles— Chargement depuisprofiles.jsonsave_profiles— Sauvegarde de la configurationdelete_profile_db— Suppression du fichier de base de donnéesget_new_profile_init_sql— Récupération du schéma consolidéhash_pin— Hachage Argon2id du PIN (formatargon2id:salt:hash)verify_pin— Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)repair_migrations— Réparation des checksums de migration (rusqlite)
license_commands.rs — Licence et activation machine (10)
validate_license_key— Validation offline d'une clé de licence (JWT Ed25519)store_license— Stockage de la clé dans le répertoire app datastore_activation_token— Stockage du token d'activation, qui doit avoir été émis pour l'identifiant dérivé de cette machineread_license— Lecture de la licence stockée, avecrevoked: 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+ champproductdepuis #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éelist_activated_machines— Liste des machines activées (/v1/licenses/verify). Une réponsevalid:false(clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide. Chaque ligne porteis_this_machine, calculé côté Rustget_activation_status— État d'activation de la machine courante (is_activatedseul, 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_licenseetnot_duesignifient 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 ».
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). 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, mais n'écrit rien, parce que le serveur y juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (#367). 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. 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 Logtorefresh_auth_token— Rafraîchit l'access token via le refresh tokenget_account_info— Lecture du cache d'affichage (viaaccount_cache::load_unverified, accepte les payloads legacy)check_subscription_status— Vérifie l'abonnement (max 1×/jour, fallback cache gracieux). Déclenche aussi la migrationtokens.json→ keychain viatoken_store::loadlogout— 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"ounull. Utilisé par la bannière de sécuritéTokenStoreFallbackBannerdans 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}dansaccount.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é parlicense_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 lefeatures[]signé de la licence, avec court-circuit fail-closed en Free (CWE-863 : une clé résolue enfree, invalide, expirée ou révoquée, ne récupère jamais ses features signées)- Source de vérité Rust :
FEATURE_TIERSdansentitlements.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 TSsrc/shared/entitlements.ts— voir la section « Gating par édition » et l'ADR 0017 - Édition et
features[]sont résolus ensemble parlicense_commands::current_entitlements(interne, pas une commande) : même chemin queget_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, pasdebug_assertions, CWE-489) compile la lecture deSR_DEV_EDITIONpour forcer l'édition résolue en test (cargo test --features dev-override)
- Source de vérité Rust :
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 connexionrusqlitecourte sur le fichier DB du profil actif, lit le snapshot ≤period_start, le snapshot ≥period_endet tous lesbalance_account_transfersJOINtransactionsdans la fenêtre, puis appellereturn_calculator::modified_dietz. RetourneAccountReturn { 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)] |
tauri-plugin-sql |
SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
OAuth2 et deep-link (Compte Maximus)
Flow complet (v0.7.3+) :
- Frontend appelle
start_oauth→ génère un code verifier PKCE (64 chars), le stocke dansOAuthState(Mutex en mémoire du processus), retourne l'URL Logto - Frontend ouvre l'URL via
tauri-plugin-opener→ le navigateur système affiche la page Logto - L'utilisateur s'authentifie (ou Logto auto-consent si session existante) → redirection 303 vers
simpl-resultat://auth/callback?code=... - L'OS route le custom scheme vers une nouvelle instance de l'app →
tauri-plugin-single-instance(featuredeep-link) détecte l'instance existante, ne démarre PAS un nouveau processus, et forwarde l'URL à l'instance vivante - Le callback
app.deep_link().on_open_url(...)enregistré viaDeepLinkExtreçoit les URLs. Pour chaque URL :- Si un param
codeest présent → appellehandle_auth_callback(token exchange vers/oidc/token, fetch/oidc/me, écriture des tokens viatoken_store::save(keychain OS, fallback fichier 0600) + cache signé viaaccount_cache::save(HMAC-SHA256), émission de l'eventauth-callback-success) - Si un param
errorest présent → émission de l'eventauth-callback-erroravecerror: error_description
- Si un param
- Le hook
useAuth(frontend) écouteauth-callback-success/auth-callback-erroret met à jour l'état
Pourquoi cet enchaînement est critique :
- Sans
tauri-plugin-single-instance: une nouvelle instance démarre à chaque callback, leOAuthStateest vide (pas de verifier), le token exchange échoue - Sans
on_open_url: l'ancien listenerapp.listen("deep-link://new-url", ...)ne recevait pas les URLs forwardées par single-instance. L'API canonique v2 viaDeepLinkExtest 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 />dansmain.tsx, attrape les crashs React et afficheErrorPageen fallbackErrorPage: 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.tsxapplique un timeout de 10 secondes surconnectActiveProfile()— afficheErrorPageau lieu d'un spinner infini si la connexion DB échoue - Retry au démarrage :
connectActiveProfile()réessaie jusqu'à 3 fois avec 1s de délai avant d'afficher l'erreur - Réparation de migrations :
repair_migrations(Rust/rusqlite) supprime les checksums invalides de_sqlx_migrationsavant le chargement de la DB - Log viewer :
logService.tscapture lesconsole.log/warn/errordans un buffer circulaire (500 entrées, persisté ensessionStorage), affiché dans la page Paramètres viaLogViewerCard
| Route | Page | Description |
|---|---|---|
/ |
DashboardPage |
Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
/import |
ImportPage |
Assistant d'import CSV : source → configuration (détection automatique + score) → sélection des fichiers → aperçu obligatoire (récap signé, inversion des signes, panneau de dérive) → doublons → confirmation |
/transactions |
TransactionsPage |
Liste avec filtres |
/categories |
CategoriesPage |
Gestion hiérarchique |
/adjustments |
AdjustmentsPage |
Ajustements manuels |
/budget |
BudgetPage |
Planification budgétaire |
/reports |
ReportsPage |
Hub des rapports : panneau faits saillants + 4 cartes de navigation |
/reports/highlights |
ReportsHighlightsPage |
Faits saillants détaillés (soldes, top mouvements, top transactions) |
/reports/trends |
ReportsTrendsPage |
Tendances (flux global + par catégorie) |
/reports/compare |
ReportsComparePage |
Comparables (MoM / YoY / Réel vs budget) |
/reports/category |
ReportsCategoryPage |
Zoom catégorie avec rollup + édition contextuelle de mots-clés |
/reports/cartes |
ReportsCartesPage |
Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
/balance |
BalancePage |
Bilan — vue d'ensemble : carte "Aujourd'hui" + Δ% + avertissement bilan pas à jour > 60j, graphique d'évolution (toggle ligne / aire empilée par catégorie), tableau des comptes avec rendements multi-horizons (3M / 1A / depuis création — Modified Dietz) côte-à-côte avec rendement non-ajusté |
/balance/snapshot |
SnapshotEditPage |
Saisie / édition d'un snapshot daté. Mode ?date=today (création) ou ?date=YYYY-MM-DD (édition, date immutable). Lignes groupées par catégorie : simple = champ valeur, priced = quantity × unit_price (value calculé read-only). Bouton "Pré-remplir depuis le snapshot précédent". Suppression à double-confirmation par re-saisie de la date |
/balance/accounts |
AccountsPage |
CRUD comptes + catégories de bilan (deux onglets). Catégories seedées (is_seed = 1) renommables mais non-supprimables ; refus de suppression d'une catégorie avec comptes liés (FK RESTRICT) |
/settings |
SettingsLayout (layout) + SettingsHomePage (index) |
Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte TokenStoreFallbackBanner une seule fois, partagé par les 4 routes principales |
/settings/users |
UsersSettingsPage |
Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis DocsContent) |
/settings/data |
DataSettingsPage |
Catégories (avec liens vers /settings/categories/standard et /settings/categories/migrate), backup chiffré et confidentialité de la récupération de prix |
/settings/systems |
SystemsSettingsPage |
Version, mise à jour (UpdateCard), historique des versions (ChangelogContent), journaux + commentaires (LogViewerCard) |
/settings/categories/standard |
CategoriesStandardGuidePage |
Guide imprimable de la structure de catégories standard (route flat, hors SettingsLayout) |
/settings/categories/migrate |
CategoriesMigrationPage |
Flux de migration v1→v2 (route flat, hors SettingsLayout) |
/docs |
DocsPage |
Redirige vers /settings/users (rétrocompatibilité bookmarks) |
/changelog |
ChangelogPage |
Redirige vers /settings/systems (rétrocompatibilité release notes) |
Page spéciale : ProfileSelectionPage (affichée quand aucun profil n'est actif).
Internationalisation
- Librairie : i18next + react-i18next
- Langue par défaut : Français (
fr) - Langue de fallback : Anglais (
en) - Fichiers :
src/i18n/locales/fr.json,src/i18n/locales/en.json - Clés organisées hiérarchiquement par domaine (
nav.*,dashboard.*,import.*, etc.)
CI/CD
Quatre workflows Forgejo Actions dans .forgejo/workflows/. Le runner est à capacité 1 : les jobs se suivent, ils ne tournent pas en parallèle.
check-rust.yml — Vérifications Rust sur PR 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 (tar, réellement présent) garantit que son silence prouve quelque chose. Voir ADR 0018.
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 demain. Deux PR ouvertes sur un même lockfile, différent de celui demain, 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 ou du markdown (paths-ignore). Lance npm ci, npm run build (tsc + vite) et npm test (vitest). 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. Conséquence à connaître : npm audit remonte 2 high en permanence, les deux clés d'une même advisory react-router (GHSA-qwww-vcr4-c8h2, mode React Server Components). Elle est acceptée : l'app est un client de bureau sans serveur — App.tsx monte un BrowserRouter client-only — et react-router-dom étant figé en 7.18.1, sortir de la plage vulnérable demanderait une migration vers react-router v8, pas un bump. Déclencheur de re-évaluation : #317. 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énementpull_request.
release.yml — Build et publication
Déclenché par les tags v*. Deux jobs :
- build-windows (windows-latest) → Installeur
.exe(NSIS) - build-linux (ubuntu-22.04) →
.deb+.rpm
Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater publié sur
https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json - Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
Architecture Decision Records (ADRs)
Les ADRs documentent les décisions techniques structurantes. Ils vivent dans docs/adr/.
| # | Titre | Date | Statut |
|---|---|---|---|
| 0001 | 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 |