docs: architecture, ADR 0019, user guide and changelog for the import format #342

Closed
maximus wants to merge 4 commits from issue-332-docs-adr-changelog into issue-331-sref-import-sources
2 changed files with 109 additions and 2 deletions
Showing only changes of commit 37b832e084 - Show all commits

View file

@ -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)", () => {

View file

@ -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) };
}
}