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
+50
View File
@@ -268,6 +268,56 @@ by the `rn` window and ordered by amount closeness, so a looser band changes
live queue at the time of the change: candidate counts went **0 → 0** on all 12
rows, i.e. no new noise. **The auto-matcher stays exact** — see `findAutoMatches()`.
### Upcoming annual charges (`GET /api/annual-charges`, migration 0038)
Card annual fees, loan package fees and annual subscriptions, derived from
**payments alone** — independent of the email lane, because a bank announces an
annual fee on the statement and nowhere else.
**`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 on this ledger is typed that way. The first version of this
filtered `transaction_type = 'debit'` and returned **none** of them. The fees are
not debits.
**Two signals, kept apart because they are not equally trustworthy:**
| confidence | basis |
|---|---|
| `confirmed` | typed a fee **and** repeated on an annual cadence |
| `likely` | typed a fee, seen once, and *named* annual/card/package — the kind is certain, the cadence inferred |
| `possible` | no fee wording; a merchant whose every gap is annual **and** whose amount is stable |
**A naive "two charges ~365 days apart" is useless** — measured on this ledger it
ranks a bakery first with **202** such pairs, then a donation, Patreon, Coles.
Frequency is the confound. Even "every gap is annual" admits a Target visited
twice ($30, then $198). The `possible` tier therefore also requires amount
stability (≤20%, which absorbs VicRoads' ~3%/yr CPI drift) and ≥2 observations.
It still carries real false positives (H&M, Superdry) — that is what the label is
for, and why it must never render like a bank fact.
**`expected_not_seen` is the more interesting status.** The estimate passed and
nothing landed, which means the account stopped being imported, or closed, or the
charge arrived under a different label. `account_days_stale` is the
discriminator: Westpac at 0 days stale is trustworthy; ANZ at 392 is simply
missing data. Never report these as upcoming.
**Account continuity (`account_links`, migration 0038).** One card can appear
under two identities: Citi Prestige `5258…0253` (to 2025-11-13) became NAB MyCard
`1015…6227` (from 2025-11-14), same 14th-to-13th cycle, no gap. Without the link
the $700 fee reads as two dead one-offs instead of one due again ~Nov 2026. The
table is **hand-asserted with evidence** (`source_documents` ids), not inferred —
a contiguous-cycle heuristic would join two genuinely different accounts as
readily as one renamed one. The resolver walks it forward recursively;
`uq_account_links_from` forbids a fork and `account_links_no_self` a self-link, so
it terminates. **Display the resolved current bank**, not the one that printed the
last fee — naming the retired brand sends you to the wrong bank.
Known limitation: the merchant signal fragments on label drift. VicRoads rego was
paid 2024/2025 as `VICROADS` and 2026 as `VICROADS ONLINE PAYM`, so the series
splits and the 2025 estimate shows as `expected_not_seen`. Not fuzzy-matched on
purpose — see the rejected pg_trgm merchant grouping.
### Cash and reconciliation
`payment_method` in (`cash`, `credits`) excludes a transaction from