annual charges: surface upcoming card/loan fees from payments alone
ci / lint-test (push) Successful in 56s
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user