diff --git a/src/utils/bankSignatures.test.ts b/src/utils/bankSignatures.test.ts index 0656c07..63df93b 100644 --- a/src/utils/bankSignatures.test.ts +++ b/src/utils/bankSignatures.test.ts @@ -222,21 +222,90 @@ describe("what the signatures actually buy (#330)", () => { expect(configOf(readCsvFixture("bank-rbc")).amountMode).toBe("single"); }); - it("keeps Tangerine's direction column out of the description", () => { + it("keeps Tangerine's direction column out of the description, signature or not", () => { // `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`. + // + // This used to be rescued by the Tangerine signature alone, leaving any + // unrecognised file with a `Transaction` column broken. The cardinality + // veto in `detectDescriptionColumn` fixes the cause instead: a labelled + // column that behaves like an enum is not the description, whether or not a + // bank was identified. const anonymised = withHeader( readCsvFixture("bank-tangerine"), "Date,Transaction,Nom,Note,Montant" ); expect(bankOf(anonymised)).toBeNull(); - expect(configOf(anonymised).columnMapping.description).toBe(1); + expect(configOf(anonymised).columnMapping.description).toBe(2); expect(configOf(readCsvFixture("bank-tangerine")).columnMapping.description).toBe( 2 ); }); + + it("does not let a generic variant claim a richer header", () => { + // Review finding on #340: Desjardins' fingerprint is four labels any + // Canadian bank could emit, so matching them as a SUBSET announced + // "Format Desjardins reconnu" over files that have nothing to do with it. + // A variant made only of generic labels now has to describe the header + // exactly. + const richer = + "Date;Description;Débit;Crédit;Montant;Solde\n" + + "05/01/2025;EPICERIE;84,32;;-84,32;1000,00\n" + + "15/01/2025;DEPOT PAIE;;1250,00;1250,00;2250,00\n" + + "20/01/2025;LOYER;900,00;;-900,00;1350,00\n" + + "25/01/2025;REMBOURSEMENT;;45,00;45,00;1395,00\n"; + expect(bankOf(richer)).toBeNull(); + + // The exact header still is Desjardins. + const exact = + "Date;Description;Montant;Solde\n" + + "05/01/2025;EPICERIE;-84,32;1000,00\n" + + "15/01/2025;DEPOT PAIE;1250,00;2250,00\n"; + expect(bankOf(exact)).toBe("desjardins"); + }); + + it("does not let a signature's amount column displace a pair it is not in", () => { + // The measured regression: with the false Desjardins match above, the + // signature's `Montant` short-circuited the sparse-complementary scan, so a + // file that reads correctly as debit/credit became one unsigned column and + // every deposit imported as an expense. The scan now runs first and the + // signature only wins when the pair contains its column — which is what + // RBC's genuine `Cheque Number` / `CAD$` case needs. + const richer = + "Date;Description;Débit;Crédit;Montant;Solde\n" + + "05/01/2025;EPICERIE;84,32;;-84,32;1000,00\n" + + "15/01/2025;DEPOT PAIE;;1250,00;1250,00;2250,00\n" + + "20/01/2025;LOYER;900,00;;-900,00;1350,00\n" + + "25/01/2025;REMBOURSEMENT;;45,00;45,00;1395,00\n"; + const config = configOf(richer); + expect(config.amountMode).toBe("debit_credit"); + expect(config.columnMapping.debitAmount).toBe(2); + expect(config.columnMapping.creditAmount).toBe(3); + + // RBC keeps its override: there the declared amount column IS in the pair. + expect(configOf(readCsvFixture("bank-rbc")).amountMode).toBe("single"); + }); + + it("holds even for a legitimately recognised bank whose file grew columns", () => { + // The guard above is only reachable through a signature that really matches, + // so it needs a discriminating one. Tangerine is identified by `memo`, so it + // still matches as a subset when the export gains Débit/Crédit columns — and + // then its declared `Amount` must not displace that genuine pair either. + const grown = + "Date,Transaction,Name,Memo,Amount,Débit,Crédit\n" + + "05/01/2025,DEBIT,EPICERIE METRO,,-84.32,84.32,\n" + + "15/01/2025,CREDIT,DEPOT PAIE,Paie,1250.00,,1250.00\n" + + "20/01/2025,DEBIT,LOYER,,-900.00,900.00,\n" + + "25/01/2025,CREDIT,REMBOURSEMENT,,45.00,,45.00\n"; + expect(bankOf(grown)).toBe("tangerine"); + + const config = configOf(grown); + expect(config.amountMode).toBe("debit_credit"); + expect(config.columnMapping.debitAmount).toBe(5); + expect(config.columnMapping.creditAmount).toBe(6); + }); }); describe("an unknown file falls back to the generic dictionary (#330)", () => { diff --git a/src/utils/bankSignatures.ts b/src/utils/bankSignatures.ts index 423b80d..96628e6 100644 --- a/src/utils/bankSignatures.ts +++ b/src/utils/bankSignatures.ts @@ -85,6 +85,30 @@ export interface BankSignature { */ 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 = 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 @@ -288,6 +312,20 @@ export function matchBankSignature( 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) }; } }