annual charges: surface upcoming card/loan fees from payments alone
ci / lint-test (push) Successful in 56s

Answers "what annual payments are coming?" without touching the email lane --
a bank announces an annual fee on the statement and nowhere else.

WHAT DOES NOT WORK, measured before writing it: "two charges ~365 days apart"
ranks a BAKERY first on this ledger, with 202 such pairs, then a donation,
Patreon, Coles. Frequency is the confound. Tightening to "every gap is annual"
still admits a Target visited twice ($30, then $198).

transaction_type='fee' is the universe, not a description match. The extractor
already classifies fees structurally (131 rows) and every card and loan annual
fee here is typed that way -- the first cut of this filtered
transaction_type='debit' and returned NONE of them. The fees are not debits.

Three confidence tiers, kept apart so a guess never renders like a bank fact:
  confirmed  typed a fee AND repeated on an annual cadence
  likely     typed a fee, seen once, and NAMED annual/card/package -- one charge
             cannot show a cadence but the kind is not in doubt
  possible   no fee wording; merchant whose every gap is annual AND whose amount
             is stable (<=20%, absorbing VicRoads' ~3%/yr drift). Catches Amazon
             Prime and rego; also where the false positives live, hence the label.

expected_not_seen is the more interesting status: the estimate passed and
nothing landed. account_days_stale is the discriminator -- Westpac at 0 days is
trustworthy, ANZ at 392 is simply missing data. Reporting those as upcoming
would be a lie.

account_links (migration 0038) fixes account continuity. Citi Prestige
5258...0253 became NAB MyCard 1015...6227 -- same card, same 14th-to-13th cycle,
no gap -- so the $700 fee charged 2024-12-13 and 2025-11-13 read as two dead
one-offs instead of one due again ~Nov 2026. Hand-asserted with source_documents
ids as evidence rather than inferred: a contiguous-cycle heuristic would join two
genuinely different accounts as readily as one renamed one. Display resolves to
the CURRENT bank; naming the retired brand sends you to the wrong bank.

The GET takes its own DASH_READ_TOKEN with no fallback to WALLET_INGEST_TOKEN --
that one unlocks wallet ingest and the estate-wide reconcile sweep, and
home-dashboard's compose states the rule: it never holds a write credential.

Known limit: the merchant signal fragments on label drift (VicRoads paid as
VICROADS then VICROADS ONLINE PAYM). Not fuzzy-matched, per the rejected pg_trgm
merchant grouping.

339 integration + 196 unit green, tsc clean. Board #265.
This commit is contained in:
2026-08-23 21:44:17 +10:00
parent 10cf0aaf21
commit 5076f27f68
5 changed files with 614 additions and 0 deletions
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import { Pool } from "pg";
import { createPool, resetDB, seedParticipants, mockDbWithPool } from "./helpers";
/**
* Upcoming annual charges from payments alone.
*
* The exclusions carry this file. Anything can be made to find a card annual
* fee; the difficulty is not also reporting the monthly account-servicing fee,
* the per-transfer Wise charge, and the restaurant visited twice a year apart.
* Measured on the real ledger, a naive "two charges ~365 days apart" ranks a
* bakery first with 202 such pairs.
*/
let pool: Pool;
let ownerId: number;
let getUpcomingAnnualCharges: typeof import("../../lib/annual-charges").getUpcomingAnnualCharges;
let acctSeq = 0;
/** A statement plus one transaction on it, for a named account. */
async function charge(
bank: string,
acct: string,
date: string,
description: string,
amount: number,
type = "fee"
) {
const key = `${bank}:${acct}`;
let stId = stmtCache.get(`${key}:${date}`);
if (!stId) {
const st = await pool.query(
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
VALUES ($4, $1, $2, $3::date - 20, $3::date + 10, $5) RETURNING id`,
[acct, bank, date, `s${acctSeq++}.pdf`, ownerId]
);
stId = st.rows[0].id as number;
stmtCache.set(`${key}:${date}`, stId);
}
await pool.query(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
VALUES ($1,$2,$3,$4,$5,$6)`,
[stId, date, description, amount, type, ownerId]
);
}
let stmtCache = new Map<string, number>();
/** A statementless merchant purchase, for the non-fee signal. */
async function purchase(date: string, merchant: string, amount: number) {
await pool.query(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id, merchant_normalized, row_index)
VALUES (NULL,$1,$2,$3,'debit',$4,$2,0)`,
[date, merchant, amount, ownerId]
);
}
const find = <T extends { label: string }>(rows: T[], label: string) =>
rows.find((r) => r.label === label);
beforeAll(async () => {
pool = createPool();
mockDbWithPool(pool);
({ getUpcomingAnnualCharges } = await import("../../lib/annual-charges"));
});
beforeEach(async () => {
await resetDB(pool);
await pool.query("DELETE FROM account_links");
stmtCache = new Map();
({ ownerId } = await seedParticipants(pool));
});
describe("what it finds", () => {
it("confirms a card annual fee that repeated a year later", async () => {
await charge("American Express", "1000", "2025-06-10", "ANNUAL CARD FEE", 875);
await charge("American Express", "1000", "2026-06-10", "ANNUAL CARD FEE", 1750);
const row = find(await getUpcomingAnnualCharges(ownerId), "ANNUAL CARD FEE");
expect(row?.confidence).toBe("confirmed");
expect(row?.observations).toBe(2);
// The amount is NOT averaged: this fee doubled, and the next one to plan for
// is the latest, not the mean of a rise.
expect(Number(row?.last_amount)).toBe(1750);
expect(row?.next_due_estimate.startsWith("2027-06")).toBe(true);
});
it("accepts a fee seen only once when the bank names it annual", async () => {
await charge("Westpac", "8032", "2025-10-21", "CARD FEE", 295);
const row = find(await getUpcomingAnnualCharges(ownerId), "CARD FEE");
// One charge cannot show a cadence, but the kind is not in doubt.
expect(row?.confidence).toBe("likely");
expect(row?.next_due_estimate).toBe("2026-10-21");
});
it("finds an annual merchant charge with no fee wording, if the amount is stable", async () => {
await purchase("2025-02-03", "AMAZON PRIME", 79);
await purchase("2026-02-01", "AMAZON PRIME", 79);
const row = find(await getUpcomingAnnualCharges(ownerId), "AMAZON PRIME");
expect(row?.confidence).toBe("possible");
});
});
describe("what it must NOT report", () => {
it("ignores a monthly account fee", async () => {
for (const d of ["2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30"]) {
await charge("ANZ", "5626", d, "ACCOUNT SERVICING FEE", 5);
}
expect(find(await getUpcomingAnnualCharges(ownerId), "ACCOUNT SERVICING FEE")).toBeUndefined();
});
it("ignores per-transaction fees, which never repeat under one label", async () => {
await charge("Wise Australia Pty Ltd.", "9001", "2026-05-20", "WISE CHARGES FOR: TRANSFER-2143509822", 30.67);
await charge("Wise Australia Pty Ltd.", "9001", "2026-06-04", "WISE CHARGES FOR: TRANSFER-2171403032", 19.51);
const rows = await getUpcomingAnnualCharges(ownerId);
expect(rows.filter((r) => r.label.startsWith("WISE CHARGES"))).toHaveLength(0);
});
it("ignores a merchant visited twice a year apart for wildly different amounts", async () => {
// The exact shape that defeats a pure cadence test: Target, $30 then $198.
await purchase("2025-07-21", "TARGET", 30);
await purchase("2026-07-11", "TARGET", 198);
expect(find(await getUpcomingAnnualCharges(ownerId), "TARGET")).toBeUndefined();
});
it("ignores a merchant seen only once", async () => {
await purchase("2026-04-03", "UBER ONE MEMBERSHIP", 96);
expect(find(await getUpcomingAnnualCharges(ownerId), "UBER ONE MEMBERSHIP")).toBeUndefined();
});
});
describe("account identity and honesty about gaps", () => {
it("treats a renamed account as one continuous fee series", async () => {
// Citi Prestige -> NAB MyCard: same card, two bank names, two numbers.
await charge("Citibank", "5258073330340253", "2024-12-13", "Annual Fee", 700);
await charge("Citibank", "5258073330340253", "2025-11-13", "Annual Fee", 700);
await pool.query(
`INSERT INTO account_links (from_bank_name, from_account_key, to_bank_name, to_account_key, effective_date, reason)
VALUES ('Citibank','5258073330340253','NAB','1015001000116227','2025-11-14','issuer_change')`
);
// A statement under the NEW identity, so the link has somewhere to resolve to.
await charge("NAB", "1015001000116227", "2026-08-13", "Purchase", 29.99, "debit");
const row = find(await getUpcomingAnnualCharges(ownerId), "ANNUAL FEE");
expect(row?.observations).toBe(2);
expect(row?.confidence).toBe("confirmed");
// Names the account's CURRENT bank -- the next fee comes from there, and
// sending someone to the retired brand is the bug this link exists to stop.
expect(row?.bank_name).toBe("NAB");
});
it("marks a passed estimate as expected_not_seen rather than upcoming", async () => {
await charge("HSBC", "0351", "2024-03-06", "ANNUAL CHARGE", 450);
await charge("HSBC", "0351", "2025-03-06", "ANNUAL CHARGE", 450);
const row = find(await getUpcomingAnnualCharges(ownerId), "ANNUAL CHARGE");
expect(row?.status).toBe("expected_not_seen");
// And it reports how stale the account is, which is what separates
// "it stopped being charged" from "we stopped importing the statements".
expect(row?.account_days_stale).toBeGreaterThan(300);
});
it("sorts upcoming ahead of expected_not_seen", async () => {
await charge("HSBC", "0351", "2024-03-06", "ANNUAL CHARGE", 450);
await charge("HSBC", "0351", "2025-03-06", "ANNUAL CHARGE", 450);
await charge("Westpac", "8032", "2026-08-01", "CARD FEE", 295);
const rows = await getUpcomingAnnualCharges(ownerId);
const firstMissing = rows.findIndex((r) => r.status === "expected_not_seen");
const lastUpcoming = rows.map((r) => r.status).lastIndexOf("upcoming");
expect(lastUpcoming).toBeLessThan(firstMissing);
});
});