diff --git a/src/__fixtures__/csv/bank-bnc.csv b/src/__fixtures__/csv/bank-bnc.csv new file mode 100644 index 0000000..f5f5585 --- /dev/null +++ b/src/__fixtures__/csv/bank-bnc.csv @@ -0,0 +1,7 @@ +Date;Description;Catégorie;Débit;Crédit;Solde +05/01/2025;EPICERIE METRO SAINTE-FOY;Alimentation;84,32;;915,68 +15/01/2025;DEPOT PAIE EMPLOYEUR;Revenu;;1250,00;2165,68 +18/01/2025;HYDRO QUEBEC PREAUTORISE;Services publics;142,18;;2023,50 +22/01/2025;RESTAURANT LE BISTRO;Restaurants;56,75;;1966,75 +27/01/2025;VIREMENT RECU;Transferts;;300,00;2266,75 +30/01/2025;FRAIS MENSUELS;Frais bancaires;6,95;;2259,80 diff --git a/src/__fixtures__/csv/bank-desjardins.csv b/src/__fixtures__/csv/bank-desjardins.csv new file mode 100644 index 0000000..4d0de25 --- /dev/null +++ b/src/__fixtures__/csv/bank-desjardins.csv @@ -0,0 +1,7 @@ +Date;Description;Montant;Solde +05/01/2025;EPICERIE METRO SAINTE-FOY;-84,32;915,68 +15/01/2025;DEPOT PAIE EMPLOYEUR;1250,00;2165,68 +18/01/2025;HYDRO QUEBEC PREAUTORISE;-142,18;2023,50 +22/01/2025;RESTAURANT LE BISTRO;-56,75;1966,75 +27/01/2025;VIREMENT RECU;300,00;2266,75 +30/01/2025;FRAIS MENSUELS;-6,95;2259,80 diff --git a/src/__fixtures__/csv/bank-rbc.csv b/src/__fixtures__/csv/bank-rbc.csv new file mode 100644 index 0000000..40c6fb9 --- /dev/null +++ b/src/__fixtures__/csv/bank-rbc.csv @@ -0,0 +1,7 @@ +Account Type,Account Number,Transaction Date,Cheque Number,Description 1,Description 2,CAD$,USD$ +Chequing,1234567,05/01/2025,,EPICERIE METRO SAINTE-FOY,,-84.32, +Chequing,1234567,15/01/2025,,DEPOT PAIE EMPLOYEUR,PAIE,1250.00, +Chequing,1234567,18/01/2025,,HYDRO QUEBEC PREAUTORISE,,-142.18, +Chequing,1234567,22/01/2025,,RESTAURANT LE BISTRO,,-56.75, +Chequing,1234567,27/01/2025,,VIREMENT RECU,,300.00, +Chequing,1234567,30/01/2025,241,FRAIS MENSUELS,,-6.95, diff --git a/src/__fixtures__/csv/bank-tangerine.csv b/src/__fixtures__/csv/bank-tangerine.csv new file mode 100644 index 0000000..901a635 --- /dev/null +++ b/src/__fixtures__/csv/bank-tangerine.csv @@ -0,0 +1,7 @@ +Date,Transaction,Name,Memo,Amount +05/01/2025,DEBIT,EPICERIE METRO SAINTE-FOY,,-84.32 +15/01/2025,CREDIT,DEPOT PAIE EMPLOYEUR,Paie bimensuelle,1250.00 +18/01/2025,DEBIT,HYDRO QUEBEC PREAUTORISE,,-142.18 +22/01/2025,DEBIT,RESTAURANT LE BISTRO,,-56.75 +27/01/2025,CREDIT,VIREMENT RECU,,300.00 +30/01/2025,DEBIT,FRAIS MENSUELS,,-6.95 diff --git a/src/__fixtures__/csv/index.ts b/src/__fixtures__/csv/index.ts index 147308f..c189eaa 100644 --- a/src/__fixtures__/csv/index.ts +++ b/src/__fixtures__/csv/index.ts @@ -28,7 +28,17 @@ export type CsvFixtureName = | "no-header" | "all-positive" | "desjardins-quoted" - | "absolute-indicator"; + | "absolute-indicator" + // Bank-signature cases (#330). Each one is the DOCUMENTED header layout of a + // bank, invented row by row like the rest of the corpus. Three of them are + // shapes the generic dictionary reads WRONG — Tangerine's `Transaction` type + // column taken for the description, RBC's `CAD$` paired with the near-empty + // `Cheque Number` as a debit/credit pair — which is what the signatures exist + // for. + | "bank-desjardins" + | "bank-rbc" + | "bank-bnc" + | "bank-tangerine"; /** Every case in the corpus, in the order the issue lists them. */ export const CSV_FIXTURE_NAMES: readonly CsvFixtureName[] = [ @@ -43,6 +53,10 @@ export const CSV_FIXTURE_NAMES: readonly CsvFixtureName[] = [ "all-positive", "desjardins-quoted", "absolute-indicator", + "bank-desjardins", + "bank-rbc", + "bank-bnc", + "bank-tangerine", ]; /** diff --git a/src/components/import/FilePreviewTable.tsx b/src/components/import/FilePreviewTable.tsx index ac7e933..cdd220a 100644 --- a/src/components/import/FilePreviewTable.tsx +++ b/src/components/import/FilePreviewTable.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { AlertCircle, ArrowDownLeft, ArrowUpRight, RefreshCw } from "lucide-react"; import type { ParsedRow } from "../../shared/types"; import { isRowErrorKey, summarizeParsedRows } from "../../utils/importFormat"; +import RepairPathNotice from "./RepairPathNotice"; /** Rows rendered in the table. The recap above it always covers the whole file. */ const DISPLAYED_ROWS = 20; @@ -119,6 +120,16 @@ export default function FilePreviewTable({ )} + + {/* + The repair path (#330), stated where the correction is offered. A user + who flips the signs here has just learned that earlier imports of the + same source read backwards; re-importing those files is the natural + next move and it double-books every row instead of fixing them. + */} +
+ +
diff --git a/src/components/import/FormatDriftPanel.tsx b/src/components/import/FormatDriftPanel.tsx new file mode 100644 index 0000000..b25148b --- /dev/null +++ b/src/components/import/FormatDriftPanel.tsx @@ -0,0 +1,133 @@ +import { useTranslation } from "react-i18next"; +import { AlertTriangle, ArrowRight, Minus, Plus } from "lucide-react"; +import type { HeaderDriftEntry } from "../../utils/bankSignatures"; +import RepairPathNotice from "./RepairPathNotice"; + +/** + * The header row of this source changed since its last successful import + * (#330). + * + * This is the failure mode the whole chantier is most afraid of, because it is + * the one nobody sees: a bank moves a column, the stored mapping keeps reading + * position 3, and the import succeeds — with the balance in the amount column, + * for as long as it takes someone to notice. There is no signal in the totals, + * no error, no red row. The only signal is the header itself, which is why it + * is recorded and compared. + * + * The panel does not decide. It shows what moved, column by column, and offers + * the two answers that exist: read the file the way it looks now, or keep + * reading it the way it used to. Nothing here writes to the database. + */ +interface FormatDriftPanelProps { + entries: HeaderDriftEntry[]; + /** False when detection could not read the new shape — nothing to adopt. */ + canAdopt: boolean; + onAdopt: () => void; + onKeep: () => void; + isBusy?: boolean; +} + +export default function FormatDriftPanel({ + entries, + canAdopt, + onAdopt, + onKeep, + isBusy = false, +}: FormatDriftPanelProps) { + const { t } = useTranslation(); + + const buttonClass = + "px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"; + + return ( +
+
+ +
+

+ {t("import.drift.title")} +

+

+ {t("import.drift.intro")} +

+
+
+ + {/* + Column by column, naming the columns. This is what the stored signature + being a label list rather than a hash buys: a hash can say "something + changed" and nothing more. + */} + + + + +
+
+ +

+ {canAdopt + ? t("import.drift.adoptHint") + : t("import.drift.adoptUnavailable")} +

+
+
+ +

+ {t("import.drift.keepHint")} +

+
+
+
+ ); +} diff --git a/src/components/import/RepairPathNotice.tsx b/src/components/import/RepairPathNotice.tsx new file mode 100644 index 0000000..a8af0c9 --- /dev/null +++ b/src/components/import/RepairPathNotice.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next"; +import { LifeBuoy } from "lucide-react"; + +/** + * The only safe way to repair an import that was already written wrong (#330). + * + * WHY THIS IS IN THE INTERFACE AND NOT ONLY IN A RISK TABLE. Every correction + * this chantier delivers — the restored format (#324), the anchored amount + * parser (#325), the sign flip and the drift panel — changes the amounts a + * source PRODUCES. The natural next move is to re-import the file "now that it + * reads right", and that move silently doubles the ledger: + * `findDuplicates` (`transactionService.ts:164`) matches on + * `date AND description AND amount`, so a corrected row does not match the + * wrong row already stored. It is filed as new. + * + * A flipped sign is the worst shape of it: the wrong row and the corrected row + * are mirror images, they net to about zero in every report, and nothing on any + * screen looks off. The only path that leaves a correct ledger is deleting the + * faulty import from the history first — `deleteImportWithTransactions` takes + * its transactions with it — and replaying the file afterwards. + * + * Shown in both places a user can be about to do exactly that: the preview, + * next to the sign flip, and the format-drift panel. + */ +export default function RepairPathNotice() { + const { t } = useTranslation(); + + return ( +
+ +
+

+ {t("import.repairPath.title")} +

+

+ {t("import.repairPath.body")} +

+
+
+ ); +} diff --git a/src/components/import/SourceConfigPanel.tsx b/src/components/import/SourceConfigPanel.tsx index 0cb1e81..0d81fd6 100644 --- a/src/components/import/SourceConfigPanel.tsx +++ b/src/components/import/SourceConfigPanel.tsx @@ -10,6 +10,10 @@ import type { ImportConfigTemplate, } from "../../shared/types"; import type { DetectionScore } from "../../utils/csvAutoDetect"; +import { + bankSignatureById, + type BankSignatureId, +} from "../../utils/bankSignatures"; import ColumnMappingEditor from "./ColumnMappingEditor"; interface SourceConfigPanelProps { @@ -30,6 +34,8 @@ interface SourceConfigPanelProps { selectedTemplateId: number | null; /** Result of the last detection run, or null when none ran on this source. */ detectionScore?: DetectionScore | null; + /** Bank recognised by signature during that same run, or null (#330). */ + detectedBank?: BankSignatureId | null; isLoading?: boolean; } @@ -50,12 +56,23 @@ export default function SourceConfigPanel({ onDeleteTemplate, selectedTemplateId, detectionScore, + detectedBank, isLoading, }: SourceConfigPanelProps) { const { t } = useTranslation(); const [showSaveTemplate, setShowSaveTemplate] = useState(false); const [templateName, setTemplateName] = useState(""); + /* + A recognised bank replaces the generic wording, but ONLY on the confident + arm (#330). "Format Desjardins reconnu" over a file two thirds of whose + rows would not read is a claim the app cannot back: the label matched, the + data did not follow, and the uncertain wording is the one that helps. + */ + const bank = detectionScore?.confident + ? bankSignatureById(detectedBank) + : null; + const selectClass = "w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"; const inputClass = selectClass; @@ -107,15 +124,23 @@ export default function SourceConfigPanel({ )}

- {t( - detectionScore.confident - ? "import.config.detectionRecognized" - : "import.config.detectionUncertain", - { - read: detectionScore.readRows, - total: detectionScore.totalRows, - } - )} + {bank + ? t("import.config.detectionBank", { + // A bank name is a proper noun: it is interpolated, never + // translated. + bank: bank.label, + read: detectionScore.readRows, + total: detectionScore.totalRows, + }) + : t( + detectionScore.confident + ? "import.config.detectionRecognized" + : "import.config.detectionUncertain", + { + read: detectionScore.readRows, + total: detectionScore.totalRows, + } + )}

{!detectionScore.confident && (

diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index b2fa11e..6fe3c1f 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -44,6 +44,12 @@ import { detectImportFormat as runAutoDetect, type DetectionScore, } from "../utils/csvAutoDetect"; +import { + buildHeaderSignature, + detectHeaderDrift, + type BankSignatureId, + type HeaderDriftEntry, +} from "../utils/bankSignatures"; import { detectAmountSeparators, flipSignFormat, @@ -85,6 +91,24 @@ interface WizardState { * ran — a source opened on its stored format, which is never re-detected. */ detectionScore: DetectionScore | null; + /** + * Bank whose documented layout the last detection recognised (#330). Tied to + * `detectionScore`: it is set with it and cleared with it, so a badge naming + * a bank can never outlive the detection that named it. + */ + detectedBank: BankSignatureId | null; + /** + * How the header row of the files being imported differs from the one the + * source recorded at its last successful import — null when there is nothing + * to report, which includes every source that has no stored signature. + */ + formatDrift: HeaderDriftEntry[] | null; + /** + * The configuration re-detected on the drifted file, offered by the panel as + * "adopt". Null when detection could not read the new shape either: the drift + * is still worth reporting, there is just nothing to adopt. + */ + driftConfig: SourceConfig | null; } type WizardAction = @@ -107,6 +131,11 @@ type WizardAction = | { type: "SET_CONFIG_TEMPLATES"; payload: ImportConfigTemplate[] } | { type: "SET_SELECTED_TEMPLATE_ID"; payload: number | null } | { type: "SET_DETECTION_SCORE"; payload: DetectionScore | null } + | { type: "SET_DETECTED_BANK"; payload: BankSignatureId | null } + | { + type: "SET_FORMAT_DRIFT"; + payload: { entries: HeaderDriftEntry[]; config: SourceConfig | null } | null; + } | { type: "RESET" }; const defaultConfig: SourceConfig = { @@ -142,6 +171,9 @@ const initialState: WizardState = { configTemplates: [], selectedTemplateId: null, detectionScore: null, + detectedBank: null, + formatDrift: null, + driftConfig: null, }; function reducer(state: WizardState, action: WizardAction): WizardState { @@ -159,8 +191,17 @@ function reducer(state: WizardState, action: WizardAction): WizardState { case "SET_SELECTED_SOURCE": // The score belongs to the source it was measured on. Carrying it over // would let a source opened on its stored format inherit the confidence - // of the previous one, which is worse than showing nothing. - return { ...state, selectedSource: action.payload, detectionScore: null }; + // of the previous one, which is worse than showing nothing. The bank + // badge and the drift report are scoped the same way, for the same + // reason — both describe one source's files. + return { + ...state, + selectedSource: action.payload, + detectionScore: null, + detectedBank: null, + formatDrift: null, + driftConfig: null, + }; case "SET_SELECTED_FILES": return { ...state, selectedFiles: action.payload }; case "SET_SOURCE_CONFIG": @@ -212,7 +253,24 @@ function reducer(state: WizardState, action: WizardAction): WizardState { case "SET_SELECTED_TEMPLATE_ID": return { ...state, selectedTemplateId: action.payload }; case "SET_DETECTION_SCORE": - return { ...state, detectionScore: action.payload }; + // Clearing the score clears the bank with it, in ONE place: the three + // sites that drop the score (a hand edit, a template, a sign flip) then + // cannot leave "Format Desjardins reconnu" standing over a format + // detection never produced. A bank is only ever set by dispatching + // `SET_DETECTED_BANK` right after a non-null score. + return { + ...state, + detectionScore: action.payload, + detectedBank: action.payload === null ? null : state.detectedBank, + }; + case "SET_DETECTED_BANK": + return { ...state, detectedBank: action.payload }; + case "SET_FORMAT_DRIFT": + return { + ...state, + formatDrift: action.payload?.entries ?? null, + driftConfig: action.payload?.config ?? null, + }; case "RESET": return { ...initialState, @@ -346,6 +404,9 @@ export function useImportWizard() { } dispatch({ type: "SET_DETECTION_SCORE", payload: outcome.score }); + // Always dispatched, including the `null` of an unknown file: a bank left + // over from the previously detected file would name the wrong bank. + dispatch({ type: "SET_DETECTED_BANK", payload: outcome.bank }); return { ...base, ...outcome.config }; }, [] @@ -672,6 +733,48 @@ export function useImportWizard() { type: "SET_PARSED_PREVIEW", payload: result, }); + + // Format drift (#330). Compared HERE and not at the configuration step + // because this is where the header of the files actually being imported + // is known — `previewHeaders` at the configuration step is read from the + // first file alone, under a delimiter the user may still be editing. + // + // `hasHeader` gates the whole thing: a headerless file has `Col 0`, + // `Col 1` … for headers, which is not a signature and must never be + // compared to one. + const drift = state.sourceConfig.hasHeader + ? detectHeaderDrift( + state.existingSource?.header_signature, + result.headers + ) + : null; + + if (drift) { + // Re-detect on the drifted file so the panel has something to offer. + // This is the THIRD caller of the single detection path, and it goes + // through it rather than around it on purpose: a second detector is the + // divergence class this whole chantier removes. A file the re-detection + // cannot read leaves `null` — the drift is still reported, with nothing + // to adopt. + const reDetected = await detectFormatForFile( + state.selectedFiles[0].file_path, + state.sourceConfig + ); + // That run measured the RE-DETECTED format, which is not the one the + // wizard is using — it is only what the panel offers. Leaving its score + // and its bank badge behind would put "Format reconnu — 6 of 6 rows + // read" next to the stored mapping the moment the user steps back to + // the configuration. Same rule as the sign flip: a badge describes the + // format in use or it describes nothing. + dispatch({ type: "SET_DETECTION_SCORE", payload: null }); + dispatch({ + type: "SET_FORMAT_DRIFT", + payload: { entries: drift, config: reDetected }, + }); + } else { + dispatch({ type: "SET_FORMAT_DRIFT", payload: null }); + } + dispatch({ type: "SET_STEP", payload: "file-preview" }); } catch (e) { dispatch({ @@ -679,7 +782,13 @@ export function useImportWizard() { payload: e instanceof Error ? e.message : String(e), }); } - }, [state.selectedFiles, parseFilesInternal]); + }, [ + state.selectedFiles, + state.sourceConfig, + state.existingSource, + parseFilesInternal, + detectFormatForFile, + ]); // Internal helper: runs duplicate checking against parsed rows. // @@ -836,18 +945,31 @@ export function useImportWizard() { // behind, and it goes through `formatToRow` so no field can be dropped. // It has to precede the file records, which carry a `source_id` FK. const formatRow = formatToRow(config); + + // The header row this import actually read, recorded so the NEXT one can + // be compared against it (#330). It is not a format field and does not go + // through the codec: it says what the file looked like, not how to read + // it. A headerless file writes null — `previewHeaders` holds `Col 0`, + // `Col 1` … there, and storing that would make every later import look + // like drift. + const headerSignature = buildHeaderSignature( + config.hasHeader ? state.previewHeaders : null + ); + let sourceId: number; if (state.existingSource) { sourceId = state.existingSource.id; await updateSource(sourceId, { name: config.name, ...formatRow, + header_signature: headerSignature, template_id: state.selectedTemplateId, }); } else { sourceId = await createSource({ name: config.name, ...formatRow, + header_signature: headerSignature, template_id: state.selectedTemplateId, }); } @@ -993,6 +1115,50 @@ export function useImportWizard() { dispatch({ type: "RESET" }); }, []); + /** + * Take the format re-detected on the drifted file, and re-read the preview + * under it (#330). + * + * Same shape as the sign flip, and for the same reason: the adopted format is + * PASSED to the parse instead of being read back from `state`, which has not + * re-rendered yet. Reading the stale one would redisplay the table the user + * just chose to replace. + * + * Nothing is written here. The adopted format reaches `import_sources` at + * `executeImport` like every other configuration — an import abandoned on + * this screen leaves the stored format exactly as it was. + */ + const adoptDriftFormat = useCallback(async () => { + const adopted = state.driftConfig; + if (!adopted) return; + + dispatch({ type: "SET_SOURCE_CONFIG", payload: adopted }); + dispatch({ type: "SET_FORMAT_DRIFT", payload: null }); + dispatch({ type: "SET_LOADING", payload: true }); + dispatch({ type: "SET_ERROR", payload: null }); + + try { + const result = await parseFilesInternal(adopted); + dispatch({ type: "SET_PARSED_PREVIEW", payload: result }); + } catch (e) { + dispatch({ + type: "SET_ERROR", + payload: e instanceof Error ? e.message : String(e), + }); + } + }, [state.driftConfig, parseFilesInternal]); + + /** + * Keep the stored configuration and dismiss the panel. + * + * The rows on screen were already parsed under that configuration, so there + * is nothing to re-read. The stored signature is still refreshed at import + * time: the new header IS what this import read, whichever format read it. + */ + const keepCurrentFormat = useCallback(() => { + dispatch({ type: "SET_FORMAT_DRIFT", payload: null }); + }, []); + const autoDetectConfig = useCallback(async () => { if (state.selectedFiles.length === 0) return; @@ -1108,6 +1274,8 @@ export function useImportWizard() { executeImport, goToStep, reset, + adoptDriftFormat, + keepCurrentFormat, autoDetectConfig, saveConfigAsTemplate, applyConfigTemplate, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3cfae2d..728fcb0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -116,7 +116,24 @@ "updateTemplate": "Update template", "detectionRecognized": "Format recognized — {{read}} of {{total}} rows read", "detectionUncertain": "Uncertain format — only {{read}} of {{total}} rows read", - "detectionUncertainHint": "Check the column mapping and the date format, then look at the preview before importing." + "detectionUncertainHint": "Check the column mapping and the date format, then look at the preview before importing.", + "detectionBank": "{{bank}} format recognized — {{read}} of {{total}} rows read" + }, + "drift": { + "title": "This source's format has changed", + "intro": "This file's header row no longer matches the one recorded at the last successful import. The stored columns may no longer point at the right data.", + "columnMoved": "{{label}}: column {{from}} → {{to}}", + "columnAdded": "{{label}}: new column, at position {{to}}", + "columnRemoved": "{{label}}: column gone, it was at position {{from}}", + "adopt": "Adopt the re-detected format", + "adoptHint": "Reads the file the way it looks today. The new configuration is saved at import time.", + "adoptUnavailable": "Detection could not read this new shape: fix the mapping at the previous step.", + "keep": "Keep the current configuration", + "keepHint": "Keeps the stored mapping. Use this when the header changed but the columns did not move." + }, + "repairPath": { + "title": "Repairing an import that was already written wrong", + "body": "Do not re-import a file you have already imported in order to correct it: duplicates are matched on the date, the description AND the amount, so a row with a corrected amount does not match the faulty row — it is added next to it. A flipped sign also produces two mirror rows that cancel each other out in every report. Delete the faulty import from the import history first, then replay the file." }, "preview": { "title": "Data Preview", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 56a1eb0..13db969 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -116,7 +116,24 @@ "updateTemplate": "Mettre à jour le modèle", "detectionRecognized": "Format reconnu — {{read}} des {{total}} lignes lues", "detectionUncertain": "Format incertain — {{read}} des {{total}} lignes lues seulement", - "detectionUncertainHint": "Vérifiez le mapping des colonnes et le format de date, puis regardez l'aperçu avant d'importer." + "detectionUncertainHint": "Vérifiez le mapping des colonnes et le format de date, puis regardez l'aperçu avant d'importer.", + "detectionBank": "Format {{bank}} reconnu — {{read}} des {{total}} lignes lues" + }, + "drift": { + "title": "Le format de cette source a changé", + "intro": "L'en-tête de ce fichier ne correspond plus à celui du dernier import réussi. Les colonnes mémorisées ne pointent peut-être plus sur les bonnes données.", + "columnMoved": "{{label}} : colonne {{from}} → {{to}}", + "columnAdded": "{{label}} : nouvelle colonne, en position {{to}}", + "columnRemoved": "{{label}} : colonne disparue, elle était en position {{from}}", + "adopt": "Adopter le format re-détecté", + "adoptHint": "Relit le fichier tel qu'il se présente aujourd'hui. La nouvelle configuration est mémorisée à l'import.", + "adoptUnavailable": "La détection n'a pas su lire cette nouvelle forme : corrigez le mapping à l'étape précédente.", + "keep": "Conserver la configuration actuelle", + "keepHint": "Garde le mapping mémorisé. À utiliser si l'en-tête a changé sans que les colonnes bougent." + }, + "repairPath": { + "title": "Réparer un import déjà écrit de travers", + "body": "Ne réimportez pas un fichier déjà importé pour le corriger : les doublons sont repérés sur la date, la description ET le montant, donc une ligne au montant corrigé ne s'apparie pas à la ligne fautive — elle s'ajoute. Une inversion de signe crée en prime deux lignes miroir qui s'annulent dans les rapports. Supprimez d'abord l'import fautif dans l'historique des imports, puis rejouez le fichier." }, "preview": { "title": "Aperçu des données", diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx index 2a704b3..154a958 100644 --- a/src/pages/ImportPage.tsx +++ b/src/pages/ImportPage.tsx @@ -4,6 +4,7 @@ import ImportFolderConfig from "../components/import/ImportFolderConfig"; import SourceList from "../components/import/SourceList"; import SourceConfigPanel from "../components/import/SourceConfigPanel"; import FilePreviewTable from "../components/import/FilePreviewTable"; +import FormatDriftPanel from "../components/import/FormatDriftPanel"; import DuplicateCheckPanel from "../components/import/DuplicateCheckPanel"; import ImportConfirmation from "../components/import/ImportConfirmation"; import ImportProgress from "../components/import/ImportProgress"; @@ -29,6 +30,8 @@ export default function ImportPage() { executeImport, goToStep, reset, + adoptDriftFormat, + keepCurrentFormat, autoDetectConfig, saveConfigAsTemplate, applyConfigTemplate, @@ -102,6 +105,7 @@ export default function ImportPage() { onDeleteTemplate={deleteConfigTemplate} selectedTemplateId={state.selectedTemplateId} detectionScore={state.detectionScore} + detectedBank={state.detectedBank} isLoading={state.isLoading} /> {/* @@ -122,6 +126,20 @@ export default function ImportPage() { {state.step === "file-preview" && (

+ {/* + Format drift (#330) — ABOVE the table, because it tells the user + whether the table below is worth reading. Present only when the + header row moved since the last successful import of this source. + */} + {state.formatDrift && ( + + )} { const db = await getDb(); const result = await db.execute( - `INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines, has_header, amount_mode, sign_convention, template_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines, has_header, amount_mode, sign_convention, header_signature, template_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT(name) DO UPDATE SET description = excluded.description, date_format = excluded.date_format, @@ -64,6 +64,7 @@ export async function createSource( has_header = excluded.has_header, amount_mode = excluded.amount_mode, sign_convention = excluded.sign_convention, + header_signature = excluded.header_signature, template_id = excluded.template_id, updated_at = CURRENT_TIMESTAMP`, [ @@ -77,6 +78,10 @@ export async function createSource( source.has_header, source.amount_mode, source.sign_convention, + // Explicitly null for a headerless file: drift detection is inoperative + // there and a stale signature from an earlier shape would be worse than + // none (#330). + source.header_signature ?? null, source.template_id ?? null, ] ); diff --git a/src/utils/bankSignatures.test.ts b/src/utils/bankSignatures.test.ts new file mode 100644 index 0000000..0656c07 --- /dev/null +++ b/src/utils/bankSignatures.test.ts @@ -0,0 +1,695 @@ +// Bank signatures and format drift (#330). +// +// Two contracts are frozen here. +// +// THE SIGNATURES. Each of the four banks has a fixture carrying its documented +// header layout. The test that matters is not "the signature matches" — it is +// the pair: the same file WITH its bank header and WITHOUT it. Three of the +// four layouts are read wrong by the generic dictionary, and the counterfactual +// is what proves the signature earns its place instead of merely agreeing with +// the heuristic. The other half of that pair is the fall-back: every fixture of +// the #326 corpus must still be detected with no bank at all. +// +// THE DRIFT. `header_signature` records the labels of the last successful +// import; a later file whose header normalizes differently is drift. The +// interesting cases are the ones that must NOT fire: no stored signature, a +// headerless file, a stored value that will not parse, and a cosmetic rename +// (`Montant` -> `MONTANT ($)`) that normalizes to the same label. + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { + BANK_SIGNATURES, + MIN_SIGNATURE_LABELS, + bankSignatureById, + buildHeaderSignature, + detectHeaderDrift, + matchBankSignature, + parseHeaderSignature, + type BankSignatureId, +} from "./bankSignatures"; +import { normalizeHeaderCell } from "./headerDictionary"; +import { detectImportFormat } from "./csvAutoDetect"; +import { CSV_FIXTURE_NAMES, readCsvFixture } from "../__fixtures__/csv"; +import fr from "../i18n/locales/fr.json"; +import en from "../i18n/locales/en.json"; + +/** The bank a file is recognised as, or null. Throws on a file detection refuses. */ +function bankOf(content: string): BankSignatureId | null { + const outcome = detectImportFormat(content); + if (outcome.status !== "ok") { + throw new Error(`detection returned ${outcome.status}`); + } + return outcome.bank; +} + +/** The configuration detection settles on. Throws on a file it refuses. */ +function configOf(content: string) { + const outcome = detectImportFormat(content); + if (outcome.status !== "ok") { + throw new Error(`detection returned ${outcome.status}`); + } + return outcome.config; +} + +/** The same file with its header row replaced — every data row untouched. */ +function withHeader(raw: string, header: string): string { + const lines = raw.split("\n"); + lines[0] = header; + return lines.join("\n"); +} + +describe("the signature table is well formed (#330)", () => { + it("gives every bank a distinct id", () => { + const ids = BANK_SIGNATURES.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("declares every label already normalized", () => { + // A label written `Montant` would never match: the header cell is + // normalized before the comparison and the table's side is not. + for (const signature of BANK_SIGNATURES) { + for (const variant of signature.variants) { + for (const label of variant.labels) { + expect(normalizeHeaderCell(label), `${signature.id}: ${label}`).toBe( + label + ); + } + } + } + }); + + it("keeps every role's label inside the fingerprint it projects", () => { + // A role naming a label absent from `labels` would resolve to a column the + // match never checked for. + for (const signature of BANK_SIGNATURES) { + for (const variant of signature.variants) { + for (const [role, label] of Object.entries(variant.roles)) { + expect(variant.labels, `${signature.id}.${role}`).toContain(label); + } + } + } + }); + + it("refuses a fingerprint too small to identify anyone", () => { + // `Date;Description;Montant` is the shape of half the corpus and of any + // hand-made export. A three-label variant would put a bank's name over + // files nobody can attribute to it. + for (const signature of BANK_SIGNATURES) { + for (const variant of signature.variants) { + expect( + variant.labels.length, + `${signature.id}: ${variant.labels.join(",")}` + ).toBeGreaterThanOrEqual(MIN_SIGNATURE_LABELS); + } + } + }); + + it("resolves a bank from its id, and nothing from an unknown one", () => { + expect(bankSignatureById("desjardins")?.label).toBe("Desjardins"); + expect(bankSignatureById(null)).toBeNull(); + expect(bankSignatureById(undefined)).toBeNull(); + }); +}); + +describe("each bank is recognised on its own fixture (#330)", () => { + it("reads the Desjardins layout and names it", () => { + expect(bankOf(readCsvFixture("bank-desjardins"))).toBe("desjardins"); + expect(configOf(readCsvFixture("bank-desjardins"))).toEqual({ + delimiter: ";", + hasHeader: true, + skipLines: 0, + dateFormat: "DD/MM/YYYY", + // Solde (column 3) is the balance the signature names, and it stays out + // of the amount candidates. + columnMapping: { date: 0, description: 1, amount: 2 }, + amountMode: "single", + signConvention: "negative_expense", + }); + }); + + it("reads the English Desjardins header too", () => { + const content = + "Date;Description;Amount;Balance\n" + + "05/01/2025;EPICERIE METRO;-84,32;915,68\n" + + "15/01/2025;DEPOT PAIE;1250,00;2165,68\n" + + "18/01/2025;HYDRO QUEBEC;-142,18;2023,50\n" + + "22/01/2025;RESTAURANT;-56,75;1966,75\n"; + expect(bankOf(content)).toBe("desjardins"); + }); + + it("reads a whole-line-quoted Desjardins export", () => { + // `preprocessQuotedCSV` unwraps it into a comma-separated file; the + // signature declares the quirk and the comma, so the match survives it. + const content = + '"Date,""Description"",Montant,Solde"\n' + + '"05/01/2025,""EPICERIE METRO"",-84.32,915.68"\n' + + '"15/01/2025,""DEPOT PAIE"",1250.00,2165.68"\n' + + '"18/01/2025,""HYDRO QUEBEC"",-142.18,2023.50"\n' + + '"22/01/2025,""RESTAURANT"",-56.75,1966.75"\n'; + expect(bankOf(content)).toBe("desjardins"); + }); + + it("reads the RBC layout and maps CAD$ as the amount", () => { + expect(bankOf(readCsvFixture("bank-rbc"))).toBe("rbc"); + expect(configOf(readCsvFixture("bank-rbc"))).toEqual({ + delimiter: ",", + hasHeader: true, + skipLines: 0, + dateFormat: "DD/MM/YYYY", + columnMapping: { date: 2, description: 4, amount: 6 }, + amountMode: "single", + signConvention: "negative_expense", + }); + }); + + it("reads the Banque Nationale layout as a debit/credit pair", () => { + expect(bankOf(readCsvFixture("bank-bnc"))).toBe("bnc"); + expect(configOf(readCsvFixture("bank-bnc"))).toEqual({ + delimiter: ";", + hasHeader: true, + skipLines: 0, + dateFormat: "DD/MM/YYYY", + columnMapping: { + date: 0, + description: 1, + debitAmount: 3, + creditAmount: 4, + }, + amountMode: "debit_credit", + signConvention: "negative_expense", + }); + }); + + it("reads the Tangerine layout and maps Name as the description", () => { + expect(bankOf(readCsvFixture("bank-tangerine"))).toBe("tangerine"); + expect(configOf(readCsvFixture("bank-tangerine"))).toEqual({ + delimiter: ",", + hasHeader: true, + skipLines: 0, + dateFormat: "DD/MM/YYYY", + columnMapping: { date: 0, description: 2, amount: 4 }, + amountMode: "single", + signConvention: "negative_expense", + }); + }); +}); + +describe("what the signatures actually buy (#330)", () => { + it("keeps RBC's cheque number out of the amounts", () => { + // The SAME rows, under a header no signature knows. `CAD$` and + // `Cheque Number` are sparse-complementary — one cheque number in six rows + // — so the shape scan pairs them as debit/credit and the row carrying a + // cheque number imports as -247.95 instead of -6.95. Nothing in the file + // can tell that apart from a genuine debit/credit pair; a documented layout + // can. + const anonymised = withHeader( + readCsvFixture("bank-rbc"), + "Type,Numero,Date,Cheque,Libelle,Note,Montant,Devise" + ); + expect(bankOf(anonymised)).toBeNull(); + expect(configOf(anonymised).amountMode).toBe("debit_credit"); + expect(configOf(anonymised).columnMapping).toEqual({ + date: 2, + description: 4, + debitAmount: 3, + creditAmount: 6, + }); + + // With the header the bank actually prints, the same rows read as one + // signed column. + expect(configOf(readCsvFixture("bank-rbc")).amountMode).toBe("single"); + }); + + it("keeps Tangerine's direction column out of the description", () => { + // `Transaction` is a description keyword of the generic dictionary, and + // Tangerine's `Transaction` column holds DEBIT / CREDIT. Read as the + // description, every transaction of the file is labelled `DEBIT`. + const anonymised = withHeader( + readCsvFixture("bank-tangerine"), + "Date,Transaction,Nom,Note,Montant" + ); + expect(bankOf(anonymised)).toBeNull(); + expect(configOf(anonymised).columnMapping.description).toBe(1); + + expect(configOf(readCsvFixture("bank-tangerine")).columnMapping.description).toBe( + 2 + ); + }); +}); + +describe("an unknown file falls back to the generic dictionary (#330)", () => { + it("names no bank on any of the shape fixtures", () => { + // The corpus frozen by #326 is synthetic and belongs to no bank. A single + // false positive here is a banner claiming a bank the file is not from. + for (const name of CSV_FIXTURE_NAMES) { + if (name.startsWith("bank-")) continue; + if (name === "absolute-indicator") continue; // refused before any bank + expect(bankOf(readCsvFixture(name)), name).toBeNull(); + } + }); + + it("leaves the pre-#330 configurations untouched", () => { + // The signatures must not have moved a single mapping of the corpus. + expect(configOf(readCsvFixture("signed-amount"))).toEqual({ + delimiter: ";", + hasHeader: true, + skipLines: 0, + dateFormat: "DD/MM/YYYY", + columnMapping: { date: 0, description: 1, amount: 2 }, + amountMode: "single", + signConvention: "negative_expense", + }); + }); +}); + +describe("the preconditions of a match (#330)", () => { + const rows = + "05/01/2025;EPICERIE METRO;-84,32;915,68\n" + + "15/01/2025;DEPOT PAIE;1250,00;2165,68\n" + + "18/01/2025;HYDRO QUEBEC;-142,18;2023,50\n" + + "22/01/2025;RESTAURANT;-56,75;1966,75\n"; + + it("refuses a delimiter the bank does not use", () => { + const tabbed = ("Date;Description;Montant;Solde\n" + rows).replace( + /;/g, + "\t" + ); + expect(bankOf(tabbed)).toBeNull(); + }); + + it("refuses an export carrying more preamble than the bank prints", () => { + const content = + "RELEVE DE COMPTE\nCompte 12345\nDate;Description;Montant;Solde\n" + rows; + expect(bankOf(content)).toBeNull(); + }); + + it("refuses a whole-line-quoted file for a bank that does not quote", () => { + // Tangerine's labels, wrapped the way Desjardins wraps its exports. The + // quirk is what says this is not a Tangerine file. + const content = + '"Date,""Transaction"",Name,Memo,Amount"\n' + + '"05/01/2025,""DEBIT"",EPICERIE,,-84.32"\n' + + '"15/01/2025,""CREDIT"",PAIE,,1250.00"\n' + + '"18/01/2025,""DEBIT"",HYDRO,,-142.18"\n' + + '"22/01/2025,""DEBIT"",RESTO,,-56.75"\n'; + expect(bankOf(content)).toBeNull(); + }); + + it("names no bank for a headerless file, whatever its shape", () => { + // There is no label to read. This is the same boundary that leaves + // `header_signature` null on those sources. + expect(bankOf(readCsvFixture("no-header"))).toBeNull(); + }); + + it("matches on the whole label, never on a substring", () => { + // `matchHeaderColumn` matches substrings — that is the generic dictionary's + // rule and the reason it mis-reads these layouts. A fingerprint compared + // loosely would inherit the same problem. + expect( + matchBankSignature({ + headerRow: ["Date", "Description", "Montant net", "Solde"], + delimiter: ";", + skipLines: 0, + wholeLineQuoted: false, + }) + ).toBeNull(); + }); + + it("resolves the roles to the columns of THIS file, not to the declared order", () => { + const match = matchBankSignature({ + headerRow: ["Solde", "Montant", "Description", "Date"], + delimiter: ";", + skipLines: 0, + wholeLineQuoted: false, + }); + expect(match?.signature.id).toBe("desjardins"); + expect(match?.roles).toEqual({ + date: 3, + description: 2, + amount: 1, + debit: null, + credit: null, + balance: 0, + roleCount: 4, + }); + }); +}); + +describe("the stored signature (#330)", () => { + it("stores the normalized labels, in order", () => { + expect(buildHeaderSignature(["Date", "Description", "Montant", "Solde"])).toBe( + '["date","description","montant","solde"]' + ); + }); + + it("stores nothing for a file with no header row", () => { + // The whole point of the boundary: `Col 0`, `Col 1` … is not a signature, + // and inventing one from the data would fire on every change of content. + expect(buildHeaderSignature(null)).toBeNull(); + expect(buildHeaderSignature([])).toBeNull(); + }); + + it("reads back what it wrote", () => { + const stored = buildHeaderSignature(["Date", "Montant"]); + expect(parseHeaderSignature(stored)).toEqual(["date", "montant"]); + }); + + it("returns null rather than throwing on anything it did not write", () => { + // A hash left by an older build, a truncated row, hand-edited SQL: drift + // detection switches off, an import never blows up. + expect(parseHeaderSignature(null)).toBeNull(); + expect(parseHeaderSignature("")).toBeNull(); + expect(parseHeaderSignature("d41d8cd98f00b204")).toBeNull(); + expect(parseHeaderSignature("{}")).toBeNull(); + expect(parseHeaderSignature("[1,2,3]")).toBeNull(); + }); +}); + +describe("drift detection stays silent when it should (#330)", () => { + const stored = buildHeaderSignature(["Date", "Description", "Montant"]); + + it("says nothing about a source that never recorded a signature", () => { + expect(detectHeaderDrift(null, ["Date", "Solde"])).toBeNull(); + expect(detectHeaderDrift(undefined, ["Date", "Solde"])).toBeNull(); + }); + + it("says nothing about a headerless file", () => { + expect(detectHeaderDrift(stored, null)).toBeNull(); + expect(detectHeaderDrift(stored, [])).toBeNull(); + }); + + it("says nothing about an identical header", () => { + expect( + detectHeaderDrift(stored, ["Date", "Description", "Montant"]) + ).toBeNull(); + }); + + it("says nothing about a cosmetic rename", () => { + // Accents, case, punctuation and spacing are stripped before the + // comparison: none of them changes which column holds what. + expect( + detectHeaderDrift(stored, ["DATE", "Description ", "MONTANT ($)"]) + ).toBeNull(); + }); + + it("says nothing when the stored value cannot be read", () => { + expect(detectHeaderDrift("not json", ["Date"])).toBeNull(); + }); +}); + +describe("drift detection names the columns that moved (#330)", () => { + it("reports a column that changed position", () => { + const stored = buildHeaderSignature([ + "Date", + "Description", + "Montant", + "Solde", + ]); + expect( + detectHeaderDrift(stored, ["Date", "Description", "Solde", "Montant"]) + ).toEqual([ + { + kind: "moved", + normalizedLabel: "solde", + label: "Solde", + previousIndex: 3, + currentIndex: 2, + }, + { + kind: "moved", + normalizedLabel: "montant", + label: "Montant", + previousIndex: 2, + currentIndex: 3, + }, + ]); + }); + + it("reports a new column and the shift it causes", () => { + const stored = buildHeaderSignature(["Date", "Description", "Montant"]); + expect( + detectHeaderDrift(stored, ["Date", "Devise", "Description", "Montant"]) + ).toEqual([ + { + kind: "added", + normalizedLabel: "devise", + label: "Devise", + previousIndex: null, + currentIndex: 1, + }, + { + kind: "moved", + normalizedLabel: "description", + label: "Description", + previousIndex: 1, + currentIndex: 2, + }, + { + kind: "moved", + normalizedLabel: "montant", + label: "Montant", + previousIndex: 2, + currentIndex: 3, + }, + ]); + }); + + it("reports a column that disappeared, under its stored label", () => { + // The raw spelling of a removed column is recorded nowhere — only the + // normalized label survives, and that is what is shown. + const stored = buildHeaderSignature(["Date", "Description", "Solde"]); + expect(detectHeaderDrift(stored, ["Date", "Description"])).toEqual([ + { + kind: "removed", + normalizedLabel: "solde", + label: "solde", + previousIndex: 2, + currentIndex: null, + }, + ]); + }); + + it("survives a bank swapping its amount column for a debit/credit pair", () => { + const stored = buildHeaderSignature([ + "Date", + "Description", + "Montant", + "Solde", + ]); + const drift = detectHeaderDrift(stored, [ + "Date", + "Description", + "Débit", + "Crédit", + "Solde", + ])!; + expect(drift.map((e) => [e.kind, e.normalizedLabel])).toEqual([ + ["added", "debit"], + ["added", "credit"], + ["moved", "solde"], + ["removed", "montant"], + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Static guards. The wiring lives in a React hook and two components, and the +// repository has no jsdom — same technique as the guards of #324 to #329. +// --------------------------------------------------------------------------- + +const WIZARD_SRC = readFileSync( + resolve(import.meta.dirname, "..", "hooks", "useImportWizard.ts"), + "utf-8" +); +const PAGE_SRC = readFileSync( + resolve(import.meta.dirname, "..", "pages", "ImportPage.tsx"), + "utf-8" +); +const componentSrc = (name: string) => + readFileSync( + resolve(import.meta.dirname, "..", "components", "import", name), + "utf-8" + ); + +describe("the signature is written at every successful import (#330)", () => { + it("builds it from the header row the import actually read", () => { + expect(WIZARD_SRC).toContain("buildHeaderSignature("); + expect(WIZARD_SRC).toContain( + "config.hasHeader ? state.previewHeaders : null" + ); + }); + + it("writes it on both arms of the single write point", () => { + const body = WIZARD_SRC.slice( + WIZARD_SRC.indexOf("const executeImport ="), + WIZARD_SRC.indexOf("const goToStep =") + ); + expect(body.match(/header_signature: headerSignature/g)).toHaveLength(2); + }); + + it("keeps it out of the format codec", () => { + // It is drift metadata, not one of the eight fields that decide how a row + // is read. `formatToRow` naming it would put it back in the format. + const CODEC = readFileSync( + resolve(import.meta.dirname, "importFormat.ts"), + "utf-8" + ); + expect(CODEC).not.toContain("header_signature"); + expect(CODEC).not.toContain("headerSignature"); + }); +}); + +describe("drift is computed on the way to the preview (#330)", () => { + const body = () => + WIZARD_SRC.slice( + WIZARD_SRC.indexOf("const parseAndPreview ="), + WIZARD_SRC.indexOf("const checkDuplicatesInternal =") + ); + + it("compares the stored signature to the headers just parsed", () => { + expect(body()).toContain("detectHeaderDrift("); + expect(body()).toContain("state.existingSource?.header_signature"); + }); + + it("does not compare anything on a headerless file", () => { + expect(body()).toContain("state.sourceConfig.hasHeader"); + }); + + it("still reaches the preview step", () => { + expect(body()).toContain( + 'dispatch({ type: "SET_STEP", payload: "file-preview" })' + ); + }); + + it("drops the score the re-detection produced, which describes another format", () => { + // The re-detection measures the format the PANEL offers, not the one in + // use. Keeping its score would show "Format reconnu — 6 of 6 rows read" + // beside the stored mapping as soon as the user steps back. + expect(body()).toContain( + 'dispatch({ type: "SET_DETECTION_SCORE", payload: null })' + ); + }); +}); + +describe("the drift panel offers two outcomes and writes neither (#330)", () => { + const PANEL = componentSrc("FormatDriftPanel.tsx"); + + it("is rendered on the preview step, above the table", () => { + expect(PAGE_SRC).toContain("{state.formatDrift && ("); + expect(PAGE_SRC).toContain(" { + // `state` has not re-rendered when the parse starts, exactly as for the + // sign flip: reading it back would redisplay the table just replaced. + const body = WIZARD_SRC.slice( + WIZARD_SRC.indexOf("const adoptDriftFormat ="), + WIZARD_SRC.indexOf("const keepCurrentFormat =") + ); + expect(body).toContain("parseFilesInternal(adopted)"); + expect(body).not.toContain("createSource("); + expect(body).not.toContain("updateSource("); + }); + + it("renders the three kinds of change through i18n", () => { + for (const key of ["columnMoved", "columnAdded", "columnRemoved"]) { + expect(PANEL, key).toContain(`import.drift.${key}`); + } + }); +}); + +describe("the repair path is stated in the interface (#330)", () => { + const NOTICE = componentSrc("RepairPathNotice.tsx"); + + it("names the history deletion rather than a vague warning", () => { + expect(NOTICE).toContain("import.repairPath.title"); + expect(NOTICE).toContain("import.repairPath.body"); + for (const locale of [fr, en]) { + expect(locale.import.repairPath.body.length).toBeGreaterThan(0); + } + // The two facts a user needs: duplicates are matched on the amount, and the + // faulty import has to go first. + expect(fr.import.repairPath.body).toContain("historique"); + expect(en.import.repairPath.body).toContain("history"); + }); + + it("appears in the preview and in the drift panel, not in one of the two", () => { + expect(componentSrc("FilePreviewTable.tsx")).toContain(""); + expect(componentSrc("FormatDriftPanel.tsx")).toContain(""); + }); +}); + +describe("the recognised-bank banner (#330)", () => { + const PANEL = componentSrc("SourceConfigPanel.tsx"); + + it("names the bank only when the rows actually read", () => { + // A bank label over a file two thirds of whose rows fail is a claim the app + // cannot back; the uncertain wording is the one that helps there. + expect(PANEL).toContain("detectionScore?.confident"); + expect(PANEL).toContain("import.config.detectionBank"); + }); + + it("interpolates the bank name instead of translating it", () => { + // A bank is a proper noun. `Desjardins` is `Desjardins` in both languages. + for (const locale of [fr, en]) { + expect(locale.import.config.detectionBank).toContain("{{bank}}"); + expect(locale.import.config.detectionBank).toContain("{{read}}"); + expect(locale.import.config.detectionBank).toContain("{{total}}"); + } + }); + + it("clears the bank with the score, in one place", () => { + // Three sites drop the score (a hand edit, a template, a sign flip). The + // reducer clearing the bank alongside it is what keeps a fourth from + // forgetting. + const reducerCase = WIZARD_SRC.slice( + WIZARD_SRC.indexOf('case "SET_DETECTION_SCORE":'), + WIZARD_SRC.indexOf('case "SET_DETECTED_BANK":') + ); + expect(reducerCase).toContain("detectedBank: action.payload === null"); + }); +}); + +describe("every new string exists in both languages (#330)", () => { + it("carries the drift panel", () => { + for (const key of [ + "title", + "intro", + "columnMoved", + "columnAdded", + "columnRemoved", + "adopt", + "adoptHint", + "adoptUnavailable", + "keep", + "keepHint", + ] as const) { + expect(fr.import.drift[key].length, `fr.${key}`).toBeGreaterThan(0); + expect(en.import.drift[key].length, `en.${key}`).toBeGreaterThan(0); + } + }); + + it("interpolates the column positions in both languages", () => { + for (const locale of [fr, en]) { + expect(locale.import.drift.columnMoved).toContain("{{label}}"); + expect(locale.import.drift.columnMoved).toContain("{{from}}"); + expect(locale.import.drift.columnMoved).toContain("{{to}}"); + expect(locale.import.drift.columnAdded).toContain("{{to}}"); + expect(locale.import.drift.columnRemoved).toContain("{{from}}"); + } + }); + + it("carries the repair path", () => { + for (const key of ["title", "body"] as const) { + expect(fr.import.repairPath[key].length, `fr.${key}`).toBeGreaterThan(0); + expect(en.import.repairPath[key].length, `en.${key}`).toBeGreaterThan(0); + } + }); +}); diff --git a/src/utils/bankSignatures.ts b/src/utils/bankSignatures.ts new file mode 100644 index 0000000..423b80d --- /dev/null +++ b/src/utils/bankSignatures.ts @@ -0,0 +1,439 @@ +/** + * Bank signatures and header-signature drift (#330). + * + * TWO THINGS LIVE HERE, and they are the same thing seen twice: a header row + * reduced to its normalized labels. + * + * - A BANK SIGNATURE is that label list written down in advance, per bank, so + * a known export is recognised by name instead of being guessed at. + * - A STORED SIGNATURE is that same label list recorded on the source at the + * last successful import (`import_sources.header_signature`), so the next + * file from the same bank can be compared against it column by column. + * + * WHY SIGNATURES RUN BEFORE THE GENERIC DICTIONARY. `headerDictionary.ts` + * matches keywords as SUBSTRINGS, one role at a time, and has no notion of a + * best match — the first column containing the keyword wins. That is the right + * rule for an unknown file and the wrong one for a known layout: + * - Tangerine writes `Date,Transaction,Name,Memo,Amount`. The dictionary reads + * `Transaction` as the description (it is a description keyword) and maps + * the transaction TYPE column — `DEBIT`/`CREDIT` — as the label of every + * row. The signature names `Name`. + * - RBC writes its amount column `CAD$`, which contains neither `montant` nor + * `amount`, so the dictionary finds no amount column at all and the shape + * heuristic pairs `CAD$` with the (usually empty) `USD$` as a debit/credit + * pair. The signature names `CAD$` as a single signed amount. + * + * WHY A FAILING SIGNATURE CANNOT BREAK ANYTHING. These signatures are written + * from documented export layouts, WITHOUT real statements — the app is + * privacy-first and no real statement lands in this repository. So a signature + * is only ever allowed to do what the generic dictionary already does: hand + * `detectImportFormat` a set of PREFERENCES. Every one of them is dropped the + * moment the data contradicts it (a date column nothing parses as a date, an + * amount column the shape scan never proposed). A file no signature recognises + * falls through to the dictionary, unchanged. Failing degrades; it never breaks. + * + * WHY THE STORED SIGNATURE IS A LABEL LIST AND NOT A HASH. The drift panel has + * to name the columns that moved ("Montant : 3 → 4"), which a hash forbids. + */ + +import { + normalizeHeaderCell, + type LexicalHeaderMap, +} from "./headerDictionary"; + +/** Every bank the table knows, and the i18n-free proper noun it is shown as. */ +export type BankSignatureId = "desjardins" | "rbc" | "bnc" | "tangerine"; + +/** The transaction roles a signature can name — `LexicalHeaderMap` minus its count. */ +export type HeaderRole = keyof Omit; + +/** + * One documented layout of one bank. + * + * `labels` is the FINGERPRINT: every one of them must appear in the header row, + * compared on the whole normalized cell (not as a substring). `roles` is a + * projection of that fingerprint onto the columns detection cares about — + * labels carrying no role (`Catégorie`, `Memo`, `N° de chèque`) stay in + * `labels` precisely because they are what makes the fingerprint distinctive. + */ +export interface BankHeaderVariant { + readonly labels: readonly string[]; + readonly roles: Readonly>>; +} + +export interface BankSignature { + readonly id: BankSignatureId; + /** Proper noun. Displayed as-is in every language, never translated. */ + readonly label: string; + /** Delimiters this bank's exports use. A precondition of the match, never an override. */ + readonly delimiters: readonly string[]; + /** Preamble lines this export prints before its header row. */ + readonly maxPreambleLines: number; + /** The export wraps whole lines in quotes — see `preprocessQuotedCSV`. */ + readonly wholeLineQuoted: boolean; + readonly variants: readonly BankHeaderVariant[]; +} + +/** + * Labels a variant must carry before it is allowed to claim a file. + * + * Three is not enough: `Date;Description;Montant` is the shape of half the + * synthetic corpus and of any hand-made export, so a three-label Desjardins + * variant would put "Format Desjardins reconnu" over files no one can attribute + * to Desjardins. Announcing the wrong bank is worse than announcing none — the + * generic path reads those files correctly already. + */ +export const MIN_SIGNATURE_LABELS = 4; + +/** + * The table. Written from the banks' documented export layouts; none of it has + * been verified against a real statement (see the file header). Adding a bank + * is adding an entry — there is no code to write. + */ +export const BANK_SIGNATURES: readonly BankSignature[] = [ + { + // AccèsD exports Date / Description / Montant / Solde, semicolon-separated, + // with comma decimals, in French or in English. Some credit-card exports + // wrap every line in quotes (handled upstream by `preprocessQuotedCSV`, + // which leaves a comma-separated file behind) and some carry NO header row + // at all — those cannot be signed, by construction. + id: "desjardins", + label: "Desjardins", + delimiters: [";", ","], + maxPreambleLines: 0, + wholeLineQuoted: true, + variants: [ + { + labels: ["date", "description", "montant", "solde"], + roles: { + date: "date", + description: "description", + amount: "montant", + balance: "solde", + }, + }, + { + labels: ["date", "description", "amount", "balance"], + roles: { + date: "date", + description: "description", + amount: "amount", + balance: "balance", + }, + }, + ], + }, + { + // RBC exports one comma-separated file for every account, amounts in a + // currency-named column. `CAD$` normalizes to `cad`, which no generic + // amount keyword matches — this variant is the only thing that maps it. + id: "rbc", + label: "RBC", + delimiters: [","], + maxPreambleLines: 0, + wholeLineQuoted: false, + variants: [ + { + labels: [ + "accounttype", + "accountnumber", + "transactiondate", + "chequenumber", + "description1", + "cad", + ], + roles: { + date: "transactiondate", + description: "description1", + amount: "cad", + }, + }, + ], + }, + { + // Banque Nationale exports a debit/credit pair next to a category column, + // semicolon-separated in French. + id: "bnc", + label: "Banque Nationale", + delimiters: [";", ","], + maxPreambleLines: 0, + wholeLineQuoted: false, + variants: [ + { + labels: ["date", "description", "categorie", "debit", "credit", "solde"], + roles: { + date: "date", + description: "description", + debit: "debit", + credit: "credit", + balance: "solde", + }, + }, + { + labels: ["date", "description", "category", "debit", "credit", "balance"], + roles: { + date: "date", + description: "description", + debit: "debit", + credit: "credit", + balance: "balance", + }, + }, + ], + }, + { + // Tangerine exports Date,Transaction,Name,Memo,Amount — comma-separated, + // signed amounts. `Transaction` holds the direction word, NOT the label of + // the operation; reading it as the description makes every row read + // `DEBIT` or `CREDIT`. + id: "tangerine", + label: "Tangerine", + delimiters: [","], + maxPreambleLines: 0, + wholeLineQuoted: false, + variants: [ + { + labels: ["date", "transaction", "name", "memo", "amount"], + roles: { date: "date", description: "name", amount: "amount" }, + }, + ], + }, +]; + +/** What the caller needs to know before a signature may claim a file. */ +export interface BankSignatureInput { + /** The header row as parsed, raw cells. */ + readonly headerRow: readonly string[]; + /** Delimiter detection settled on. */ + readonly delimiter: string; + /** Preamble lines detection had to skip. */ + readonly skipLines: number; + /** True when `preprocessQuotedCSV` actually unwrapped the file. */ + readonly wholeLineQuoted: boolean; +} + +export interface BankSignatureMatch { + readonly signature: BankSignature; + readonly variant: BankHeaderVariant; + /** The variant's roles, resolved to column indices of THIS file. */ + readonly roles: LexicalHeaderMap; +} + +/** + * First-occurrence index of every normalized label of a header row. + * + * First occurrence, not last: a file repeating a label (`Montant;…;Montant`) + * is degenerate either way, and taking the leftmost keeps the result stable + * between the matcher and the drift diff, which both read this map. + */ +function labelIndex(headerRow: readonly string[]): Map { + const index = new Map(); + headerRow.forEach((cell, i) => { + const label = normalizeHeaderCell(cell); + if (label && !index.has(label)) index.set(label, i); + }); + return index; +} + +function rolesOf( + variant: BankHeaderVariant, + index: ReadonlyMap +): LexicalHeaderMap { + const columnOf = (role: HeaderRole): number | null => { + const label = variant.roles[role]; + if (label === undefined) return null; + return index.get(label) ?? null; + }; + + const date = columnOf("date"); + const description = columnOf("description"); + const amount = columnOf("amount"); + const debit = columnOf("debit"); + const credit = columnOf("credit"); + const balance = columnOf("balance"); + + return { + date, + description, + amount, + debit, + credit, + balance, + roleCount: [date, description, amount, debit, credit, balance].filter( + (c) => c !== null + ).length, + }; +} + +/** + * Find the bank whose documented layout this header row is. + * + * Three preconditions guard every match, each one a way the file says it is not + * this export: a delimiter the bank does not use, more preamble than the bank + * prints, or a whole-line-quoted file claiming to be a bank that does not quote. + * They can only ever turn a match into a fall-back to the generic dictionary. + * + * Returns null for an unknown file — which is the common case and not an error. + */ +export function matchBankSignature( + input: BankSignatureInput +): BankSignatureMatch | null { + const index = labelIndex(input.headerRow); + if (index.size === 0) return null; + + for (const signature of BANK_SIGNATURES) { + if (!signature.delimiters.includes(input.delimiter)) continue; + if (input.skipLines > signature.maxPreambleLines) continue; + if (input.wholeLineQuoted && !signature.wholeLineQuoted) continue; + + for (const variant of signature.variants) { + if (!variant.labels.every((label) => index.has(label))) continue; + return { signature, variant, roles: rolesOf(variant, index) }; + } + } + + return null; +} + +/** The bank behind an id, for the banner. Null for an id the table lost. */ +export function bankSignatureById( + id: BankSignatureId | null | undefined +): BankSignature | null { + if (!id) return null; + return BANK_SIGNATURES.find((s) => s.id === id) ?? null; +} + +// --------------------------------------------------------------------------- +// The stored signature and the drift it detects +// --------------------------------------------------------------------------- + +/** + * The header row of a successful import, as `import_sources.header_signature` + * stores it: a JSON array of normalized labels. + * + * Returns null for a file with NO header row — and that null is written to the + * column as-is. A headerless source has no signature to compare against and + * drift detection is inoperative on it, deliberately: a signature invented from + * the data would fire on every change of content, which is every import. + */ +export function buildHeaderSignature( + headers: readonly string[] | null | undefined +): string | null { + if (!headers || headers.length === 0) return null; + return JSON.stringify(headers.map(normalizeHeaderCell)); +} + +/** + * Read back a stored signature. Anything that is not an array of strings — a + * hash written by an older build, a truncated row, hand-edited SQL — comes back + * null, which switches drift detection off rather than throwing inside an + * import. Same rule as everywhere else here: degrade, never break. + */ +export function parseHeaderSignature( + stored: string | null | undefined +): string[] | null { + if (!stored) return null; + try { + const parsed: unknown = JSON.parse(stored); + if (!Array.isArray(parsed)) return null; + if (!parsed.every((label) => typeof label === "string")) return null; + return parsed as string[]; + } catch { + return null; + } +} + +export type HeaderDriftKind = "moved" | "added" | "removed"; + +export interface HeaderDriftEntry { + readonly kind: HeaderDriftKind; + /** The normalized label — the key the two signatures are compared on. */ + readonly normalizedLabel: string; + /** + * What to show. The file's own spelling for a column that still exists + * (`Montant`), the normalized label for one that disappeared — the raw + * spelling of a removed column is not recorded anywhere. + */ + readonly label: string; + /** Column index at the last successful import; null for a new column. */ + readonly previousIndex: number | null; + /** Column index in the file being imported; null for a column that vanished. */ + readonly currentIndex: number | null; +} + +/** + * Compare the header row of the file being imported against the one the last + * successful import recorded. + * + * Returns null when there is nothing to say — no stored signature, a headerless + * file, an unreadable stored value, or a header that normalizes to exactly the + * same labels in the same order. `Montant` becoming `MONTANT ($)` is NOT drift: + * it normalizes identically and changes nothing about how the file is read. + * + * A non-null result is always a non-empty list, so the caller can treat it as + * "show the panel". + */ +export function detectHeaderDrift( + stored: string | null | undefined, + currentHeaders: readonly string[] | null | undefined +): HeaderDriftEntry[] | null { + const previous = parseHeaderSignature(stored); + if (!previous || previous.length === 0) return null; + if (!currentHeaders || currentHeaders.length === 0) return null; + + const current = currentHeaders.map(normalizeHeaderCell); + if ( + previous.length === current.length && + previous.every((label, i) => label === current[i]) + ) { + return null; + } + + const previousIndex = new Map(); + previous.forEach((label, i) => { + if (label && !previousIndex.has(label)) previousIndex.set(label, i); + }); + const currentIndex = labelIndex(currentHeaders); + + const entries: HeaderDriftEntry[] = []; + + // Walk the file being imported first, so the panel reads in its column order. + current.forEach((label, i) => { + if (!label) return; + if (currentIndex.get(label) !== i) return; // duplicate label, already handled + const before = previousIndex.get(label); + if (before === undefined) { + entries.push({ + kind: "added", + normalizedLabel: label, + label: (currentHeaders[i] ?? "").trim() || label, + previousIndex: null, + currentIndex: i, + }); + } else if (before !== i) { + entries.push({ + kind: "moved", + normalizedLabel: label, + label: (currentHeaders[i] ?? "").trim() || label, + previousIndex: before, + currentIndex: i, + }); + } + }); + + // Then the columns that are simply gone, in the order they used to be in. + previous.forEach((label, i) => { + if (!label) return; + if (previousIndex.get(label) !== i) return; + if (currentIndex.has(label)) return; + entries.push({ + kind: "removed", + normalizedLabel: label, + label, + previousIndex: i, + currentIndex: null, + }); + }); + + return entries.length > 0 ? entries : null; +} diff --git a/src/utils/csvAutoDetect.test.ts b/src/utils/csvAutoDetect.test.ts index 72fb8e6..02f4bb6 100644 --- a/src/utils/csvAutoDetect.test.ts +++ b/src/utils/csvAutoDetect.test.ts @@ -260,7 +260,8 @@ const REFERENCE_AMOUNTS = [-84.32, 1250, -142.18, -56.75, 300, -6.95]; describe("corpus integrity (#326)", () => { it("exposes every declared fixture as readable, non-empty content", () => { - expect(CSV_FIXTURE_NAMES).toHaveLength(11); + // 11 shape cases (#326) + the 4 bank layouts (#330). + expect(CSV_FIXTURE_NAMES).toHaveLength(15); for (const name of CSV_FIXTURE_NAMES) { expect(readCsvFixture(name).trim().length).toBeGreaterThan(0); } @@ -1126,10 +1127,14 @@ describe("useImportWizard — detection fires on its own (#328)", () => { expect(restoreArm).not.toContain("runAutoDetect"); }); - it("keeps a single detection path for the button and the automatic run", () => { - // One detector, two callers: the wand button and `selectSource`. + it("keeps a single detection path for every caller", () => { + // ONE detector. The count of callers is deliberate and rises when a + // feature earns it: the wand button and `selectSource` (#328), then the + // format-drift re-detection in `parseAndPreview` (#330), which goes through + // this path rather than calling `runAutoDetect` a second time — a second + // detector is the divergence class this chantier removes. expect(WIZARD.match(/runAutoDetect\(/g)).toHaveLength(1); - expect(WIZARD.match(/await detectFormatForFile\(/g)).toHaveLength(2); + expect(WIZARD.match(/await detectFormatForFile\(/g)).toHaveLength(3); }); it("drops the score as soon as the format is edited by hand", () => { diff --git a/src/utils/csvAutoDetect.ts b/src/utils/csvAutoDetect.ts index 3f91cd7..4e89927 100644 --- a/src/utils/csvAutoDetect.ts +++ b/src/utils/csvAutoDetect.ts @@ -1,6 +1,11 @@ import Papa from "papaparse"; import { parseDate } from "./dateParser"; import { parseFrenchAmount } from "./amountParser"; +import { + matchBankSignature, + type BankSignatureId, + type BankSignatureMatch, +} from "./bankSignatures"; import { CREDIT_INDICATOR_TOKENS, DEBIT_INDICATOR_TOKENS, @@ -78,7 +83,19 @@ export const CONFIDENCE_THRESHOLD = 0.9; * backwards" call for different messages. `detectImportFormat` separates them. */ export type AutoDetectOutcome = - | { status: "ok"; config: AutoDetectResult; score: DetectionScore } + | { + status: "ok"; + config: AutoDetectResult; + score: DetectionScore; + /** + * The bank whose documented layout this file matched, or null when the + * generic dictionary read the header (#330). Deliberately NOT part of + * `config`: it says where the configuration came from, it is not one of + * the fields that decide how a row is read, and it is never persisted as + * format. + */ + bank: BankSignatureId | null; + } | { status: "rejected"; reason: AutoDetectRejectionKey } | { status: "failed" }; @@ -148,6 +165,13 @@ export function autoDetectConfig(rawContent: string): AutoDetectResult | null { * naming a column the data contradicts (a "Date" column nothing parses as a * date, a "Solde" column that is the only amount candidate left) is dropped and * the shape heuristic decides, exactly as it did before #327. + * + * In FRONT of that lexical layer sits the bank-signature table (#330): a header + * row matching a documented export layout gets its roles from that layout and + * the generic dictionary is not consulted at all. The hints it produces are the + * same kind of preference — the file is reported as recognised, not read + * differently on trust. An unknown header falls straight through to the + * dictionary, which is what every file did before. */ export function detectImportFormat(rawContent: string): AutoDetectOutcome { const failed: AutoDetectOutcome = { status: "failed" }; @@ -193,9 +217,28 @@ export function detectImportFormat(rawContent: string): AutoDetectOutcome { // Step 2: Detect header const hasHeader = detectHeader(effectiveData[0]); - // Step 2b: Read the header labels. A headerless file yields no map at all, - // which is the explicit fall-back: every step below then runs on shape only. - const lexical = hasHeader ? matchTransactionHeaders(effectiveData[0]) : null; + // Step 2b: Try the known banks first (#330). A signature is claimed on the + // WHOLE normalized label, delimiter and preamble included, so an unknown file + // simply does not match and the generic dictionary reads it as before. A + // headerless file matches nothing by construction — there is no label to read. + const signature = hasHeader + ? matchBankSignature({ + headerRow: effectiveData[0], + delimiter, + skipLines, + // `preprocessQuotedCSV` returns its input untouched when the file is not + // whole-line-quoted, so this comparison IS the quirk. + wholeLineQuoted: content !== rawContent, + }) + : null; + + // Step 2c: Read the header labels. A signature that matched has already named + // the roles; otherwise the generic dictionary does. A headerless file yields + // no map at all, which is the explicit fall-back: every step below then runs + // on shape only. + const lexical = hasHeader + ? (signature?.roles ?? matchTransactionHeaders(effectiveData[0])) + : null; const dataStartIdx = hasHeader ? 1 : 0; const sampleRows = effectiveData.slice(dataStartIdx, dataStartIdx + 20); @@ -254,7 +297,12 @@ export function detectImportFormat(rawContent: string): AutoDetectOutcome { ); // Step 7: Determine amount mode - const amountResult = detectAmountMode(sampleRows, amountCandidates, lexical); + const amountResult = detectAmountMode( + sampleRows, + amountCandidates, + lexical, + signature + ); if (!amountResult) return failed; // Step 7b: Refuse the third amount format — unsigned magnitudes plus a @@ -303,7 +351,12 @@ export function detectImportFormat(rawContent: string): AutoDetectOutcome { // Step 8: Replay what we just decided, over the WHOLE file. The sample above // is 20 rows because that is enough to decide a shape; the score is what the // user is told, so it has to describe the actual file. - return { status: "ok", config, score: scoreConfig(config, data) }; + return { + status: "ok", + config, + score: scoreConfig(config, data), + bank: signature?.signature.id ?? null, + }; } /** @@ -711,13 +764,44 @@ interface DebitCreditResult { type AmountModeResult = SingleAmountResult | DebitCreditResult; +/** + * Decide the amount mode, and which column(s) carry it. + * + * `signature` is the one hint that outranks the sparse-complementary scan, and + * only because that scan cannot be told apart from the truth by shape alone: + * RBC's `CAD$` and `USD$` ARE complementary — the USD column is empty on a + * Canadian account — so a file whose amounts are one signed column reads as a + * debit/credit pair, with every credit imported as an expense. A bank that + * documents its layout settles that; nothing else in the file can. + * + * It stays a preference all the same: the columns it names must be candidates + * the shape scan itself proposed. A signature naming a column that parses as + * nothing numeric is dropped here and the generic path decides, exactly like a + * mismatched label. + */ function detectAmountMode( rows: string[][], amountCandidates: number[], - lexical: LexicalHeaderMap | null + lexical: LexicalHeaderMap | null, + signature: BankSignatureMatch | null ): AmountModeResult | null { if (amountCandidates.length === 0) return null; + if (signature) { + const { debit, credit, amount } = signature.roles; + if ( + debit !== null && + credit !== null && + amountCandidates.includes(debit) && + amountCandidates.includes(credit) + ) { + return { mode: "debit_credit", debitCol: debit, creditCol: credit }; + } + if (amount !== null && amountCandidates.includes(amount)) { + return detectSingleAmount(rows, amount); + } + } + if (amountCandidates.length === 1) { return detectSingleAmount(rows, amountCandidates[0]); }