fix(import): stop a generic bank signature from claiming, and misreading, a richer file
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
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>
This commit is contained in:
parent
ef7de3cf9b
commit
37b832e084
2 changed files with 109 additions and 2 deletions
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
@ -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) };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue