Simpl-Resultat/docs/adr/0019-format-import-persiste.md
le king fu e2b8eb8b22
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m38s
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 <noreply@anthropic.com>
2026-08-14 11:13:25 -04:00

12 KiB

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-plan-import-csv-format.md
  • Prolonge ADR 0003 (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<keyof ImportFormat, keyof ImportFormatRow> — 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êteheader_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éesimport_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).

Liens