Simpl-Resultat/src/utils/bankSignatures.ts
le king fu 37b832e084
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
fix(import): stop a generic bank signature from claiming, and misreading, a richer file
Review finding on #330, two defects with one root cause.

Desjardins' fingerprint is date/description/montant/solde — four labels any
Canadian bank could emit — and MIN_SIGNATURE_LABELS = 4 did not deliver the
property it promised, because matching was by SUBSET. Any
Date;Description;Montant;Solde file was announced 'Format Desjardins reconnu'.
A variant built only from generic labels must now describe the header exactly;
one carrying a discriminating label (chequenumber, categorie, memo) keeps
subset matching, so extra columns stay fine once something identifies the bank.

Worse, a matched signature's single amount column short-circuited the
sparse-complementary scan instead of being arbitrated against it. A
Date;Description;Debit;Credit;Montant;Solde file reads correctly as debit/credit
before #330 and became one unsigned column after, importing every deposit as an
expense. The scan now runs first; a signature's amount column only wins when the
pair contains it — which is what RBC's genuine Cheque Number / CAD$ case needs,
and it still passes.

Refs #330

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:03:07 -04:00

477 lines
17 KiB
TypeScript

/**
* 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<LexicalHeaderMap, "roleCount">;
/**
* 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<Partial<Record<HeaderRole, string>>>;
}
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;
/**
* Labels any Canadian bank could emit. A variant built only from these
* identifies no bank in particular, so the count alone does not deliver the
* property `MIN_SIGNATURE_LABELS` promises — such a variant has to match the
* header exactly (see `matchBankSignature`).
*/
const GENERIC_LABELS: ReadonlySet<string> = new Set([
"date",
"description",
"libelle",
"detail",
"transaction",
"montant",
"amount",
"solde",
"balance",
"debit",
"credit",
"retrait",
"depot",
"withdrawal",
"deposit",
]);
/**
* 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<string, number> {
const index = new Map<string, number>();
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<string, number>
): 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;
// A variant made only of generic labels must describe the header EXACTLY,
// not merely be contained in it. Desjardins is
// `date;description;montant;solde` — four labels any Canadian bank could
// emit — so accepting them as a SUBSET let a
// `Date;Description;Débit;Crédit;Montant;Solde` file claim to be
// Desjardins. A variant carrying a discriminating label (`chequenumber`,
// `categorie`, `memo`, …) keeps subset matching: extra columns are fine
// once something actually identifies the bank.
if (
variant.labels.every((label) => GENERIC_LABELS.has(label)) &&
index.size !== variant.labels.length
) {
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<string, number>();
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;
}