docs: architecture, ADR 0019, user guide and changelog for the import format #342
1 changed files with 68 additions and 12 deletions
|
|
@ -707,6 +707,37 @@ function detectBalanceColumns(
|
|||
* labelling its amount column `Détail du montant` therefore cannot end up with
|
||||
* 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(
|
||||
rows: string[][],
|
||||
colCount: number,
|
||||
|
|
@ -719,7 +750,8 @@ function detectDescriptionColumn(
|
|||
preferred !== null &&
|
||||
preferred < colCount &&
|
||||
preferred !== dateCol &&
|
||||
!numericCols.has(preferred)
|
||||
!numericCols.has(preferred) &&
|
||||
!looksLikeEnumColumn(rows, preferred)
|
||||
) {
|
||||
return preferred;
|
||||
}
|
||||
|
|
@ -779,6 +811,26 @@ type AmountModeResult = SingleAmountResult | DebitCreditResult;
|
|||
* nothing numeric is dropped here and the generic path decides, exactly like a
|
||||
* 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(
|
||||
rows: string[][],
|
||||
amountCandidates: number[],
|
||||
|
|
@ -787,6 +839,8 @@ function detectAmountMode(
|
|||
): AmountModeResult | null {
|
||||
if (amountCandidates.length === 0) return null;
|
||||
|
||||
const pair = findSparseComplementaryPair(rows, amountCandidates);
|
||||
|
||||
if (signature) {
|
||||
const { debit, credit, amount } = signature.roles;
|
||||
if (
|
||||
|
|
@ -797,7 +851,17 @@ function detectAmountMode(
|
|||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -806,16 +870,8 @@ function detectAmountMode(
|
|||
return detectSingleAmount(rows, amountCandidates[0]);
|
||||
}
|
||||
|
||||
// Check for sparse-complementary pair (debit/credit pattern)
|
||||
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 orderDebitCredit(colA, colB, lexical);
|
||||
}
|
||||
}
|
||||
if (pair) {
|
||||
return orderDebitCredit(pair[0], pair[1], lexical);
|
||||
}
|
||||
|
||||
// No complementary pair found — the labelled amount column if there is one,
|
||||
|
|
|
|||
Loading…
Reference in a new issue