Credit cards keep arriving as monthly statements and stay the source of
truth for them. This covers the other fourteen accounts, whose statements
arrive every 182 to 460 days — AMP including the loan, ANZ Access, Wise
including the income account, Up, ING, and the small transaction accounts.
About 50 rows a month, where the alternative is downloading each statement
by hand.
The file needs three defences, all found by diffing three real exports
(smarthome DECISIONS.md ING-11):
A CDR re-consent makes Frollo re-ingest an account's whole history under
fresh transaction ids while the originals survive, and consents expire
annually. On this export 112 rows were such twins, and every HDR salary
payment appeared twice — importing blind doubles reported income. dedupe()
collapses each natural-key group to its lowest id, lowest because old ids
were a strict subset of new across two exports, so source_ref stays stable
and a re-import inserts nothing.
An earlier version of that rule kept close-id rows on a 10,000 threshold,
reasoning that genuine same-day repeats have consecutive ids. Verifying it
against the income rows killed it: in-scope duplicate pairs have id gaps
from 58 to 260 million, so no threshold separates them from the gaps of 1-4
that real repeats showed. It now collapses unconditionally and flags
anything within 10 for review — the errors are asymmetric, and nothing in
scope has ever tripped the flag.
A lapsed consent removes an account from the export silently, with no error
and no marker; the row count just drops. So the import asserts the account
roster and refuses to run when a configured account contributes nothing.
Also holds these rows out of the pending-reconciliation queue. A feed row is
the account's own ledger entry, not a receipt awaiting a statement line —
these accounts' statements are deliberately not imported — so without the
exclusion 550 rows a year would bury the receipts that need a decision. The
queue stays at 8 instead of 558.
Foreign rows follow order-ingestion's existing shape: amount is the native
figure, foreign_currency_code names it, amount_aud stays NULL rather than
asserting a rate, and AMOUNT_UNCONVERTED already reports the balance as
incomplete.
Dry run by default. Verified against the real export before applying:
550 rows inserted, 14 accounts, re-run inserts 0.
Adds --write to the matcher. Wrote 1,242 split rows across 657 transactions.
Imported settled, and that is the whole design. These obligations were
discharged years ago on a platform we no longer run, and their residual is
already carried by transaction 2348. Writing them unsettled would re-open
roughly $40k of debts that were paid. ACTIVE_OBLIGATION keeps settled splits
out of every owed figure while myShare/mySplitOf still count them, which is
exactly the asymmetry this needs: the import exists to correct historical
SPEND, not to move a balance.
Effect: $35,259 leaves my historical spend -- $13,088 in 2024, $22,117 in
2025 -- because a $200 grocery shop that was always half hers no longer reads
as $200 of mine. Balances are byte-identical before and after (Molina
-1226.72/145, Sonu 5428.08/419), which is the assertion that matters.
Shares are written as the CSV computed them, so a 50/50 row can land as
50.01/49.99. That is faithful rather than tidy; no transaction exceeds 100%.
Rehearsed on the 37-row Rome file first (24 rows) and verified before the full
run -- both the balances and one split read back through the API.
Matches the five CSV exports against transactions already in the ledger and
reports what it would do. Writes nothing -- importing is a separate step, and
rehearsing it first is what catches the defects that tests do not.
What it found, and why the number is what it is: 676 of 1,536 shareable rows
match (44%). The ceiling is ledger coverage, not matcher quality. The CSVs
describe 678 shared expenses in 2024 alone; the ledger holds 591 rows for the
whole of that year, 3 to 72 a month, which is far less than a household
actually spends. Most 2024 CSV rows have no transaction to attach a split to
and never will. South Korea April 2024 matches 4 of 158 for that reason.
Three decisions are encoded deliberately:
- Date format is decided per FILE, not per row. The household export writes
D/M/YYYY and the four trip exports write ISO, and 474 rows parse validly
under both readings -- per-row guessing silently swaps January and February
for some rows and not others.
- A person's column is their net balance impact, not their share. The payer
is whoever is positive; the other's share is |their negative| / cost. So a
+cost/-cost row means the other party owes 100%, not that the expense was
unshared -- the reading that would fake an arrangement change.
- Matching is one-to-one, best pair first. The NZ trip has two identical
$10.16 Uber rows against three ledger rows and four PayMyPark rows in the
same shape; without this a ledger row is claimed repeatedly and the second
CSV row looks matched while being unrepresented.
- Extract evaluateCondition + rule types into src/lib/rules.ts for testability
- 48 unit tests for evaluateCondition (all fields/operators) and formatCategory
- 21 integration tests for getTransactions filters and getParticipantBalances
- Vitest configs for unit (vitest.config.ts) and integration (vitest.integration.config.ts)
- setup-test-db.sh creates personal_test DB from production schema via pg_dump
- Use vi.doMock + dynamic import pattern to isolate test DB from Prisma singleton