docs: architecture, ADR 0019, user guide and changelog for the import format #342
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");
|
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
|
// `Transaction` is a description keyword of the generic dictionary, and
|
||||||
// Tangerine's `Transaction` column holds DEBIT / CREDIT. Read as the
|
// Tangerine's `Transaction` column holds DEBIT / CREDIT. Read as the
|
||||||
// description, every transaction of the file is labelled `DEBIT`.
|
// 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(
|
const anonymised = withHeader(
|
||||||
readCsvFixture("bank-tangerine"),
|
readCsvFixture("bank-tangerine"),
|
||||||
"Date,Transaction,Nom,Note,Montant"
|
"Date,Transaction,Nom,Note,Montant"
|
||||||
);
|
);
|
||||||
expect(bankOf(anonymised)).toBeNull();
|
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(
|
expect(configOf(readCsvFixture("bank-tangerine")).columnMapping.description).toBe(
|
||||||
2
|
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)", () => {
|
describe("an unknown file falls back to the generic dictionary (#330)", () => {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,30 @@ export interface BankSignature {
|
||||||
*/
|
*/
|
||||||
export const MIN_SIGNATURE_LABELS = 4;
|
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
|
* 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
|
* 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) {
|
for (const variant of signature.variants) {
|
||||||
if (!variant.labels.every((label) => index.has(label))) continue;
|
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 { signature, variant, roles: rolesOf(variant, index) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue