frollo: don't import what the statements already cover
ci / lint-test (push) Successful in 45s

"We should not be importing from Frollo what we already have from statements"
(owner). The amount+direction guard could not deliver that, because the two
sources decompose the same event differently: Frollo bundles the Wise fee into
the transfer (10001.13) where the statement itemises it (10000.00 + 1.13). So 38
Wise USD rows passed the amount guard as new while being the same money, and were
the reason the transactions view showed a USD figure where every neighbouring row
showed AUD.

A statement's billing_end_date is a hard watermark: everything on that account up
to that date is already in the ledger, itemised and converted. Guard 1 now drops
any feed row at or before its account's newest statement. Guard 2 (amount +
direction) stays as the net for accounts that have no statement at all.

Note this is the coverage test the first import needed and got wrong. That one
asked whether a row's date fell inside a statement's min-max window, which for
periods spanning 182 to 460 days swallows a year and answers nothing. The
watermark asks a question that has an answer: up to what date is this account
complete?

Matching is on last4, verified against the live statement set — the eight
in-scope accounts with statements each map to one bank, no cross-bank collision.
The watermarks are printed by the CLI so a wrong boundary is visible rather than
inferred from a row count.

Re-imported from empty: 425 covered by statement, 15 amount twins, 110 inserted.
Exactly one row now carries a foreign currency with no AUD figure — the
2026-08-12 HDR salary, which is the genuinely-new pre-statement row this feed
exists for. Was 39.
This commit is contained in:
2026-08-13 12:36:08 +10:00
parent afd75d3f09
commit b82c4570bd
3 changed files with 133 additions and 22 deletions
+81 -17
View File
@@ -1,6 +1,7 @@
import {
accountReport,
dedupe,
last4,
readRows,
skipReason,
toLedgerRow,
@@ -53,16 +54,6 @@ export const LARGE_BATCH = 200;
*/
export const LEDGER_MATCH_DAYS = 3;
/**
* A date column, as midnight UTC, from whatever the driver handed back.
*
* `pg` maps a Postgres DATE to a JS `Date` in local time, while Prisma and the
* CSV both yield `"YYYY-MM-DD"` strings. Reading one as the other is silent:
* `String(new Date()).slice(0, 10)` is `"Wed Mar 10"`, which parses to NaN, and
* a guard built on it skips every row it was meant to compare — the first dry
* run of this code reported 550 rows to insert and zero duplicates against a
* ledger holding 422 of them.
*/
/**
* Ledger transaction types that mean money arriving.
*
@@ -75,6 +66,16 @@ export const LEDGER_MATCH_DAYS = 3;
*/
const INFLOW_TYPES = new Set(["credit", "refund"]);
/**
* A date column, as midnight UTC, from whatever the driver handed back.
*
* `pg` maps a Postgres DATE to a JS `Date` in local time, while Prisma and the
* CSV both yield `"YYYY-MM-DD"` strings. Reading one as the other is silent:
* `String(new Date()).slice(0, 10)` is `"Wed Mar 10"`, which parses to NaN, and
* a guard built on it skips every row it was meant to compare — the first dry
* run of this code reported 550 rows to insert and zero duplicates against a
* ledger holding 422 of them.
*/
function dayMs(v: unknown): number {
if (v instanceof Date) return Date.UTC(v.getFullYear(), v.getMonth(), v.getDate());
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v));
@@ -101,9 +102,18 @@ export interface IngestReport {
suspectRepeats: { description: string; transactionDate: string; amount: number; idGap: number }[];
alreadyImported: number;
/**
* Rows dropped because the ledger already holds the same event from a
* statement. Counted and reported, never silent: a Frollo row vanishing can
* mean several things and one of them is worth following up.
* Rows dropped because a statement already covers that account up to that
* date. The primary guard — see `statementWatermarks`.
*/
coveredByStatement: number;
/** Per-account coverage boundary used above, for the report. */
statementWatermarks: { last4: string; coveredTo: string }[];
/**
* Rows dropped because the ledger holds the same amount, same direction,
* within a few days. The secondary guard, for accounts with no statement.
*
* Both counts are reported, never silent: a Frollo row vanishing can mean
* several things and one of them is worth following up.
*/
ledgerDuplicates: number;
toInsert: number;
@@ -195,7 +205,59 @@ export async function ingestFrolloCsv(
const seen = new Set(existing.map((r) => r.source_ref));
const notYetImported = ledger.filter((r) => !seen.has(r.sourceRef));
// Drop rows the ledger already holds from a statement.
// GUARD 1 — statement coverage. Do not import what the statements already
// have.
//
// A statement's `billing_end_date` is a hard watermark: everything on that
// account up to that date is already in the ledger, itemised and converted.
// Amount-matching cannot replace this, because the two sources decompose the
// same event differently — Frollo bundles the Wise fee into the transfer
// (10001.13) where the statement itemises it (10000.00 + 1.13), so 38 Wise USD
// rows survived the amount guard as "new" while being the same money.
//
// This is the coverage test the first import needed and got wrong. It asked
// whether a row's date fell inside a statement's minmax *window*, which for
// periods spanning 182 to 460 days swallows a year and answers nothing. The
// watermark asks a different and answerable question: up to what date is this
// account complete?
//
// Matching is on last4 alone. Verified against the live statement set: the
// eight in-scope accounts with statements each map to exactly one bank, no
// cross-bank collision. The per-account watermarks are reported so a wrong one
// is visible rather than inferred from a row count.
const coverage = await exec<{ last4: string; covered_to: unknown }>(
`SELECT right(regexp_replace(account_number, '[^0-9]', '', 'g'), 4) AS last4,
MAX(billing_end_date) AS covered_to
FROM statements
WHERE account_number IS NOT NULL
GROUP BY 1`,
[]
);
const watermark = new Map<string, number>();
for (const c of coverage) {
if (!c.last4) continue; // Wise files one statement as account_number 'N/A'
const t = dayMs(c.covered_to);
if (!Number.isFinite(t)) continue;
watermark.set(c.last4, Math.max(watermark.get(c.last4) ?? -Infinity, t));
}
let coveredByStatement = 0;
const uncovered = notYetImported.filter((r) => {
const w = watermark.get(last4(r.sourceAccount));
if (w === undefined) return true; // no statement for this account, ever
if (dayMs(r.transactionDate) <= w) {
coveredByStatement += 1;
return false;
}
return true;
});
const statementWatermarks = [...watermark.entries()]
.filter(([l4]) => notYetImported.some((r) => last4(r.sourceAccount) === l4))
.map(([l4, t]) => ({ last4: l4, coveredTo: new Date(t).toISOString().slice(0, 10) }))
.sort((a, b) => a.last4.localeCompare(b.last4));
// GUARD 2 — drop rows the ledger already holds from a statement.
//
// This guard is the whole lesson of the 2026-08-13 first import. The feed was
// scoped to "accounts that issue no monthly statement", but that was asserted,
@@ -209,9 +271,9 @@ export async function ingestFrolloCsv(
// check that was run instead compared each row's date against the statement's
// min-max window, which for accounts whose statements span 182 to 460 days
// swallows a year and cannot distinguish covered from uncovered at all.
const dates = notYetImported.map((r) => dayMs(r.transactionDate)).filter(Number.isFinite);
const dates = uncovered.map((r) => dayMs(r.transactionDate)).filter(Number.isFinite);
let ledgerDuplicates = 0;
let fresh = notYetImported;
let fresh = uncovered;
if (dates.length > 0) {
const pad = LEDGER_MATCH_DAYS * 86_400_000;
const from = new Date(Math.min(...dates) - pad).toISOString().slice(0, 10);
@@ -252,7 +314,7 @@ export async function ingestFrolloCsv(
else byAmount.set(k, [t]);
}
fresh = notYetImported.filter((r) => {
fresh = uncovered.filter((r) => {
const candidates = byAmount.get(key(r.amount, r.transactionType === "credit"));
if (!candidates) return true;
const t = dayMs(r.transactionDate);
@@ -277,6 +339,8 @@ export async function ingestFrolloCsv(
duplicatesDropped: dropped.length,
suspectRepeats,
alreadyImported: ledger.length - notYetImported.length,
coveredByStatement,
statementWatermarks,
ledgerDuplicates,
toInsert: fresh.length,
};