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
Showing only changes of commit ef7de3cf9b - Show all commits

View file

@ -707,6 +707,37 @@ function detectBalanceColumns(
* labelling its amount column `Détail du montant` therefore cannot end up with * labelling its amount column `Détail du montant` therefore cannot end up with
* its amounts in the description. * its amounts in the description.
*/ */
/**
* Does this column behave like an enumeration rather than free text?
*
* A label alone is not enough to pick the description: Tangerine exports
* `Date,Transaction,Name,Memo,Amount`, where `Transaction` holds DEBIT/CREDIT
* and `Name` holds the merchant. Honouring the label there moves the merchant
* out of the description and kills keyword categorisation.
*
* Cardinality separates the two a description repeats almost nothing, an enum
* repeats almost everything. Average length does NOT: `Note` and `Libellé` are
* both short, and vetoing on length would reject legitimate columns.
*/
function looksLikeEnumColumn(rows: string[][], col: number): boolean {
const distinct = new Set<string>();
let filled = 0;
for (const row of rows) {
const cell = row[col]?.trim();
if (!cell) continue;
filled++;
distinct.add(cell.toLowerCase());
}
// A labelled but entirely empty column is never the description either.
if (filled === 0) return true;
// Too few rows to read anything into the cardinality.
if (filled < 4) return false;
return distinct.size <= Math.max(2, Math.floor(filled / 4));
}
function detectDescriptionColumn( function detectDescriptionColumn(
rows: string[][], rows: string[][],
colCount: number, colCount: number,
@ -719,7 +750,8 @@ function detectDescriptionColumn(
preferred !== null && preferred !== null &&
preferred < colCount && preferred < colCount &&
preferred !== dateCol && preferred !== dateCol &&
!numericCols.has(preferred) !numericCols.has(preferred) &&
!looksLikeEnumColumn(rows, preferred)
) { ) {
return preferred; return preferred;
} }
@ -779,6 +811,26 @@ type AmountModeResult = SingleAmountResult | DebitCreditResult;
* nothing numeric is dropped here and the generic path decides, exactly like a * nothing numeric is dropped here and the generic path decides, exactly like a
* mismatched label. * mismatched label.
*/ */
/**
* The first sparse-complementary pair among the candidates, in column order, or
* null. Extracted from `detectAmountMode` so a bank signature can be arbitrated
* AGAINST the pair the shape scan would have found, instead of short-circuiting
* a scan that never ran.
*/
function findSparseComplementaryPair(
rows: string[][],
amountCandidates: number[]
): [number, number] | null {
for (let a = 0; a < amountCandidates.length; a++) {
for (let b = a + 1; b < amountCandidates.length; b++) {
const colA = amountCandidates[a];
const colB = amountCandidates[b];
if (isSparseComplementary(rows, colA, colB)) return [colA, colB];
}
}
return null;
}
function detectAmountMode( function detectAmountMode(
rows: string[][], rows: string[][],
amountCandidates: number[], amountCandidates: number[],
@ -787,6 +839,8 @@ function detectAmountMode(
): AmountModeResult | null { ): AmountModeResult | null {
if (amountCandidates.length === 0) return null; if (amountCandidates.length === 0) return null;
const pair = findSparseComplementaryPair(rows, amountCandidates);
if (signature) { if (signature) {
const { debit, credit, amount } = signature.roles; const { debit, credit, amount } = signature.roles;
if ( if (
@ -797,7 +851,17 @@ function detectAmountMode(
) { ) {
return { mode: "debit_credit", debitCol: debit, creditCol: credit }; return { mode: "debit_credit", debitCol: debit, creditCol: credit };
} }
if (amount !== null && amountCandidates.includes(amount)) { // A signature's SINGLE amount column may not silently displace a genuine
// debit/credit pair it has no part in. RBC needs the override — its
// near-empty `Cheque Number` really is sparse-complementary with `CAD$` —
// but there the pair contains the declared amount column. On a
// `Date;Description;Débit;Crédit;Montant;Solde` file the pair does not, and
// taking `Montant` unsigned imported every deposit as an expense.
if (
amount !== null &&
amountCandidates.includes(amount) &&
(!pair || pair.includes(amount))
) {
return detectSingleAmount(rows, amount); return detectSingleAmount(rows, amount);
} }
} }
@ -806,16 +870,8 @@ function detectAmountMode(
return detectSingleAmount(rows, amountCandidates[0]); return detectSingleAmount(rows, amountCandidates[0]);
} }
// Check for sparse-complementary pair (debit/credit pattern) if (pair) {
for (let a = 0; a < amountCandidates.length; a++) { return orderDebitCredit(pair[0], pair[1], lexical);
for (let b = a + 1; b < amountCandidates.length; b++) {
const colA = amountCandidates[a];
const colB = amountCandidates[b];
if (isSparseComplementary(rows, colA, colB)) {
return orderDebitCredit(colA, colB, lexical);
}
}
} }
// No complementary pair found — the labelled amount column if there is one, // No complementary pair found — the labelled amount column if there is one,