From e2b8eb8b225bbee1f4a27f06c7afdfa00604a4a1 Mon Sep 17 00:00:00 2001 From: le king fu Date: Fri, 14 Aug 2026 11:13:25 -0400 Subject: [PATCH] docs: architecture, ADR 0019, user guide and changelog for the import format Last link of the ten-link import-format stack. Links 1-9 deliberately wrote no changelog and no documentation, to avoid a conflict at every level of a linear pile; this link owes all of it. - ADR 0019 records the structuring decision: the import format is a fully persisted value, never re-inferred. It documents the three independent paths by which it used to be lost (hardcoded restore, header drift, data export), and why a single codec with a completeness test closes the class rather than a composed type -- the two carriers are structurally incompatible (`has_header` is boolean on one and number on the other). `template_id` is recorded as a provenance label, never re-read as format. - `docs/architecture.md` gains a dedicated "Import CSV" section covering the codec, the lexical detection layer and its separate dictionary module, the bank signatures, the now-mandatory preview step, and sources/templates in the SREF envelope. Migration v17 and its four CHECK-guarded columns are listed in the migrations table. - Stale counts corrected against the tree, not by arithmetic: 16 -> 17 migrations (both files), `src/components/import/` 13 -> 14, `src/utils/` 4 -> 13. Tables (20) and indexes (24) were re-measured and are NOT stale -- v17 is an ALTER TABLE only -- so they are left as they are, with the reason written down. ADR 0018, missing from the ADR table, is added. - The user guide and the `docs.*` keys in both locales carry the same new import journey: automatic detection, confidence score, mandatory preview with its signed recap, sign inversion, recognised bank formats, drift panel, and the safe repair path. - Both changelogs carry the same entries, translated, verified section by section including issue references. The `public/` copies sync automatically via `syncChangelogs()`. 1176 vitest green, build clean, cargo check clean. No DB migration. Resolves #332 Co-Authored-By: Claude Opus 5 --- CHANGELOG.fr.md | 16 ++++ CHANGELOG.md | 16 ++++ CLAUDE.md | 10 +-- docs/adr/0019-format-import-persiste.md | 99 +++++++++++++++++++++++++ docs/architecture.md | 70 +++++++++++++++-- docs/guide-utilisateur.md | 26 +++++-- src/i18n/locales/en.json | 26 +++++-- src/i18n/locales/fr.json | 26 +++++-- 8 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 docs/adr/0019-format-import-persiste.md diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 48b3bc7..afbabe8 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -4,12 +4,28 @@ ### Ajouté +- Import : ouvrir une source jamais configurée **détecte désormais son format tout seul** — délimiteur, encodage, format de date, et quelle colonne porte la date, la description et le montant. Les colonnes sont reconnues par leur libellé d'en-tête en français comme en anglais (`Montant`/`Amount`, `Débit`/`Withdrawal`, `Crédit`/`Deposit`…), et les formats d'export de **Desjardins, RBC, Banque Nationale et Tangerine** sont reconnus par leur nom. La détection rejoue ensuite sa propre décision sur toutes les lignes du fichier et affiche un **score de confiance** (« 147 des 150 lignes lues ») : au-dessus de 90 % le bandeau est neutre, en dessous il avertit et renvoie vers le mapping des colonnes et l'aperçu. Rien n'est jamais bloqué par le score — il dit ce qui a pu être lu, pas si le sens est bon. Le bouton baguette reste disponible pour relancer la détection à la demande, et une source qui a déjà un format enregistré n'est jamais re-détectée (#327, #328). +- Import : l'**aperçu des données est désormais une étape obligatoire de chaque import**, et non plus une fenêtre optionnelle. Il affiche un récapitulatif signé de ce que le fichier va réellement écrire : combien de sorties et pour quel total, combien d'entrées et pour quel total, et combien de lignes n'ont pas pu être lues. Si les entrées et les sorties sont inversées, un bouton **« Inverser les signes »** les corrige — et comme il corrige la configuration de la source plutôt que le seul aperçu, le prochain fichier de cette banque se lira correctement de lui-même. L'écran de confirmation énonce maintenant aussi le mode de montant, la convention de signe et le mapping des colonnes nommé par en-tête, pour les vérifier avant que l'import ne parte (#329). +- Import : quand une banque change la disposition de son export, l'assistant le **signale avant d'importer**. L'en-tête de chaque import réussi est mémorisé sur la source, et un fichier dont l'en-tête ne correspond plus ouvre un panneau listant les colonnes qui ont bougé, apparu ou disparu (« Montant : colonne 3 → 4 »), avec les deux seuls choix qui existent : adopter le format re-détecté, ou conserver celui enregistré. Un renommage purement cosmétique (`Montant` → `MONTANT ($)`) est reconnu comme le même en-tête et ne dit rien. Le panneau de dérive comme le bouton d'inversion portent en plus une note expliquant la **façon sûre de réparer un import déjà écrit de travers** : le supprimer d'abord dans l'historique des imports, puis rejouer le fichier — le réimporter par-dessus doublerait les lignes, les doublons étant repérés sur la date, la description et le montant ensemble (#330). +- Import : vos **sources et modèles d'import font désormais partie de la sauvegarde chiffrée**. Exporter puis réimporter vos données effaçait toute configuration d'import et la remplaçait par une unique source synthétique « Data Import » : restaurer une sauvegarde obligeait à reconfigurer chaque banque à la main. Les anciens fichiers de sauvegarde, qui ne les contiennent pas, sont restaurés exactement comme avant (#331). - Migration des catégories : les catégories personnalisées sans correspondance standard peuvent désormais être **fusionnées** dans une catégorie standard, au lieu d'être seulement mises de côté. Chaque catégorie personnalisée du bloc « Catégories personnalisées » reçoit le même sélecteur de cible que les lignes du seed — choisissez une feuille standard et ses transactions, budgets, mots-clés et fournisseurs y sont réassignés, puis la catégorie personnalisée est retirée. Laisser une catégorie personnalisée non mappée conserve le comportement précédent (regroupée sous « Catégories personnalisées (migration) ») et ne bloque jamais la migration (#259). ### Modifié +- Import : le format d'une source — y compris le **mode de montant** (montant unique, ou colonnes débit et crédit séparées) et la **convention de signe** (dépenses écrites négatives ou positives) — est désormais enregistré en entier sur la source elle-même et restitué exactement tel que vous l'avez réglé. Jusqu'ici ces deux réglages n'étaient conservés que sur les modèles, et rouvrir une source configurée re-déduisait le mode puis remettait silencieusement la convention sur « dépenses négatives » : une source que vous aviez configurée en dépenses positives revenait sur le défaut à son deuxième import. Reconfigurer une source ne change pas, mais une source configurée une fois n'est plus jamais devinée. Le sélecteur de convention de signe est maintenant masqué en mode débit/crédit, où il ne s'appliquait pas (votre valeur enregistrée reste intacte), et votre configuration n'est écrite qu'au moment où l'import part réellement — abandonner l'assistant à l'étape des doublons ne laisse plus de configuration à moitié enregistrée (#323, #324, #328). + - L'application déverrouille désormais ses modules par édition — **Gratuite**, **Base** ou **Premium**, résolue depuis votre clé de licence. La Gratuite conserve le Tableau de bord, l'Import CSV, les Transactions, les Catégories, le rapport Tendances (et le hub Rapports), l'export/import chiffré et le journal des modifications, avec un seul profil. La **Base** débloque en plus le Budget, les Ajustements, les quatre rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes), les profils multiples et les mises à jour automatiques. La **Premium** débloque en plus le module Bilan complet (patrimoine, détail par titre, cours du marché). Les modules verrouillés restent visibles — un cadenas apparaît dans la barre latérale et sur les tuiles de rapports — et les ouvrir affiche un écran de déverrouillage avec un raccourci « J'ai déjà une clé » vers la carte de licence ; le bouton d'achat en ligne « Obtenir Base / Premium » est affiché mais désactivé pour l'instant (« bientôt disponible »). Le verrouillage n'est jamais destructif : quelle que soit l'édition, vos données sont conservées intactes et tout réapparaît dès qu'une clé valide est entrée — en Gratuite votre profil actif reste toujours accessible, seuls la création d'un profil supplémentaire ou le passage à un autre profil sont verrouillés (#297, #298, #299, #300, #301). +### Corrigé + +- Import : un relevé dont les colonnes débit et crédit sont écrites dans cet ordre (`Date;Description;Crédit;Débit`) voyait **tous ses signes inversés** — les dépenses importées en revenus et les revenus en dépenses. Les deux colonnes étaient distinguées par leur position, jamais par leur libellé. Elles sont désormais identifiées par leur en-tête, l'ordre dans lequel elles apparaissent n'a donc plus d'importance (#327). +- Import : chez les nombreuses banques qui écrivent `0,00` dans la colonne inutilisée au lieu de la laisser vide, **chaque dépense s'importait à 0,00** et disparaissait purement et simplement du grand livre, sans aucune erreur. Les montants sont maintenant calculés comme crédit moins débit, ce qui ne demande aucun cas particulier pour une cellule à zéro. Une ligne illisible dans les deux colonnes est signalée comme erreur au lieu d'être écrite en transaction gratuite à 0,00, et une colonne de montant laissée non mappée est signalée d'emblée plutôt que de retomber sur la lecture de la colonne de date (#325). +- Import : les montants portant un code de devise ou un marqueur comptable en suffixe étaient lus **cent fois trop grands** — `100,00 CAD` s'importait à 10 000,00 et passait toutes les validations, seuls les chiffres de tête étant lus et le reste ignoré. Une telle valeur est désormais rejetée comme illisible et signalée sur sa ligne. Les formes comptables sont en revanche prises en charge : `(50,00)` et `50,00-` valent tous deux −50,00. La même correction s'applique à l'import CSV de titres dans un snapshot de bilan, où un prix écrit `150,25 CAD` était enregistré à 15 025 et une quantité illisible sauvegardée en silence comme une position de valeur nulle (#325). +- Import : dans un fichier où une colonne utilise la virgule décimale et une autre le point, `1.234` pouvait être lu 1234 à un endroit et 1,234 à l'autre. Le séparateur décimal est maintenant tranché par colonne, à partir des valeurs de cette colonne (#325). +- Import : un relevé à **montants positifs accompagnés d'une colonne d'indicateur « D » / « C »** était indiscernable d'un fichier tout-positif, donc configuré en « dépenses positives », et **chaque dépôt s'importait en dépense**. Ce format est désormais détecté et refusé avec une explication, au lieu d'être importé à l'envers. Réexportez le relevé avec des montants signés, ou avec des colonnes débit et crédit séparées (#327). +- Import : une ligne d'en-tête dont la première cellule commence par des chiffres (une colonne intitulée `2025`, par exemple) était prise pour une ligne de données, et l'en-tête s'importait en transaction. Les lignes d'en-tête sont maintenant reconnues par leurs libellés autant que par leur forme (#325, #327). +- Export/import de données : la restauration d'une sauvegarde n'était pas enveloppée dans une transaction — une violation de contrainte en cours de route abandonnait la restauration alors que les données existantes avaient déjà été effacées, détruisant l'historique financier sans retour arrière. L'effacement et la restauration réussissent ou échouent désormais d'un bloc. Restaurer dans un profil qui possédait déjà des modèles d'import n'échoue plus non plus sur une collision de nom (#331). + ### Sécurité - Mise à jour de deux dépendances Rust situées sur le chemin de la mise à jour automatique, corrigeant six advisories RustSec : `rustls-webpki` 0.103.9 → 0.103.13 (traitement des contraintes de nom de certificat et analyse des listes de révocation, dont une panique atteignable) et `tar` 0.4.44 → 0.4.46 (`chmod` de répertoires arbitraires en suivant des liens symboliques pendant l'extraction, et en-têtes de taille PAX mal pris en compte). Les deux sont tirées par `tauri-plugin-updater`, qui télécharge et décompresse les mises à jour de l'application : ce sont donc de vrais chemins de code du produit livré. Aucun changement de comportement. Trois autres advisories restent signalées sur le fichier de verrouillage mais ne s'appliquent pas au produit livré — `quick-xml` n'est compilé que pour des cibles Apple que nous ne livrons pas, et `rsa` n'est jamais compilé — elles sont désormais consignées comme acceptées, avec leur justification et les conditions de leur retrait, plutôt que de laisser l'audit de sécurité quotidien rouge en permanence (#310). diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b06af..0e73f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,28 @@ ### Added +- Import: opening a source you have never configured now **detects its format on its own** — delimiter, encoding, date format, and which column holds the date, the description and the amount. Columns are recognised by their header label in French and in English (`Montant`/`Amount`, `Débit`/`Withdrawal`, `Crédit`/`Deposit`…), and the exported layouts of **Desjardins, RBC, National Bank and Tangerine** are recognised by name. Detection then replays its own decision over every row of the file and reports a **confidence score** ("147 of 150 rows read"): above 90 % the banner is neutral, below it the banner warns and points you at the column mapping and the preview. Nothing is ever blocked by the score — it says what could be read, not whether the meaning is right. The magic-wand button is still there to re-run detection on demand, and a source that already has a saved format is never re-detected (#327, #328). +- Import: the data **preview is now a mandatory step of every import**, no longer an optional pop-up, and it shows a signed recap of what the file will actually write: how many outflows and for what total, how many inflows and for what total, and how many rows could not be read. If inflows and outflows are swapped, an **"Flip the signs"** button fixes them — and because it corrects the source's configuration rather than just this preview, the next file from that bank reads correctly on its own. The confirmation screen now also states the amount mode, the sign convention and the column mapping named by header, so you can check them before the import runs (#329). +- Import: when a bank changes the layout of its export, the wizard now **says so before importing**. The header of every successful import is recorded on the source, and a file whose header no longer matches opens a panel listing the columns that moved, appeared or disappeared ("Amount: column 3 → 4"), with the two choices that exist: adopt the newly detected format, or keep the stored one. A purely cosmetic rename (`Montant` → `MONTANT ($)`) is recognised as the same header and says nothing. Both the drift panel and the sign-flip button now also carry a note explaining the **safe way to repair an import that was already written wrong**: delete it from the import history first, then replay the file — re-importing on top of it would double the rows, since duplicates are matched on date, description and amount together (#330). +- Import: your **import sources and templates are now included in the encrypted backup**. Exporting and re-importing your data used to wipe every import configuration and replace it with a single synthetic "Data Import" source, so restoring a backup meant reconfiguring every bank by hand. Older backup files, which do not carry them, are still restored exactly as before (#331). - Category migration: custom categories that have no standard match can now be **merged** into a standard category instead of only being set aside. Each custom category in the "Custom categories" block gets the same target picker as the seeded rows — choose a standard leaf and its transactions, budgets, keywords and suppliers are reassigned to it, then the custom category is removed. Leaving a custom category unmapped keeps the previous behaviour (grouped under "Custom categories (migration)") and never blocks the migration (#259). ### Changed +- Import: the format of a source — including the **amount mode** (single amount, or separate debit and credit columns) and the **sign convention** (expenses written negative or positive) — is now stored in full on the source itself and restored exactly as you set it. Until now those two settings were only kept on templates, and reopening a configured source re-derived the mode and silently reset the convention to "expenses negative"; a source you had configured for positive expenses came back on the default at its second import. Reconfiguring a source is unchanged, but a source configured once is never guessed at again. The sign-convention selector is now hidden in debit/credit mode, where it never applied (your stored value is left untouched), and your configuration is written only once the import actually runs — abandoning the wizard at the duplicate-check step no longer leaves a half-saved configuration behind (#323, #324, #328). + - The application now unlocks its modules per edition — **Free**, **Base** or **Premium**, resolved from your license key. Free keeps the Dashboard, CSV Import, Transactions, Categories, the Trends report (and the Reports hub), encrypted export/import and the changelog, with a single profile. **Base** additionally unlocks Budget, Adjustments, the four advanced reports (Highlights, Compare, Category analysis, Cards), multiple profiles and automatic updates. **Premium** additionally unlocks the full Balance module (net worth, per-security detail, market prices). Locked modules stay visible — a lock badge shows in the sidebar and on the report tiles — and opening one shows an unlock screen with an "I already have a key" shortcut to the license card; the online "Get Base / Premium" purchase button is shown but disabled for now ("coming soon"). Locking is never destructive: whatever the edition, your data is kept untouched and everything reappears as soon as a valid key is entered — on Free your active profile always stays accessible, only creating or switching to another profile is locked (#297, #298, #299, #300, #301). +### Fixed + +- Import: a statement whose debit and credit columns are written in that order (`Date;Description;Credit;Debit`) used to have **every single sign inverted** — expenses imported as income and income as expenses. The two columns were told apart by their position, never by their label. They are now identified by their header, so the order they appear in no longer matters (#327). +- Import: on the many banks that write `0,00` in the unused column instead of leaving it empty, **every expense used to import as 0,00** and simply vanished from your ledger, with no error anywhere. Amounts are now computed as credit minus debit, which needs no special case for a zero cell. A row that cannot be read in either column is reported as an error instead of being silently written as a free 0,00 transaction, and an amount column left unmapped is now reported up front rather than falling back to reading the date column (#325). +- Import: amounts carrying a trailing currency code or accounting marker were read **a hundred times too large** — `100,00 CAD` imported as 10 000,00 and passed every validation, since only the leading digits were read and the rest was discarded. Such a value is now rejected as unreadable and reported on its row. Accounting forms are handled properly instead: `(50,00)` and `50,00-` both read as −50,00. The same fix applies to the CSV import of securities into a balance snapshot, where a price written `150,25 CAD` used to be stored as 15 025 and an unreadable quantity was silently saved as a zero-value position (#325). +- Import: in a file where one column uses a comma for decimals and another a period, `1.234` could be read as 1234 in one place and 1,234 in the other. The decimal separator is now decided per column, from that column's own values (#325). +- Import: a statement with **positive amounts plus a separate "D" / "C" indicator column** used to be indistinguishable from an all-positive file, so it was configured as "expenses positive" and **every deposit imported as an expense**. This layout is now detected and refused with an explanation, instead of being imported backwards. Re-export the statement with signed amounts, or with separate debit and credit columns (#327). +- Import: a header row whose first cell starts with digits (a column titled `2025`, for example) was mistaken for a data row, and the header was imported as a transaction. Header rows are now recognised by their labels as well as their shape (#325, #327). +- Data export/import: restoring a backup was not wrapped in a transaction — a constraint failure partway through would abandon the restore after the existing data had already been wiped, destroying your financial history with no rollback. The wipe and the restore now succeed or fail as a whole. Restoring into a profile that already had import templates also no longer fails on a name collision (#331). + ### Security - Updated two Rust dependencies sitting on the automatic-update path, clearing six RustSec advisories: `rustls-webpki` 0.103.9 → 0.103.13 (certificate name-constraint handling and certificate-revocation-list parsing, including a reachable panic) and `tar` 0.4.44 → 0.4.46 (arbitrary directory `chmod` by following symlinks during extraction, and mishandled PAX size headers). Both are pulled in by `tauri-plugin-updater`, which downloads and unpacks application updates, so these are real code paths in the shipped app. No behaviour change. Three further advisories are still reported against the lockfile but do not apply to the shipped product — `quick-xml` is only compiled for Apple targets we do not ship, and `rsa` is never compiled at all — so they are now recorded as accepted, together with their justification and the conditions for removing them, instead of leaving the daily security audit permanently red (#310). diff --git a/CLAUDE.md b/CLAUDE.md index 8f2f05c..379b2dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ src/ │ ├── budget/ # Budget │ ├── categories/ # Catégories hiérarchiques │ ├── dashboard/ # Tableau de bord -│ ├── import/ # Wizard d'import (13 composants) +│ ├── import/ # Wizard d'import (14 composants) │ ├── layout/ # AppShell, Sidebar │ ├── profile/ # Profils (PIN, formulaire, switcher) │ ├── reports/ # Graphiques et rapports @@ -70,7 +70,7 @@ src-tauri/ │ │ ├── schema.sql # Schéma initial (v1) │ │ ├── seed_categories.sql # Seed catégories (v2) │ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils) -│ ├── lib.rs # Point d'entrée, 7 migrations inline, plugins +│ ├── lib.rs # Point d'entrée, 17 migrations inline, plugins │ └── main.rs └── Cargo.toml ``` @@ -86,7 +86,7 @@ src-tauri/ ## Fonctionnalités principales -- **Import CSV** : wizard multi-étapes, détection auto de l'encodage/délimiteur, templates de config, déduplication par fichier +- **Import CSV** : wizard multi-étapes, détection auto (encodage, délimiteur, colonnes par libellé FR/EN, formats de banques connus) avec score de confiance, **aperçu obligatoire à récap signé** avant tout import, templates de config, déduplication par fichier. Le format d'import est **persisté en entier sur la source et jamais re-deviné** — [ADR 0019](docs/adr/0019-format-import-persiste.md) - **Catégorisation** : automatique (mots-clés avec priorité) et manuelle, drag-and-drop pour réorganiser - **Transactions** : filtrage, tri, split sur plusieurs catégories, notes - **Budget** : grille 12 mois, templates réutilisables, budget vs réel @@ -118,8 +118,8 @@ src-tauri/ ## Base de données -- **20 tables** SQLite, **24 index** (voir `docs/architecture.md` pour le détail). Le module Bilan en représente 7 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`, puis `balance_securities` + `balance_snapshot_holdings` ajoutées en Étape 2 — détail par titre) et 9 index -- **16 migrations inline** dans `lib.rs` (v1→v16, via `tauri_plugin_sql::Migration`). Étape 2 (détail par titre) : v14 (`balance_securities` + `balance_snapshot_holdings` + 2 index), v15 (`balance_accounts.kind` + `detailed_since` + backfill), v16 (conversion des comptes cotés existants en détaillés 1-position). Voir [ADR 0015](docs/adr/0015-balance-detail-par-titre.md) +- **20 tables** SQLite, **24 index** (voir `docs/architecture.md` pour le détail). Le module Bilan en représente 7 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`, puis `balance_securities` + `balance_snapshot_holdings` ajoutées en Étape 2 — détail par titre) et 9 index. Ces deux comptes sont inchangés depuis v13 : v14-v16 ajoutent 2 tables + 2 index (déjà comptés), v17 est un `ALTER TABLE` pur +- **17 migrations inline** dans `lib.rs` (v1→v17, via `tauri_plugin_sql::Migration`). Étape 2 du Bilan (détail par titre) : v14 (`balance_securities` + `balance_snapshot_holdings` + 2 index), v15 (`balance_accounts.kind` + `detailed_since` + backfill), v16 (conversion des comptes cotés existants en détaillés 1-position) — voir [ADR 0015](docs/adr/0015-balance-detail-par-titre.md). Import : v17 (`import_sources.amount_mode` + `sign_convention`, les deux avec `CHECK`, + `header_signature` + `template_id`) — voir [ADR 0019](docs/adr/0019-format-import-persiste.md) - **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils - Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle diff --git a/docs/adr/0019-format-import-persiste.md b/docs/adr/0019-format-import-persiste.md new file mode 100644 index 0000000..5750e69 --- /dev/null +++ b/docs/adr/0019-format-import-persiste.md @@ -0,0 +1,99 @@ +# ADR 0019 — Le format d'import est une donnée persistée intégralement, jamais re-devinée + +- Status: **Accepted** +- Date: 2026-08-13 +- Issues: #326 (corpus de fixtures figeant le comportement fautif), #323 (migration v17), #324 (codec `formatToRow`/`formatFromRow`, racine du bug), #325 (règle débit/crédit + parseur ancré), #327 (détection lexicale par libellé), #328 (score de confiance + détection autonome), #329 (aperçu obligatoire + recap signé), #330 (signatures de banques + dérive d'en-tête), #331 (sources et modèles dans le format SREF), #332 (cette doc) +- Spec: [`spec-decisions-import-csv-format.md`](../../spec-decisions-import-csv-format.md), [`spec-plan-import-csv-format.md`](../../spec-plan-import-csv-format.md) +- Prolonge [ADR 0003](0003-sqlx-migrations.md) (migrations SQL inline additives) pour la migration v17 + +## Contexte + +Un import CSV transforme une cellule de texte en un montant signé au grand livre. Deux informations décident du signe, et elles seules : + +- **`amount_mode`** — le fichier porte-t-il un montant unique, ou deux colonnes débit et crédit ? +- **`sign_convention`** — en montant unique, une dépense s'écrit-elle négative ou positive ? + +Ces deux champs vivaient sur `import_config_templates` mais **pas** sur `import_sources`. La source — la configuration réellement utilisée à chaque import — ne portait que les réglages mécaniques (délimiteur, encodage, format de date, mapping de colonnes). L'asymétrie était structurelle, et le code la comblait en devinant : `useImportWizard.ts:321-323` ré-inférait le mode depuis `mapping.debitAmount !== undefined` et écrivait `signConvention: "negative_expense"` **en dur**. + +Une source de carte de crédit configurée en dépenses positives revenait donc sur la convention par défaut à son **deuxième** import, et chaque montant était nié : les dépenses arrivaient en revenus. Sans erreur, sans avertissement, et sans contrôle agrégé capable de le voir — un signe inversé ne fait que changer le signe du total. + +Le défaut n'avait pas une voie mais **trois**, indépendantes l'une de l'autre : + +1. **La restauration en dur.** Recharger une source configurée écrasait sa convention par la valeur codée en dur (#324). +2. **La dérive d'en-tête.** La banque déplace, renomme ou ajoute une colonne ; le mapping mémorisé continue de pointer sur des positions qui ne désignent plus les mêmes données, et l'import passe sans rien signaler (#330). +3. **L'export de données.** `dataExportService` ne sérialisait ni les sources ni les modèles, puis exécutait `DELETE FROM import_sources` à la restauration et les remplaçait par une source synthétique « Data Import ». Restaurer une sauvegarde détruisait toute configuration d'import (#331). + +Les trois produisent la même classe de panne — un format perdu, remplacé par une supposition — et une correction ponctuelle sur l'une n'apprend rien aux deux autres. + +## Décision + +**Le format d'import est une valeur persistée intégralement sur `import_sources`, lue telle quelle, jamais re-inférée.** Quatre règles la tiennent. + +### 1. La base porte le format en entier (migration v17) + +v17 ajoute quatre colonnes à `import_sources`, en additif pur (v1→v16 intactes) : + +| Colonne | Définition | +|---|---| +| `amount_mode` | `TEXT NOT NULL DEFAULT 'single'` + `CHECK (amount_mode IN ('single','debit_credit','absolute_indicator'))` | +| `sign_convention` | `TEXT NOT NULL DEFAULT 'negative_expense'` + `CHECK (sign_convention IN ('negative_expense','positive_expense'))` | +| `header_signature` | `TEXT` — libellés normalisés de l'en-tête du dernier import réussi (détection de dérive) | +| `template_id` | `INTEGER REFERENCES import_config_templates(id) ON DELETE SET NULL` | + +Le `CHECK` d'`amount_mode` admet `absolute_indicator` **dès maintenant** : le troisième mode de montant pourra être livré sans nouvelle migration, tandis que la base refuse déjà toute valeur corrompue. Le backfill (`UPDATE … WHERE column_mapping LIKE '%debitAmount%'`) reproduit exactement ce que le code déduisait à la volée — v17 ne change aucun comportement observable. `sign_convention` n'est délibérément **pas** backfillée : son `DEFAULT` restitue précisément la valeur que le code codait en dur, la seule convention passée qui puisse être inférée ; en deviner une autre réécrirait silencieusement du sens. + +`LIKE` plutôt que `json_extract`, pour que la migration ne dépende d'aucune extension JSON1 dans le SQLite embarqué. + +### 2. Un codec unique, pas un type composé + +La garantie de complétude vient de **`src/utils/importFormat.ts`**, seul point de conversion entre : + +- `ImportFormatRow` — la forme persistée (snake_case, mapping en JSON, `has_header` normalisé en 0/1) ; +- `ImportFormat` — la forme domaine consommée par le wizard et par `mapRow`. + +Un type `ImportFormat` **composé**, partagé par les deux porteurs, était l'approche naturelle et elle est **structurellement impossible** : `ImportSource.has_header` est déclaré `boolean`, `ImportConfigTemplate.has_header` est un `number`, et `SourceConfig` est camelCase sur un mapping déjà parsé. Les porteurs sont incompatibles ; le point de passage, lui, peut être unique. + +Ce qui donne des dents au codec est son **test de complétude**, sur deux niveaux : + +- `FORMAT_FIELD_PAIRS` est typé `Record` — un champ ajouté au format **ne compile pas** tant qu'il n'y figure pas ; +- le test compare les clés réellement produites par chaque sens du codec à cette table — un champ déclaré mais non câblé **fait échouer le test**. + +Supprimer `sign_convention` de `formatToRow` — la forme exacte du bug d'origine — fait tomber 14 tests. Les deux écrivains de modèles passent aussi par le codec, pour qu'un nouveau champ ne puisse pas atteindre une table et manquer l'autre. + +`formatFromRow` **lève** sur une valeur qu'il ne sait pas lire au lieu de retomber sur un défaut : retomber sur la branche `single` lirait la mauvaise colonne à chaque ligne, et toute valeur autre que `positive_expense` signifierait silencieusement `negative_expense`. L'erreur porte une clé i18n et le wizard s'ouvre alors sur une configuration neuve — bloquer le wizard rendrait « reconfigurer cette source » impossible. + +### 3. `template_id` est une étiquette de provenance, jamais relue comme format + +Un modèle sert à **initialiser** une source ; il ne la gouverne pas. `template_id` est enregistré, restauré et affiché, mais **jamais relu pour dériver un format**. Éditer un modèle ne change aucune source liée — c'est un critère d'acceptation testé de bout en bout, avec vérification de non-vacuité (le modèle a bien changé). `ON DELETE SET NULL` : supprimer le modèle efface l'étiquette et laisse le format intact. + +La raison est la même que celle du reste de l'ADR : si le format se déduisait du modèle, il redeviendrait une valeur devinée, portée par une ligne que l'utilisateur peut modifier ailleurs et à un autre moment. + +### 4. Le format persisté gagne, et les deux autres voies de perte sont fermées + +- **Détection** — elle se déclenche seule sur une source **sans format enregistré**, et **jamais** sur une source qui en a un (garde `!existing`). Le bouton baguette reste, pour la rejouer à la demande. Une source dont le format stocké échoue à décoder émet son erreur explicite plutôt qu'une nouvelle supposition. +- **Dérive d'en-tête** — `header_signature` stocke les libellés normalisés du dernier import réussi, en **tableau JSON et non en hachage** : le panneau doit pouvoir nommer les colonnes qui ont bougé (« Montant : colonne 3 → 4 »). Un en-tête qui normalise différemment ouvre `FormatDriftPanel` au-dessus de l'aperçu, avec les deux seules issues qui existent — **adopter** le format re-détecté ou **conserver** celui enregistré. Un simple renommage cosmétique normalise à l'identique et ne dit rien. Une source dont les fichiers n'ont pas de ligne d'en-tête garde `header_signature` à NULL et la détection de dérive y est inopérante : c'est documenté, pas contourné — une signature inventée depuis les données se déclencherait à chaque import. +- **Export/import de données** — `import_sources` et `import_config_templates` sont sérialisés dans l'enveloppe SREF derrière un `format_version` explicite. Les modèles sont restaurés **avant** les sources (`template_id` est une clé étrangère), upsertés par nom, et `template_id` est remappé sur les identifiants résolus. Le wipe et la restauration sont enveloppés dans `withTransaction`. + +## Alternatives considérées + +- **Un type `ImportFormat` composé, partagé par les deux tables.** Rejeté : impossible sans réécrire les deux porteurs (`has_header` `boolean` d'un côté, `number` de l'autre, mapping parsé vs JSON). Le codec obtient la même garantie sans toucher aux formes existantes, et son test la rend vérifiable — ce qu'un type partagé n'aurait pas donné à lui seul. +- **Backfiller `sign_convention` en devinant depuis les données** (ex. « majorité de montants négatifs ⇒ `negative_expense` »). Rejeté : c'est exactement la re-inférence que cet ADR supprime, appliquée une fois pour toutes et sans possibilité de la relire. Le `DEFAULT` restitue la seule convention réellement en vigueur avant v17. +- **Dériver le format depuis `template_id`.** Rejeté : voir la règle 3 — cela redonne au format un porteur mutable ailleurs. +- **Hacher `header_signature`.** Rejeté : un hachage détecte la dérive mais ne peut pas la décrire, et le panneau de dérive n'aurait plus rien à montrer que « ça a changé ». +- **Corriger rétroactivement les transactions déjà importées à l'envers.** Rejeté : on ne mute jamais des montants déjà écrits. La voie de réparation est `deleteImportWithTransactions` puis re-import — `RepairPathNotice` l'énonce dans l'interface, parce que ré-importer par-dessus **double** les lignes (`findDuplicates` apparie sur date + description + montant, donc une ligne corrigée ne s'apparie pas à la fautive). + +## Conséquences + +- **Positif.** Le format d'une source est lisible en base et rejoué à l'identique. Le codec et son test forment le point de contrôle unique : ajouter un champ de format sans le persister ne compile pas, ou ne passe pas. Les trois voies de perte sont fermées par construction, pas par vigilance. +- **Positif.** L'aperçu signé, obligatoire à chaque import, montre ce que le format *signifie* (sorties, entrées, totaux signés, lignes en erreur) — un score de lecture parfait ne dit rien du signe. +- **Coût.** Une source dont le format enregistré est illisible (valeur hors `CHECK` arrivée par un chemin non prévu, JSON corrompu) affiche une erreur et repart sur une configuration neuve, au lieu de continuer sur un défaut. C'est le comportement voulu, mais c'est un arrêt visible là où il y avait un silence. +- **Coût.** `header_signature` n'est écrite qu'à l'import réussi et reste NULL pour les fichiers sans en-tête : la détection de dérive ne couvre pas ces sources. +- **À surveiller.** Le `CHECK` d'`amount_mode` admet `absolute_indicator`, que l'application **refuse** aujourd'hui avec un message dédié plutôt que de l'importer de travers. Le jour où ce mode sera livré, le codec devra l'accepter — la migration, elle, n'est pas à refaire. +- **Portée.** Les modules d'import restent entièrement en édition Gratuite ; ce chantier n'ajoute aucun gating ([ADR 0017](0017-feature-gating-par-tier.md)). + +## Liens + +- Spec : [`spec-decisions-import-csv-format.md`](../../spec-decisions-import-csv-format.md), [`spec-plan-import-csv-format.md`](../../spec-plan-import-csv-format.md) +- [ADR 0003](0003-sqlx-migrations.md) — migrations SQL inline via tauri-plugin-sql (v17 en suit la convention additive) +- [ADR 0004](0004-aes-256-gcm-encryption.md) — chiffrement de l'enveloppe SREF, dont le `format_version` est étendu par #331 +- `docs/architecture.md` — section « Import CSV — format persisté et détection » diff --git a/docs/architecture.md b/docs/architecture.md index 9a2ffb3..714b96c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture technique — Simpl'Résultat -> Document mis à jour le 2026-07-20 — Version 0.14.x (gating par édition) +> Document mis à jour le 2026-08-13 — Version 0.14.x (format d'import persisté, migration v17) ## Stack technique @@ -32,7 +32,7 @@ simpl-resultat/ │ │ ├── budget/ # 5 composants │ │ ├── categories/ # 5 composants │ │ ├── dashboard/ # 2 composants -│ │ ├── import/ # 13 composants (wizard d'import) +│ │ ├── import/ # 14 composants (wizard d'import, dont FormatDriftPanel et RepairPathNotice) │ │ ├── layout/ # AppShell, Sidebar │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie) @@ -44,7 +44,7 @@ simpl-resultat/ │ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── services/ # 14 services métier │ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts) -│ ├── utils/ # 4 utilitaires (parsing, CSV, charts) +│ ├── 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 │ ├── App.tsx # Router principal │ └── main.tsx # Point d'entrée @@ -81,7 +81,7 @@ simpl-resultat/ | Table | Description | |-------|-------------| -| `import_sources` | Configuration des sources d'import CSV | +| `import_sources` | Configuration des sources d'import CSV. Porte le **format d'import en entier** depuis v17 : `amount_mode` (`CHECK ∈ {single, debit_credit, absolute_indicator}`) et `sign_convention` (`CHECK ∈ {negative_expense, positive_expense}`) s'ajoutent aux réglages mécaniques (délimiteur, encodage, format de date, `column_mapping`), plus `header_signature` (libellés normalisés du dernier import réussi, détection de dérive) et `template_id` (**étiquette de provenance**, `ON DELETE SET NULL`, jamais relue comme format) — voir [ADR 0019](adr/0019-format-import-persiste.md) et la section « Import CSV » | | `imported_files` | Suivi des fichiers importés (hash anti-doublons) | | `categories` | Catégories hiérarchiques (dépenses/revenus) | | `suppliers` | Fournisseurs avec auto-catégorisation | @@ -159,6 +159,9 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug | 14 | v14 | Étape 2 : `balance_securities` + `balance_snapshot_holdings` + 2 index (additive) — [ADR 0015](adr/0015-balance-detail-par-titre.md) | | 15 | v15 | Étape 2 : `balance_accounts.kind` (`simple`/`detailed`) + `detailed_since` + backfill depuis `category.kind` (`priced` → `detailed`) — [ADR 0015](adr/0015-balance-detail-par-titre.md) | | 16 | v16 | Étape 2 : conversion des comptes cotés existants en détaillés 1-position (security + holding miroir, gardée anti-perte, idempotente) — [ADR 0015](adr/0015-balance-detail-par-titre.md) | +| 17 | v17 | Import : `import_sources.amount_mode` + `sign_convention` (les deux avec `CHECK`) + `header_signature` + `template_id` (FK `ON DELETE SET NULL`), backfill `amount_mode = 'debit_credit' WHERE column_mapping LIKE '%debitAmount%'` (additive, aucun changement de comportement observable) — [ADR 0019](adr/0019-format-import-persiste.md) | + +v17 est purement additive : elle n'ajoute **ni table ni index** (20 tables / 24 index inchangés). Le `CHECK` d'`amount_mode` admet `absolute_indicator` dès maintenant, pour que le troisième mode de montant puisse être livré sans nouvelle migration, alors que l'application le **refuse** encore explicitement (voir plus bas). `LIKE` plutôt que `json_extract` : la migration ne dépend d'aucune extension JSON1 dans le SQLite embarqué. `sign_convention` n'est délibérément pas backfillée — son `DEFAULT` restitue la valeur que le code codait en dur, la seule convention passée inférable. Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations). @@ -170,7 +173,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le | `profileService.ts` | Gestion des profils | | `categoryService.ts` | CRUD catégories hiérarchiques | | `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) | -| `importSourceService.ts` | Configuration des sources d'import | +| `importSourceService.ts` | Configuration des sources d'import — lit et écrit le format complet via le codec `importFormat.ts` ([ADR 0019](adr/0019-format-import-persiste.md)) | | `importedFileService.ts` | Suivi des fichiers importés | | `importConfigTemplateService.ts` | Modèles de configuration d'import | | `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) | @@ -178,7 +181,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le | `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é) | +| `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 | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | | `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) | @@ -205,7 +208,7 @@ Chaque hook encapsule la logique d'état via `useReducer` : | `useCategories` | Catégories avec hiérarchie | | `useTransactions` | Transactions et filtrage | | `useDataImport` | Import de données | -| `useImportWizard` | Assistant d'import multi-étapes | +| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md) | | `useImportHistory` | Historique des imports | | `useAdjustments` | Ajustements | | `useBudget` | Budget | @@ -233,6 +236,55 @@ Chaque hook encapsule la logique d'état via `useReducer` : - `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible. - `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler). +## Import CSV — format persisté et détection + +Le chantier #323-#332 a fait du **format d'import une donnée persistée intégralement, jamais re-devinée** — [ADR 0019](adr/0019-format-import-persiste.md) porte la décision, ses trois voies de perte et les alternatives rejetées. Quatre couches, du schéma à l'écran. + +### 1. Le codec — `src/utils/importFormat.ts` + +Point de conversion **unique** entre `ImportFormatRow` (persisté : snake_case, mapping en JSON, `has_header` normalisé 0/1) et `ImportFormat` (domaine). Il n'existe pas de type composé partagé par les deux porteurs — c'est structurellement impossible (`ImportSource.has_header` est `boolean`, `ImportConfigTemplate.has_header` est `number`), et la garantie de complétude vient du codec et de son test, pas d'une forme commune : + +- `FORMAT_FIELD_PAIRS: Record` — un champ ajouté au format **ne compile pas** tant qu'il n'y est pas listé ; +- le test compare les clés réellement produites par `formatToRow` / `formatFromRow` à cette table — un champ listé mais non câblé **fait échouer le test**. + +`formatFromRow` lève (`ImportFormatError`, clé i18n) sur une valeur illisible plutôt que de retomber sur un défaut. Le module porte aussi `mapRow` (la règle de ligne, pure), `detectAmountSeparators`, `summarizeParsedRows` (le récapitulatif signé de l'aperçu), `flipSignFormat` et `clearMappingForMode`. + +**La règle de montant** : `amount = crédit − débit` sur des **magnitudes** (`Math.abs`), ce qui ne demande aucun cas particulier pour la colonne inutilisée remplie `0,00` (zéro est l'élément neutre de la soustraction) — le `isNaN(credit)` d'avant importait chaque débit d'un tel fichier à 0. Une ligne illisible dans les **deux** colonnes est une erreur de ligne, pas un 0,00 gratuit. `parseFrenchAmount` est **ancré** (plus de `parseFloat` rendant le plus long préfixe valide) : `100,00 CAD` rend NaN au lieu de 10000, `50,00-` rend −50, `(50,00)` rend −50. Le séparateur décimal est arbitré **par colonne** (`1.234` vaut 1234 en colonne française et 1.234 en anglaise ; aucune règle appliquée à la cellule seule ne peut trancher). L'écriture de la configuration se fait à `executeImport`, point d'écriture unique — un import abandonné à l'étape des doublons ne laisse aucune configuration derrière lui. + +### 2. La détection — `csvAutoDetect.ts` + `headerDictionary.ts` + `bankSignatures.ts` + +Trois modules distincts, évalués du plus spécifique au plus générique. + +| Module | Rôle | +|---|---| +| `bankSignatures.ts` | Dispositions d'export documentées de **Desjardins, RBC, BNC et Tangerine** : un jeu de libellés normalisés + délimiteur + préambule. Évalué **avant** le dictionnaire générique. `MIN_SIGNATURE_LABELS = 4` (`Date;Description;Montant` est trop banal pour attribuer un nom de banque). Porte aussi la **signature d'en-tête stockée** (`buildHeaderSignature` / `parseHeaderSignature` / `detectHeaderDrift`) : même objet — une ligne d'en-tête réduite à ses libellés normalisés — vu deux fois | +| `headerDictionary.ts` | Dictionnaire lexical FR/EN des rôles de colonnes (date ; description/libellé/détail/transaction ; montant/amount ; débit/retrait/déboursé/withdrawal ; crédit/dépôt/encaissement/deposit ; solde/balance) + `normalizeHeaderCell` / `matchHeaderColumn` / `matchTransactionHeaders`. **Module séparé de `csvAutoDetect.ts` par nécessité** : `montant` est le mot-clé *primaire* du montant pour les transactions et un token d'*exclusion* pour l'import de titres (`VALUE_HEADER_KEYWORDS`, #245) — deux tables qui ne doivent pas se percuter. Les helpers ont été **déplacés** plutôt qu'exportés-puis-réimportés, pour que la dépendance reste à sens unique | +| `csvAutoDetect.ts` | Heuristiques de forme (délimiteur, encodage, en-tête, colonnes candidates) + `detectImportFormat`, l'entrée pleine fidélité qui rend soit une config soit une **raison** de refus, et le score | + +**L'ordre débit/crédit vient du libellé, plus de la position.** La paire est toujours trouvée par la forme (colonnes creuses et complémentaires), mais laquelle est le débit est décidé par le dictionnaire — connaître l'une suffit. Avant, `Date;Description;Credit;Debit` inversait **tous** les signes, silencieusement (un total inversé ne fait que changer de signe). `detectHeader` gagne un **signal lexical** : une ligne que le test de forme rejette *uniquement* à cause d'un nombre est un en-tête si elle nomme ≥ 2 rôles (`MIN_HEADER_ROLE_MATCHES`). Chaque indice lexical reste une **préférence que les données peuvent contredire** ; sans en-tête, ou avec des libellés inconnus, tout retombe sur la forme exactement comme avant. + +**Un format est explicitement refusé** : montants absolus accompagnés d'une colonne d'indicateur `D`/`C` (ou `DB`/`CR`) adjacente portant ≥ 2 valeurs distinctes. Il était auparavant indiscernable d'un fichier tout-positif, déduit `positive_expense`, et **chaque dépôt s'importait en dépense**. Message dédié (`import.errors.absoluteIndicatorFormat`) plutôt qu'un import de travers. + +**Le score de confiance** (`DetectionScore` : `readRows`, `totalRows`, `ratio`, `confident`, seuil `CONFIDENCE_THRESHOLD = 0.9`) rejoue la configuration décidée sur **toutes** les lignes du fichier via `mapRow` — la fonction de production, pas un second mappeur — sous la même arbitration décimale. Il **colore, il ne bloque pas** : un score parfait ne dit rien du **signe** (le fixture `all-positive` score 100 % alors que chaque crédit s'importe en dépense). La détection se déclenche **seule** sur une source sans format enregistré, et **jamais** sur une source qui en a un (garde `!existing`) — le format stocké gagne. Le score est invalidé au changement de source, à l'édition manuelle du format et au chargement d'un modèle. + +### 3. Le wizard — l'aperçu est obligatoire + +L'étape `file-preview`, déclarée depuis l'origine dans `ImportWizardStep` sans qu'aucun dispatch ne la vise, est **rendue et traversée à chaque import** ; `FilePreviewModal` (l'aperçu optionnel qui l'avait supplantée) est supprimé. `checkDuplicates` en est le bouton « suivant », donc les lignes validées sont exactement les lignes vérifiées, sans second parsing entre les deux. L'étape n'est gatée par rien — surtout pas par le seuil de 90 %, puisque le cas qu'elle attrape est précisément le score parfait au signe inversé. + +- **Récapitulatif signé** (`summarizeParsedRows`) : sorties et leur total, entrées et le leur, lignes en erreur. Les totaux restent **signés** — des magnitudes masqueraient la seule chose que le récap expose. Calculé sur tout le fichier, jamais sur les vingt lignes affichées. +- **« Inverser les signes »** (`flipSignFormat`) agit sur la **configuration**, donc la correction est mémorisée avec la source. En mode `single` elle bascule la convention ; en `debit_credit` elle **échange les deux indices de colonnes**, parce que `mapRow` y calcule `crédit − débit` sur des magnitudes et ne lit jamais la convention — une bascule y aurait été inerte. +- **`FormatDriftPanel`** s'ouvre au-dessus de l'aperçu quand l'en-tête normalise différemment de `header_signature`, colonne par colonne (« Montant : colonne 3 → 4 »), avec les deux seules issues qui existent : **adopter** le format re-détecté ou **conserver** celui enregistré. Un renommage cosmétique normalise à l'identique et ne dit rien. Une source dont les fichiers n'ont pas d'en-tête garde `header_signature` NULL et la détection de dérive y est inopérante — documenté, pas contourné. +- **`RepairPathNotice`**, rendu dans le panneau de dérive **et** à côté du bouton d'inversion : `findDuplicates` apparie sur date + description + montant, donc ré-importer un fichier « maintenant qu'il se lit bien » ne corrige pas les lignes déjà écrites — il les **double**, et une inversion de signe produit des paires miroir qui s'annulent dans tous les rapports. La seule voie sûre est `deleteImportWithTransactions` puis rejeu. Aucun montant déjà écrit n'est jamais muté. +- **`ImportConfirmation`** énonce désormais le mode de montant, la convention de signe (seulement dans le mode qui l'applique) et le mapping nommé par en-tête. Le sélecteur de convention est **masqué** en mode débit/crédit, où le parsing ne le lit pas — masqué, pas réinitialisé : la valeur stockée est intacte. + +### 4. Le format SREF porte les sources et les modèles + +`dataExportService` sérialisait uniquement catégories, fournisseurs, mots-clés et transactions, puis exécutait `DELETE FROM import_sources` à la restauration en les remplaçant par une source synthétique « Data Import » — restaurer une sauvegarde détruisait toute configuration d'import. Depuis #331 : `import_sources` et `import_config_templates` sont dans l'enveloppe derrière un `format_version` explicite (`SREF_FORMAT_VERSION = 2` ; une enveloppe sans le champ est l'ancien format, ses tableaux absents traités comme vides). Les **modèles sont restaurés avant les sources** (`template_id` est une FK), upsertés par nom, et `template_id` remappé sur les identifiants résolus. Wipe et restauration des deux chemins sont enveloppés dans `withTransaction` — le service n'en avait aucune, et une violation de contrainte en cours de restauration détruisait l'historique financier sans rollback. `amount_mode` et `sign_convention` sont whitelistés à la frontière d'import avec un message lisible (le `CHECK` v17 refuse déjà les mauvaises valeurs, mais une erreur de contrainte SQLite n'est pas un message utilisateur). + +### Corpus de fixtures — `src/__fixtures__/csv/` + +15 fichiers CSV **synthétiques** (aucune donnée réelle, aucun relevé réel n'entre dans ce dépôt — les signatures de banques sont écrites depuis des dispositions documentées). Ils couvrent des formes qu'un vrai relevé ne réunit jamais toutes : montant signé, débit/crédit, débit/crédit inversés, colonne inutilisée à `0,00`, préambule, en-tête contenant un nombre, en-tête à libellé numérique, sans en-tête, tout-positif, Desjardins quoté, montant absolu + indicateur, plus les quatre dispositions de banque. Posés en #326 **avant** la refonte, avec le comportement fautif figé et marqué `KNOWN DEFECT` + l'issue devant le corriger ; chaque maillon suivant a mis à jour l'attente et retiré le marqueur, sans jamais supprimer un test. + ## Gating par édition (licence) Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de licence (`free` / `base` / `premium`) **côté interface uniquement** — un soft-paywall assumé (l'app est GPL ; le seul gate appliqué côté serveur reste la récupération de cours Premium via maximus-api). La matrice complète tier → modules, le rationale et les alternatives rejetées sont dans l'[ADR 0017](adr/0017-feature-gating-par-tier.md). @@ -383,7 +435,7 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF | 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 | +| `/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 | @@ -478,3 +530,5 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d | [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted | | [0016](adr/0016-persistance-etat-ui-par-profil.md) | Persistance de l'état UI par profil : repli des catégories dans `user_preferences` | 2026-07-15 | Accepted | | [0017](adr/0017-feature-gating-par-tier.md) | Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé | 2026-07-20 | Accepted | +| [0018](adr/0018-suppression-advisories-non-atteignables.md) | Suppression d'advisories non atteignables : admission sur preuve par cible livrée, clé par ID, garde-fou bloquant | 2026-07-27 | Accepted | +| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted | diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index dcc6316..27f6f9f 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -100,8 +100,12 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista ### Fonctionnalités -- Assistant d'import multi-étapes avec aperçu des données -- Mapping de colonnes configurable, délimiteur et format de date +- Détection automatique du format à l'ouverture d'une source jamais configurée : délimiteur, encodage, format de date et colonnes reconnues par leur libellé (français et anglais) +- Formats reconnus par banque — Desjardins, RBC, Banque Nationale et Tangerine sont lus par leur nom +- Score de confiance affiché après détection (« 147 des 150 lignes lues ») : il indique ce qui a pu être lu, jamais si le sens est bon +- Aperçu obligatoire avant chaque import, avec un récapitulatif signé : sorties, entrées, totaux et lignes en erreur, plus un bouton « Inverser les signes » +- Avertissement quand l'en-tête d'un fichier ne correspond plus à celui du dernier import réussi, colonne par colonne +- Mapping de colonnes configurable, délimiteur, encodage et format de date modifiables à tout moment - Détection automatique des doublons (dans le lot et contre les données existantes) - Modèles d'import pour sauvegarder et réutiliser les configurations - Historique des imports avec possibilité de supprimer les imports précédents @@ -110,16 +114,24 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista 1. Définissez votre dossier d'import via le sélecteur de dossier en haut de la page 2. Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV -3. Cliquez sur une source pour ouvrir l'assistant d'import -4. Configurez le délimiteur, l'encodage, le format de date et le mapping des colonnes -5. Sélectionnez les fichiers à importer et prévisualisez les données analysées -6. Vérifiez les doublons, examinez le résumé, puis confirmez l'import +3. Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat +4. Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain +5. Sélectionnez les fichiers à importer +6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants +7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de cette banque se liront correctement d'eux-mêmes +8. Vérifiez les doublons, examinez le résumé, puis confirmez l'import ### Astuces -- Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois +- Le récapitulatif de l'aperçu est le seul contrôle qui voit le **sens** des montants : un score de 100 % signifie que chaque ligne a été lue, pas que vos dépenses ne sont pas comptées comme des revenus. Prenez deux secondes pour le lire. +- Votre configuration est mémorisée intégralement sur la source, y compris le mode de montant et la convention de signe : une source configurée une fois n'est plus jamais re-devinée aux imports suivants +- Si votre banque change la disposition de ses colonnes, un panneau le signale avant l'import et propose deux choix : adopter le nouveau format détecté, ou conserver celui enregistré +- Pour réparer un import déjà écrit de travers, **ne ré-importez pas le fichier par-dessus** : les doublons sont repérés sur la date, la description ET le montant, donc une ligne corrigée s'ajoute au lieu de remplacer la fautive. Supprimez d'abord l'import fautif dans l'historique, puis rejouez le fichier. +- Un relevé à montants positifs accompagné d'une colonne « D / C » est refusé plutôt qu'importé à l'envers : réexportez-le depuis votre banque avec des montants signés, ou avec deux colonnes débit et crédit séparées +- Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois. Un modèle sert à initialiser une source ; le modifier ensuite ne change aucune source déjà configurée. - Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons - Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions +- Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer --- diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d3ec25e..e900fa2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -855,8 +855,12 @@ "title": "Import", "overview": "Import bank statements from CSV files using a step-by-step wizard. Each bank account is represented as a source folder.", "features": [ - "Multi-step import wizard with data preview", - "Configurable column mapping, delimiter, and date format", + "Automatic format detection when you open a source that was never configured: delimiter, encoding, date format, and columns recognised by their header label (French and English)", + "Bank layouts recognised by name — Desjardins, RBC, National Bank and Tangerine are read by their documented export format", + "A confidence score shown after detection (“147 of 150 rows read”): it tells you what could be read, never whether the meaning is right", + "A mandatory preview before every import, with a signed recap: outflows, inflows, totals and rows in error, plus a “Flip the signs” button", + "A warning when a file's header no longer matches the one from the last successful import, column by column", + "Configurable column mapping; delimiter, encoding and date format editable at any time", "Automatic duplicate detection (within batch and against existing data)", "Import templates to save and reuse source configurations", "Import history with the ability to delete past imports" @@ -864,15 +868,23 @@ "steps": [ "Set your import folder via the folder picker at the top of the page", "Create a subfolder for each bank/source and place CSV files inside", - "Click on a source to open the import wizard", - "Configure the delimiter, encoding, date format, and column mapping", - "Select which files to import and preview the parsed data", + "Click on a source to open the import wizard — the first time, the format is detected automatically and a banner announces the result", + "Check the proposed configuration (delimiter, encoding, date format, column mapping) and correct it if the banner reports an uncertain format", + "Select which files to import", + "Read the preview: the recap tells you how many outflows and inflows the file will produce, and for what amounts", + "If inflows and outflows are swapped, click “Flip the signs” — the correction is saved with the source, so the next files from that bank read correctly on their own", "Check for duplicates, review the summary, then confirm the import" ], "tips": [ - "Save your configuration as a template so you don't have to reconfigure each time", + "The preview recap is the only check that sees what the amounts mean: a 100 % score means every row was read, not that your expenses are not being counted as income. Take two seconds to read it.", + "Your configuration is stored in full on the source, including the amount mode and the sign convention: a source configured once is never guessed at again on later imports", + "If your bank changes its column layout, a panel reports it before the import and offers two choices: adopt the newly detected format, or keep the stored one", + "To repair an import that was already written the wrong way round, do not re-import the file on top of it: duplicates are matched on date, description AND amount, so a corrected row is added instead of replacing the faulty one. Delete the faulty import from the history first, then replay the file.", + "A statement with positive amounts plus a “D / C” column is refused rather than imported backwards: re-export it from your bank with signed amounts, or with separate debit and credit columns", + "Save your configuration as a template so you don't have to reconfigure each time. A template initialises a source; editing it afterwards changes no source you have already configured.", "Files already imported are marked with a badge — re-importing them will trigger duplicate detection", - "You can delete an import from the history to remove all its transactions" + "You can delete an import from the history to remove all its transactions", + "Your import sources and templates are now part of the encrypted backup: restoring a backup no longer forces you to reconfigure everything" ] }, "transactions": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2db2e8d..cf61a71 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -855,8 +855,12 @@ "title": "Import", "overview": "Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assistant étape par étape. Chaque compte bancaire est représenté par un dossier source.", "features": [ - "Assistant d'import multi-étapes avec aperçu des données", - "Mapping de colonnes configurable, délimiteur et format de date", + "Détection automatique du format à l'ouverture d'une source jamais configurée : délimiteur, encodage, format de date et colonnes reconnues par leur libellé (français et anglais)", + "Formats reconnus par banque — Desjardins, RBC, Banque Nationale et Tangerine sont lus par leur nom", + "Score de confiance affiché après détection (« 147 des 150 lignes lues ») : il indique ce qui a pu être lu, jamais si le sens est bon", + "Aperçu obligatoire avant chaque import, avec un récapitulatif signé : sorties, entrées, totaux et lignes en erreur, plus un bouton « Inverser les signes »", + "Avertissement quand l'en-tête d'un fichier ne correspond plus à celui du dernier import réussi, colonne par colonne", + "Mapping de colonnes configurable, délimiteur, encodage et format de date modifiables à tout moment", "Détection automatique des doublons (dans le lot et contre les données existantes)", "Modèles d'import pour sauvegarder et réutiliser les configurations", "Historique des imports avec possibilité de supprimer les imports précédents" @@ -864,15 +868,23 @@ "steps": [ "Définissez votre dossier d'import via le sélecteur de dossier en haut de la page", "Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV", - "Cliquez sur une source pour ouvrir l'assistant d'import", - "Configurez le délimiteur, l'encodage, le format de date et le mapping des colonnes", - "Sélectionnez les fichiers à importer et prévisualisez les données analysées", + "Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat", + "Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain", + "Sélectionnez les fichiers à importer", + "Examinez l'aperçu : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants", + "Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de cette banque se liront correctement d'eux-mêmes", "Vérifiez les doublons, examinez le résumé, puis confirmez l'import" ], "tips": [ - "Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois", + "Le récapitulatif de l'aperçu est le seul contrôle qui voit le sens des montants : un score de 100 % signifie que chaque ligne a été lue, pas que vos dépenses ne sont pas comptées comme des revenus. Prenez deux secondes pour le lire.", + "Votre configuration est mémorisée intégralement sur la source, y compris le mode de montant et la convention de signe : une source configurée une fois n'est plus jamais re-devinée aux imports suivants", + "Si votre banque change la disposition de ses colonnes, un panneau le signale avant l'import et propose deux choix : adopter le nouveau format détecté, ou conserver celui enregistré", + "Pour réparer un import déjà écrit de travers, ne ré-importez pas le fichier par-dessus : les doublons sont repérés sur la date, la description ET le montant, donc une ligne corrigée s'ajoute au lieu de remplacer la fautive. Supprimez d'abord l'import fautif dans l'historique, puis rejouez le fichier.", + "Un relevé à montants positifs accompagné d'une colonne « D / C » est refusé plutôt qu'importé à l'envers : réexportez-le depuis votre banque avec des montants signés, ou avec deux colonnes débit et crédit séparées", + "Sauvegardez votre configuration comme modèle pour ne pas avoir à reconfigurer à chaque fois. Un modèle sert à initialiser une source ; le modifier ensuite ne change aucune source déjà configurée.", "Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons", - "Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions" + "Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions", + "Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer" ] }, "transactions": {