name: reference-finances-icon-persistence description: How finances.justinsforge.com stores account + merchant/transaction icons, the canonical _icon_key contract, and why icons must never be looked up by a raw key type: reference
Finances hub icon persistence¶
CANONICAL note (2026-07-08): this documents the RETIRED Flask hub (
finance.db,forge_finances_ui_*.py). Finance is now native in CreatorTrack; see CT finance module. The_icon_key/iconKeycanonical-key contract below still holds. NEW infeat/finances-per-workspace(pending merge): merchant icons (and all finance data) are now scoped to the ACTIVE workspace viaapp.finance_wsGUC +financeWsFilter, so a logo set in one workspace no longer bleeds into the user's other workspaces. Details in the CT module ref.merchant_iconsPK is(workspace_id, merchant_key).
Two icon stores in data/finance/finance.db (backed up nightly via restic to Google Drive, see forge backup; NOT on GitHub, db is gitignored):
| Icon | Storage | Key |
|---|---|---|
| Account avatar | accounts.icon column |
account id (stable, never re-keyed) |
| Merchant / transaction icon | merchant_icons table |
canonical _icon_key |
Icon value format: builtin:<name> | url:<href> | urlfill:<href>. Uploaded images live in data/finance/icons/, referenced as url:/finance-icon/<file>.
The canonical-key contract (the load-bearing rule)¶
Merchant icons MUST be both SET and LOOKED UP through _icon_key() in forge_finances_ui_icons.py, which delegates to forge_finance_recurring._norm_key (uppercase, strip digits + punctuation, keep first 3 tokens; idempotent). This collapses store/terminal-number variants (008 TORCHYS, 032 TORCHYS) and survives re-ingests + normalizer tweaks.
Before 2026-06-15 the code keyed icons two different ways: transaction views by raw transactions.merchant_norm (4709 distinct, full of store numbers), recurring views by recurring.merchant_key (sometimes amount-suffixed like APPLE $9.99, not canonical). Picks saved under one key silently failed to render under the other and "disappeared" after any re-ingest. The rows were never deleted; the lookup just missed.
Fix (2026-06-15): every micons.get(...) call site across forge_finances_ui_{transactions,accounts,alerts,dashboard,categorize,spending,cashflow,recurring}.py now wraps its key in _icon_key(...); the /merchant/icon + upload routes normalize the key before storing. One-time idempotent migration scripts/forge_finance_icon_rekey.py rewrote existing rows to canonical form (50 rows, 14 moved, 0 collisions). Result: transactions matching a chosen icon went 24 -> 102 distinct merchants; recurring 44 -> 46.
Logos are self-hosted, never hotlinked (2026-06-15)¶
The original "icons went away" had TWO causes: the key mismatch above, AND remote logo URLs expiring. Brandfetch picks are saved with a ?c=<token> that returns HTTP 410 Link expired after a while; icon.horse/Iconify can rate-limit or referrer-block. When the image dies the browser's onerror="this.remove()" drops it and falls back to favicon/initial, looking like the icon was lost.
Fix: _localize_icon() in forge_finances_ui_icons.py downloads any remote url:/urlfill: logo into ICON_DIR (data/finance/icons/) on save and rewrites the stored value to <prefix>/finance-icon/<file> (served by the serve_icon route). Wired into both /merchant/icon and /account/<id>/icon set routes. The data/finance/icons/ dir rides the nightly restic backup. Fetch failure keeps the original hotlink and logs to stderr (fail-loud comment in code), never blocks the save.
One-time backfill: scripts/forge_finance_icon_localize.py --apply localized 43 merchant + 13 account existing logos; 7 + 7 had already-expired (410) links and were left as dead hotlinks to re-pick. Re-keying companion: scripts/forge_finance_icon_rekey.py.
Gotchas¶
- NEVER add a
micons.get(<raw merchant_norm or merchant_key>)call. Alwaysmicons.get(_icon_key(...)). An eval-free way to check:grep -rn "micons.get(" scripts/forge_finances_ui_*.py | grep -v _icon_keyshould return only the internal call inside_merchant_avatar(where the arg is already_icon_key'd). _icon_keyis exported via the icons module__all__; other UI modules get it throughfrom forge_finances_ui_icons import *.- Account icons are safe (keyed by stable
id); only merchant icons need the canonical key.
[Claude Code]