The import wizard forgets its format between runs: import_sources carries neither amount_mode nor sign_convention, so a source configured for positive expenses silently flips every amount on its second import. Force-added despite .gitignore so /autopilot workers can read them from a worktree — same precedent as PR #295 (ADR 0016 shipped a dead Spec: line). Plan reviewed by the 3-expert pass: 7 criticals integrated, 8 decisions drained. Milestone planned-2026-08-12-import-csv-format (#323-#332). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
43 KiB
Spec Plan — Import CSV et reconnaissance de format
Date: 2026-08-12 Projet: simpl-resultat Statut: Draft Slug: import-csv-format Decisions: spec-decisions-import-csv-format.md
Design
UX / Interface
Le wizard passe de six étapes rendues à sept. ImportWizardStep déclare déjà file-preview (types/index.ts:543) sans que ImportPage.tsx ne la rende jamais — le modal optionnel l'avait supplantée. L'étape est restaurée et devient obligatoire.
Étape configuration. À l'ouverture d'une source jamais configurée, la détection se lance seule ; le bouton baguette magique reste pour la rejouer. Le panneau gagne un bandeau de résultat : soit une banque reconnue par signature (« Format Desjardins reconnu »), soit un score générique (« Format reconnu — 147 des 150 lignes lues »), soit un avertissement sous le seuil. Le sélecteur de convention de signe, aujourd'hui affiché en permanence alors que le parsing ne l'applique qu'en mode simple (useImportWizard.ts:514), n'apparaît plus qu'en mode montant unique.
Étape aperçu. Nouvelle étape traversée à chaque import. Au-dessus du tableau des vingt premières lignes, un récapitulatif signé : nombre de sorties et total, nombre d'entrées et total, nombre de lignes en erreur. C'est le contrôle qui rattrape visuellement toute erreur de convention, quelle qu'en soit la cause. Un bouton Inverser les signes bascule sign_convention et relance le parsing — il agit sur la configuration, jamais sur les données seules, pour que la correction soit mémorisée.
Étape confirmation. Le récapitulatif des réglages passe de quatre entrées à sept : le mode de montant, la convention de signe et le mapping des colonnes rejoignent délimiteur, encodage, format de date et lignes ignorées.
🟡 SECURITE — « Inverser les signes » est inerte en mode débit/crédit.
sign_conventionn'est appliqué que dans la branche montant unique (useImportWizard.ts:514), et l'issue 6 masque son sélecteur en mode débit/crédit : le bouton présenté comme rattrapant « toute erreur de convention, quelle qu'en soit la cause » ne fait rien sur un fichier dont les deux colonnes sont mappées à l'envers. Resolution : En mode débit/crédit, le même bouton permutedebitAmountetcreditAmountdans le mapping puis relance le parsing.
Dérive de format. Au ré-import d'une source connue dont l'en-tête ne correspond plus à la signature mémorisée, un panneau présente l'écart colonne par colonne (« Montant : 3 → 4 ») avec deux issues : adopter la configuration re-détectée, ou conserver l'ancienne.
Donnees
Migration v17 — additive, v1 à v16 intactes.
ALTER TABLE import_sources ADD COLUMN amount_mode TEXT NOT NULL DEFAULT 'single'
CHECK (amount_mode IN ('single','debit_credit','absolute_indicator'));
ALTER TABLE import_sources ADD COLUMN sign_convention TEXT NOT NULL DEFAULT 'negative_expense'
CHECK (sign_convention IN ('negative_expense','positive_expense'));
ALTER TABLE import_sources ADD COLUMN header_signature TEXT;
ALTER TABLE import_sources ADD COLUMN template_id INTEGER REFERENCES import_config_templates(id) ON DELETE SET NULL;
UPDATE import_sources SET amount_mode = 'debit_credit'
WHERE column_mapping LIKE '%debitAmount%';
Le backfill reproduit exactement la règle appliquée aujourd'hui à la volée (useImportWizard.ts:321), donc aucune source ne change de comportement à la migration. Le test LIKE est préféré à json_extract pour ne dépendre d'aucune extension JSON1 dans le SQLite embarqué.
Les deux colonnes d'énumération portent un CHECK, conformément au pattern de la v15 sur balance_accounts.kind. Celui d'amount_mode accepte d'emblée absolute_indicator, la valeur du troisième mode laissé hors scope : la contrainte protège dès aujourd'hui contre une valeur corrompue, et le mode pourra être implémenté plus tard sans migration. La liste blanche applicative de la frontière SREF reste nécessaire — elle produit un message lisible là où la base ne rendrait qu'une erreur de contrainte.
🟢 ARCHITECTURE — L'objectif (ajouter le 3e mode sans migration) s'atteint sans renoncer à la contrainte :
CHECK (amount_mode IN ('single','debit_credit','absolute_indicator'))tient dès aujourd'hui et accepte déjà la valeur future. Resolution : Écrire leCHECKélargi en v17 plutôt que de l'omettre.
🟡 SECURITE — Sans
CHECK, une valeur inconnue restaurée depuis une sauvegarde SREF éditable retombe en silence sur un défaut :useImportWizard.ts:501brancheif (amountMode === "debit_credit") … else, donc toute autre valeur lit la mauvaise colonne, et toute valeur autre quepositive_expensesignifienegative_expense. C'est exactement la classe d'erreur silencieuse que le chantier existe pour tuer. Resolution : Valider les deux champs contre une liste blanche à la frontière d'import SREF et à la lecture dansimportSourceService, avec erreur visible plutôt que repli. Ref : CWE-20
consolidated_schema.sql reçoit les quatre colonnes, non pour alimenter les nouveaux profils — ils les reçoivent de la v17, puisque le script consolidé s'exécute après toutes les migrations et n'utilise que CREATE TABLE IF NOT EXISTS — mais pour rester la définition de référence testée. Une constante V17_SQL miroir est ajoutée côté tests, conformément au pattern V13_SQL à V16_SQL, avec un test de parité sur le modèle de consolidated_schema_has_holdings_tables_and_kind_at_parity (lib.rs:2824) : sans lui, les DEFAULT et les CHECK peuvent diverger entre les deux définitions.
🟢 SECURITE + TECHNIQUE — Le rationale est inversé, vérification faite.
get_new_profile_init_sqls'exécute après que tauri-plugin-sql a appliqué toutes les migrations, et le script consolidé utiliseCREATE TABLE IF NOT EXISTS: les quatre colonnes qui y sont ajoutées sont inertes en production. Les nouveaux profils les reçoivent de la v17 seule. Le miroir sert au test de parité, pas au chemin de production. Resolution : Corriger la formulation et exiger un test de parité sur le modèle deconsolidated_schema_has_holdings_tables_and_kind_at_parity(lib.rs:2824), sans quoi leDEFAULTdesign_conventionpeut diverger entre les deux définitions.
Après la v17, import_sources et import_config_templates portent les mêmes huit champs de format. L'asymétrie qui a causé le bug racine disparaît structurellement.
template_id est une étiquette de provenance : elle enregistre le modèle depuis lequel la source a été configurée et n'est jamais relue comme format. Le format en vigueur est toujours celui des huit colonnes de la source. La colonne sert à l'affichage (« configurée depuis le modèle Desjardins ») et au signalement de divergence ; éditer un modèle ne modifie aucune source liée, ce qu'un critère d'acceptation vérifie.
🟡 ARCHITECTURE —
template_idréintroduit une seconde source de vérité que la migration venait d'éliminer. Les huit champs sont déjà copiés sur la source ; orupdateConfigTemplatemodifie un modèle en place (useImportWizard.ts:965), donc le format d'une source liée diverge silencieusement de son modèle, sans qu'aucune règle ne dise lequel fait foi. Resolution : Soit retirertemplate_id(YAGNI — le format est déjà copié), soit énoncer dans le plan et l'ADR qu'il est une étiquette de provenance, jamais relue comme format, avec un critère d'acceptation prouvant qu'éditer un modèle ne modifie aucune source liée.
Format SREF. Le fichier d'export gagne deux tableaux, import_sources et import_config_templates. À l'import, les sources sont restaurées au lieu d'être détruites ; la source factice « Data Import » n'est créée que pour rattacher des transactions orphelines. Une sauvegarde produite avant ce changement ne porte aucun de ces tableaux : l'import doit alors conserver le comportement actuel plutôt qu'échouer.
Architecture
Deux types et un codec. ImportFormatRow est la forme persistée — snake_case, mapping en JSON, has_header normalisé — partagée par import_sources et import_config_templates. ImportFormat est la forme de domaine — camelCase, mapping parsé — manipulée par le wizard et la détection. Une paire formatToRow / formatFromRow est le seul point de conversion entre les deux.
La garantie recherchée ne vient donc pas de la structure des types, qui ne peut pas être commune (les porteurs actuels mélangent les casses et typent has_header en boolean d'un côté, number de l'autre), mais du codec et de son test : un champ ajouté au format sans être traversé par le codec fait échouer le test de complétude. C'est ce qui ferme la classe d'erreur à l'origine du chantier.
🔴 ARCHITECTURE + TECHNIQUE — La composition est structurellement impossible telle qu'écrite : les porteurs ont des formes incompatibles, vérifiées dans
types/index.ts—ImportSource.has_header: boolean(:12) etcolumn_mapping: string(:10),ImportConfigTemplate.has_header: number(:164),SourceConfig.hasHeader: booleanen camelCase aveccolumnMapping: ColumnMappingobjet (:225), plusAutoDetectResultqui ne porte que 7 des 8 champs (pas d'encoding). Un type unique composé dans les quatre ne peut pas exister sans trancher une casse et une représentation du mapping. Resolution : Deux types et un codec :ImportFormatRow(persisté, snake_case, mapping en JSON,has_headernormalisé) etImportFormat(domaine, camelCase, mapping parsé), reliés par une paireformatToRow/formatFromRowunique point de conversion. La garantie de complétude vient alors du codec et de son test, pas de la structure du type.
La détection gagne une couche lexicale en amont de l'heuristique existante. normalizeHeaderCell et matchHeaderColumn (csvAutoDetect.ts:633-666) sont déjà au niveau module et sont réutilisés tels quels ; c'est le dictionnaire qui est nouveau, et il vit dans son propre module plutôt que dans csvAutoDetect.ts, déjà long de 856 lignes pour deux flux sans rapport. La séparation n'est pas cosmétique : montant est un token d'exclusion pour les titres (VALUE_HEADER_KEYWORDS, :630) et le mot-clé de montant principal pour les transactions — deux tables distinctes, deux flux qui ne se marchent pas dessus.
🟡 ARCHITECTURE — Le « remontage » est un no-op : les deux fonctions sont déjà au niveau module du même fichier que
autoDetectConfig(:633,:646). La mitigation du tableau des risques couvre donc un risque inexistant, tandis que le vrai couplage n'est pas traité — les tables de mots-clés sont partagées, etmontantest un token d'exclusion pour les holdings (VALUE_HEADER_KEYWORDS,:630) alors qu'il est le mot-clé de montant principal pour les transactions. Resolution : Retirer la tâche de remontage ; placer le dictionnaire transactions dans son propre module à côté debankSignatures.ts, important les deux helpers et laissant les tables holdings intactes.csvAutoDetect.tsfait déjà 856 lignes pour deux flux sans rapport. Le dictionnaire FR/EN couvre date, description (libellé, détail), montant, débit (retrait, déboursé), crédit (dépôt, encaissement) et solde. Quand les libellés tranchent, ils priment ; quand ils sont muets — fichier sans en-tête, libellés inconnus — l'heuristique de forme actuelle reprend la main. C'est ce qui résout d'un coup l'ordre débit/crédit deviné par position (csvAutoDetect.ts:461-470), la détection d'en-tête aveugle aux nombres et le choix de la colonne description par longueur moyenne.
La règle de mapping d'une ligne — date, montant, application du signe — est extraite de parseFilesInternal en une fonction pure exportée mapRow(raw, format): ParsedRow. Elle devient le point unique consommé par le wizard, par le calcul de score et par le récapitulatif d'aperçu. Sans cette extraction, le score réimplémenterait la règle et pourrait afficher 100 % pendant que l'import écrit de mauvais signes — la divergence même que ce chantier combat. Elle rend au passage la règle testable unitairement, le dépôt n'ayant pas de jsdom (d'où le pattern d'export des reducers de useSnapshotEditor.ts).
autoDetectConfig retourne désormais un score : la configuration détectée est rejouée via mapRow sur les lignes de l'échantillon et le taux de lignes parsées sans erreur est remonté à l'appelant.
🔴 ARCHITECTURE + TECHNIQUE — Rejouer la configuration exige la règle de mapping de ligne (date + montant + signe), qui vit dans
parseFilesInternal, unuseCallbackdeuseImportWizard.ts:461-557.autoDetectConfigest un util pur qui ne reçoit querawContent: calculer le score dans le module de détection en écrirait une seconde implémentation. Deux mappers, c'est précisément la classe de divergence que ce chantier existe pour tuer — le score pourrait afficher 100 % pendant que l'import écrit de mauvais signes. Resolution : Extraire en issue 3 une fonction pure exportéemapRow(raw, format): ParsedRowdanssrc/utils/, consommée par le hook, le calcul de score et le récapitulatif d'aperçu. Elle rend au passage possibles les tests unitaires promis par l'issue 3 — le dépôt n'a pas de jsdom, d'où le pattern d'export des reducers et builders deuseSnapshotEditor.ts.
Les signatures de banques sont un tableau de règles déclaratives — ensemble de libellés d'en-tête normalisés, délimiteur, particularités de préambule. Elles sont évaluées avant le dictionnaire générique et n'ajoutent aucune surface d'interface.
Le point d'écriture de la configuration en base migre de checkDuplicatesInternal (useImportWizard.ts:588-606) vers executeImport, pour qu'un import abandonné ne laisse plus de configuration derrière lui.
Plan de travail
Issue 1 — Migration v17 : le format complet sur les sources [type:schema]
Dependances : Issue 4
- Migration v17 dans
lib.rs: quatre colonnes + backfillamount_mode - Miroir dans
consolidated_schema.sql - Constante
V17_SQL+ test d'application sur une base v16 - Test : le backfill reproduit la règle actuelle (source avec
debitAmount→debit_credit, sans →single) - Test de parité entre
consolidated_schema.sqlet la chaîne v1→v17 (colonnes,DEFAULT,CHECK) - Test de non-régression : les chaînes SQL
v1àv16absentes du diff
🟡 TECHNIQUE — « Checksums intacts » n'est pas une assertion testable ici : aucun harnais de checksum n'existe. Les constantes
V10_SQLàV16_SQLsont des copies manuelles appliquées viaexecute_batch, et les checksums ne vivent qu'au runtime dans_sqlx_migrations(réparés parprofile_commands.rs:223-275). Un worker autonome va soit bâcler la case, soit y brûler un cycle. Resolution : Remplacer par ce que le harnais vérifie réellement — les chaînes SQL v1 à v16 absentes du diff, plus l'application deV17_SQLsur une base v16 peuplée — et retirer le mot « checksums ». Ajouter le test de parité du schéma consolidé.
Issue 2 — Persister et restaurer le format d'import [type:bug]
Dependances : Issue 1
- Type
ImportFormatpartagé ;ImportSourceetImportConfigTemplatele composent importSourceService: les quatre nouvelles colonnes en création, mise à jour et lectureuseImportWizard.selectSource: lireamount_modeetsign_convention; supprimer la valeur en dur (:323) et la ré-inférence du mode (:321)ColumnMappingEditor.onAmountModeChangenettoie les colonnes du mode abandonné- Déplacer l'écriture de la config de
checkDuplicatesInternalversexecuteImport - Lier la source au modèle appliqué (
template_id), persisté et restauré - Test de cycle : configurer → sauvegarder → recharger → le format est identique au bit près
Issue 3 — Règle débit/crédit et parsing des montants [type:bug]
Dependances : Issue 2
amount = crédit − débitsur magnitudes, en remplacement de la comparaison de nullité (:509)- Colonne inutilisée à
0,00traitée comme absente - Supprimer les fallbacks
?? 0(:504-512) au profit d'une erreur de ligne « colonne de montant non mappée » parseFrenchAmount: parenthèses comptables(50,00)et signe suffixe50,00-- Séparateur décimal arbitré au niveau de la colonne et non de la cellule
- Validation ancrée :
parseFrenchAmountrendNaNsur tout caractère résiduel après normalisation — aujourd'hui"50,00-"rend 5000,"1 234,56 CR"rend 123456 et"100,00 CAD"rend 10000, erreurs de facteur 100 qui passentisNaN - Durcissement appliqué partout (décision tranchée) : vérifier les 11 sites d'appel — 8 dans
csvAutoDetect.ts(:224,:289,:323,:489,:517,:543-544,:712) et 3 dansuseSnapshotEditor.ts:191-202(import CSV de titres, #245) - Extraire
mapRow(raw, format): ParsedRowpur et exporté depuisparseFilesInternal, consommé par le wizard, le score et l'aperçu - Tests unitaires sur chaque cas, dans le nouveau
src/utils/amountParser.test.ts - Régression : les tests holdings existants restent verts
🔴 SECURITE — Le défaut est plus grave que décrit, vérifié en exécutant la fonction :
parseFrenchAmounttermine surparseFloat, qui s'arrête au premier caractère invalide au lieu de rejeter."50,00-"rend 5000,"1 234,56 CR"rend 123456,"100,00 CAD"rend 10000 — une erreur de facteur 100, pas un signe perdu. Chacune passeisNaNet sera donc comptée comme ligne valide dans le nouveau récapitulatif signé, ce qui neutralise le filet de sécurité principal du chantier. Ajouter la forme50,00-traite un suffixe et laisse passer tous les autres. Resolution : Valider la chaîne entière par une regex ancrée après normalisation et rendreNaNsur tout caractère résiduel. Ajouter ces trois chaînes aux tests unitaires. Ref : CWE-1284
🔴 TECHNIQUE —
parseFrenchAmounta 11 sites d'appel que l'issue ne liste pas : 8 danscsvAutoDetect.ts(:224detectHeader,:289,:323,:489,:517detectSingleAmount,:543-544isSparseComplementary,:712holdings) et 3 dansuseSnapshotEditor.ts:191-202— l'import CSV de titres (#245).useSnapshotEditor.tsest absent du tableau « Fichiers concernés ». Changer le parser déplace silencieusementhasNumber,negCountet les quantités de titres. Resolution : AjoutercsvAutoDetect.tsetuseSnapshotEditor.tsau périmètre de l'issue, avec une case de régression sur les tests holdings, et trancher explicitement si les nouvelles formes sont globales ou opt-in.
Issue 4 — Corpus de fixtures CSV [type:feature]
Dependances : aucune — premier maillon de la pile
🔴 TECHNIQUE + ARCHITECTURE — Le corpus censé « figer le comportement avant la refonte » arrive après le changement qu'il doit servir de référence. L'issue 3 modifie
parseFrenchAmount, dont dépendentdetectHeader,detectSingleAmount,pickBestAmountColumnetisSparseComplementary: la ligne de base enregistrerait donc un comportement déjà modifié. Resolution : Déplacer cette issue en première position — elle ne dépend de rien, ni migration ni persistance. Nouvel ordre : 4 → 1 → 2 → 3 → 5 → … Les issues 3, 5 et 6 se mesurent alors toutes à un contrat préexistant. Étendre les assertions aux sites d'appel holdings.
- Fixtures synthétiques : montant signé, débit/crédit, débit/crédit inversés, colonne à
0,00, préambule, en-tête contenant un nombre, sans en-tête, tout-positif, ligne entière entre guillemets - Tests de contrat figeant le comportement de
autoDetectConfigavant sa refonte - Tests de bout en bout du parsing sur chaque fixture
Issue 5 — Détection par libellé d'en-tête [type:feature]
Dependances : Issue 3
- Remonter
normalizeHeaderCelletmatchHeaderColumnen helpers partagés du module - Dictionnaire FR/EN : date, description, montant, débit, crédit, solde
- Ordre débit/crédit résolu par libellé plutôt que par position
detectHeader: signal lexical en complément de la forme- Repli sur l'heuristique actuelle quand les libellés sont muets
- Dictionnaire dans son propre module, hors de
csvAutoDetect.ts— les tables holdings restent intactes (montanty est un token d'exclusion) - Refus du troisième format : détecter montants tous positifs + colonne voisine à une seule lettre
D/C, et refuser explicitement avec un message dédié plutôt que d'importer chaque débit comme un revenu - Tests sur les fixtures, dont l'inversion crédit-avant-débit et le fichier à indicateur refusé
🟡 SECURITE — Le document de décisions promet qu'un fichier au format « montant absolu + indicateur
D/C» sera « refusé explicitement plutôt qu'importé de travers », mais aucune issue du plan ne le détecte ni ne le refuse. En l'état, un tel fichier présente une colonne de montants tous positifs,detectSingleAmountrendpositive_expense, et chaque débit est importé comme un revenu. Resolution : Ajouter ici une règle de détection (colonne de montants tous positifs plus une colonne voisine à une seule lettreD/C) et un message de refus explicite, avec un critère d'acceptation et une fixture dans l'issue de corpus.
Issue 6 — Score de confiance et détection lancée d'office [type:feature]
Dependances : Issue 5
autoDetectConfigrejoue sa configuration sur l'échantillon et retourne un taux- Détection déclenchée seule à l'ouverture d'une source non configurée
- Bandeau de résultat dans
SourceConfigPanel: reconnu, score, ou avertissement - Convention de signe masquée en mode débit/crédit
- Clés i18n FR et EN
Issue 7 — Aperçu obligatoire et récapitulatif signé [type:feature]
Dependances : Issue 6
- Rendre l'étape
file-previewdansImportPage, traversée à chaque import - Récapitulatif : sorties et total, entrées et total, lignes en erreur
- Bouton Inverser les signes agissant sur la configuration, avec re-parsing
ImportConfirmationaffiche mode, convention et mappinguseImportWizard: nouvelle transition versfile-previewdans le reducer, recâblage decheckDuplicates(aujourd'hui code mort) et deparseAndCheckDuplicatesqui saute l'étapeImportPage: remplacer la paire de boutons Aperçu / Vérifier-doublons parWizardNavigation- Retirer
FilePreviewModal - En mode débit/crédit, Inverser les signes permute
debitAmountetcreditAmount - Clés i18n FR et EN
🟡 TECHNIQUE — L'étape est un changement de machine à états, pas un rendu, et le périmètre annoncé est trop étroit.
file-previewn'a aucune transition dans le reducer (SET_STEPne la vise jamais),parseAndCheckDuplicatessaute desource-configàduplicate-check(:719, commentaire « skips preview step »),ImportPageporte encore la paire de boutons Aperçu / Vérifier-doublons (:126-140) et leFilePreviewModal(:196-202) ; enfincheckDuplicates(:685, exporté:1006) est du code mort à recâbler. ModifierFilePreviewTable, seul fichier listé, mute aussi le modal encore vivant. Resolution : Étendre le périmètre àuseImportWizard(nouvelle transition + recâblage decheckDuplicates),ImportPage(remplacer la paire de boutons parWizardNavigation) etFilePreviewModal(le retirer).
Issue 8 — Signatures de banques et dérive de format [type:feature]
Dependances : Issue 7
- Signatures déclaratives Desjardins, RBC, BNC, Tangerine, évaluées avant le dictionnaire générique
header_signaturemémorisée à l'import réussi- Au ré-import : comparaison, re-détection, panneau d'écart avec adopter ou conserver
- Clés i18n FR et EN
- Le panneau de dérive et l'aperçu énoncent le seul chemin de réparation sûr : supprimer l'import fautif via l'historique avant de le rejouer — un ré-import corrigé ne s'apparie pas aux lignes déjà écrites et les double
- Tests sur fixtures par banque et sur un cas de dérive
Issue 9 — Sources et modèles dans l'export de données [type:bug]
Dependances : Issue 8
- Sérialiser
import_sourcesetimport_config_templatesà l'export - Restaurer les sources à l'import au lieu du
DELETEsuivi d'une source factice (:265,:362) - Envelopper purge et restauration dans
withTransaction— les deux fonctions n'en ont aucune aujourd'hui, une violation de contrainte à mi-course détruit l'historique sans retour arrière - Ordre de restauration : modèles avant sources ; stratégie d'identifiants explicite (upsert par nom + remap de
template_id),import_config_templatesn'étant dans aucune liste de purge - Liste blanche sur
amount_modeetsign_conventionà la frontière d'import, avec erreur lisible - Rétrocompatibilité : une sauvegarde antérieure sans ces tableaux s'importe comme aujourd'hui
- Tests de cycle export/import préservant les configurations
- Test : une restauration qui échoue à la ligne N laisse le profil intact
🔴 SECURITE — La restauration SREF n'est enveloppée dans aucune transaction :
dataExportService.tsne contient pas une seule occurrence dewithTransaction(vérifié), et enchaîneDELETE FROM transactions / imported_files / import_sources / keywords / suppliers / categories(:263-268,:360-362) puis desdb.executed'insertion. Cette issue ajoute deux boucles d'insertion de plus, sur des tables à contrainteUNIQUE(name)et une clé étrangèretemplate_id: la moindre violation abandonne la restauration à mi-course et l'historique financier de l'utilisateur est perdu sans retour arrière. Resolution : Envelopper l'ensemble purge + restauration des deux fonctions danswithTransaction, et ajouter un critère d'acceptation : une restauration qui échoue à la ligne N laisse le profil intact. Ref : CWE-460
🔴 SECURITE —
import_config_templatesn'apparaît dans aucune des deux listes de purge (vérifié) : réinsérer des modèles restaurés dans un profil qui en possède déjà échoue surUNIQUE constraint failed: import_config_templates.name. Ce n'est pas un cas théorique — restaurer dans un profil existant est le chemin normal. L'ordre de restauration n'est pas non plus spécifié alors quetemplate_idporte désormais une clé étrangère vers cette table. Resolution : Spécifier l'ordre (modèles avant sources) et la stratégie d'identifiants : soit purger aussiimport_config_templates, soit faire un upsert par nom et remapperimport_sources.template_idvers les identifiants résolus.
Issue 10 — Documentation [type:feature]
Dependances : Issue 9
docs/architecture.md: migration v17, quatre colonnes, couche de détection lexicale, nouvelle étape du wizard- ADR 0019 — le format d'import est une donnée persistée intégralement, jamais re-devinée
🟢 ARCHITECTURE —
.gitignore:71-72ignorespec-decisions-*.mdetspec-plan-*.md: l'ADR 0016 a livré une ligneSpec:morte pour cette raison exacte, corrigée par un force-add (PR #295). Resolution : Ajoutergit add -f spec-decisions-import-csv-format.md spec-plan-import-csv-format.mdà la checklist, avant d'écrire la ligneSpec:.
docs/guide-utilisateur.md+ clésdocs.*FR et ENCHANGELOG.mdetCHANGELOG.fr.mdsous[Unreleased]
Ordre d'execution
4 → 1 → 2 → 3 → 5 → 6 → 7 → 8 → 9 → 10
Pile strictement linéaire. Chaque maillon touche useImportWizard.ts, csvAutoDetect.ts ou les deux ; une exécution en vagues parallèles produirait des conflits sur ces deux fichiers à chaque étage. Le CHANGELOG est centralisé dans l'issue 10 pour la même raison.
🔴 TECHNIQUE + ARCHITECTURE — L'ordre place le corpus de contrat (issue 4) après le changement de parser qu'il doit servir de référence (issue 3). Ordre corrigé :
4 → 1 → 2 → 3 → 5 → 6 → 7 → 8 → 9 → 10Resolution : L'issue 4 ne dépend de rien et passe en tête ; la chaîne reste linéaire. Les issues Forgejo #323-#332 doivent être renumérotées en conséquence dans leurs lignes
Depends on.
Fichiers concernes
| Fichier | Action | Raison |
|---|---|---|
src-tauri/src/lib.rs |
Modifier | Migration v17 + constante V17_SQL + tests |
src-tauri/src/database/consolidated_schema.sql |
Modifier | Quatre colonnes sur import_sources pour les nouveaux profils |
src/shared/types/index.ts |
Modifier | Type ImportFormat, composition dans ImportSource et ImportConfigTemplate |
src/services/importSourceService.ts |
Modifier | Persistance des quatre colonnes en création, mise à jour, lecture |
src/services/importConfigTemplateService.ts |
Modifier | Alignement sur ImportFormat |
src/hooks/useImportWizard.ts |
Modifier | Restauration réelle, règle débit/crédit, point d'écriture, score, étape aperçu |
src/utils/csvAutoDetect.ts |
Modifier | Couche lexicale, helpers partagés, score, signatures de banques |
src/utils/amountParser.ts |
Modifier | Parenthèses comptables, signe suffixe, arbitrage par colonne |
src/hooks/useSnapshotEditor.ts |
Modifier | Manquant — 3 appels à parseFrenchAmount (:191-202), import CSV de titres (#245) |
src/utils/amountParser.test.ts |
Créer | Manquant — n'existe pas aujourd'hui, requis par l'issue de parsing |
src/components/import/ColumnMappingEditor.tsx |
Modifier | Le mode nettoie le mapping opposé |
src/components/import/SourceConfigPanel.tsx |
Modifier | Bandeau de détection, convention masquée en débit/crédit |
src/components/import/FilePreviewTable.tsx |
Modifier | Récapitulatif signé et bascule de convention |
src/components/import/ImportConfirmation.tsx |
Modifier | Mode, convention et mapping au récapitulatif |
src/components/import/FormatDriftPanel.tsx |
Créer | Panneau d'écart au ré-import |
src/pages/ImportPage.tsx |
Modifier | Étape file-preview rendue, panneau de dérive |
src/services/dataExportService.ts |
Modifier | Sources et modèles sérialisés et restaurés |
src/utils/bankSignatures.ts |
Créer | Signatures déclaratives par banque |
src/utils/csvAutoDetect.test.ts |
Modifier | Tests du flux transactions, aujourd'hui absents |
src/__fixtures__/csv/ |
Créer | Corpus synthétique |
🟡 TECHNIQUE + ARCHITECTURE — Le chemin
src/test/fixtures/csv/inventait une troisième convention :src/test/n'existe pas (vérifié). Les tests sont colocalisés (src/utils/*.test.ts,src/services/*.test.ts), les fixtures vivent danssrc/__fixtures__/et les tests d'intégration danssrc/__integration__/. Corrigé dans le tableau ci-dessus. Resolution : Corpus danssrc/__fixtures__/csv/, tests de contrat danssrc/utils/csvAutoDetect.test.tset le nouveausrc/utils/amountParser.test.ts. |src/i18n/locales/{fr,en}.json| Modifier | Clés de détection, aperçu, dérive, aide | |docs/architecture.md| Modifier | v17, détection lexicale, étape du wizard | |docs/adr/0019-format-import-persiste.md| Créer | Décision structurante | |docs/guide-utilisateur.md| Modifier | Nouveau parcours d'import | |CHANGELOG.md,CHANGELOG.fr.md| Modifier | Entrées sous[Unreleased]|
Plan de tests
Tests unitaires
parseFrenchAmount sur chaque forme de montant, dont parenthèses et signe suffixe. parseDate inchangé, couvert en régression. La couche lexicale : chaque libellé du dictionnaire, l'ordre débit/crédit inversé, les libellés muets qui déclenchent le repli. autoDetectConfig sur chaque fixture, contrat complet — délimiteur, en-tête, lignes ignorées, format de date, mode, convention, mapping, score. Les signatures de banques, une par banque plus un fichier inconnu qui doit retomber sur le générique.
Tests d'integration
Le cycle de format configurer → sauvegarder → recharger, qui est le test qui aurait attrapé le bug racine. Le cycle export → import de données préservant sources et modèles, plus l'import d'une sauvegarde antérieure sans ces tableaux. L'application de la migration v17 sur une base v16 peuplée, avec vérification du backfill. Le parsing de bout en bout sur chaque fixture, du fichier brut aux montants signés.
Tests de regression
Les tests existants restent verts — 871 vitest et 106 Rust au dernier relevé. Le corpus de fixtures de l'issue 4 fige le comportement de autoDetectConfig avant sa refonte, de sorte que les issues 5 et 6 se mesurent à un contrat établi plutôt qu'à une intention. Les migrations v1 à v16 conservent leurs checksums.
Criteres d'acceptation
- Une source réglée en montants positifs conserve sa convention au deuxième import, et à tous les suivants
- Un fichier
Date;Description;Crédit;Débitest mappé dans le bon ordre sans intervention - Un fichier dont la colonne inutilisée porte
0,00produit les bons montants, aucun n'est nul - Un mapping incomplet produit une erreur de ligne explicite, jamais une lecture de la colonne 0
- Basculer de mode puis recharger la source restitue le mode choisi
- Une source jamais configurée déclenche la détection sans action de l'utilisateur
- L'aperçu est traversé à chaque import et affiche sorties, entrées et totaux
- L'écran de confirmation affiche mode, convention et mapping
- Un import abandonné ne laisse aucune configuration en base
- Un fichier Desjardins est reconnu par signature et annoncé comme tel
- Un en-tête modifié depuis le dernier import déclenche le panneau d'écart
- Exporter puis réimporter ses données préserve toutes les configurations de sources
- Une sauvegarde produite avant ce chantier s'importe sans erreur
- Les migrations v1 à v16 sont inchangées ; la v17 s'applique sur une base v16 peuplée
Edge cases et risques
| Cas | Mitigation |
|---|---|
| Le backfill v17 ne peut pas deviner la convention passée d'une source existante | Aucune source ne change de comportement — le défaut restitue exactement ce que le code appliquait déjà. Une source qui souffrait du bug continue de souffrir jusqu'au prochain import, où le score de confiance et l'aperçu obligatoire exposent l'écart. C'est une amélioration franche sans mutation silencieuse, cohérente avec la décision d'exclure toute correction rétroactive. |
| Un ré-import corrigé double-compte les lignes déjà importées de travers | findDuplicates (transactionService.ts:164) apparie sur date AND description AND amount. Les issues 2 et 3 changent les montants produits par une source — signe inversé, débits qui ne valent plus 0 : le ré-import que la ligne précédente invite à faire ne reconnaîtra pas les lignes fautives et les ajoutera en double, une inversion de signe produisant en prime des paires miroir qui se compensent à ~0 au lieu de sauter aux yeux. Le seul chemin de réparation sûr est deleteImportWithTransactions sur l'import fautif avant de le rejouer — à énoncer dans l'interface de dérive et d'aperçu, pas seulement ici. |
| Un fichier sans ligne d'en-tête ne bénéficie d'aucun signal lexical | Repli explicite sur l'heuristique de forme actuelle, qui reste testée par le corpus. Le score de confiance sera mécaniquement plus bas, ce qui est l'information juste. |
| Un fichier sans en-tête n'a pas de signature à mémoriser | header_signature reste nulle et la détection de dérive est inopérante sur ces sources. Documenté plutôt que contourné : inventer une signature sur les données produirait de faux positifs à chaque changement de contenu. |
json_extract indisponible dans le SQLite embarqué |
Backfill écrit en LIKE '%debitAmount%', sans dépendance à l'extension JSON1. |
| L'aperçu obligatoire ajoute un clic à chaque import mensuel | Coût accepté par décision explicite, contre la recommandation d'un aperçu conditionné à la confiance. |
| Les signatures de banques sont écrites sans relevés réels | Elles reposent sur les formats documentés et sur preprocessQuotedCSV, qui prouve déjà le cas Desjardins. Un fichier non reconnu retombe sur le dictionnaire générique — l'échec d'une signature dégrade, il ne casse pas. |
Modifier csvAutoDetect.ts risque de régresser le flux holdings (#245) |
Les helpers sont remontés sans changer leur comportement ; les tests holdings existants font foi et doivent rester verts à chaque maillon. |
| Une pile de dix issues sur deux fichiers centraux dérive au rebase | Ordre strictement linéaire, CHANGELOG centralisé dans le dernier maillon, chaque PR basée sur la précédente. |
Revision — Synthese
Date: 2026-08-12 | Experts: Securite, Architecture, Technique
Verdict
🟡 CRITIQUES ADRESSEES — plan corrige le 2026-08-13 — A la revue, deux fondations du plan etaient fausses telles qu'ecrites (le type ImportFormat compose, l'ordre des issues) et trois defauts du code se sont reveles plus graves que ce que le plan enoncait. Les 7 critiques et les 9 ameliorations sont desormais integrees au corps du document ; les annotations restent en trace de revue. Decisions tranchees : 4 (voir ci-dessous).
Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|---|---|---|---|---|
| Securite | 3 | 4 | 1 | Restauration SREF hors transaction ; parseFloat accepte les suffixes et rend une magnitude ×100 ; modeles absents de la liste de purge |
| Architecture | 3 | 3 | 2 | ImportFormat compose est structurellement impossible ; le score dupliquerait la regle de parsing ; template_id recree une seconde source de verite |
| Technique | 4 | 3 | 1 | Le corpus de contrat arrive apres le changement qu'il fige ; 11 sites d'appel non listes ; l'etape apercu est un changement de machine a etats |
Trois constats ont ete verifies a la main avant annotation, et un constat d'agent a ete durci plutot que repris : parseFrenchAmount("50,00-") rend 5000, pas 50 — la revue d'implementation initiale sous-estimait ce defaut.
Actions requises
- 🔴
ImportFormatcompose — remplacer parImportFormatRow+ImportFormatrelies par un codec unique ; les quatre porteurs ont des casses et des types incompatibles. - 🔴 Ordre des issues — le corpus de contrat passe en tete :
4 → 1 → 2 → 3 → 5 → …. - 🔴
parseFrenchAmount— validation ancree rendantNaNsur tout residu ; les suffixes produisent aujourd'hui une erreur de facteur 100 qui passeisNaN. - 🔴 Sites d'appel du parser — ajouter
csvAutoDetect.tsetuseSnapshotEditor.tsau perimetre ; 11 appels non listes, dont le flux holdings. - 🔴 Score de confiance — extraire
mapRow(raw, format)pur en amont, sinon le score reimplemente la regle de parsing. - 🔴 Restauration SREF — envelopper purge et restauration dans
withTransaction; aujourd'hui aucune. - 🔴 Modeles a la restauration — ordre et strategie d'identifiants a specifier ;
import_config_templatesn'est dans aucune liste de purge. - 🟡 Whitelist des valeurs
amount_mode/sign_conventiona la frontiere SREF. - 🟡
template_id— le retirer ou le declarer etiquette de provenance. - 🟡 Bouton d'inversion inerte en mode debit/credit.
- 🟡 Le refus du troisieme mode de montant est promis sans livrable.
- 🟡 Un re-import corrige double-compte les lignes fautives (
findDuplicatesapparie sur le montant). - 🟡 Perimetre de l'etape apercu — machine a etats, pas rendu.
- 🟡 Le remontage des helpers est un no-op ; le vrai couplage est le dictionnaire partage.
- 🟡 « Checksums intacts » n'est pas testable ; chemin des fixtures corrige en
src/__fixtures__/csv/.
Decisions tranchees (2026-08-13)
| Decision | Retenu | Rationale |
|---|---|---|
Contrainte CHECK sur amount_mode |
CHECK elargi a absolute_indicator |
Revise la decision de cadrage « pas de CHECK ». L'objectif d'origine — ajouter le 3e mode sans migration — est atteint par la valeur future deja admise dans la contrainte, sans renoncer a la garantie en base. sign_convention recoit le meme traitement. |
Role de template_id |
Etiquette de provenance | La colonne enregistre d'ou vient la configuration et n'est jamais relue comme format ; les huit colonnes de la source font foi. Un critere d'acceptation verifie qu'editer un modele ne modifie aucune source liee. |
| Troisieme format de montant | Detecte et refuse explicitement | Le document de decisions promettait un refus explicite sans qu'aucune issue ne le livre. Sans la regle, un tel fichier importe chaque debit comme un revenu — l'echec silencieux que le chantier combat. |
Portee du durcissement de parseFrenchAmount |
Global, tous les sites d'appel | Un "100,00 CAD" qui rend 10000 est un bug partout, y compris a l'import de titres. Une seule fonction a raisonner, au prix d'une passe de regression sur les tests holdings existants. |
Corrections integrees sans arbitrage
ImportFormatRow + ImportFormat relies par un codec unique (la composition d'un type unique etait structurellement impossible) ; ordre des issues 4 → 1 → 2 → 3 → 5 → … ; extraction de mapRow avant le calcul de score ; withTransaction sur la purge et la restauration SREF ; ordre et strategie d'identifiants a la restauration des modeles ; perimetre de l'etape apercu etendu au reducer, a ImportPage et au modal ; dictionnaire lexical dans son propre module ; useSnapshotEditor.ts et src/utils/amountParser.test.ts ajoutes au tableau des fichiers ; chemin des fixtures corrige en src/__fixtures__/csv/ ; assertion « checksums » remplacee par ce que le harnais verifie reellement ; chemin de reparation sur re-import enonce dans l'interface.
Decisions de planification (2026-08-13, seance /plan-run)
| Question | Decision | Portee |
|---|---|---|
| Les specs sont gitignorees, les workers ne les verraient pas | Force-add et commit des deux fichiers (precedent PR #295) et bodies d'issues auto-suffisants | Toutes les issues ; debloque la ligne Spec: de l'ADR 0019 |
| Seuil de confiance non chiffre | 90 % de lignes lues. En dessous : bandeau d'avertissement + score detaille (« 132/150 lignes »). Au-dessus : bandeau neutre. L'apercu obligatoire reste le filet reel quel que soit le score | Issues 6 et 7 (#328, #329) |
Contenu de header_signature |
Tableau JSON des libelles normalises par normalizeHeaderCell, ex. ["date","description","montant","solde"]. Un hash rendrait impossible l'ecart colonne par colonne promis par le panneau de derive |
Issue 8 (#330) |
| Compatibilite du format SREF | Champ de version explicite dans le fichier exporte. A l'import, son absence signifie « format anterieur » et les tableaux manquants sont traites comme vides | Issue 9 (#331) |
Emplacement du codec et de mapRow |
src/utils/importFormat.ts — meme dossier que amountParser, dateParser et csvAutoDetect, qui portent deja la logique pure du domaine. Les types restent dans src/shared/types/ |
Issues 2 et 3 (#324, #325) |