csv import: exclude what the statements already cover, and delete the Frollo importer
ci / lint-test (push) Successful in 43s
ci / lint-test (push) Successful in 43s
"This is becoming too complex... Frollo should be done through that [the manual
CSV import]" (owner). It was right: a bespoke importer, an API route, a CLI, two
scheduled n8n workflows and a shared secret existed to do what the CSV import
modal already did, minus one rule.
That rule is statement coverage, and it turns out to be the whole thing. Applying
each account's newest billing_end_date as a watermark takes the real 2,607-row
Frollo export down to 171 rows — with no account allowlist, no credit-card
exclusion and no Frollo-specific scoping at all. Cards drop out on their own
because their statements are current; the 46 card rows that survive are genuinely
post-statement. Every bit of the bespoke apparatus was doing by hand what one
query does generically.
Deleted: src/lib/frollo-csv.ts, src/lib/frollo-ingest.ts, scripts/import-frollo.mts,
src/app/api/frollo/, both test files, the FROLLO_INGEST_TOKEN wiring, and the n8n
Frollo Import + Frollo Freshness Check workflows.
Added to the shared CSV path, so every import benefits:
- getStatementCoverage() + /api/import/statement-coverage. The review step
leaves out rows an account's statements already cover and says how many, with
the rows one click away. Only applies when an account column is mapped and
that account has statements — a row is never dropped on a guess.
- Optional Account and Row ID columns in the mapper. Account drives the
watermark and is stored as source_account; Row ID becomes source_ref.
- An in-file duplicate warning. A CDR re-consent re-exports history under
fresh ids, so source_ref cannot see it — 385 twins in one 2,563-row export
doubled every salary payment, and that is not visible by eye in a review
table.
Two pre-existing bugs in that path, both of which this plan depends on:
- The category chosen in the review step was accepted by
batchInsertCSVTransactions and then left out of the INSERT column list, so it
was silently discarded and the trigger wrote 'other'. Not cosmetic: an
uncategorised credit is admitted by NET_SPEND_ROWS and negated by
SPEND_SIGNED, so 62 imported transfers cancelled $74,338 of spend while
counting as no income.
- The path had no idempotency whatsoever. row_index is assigned MAX+1 on every
run, which makes uq_transaction_identity structurally unable to fire, so a
second import of the same file duplicated all of it. Now writes source +
source_ref with ON CONFLICT DO NOTHING.
awaitsStatementLine()'s removal note is kept but rewritten: it no longer points
at a deleted file, and the lesson stands — the queue jump from 8 to 558 was the
measurement, not the noise.
This commit is contained in:
@@ -9,6 +9,22 @@ export interface ColumnMapping {
|
||||
creditCol?: string;
|
||||
merchantCol?: string;
|
||||
categoryCol?: string;
|
||||
/**
|
||||
* Column holding the account number, when the file covers more than one.
|
||||
*
|
||||
* Optional and usually absent — a bank's own export is one account per file.
|
||||
* An aggregator export (Frollo) is not, and without this every row would be
|
||||
* measured against the same statement coverage.
|
||||
*/
|
||||
accountCol?: string;
|
||||
/**
|
||||
* Column holding the provider's own row id, when it has one.
|
||||
*
|
||||
* Stored as `source_ref` so re-importing the same file inserts nothing. The
|
||||
* importer has no other idempotency: `row_index` is assigned MAX+1 on every
|
||||
* run, which makes `uq_transaction_identity` structurally unable to fire.
|
||||
*/
|
||||
sourceRefCol?: string;
|
||||
}
|
||||
|
||||
export interface BankPreset {
|
||||
@@ -24,6 +40,10 @@ export interface ParsedTransaction {
|
||||
transaction_type: string;
|
||||
merchant_name?: string;
|
||||
category?: string;
|
||||
/** Raw account identifier from the file, when a column was mapped. */
|
||||
account?: string;
|
||||
/** Provider row id, when a column was mapped. */
|
||||
source_ref?: string;
|
||||
}
|
||||
|
||||
export function parseCSVRows(text: string): string[][] {
|
||||
@@ -108,6 +128,8 @@ export function applyMapping(
|
||||
const descIdx = idx(mapping.descriptionCol);
|
||||
const merchantIdx = mapping.merchantCol ? idx(mapping.merchantCol) : -1;
|
||||
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
|
||||
const accountIdx = mapping.accountCol ? idx(mapping.accountCol) : -1;
|
||||
const sourceRefIdx = mapping.sourceRefCol ? idx(mapping.sourceRefCol) : -1;
|
||||
|
||||
const results: ParsedTransaction[] = [];
|
||||
for (const row of dataRows) {
|
||||
@@ -138,6 +160,8 @@ export function applyMapping(
|
||||
const tx: ParsedTransaction = { date, description, amount, transaction_type };
|
||||
if (merchantIdx >= 0 && row[merchantIdx]) tx.merchant_name = row[merchantIdx].trim();
|
||||
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
|
||||
if (accountIdx >= 0 && row[accountIdx]) tx.account = row[accountIdx].trim();
|
||||
if (sourceRefIdx >= 0 && row[sourceRefIdx]) tx.source_ref = row[sourceRefIdx].trim();
|
||||
results.push(tx);
|
||||
}
|
||||
return results;
|
||||
@@ -154,3 +178,57 @@ export function loadBankPresets(): BankPreset[] {
|
||||
try { return JSON.parse(localStorage.getItem("csv-presets") || "[]"); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
/** Last four digits of an account identifier, however it is written. */
|
||||
export function last4(s: string): string {
|
||||
const d = s.replace(/\D/g, "");
|
||||
return d.length >= 4 ? d.slice(-4) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits parsed rows into what to import and what the statements already have.
|
||||
*
|
||||
* A statement's end date is a hard watermark: everything on that account up to
|
||||
* it is already in the ledger, itemised and converted. Without this the first
|
||||
* aggregator import wrote 422 duplicates out of 550 rows; with it the same
|
||||
* 2,607-row export offers 171.
|
||||
*
|
||||
* Rows are only excluded when an account column was mapped AND that account has
|
||||
* statements. Anything else is imported — a row is never dropped on a guess.
|
||||
*/
|
||||
export function splitByCoverage(
|
||||
rows: ParsedTransaction[],
|
||||
coverage: { last4: string; coveredTo: string }[]
|
||||
): { keep: ParsedTransaction[]; covered: ParsedTransaction[] } {
|
||||
const wm = new Map(coverage.map((c) => [c.last4, c.coveredTo]));
|
||||
const keep: ParsedTransaction[] = [];
|
||||
const covered: ParsedTransaction[] = [];
|
||||
for (const r of rows) {
|
||||
const to = r.account ? wm.get(last4(r.account)) : undefined;
|
||||
// Both are YYYY-MM-DD, so a string compare is a date compare.
|
||||
if (to && r.date <= to) covered.push(r);
|
||||
else keep.push(r);
|
||||
}
|
||||
return { keep, covered };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows that duplicate another row in the SAME file.
|
||||
*
|
||||
* Distinct from the coverage check, and not catchable by `source_ref`: a CDR
|
||||
* re-consent makes the provider re-export an account's whole history under
|
||||
* fresh ids while the originals survive, so the twins are identical in
|
||||
* everything except the id. That put 385 duplicates in one 2,563-row export and
|
||||
* doubled every salary payment. 385 rows is not something you spot by eye in a
|
||||
* review table, so it is counted here.
|
||||
*/
|
||||
export function inFileDuplicates(rows: ParsedTransaction[]): number {
|
||||
const seen = new Set<string>();
|
||||
let n = 0;
|
||||
for (const r of rows) {
|
||||
const key = [r.date, r.description, r.amount, r.transaction_type, r.account ?? ""].join("|");
|
||||
if (seen.has(key)) n += 1;
|
||||
else seen.add(key);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
/**
|
||||
* Frollo transaction-export parsing and de-duplication.
|
||||
*
|
||||
* Frollo is a CDR (Open Banking) aggregator. Its app emails a CSV of up to 12
|
||||
* months of transactions. This module turns that file into rows ready for
|
||||
* `transactions`, and — mostly — decides which of them are real.
|
||||
*
|
||||
* Scope is deliberately narrow: credit cards are excluded because they already
|
||||
* arrive as monthly statements, which are authoritative. What Frollo is for is
|
||||
* the accounts whose statements arrive every 182 to 460 days (see
|
||||
* ACCOUNTS below), where the alternative is downloading each one by hand.
|
||||
*
|
||||
* The file has three properties that any consumer has to handle, all learned by
|
||||
* diffing three real exports (smarthome DECISIONS.md ING-11):
|
||||
*
|
||||
* 1. A CDR re-consent makes Frollo re-ingest the account's whole history under
|
||||
* fresh transaction ids, while the originals survive. So the same purchase
|
||||
* appears twice with different ids, and consents expire annually — this is
|
||||
* not a one-off. On one 1,989-row export, 385 rows were such twins, and Wise
|
||||
* credits were overstated by exactly 100%.
|
||||
* 2. `transaction_id` is otherwise stable: across two exports 12 days apart,
|
||||
* 1,462 of 1,532 shared rows kept identical ids and none were replaced. It
|
||||
* is therefore a good `source_ref` — it just cannot detect a twin.
|
||||
* 3. The export cannot express "pending", and the tell differs per bank
|
||||
* (Westpac prefixes the description; Amex emits no `posted_date` or
|
||||
* `transaction_type` at all, so its pending rows are indistinguishable from
|
||||
* its posted ones). The export's own option is the only reliable control.
|
||||
* Every pending row observed was on a credit card, i.e. out of scope, so
|
||||
* exporting with pending EXCLUDED costs nothing here — and it avoids the
|
||||
* re-key problem, since a settling row changes both its id and its
|
||||
* description.
|
||||
*/
|
||||
|
||||
/** A row as it appears in the CSV, after header mapping. */
|
||||
export interface FrolloRow {
|
||||
transactionId: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
transactionDate: string;
|
||||
postedDate: string | null;
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
creditDebit: string;
|
||||
transactionType: string | null;
|
||||
providerName: string;
|
||||
merchantName: string | null;
|
||||
}
|
||||
|
||||
/** A row shaped for insertion into `transactions`. */
|
||||
export interface LedgerRow {
|
||||
sourceRef: string;
|
||||
sourceAccount: string;
|
||||
transactionDate: string;
|
||||
description: string;
|
||||
/** Always positive; direction lives in `transactionType`. */
|
||||
amount: number;
|
||||
transactionType: "debit" | "credit";
|
||||
merchantName: string | null;
|
||||
foreignCurrencyAmount: number | null;
|
||||
foreignCurrencyCode: string | null;
|
||||
}
|
||||
|
||||
export interface AccountSpec {
|
||||
/** Last four characters of the digits in the masked account number. */
|
||||
last4: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts this importer owns.
|
||||
*
|
||||
* Keyed on the last four digits rather than the masked string, because the
|
||||
* masking format varies by institution (`xxx-xxx xxxx1910`, `xxxxxx xxxxx6264`,
|
||||
* `x xxxx 0887`) and is a presentation choice Frollo could change. All fourteen
|
||||
* are distinct, as are the five excluded cards.
|
||||
*
|
||||
* Credit cards are absent on purpose — monthly statements already cover them and
|
||||
* are the source of truth. Adding one here would create a second producer for
|
||||
* rows the reconcile queue would then have to match against themselves.
|
||||
*/
|
||||
export const ACCOUNTS: AccountSpec[] = [
|
||||
{ last4: "6264", label: "ANZ Access Advantage", provider: "ANZ" },
|
||||
{ last4: "9940", label: "AMP (mortgage)", provider: "AMP" },
|
||||
{ last4: "8109", label: "AMP", provider: "AMP" },
|
||||
{ last4: "0682", label: "AMP (loan)", provider: "AMP" },
|
||||
{ last4: "4830", label: "Wise (income)", provider: "Wise" },
|
||||
{ last4: "2176", label: "Wise", provider: "Wise" },
|
||||
{ last4: "0052", label: "Up Spending", provider: "Up" },
|
||||
{ last4: "0078", label: "Up Savings", provider: "Up" },
|
||||
{ last4: "1910", label: "CommBank Smart Access", provider: "CommBank" },
|
||||
{ last4: "4878", label: "Westpac Choice", provider: "Westpac" },
|
||||
{ last4: "4758", label: "Westpac Business One", provider: "Westpac" },
|
||||
{ last4: "0887", label: "ING Orange Everyday", provider: "ING" },
|
||||
{ last4: "9336", label: "ING Savings Maximiser", provider: "ING" },
|
||||
{ last4: "9807", label: "ANZ Online Saver", provider: "ANZ" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Accounts knowingly left out, so "excluded" is distinguishable from "unknown".
|
||||
*
|
||||
* Without this the five credit cards read as unrecognised accounts on every
|
||||
* single run, which would raise the new-account anomaly every time and make an
|
||||
* automatic import permanently refuse. An alert that always fires is an alert
|
||||
* nobody reads.
|
||||
*
|
||||
* Anything in neither list is genuinely new — a freshly connected account, or a
|
||||
* re-masked number — and does deserve a look before its rows are ignored.
|
||||
*/
|
||||
export const EXCLUDED_ACCOUNTS: AccountSpec[] = [
|
||||
{ last4: "3893", label: "CommBank Ultimate Awards (card)", provider: "CommBank" },
|
||||
{ last4: "8032", label: "Westpac Altitude Qantas Black (card)", provider: "Westpac" },
|
||||
{ last4: "1000", label: "Amex Platinum Business (card)", provider: "American Express" },
|
||||
{ last4: "0351", label: "HSBC Star Alliance (card)", provider: "HSBC" },
|
||||
{ last4: "6227", label: "MyCard Prestige (card)", provider: "Mycard" },
|
||||
];
|
||||
|
||||
export function isKnownExcluded(accountNumber: string): boolean {
|
||||
const key = last4(accountNumber);
|
||||
return EXCLUDED_ACCOUNTS.some((a) => a.last4 === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Below this id gap, two rows sharing a natural key are *probably* a genuine
|
||||
* same-day repeat rather than a re-ingest twin — and are reported rather than
|
||||
* silently collapsed.
|
||||
*
|
||||
* This is a review trigger, not a decision. An earlier version used a 10,000
|
||||
* threshold to *keep* close-id rows, and it was wrong: measured across two real
|
||||
* exports, in-scope duplicate pairs have gaps from **58 to 260 million**, while
|
||||
* the only observed genuine repeats (five identical $1.00 Apple charges) had
|
||||
* gaps of 1 to 4. A threshold anywhere in between is a magic number, and the
|
||||
* error it enables is asymmetric — keeping a twin doubled reported income by
|
||||
* 100%, whereas collapsing a real repeat understates by one row.
|
||||
*
|
||||
* So dedupe collapses unconditionally and flags anything this close for a human
|
||||
* to look at. On the accounts in scope, nothing has ever tripped it: every
|
||||
* duplicate group is a pair and the minimum gap is 58.
|
||||
*/
|
||||
export const SUSPECT_REPEAT_GAP = 10;
|
||||
|
||||
export function last4(accountNumber: string): string {
|
||||
return accountNumber.replace(/\D/g, "").slice(-4);
|
||||
}
|
||||
|
||||
export function accountFor(accountNumber: string): AccountSpec | undefined {
|
||||
const key = last4(accountNumber);
|
||||
return ACCOUNTS.find((a) => a.last4 === key);
|
||||
}
|
||||
|
||||
const REQUIRED_COLUMNS = [
|
||||
"transaction_id",
|
||||
"description",
|
||||
"amount",
|
||||
"currency",
|
||||
"transaction_date",
|
||||
"account_number",
|
||||
"account_name",
|
||||
"credit_debit",
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps a parsed CSV grid to rows, by header name rather than position.
|
||||
*
|
||||
* Throws on a missing required column rather than reading undefined into a
|
||||
* transaction: a column rename upstream should stop the import loudly, not
|
||||
* silently produce rows with blank descriptions.
|
||||
*/
|
||||
export function readRows(grid: string[][]): FrolloRow[] {
|
||||
if (grid.length === 0) throw new Error("empty CSV");
|
||||
const header = grid[0].map((h) => h.trim());
|
||||
const missing = REQUIRED_COLUMNS.filter((c) => !header.includes(c));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Frollo CSV missing expected column(s): ${missing.join(", ")}`);
|
||||
}
|
||||
const at = (row: string[], col: string): string => {
|
||||
const i = header.indexOf(col);
|
||||
return i >= 0 ? (row[i] ?? "").trim() : "";
|
||||
};
|
||||
const out: FrolloRow[] = [];
|
||||
for (const row of grid.slice(1)) {
|
||||
if (row.every((f) => f === "")) continue;
|
||||
const amount = Number(at(row, "amount"));
|
||||
out.push({
|
||||
transactionId: at(row, "transaction_id"),
|
||||
description: at(row, "description"),
|
||||
amount: Number.isFinite(amount) ? amount : NaN,
|
||||
currency: at(row, "currency") || "AUD",
|
||||
transactionDate: at(row, "transaction_date"),
|
||||
postedDate: at(row, "posted_date") || null,
|
||||
accountNumber: at(row, "account_number"),
|
||||
accountName: at(row, "account_name"),
|
||||
creditDebit: at(row, "credit_debit"),
|
||||
transactionType: at(row, "transaction_type") || null,
|
||||
providerName: at(row, "provider_name"),
|
||||
merchantName: at(row, "merchant_name") || null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type SkipReason =
|
||||
| "zero_amount"
|
||||
| "unparseable_amount"
|
||||
| "out_of_scope_account"
|
||||
| "pending";
|
||||
|
||||
/**
|
||||
* Why a row is not imported, or null if it should be.
|
||||
*
|
||||
* `zero_amount` is mostly CommBank fee *waivers* — 574 of them in one export,
|
||||
* genuinely $0.00 with the notional fee written into the description text
|
||||
* (`INTNL TRANSACTION FEE, $ 41.58 FEE SAV`). They are not transactions.
|
||||
*
|
||||
* `pending` is a belt-and-braces check on top of the export option. It only
|
||||
* catches Westpac's description prefix; Amex pending rows are undetectable here,
|
||||
* which is why the option matters and this does not replace it.
|
||||
*/
|
||||
export function skipReason(row: FrolloRow): SkipReason | null {
|
||||
if (!Number.isFinite(row.amount)) return "unparseable_amount";
|
||||
if (row.amount === 0) return "zero_amount";
|
||||
// Pending is tested before account scope on purpose. Every pending row seen so
|
||||
// far has been on a credit card, so scoping first would classify them all as
|
||||
// out-of-scope and the "this export was taken with pending INCLUDED" signal
|
||||
// would never fire — even though it is worth acting on wherever it appears,
|
||||
// because the option makes successive exports incomparable.
|
||||
if (/^\s*pending\s*[-:]/i.test(row.description)) return "pending";
|
||||
if (!accountFor(row.accountNumber)) return "out_of_scope_account";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Natural key: what makes two rows the same purchase, ignoring ids. */
|
||||
export function naturalKey(row: FrolloRow): string {
|
||||
return [
|
||||
last4(row.accountNumber),
|
||||
row.transactionDate,
|
||||
row.amount.toFixed(2),
|
||||
row.description,
|
||||
row.currency,
|
||||
].join(" | ||||