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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user