diff --git a/_bmad-output/implementation-artifacts/8-3-detection-doublons-rejet-partiel.md b/_bmad-output/implementation-artifacts/8-3-detection-doublons-rejet-partiel.md new file mode 100644 index 000000000..2fc0d17a3 --- /dev/null +++ b/_bmad-output/implementation-artifacts/8-3-detection-doublons-rejet-partiel.md @@ -0,0 +1,846 @@ +# Story 8-3: Détection de doublons & rejet partiel + +Status: review + + + +## Story + +As a **utilisateur Kesh (PME / indépendant suisse)**, +I want **que Kesh signale les fichiers déjà importés et les transactions qui chevauchent un import précédent, et qu'il accepte d'importer les lignes valides d'un fichier partiellement défaillant**, +so that **aucune transaction n'apparaît en double dans ma comptabilité, et qu'un fichier CSV avec quelques lignes en erreur ne m'oblige pas à tout réimporter une fois corrigé**. + +### Contexte + +**Story 8-3 = troisième story de l'Epic 8 « Import Bancaire & Réconciliation »**, après **8-1a** (parser CAMT.053 dans `kesh-import`) + **8-1b** (persistance `bank_imports`/`bank_transactions` + UI `/bank-import`) + **8-2** (CSV multi-encodage + profils banque). Elle ferme la moitié du **scope FR43 + FR51** que 8-1b et 8-2 ont **explicitement reportée** : + +- **FR43 (doublons fichier + ligne-par-ligne)** : 8-1b a livré `(company_id, file_hash) UNIQUE` + mapping `409 BANK_IMPORT_DUPLICATE_FILE`, **sans option de forcer** (le PRD exige « avertissement avec option de forcer »). 8-3 livre l'option de forcer (`confirmDuplicateFile=true`) **et** la détection ligne-par-ligne sur `(date, amount, reference, bank_account_id)` (pour les chevauchements de relevés — scénario Sophie PRD §134). +- **FR51 (rejet partiel)** : 8-2 a livré le **strict-reject** (`CsvError::PartialFailure` → `422 BANK_CSV_PARTIAL_FAILURE`, listing complet, **aucune** transaction persistée). 8-3 livre le **partial commit** (`confirmPartialImport=true` → persistance des lignes valides, retour `201 Created` + `warnings.invalidLines`). +- **KF #70** : wiring frontend `bankProfileId` + `confirmEncodingMismatch` UI (héritée 8-2 code review Pass 3 BH3-2). 8-2 a documenté en `Limitations connues v0.1 L10` et différé à 8-3 ou 8-2bis. **Décision créateur de story** : adresser dans 8-3 puisque le panneau preview est déjà en train d'être enrichi (doublons fichier/ligne + invalidLines + confirms multiples → un seul refactor d'UI cohérent plutôt que deux passes séparées). + +**Statement balance check** (CR-010 #62) déjà clos en 8-1b — pas re-traité. + +**Status sprint** : `8-3-detection-doublons-rejet-partiel: backlog → ready-for-dev` après création de cette spec. + +### Scope verrouillé — ce qui est livré par 8-3 + +1. **Détection fichier déjà importé avec option de forcer** (FR43 partie 1) — `confirmDuplicateFile=true` autorise un second `bank_imports` pour le même `(company_id, file_hash)`. Migration DB qui relâche l'UNIQUE en INDEX simple. Audit log discriminant (`bank_import.created_with_duplicate_file`). +2. **Détection ligne-par-ligne** (FR43 partie 2) — au sein d'un même `bank_account_id`, une transaction nouvelle dont la **clé composite** `(booking_date, amount, normalize(reference|end_to_end_id|transaction_id))` matche une transaction existante d'un import précédent est détectée et **skip par défaut**. Helper `kesh_core::bank_imports::detect_duplicate_lines` + repository extension `bank_transactions::find_in_dedup_window`. +3. **Partial commit CSV** (FR51 partie 2) — `confirmPartialImport=true` autorise la persistance des lignes valides d'un CSV partiellement défaillant (`CsvError::PartialFailure` → `201 Created` + `warnings.invalidLines` + audit log `bank_import.created_with_partial`). Strict-reject 8-2 reste comportement par défaut sans le flag. +4. **Preview enrichie** — 4 sections de warnings exposées dans la réponse `POST /preview` : `duplicateFile`, `duplicateLines`, `invalidLines` (CSV), `balanceMismatch` (existant 8-1b), `unsupportedCurrency` (existant 8-1b), `encodingMismatch` (existant 8-2). Toutes non-bloquantes au preview, bloquantes au `POST /bank-imports` final sauf `confirm*=true`. +5. **Frontend KF #70 closure** — `BankImportUpload.svelte` + `bank-import.api.ts` wirent `bankProfileId` (sélecteur explicite avec auto-match par filename comme valeur par défaut) + `confirmEncodingMismatch` (checkbox apparaissant quand le warning est retourné par preview). Issue #70 fermée par cette story. +6. **Extension UI preview** — panneau doublons fichier (1 ligne avec lien vers l'import précédent) + panneau doublons ligne (table avec « N transactions chevauchent l'import #X ») + panneau lignes invalides (table CSV existante 8-2 dans `BankImportUpload`, déplacée vers `BankImportPreviewPanel.svelte` partagé) + 3 checkboxes de confirmation (`confirmDuplicateFile`, `confirmPartialImport`, `confirmEncodingMismatch`). +7. **i18n** — ~10 nouvelles clés `bank-import-warnings-*` + `bank-import-labels-*` + `bank-import-errors-*` (4 locales fr/de/it/en-CH, lint pass). +8. **Tests** — E2E HTTP (~10 nouveaux), Vitest (~2), Playwright (~3 scénarios). +9. **Sync** sprint-status + README + close GitHub issue #70. + +**HORS scope 8-3 (reportés Stories 8-4 / 8-5 / v0.2) :** + +- Réconciliation auto/manuelle des transactions importées → 8-4 / 8-5. +- Re-import du **delta** corrigé d'un partial commit (UI qui reprend les lignes en erreur, permet de les éditer inline et les soumet sans réuploader le fichier complet) → reporté v0.2 (scope front significatif). +- Détection de doublons **inter-comptes-bancaires** (transaction de virement interne) → hors v0.1 (FR48 « éclatement » est sur transaction agrégée, pas sur transferts entre comptes). +- Tolérance dates (booking_date vs value_date dérivés) → décision §dedup-key : exact `booking_date` uniquement. +- Tolérance montants (centimes) → décision §dedup-key : exact `Decimal` (pas de tolérance). + +### Décisions de conception (clés) + +#### §dedup-file (détection fichier déjà importé) + +- **Critère** : `(company_id, file_hash)` exact (SHA-256 hex). Hérité 8-1b. +- **Migration** : `20260507000001_bank_imports_relax_hash_unique.sql` remplace `uq_bank_imports_company_hash UNIQUE` par `idx_bank_imports_company_hash` non-unique. **Justification** : pour permettre `confirmDuplicateFile=true`, deux rows distincts avec même `(company_id, file_hash)` doivent coexister (chacun avec son propre `imported_at` / `imported_by_user_id` / éventuellement transactions différentes après skip de doublons-ligne). Sans relâcher le UNIQUE, l'INSERT échoue avec SQL 1062 et il n'y a pas d'override propre. +- **Test 8-1b à adapter** : `unique_company_hash_blocks_duplicate_within_same_company` actuellement asserte une erreur SQL 1062. Le renommer / réécrire en `find_by_company_and_hash_finds_existing_for_dedup_check` qui asserte le check applicatif via `repositories::bank_imports::find_by_company_and_hash` (déjà existant dans 8-1b à `bank_imports.rs:230`). **Sans cette modification, la migration plante les tests existants**. +- **Concurrence** : un INSERT concurrent sans `confirmDuplicateFile=true` peut arriver à `find_by_company_and_hash` retournant `None` côté handler A pendant que handler B est en train de commit. **Sous MariaDB `REPEATABLE READ` (default), le snapshot début-tx ne voit pas l'INSERT concurrent committé après le début ; les deux SELECT retournent `None` → les deux INSERT passent**. Sans le UNIQUE relâché, on pourrait s'appuyer sur la contrainte ; avec relâchement, la race n'est **pas** fermée par REPEATABLE READ. Mitigations possibles : + - **(a)** `SELECT ... FOR UPDATE` sur une row sentinelle de `bank_imports` (pas trivial — il faudrait un row d'index unique scopé `(company_id, file_hash)` pré-existant qu'on lock, ou un advisory lock applicatif via `GET_LOCK('bank_import_dedup', company_id || file_hash)`). + - **(b)** Rétablir le UNIQUE conditionnel via une colonne `force_insert_marker` (CHECK `(force_insert_marker IS NULL AND ...) UNIQUE`) — complexité MariaDB non triviale. + - **(c)** Accepter la race comme **rare empiriquement** (les imports concurrents du même fichier par le même utilisateur sur la même company sont peu probables — typiquement 1 import par jour, retry-after-error humain, pas de cron). + + **Décision v0.1** : option (c) — race acceptée + documentée en L11. Si la rareté empirique se révèle fausse en prod (KF émergente), revenir à (a) advisory lock dans T6.2 step 2 (lift-and-shift, ~10 lignes). Les imports CAMT.053 mensuels et les CSV bancaires manuels ne sont structurellement pas exposés à du concurrent INSERT par le même user/company/hash. + +#### §dedup-key (détection ligne-par-ligne) + +- **Clé composite stable** : `(booking_date, amount, reference_normalized, bank_account_id)` où `reference_normalized = trim(lowercase(coalesce(reference, end_to_end_id, transaction_id, ""))).` Décision R1 epic-8 : pas de tolérance sur dates ni montants — Decimal exact, NaiveDate exact. +- **Référence vide** : si `reference IS NULL`, fallback sur `end_to_end_id`, puis `transaction_id`, puis chaîne vide. Le cas chaîne-vide est conservateur : deux transactions même date / même montant / même compte / aucun identifiant → considérées doublons (signalé en preview, skip au commit). Edge case réel : virement bancaire sans communication (rare en CAMT.053, fréquent en CSV peu structuré). **Note** : ce cas peut être un faux positif (deux paiements légitimes le même jour pour le même montant sans communication) mais il est rare et l'utilisateur peut l'override via `confirmDuplicateLines=import` si nécessaire — voir §confirm-flags. +- **Fenêtre de comparaison** : on charge en mémoire les transactions existantes filtrées sur `(company_id, bank_account_id, booking_date BETWEEN period_from AND period_to)` du nouvel import. Périmètre étroit (typiquement < 100 transactions pour un mois) → comparaison en O(N + M) via HashSet sur la clé composite côté Rust. Pas de query SQL ligne-par-ligne. +- **Helper** : `kesh_core::bank_imports::detect_duplicate_lines(new: &[BankTransactionDraft], existing: &[BankTransaction]) -> Vec` où `DuplicateLine { new_index: usize, existing_id: i64, key: String }`. Pure (sans I/O), facilement testable. +- **Justification clé** : `(date, amount, ref, account)` est exactement le critère donné par epic-8 (« And détection de doublons par combinaison : `date + montant + référence + bank_account_id` »). Le fallback `coalesce(ref, eid, tid, "")` est l'extension naturelle pour CSV où `reference` est souvent absent mais `end_to_end_id` ou `transaction_id` peut être disponible (hérité du parser). + +#### §confirm-flags (4 flags `confirm*` côté `POST /bank-imports` — 3 nouveaux 8-3 + 1 hérité 8-2) + +| Flag multipart | Default | Quand actif | Comportement | Audit (cf. §audit-log-actions) | +|---|---|---|---|---| +| `confirmDuplicateFile` | `false` | preview retourne `warnings.duplicateFile` non-nil | `false` → **422 BANK_IMPORT_DUPLICATE_FILE** (pas 409 — voir note ci-dessous). `true` → INSERT bank_imports même hash. | `details_json.modifiers += ["duplicate_file"]` | +| `confirmDuplicateLines` | `"skip"` (string enum) | preview retourne `warnings.duplicateLines` non-vide | `"skip"` → persiste seulement les lignes non-doublons. `"import"` → persiste toutes les lignes (override du skip). **Pas d'option `"reject"` distincte** : si l'utilisateur veut tout rejeter, il ne soumet simplement pas le `POST /bank-imports`. | `+= ["duplicate_lines_skipped"]` ou `+= ["duplicate_lines_imported"]` ; `details_json.duplicate_lines_skipped: N` ou `duplicate_lines_imported: N` | +| `confirmPartialImport` | `false` | parser CSV retourne `CsvError::PartialFailure` | `false` → `422 BANK_CSV_PARTIAL_FAILURE` + payload listing (comportement 8-2 inchangé). `true` → persiste les lignes valides + retour `201 Created` + `warnings.invalidLines: { lines, totalErrors, truncated }`. | `+= ["partial"]` ; `details_json.partial_invalid_lines: N`, `partial_total_errors: N`, `partial_truncated: bool` | +| `confirmEncodingMismatch` *(8-2 backend, 8-3 frontend wiring)* | `false` | preview retourne `warnings.encodingMismatch` (8-2) | **8-2 backend déjà parsé + déjà mappé** ; **8-3 = frontend wiring uniquement** (KF #70, T7.3, AC #20). Audit existait en 8-2 sous l'action discriminante `bank_import.created_with_encoding_mismatch` → migrée 8-3 vers le pattern modifiers (cf. T6.6). | `+= ["encoding_mismatch"]` | + +**Note codes HTTP `BANK_IMPORT_DUPLICATE_FILE` 409 → 422** : 8-1b utilise `409 BANK_IMPORT_DUPLICATE_FILE` retourné via `Err(DbError::UniqueConstraintViolation)` mappé inline. 8-3 retire le UNIQUE (cf. §dedup-file) et fait le check applicatif **avant** l'INSERT, **dans le handler** (pas dans le repo). Le code 409 « Conflict » garde du sens pour l'unicité, mais `422 Unprocessable Entity` est plus cohérent avec le reste des erreurs `BankImport*` (balance/currency/no-matching-statement) qui sont aussi des refus métier. **Décision** : aligner sur `422 BANK_IMPORT_DUPLICATE_FILE` pour la cohérence des `confirm*=true`. Adapter le test `unique_company_hash_blocks_duplicate_within_same_company` en conséquence. + +**Cohérence avec `confirmBalanceMismatch` (8-1b)** : même pattern multipart `confirmBalanceMismatch=true|false`. Tous les `confirm*` sont parsés via `parse_multipart` étendue, mêmes erreurs `400 Validation` sur valeurs invalides. + +#### §preview-warnings-shape + +Réponse JSON `POST /preview` étendue (forme stable, fields optionnels) : + +```json +{ + "transactions": [...], + "warnings": { + "balanceMismatch": null | {"opening": "...", "closing": "...", "sum": "...", "diff": "..."}, + "unsupportedCurrency": null | {"currency": "EUR"}, + "encodingMismatch": null | {"profile": "ISO-8859-1", "detected": "UTF-8"}, + "ignoredStatements": [{"statementId": "...", "iban": "..."}], + "duplicateFile": null | {"existingImportId": 42, "existingFilename": "releve.xml", "existingImportedAt": "2026-04-12T10:30:00Z"}, + "duplicateLines": [{"newIndex": 0, "existingTransactionId": 123, "key": "2026-04-12|150.00|REF-ABC|17"}], + "invalidLines": null | { + "lines": [{"line": 7, "code": "InvalidDate", "value": "32.13.2026", "messageI18nKey": "bank-import-csv-errors-invalid-date"}], + "totalErrors": 3, + "truncated": false + } + } +} +``` + +`warnings` est toujours présent (jamais `null`). Champs `null`/`[]` quand absent. **`invalidLines` est un objet** `{ lines, totalErrors, truncated }` (pas un array) — analogue à `balanceMismatch` qui est aussi un objet, et homogène avec le besoin d'exposer `totalErrors`/`truncated` en cas de cap (cf. AC #15). **Backward-compat 8-1b/8-2** : les tests existants n'asserent que sur `transactions[*]` ou sur `warnings.balanceMismatch`/`unsupportedCurrency`, ne cassent pas avec les ajouts. + +#### §kesh-import-partial-mode (parser CSV) + +Le parser actuel `kesh_import::csv::parser::parse_csv(...)` retourne `Result, CsvError>` où `CsvError::PartialFailure { errors, total_errors, truncated }` regroupe les erreurs ligne. **Choix v0.1 8-2** : strict-reject — l'erreur ne porte **pas** les transactions valides parsées avant l'erreur. + +**Extension 8-3** : nouvelle fonction `parse_csv_collect(input: &[u8], profile: &BankProfile, config: &CsvParseConfig) -> ParseCsvOutcome` (signature complète à respecter — `CsvParseConfig` est la struct existante 8-2 dans `kesh-import::csv::config` qui porte les seuils de parsing, `MAX_CSV_LINE_ERRORS` étant déjà la const exposée `kesh_import::MAX_CSV_LINE_ERRORS = 100` re-exportée depuis `kesh-import/src/error.rs:49`). Forme : + +```rust +pub enum ParseCsvOutcome { + AllValid(Vec), + PartialFailure { + valid: Vec, + errors: Vec, + total_errors: usize, + truncated: bool, + }, + HardFailure(CsvError), // empty file, unsupported encoding, profile misconfigured, etc. +} +``` + +`AllValid` et `PartialFailure` sont les **deux** issues du parsing « happy-ish » (au moins 1 ligne valide ou 0 erreur). `HardFailure` capture les erreurs qui font échouer le parser au démarrage (encoding, profile, fichier vide). Le caller handler 8-3 : +- `AllValid(txs)` → comportement classique. +- `PartialFailure { valid, errors, ... }` + `confirmPartialImport=false` → `422 BANK_CSV_PARTIAL_FAILURE` (comportement 8-2 inchangé). +- `PartialFailure { valid, errors, ... }` + `confirmPartialImport=true` → persistance de `valid` + warning `invalidLines`. +- `HardFailure(e)` → mapping AppError selon variante (rien ne change vs 8-2). + +**Compatibilité 8-2** : `parse_csv` (signature actuelle, retournant `CsvError::PartialFailure`) **est conservé** comme wrapper léger qui appelle `parse_csv_collect` et convertit `PartialFailure` → `CsvError::PartialFailure { errors, total_errors, truncated }` (sans le champ `valid`). Ainsi les tests existants 8-2 ne changent pas (sauf la signature interne du collecteur). + +**Mapping `ParseCsvOutcome::PartialFailure` → `warnings.invalidLines` (handler 8-3)** : + +| Champ Rust (`ParseCsvOutcome::PartialFailure`) | Champ JSON (`warnings.invalidLines`) | Type | +|---|---|---| +| `valid: Vec` | (consumé pour persister les transactions) | — | +| `errors: Vec` | `lines: Array<{line, code, value, messageI18nKey}>` | sérialisation directe | +| `total_errors: usize` | `totalErrors: number` | rename camelCase | +| `truncated: bool` | `truncated: boolean` | passthrough | + +Le mapping s'effectue côté `kesh-api::routes::bank_imports` (handler 8-3, T5.1 + T6.3), pas dans `kesh-import` qui reste agnostique du shape JSON HTTP. + +**Localisation des changements** : `crates/kesh-import/src/csv/parser.rs` (refactor + nouvelle fn) + `crates/kesh-import/src/csv/mod.rs` (re-export). **Pas de breaking change** sur `kesh_import::CsvError` ni sur `parse_csv`. Module `kesh-import` reste publishable (invariant 8-1a `cargo publish --dry-run` préservé). + +#### §audit-log-actions (action canonique unique + modifiers discriminants) + +**Décision 8-3 — single canonical action** : on **abandonne** le pattern action-discriminante de 8-1b (`bank_import.created_with_balance_mismatch`) au profit d'une **action canonique unique** `bank_import.created` + `details_json.modifiers: Vec` qui discrimine les variantes. Justification : avec 8-3, le nombre de combinaisons explose (5 modifiers × combinaisons N!) ; les actions composées (`bank_import.created_with_balance_mismatch_and_duplicate_file_and_partial`) deviennent illisibles et ingérables côté requêtes audit. + +**Migration 8-1b** : le test 8-1b qui asserte `action == "bank_import.created_with_balance_mismatch"` doit être **adapté** pour asserter `action == "bank_import.created"` + `details_json.modifiers contains "balance_mismatch"`. Cf. T6.6 (nouvelle subtask). + +**Mapping canonique 8-3** : + +| Condition | `audit_log.action` | `details_json.modifiers` (Vec) | +|---|---|---| +| Aucun warning, ou tous warnings absents | `bank_import.created` | `[]` | +| `confirmBalanceMismatch=true` | `bank_import.created` | `["balance_mismatch"]` | +| `confirmDuplicateFile=true` | `bank_import.created` | `["duplicate_file"]` | +| `warnings.duplicateLines` non-vide, default skip | `bank_import.created` | `["duplicate_lines_skipped"]` | +| `warnings.duplicateLines` non-vide, `confirmDuplicateLines="import"` | `bank_import.created` | `["duplicate_lines_imported"]` | +| `confirmPartialImport=true` | `bank_import.created` | `["partial"]` | +| `confirmEncodingMismatch=true` | `bank_import.created` | `["encoding_mismatch"]` | +| Combinaisons | `bank_import.created` | concat des modifiers triés alphabétiquement | + +**Combinaisons** : exemple `confirmDuplicateFile=true` + `confirmPartialImport=true` → `details_json.modifiers = ["duplicate_file", "partial"]` (tri alphabétique pour stabilité des tests). **Une seule entrée audit** par import quel que soit le nombre de modifiers actifs. + +**Note 8-2 `bank_import.created_with_encoding_mismatch`** : 8-2 a livré cette action discriminante. **Migration symétrique** dans 8-3 : `action = "bank_import.created"` + `modifiers = ["encoding_mismatch"]`. Adapter le test 8-2 correspondant. Cf. T6.6. + +**Note traçabilité** : `details_json` contient en plus `duplicate_lines_skipped: N`, `duplicate_lines_imported: N`, `partial_invalid_lines: N`, `partial_total_errors: N`, `partial_truncated: bool` selon les modifiers actifs. Permet à un audit a posteriori de quantifier les overrides utilisateur sans avoir à parser des actions composées. + +**Backward-compat des consommateurs externes** : aucun consommateur externe (BI / dashboards) n'a été identifié sur l'audit `bank_import.*` à ce stade du projet (pré-v0.1 GA). Si un consommateur apparaît en v0.2+, prévoir une vue SQL `audit_log_legacy` qui synthétise l'action+modifiers en `bank_import.created_with_X` à la volée. + +### Risque de splitting (CLAUDE.md check) + +**Modules touchés par 8-3** : 6 (`kesh-db`, `kesh-core`, `kesh-import`, `kesh-api`, `frontend`, `kesh-i18n`). **Au-dessus du seuil > 5 modules** énoncé par CLAUDE.md. + +**Décision : pas de split préventif**, **risque tracé**. Justifications : + +1. **Profondeur d'incertitude faible** : tous les patterns sont établis (multi-tenant scoping 6-2/7-1, audit log 1-8, `confirm*` flags 8-1b/8-2, partial-failure infrastructure 8-2). 8-3 est une **extension cohérente** pas un greenfield. +2. **Frontière de split naturelle absente** : contrairement à 8-1 (parser autonome `kesh-import` vs persistance/UI = `cargo path dep`), 8-3 ne se découpe pas en deux livrables testables en isolation. Les helpers `kesh-core` détectent des doublons que seul un test E2E HTTP peut valider en bout de chaîne ; le mode partial commit `kesh-import` est trivialement testable mais inutile sans le wiring handler. +3. **Volume estimé** : ~1500-2000 lignes net (vs 4500 pour 8-2 qui avait un nouveau parser CSV + nouvelle table profiles + nouveau frontend pour les profils). 8-3 est environ **40% du volume 8-2**, donc plus tenable en mental model unique. +4. **Précédents** : Story 8-1b a tenu sur 5 modules sans split (3 passes review, convergence). Story 8-2 sur 6 modules a coûté **6 passes** (3 spec + 3 code) — c'est-à-dire **au seuil CLAUDE.md de splitting préventif** (4 passes). 8-3 = 6 modules avec patterns acquis → projection **4-6 passes** (2-3 spec + 2-3 code), pas optimiste. Si la convergence requiert > 6 passes, le coût LLM dépasse celui d'un split rétroactif. + +**Trigger d'arrêt** : si **Pass 4 spec validate** ne converge pas (≥ 1 finding > LOW), **splitter rétroactivement** selon la frontière `kesh-import` cargo dependency (publishable invariant 8-1a) : + +- **8-3a backend partial commit + dedup core** : T1 (migration DB), T2 (helper `detect_duplicate_lines`), T3 (`find_in_dedup_window`), T4 (mode partial `parse_csv_collect`), T5 (`preview` enrichie) + tests E2E preview-only correspondants. Livrable testable en isolation : la route `POST /preview` retourne les 4 nouveaux warnings, mais `POST /bank-imports` reste 8-2 (pas de `confirm*` flags additionnels). Backward-compat absolue. +- **8-3b backend create flags + audit log + frontend** : T6 (`create` handler + `confirm*` flags + audit modifiers + T6.6 migration tests), T7 (frontend KF #70 + UI extension), T8 (i18n), T9 (Playwright), T10 (sync). Dépend de 8-3a comme prérequis. + +Cette frontière a deux avantages : (a) 8-3a est **déjà partiellement testable** en isolation via `POST /preview` (la UI 8-2 ignorerait juste les nouveaux champs warnings), (b) 8-3b devient un sprint front-heavy avec moins de risque architectural (les helpers backend sont déjà validés). **Décision pré-implementation** prise par Guy si la trigger se déclenche — pas appliquée par défaut. + +**Note 8-2** : KF #70 (frontend wiring `bankProfileId` + `confirmEncodingMismatch`) est inclus dans 8-3 car il **augmente la cohérence** du refactor frontend (un seul passage sur `BankImportUpload.svelte`) plutôt que de la fragmenter. Sans inclusion, `BankImportUpload` serait modifié 2× (8-3 puis 8-2bis), avec risque de régression. Le surcoût en lignes ajoutées est ~250 (estimé KF #70) + ~400 (ajouts 8-3 native) = ~650 lignes frontend, raisonnable. + +## Acceptance Criteria + +Numérotation continue 8-1b/8-2. Les ACs `**Hérité 8-1b**` ou `**Hérité 8-2**` sont déjà satisfaits et ne sont **pas** re-testés en 8-3 (cités pour traçabilité). + +### Détection fichier déjà importé (FR43 partie 1) + +1. **(FR43 — preview, fichier déjà importé)** Given un fichier dont le `(company_id, file_hash)` matche un `bank_imports` existant, When `POST /preview`, Then `200 OK` + `warnings.duplicateFile = { existingImportId, existingFilename, existingImportedAt }`. Les transactions sont **tout de même listées** dans `transactions` (l'utilisateur voit ce qu'il s'apprête à ré-importer). *Test E2E HTTP : `post_preview_returns_duplicate_file_warning`.* + +2. **(FR43 — create, fichier déjà importé sans confirm)** Given le même fichier déjà importé, When `POST /bank-imports` **sans** `confirmDuplicateFile`, Then `422 BANK_IMPORT_DUPLICATE_FILE` avec `details = { existingImportId, existingFilename }`. Aucune row insérée. *Test E2E HTTP : `post_import_rejects_duplicate_file_without_confirm`.* — **Remplacement test 8-1b** : ce test remplace `unique_company_hash_blocks_duplicate_within_same_company` qui assertait l'erreur SQL 1062 (caduque post-§dedup-file). + +3. **(FR43 — create, fichier déjà importé avec confirm)** Given le même fichier déjà importé, When `POST /bank-imports` **avec** `confirmDuplicateFile=true`, Then `201 Created` + nouveau `bank_imports.id` distinct + audit log `action = "bank_import.created"` + `details_json.modifiers = ["duplicate_file"]`. Les deux rows `bank_imports` (ancien + nouveau) coexistent en DB pour le même `(company_id, file_hash)`. *Test E2E HTTP : `post_import_accepts_duplicate_file_with_confirm`.* + +4. **(FR43 — multi-tenant safety, fichier déjà importé)** **Hérité 8-1b** (test `unique_company_hash_allows_same_hash_across_companies`). Le même fichier importé pour `company_A` n'empêche pas un import pour `company_B`. **Pas re-testé 8-3** mais le test existant **doit continuer à passer** post-migration §dedup-file. + +### Détection ligne-par-ligne (FR43 partie 2) + +5. **(FR43 — preview, doublons ligne)** Given un nouvel import (fichier hash distinct) sur `bank_account_id = X` qui contient 5 transactions dont 2 matchent (selon clé `(booking_date, amount, ref_normalized, bank_account_id)`) des `bank_transactions` existantes d'un import précédent dans la même fenêtre `[period_from, period_to]`, When `POST /preview`, Then `warnings.duplicateLines = [{newIndex: 1, existingTransactionId: ..., key: "..."}, {newIndex: 3, ...}]` (2 entrées). Les 5 transactions sont listées dans `transactions` (l'utilisateur voit ce qu'il importe + ce qui est doublon). *Test E2E HTTP : `post_preview_returns_duplicate_lines_warning`.* + +6. **(FR43 — create, doublons ligne, skip default)** Given le même import qu'AC #5, When `POST /bank-imports` **sans** `confirmDuplicateLines` (ou avec `confirmDuplicateLines=skip` explicite), Then `201 Created` + 3 transactions persistées (5 - 2 doublons) + `bank_imports.transaction_count = 3` + audit log `action = "bank_import.created"` + `details_json.modifiers = ["duplicate_lines_skipped"]` + `details_json.duplicate_lines_skipped = 2`. *Test E2E HTTP : `post_import_skips_duplicate_lines_by_default`.* + +7. **(FR43 — create, doublons ligne, force import)** Given le même import qu'AC #5, When `POST /bank-imports` avec `confirmDuplicateLines=import`, Then `201 Created` + **5** transactions persistées (toutes, doublons inclus) + `bank_imports.transaction_count = 5` + audit log `action = "bank_import.created"` + `details_json.modifiers = ["duplicate_lines_imported"]` + `details_json.duplicate_lines_imported = 2`. *Test E2E HTTP : `post_import_force_imports_duplicate_lines`.* + +8. **(FR43 — détection clé composite stable, référence vide)** Given une transaction nouvelle avec `reference = NULL`, `end_to_end_id = "EID-42"` matchant une existante avec `reference = NULL`, `end_to_end_id = "EID-42"`, même date/montant/compte, When détection, Then la transaction est marquée doublon (le fallback `coalesce(reference, end_to_end_id, transaction_id, "")` capture l'identité). *Test unitaire `kesh-core` : `detect_duplicate_lines_uses_end_to_end_id_when_reference_null`.* + +9. **(FR43 — détection clé, normalisation référence)** Given `reference = " ABC-123 "` (espaces autour) vs `reference = "abc-123"` existant, When détection, Then doublon détecté (trim + lowercase). *Test unitaire `kesh-core` : `detect_duplicate_lines_normalizes_reference_whitespace_and_case`.* + +10. **(FR43 — multi-tenant safety, doublons ligne)** Given une transaction de `company_A` (`bank_account_id = 17` appartenant à A) avec mêmes date/montant/ref qu'une transaction `company_B` (`bank_account_id = 99` appartenant à B), When `company_A` importe pour son compte 17, Then aucun doublon détecté (la requête `find_in_dedup_window` filtre par `(company_id, bank_account_id)` — KF-002 Pattern 1). *Test E2E HTTP : `post_import_does_not_detect_duplicate_lines_across_tenants`.* + +11. **(FR43 — fenêtre de comparaison `period_from..period_to`)** Given une transaction nouvelle au `2026-05-15` dans un import dont `period_from=2026-05-01` et `period_to=2026-05-31`, et une transaction existante au `2026-04-12` (hors fenêtre), When détection, Then **pas** de doublon. Si la transaction existante est au `2026-05-15` (dans la fenêtre, même date/montant/ref/account), Then doublon détecté. **Le filtre fenêtre est appliqué côté SQL** par `find_in_dedup_window` (cf. T3.1) — le helper `detect_duplicate_lines` reçoit déjà des `existing_keys` filtrées et n'applique aucun filtre de date supplémentaire. *Tests : (a) intégration SQL `find_in_dedup_window_returns_only_within_period` (T3.2#1) couvre le filtre de fenêtre côté DB ; (b) unitaire `detect_duplicate_lines_finds_match_when_existing_key_in_input` couvre le helper sur des `existing_keys` pré-filtrés.* + +### Partial commit CSV (FR51 partie 2) + +12. **(FR51 — preview, lignes invalides)** Given un fichier CSV avec 5 lignes valides + 3 lignes invalides (date malformée ligne 7, montant non-numérique ligne 12, ligne 18 trop courte), When `POST /preview`, Then `200 OK` + `transactions` contient les 5 valides + `warnings.invalidLines = { lines: [{line:7, code:"InvalidDate", ...}, {line:12, code:"InvalidAmount", ...}, {line:18, code:"RowTooShort", ...}], totalErrors: 3, truncated: false }`. Pas d'erreur 422 au preview. *Test E2E HTTP : `post_preview_csv_returns_invalid_lines_warning`.* + +13. **(FR51 — create, partial sans confirm — strict reject 8-2)** **Hérité 8-2** (test `post_csv_rejects_partial_failure_with_detailed_lines`). Given le même CSV, When `POST /bank-imports` **sans** `confirmPartialImport`, Then `422 BANK_CSV_PARTIAL_FAILURE` avec body `{ lines: [...], totalErrors: 3, truncated: false }`. **Pas re-testé 8-3** mais doit continuer à passer. + +14. **(FR51 — create, partial avec confirm)** Given le même CSV (5 valides + 3 invalides), When `POST /bank-imports` avec `confirmPartialImport=true`, Then `201 Created` + 5 transactions persistées + `bank_imports.transaction_count = 5` + warnings response `{ invalidLines: { lines: [...], totalErrors: 3, truncated: false } }` retournée + audit log `action = "bank_import.created"` + `details_json.modifiers = ["partial"]` + `details_json.partial_invalid_lines = 3` + `details_json.partial_total_errors = 3` + `details_json.partial_truncated = false`. *Test E2E HTTP : `post_import_csv_accepts_partial_with_confirm`.* + +15. **(FR51 — partial commit + cap 100 errors)** Given un CSV avec 50 lignes valides + 150 lignes invalides (au-dessus du cap `kesh_import::MAX_CSV_LINE_ERRORS = 100`, const exposée hérité 8-2 dans `crates/kesh-import/src/error.rs:49`), When `POST /bank-imports` avec `confirmPartialImport=true`, Then `201 Created` + 50 transactions persistées + `warnings.invalidLines.lines.length == 100` (cappé) + `warnings.invalidLines.totalErrors == 150` + `warnings.invalidLines.truncated == true` + audit log `action = "bank_import.created"` + `details_json.modifiers = ["partial"]` + `details_json.partial_invalid_lines = 100` + `details_json.partial_total_errors = 150` + `details_json.partial_truncated = true`. *Test E2E HTTP : `post_import_csv_accepts_partial_with_truncated_errors`.* + +16. **(FR51 — partial commit + 0 lignes valides)** Given un CSV avec 0 lignes valides + 3 lignes invalides, When `POST /bank-imports` avec `confirmPartialImport=true`, Then `422 BANK_CSV_PARTIAL_FAILURE` avec un détail discriminant `details.reason = "no_valid_lines_to_commit"` (pas `201` car aucune transaction à persister — `bank_imports.transaction_count = 0` n'a pas de sens v0.1, UX misleading). Shape réponse JSON : + ```json + { + "status": 422, + "code": "BANK_CSV_PARTIAL_FAILURE", + "details": { + "reason": "no_valid_lines_to_commit", + "lines": [{"line": 7, "code": "InvalidDate", "value": "32.13.2026", "messageI18nKey": "bank-import-csv-errors-invalid-date"}, ...], + "totalErrors": 3, + "truncated": false + } + } + ``` + Le frontend distingue ce cas (panneau dédié « Aucune ligne valide à importer ») via `details.reason === "no_valid_lines_to_commit"`. *Test E2E HTTP : `post_import_csv_rejects_partial_when_zero_valid_lines`.* + +### Combinaisons confirm-flags (cas croisés) + +17. **(combinaison `duplicateFile + duplicateLines + partial`)** Given un fichier CSV avec (a) hash matchant un import existant, (b) 2 transactions chevauchant des transactions existantes dans la fenêtre, (c) 3 lignes invalides, When `POST /bank-imports` avec **les 3 flags** `confirmDuplicateFile=true`, `confirmDuplicateLines=skip`, `confirmPartialImport=true`, Then `201 Created` + audit log `action = "bank_import.created"` + `details_json.modifiers = ["duplicate_file", "duplicate_lines_skipped", "partial"]` (tri alphabétique) + transaction_count cohérent (`5 valides - 2 doublons = 3`). *Test E2E HTTP : `post_import_csv_combines_three_confirm_flags`.* + +18. **(combinaison absente — fail-fast applicatif)** Given le même fichier qu'AC #17 mais avec aucun flag confirm, When `POST /bank-imports`, Then `422 BANK_IMPORT_DUPLICATE_FILE` retourné **en premier** (le check applicatif `find_by_company_and_hash` précède le parse CSV — cf. T6.3 step 1). Aucune ligne invalide n'est exposée tant que le duplicate file n'est pas confirmé via `confirmDuplicateFile=true`. Ordre de précédence des erreurs documenté en §error-precedence-order. *Test E2E HTTP : `post_import_returns_duplicate_file_first_when_no_flags`.* + +### KF #70 closure (frontend wiring) + +19. **(KF #70 — `bankProfileId` UI selector)** Given la page `/bank-import` avec un fichier CSV uploadé, When le filename matche un profil par regex (auto-match Story 8-2), Then le sélecteur `BankProfileSelector.svelte` affiche le profil auto-matché en valeur par défaut + permet à l'utilisateur de sélectionner un autre profil parmi `list_available_profiles` retournée par l'API + le `bankProfileId` choisi est inclus dans le multipart `POST /preview` et `POST /bank-imports`. *Tests : E2E HTTP `post_csv_uses_explicit_bank_profile_id_from_ui` (déjà couvert 8-2 backend, ajout frontend ; vérifier qu'aucune régression) + Vitest `BankImportUpload.test.ts: explicit profile selection overrides auto-match`.* + +20. **(KF #70 — `confirmEncodingMismatch` checkbox UI)** Given un upload CSV où le profil annonce `encoding=ISO-8859-1` mais le détecteur trouve `UTF-8`, When `POST /preview` retourne `warnings.encodingMismatch`, Then dans la preview UI une checkbox « Confirmer le décodage UTF-8 malgré le profil ISO-8859-1 » apparaît + cocher la checkbox + cliquer « Confirmer l'import » envoie `confirmEncodingMismatch=true` au handler create + `201 Created` retourné. *Tests : Playwright `csv encoding mismatch confirm flow end-to-end` + Vitest `BankImportUpload.test.ts: encoding mismatch confirm wires confirmEncodingMismatch flag`.* + +21. **(KF #70 — issue closure)** GitHub issue #70 fermée au merge de la PR 8-3. **`closes #70` doit apparaître dans le body de la PR** (qui devient le squash commit message) — cf. T10.4. Pas dans un commit intermédiaire, qui serait perdu au squash. + +### UI preview enrichie (panneaux warnings) + +22. **(UI — panneau doublons fichier)** Given preview retourne `warnings.duplicateFile`, When la preview est affichée, Then un panneau « Ce fichier a déjà été importé » apparaît avec `existingFilename` + `existingImportedAt` formaté localement + lien vers `/bank-import/[existingImportId]` + checkbox `confirmDuplicateFile`. Cocher la checkbox déverrouille le bouton « Confirmer l'import ». *Tests : Playwright `duplicate file warning shows panel and accepts override` + Vitest `BankImportUpload.test.ts: duplicate file checkbox toggles confirm flag`.* + +23. **(UI — panneau doublons ligne)** Given preview retourne `warnings.duplicateLines.length > 0`, When la preview est affichée, Then un panneau « N transactions chevauchent un import précédent » apparaît avec une table listant `existingTransactionId`, `key`, `newIndex` + un radio group `confirmDuplicateLines` avec 2 options `Ignorer les doublons (par défaut)` / `Importer quand même`. *Tests : Playwright `duplicate lines warning shows panel with skip-or-import radio` + Vitest `BankImportUpload.test.ts: duplicate lines radio updates state`.* + +24. **(UI — panneau lignes invalides CSV)** Given preview retourne `warnings.invalidLines !== null && warnings.invalidLines.lines.length > 0`, When la preview est affichée, Then un panneau « N lignes invalides détectées » apparaît avec une table (line, code, value, message via i18n) + checkbox `confirmPartialImport` (« Importer les lignes valides quand même »). Le compteur N affiché est `warnings.invalidLines.totalErrors` (pas `lines.length` — pour exposer le total réel quand `truncated == true`). Si `truncated === true`, afficher un sous-titre « N premières erreurs affichées (cap 100) ». *Tests : Playwright `csv partial failure shows panel and accepts partial commit` + Vitest `BankImportUpload.test.ts: partial commit checkbox toggles confirm flag`.* — **Refactor** : la table de lignes invalides existait déjà en 8-2 dans `BankImportUpload.svelte`. La déplacer dans `BankImportPreviewPanel.svelte` (composant partagé) pour pouvoir l'utiliser conjointement avec les nouveaux panneaux doublons. + +### Sécurité & multi-tenant + +25. **(KF-002 Pattern 1)** Given un utilisateur de `company_A`, When les helpers `find_in_dedup_window` / `find_by_company_and_hash` sont appelés, Then ils filtrent **systématiquement** par `company_id = current_user.company_id`. *Test : `bank_transactions::tests::find_in_dedup_window_scopes_by_company` + couvert par AC #10 E2E.* + +26. **(RBAC — Hérité 8-1b)** **Pas re-testé 8-3** : le sub-router `comptable_routes` couvre `POST /bank-imports`. Les 3 nouveaux flags ne changent pas le périmètre RBAC. + +### i18n & accessibilité + +27. **(i18n — 4 locales)** Given les ~10 nouvelles clés (`bank-import-warnings-duplicate-file`, `bank-import-warnings-duplicate-lines`, `bank-import-labels-confirm-duplicate-file`, `bank-import-labels-confirm-duplicate-lines-skip`, `bank-import-labels-confirm-duplicate-lines-import`, `bank-import-labels-confirm-partial`, `bank-import-errors-duplicate-file`, `bank-import-errors-no-valid-lines-to-commit`, `bank-import-labels-bank-profile-selector`, `bank-import-labels-confirm-encoding-mismatch`), When `npm run lint-i18n-ownership`, Then le lint passe sans erreur (préfixe `bank-import-*` strict, kebab-case). *Test : CI Story 6-3.* + +28. **(Accessibilité — axe-core)** Given la preview UI étendue (panneaux warnings + checkboxes/radio), When `axe-core` scan, Then zéro violation. Tous les inputs ont des labels ARIA. *Test : E2E `accessibility — bank import preview with warnings axe scan zero violations`.* + +### Performance NFR + +29. **(Performance — détection ligne-par-ligne, stress test volontairement large)** Given un import de 200 transactions sur un compte bancaire avec ~2000 transactions existantes dans la fenêtre `period_from..period_to` (stress test : représente un user PME haut volume avec multiples imports CAMT.053 ou CSV chevauchants sur la même fenêtre — dépasse largement le cas nominal d'1 mois × 1 import = ~200 transactions), When `POST /bank-imports`, Then la durée totale (parse + dedup + DB) < 3s sur la machine de dev nominale. *Test : `dedup_handles_2000_existing_under_3s` (smoke, instrumenté `Instant::now()`, **non-bloquant CI** — émet un warning si > 3s mais ne fail pas le test ; permet de détecter les régressions perf sans flakiness CI).* + +## Tasks / Subtasks + +### T1. Migration DB `bank_imports` — relax UNIQUE → INDEX (AC #2, #3, #4) + +- [ ] T1.1 — Créer `crates/kesh-db/migrations/20260507000001_bank_imports_relax_hash_unique.sql` : + ```sql + -- Story 8-3 — relax UNIQUE constraint on (company_id, file_hash) to allow + -- explicit re-import via confirmDuplicateFile=true (FR43). + -- Le check applicatif via repositories::bank_imports::find_by_company_and_hash + -- reste la source of truth pour la détection. + ALTER TABLE bank_imports DROP INDEX uq_bank_imports_company_hash; + CREATE INDEX idx_bank_imports_company_hash ON bank_imports (company_id, file_hash); + ``` +- [ ] T1.2 — Vérifier l'application sur DB fraîche (`cargo test -p kesh-db --lib test_fixtures` avec MariaDB up + KESH_TEST_MODE=true — règle CLAUDE.md « Test Locally First » sur modif migrations). +- [ ] T1.3 — Réversibilité manuelle vérifiée (`DROP INDEX idx_bank_imports_company_hash; ALTER TABLE bank_imports ADD CONSTRAINT uq_bank_imports_company_hash UNIQUE (company_id, file_hash);`). +- [ ] T1.4 — Pas de modification de `crates/kesh-db/src/test_fixtures.rs::TABLES_TO_TRUNCATE` (tables identiques). +- [ ] T1.5 — **Ordre d'application critique** : avant `cargo test --workspace`, supprimer ou renommer le test 8-1b `unique_company_hash_blocks_duplicate_within_same_company` qui asserte l'erreur SQL 1062 (caduque post-relax). Sinon le test plante au premier `cargo test` post-migration. Ce test sera **remplacé** par `post_import_rejects_duplicate_file_without_confirm` en T6.5#2 (mais T6 vient plus tard — d'où la nécessité de supprimer dès T1). + +### T2. Helper `kesh_core::bank_imports::detect_duplicate_lines` (AC #5, #8, #9, #10, #11, #25, #29) + +- [ ] T2.1 — Étendre `crates/kesh-core/src/bank_imports.rs` : + ```rust + /// Clé composite stable de détection de doublon ligne-par-ligne. + /// `(booking_date, amount_cents, reference_normalized, bank_account_id)`. + /// `reference_normalized = trim(lowercase(coalesce(reference, end_to_end_id, transaction_id, "")))`. + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + pub struct DuplicateKey { + pub booking_date: chrono::NaiveDate, + pub amount: rust_decimal::Decimal, + pub reference_normalized: String, + pub bank_account_id: i64, + } + + pub fn dedup_key_from_draft(t: &BankTransactionDraft, bank_account_id: i64) -> DuplicateKey { ... } + + /// Construction de `DuplicateKey` à partir de scalars — utilisée par + /// le caller `kesh-api` pour mapper `Vec` (qui vit + /// dans `kesh-db`) vers `Vec<(i64, DuplicateKey)>` sans introduire + /// de dépendance `kesh-core → kesh-db`. + pub fn dedup_key_scalar( + booking_date: chrono::NaiveDate, + amount: rust_decimal::Decimal, + reference: Option<&str>, + end_to_end_id: Option<&str>, + transaction_id: Option<&str>, + bank_account_id: i64, + ) -> DuplicateKey { ... } + + #[derive(Debug, Clone, serde::Serialize)] + pub struct DuplicateLine { + pub new_index: usize, + pub existing_transaction_id: i64, + pub key: String, // "yyyy-mm-dd|amount|ref|account_id" pour debug + UI + } + + /// Compare en O(N + M) via HashSet sur la clé composite. + /// Le caller construit `existing_keys` côté `kesh-api` via : + /// `existing.iter().map(|t| (t.id, dedup_key_scalar(t.booking_date, t.amount, + /// t.reference.as_deref(), t.end_to_end_id.as_deref(), + /// t.transaction_id.as_deref(), t.bank_account_id))).collect()` + pub fn detect_duplicate_lines( + new: &[BankTransactionDraft], + bank_account_id: i64, + existing_keys: &[(i64, DuplicateKey)], + ) -> Vec { ... } + ``` + + **Note crate dep** : `kesh_core` ne dépend pas de `kesh_db` (vérifié dans `crates/kesh-core/Cargo.toml`). Le helper accepte `&[(i64, DuplicateKey)]` (id + clé pré-calculée) plutôt que `&[BankTransaction]`, ce qui maintient l'invariant et rend le helper testable en pur unitaire. Le mapping `BankTransaction → (id, DuplicateKey)` se fait côté `kesh-api` via `dedup_key_scalar` (les champs scalaires de `BankTransaction` sont accessibles depuis `kesh-api` qui dépend déjà de `kesh-db`). + + **Pas** de helper `dedup_key_from_existing(t: &BankTransaction)` (qui aurait nécessité une dépendance `kesh-core → kesh-db`). La signature canonique est `dedup_key_scalar(...)`. + +- [ ] T2.2 — Tests unitaires `kesh-core::bank_imports::detect_duplicate_lines` (`crates/kesh-core/src/bank_imports.rs` `#[cfg(test)] mod tests`) : + 1. `detect_duplicate_lines_finds_match_on_full_key` — happy path. + 2. `detect_duplicate_lines_uses_end_to_end_id_when_reference_null` (AC #8). + 3. `detect_duplicate_lines_uses_transaction_id_when_eid_null` — fallback chain. + 4. `detect_duplicate_lines_normalizes_reference_whitespace_and_case` (AC #9). + 5. `detect_duplicate_lines_treats_empty_string_as_distinct_from_null` — note edge : `coalesce("", null)` retourne `""`, donc deux refs vides sont matched. Documenter en commentaire. + 6. `detect_duplicate_lines_does_not_filter_by_date` — la fenêtre est filtrée **côté SQL** par `find_in_dedup_window` (cf. AC #11 + T3.2#1). Le helper reçoit déjà `existing_keys` pré-filtré ; ce test vérifie que **si** un caller passe des keys hors-fenêtre, le helper retourne quand même les matchs (pas de double-filtrage côté helper). + 7. `detect_duplicate_lines_finds_match_when_existing_key_in_input` (AC #11) — symétrique du précédent : keys dans la fenêtre → matchs détectés. + 8. `detect_duplicate_lines_returns_empty_when_no_match`. + 9. `detect_duplicate_lines_handles_n_to_m_in_o_n_plus_m` — perf smoke : `new.len() = 1000`, `existing.len() = 5000`, durée < 50ms via HashSet. + +### T3. Repository `bank_transactions::find_in_dedup_window` (AC #5, #10, #25) + +- [ ] T3.1 — Étendre `crates/kesh-db/src/repositories/bank_transactions.rs` : + ```rust + /// Charge les transactions existantes dans la fenêtre `(period_from..period_to)` + /// pour le compte donné, scopées multi-tenant. Utilisé par le handler 8-3 + /// pour la détection de doublons ligne-par-ligne. + pub async fn find_in_dedup_window( + pool: &MySqlPool, + company_id: i64, + bank_account_id: i64, + period_from: chrono::NaiveDate, + period_to: chrono::NaiveDate, + ) -> Result, DbError> { + sqlx::query_as::<_, BankTransaction>(&format!( + "SELECT {COLUMNS} FROM bank_transactions \ + WHERE company_id = ? AND bank_account_id = ? \ + AND booking_date BETWEEN ? AND ?" + )) + .bind(company_id) + .bind(bank_account_id) + .bind(period_from) + .bind(period_to) + .fetch_all(pool) + .await + .map_err(map_db_error) + } + ``` + + **Note index** : la query utilise `idx_bank_transactions_company_account_date (company_id, bank_account_id, booking_date)` créé en 8-1b, donc c'est efficace même sur 100k+ transactions. + +- [ ] T3.2 — Tests d'intégration `#[sqlx::test]` (inline `bank_transactions.rs::tests` ou `crates/kesh-db/tests/bank_transactions_test.rs` si jamais inexistant) : + 1. `find_in_dedup_window_returns_only_within_period` — 3 transactions : 1 dans la fenêtre, 1 avant, 1 après → retourne seulement la 1. + 2. `find_in_dedup_window_scopes_by_company` (AC #25) — multi-tenant : transactions de `company_B` non retournées pour `company_A`. + 3. `find_in_dedup_window_scopes_by_bank_account` — 2 comptes même company : seul le compte demandé est retourné. + 4. `find_in_dedup_window_returns_empty_when_no_match` — happy path empty result. + +### T4. Mode partial commit `kesh-import` (AC #12, #14, #15, #16) + +- [ ] T4.1 — Étendre `crates/kesh-import/src/csv/parser.rs` avec `parse_csv_collect` (cf. §kesh-import-partial-mode pour la signature). Le helper actuel `parse_csv` devient un wrapper qui appelle `parse_csv_collect` et convertit `PartialFailure { valid, errors, .. }` → `CsvError::PartialFailure { errors, .. }` (sans `valid`). **Backward-compat absolue** : aucun test 8-2 ne change. + +- [ ] T4.2 — Re-export `parse_csv_collect` + `ParseCsvOutcome` dans `crates/kesh-import/src/csv/mod.rs` + `crates/kesh-import/src/lib.rs` (`pub use csv::{parse_csv, parse_csv_collect, ParseCsvOutcome};`). + +- [ ] T4.3 — Tests unitaires `kesh-import::csv::parser` : + 1. `parse_csv_collect_all_valid_returns_all_valid` — happy path. + 2. `parse_csv_collect_partial_returns_valid_and_errors` — fixture `csv_partial_failure.csv` (5 valid + 3 invalid déjà existante en 8-2). + 3. `parse_csv_collect_caps_errors_at_max` — 50 valid + 150 invalid → `errors.len() == 100`, `total_errors == 150`, `truncated == true`, `valid.len() == 50`. + 4. `parse_csv_collect_zero_valid_returns_partial_with_empty_valid` — 0 valid + 3 invalid → `PartialFailure { valid: vec![], errors: 3, .. }`. + 5. `parse_csv_collect_hard_failure_on_empty_file` — 0 lignes → `HardFailure(CsvError::EmptyFile)`. + 6. `parse_csv_wrapper_preserves_legacy_behavior` — vérifie que `parse_csv` (signature 8-2) retourne toujours `Err(CsvError::PartialFailure)` sur un partial. + +- [ ] T4.4 — Vérifier que `cargo publish --dry-run -p kesh-import` reste vert (invariant 8-1a — `crates/kesh-import` reste publishable, zéro path dep interne). + +### T5. Route `preview` enrichie (AC #1, #5, #12) + +- [ ] T5.1 — Étendre `crates/kesh-api/src/routes/bank_imports.rs` : + - Modifier la struct de réponse preview existante pour ajouter les 3 champs warnings : `duplicateFile`, `duplicateLines`, `invalidLines`. Champs **optionnels** (sérialisés comme `null` ou `[]` quand absent). + - **CAMT path** (`preview` handler) : ajouter step après `parse_camt053` + `select_statement_by_iban` : + 1. Compute `file_hash` (déjà en place 8-1b — cf. ligne 656) → check `bank_imports::find_by_company_and_hash` → si `Some(existing)` → `warnings.duplicateFile = Some({...})`. + 2. Convertir le `stmt` en drafts via `from_imported` (déjà en place) → extraire `period_from = stmt.opening_balance_date.unwrap_or_else(|| min(tx.booking_date))` et `period_to = max(tx.booking_date)`. + 3. Charger `bank_transactions::find_in_dedup_window(pool, company_id, bank_account_id, period_from, period_to)`. + 4. Construire `existing_keys: Vec<(i64, DuplicateKey)>` via `dedup_key_scalar(...)`. + 5. Appeler `detect_duplicate_lines(new_drafts, bank_account_id, &existing_keys)` → `warnings.duplicateLines = Vec`. + - **CSV path** (`preview_csv` helper) : remplacer l'appel `parse_csv` par `parse_csv_collect`. Si `ParseCsvOutcome::PartialFailure { valid, errors, .. }` → utiliser `valid` pour les transactions affichées + `warnings.invalidLines = errors`. Si `AllValid` → comportement classique. Si `HardFailure(e)` → `Err(map_csv_error(e))` (inchangé). **Puis** appliquer le même check duplicateFile + duplicateLines que CAMT (étapes 1-5 ci-dessus, sur les drafts CSV). + +- [ ] T5.2 — Audit log preview : **pas modifié** (8-1b/8-2 ne tracent pas le preview, on garde). + +### T6. Route `create` avec 3 nouveaux flags (AC #2, #3, #6, #7, #14, #15, #16, #17, #18) + +- [ ] T6.1 — Étendre `parse_multipart` dans `bank_imports.rs` (autour ligne 196) avec les 3 nouveaux flags : + - `confirmDuplicateFile: bool` (default `false`) — pattern strict identique à `confirmBalanceMismatch` (`"true"|"false"` / `400 Validation` sur autres valeurs). + - `confirmDuplicateLines: ConfirmDuplicateLines` (enum `Skip|Import`, default `Skip`) — accepter strings `"skip"|"import"` / `400 Validation` sur autres. + - `confirmPartialImport: bool` (default `false`). + - Validation : duplications dans le multipart → `400 Validation` (pattern `bankProfileId` 8-2 M8). + +- [ ] T6.2 — Modifier le handler `create` (CAMT path) : + 1. **Avant** `parse_camt053`, compute `file_hash` (déjà en place). + 2. Open transaction `tx = pool.begin().await`. **Dans la transaction** : `bank_imports::find_by_company_and_hash(&mut *tx, ...)`. Si `Some(existing)` : + - `confirm_duplicate_file=false` → `Err(AppError::BankImportDuplicateFile { existing_import_id: existing.id, existing_filename: existing.filename })` (variante enrichie avec details). + - `confirm_duplicate_file=true` → continue, mémoriser `modifier = "duplicate_file"`. + 3. Parse + balance + currency (inchangé). + 4. Convertir vers drafts (`from_imported`) + extraire `period_from`/`period_to`. + 5. **Dans la transaction**, charger `bank_transactions::find_in_dedup_window(...)`. + 6. Appeler `detect_duplicate_lines(...)` → `Vec`. + 7. Filtrer les drafts selon `confirm_duplicate_lines` : + - `Skip` (default) → ne pas insérer les drafts dont `new_index ∈ duplicate_lines.new_index`. Mémoriser `modifier = "duplicate_lines_skipped"`, `details_json.duplicate_lines_skipped = duplicate_lines.len()`. + - `Import` → insérer tout. Mémoriser `modifier = "duplicate_lines_imported"`, `details_json.duplicate_lines_imported = duplicate_lines.len()`. + 8. INSERT `bank_imports` + `bank_transactions` filtrés. + 9. Audit log : **action canonique unique** `bank_import.created` (jamais composée). `details_json.modifiers: Vec` triés alphabétiquement liste les modifiers actifs (`balance_mismatch`, `duplicate_file`, `duplicate_lines_skipped`, `duplicate_lines_imported`, `partial`, `encoding_mismatch`). Si `modifiers.is_empty()` → `details_json.modifiers = []`. Champs additionnels `details_json.duplicate_lines_skipped: N`, `partial_invalid_lines: N`, etc. selon les modifiers actifs. Cf. §audit-log-actions. + 10. Commit transaction. + +- [ ] T6.3 — Modifier le handler `create_csv` (CSV path) — mêmes étapes que T6.2 mais avec `parse_csv_collect` : + 1. Avant parse, compute `file_hash` + check `find_by_company_and_hash` (idem T6.2.2). + 2. `parse_csv_collect(...)`. Si `HardFailure(e)` → mapping AppError (inchangé). Si `AllValid` ou `PartialFailure` → continuer. + 3. Si `PartialFailure { valid, errors, total_errors, truncated }` : + - `confirm_partial_import=false` → `Err(AppError::BankCsvPartialFailure { lines: errors, total_errors, truncated })` (8-2 inchangé). + - `confirm_partial_import=true` AND `valid.is_empty()` → `Err(AppError::BankCsvPartialFailure { ... reason: "no_valid_lines_to_commit" ... })` (AC #16). + - `confirm_partial_import=true` AND `valid.len() > 0` → continue avec `valid` comme drafts. Mémoriser `modifier = "partial"` + `details_json.partial_invalid_lines = errors.len()`, `partial_total_errors = total_errors`, `partial_truncated = truncated`. + 4. Suite identique à T6.2 (period, dedup window, detect_duplicate_lines, INSERT, audit log). + +- [ ] T6.4 — Étendre `crates/kesh-api/src/errors.rs` : + - Modifier `AppError::BankImportDuplicateFile` pour porter `{ existing_import_id: i64, existing_filename: String }` (au lieu de variant unit). Mapping HTTP : `422 BANK_IMPORT_DUPLICATE_FILE` (changement vs 8-1b 409 — cf. §confirm-flags note codes HTTP). + - Modifier `AppError::BankCsvPartialFailure` pour ajouter optional `reason: Option<&'static str>` (pour AC #16 `"no_valid_lines_to_commit"`). + - Pas de nouvelle variante : tous les autres cas réutilisent les variantes 8-1b/8-2 existantes. + +- [ ] T6.5 — Tests E2E HTTP (`crates/kesh-api/tests/bank_imports_e2e.rs`) — **13 nouveaux tests** : + 1. `post_preview_returns_duplicate_file_warning` (AC #1). + 2. `post_import_rejects_duplicate_file_without_confirm` (AC #2 — remplace `unique_company_hash_blocks_duplicate_within_same_company` 8-1b). + 3. `post_import_accepts_duplicate_file_with_confirm` (AC #3). + 4. `post_preview_returns_duplicate_lines_warning` (AC #5). + 5. `post_import_skips_duplicate_lines_by_default` (AC #6). + 6. `post_import_force_imports_duplicate_lines` (AC #7). + 7. `post_import_does_not_detect_duplicate_lines_across_tenants` (AC #10). + 8. `post_preview_csv_returns_invalid_lines_warning` (AC #12) — fixture `csv_partial_failure.csv` 8-2 réutilisée. + 9. `post_import_csv_accepts_partial_with_confirm` (AC #14). + 10. `post_import_csv_accepts_partial_with_truncated_errors` (AC #15) — nouvelle fixture `csv_huge_partial_failure.csv` (50 valid + 150 invalid). + 11. `post_import_csv_rejects_partial_when_zero_valid_lines` (AC #16) — fixture `csv_all_invalid.csv` (0 valid + 3 invalid). + 12. `post_import_csv_combines_three_confirm_flags` (AC #17). + 13. `post_import_returns_duplicate_file_first_when_no_flags` (AC #18) — ordre de précédence (duplicate file check applicatif avant parse). + + **Ordre de précédence des erreurs (§error-precedence-order)** : + + | # | Erreur | HTTP | Overridable ? | + |---|---|---|---| + | 1 | RBAC | 403 | Non (bloquant absolu) | + | 2 | Validation multipart | 400 | Non (bloquant absolu) | + | 3 | Payload too large | 413 | Non (bloquant absolu) | + | 4 | Bank account not found | 404 | Non (bloquant absolu) | + | 5 | Format detection | 415 | Non (bloquant absolu) | + | 6 | **Duplicate file (check applicatif)** | 422 | **Oui** (`confirmDuplicateFile=true`) | + | 7 | Currency unsupported | 422 | Non (bloquant absolu) | + | 8 | Encoding mismatch | 422 | Oui (`confirmEncodingMismatch=true`, hérité 8-2) | + | 9 | Profile misconfigured | 422 | Non (bloquant absolu) | + | 10 | CSV partial failure (parsing-side) | 422 | Oui (`confirmPartialImport=true`) | + | 11 | Balance mismatch | 422 | Oui (`confirmBalanceMismatch=true`, hérité 8-1b) | + | 12 | Duplicate lines | (n/a) | Jamais bloquant — `201` avec skip/import | + + **Note ordre 6 vs 10** : le **duplicate file check est applicatif et précède le parse CSV** (cf. T6.3 step 1). Si l'utilisateur ne confirme pas, la requête est rejetée avant même de tenter le parsing — aucune ligne invalide n'est exposée. Ce choix `fail-fast applicatif` économise le coût de parsing sur des fichiers déjà importés. Conséquence : AC #18 asserte `BANK_IMPORT_DUPLICATE_FILE` en premier (pas `BANK_CSV_PARTIAL_FAILURE`). + +- [ ] T6.6 — Migration des tests audit log 8-1b/8-2 vers le pattern action canonique unique (cf. §audit-log-actions) : + - **Étape 1 — Inventaire** : `rg "bank_import\.created_with_" crates/ tests/ frontend/` et `rg "created_with_balance_mismatch|created_with_encoding_mismatch" crates/`. Lister tous les call sites (production + tests). Path attendu principal : `crates/kesh-api/tests/bank_imports_e2e.rs` (tests E2E HTTP 8-1b/8-2). Possiblement aussi `crates/kesh-api/src/routes/bank_imports.rs` (production — c'est précisément ce que 8-3 va changer). + - **Étape 2 — Production** : remplacer dans `crates/kesh-api/src/routes/bank_imports.rs` les call sites qui construisent `action_str = "bank_import.created_with_balance_mismatch"` ou `..._encoding_mismatch` par le builder de modifiers (cf. T6.2 step 9). Une seule action canonique `bank_import.created` est insérée ; les modifiers sont triés alphabétiquement. + - **Étape 3 — Tests** : adapter les tests E2E 8-1b/8-2 qui asserent l'action discriminante : + - Test 8-1b balance mismatch (probable nom : `post_import_creates_audit_log_with_balance_mismatch_when_confirm_set` ou similaire — à confirmer par grep) : `action == "bank_import.created"` + `details_json["modifiers"].contains("balance_mismatch")`. + - Test 8-2 encoding mismatch (probable : `post_csv_creates_audit_log_with_encoding_mismatch_when_confirm_set`) : idem avec `"encoding_mismatch"`. + - **Étape 4 — Sanity** : `cargo test --workspace` post-implémentation T6.6 doit passer sans régression sur les tests 8-1b/8-2 audit. Si une régression émerge, c'est un signal que le builder de modifiers dans T6.2 step 9 ne couvre pas un cas — investiguer avant Pass 3 code review. + - Pas de Dual-write transitoire : la migration est atomique (le commit 8-3 change la production + les tests dans la même PR). Pas de feature flag (jamais publié à des consommateurs externes — cf. §audit-log-actions backward-compat note). + +### T7. Frontend KF #70 closure + extension UI preview (AC #19, #20, #22, #23, #24, #28) + +- [ ] T7.1 — Étendre `frontend/src/lib/features/bank-import/bank-import.api.ts` : + - `previewBankImport(formData)` ne change pas (multipart construit côté composant). + - `createBankImport(formData)` accepte les nouveaux flags `confirmDuplicateFile`, `confirmDuplicateLines`, `confirmPartialImport`, `confirmEncodingMismatch`, `bankProfileId` (KF #70). Tous optionnels. + - Types : `BankImportPreviewResponse.warnings` étendu avec les 3 nouveaux champs. Strict TypeScript : tous les champs warnings sont optionnels mais `warnings` lui-même est always-present. + +- [ ] T7.2 — Créer `frontend/src/lib/features/bank-import/BankImportPreviewPanel.svelte` (composant partagé) : + - Props : `warning: { type: 'duplicateFile' | 'duplicateLines' | 'invalidLines' | 'balanceMismatch' | 'unsupportedCurrency' | 'encodingMismatch' | 'ignoredStatements', payload: any, confirmFlag?: string, confirmHandler?: (value: any) => void }`. Les props `confirmFlag` et `confirmHandler` sont **optionnels** : présents pour les types avec checkbox/radio (`duplicateFile`, `duplicateLines`, `invalidLines`, `balanceMismatch`, `encodingMismatch`), absents pour les types **read-only** (`unsupportedCurrency`, `ignoredStatements`) qui n'ont pas d'override utilisateur. + - Render : titre i18n + table/text + checkbox/radio si `confirmFlag` présent. Si absent → panneau read-only avec icône warning. + - **Discrimination du type** : pattern Svelte `{#if warning.type === 'duplicateFile'}...{:else if warning.type === 'duplicateLines'}...` dans le template. Pas de composants enfants par type, pour limiter la duplication tout en gardant le shape de chaque payload type-safe via les types TS de `BankImportPreviewResponse.warnings`. + - Pattern : **un seul composant déclaré 7× avec props différents** par `BankImportUpload.svelte`, pas un composant par type. Réduit la duplication. + +- [ ] T7.3 — Étendre `BankImportUpload.svelte` : + - Ajouter état `bankProfileId: number | null` (KF #70 wiring) + `confirmDuplicateFile: boolean` + `confirmDuplicateLines: 'skip' | 'import'` + `confirmPartialImport: boolean` + `confirmEncodingMismatch: boolean`. + - Refactor : déplacer la table de lignes invalides existante (8-2) dans `BankImportPreviewPanel.svelte` (avec props `type='invalidLines'`). + - Ajouter rendering conditionnel des 3 nouveaux panneaux warnings (duplicateFile, duplicateLines, invalidLines) + 1 panneau encodingMismatch (KF #70 wiring) + déjà existant balanceMismatch (8-1b). + - Bouton « Confirmer l'import » désactivé tant que les checkboxes/radios requises ne sont pas dans un état valide (e.g. `warnings.duplicateFile && !confirmDuplicateFile` → bouton disabled). + - data-testid partout (lessons KF-008/KF-010 — pas de `getByText()` brittle). + +- [ ] T7.4 — Créer `frontend/src/lib/features/bank-import/BankProfileSelector.svelte` (KF #70) : + - Props : `profiles: BankProfile[]`, `autoMatchedId: number | null`, `value: number | null`, `onChange: (id) => void`. + - Render : ` (option vide), distinct de +# l'annotation `auto-matched` ci-dessus pour permettre des traductions +# divergentes (ex. EN: "Auto-detect" placeholder vs "(auto-matched)" annotation). +bank-import-labels-bank-profile-auto-detect-placeholder = Auto-détection +# M8 (Pass 1 review) — clés i18n pour les codes informationnels remontés +# via `warnings.informational[]` (snake_case → kebab-case côté frontend). +bank-import-info-bank-csv-profile-auto-matched = Profil bancaire détecté automatiquement par le nom du fichier. +bank-import-info-bank-csv-multiple-profile-matches = Plusieurs profils correspondent au nom du fichier ; le premier a été retenu. +bank-import-errors-no-valid-lines-to-commit = Aucune ligne valide à importer dans le CSV. bank-import-labels-list-title = Imports précédents bank-import-labels-import-success = Import réussi. bank-import-labels-empty = Aucun import bancaire. diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 0a107a7f2..91f5de8ef 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -552,6 +552,12 @@ bank-import-errors-parse-failed = Il file CAMT.053 non ha potuto essere analizza bank-import-warnings-balance-mismatch = Saldo finale incoerente. bank-import-warnings-unsupported-currency = Valuta non supportata in v0.1. bank-import-warnings-ignored-statements = Alcuni estratti del file non corrispondono al conto selezionato e saranno ignorati. +# Story 8-3 — rilevamento duplicati + accettazione parziale +bank-import-warnings-duplicate-file = Questo file è già stato importato. +bank-import-warnings-duplicate-lines-summary = transazioni si sovrappongono a un import precedente. +bank-import-warnings-invalid-lines-summary = righe non valide rilevate nel CSV. +bank-import-warnings-invalid-lines-truncated = Mostrati i primi 100 errori (limite raggiunto). +bank-import-warnings-encoding-mismatch = La codifica rilevata differisce dal profilo. bank-import-labels-page-title = Importazione bancaria CAMT.053 bank-import-labels-bank-account-selector = Conto bancario di destinazione @@ -560,6 +566,20 @@ bank-import-labels-preview-title = Anteprima bank-import-labels-confirm-import = Conferma importazione bank-import-labels-cancel = Annulla bank-import-labels-confirm-balance-mismatch = Importa nonostante lo scarto di saldo +# Story 8-3 — flag di conferma + KF #70 +bank-import-labels-confirm-duplicate-file = Importa nonostante il file già importato +bank-import-labels-confirm-duplicate-lines = Comportamento sui duplicati +bank-import-labels-confirm-duplicate-lines-skip = Ignora i duplicati (predefinito) +bank-import-labels-confirm-duplicate-lines-import = Importa comunque +bank-import-labels-confirm-partial-import = Importa comunque le righe valide +bank-import-labels-confirm-encoding-mismatch = Importa con la codifica rilevata +bank-import-labels-bank-profile-selector = Profilo bancario CSV +bank-import-labels-bank-profile-auto-matched = rilevato automaticamente +# L6 / M8 (Pass 1 review) +bank-import-labels-bank-profile-auto-detect-placeholder = Rilevamento automatico +bank-import-info-bank-csv-profile-auto-matched = Profilo bancario rilevato automaticamente dal nome del file. +bank-import-info-bank-csv-multiple-profile-matches = Più profili corrispondono al nome del file ; è stato selezionato il primo. +bank-import-errors-no-valid-lines-to-commit = Nessuna riga valida da importare nel CSV. bank-import-labels-list-title = Importazioni precedenti bank-import-labels-import-success = Importazione riuscita. bank-import-labels-empty = Nessuna importazione bancaria. diff --git a/crates/kesh-import/src/csv/mod.rs b/crates/kesh-import/src/csv/mod.rs index a9a52884a..5a26c7fdf 100644 --- a/crates/kesh-import/src/csv/mod.rs +++ b/crates/kesh-import/src/csv/mod.rs @@ -16,5 +16,5 @@ pub mod parser; pub mod profile; pub use encoding::{DetectedEncoding, detect_encoding}; -pub use parser::parse_csv; +pub use parser::{ParseCsvOutcome, empty_valid_sentinel_date, parse_csv, parse_csv_collect}; pub use profile::{ColumnMapping, CsvProfile}; diff --git a/crates/kesh-import/src/csv/parser.rs b/crates/kesh-import/src/csv/parser.rs index 47a44bdd3..050384833 100644 --- a/crates/kesh-import/src/csv/parser.rs +++ b/crates/kesh-import/src/csv/parser.rs @@ -30,12 +30,116 @@ use crate::types::{ImportedStatement, ImportedTransaction, SourceFormat}; use chrono::NaiveDate; use rust_decimal::Decimal; +/// Sentinel publique pour `period_from`/`period_to` quand +/// `parse_csv_collect` retourne `PartialFailure { valid: stmt, ... }` +/// avec `stmt.transactions.is_empty()` (M9, Pass 1 review). +/// +/// Le caller `kesh-api::create_csv` doit comparer explicitement contre +/// cette sentinel avant tout usage en DB (typiquement +/// `find_in_dedup_window`) afin d'éviter un BETWEEN scan complet de +/// l'historique. La voie nominale est de rejeter avec +/// `reason = "no_valid_lines_to_commit"` (AC #16) avant cet appel. +/// +/// **F5 (Pass 3 review)** : utilise `NaiveDate::MIN` (an -262143) plutôt +/// que `1970-01-01` (initial M9 Pass 1). Justification : `1970-01-01` +/// est une date réelle qui pourrait apparaître dans un import légitime +/// (système-default, dataset de test/dev). Si une telle transaction +/// arrive avec une autre clé composite à dédupliquer dans la même +/// fenêtre, la garde runtime L-1 court-circuiterait `find_in_dedup_window` +/// et **bypasserait silencieusement** la dedup pour cette transaction. +/// `NaiveDate::MIN` ne peut jamais correspondre à une transaction +/// bancaire réelle → la sentinel reste défensive sans risque de +/// collision avec des données légitimes. Le runtime guard L-1 +/// (côté kesh-api) couvre déjà le risque de scan complet. +pub fn empty_valid_sentinel_date() -> NaiveDate { + NaiveDate::MIN +} + +/// Issue du parser CSV en mode « collect » (Story 8-3 T4). +/// +/// Discrimine trois résultats : +/// +/// - [`ParseCsvOutcome::AllValid`] : toutes les lignes data ont parsé +/// avec succès — l'`ImportedStatement` est complet. +/// - [`ParseCsvOutcome::PartialFailure`] : au moins une ligne data a +/// échoué (mais le parsing global a démarré sans erreur fatale). Le +/// caller décide : +/// - `confirmPartialImport=false` (8-2 strict reject) → retourne +/// `422 BANK_CSV_PARTIAL_FAILURE`. +/// - `confirmPartialImport=true` (8-3 partial commit) → persiste les +/// transactions valides + retourne un warning `invalidLines`. +/// +/// La partie `valid: ImportedStatement` peut avoir un `transactions` +/// vide (cas « 0 valides parmi N invalides », AC #16) auquel cas le +/// handler retourne `422 BANK_CSV_PARTIAL_FAILURE` avec +/// `reason = "no_valid_lines_to_commit"`. +/// - [`ParseCsvOutcome::HardFailure`] : le parser n'a pas pu démarrer +/// (encoding non supporté, profil mal configuré, fichier vide). Le +/// caller mappe vers la variante `AppError` correspondante. +#[derive(Debug, Clone, PartialEq)] +pub enum ParseCsvOutcome { + AllValid(ImportedStatement), + PartialFailure { + valid: ImportedStatement, + errors: Vec, + total_errors: usize, + truncated: bool, + }, + HardFailure(CsvError), +} + /// Parse un fichier CSV bancaire selon le profil. /// -/// Retourne un `ImportedStatement` complet avec `period_from`/`period_to` -/// calculés depuis les booking_dates si succès. En cas d'erreur, voir -/// [`CsvError`] pour la sémantique de chaque variante. +/// Wrapper legacy 8-2 autour de [`parse_csv_collect`] (Story 8-3 T4) : +/// convertit `ParseCsvOutcome::PartialFailure` en +/// `Err(CsvError::PartialFailure { errors, total_errors, truncated })` +/// (sans le champ `valid`). **Backward-compat absolue** — les tests +/// 8-2 ne changent pas. +/// +/// Pour les nouveaux call-sites (Story 8-3 partial commit), utiliser +/// [`parse_csv_collect`]. pub fn parse_csv(bytes: &[u8], profile: &CsvProfile) -> Result { + match parse_csv_collect(bytes, profile) { + ParseCsvOutcome::AllValid(stmt) => Ok(stmt), + ParseCsvOutcome::PartialFailure { + errors, + total_errors, + truncated, + .. + } => Err(CsvError::PartialFailure { + errors, + total_errors, + truncated, + }), + ParseCsvOutcome::HardFailure(e) => Err(e), + } +} + +/// Parse un fichier CSV bancaire en mode « collect » (Story 8-3 T4). +/// +/// Contrairement à [`parse_csv`] (strict reject 8-2), `parse_csv_collect` +/// **collecte** systématiquement les transactions valides ET les +/// erreurs ligne — laisse au caller le choix de rejeter strictement ou +/// de persister les valides via le flag multipart `confirmPartialImport`. +/// +/// Retourne [`ParseCsvOutcome`] discriminant les 3 issues. Les caps +/// anti-DoS hérités 8-2 (`MAX_CSV_LINE_ERRORS = 100` + flag +/// `truncated`) sont préservés. +pub fn parse_csv_collect(bytes: &[u8], profile: &CsvProfile) -> ParseCsvOutcome { + match parse_csv_collect_inner(bytes, profile) { + Ok(outcome) => outcome, + Err(e) => ParseCsvOutcome::HardFailure(e), + } +} + +/// Helper interne : retourne `Ok(AllValid|PartialFailure)` pour le cas +/// nominal et `Err(CsvError)` pour les hard failures (encoding, profile, +/// missing header, empty file). [`parse_csv_collect`] convertit +/// `Err(...)` en [`ParseCsvOutcome::HardFailure`] uniformément. +fn parse_csv_collect_inner( + bytes: &[u8], + profile: &CsvProfile, +) -> Result { profile.validate()?; // 1. Détection encoding + skip BOM @@ -190,42 +294,41 @@ pub fn parse_csv(bytes: &[u8], profile: &CsvProfile) -> Result MAX_CSV_LINE_ERRORS; - return Err(CsvError::PartialFailure { - errors, - total_errors, - truncated, - }); - } - - // 9. EmptyFile si 0 transactions valides après iteration complète. - if transactions.is_empty() { - return Err(CsvError::EmptyFile { - reason: "0 valid transactions after parsing".to_string(), - }); - } - - // 10. Calcul period_from / period_to (Pass 1 H2) - let period_from = transactions - .iter() - .map(|t| t.booking_date) - .min() - .expect("transactions non-vide vérifié ligne 8"); - let period_to = transactions - .iter() - .map(|t| t.booking_date) - .max() - .expect("transactions non-vide vérifié ligne 8"); - - // 11. Construire ImportedStatement + // 8. Construit l'`ImportedStatement` candidat (avec les transactions + // valides, possiblement vide si toutes ont échoué). Story 8-3 T4 : + // `parse_csv_collect` retourne toujours un `ImportedStatement` — + // le caller décide de rejeter (strict mode 8-2) ou persister + // (partial commit 8-3) selon `confirmPartialImport`. // // Note Pass 2 M'4 alignement : `ImportedStatement.account_iban: String` // (pas Option). CSV n'expose pas d'IBAN → string vide. Le frontend // serialise `accountIban === ""` comme « pas d'IBAN » côté UI // (cf. §preview-csv-response-shape). statement_id reste Option. - Ok(ImportedStatement { + let sentinel = empty_valid_sentinel_date(); + let (period_from, period_to) = if transactions.is_empty() { + // M9 (Pass 1 review) — sentinel epoch 1970-01-01 (cf. doc-comment + // de [`empty_valid_sentinel_date`]). Le caller doit comparer + // explicitement avant tout usage en DB. + (sentinel, sentinel) + } else { + let from = transactions + .iter() + .map(|t| t.booking_date) + .min() + .expect("transactions non-vide"); + let to = transactions + .iter() + .map(|t| t.booking_date) + .max() + .expect("transactions non-vide"); + (from, to) + }; + debug_assert!( + (period_from == sentinel) == transactions.is_empty(), + "empty_valid_sentinel_date invariant: sentinel iff transactions.is_empty()" + ); + + let stmt = ImportedStatement { source_format: SourceFormat::Csv { encoding: detected_encoding.as_str().to_string(), profile_name: Some(profile.bank_name.clone()), @@ -238,7 +341,28 @@ pub fn parse_csv(bytes: &[u8], profile: &CsvProfile) -> Result MAX_CSV_LINE_ERRORS; + return Ok(ParseCsvOutcome::PartialFailure { + valid: stmt, + errors, + total_errors, + truncated, + }); + } + + // 0 erreurs ET 0 valides — défensif (en pratique unreachable post + // first_record check, mais garde la sémantique 8-2 EmptyFile). + if stmt.transactions.is_empty() { + return Err(CsvError::EmptyFile { + reason: "0 valid transactions after parsing".to_string(), + }); + } + + Ok(ParseCsvOutcome::AllValid(stmt)) } /// Parse une ligne CSV en `ImportedTransaction` ou `CsvLineError`. @@ -975,4 +1099,177 @@ mod tests { other => panic!("expected PartialFailure, got {:?}", other), } } + + // ───────────────────────────────────────────────────────────── + // Story 8-3 T4.3 — `parse_csv_collect` 6 tests + // ───────────────────────────────────────────────────────────── + + fn simple_profile() -> CsvProfile { + make_profile( + ';', + '.', + 1, + ColumnMapping { + date: 0, + amount: Some(1), + debit_credit_split: None, + reference: Some(2), + details: Some(3), + counterparty: None, + }, + ) + } + + #[test] + fn parse_csv_collect_all_valid_returns_all_valid() { + // T4.3#1 — happy path : 2 lignes valides → AllValid. + let csv = + "date;amount;ref;details\n2026-01-15;100.00;R1;Loyer\n2026-01-16;-50.00;R2;Achat\n"; + let outcome = parse_csv_collect(csv.as_bytes(), &simple_profile()); + match outcome { + ParseCsvOutcome::AllValid(stmt) => { + assert_eq!(stmt.transactions.len(), 2); + assert_eq!(stmt.transactions[0].amount, dec!(100.00)); + } + other => panic!("expected AllValid, got {:?}", other), + } + } + + #[test] + fn parse_csv_collect_partial_returns_valid_and_errors() { + // T4.3#2 — fixture-like : 3 valides + 2 invalides (date + amount). + let csv = "date;amount;ref;details\n\ + 2026-01-15;100.00;R1;A\n\ + INVALID_DATE;200.00;R2;B\n\ + 2026-01-17;NOT_A_NUMBER;R3;C\n\ + 2026-01-18;300.00;R4;D\n\ + 2026-01-19;400.00;R5;E\n"; + let outcome = parse_csv_collect(csv.as_bytes(), &simple_profile()); + match outcome { + ParseCsvOutcome::PartialFailure { + valid, + errors, + total_errors, + truncated, + } => { + assert_eq!(valid.transactions.len(), 3); + assert_eq!(errors.len(), 2); + assert_eq!(total_errors, 2); + assert!(!truncated); + // Vérifie codes d'erreur attendus. + let codes: Vec<_> = errors.iter().map(|e| e.code).collect(); + assert!(codes.contains(&CsvLineErrorCode::InvalidDate)); + assert!(codes.contains(&CsvLineErrorCode::InvalidAmount)); + } + other => panic!("expected PartialFailure, got {:?}", other), + } + } + + #[test] + fn parse_csv_collect_caps_errors_at_max() { + // T4.3#3 — 50 valides + 150 invalides → errors=100, total_errors=150, + // truncated=true, valid=50. + let mut csv = String::from("date;amount;ref;details\n"); + for i in 0..50 { + csv.push_str(&format!("2026-01-15;{}.00;R{};V\n", i + 1, i)); + } + for i in 0..150 { + csv.push_str(&format!("INVALID_DATE_{};100.00;R;X\n", i)); + } + let outcome = parse_csv_collect(csv.as_bytes(), &simple_profile()); + match outcome { + ParseCsvOutcome::PartialFailure { + valid, + errors, + total_errors, + truncated, + } => { + assert_eq!(valid.transactions.len(), 50); + assert_eq!(errors.len(), 100, "cap MAX_CSV_LINE_ERRORS=100"); + assert_eq!(total_errors, 150); + assert!(truncated, "truncated flag doit être true"); + } + other => panic!("expected PartialFailure, got {:?}", other), + } + } + + #[test] + fn parse_csv_collect_zero_valid_returns_partial_with_empty_valid() { + // T4.3#4 — 0 valides + 3 invalides → PartialFailure { valid empty }. + let csv = "date;amount;ref;details\n\ + INVALID_A;100;R1;A\n\ + INVALID_B;200;R2;B\n\ + INVALID_C;300;R3;C\n"; + let outcome = parse_csv_collect(csv.as_bytes(), &simple_profile()); + match outcome { + ParseCsvOutcome::PartialFailure { + valid, + errors, + total_errors, + truncated, + } => { + assert!(valid.transactions.is_empty(), "0 valides attendu"); + assert_eq!(errors.len(), 3); + assert_eq!(total_errors, 3); + assert!(!truncated); + } + other => panic!("expected PartialFailure with empty valid, got {:?}", other), + } + } + + #[test] + fn parse_csv_collect_zero_valid_uses_sentinel_date() { + // M9 (Pass 1) + F5 (Pass 3) — quand `transactions.is_empty()` après + // parse, `period_from`/`period_to` doivent être la sentinel publique + // [`empty_valid_sentinel_date`] (= `NaiveDate::MIN`, jamais une date + // bancaire réelle) pour permettre au caller de comparer explicitement + // avant un `find_in_dedup_window` sans risque de collision avec des + // données légitimes (ex. transaction datée 1970-01-01). + let csv = "date;amount;ref;details\n\ + INVALID_A;100;R1;A\n"; + let outcome = parse_csv_collect(csv.as_bytes(), &simple_profile()); + match outcome { + ParseCsvOutcome::PartialFailure { valid, .. } => { + let sentinel = empty_valid_sentinel_date(); + assert_eq!(valid.transactions.len(), 0); + assert_eq!(valid.period_from, sentinel); + assert_eq!(valid.period_to, sentinel); + assert_eq!(sentinel, NaiveDate::MIN); + } + other => panic!("expected PartialFailure, got {:?}", other), + } + } + + #[test] + fn parse_csv_collect_hard_failure_on_empty_file() { + // T4.3#5 — header seul (0 data rows) → HardFailure(EmptyFile). + let csv = "date;amount;ref;details\n"; + let outcome = parse_csv_collect(csv.as_bytes(), &simple_profile()); + match outcome { + ParseCsvOutcome::HardFailure(CsvError::EmptyFile { .. }) => {} + other => panic!("expected HardFailure(EmptyFile), got {:?}", other), + } + } + + #[test] + fn parse_csv_wrapper_preserves_legacy_behavior() { + // T4.3#6 — `parse_csv` (signature 8-2) doit retourner Err sur + // les partial failures, sans exposer le champ `valid`. + let csv = "date;amount;ref;details\n\ + 2026-01-15;100.00;R1;A\n\ + INVALID_DATE;200;R2;B\n"; + let err = parse_csv(csv.as_bytes(), &simple_profile()).unwrap_err(); + match err { + CsvError::PartialFailure { + errors, + total_errors, + truncated, + } => { + assert_eq!(errors.len(), 1); + assert_eq!(total_errors, 1); + assert!(!truncated); + } + other => panic!("expected PartialFailure (legacy), got {:?}", other), + } + } } diff --git a/crates/kesh-import/src/lib.rs b/crates/kesh-import/src/lib.rs index aa46dfaf4..701e663e9 100644 --- a/crates/kesh-import/src/lib.rs +++ b/crates/kesh-import/src/lib.rs @@ -30,7 +30,10 @@ pub mod error; pub mod types; pub use camt053::parse as parse_camt053; -pub use csv::{ColumnMapping, CsvProfile, DetectedEncoding, parse_csv}; +pub use csv::{ + ColumnMapping, CsvProfile, DetectedEncoding, ParseCsvOutcome, empty_valid_sentinel_date, + parse_csv, parse_csv_collect, +}; pub use error::{ CamtError, CsvError, CsvLineError, CsvLineErrorCode, ImportError, MAX_CSV_LINE_ERROR_VALUE_CHARS, MAX_CSV_LINE_ERRORS, diff --git a/crates/kesh-import/tests/fixtures/camt053/v04_overlap.xml b/crates/kesh-import/tests/fixtures/camt053/v04_overlap.xml new file mode 100644 index 000000000..ac488d4de --- /dev/null +++ b/crates/kesh-import/tests/fixtures/camt053/v04_overlap.xml @@ -0,0 +1,110 @@ + + + + + + STMT-2026-05-002-OVERLAP + 2026-05-31T23:59:00 + + + STMT-002 + 2026-05-31T23:59:00 + + 2026-05-01T00:00:00 + 2026-05-31T23:59:59 + + + + CH4431999123000889012 + + CHF + + + + + OPBD + + + 10000.00 + CRDT +
+
2026-05-01
+ +
+ + + + CLBD + + + 11784.56 + CRDT +
+
2026-05-31
+ +
+ + + 1234.56 + CRDT + BOOK + +
2026-05-15
+
+ +
2026-05-15
+
+ BANK-TX-42 + + + + E2E-2026-05-001 + + + + + RF18539007547034 + + + Loyer mai 2026 + + + + Loyer mai 2026 +
+ + + 550.00 + CRDT + BOOK + +
2026-05-20
+
+ +
2026-05-20
+
+ BANK-TX-43 + + + + E2E-2026-05-002 + + + + + RF99887766554433 + + + Honoraires mai 2026 + + + + Honoraires mai 2026 +
+
+
+
diff --git a/frontend/src/lib/features/bank-import/BankImportUpload.svelte b/frontend/src/lib/features/bank-import/BankImportUpload.svelte index 7efefd286..a011775cd 100644 --- a/frontend/src/lib/features/bank-import/BankImportUpload.svelte +++ b/frontend/src/lib/features/bank-import/BankImportUpload.svelte @@ -2,21 +2,21 @@ Story 8-1b — Upload + preview + confirm dans un seul composant (state machine inline : idle → file-selected → previewing → preview-shown → confirming → success/error). - Pour scope v0.1, drop-zone + file input + preview table sont - cohabités dans le même composant — le découpage en 5 sous-composants - (BankImportPreviewTable, etc.) reste possible en post-v0.1 si l'UX - veut isoler. Le data-testid pattern (Story 7-5/KF-008) est utilisé - partout pour les E2E Playwright. + Story 8-3 — extension : + - 4 nouveaux panneaux warnings : duplicateFile, duplicateLines, invalidLines, encodingMismatch + - 3 nouveaux flags confirm : confirmDuplicateFile, confirmDuplicateLines (skip|import), confirmPartialImport + - KF #70 closure : BankProfileSelector + confirmEncodingMismatch wiring -->
@@ -233,6 +213,17 @@ {/each} + {#if availableProfiles.length > 0} +
+ (bankProfileId = id)} + /> +
+ {/if} + {#if !preview}

- - {i18nMsg( - 'bank-import-profile-labels-page-title', - 'Profils bancaires CSV', - )} + + {i18nMsg('bank-import-profile-labels-page-title', 'Profils bancaires CSV')}

{/if} @@ -294,31 +278,6 @@ role="alert" >

{errorMessage}

- {#if errorPartialLines && errorPartialLines.length > 0} -
- - - - - - - - - - {#each errorPartialLines as line, i (i)} - - - - - - {/each} - -
LigneCodeValeur
{line.line}{line.code}{line.value ?? '—'}
-
- {/if} {/if} @@ -342,13 +301,251 @@
{preview.transactions.length}
- {#if preview.warnings.length > 0} + + {#if preview.warnings.duplicateFile} +
+

+ {i18nMsg( + 'bank-import-warnings-duplicate-file', + 'Ce fichier a déjà été importé.', + )} +

+

+ + {preview.warnings.duplicateFile.existingFilename} + + — {preview.warnings.duplicateFile.existingImportedAt} +

+ +
+ {/if} + + + {#if preview.warnings.duplicateLines.length > 0} +
+

+ {preview.warnings.duplicateLines.length} + {i18nMsg( + 'bank-import-warnings-duplicate-lines-summary', + 'transactions chevauchent un import précédent.', + )} +

+ + + + + + + + + + {#each preview.warnings.duplicateLines as dup (dup.newIndex)} + + + + + + {/each} + +
IndexID existantClé
{dup.newIndex}{dup.existingTransactionId}{dup.key}
+
+ + {i18nMsg( + 'bank-import-labels-confirm-duplicate-lines', + 'Comportement face aux doublons', + )} + + + +
+
+ {/if} + + + {#if preview.warnings.invalidLines} +
+

+ {preview.warnings.invalidLines.totalErrors} + {i18nMsg( + 'bank-import-warnings-invalid-lines-summary', + 'lignes invalides détectées', + )} +

+ {#if preview.warnings.invalidLines.truncated} +

+ {i18nMsg( + 'bank-import-warnings-invalid-lines-truncated', + 'Premières 100 erreurs affichées (cap atteint).', + )} +

+ {/if} +
+ + + + + + + + + + {#each preview.warnings.invalidLines.lines as line, i (i)} + + + + + + {/each} + +
LigneCodeValeur
{line.line}{line.code}{line.value ?? '—'}
+
+ +
+ {/if} + + + {#if preview.warnings.encodingMismatch}
- {#each preview.warnings as warn (warn)} -

{warningLabel(warn)}

+

+ {i18nMsg( + 'bank-import-warnings-encoding-mismatch', + "L'encodage détecté diffère du profil.", + )} +

+

+ {preview.warnings.encodingMismatch.profile} → {preview.warnings.encodingMismatch + .detected} +

+ +
+ {/if} + + + {#if preview.warnings.balanceMismatch} +
+

+ {i18nMsg( + 'bank-import-warnings-balance-mismatch', + 'Solde de clôture incohérent.', + )} +

+

+ Δ = {preview.warnings.balanceMismatch.diff} +

+ +
+ {/if} + + + {#if preview.warnings.unsupportedCurrency} +
+

+ {i18nMsg( + 'bank-import-warnings-unsupported-currency', + 'Devise non supportée v0.1.', + )} + {preview.warnings.unsupportedCurrency.currency} +

+
+ {/if} + + + + {#if preview.warnings.informational.length > 0} +
+ {#each preview.warnings.informational as info (info)} +

+ {i18nMsg(`bank-import-info-${info.replace(/_/g, '-')}`, info)} +

{/each}
{/if} @@ -380,27 +577,12 @@ - {#if preview.warnings.includes('balance_mismatch')} - - {/if} -