diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ecb026..cf7d7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.18.2] — 2026-11-04 + +### Fixed — Transfers, Spending, Income +- **A transfer keyword alone no longer hides money you sent or received.** Detection's second pass (`detectTransfers()`, `core.js`) marked every keyword match without a second leg as a transfer, so it was left out of income and spending. In `samples/`, six `ZELLE TO M TORRES` payments ($230.00) were missing from spending, and the 30 transfers netted to −$230.00 instead of $0. + - **Now only a pair is a transfer.** A one-sided match counts as spending or income. It keeps its own category ("PAYPAL *NETFLIX" stays Subscriptions); when that's only Other, or the Transfer label the built-in rules give every keyword, it gets the new categories **Money sent** / **Money received** (`SENT_RECEIVED`, also in the default category list). + - **Samples:** spending $16,795.07 → $17,025.07; transfers 30 → 24, netting to exactly $0.00; income unchanged ($32,524.88). + - **Keywords still pair legs** up to 2 days apart instead of 1, as before. + - **A move to an account you haven't imported** (a brokerage, savings at another bank) now counts as spending until you set its type to Transfer, which sticks, as any type you set does. + - **Undoing it:** `undoTransferDetection()` puts a "Money sent" / "Money received" from detection back to the row's own category (`isSentReceived()`, `ownCategories()`), so removing a keyword or re-importing decides again. A category you set or a rule gives is kept. +- **One-sided matches are flagged for review.** Counting them as spending or income is a guess, so each waits for your answer: + - **Transfers → To review** lists them all (every date, newest first) with **Keep as spending / income** and **It's a transfer**, plus **Keep all**. The Transfers pill shows how many are left, and the Transactions list marks each with a **Review** flag. + - **It's a transfer** stores the Transfer type, as the drawer does, so it leaves your totals. **Keep** stores `reviewed: true` in the edit (`txOverrides`), so it isn't asked again. A type set in the drawer answers it too. + - **Worked out, never stored** (`needsTransferReview()`, `core.js`), so it holds after a boot from `finlens-state.json`. `transferKeyRegex()` is now shared by detection and the review. +- **Transfers screen:** the keyword note says what keywords do now. +- **README, CLAUDE.md §11:** updated to match. +- **AI Analysis:** the summary's spending, categories and monthly trend change with the samples' Zelle payments, so the published eval (`docs/EVAL.md`) no longer matches the summary FinLens sends. **Re-run the eval with your key** before trusting its numbers again. + +--- + ## [1.18.1] — 2026-11-02 ### Docs diff --git a/CLAUDE.md b/CLAUDE.md index 222251b..8604fe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ Until then, this section is the rule. FinLens is a **single-origin, zero-build, vanilla JavaScript personal finance dashboard** that runs entirely in the browser. There is no server, no npm, no bundler, no framework, and no TypeScript. The entry point is `index.html`; everything else is a plain `.js` file in the `js/` folder. -**Current version:** v1.18.1 (updated in `index.html` footer, `README.md`, and `CHANGELOG.md` on every release). +**Current version:** v1.18.2 (updated in `index.html` footer, `README.md`, and `CHANGELOG.md` on every release). **What it does:** Imports CSV exports from a bank (checking + savings) and credit cards, parses and normalizes them into a unified transaction model, and renders a multi-tab financial dashboard with budgeting, debt tracking, savings forecasting, investment planning, and Claude-powered AI analysis. @@ -228,6 +228,7 @@ The optional fields live inside `bills`, so they're saved, loaded, exported and ```js { description, categories: string[], type, notes, isBill, isRecurring, isSubscription, + reviewed?: true, // a one-sided transfer keyword match you kept as spending or income (v1.18.2, §11) accountOverride?: string, // an accountId link?: { type: 'budget' | 'bucket' | 'debt', id: string } } // the transaction drawer's "Link to"; id = the item's id (v1.16.7) ``` @@ -463,7 +464,7 @@ Dark mode is applied by setting `data-theme="dark"` on `` (`applyTheme()`, .rows/.r — list rows in a panel; .date-block (MON / day) .tbl.flush — table without side padding; .table-scroll (overflow-x box, positioned); .mw-520…860 min-widths .fit-sm .hide-sm .w-date .w-amt .w-num — below 600px: fixed layout, hidden columns, set widths -.tag .flag .pill.accent .code-tag — category tag, flag chip, accent pill, rule keyword +.tag .flag .pill.accent .code-tag — category tag, flag chip, accent pill, rule keyword; .flag.review (warn tints) — a row to review (v1.18.2) .field .field-row .form-row .check-chip .flag-set — form fields (label wraps its control) .dform .sum-box .drawer-note — drawer bodies; .drawer-head/.drawer-body/.drawer-foot are the drawer itself ``` @@ -659,6 +660,9 @@ undoTransferDetection(t, row?) // a detected transfer back to its CSV type and i redetectTransfers() // undoTransferDetection() on every transaction, then detectTransfers(): a keyword change, a type change (v1.16.26) ownCategories(t, ov?, row?, ruleCats?) // the categories t has when it isn't a transfer (v1.16.26, §11): never Transfer out / in withoutDirection(cats) // cats without Transfer out / Transfer in ([] for a non-list) +transferKeyRegex() // the transfer keywords as one case-insensitive RegExp (the user's list, else the defaults) +needsTransferReview(t, ov?, key?) // a one-sided keyword match you haven't answered yet (v1.18.2, §11); worked out, never stored +transfersToReview() // (views-main.js) every one of them, newest first; answerTransferReview(id, 'transfer' | 'keep') answers one accountFiles(a) // the CSV names an account came from: a.files, else [a.sourceFile] (saved before v1.16.17) earlierCopies(read) // (v1.16.17) loaded rows a re-read file gave under another account → Map(old id → { id, from, file }); reads only moveEarlierCopies(moves, read, fresh) // removes them (memory, IndexedDB), moves their edits to the new ids, drops accounts left empty @@ -846,7 +850,11 @@ The account id comes last on purpose: `migrate.js` relies on it to rename accoun `detectTransfers()` runs after every `loadFiles()`. It only looks at transactions that aren't transfers yet, so `loadFiles()` first puts the transfers it keeps back to their CSV type and category (`undoTransferDetection()`, v1.16.3): a kept leg then pairs with its partner re-read from a file, exactly as in a full import. It does two passes: 1. **Paired match:** Finds transactions across different accounts with equal-and-opposite amounts within 2 days. Sets `type='transfer'`, `categories=['Transfer out']` (negative) or `categories=['Transfer in']` (positive). -2. **Keyword match:** Marks unmatched transactions whose description matches `/transfer|zelle|ach|venmo|paypal|coinbase|savings booster/i` by direction. +2. **Keyword match, one leg only (v1.18.2):** an unmatched transaction whose description contains a transfer keyword is **not** a transfer. With no other leg loaded it's money that left for, or came from, someone outside your accounts, so it counts as spending or income. It keeps its own category; when that's only `Other` or the built-in `Transfer` label (below), it gets `Money sent` (negative) or `Money received` (positive), from `SENT_RECEIVED`. A stored category or a stored type wins, as for pass 1. Keywords still matter in pass 1: with one, two legs pair up to 2 days apart instead of 1. + - **Why:** only a pair nets to zero. Up to v1.18.1 pass 2 marked every keyword match as a transfer, so a Zelle payment to a person was left out of spending: in `samples/`, six `ZELLE TO M TORRES` payments hid $230.00, and the 30 transfers netted to −$230.00 instead of $0. Now the 24 paired legs net to exactly $0. + - **A move to an account you haven't imported** (a brokerage, a savings account at another bank) now counts as spending until you set its type to Transfer, which sticks. + - **Undoing it:** `undoTransferDetection()` puts a row whose category is just `Money sent` / `Money received` back to `ownCategories()` first (`isSentReceived()`), so a keyword change or re-import decides again. A category you set, or a rule gives, survives that. + - **Flagged for review.** Counting it is a guess, so each one waits for your answer: `needsTransferReview(t)` is true for a row that isn't a transfer or a card payment, has a keyword in its description, and has no stored `type` and no `reviewed`. The Transfers screen lists them all, every date, under **To review** (`transfersToReview()`); the Transactions list flags each with **Review**, and the Transfers pill shows the count. **It's a transfer** stores the Transfer type (and its direction as the category, unless you set categories), then `redetectTransfers()`, as the drawer does. **Keep as spending / income** (or **Keep all**) stores `reviewed: true`. A type you set in the drawer answers it too; a category alone doesn't. The flag is worked out every time, never stored, so it holds after a boot from `finlens-state.json`. Clear transaction edits removes the answers, so the rows are asked about again. **The user's edit wins over both passes (v1.16.23):** - **A stored `type`** (`txOverrides[id].type`, whatever its value) keeps the row out of detection. It isn't marked and doesn't pair. The row it would have paired with is checked on its own, as if the typed row weren't there: it can pair with another row, match a keyword (a one-sided transfer), or stay as imported. A type set to Transfer already made the row a transfer that detection skips; that's unchanged. @@ -862,10 +870,10 @@ The account id comes last on purpose: `migrate.js` relies on it to rename accoun - **Type:** the stored `type`; else the CSV's (`_originalType`: a card's Sale, Return or Payment, a bank's Withdrawal or Deposit); else its IndexedDB row's; else debit or credit by sign. A stored Transfer type stays a transfer. - **Categories:** `ownCategories()` (v1.16.26), the first of: the stored ones, without `Transfer out` / `Transfer in`; the one Re-categorize all saved (`State.ruleCategories`, v1.16.25; skipped where it doesn't exist); the ones it has besides `Transfer out` / `Transfer in` (without `ruleCategories`, Re-categorize all puts its category on a detected transfer too); the CSV's (`_originalCategories`: a card file's own Category, otherwise what the import's `autoCategory()` gave, category rules first); the row's; `autoCategory()` now; `Other`. Before v1.16.26 the stored ones were kept with `Transfer out` / `Transfer in`. - **Before v1.16.24** `reapplyTransferKeywords()` didn't use it: a transfer went back as debit or credit by sign, with `Other` or `Income` when it had no other category. A card Payment then counted as income, and the wrong category was saved to `finlens-state.json`. Rows saved that way keep it until their CSV is imported again (Reload CSVs, Rebuild from your folder). -- **The built-in rules give ZELLE, TRANSFER, ACH, VENMO, PAYPAL and COINBASE the category `Transfer`** (`DEFAULT_RULES`). So a ZELLE row back from a transfer is an expense with the category `Transfer`, and counts in Spending (below). +- **The built-in rules give ZELLE, TRANSFER, ACH, VENMO, PAYPAL and COINBASE the category `Transfer`** (`DEFAULT_RULES`). So a ZELLE row back from a transfer is an expense with the category `Transfer`, and counts in Spending (below). Since v1.18.2 a one-sided keyword match with only that label gets `Money sent` / `Money received` instead (pass 2 above). **The type decides (v1.16.26).** A transaction is a transfer when `t.type === 'transfer'`, never because of its category. Every other type counts as money in or out by its sign (a card payment isn't income, §11.1). `sumIncome()`, `sumExpense()`, `sumTransfers()`, `byCategory()`, `byMonth()`, `detectRecurring()`, `transferPairs()`, `starterBudgetList()` and the Income, Spending and Bills screens all test the type only, and so do the Overview, Trends, Budget and the AI summary through them. -- **The category `Transfer` is a label.** On a row that isn't a transfer it counts as spending or income like any other category. Only the type makes a transfer: a keyword, a pair, or Transfer chosen as the type. Without edits this changes nothing: the built-in rules give `Transfer` only to descriptions that contain a built-in keyword too, so every such row is already a transfer by type. The category `Transfer` from a user's category rule, or from a card file's Category column, no longer keeps a row out of the totals; a transfer keyword or the Transfer type does. +- **The category `Transfer` is a label.** On a row that isn't a transfer it counts as spending or income like any other category. Only the type makes a transfer: a pair, or Transfer chosen as the type (a keyword alone no longer does, v1.18.2). The built-in rules give `Transfer` only to descriptions that contain a built-in keyword; since v1.18.2 such a row is a transfer only when it pairs, and otherwise gets `Money sent` / `Money received` (pass 2). The category `Transfer` from a user's category rule, or from a card file's Category column, doesn't keep a row out of the totals; a pair or the Transfer type does. - **`Transfer out` / `Transfer in` only on transfers.** A row that isn't a transfer shows `ownCategories()` instead, wherever its categories come from: the drawer and bulk Set type (§4.1), `undoTransferDetection()`, `mkTx()` (an edit holding a direction) and `reconstituteTx()` (an edit, or a `finlens-state.json` entry from before v1.16.26). The edit itself keeps them, so `detectTransfers()`' `mark()` gives them back if the row becomes a transfer again. - **Before v1.16.26** the totals went by category too: `Transfer`, `Transfer in` and `Transfer out` (some helpers all three, some only `Transfer`) counted as a transfer whatever the type. A ZELLE row set to Expense stayed on the Transfers screen and out of Spending's categories, and after a reload out of its total. @@ -1157,7 +1165,7 @@ Multiple credit card files can be loaded simultaneously. Each gets a unique `acc --- -*Last updated: v1.18.1 — 2026-11-02 (v1.18.1: MIT license added, no section changes; v1.18.0: backup status in the sync badge, save before leaving: §1 version; §4.5 1.5 s save delay, save state, flush when the tab is hidden, leave warning, Start over; §6 sync badge classes; §8 `flushPendingSave()`, `backupWouldBeLost()`; §9 sync badge states, tooltip, name, announcements and clicks; §12 the badge in the app bar; §14 the badge's Reconnect and Retry are click handlers; v1.17.0: Settings → Database tab, DB Inspector and DB Schema rebuilt on Clarity: §1 version; §3 views-db.js role; §6 Database classes; §12 seventh Settings tab, its sub-tabs, the hidden-tab guard, the tab bar scrolling to the selected tab, view registrations; §19 database views; v1.16.27: §9 and §12 the developer views are in `NO_RANGE_VIEWS`; §14 the tab's name is Data & reset; §20 a render partway through an action keeps its button's done label (`DataCleanup.running`); v1.16.26: the type decides whether a transaction is a transfer: §4.1 the drawers note, a type changed away from Transfer; §8 the sums go by type, `ownCategories()`, `withoutDirection()`, `redetectTransfers()`; §11 the type decides, Transfer out / in only on transfers, a type change re-runs detection; §11.1 a retyped autopay's card payment; §13 the summary's totals go by type; v1.16.25: Re-categorize all is saved: §1 version; §4.1 `ruleCategories`; §4.2 categories; §8 `categoryFromRules()`; §9 `recategorizeAll()`; §20 where `ruleCategories` is kept, what clears it, left out of the JSON export; v1.16.24: a transfer that stops being one gets its own type and category back: §8 `undoTransferDetection()`, §11 un-transferring; v1.16.23: transfer detection respects the user's type and categories: §1 version, §4.1 the drawers note, §11 the user's edit wins; v1.16.22: §13 the AI summary's trend stops at a past period's end, subscriptions labelled current; §21 the parser finds sections by their heading's start; v1.16.21: §8 `overrideBalanceAt()`, `balanceAt()`; §18 a card's amount owed is taken back plus the later transactions, a bank balance minus them; the sign of a card's balance; v1.16.20: §18 a balance override at the end of every range is the override less the later transactions, no 7-day exception; v1.16.19: overlapping exports of one account count each shared row once: §4.5 one transaction per id; §8 `mergeImported()`, `importParsedCsv()`; §10 overlapping exports of one account; §19 the leftover-copy check keeps the import's ids; v1.16.18: a JSON import's transaction edits show at once: §9 `applyImportedEdits()`; §20 what takes effect without a reload; v1.16.17: a file read into another account takes its rows with it: §4.3 the account's `files`, `accountFiles()`; §4.5 the exception to an import never dropping rows; §8 `accountFiles()`, `earlierCopies()`, `moveEarlierCopies()`; §10 Read as changes the ids; §19 `DB.removeTransactions()`, `DB.patchAccount()`; §22 `_rekey()` is also used by imports; v1.16.16: the transaction and transfer drawers store only the fields you change: §4.1 TxOverride, §11 transfer keywords, §12 state mutations; v1.16.15: §1 desktop only; §3 `views-database.js` removed, the Changelog view reads CHANGELOG.md; §6, §18, §19 point to the current screens; v1.16.14: §9 the drawer is a modal dialog: inert page, Tab wraps, first field focused, focus returns after a re-render; v1.16.13: account order: §4.1 `accountOrder` keeps ids not loaded; §8 `orderedAccounts()`; §18 display order; v1.16.12: account names: §4.1 `accountLabels`; §4.3 an account's `name` is its default; §8 `defaultAccountName()`; §9 `acctName()`; §13 the summary lists accounts by `acctName()`; §18 names you type, and clearing them; v1.16.11: AI summary balances at the end of the selected period: §13 `summaryBalanceEnd()` and the date notes; §21 the parser reads the balance lines by how they start; v1.16.10: starting balance counted in every range: §8 `startingBalance()`, `currentBalances()`; §13 summary balances; §18 one balance rule; v1.16.9: the transaction drawer stores only the flags you change: §4.2 flags, §12 state mutations; v1.16.8: flags from transaction rules are worked out on load, never read from storage: §4.2 flags, §8 `txRuleFlag()` / `txFlags()` / `applyTxRulesToAll()`, §19 flags in the transactions store; v1.16.7: transaction links to budgets, buckets and debts by id: §1 version; §3 migrate.js role; §4.1 the `id` fields, TxOverride and its `link` shape; §8 `PLAN_LINK_TYPES`, `newPlanId()`; §9 `planItem()`, `linkedTransactions()`, `unlinkPlanItems()`, `ensurePlanIds()`; §20 clearing budgets, buckets or debts removes their links; §22 the plan-link migration; v1.16.6: JSON export carries account names and order, Read as, flag rules, transfer keywords and theme: §9 `JSON_EXPORT_KEYS`; §12 adding a persisted field; §20 what the export holds; v1.16.5: a new database copies every saved setting: §4.5 the keys that go through `storageKey()`; §12 adding a persisted field; §19 `DB.APP_STATE_KEYS`; v1.16.4: JSON import saves under the storage keys: §4.5 `STORAGE_KEYS` / `storageKey()`; §8 `storageKey()`; §12 adding a persisted field uses the State name; v1.16.3: An import keeps the loaded transactions it doesn't replace: §4.5 an import never drops loaded transactions; §8 `mergeImported()`, `undoTransferDetection()`; §10 memory follows the same re-upload rules; §11 kept transfers are detected again; v1.16.2: Imports read every file before changing anything: §8 `loadFiles()` options and result, `parseCsvFile()` reads only, `importParsedCsv()`, `importProblemText()`, `reloadFromDirHandle()`; §20 Rebuild from your folder reads before it clears, read before clearing; v1.16.1: Clear transaction edits works from memory: §4.2 `_originalCategories`, `_originalType` and what a transaction keeps of its row; §4.5 IndexedDB may not hold the loaded transactions; §10 a stored row holds the CSV's values; §20 Clear transaction edits; v1.16.0: Trends, AI Analysis, Settings and the Welcome rebuilt: §3 file roles; §4.1 `cardDetails`, `sampleData`, AI history `model`; §6 tile tokens and Insights/Settings/Welcome components; §7 rule 7 covers Trends; §8 `loadFiles()` sample option; §9 `shellHero()`, `NO_RANGE_VIEWS`, ☰ → ×, JSON export/import buttons; §12 Settings tabs renamed, the Welcome, range label hidden on Trends and Settings; §13 quick analyses, tab state, history; §20 new fields, Use my own files, Export/Import JSON; §21 quick analyses send `AI_PROMPTS`; v1.15.0: Plan screens rebuilt: §3 file roles; §4.1 `debtPlan` and the Budget / Bucket / Debt / Scenario shapes; §6 `--teal-text` and Plan components; §7 rule 7 covers the Plan charts; §8 plan helpers (`payoffPlan()`, `growthByYear()` …); §9 plan form helpers, `simulatePayoff()` kept unused; §12 Plan titles, Forecast tabs, range label hidden on Plan screens; §13 debt balances in the summary; §20 Clear forecast scenarios / investment mix, Clear debts resets `debtPlan`; v1.14.0: Finances screens rebuilt: §4.1 Bill shape and `recurringConfirmed`; §6 new tokens and Finances components; §7 rule 7 inline SVG charts; §8 new aggregation helpers; §9 drawer, `TX_VIEWS`, SVG/heatmap/format helpers, bill helpers, removed renderers; §11 keywords re-run detection, `transferPairs()`, card payments; §12 page actions and Transactions pills; §20 Clear bills also clears spotted-charge choices; v1.13.0: Clarity redesign — §6 tokens and components; §9 theme, chart colors and app bar helpers; §12 app bar navigation replaces the sidebar and the emoji rule; §17 footer; §20 Data & Reset; v1.12.0: bank-neutral account codes, names, samples and docs; §22 `migrate.js` added; §3, §4, §5, §10, §18, §21 updated; v1.10.5: no section changes: Import Logs race fix; v1.10.4: §8, §13 single `~` is literal; §21 excerpts escape `~`; v1.10.3: §13 refusals; §21 declined runs; v1.10.2: §13 summary states its dates; §21 per-month derivations; v1.10.1: §13 `AI_MAX_TOKENS`, thinking and cut-off answers; v1.10.0: §21 AI Eval added; §13 `callClaude()` raw mode; v1.9.6: §14 folder access lasts until the last tab closes, not one page load; v1.9.5: §7 chart guard; v1.9.4: views-db.js changelog reads CRLF checkouts; v1.9.3: §14 folder permission: re-request on click, Reconnect banner, browser copy wins; v1.9.2: §0 branch, PR and approval workflow added; v1.9.1: §4.5 rehydrate transactions loaded from JSON; v1.9.0: §20 Data & Reset added; v1.8.1: §10 re-upload dedup rewritten for the occurrence-suffix fix; v1.8.0: AI Analysis restored with API-key and output hardening; `renderMarkdown()` added; `samples/` added; §1–3, §13, §14, §17 corrected to match the code)* +*Last updated: v1.18.2 — 2026-11-04 (v1.18.2: one-sided keyword matches count as spending or income and are flagged for review: §4.1 `reviewed`; §6 `.flag.review`; §8 `transferKeyRegex()`, `needsTransferReview()`, `transfersToReview()`; §11 pass 2, `SENT_RECEIVED`, `isSentReceived()`, review, the type decides; v1.18.1: MIT license added, no section changes; v1.18.0: backup status in the sync badge, save before leaving: §1 version; §4.5 1.5 s save delay, save state, flush when the tab is hidden, leave warning, Start over; §6 sync badge classes; §8 `flushPendingSave()`, `backupWouldBeLost()`; §9 sync badge states, tooltip, name, announcements and clicks; §12 the badge in the app bar; §14 the badge's Reconnect and Retry are click handlers; v1.17.0: Settings → Database tab, DB Inspector and DB Schema rebuilt on Clarity: §1 version; §3 views-db.js role; §6 Database classes; §12 seventh Settings tab, its sub-tabs, the hidden-tab guard, the tab bar scrolling to the selected tab, view registrations; §19 database views; v1.16.27: §9 and §12 the developer views are in `NO_RANGE_VIEWS`; §14 the tab's name is Data & reset; §20 a render partway through an action keeps its button's done label (`DataCleanup.running`); v1.16.26: the type decides whether a transaction is a transfer: §4.1 the drawers note, a type changed away from Transfer; §8 the sums go by type, `ownCategories()`, `withoutDirection()`, `redetectTransfers()`; §11 the type decides, Transfer out / in only on transfers, a type change re-runs detection; §11.1 a retyped autopay's card payment; §13 the summary's totals go by type; v1.16.25: Re-categorize all is saved: §1 version; §4.1 `ruleCategories`; §4.2 categories; §8 `categoryFromRules()`; §9 `recategorizeAll()`; §20 where `ruleCategories` is kept, what clears it, left out of the JSON export; v1.16.24: a transfer that stops being one gets its own type and category back: §8 `undoTransferDetection()`, §11 un-transferring; v1.16.23: transfer detection respects the user's type and categories: §1 version, §4.1 the drawers note, §11 the user's edit wins; v1.16.22: §13 the AI summary's trend stops at a past period's end, subscriptions labelled current; §21 the parser finds sections by their heading's start; v1.16.21: §8 `overrideBalanceAt()`, `balanceAt()`; §18 a card's amount owed is taken back plus the later transactions, a bank balance minus them; the sign of a card's balance; v1.16.20: §18 a balance override at the end of every range is the override less the later transactions, no 7-day exception; v1.16.19: overlapping exports of one account count each shared row once: §4.5 one transaction per id; §8 `mergeImported()`, `importParsedCsv()`; §10 overlapping exports of one account; §19 the leftover-copy check keeps the import's ids; v1.16.18: a JSON import's transaction edits show at once: §9 `applyImportedEdits()`; §20 what takes effect without a reload; v1.16.17: a file read into another account takes its rows with it: §4.3 the account's `files`, `accountFiles()`; §4.5 the exception to an import never dropping rows; §8 `accountFiles()`, `earlierCopies()`, `moveEarlierCopies()`; §10 Read as changes the ids; §19 `DB.removeTransactions()`, `DB.patchAccount()`; §22 `_rekey()` is also used by imports; v1.16.16: the transaction and transfer drawers store only the fields you change: §4.1 TxOverride, §11 transfer keywords, §12 state mutations; v1.16.15: §1 desktop only; §3 `views-database.js` removed, the Changelog view reads CHANGELOG.md; §6, §18, §19 point to the current screens; v1.16.14: §9 the drawer is a modal dialog: inert page, Tab wraps, first field focused, focus returns after a re-render; v1.16.13: account order: §4.1 `accountOrder` keeps ids not loaded; §8 `orderedAccounts()`; §18 display order; v1.16.12: account names: §4.1 `accountLabels`; §4.3 an account's `name` is its default; §8 `defaultAccountName()`; §9 `acctName()`; §13 the summary lists accounts by `acctName()`; §18 names you type, and clearing them; v1.16.11: AI summary balances at the end of the selected period: §13 `summaryBalanceEnd()` and the date notes; §21 the parser reads the balance lines by how they start; v1.16.10: starting balance counted in every range: §8 `startingBalance()`, `currentBalances()`; §13 summary balances; §18 one balance rule; v1.16.9: the transaction drawer stores only the flags you change: §4.2 flags, §12 state mutations; v1.16.8: flags from transaction rules are worked out on load, never read from storage: §4.2 flags, §8 `txRuleFlag()` / `txFlags()` / `applyTxRulesToAll()`, §19 flags in the transactions store; v1.16.7: transaction links to budgets, buckets and debts by id: §1 version; §3 migrate.js role; §4.1 the `id` fields, TxOverride and its `link` shape; §8 `PLAN_LINK_TYPES`, `newPlanId()`; §9 `planItem()`, `linkedTransactions()`, `unlinkPlanItems()`, `ensurePlanIds()`; §20 clearing budgets, buckets or debts removes their links; §22 the plan-link migration; v1.16.6: JSON export carries account names and order, Read as, flag rules, transfer keywords and theme: §9 `JSON_EXPORT_KEYS`; §12 adding a persisted field; §20 what the export holds; v1.16.5: a new database copies every saved setting: §4.5 the keys that go through `storageKey()`; §12 adding a persisted field; §19 `DB.APP_STATE_KEYS`; v1.16.4: JSON import saves under the storage keys: §4.5 `STORAGE_KEYS` / `storageKey()`; §8 `storageKey()`; §12 adding a persisted field uses the State name; v1.16.3: An import keeps the loaded transactions it doesn't replace: §4.5 an import never drops loaded transactions; §8 `mergeImported()`, `undoTransferDetection()`; §10 memory follows the same re-upload rules; §11 kept transfers are detected again; v1.16.2: Imports read every file before changing anything: §8 `loadFiles()` options and result, `parseCsvFile()` reads only, `importParsedCsv()`, `importProblemText()`, `reloadFromDirHandle()`; §20 Rebuild from your folder reads before it clears, read before clearing; v1.16.1: Clear transaction edits works from memory: §4.2 `_originalCategories`, `_originalType` and what a transaction keeps of its row; §4.5 IndexedDB may not hold the loaded transactions; §10 a stored row holds the CSV's values; §20 Clear transaction edits; v1.16.0: Trends, AI Analysis, Settings and the Welcome rebuilt: §3 file roles; §4.1 `cardDetails`, `sampleData`, AI history `model`; §6 tile tokens and Insights/Settings/Welcome components; §7 rule 7 covers Trends; §8 `loadFiles()` sample option; §9 `shellHero()`, `NO_RANGE_VIEWS`, ☰ → ×, JSON export/import buttons; §12 Settings tabs renamed, the Welcome, range label hidden on Trends and Settings; §13 quick analyses, tab state, history; §20 new fields, Use my own files, Export/Import JSON; §21 quick analyses send `AI_PROMPTS`; v1.15.0: Plan screens rebuilt: §3 file roles; §4.1 `debtPlan` and the Budget / Bucket / Debt / Scenario shapes; §6 `--teal-text` and Plan components; §7 rule 7 covers the Plan charts; §8 plan helpers (`payoffPlan()`, `growthByYear()` …); §9 plan form helpers, `simulatePayoff()` kept unused; §12 Plan titles, Forecast tabs, range label hidden on Plan screens; §13 debt balances in the summary; §20 Clear forecast scenarios / investment mix, Clear debts resets `debtPlan`; v1.14.0: Finances screens rebuilt: §4.1 Bill shape and `recurringConfirmed`; §6 new tokens and Finances components; §7 rule 7 inline SVG charts; §8 new aggregation helpers; §9 drawer, `TX_VIEWS`, SVG/heatmap/format helpers, bill helpers, removed renderers; §11 keywords re-run detection, `transferPairs()`, card payments; §12 page actions and Transactions pills; §20 Clear bills also clears spotted-charge choices; v1.13.0: Clarity redesign — §6 tokens and components; §9 theme, chart colors and app bar helpers; §12 app bar navigation replaces the sidebar and the emoji rule; §17 footer; §20 Data & Reset; v1.12.0: bank-neutral account codes, names, samples and docs; §22 `migrate.js` added; §3, §4, §5, §10, §18, §21 updated; v1.10.5: no section changes: Import Logs race fix; v1.10.4: §8, §13 single `~` is literal; §21 excerpts escape `~`; v1.10.3: §13 refusals; §21 declined runs; v1.10.2: §13 summary states its dates; §21 per-month derivations; v1.10.1: §13 `AI_MAX_TOKENS`, thinking and cut-off answers; v1.10.0: §21 AI Eval added; §13 `callClaude()` raw mode; v1.9.6: §14 folder access lasts until the last tab closes, not one page load; v1.9.5: §7 chart guard; v1.9.4: views-db.js changelog reads CRLF checkouts; v1.9.3: §14 folder permission: re-request on click, Reconnect banner, browser copy wins; v1.9.2: §0 branch, PR and approval workflow added; v1.9.1: §4.5 rehydrate transactions loaded from JSON; v1.9.0: §20 Data & Reset added; v1.8.1: §10 re-upload dedup rewritten for the occurrence-suffix fix; v1.8.0: AI Analysis restored with API-key and output hardening; `renderMarkdown()` added; `samples/` added; §1–3, §13, §14, §17 corrected to match the code)* --- diff --git a/README.md b/README.md index bec1306..8fffbe9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > A zero-install, browser-native finance dashboard. Drop in your bank CSVs, get instant clarity on your money. -**Version:** v1.18.1  ·  **Stack:** Vanilla JS, HTML, CSS  ·  **Install:** None  ·  **License:** [MIT](LICENSE) +**Version:** v1.18.2  ·  **Stack:** Vanilla JS, HTML, CSS  ·  **Install:** None  ·  **License:** [MIT](LICENSE) --- @@ -119,7 +119,7 @@ Total income, average per month, interest earned and refunds. Income by source a Total spent, average per day, top category and highest month. Categories ranked by spend: pick one to see its transactions. A daily spending heatmap, the monthly trend, top merchants, and **category rules** (keyword → category). **Re-categorize all** applies them to everything already imported, and the result is kept. A category you set yourself wins, transfers keep theirs, and a card transaction no rule matches keeps the category its CSV gave it. ### Transfers -Both legs of a transfer are paired automatically. A diagram shows where money moved between your accounts, and what left FinLens (e.g. Zelle payments). Filter the list by From and To account. **Transfer keywords** decide which unpaired transactions count as transfers; changing them re-runs detection right away. A type you set on a transaction wins: detection leaves that transaction alone, so one you set to Expense stays an expense and isn't paired. Categories you add to a detected transfer are kept. A transaction that stops being a transfer (you set another type, or remove its keyword) gets its own type and category back: a card's Category and Sale, Return or Payment from its file, otherwise the category it was imported with (your category rules first, then the built-in ones). The type decides what counts: a transaction that isn't a transfer counts as spending or income whatever its category, and the category Transfer (which the built-in rules give Zelle, Venmo, PayPal and similar payments) is just a label. To keep a transaction out of your totals, set its type to Transfer or add a transfer keyword. +Both legs of a transfer are paired automatically. A diagram shows where money moved between your accounts, and what left FinLens (a transfer you set by hand to an account you haven't imported). Filter the list by From and To account. **Transfer keywords** let two legs pair up to 2 days apart instead of 1; changing them re-runs detection right away. A keyword match with no other leg in your accounts (a Zelle payment to a friend, say) is money sent or received, so it counts as spending or income, as **Money sent** / **Money received** when it has no better category. Each one is listed under **To review** on the Transfers screen (and flagged **Review** in Transactions) until you keep it or mark it as a transfer. A type you set on a transaction wins: detection leaves that transaction alone, so one you set to Expense stays an expense and isn't paired. Categories you add to a detected transfer are kept. A transaction that stops being a transfer (you set another type, or remove its keyword) gets its own type and category back: a card's Category and Sale, Return or Payment from its file, otherwise the category it was imported with (your category rules first, then the built-in ones). The type decides what counts: a transaction that isn't a transfer counts as spending or income whatever its category, and the category Transfer (which the built-in rules give Zelle, Venmo, PayPal and similar payments) is just a label. To keep a transaction out of your totals (a move to an account you haven't imported), set its type to Transfer. ### Budget Pick a month with ‹ and ›. Each category shows a progress bar, what you spent, its budget and what's left; the pencil changes the amount right in the row, and the name opens the full edit (name, categories, delete). Alerts flag categories over budget or at 90% and up. A chart compares 6 months of spending with the total budget. **Use starter budget** fills in a budget per category from what you usually spend (the average of up to 3 recent complete months, rounded up to $10) and never replaces one you have. @@ -271,4 +271,4 @@ FinLens is released under the [MIT License](LICENSE). Copyright (c) 2026 Luis Ri --- -*FinLens v1.18.1 — Built for one person's finances, designed to be useful for anyone's.* +*FinLens v1.18.2 — Built for one person's finances, designed to be useful for anyone's.* diff --git a/index.html b/index.html index 73816dd..114b979 100644 --- a/index.html +++ b/index.html @@ -464,6 +464,7 @@ .date-block .dd{font-size:17px;font-weight:700;line-height:1.2} .pill.accent{background:var(--accent-soft);color:var(--accent-strong-text)} .flag{font-size:11px;font-weight:600;background:var(--accent-soft);color:var(--accent-strong-text);padding:2px 7px;border-radius:999px;white-space:nowrap} +.flag.review{background:var(--warn-soft);color:var(--warn-strong-text)} /* a one-sided transfer keyword match to review (v1.18.2) */ .tag{display:inline-block;font-size:12px;background:var(--muted);color:var(--text-body);padding:3px 8px;border-radius:6px;white-space:nowrap} .flags{display:flex;flex-wrap:wrap;gap:4px} /* tables in panels */ @@ -1258,7 +1259,7 @@
- + diff --git a/js/core.js b/js/core.js index 4753248..997c8ba 100644 --- a/js/core.js +++ b/js/core.js @@ -501,7 +501,7 @@ const State = { categories: lsGet('categories', null) || [ 'Food & Dining','Groceries','Housing','Transport','Entertainment', 'Health','Shopping','Bills & Utilities','Subscriptions','Personal', - 'Travel','Income','Transfer','Fees','Other' + 'Travel','Income','Transfer','Money sent','Money received','Fees','Other' ], apiKey: lsGet('apiKey', ''), aiHistory: lsGet('aiHistory', []), @@ -1136,6 +1136,18 @@ async function mergeImported(loaded, fresh) { // (detectTransfers()), and so does setting a type to Transfer. They mean nothing on a // transaction that isn't a transfer, so it never shows them (v1.16.26): ownCategories() // takes their place. withoutDirection(cats) is `cats` without them ([] when it isn't a list). +// Categories detectTransfers() gives a one-sided keyword match (v1.18.2): money sent to, or +// received from, someone outside your loaded accounts. They count as spending and income. +const SENT_RECEIVED = { out: 'Money sent', in: 'Money received' }; + +// True when `t`'s category is just "Money sent" or "Money received". Undoing detection then +// works its category out again with ownCategories(), which keeps a category you set or a +// category rule gives (even "Money sent" itself) and otherwise drops the one detection gave. +function isSentReceived(t) { + return Array.isArray(t.categories) && t.categories.length === 1 + && (t.categories[0] === SENT_RECEIVED.out || t.categories[0] === SENT_RECEIVED.in); +} + function withoutDirection(cats) { return Array.isArray(cats) ? cats.filter(c => c !== 'Transfer out' && c !== 'Transfer in') : []; } @@ -1183,6 +1195,9 @@ function ownCategories(t, ov, row, ruleCats = State.ruleCategories) { // its type and category are worked out as Clear transaction edits does. Changes `t` in place. function undoTransferDetection(t, row) { const ov = State.txOverrides[t.id] || {}; + // A one-sided keyword match that detection gave "Money sent" / "Money received" (v1.18.2) + // goes back to its own category, so a keyword change or a re-import can decide again. + if (isSentReceived(t)) { t.categories = []; t.categories = ownCategories(t, ov, row); } if (t.type !== 'transfer' || ov.type === 'transfer') return; const type = t._originalType || (row && row.type) || (t.amount < 0 ? 'debit' : 'credit'); t.type = ov.type || type; @@ -1298,13 +1313,31 @@ async function moveEarlierCopies(moves, read, fresh) { // Before v1.16.23 both passes marked every match and set its categories, whatever its edit, // so a ZELLE row typed as Expense became a transfer again at the next keyword change, import // or reload, and a category added to a detected transfer dropped at the next reload. -function detectTransfers() { - // Build keyword regex from user-editable list (falls back to defaults if empty) +// The transfer keywords as one case-insensitive pattern: the user's list, else the defaults. +function transferKeyRegex() { const kws = (State.transferKeywords && State.transferKeywords.length) ? State.transferKeywords : DEFAULT_TRANSFER_KEYWORDS; - const escapedKws = kws.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - const key = new RegExp(escapedKws.join('|'), 'i'); + return new RegExp(kws.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'i'); +} + +// A one-sided keyword match waiting for your answer (v1.18.2): not a transfer, its description +// has a transfer keyword (so detection found no second leg), and you haven't said what it is. +// Detection counts it as spending or income, but it may be a move to an account you haven't +// imported, so the Transfers screen lists it for review and Transactions flags it. Your answer +// is an edit: "It's a transfer" stores the Transfer type, "Keep" stores `reviewed: true`; any +// type you set in the drawer answers it too. Worked out every time, never stored, so it also +// holds after a boot from finlens-state.json (which doesn't run detection). A card payment +// (type 'payment') is never asked about: it already isn't income. +function needsTransferReview(t, ov, key) { + if (t.type === 'transfer' || t.type === 'payment') return false; + ov = ov || State.txOverrides[t.id] || {}; + if (ov.type || ov.reviewed) return false; + return (key || transferKeyRegex()).test(t.description || ''); +} + +function detectTransfers() { + const key = transferKeyRegex(); const ovs = State.txOverrides || {}; const mark = (t, dir) => { const ov = ovs[t.id] || {}; @@ -1331,10 +1364,20 @@ function detectTransfers() { break; } } - // Pass 2: mark unmatched transactions whose description screams "transfer" + // Pass 2 (v1.18.2): a keyword match with no other leg is NOT a transfer. Only a pair whose + // legs are both loaded nets to zero; one leg alone is money that left or arrived from outside + // your accounts ("ZELLE TO M TORRES"), so it counts as spending or income like any other row. + // Up to v1.18.1 it was marked a transfer, which left it out of income and spending: the six + // Zelle payments in samples/ hid $230.00 of spending. It keeps its own category ("PAYPAL + // *NETFLIX" stays Subscriptions); when that's only Other, or the Transfer label the built-in + // rules give every keyword (DEFAULT_RULES), it gets "Money sent" or "Money received". You can + // still set Transfer by hand (a move to an account FinLens doesn't have), and that sticks. for (const t of cands) { - if (used.has(t.id)) continue; - if (key.test(t.description)) mark(t, t.amount<0?'Transfer out':'Transfer in'); + if (used.has(t.id) || !key.test(t.description)) continue; + const ov = ovs[t.id] || {}; + if ((ov.categories && ov.categories.length) || ov.category) continue; + const own = ownCategories(t, ov); + if (own.length === 1 && (own[0] === 'Other' || own[0] === 'Transfer')) t.categories = [t.amount < 0 ? SENT_RECEIVED.out : SENT_RECEIVED.in]; } } diff --git a/js/views-main.js b/js/views-main.js index d0bc5bd..58b34a4 100644 --- a/js/views-main.js +++ b/js/views-main.js @@ -38,11 +38,40 @@ function pageActions(root, note, acts) { // Income, Spending, Bills and Transfers open their own registered views. const TX_PILLS=[['all','All','transactions'],['income','Income','income'],['spending','Spending','spending'], ['bills','Bills','bills'],['payments','Payments','transactions'],['transfers','Transfers','transfers']]; +let reviewKey=null; // transferKeyRegex() for one Transactions render (the Review flag) + +// One-sided keyword matches waiting for your answer, newest first (needsTransferReview(), core.js). +function transfersToReview() { + const key=transferKeyRegex(); + return State.transactions.filter(t=>needsTransferReview(t,null,key)).sort((a,b)=>b.date-a.date); +} + +// Your answer for one of them (v1.18.2). 'transfer': the Transfer type, with its direction as +// the category unless you set categories, as the drawer stores it; then transfer detection runs +// again, as for any type change. 'keep': it stays spending or income, and isn't asked again. +function answerTransferReview(id, answer) { + const t=State.transactions.find(x=>x.id===id); + if (!t) return; + const ov=State.txOverrides[id]||{}; + if (answer==='transfer') { + ov.type='transfer'; + if (!(ov.categories&&ov.categories.length)) ov.categories=[t.amount<0?'Transfer out':'Transfer in']; + t.type='transfer'; t.categories=[...ov.categories]; + } else ov.reviewed=true; + State.txOverrides[id]=ov; + lsSet('txOverrides',State.txOverrides); + if (answer==='transfer') redetectTransfers(); + renderActive(); +} + function renderTxPills(root, current) { const nav=document.createElement('nav'); nav.className='fpills'; nav.setAttribute('aria-label','Transaction filters'); - nav.innerHTML=TX_PILLS.map(([k,l,v])=>`${l}`).join(''); + // The Transfers pill shows how many one-sided matches wait for review (v1.18.2). + const toReview=transfersToReview().length; + const badge=(k)=>k==='transfers'&&toReview?` (${plural(toReview,'transaction')} to review)`:''; + nav.innerHTML=TX_PILLS.map(([k,l,v])=>`${l}${badge(k)}`).join(''); nav.addEventListener('click',e=>{ const a=e.target.closest('[data-pill]'); if (!a) return; @@ -601,16 +630,17 @@ registerView('transactions','Transactions',(root)=>{ DateDescriptionCategory AccountFlagsAmount - ${slice.length?slice.map(t=>{ + ${slice.length?(reviewKey=transferKeyRegex(),slice).map(t=>{ const cs=(t.categories&&t.categories.length?t.categories:[t.category]); const flags=[t.isBill&&'Bill',t.isRecurring&&'Recurring',t.isSubscription&&'Sub'].filter(Boolean); + const review=needsTransferReview(t,null,reviewKey)?`Review`:''; return ` ${dayjs(t.date).format('MMM D')} ${t.notes?`
${escapeHtml(t.notes)}
`:''}
${escapeHtml(cs.join(', '))} · ${escapeHtml(acctName(t.account))}${flags.length?' · '+flags.join(', '):''}
${cs.map(c=>`${escapeHtml(c)}`).join('')}
${escapeHtml(acctName(t.account))} -
${flags.map(f=>`${f}`).join('')}
+
${review}${flags.map(f=>`${f}`).join('')}
${fmtSigned(t.amount)} `; }).join(''):`

${empty}

`}`; @@ -1279,6 +1309,28 @@ registerView('transfers','Transfers',(root)=>{ const kind=(id)=>id?State.accounts[id]?.kind:null; const kws=(State.transferKeywords&&State.transferKeywords.length)?State.transferKeywords:DEFAULT_TRANSFER_KEYWORDS; + /* ---- one-sided matches to review (v1.18.2): every date, not only the range ---- */ + const review=transfersToReview(); + if (review.length) { + const rows=review.map(t=>{ + const out=t.amount<0, what=out?'spending':'income'; + return `
+
${escapeHtml(t.description)}
${dayjs(t.date).format('MMM D, YYYY')} · ${escapeHtml(acctName(t.account))} · ${escapeHtml(t.category)}
+
${fmtSigned(t.amount)}
+
`; + }).join(''); + appendHtml(root,`
+

To review ${review.length}

These have a transfer keyword but no matching leg in your accounts, so they count as ${review.some(t=>t.amount<0)&&review.some(t=>t.amount>0)?'spending or income':review[0].amount<0?'spending':'income'}. Keep the ones that really left (or came from) someone else. Mark a move to an account you haven’t imported as a transfer, so it stays out of your totals.

+ ${review.length>1?`
`:''}
+
${rows}
`); + const sec=$('#content [aria-labelledby="tfRevH"]'); + const next=(id)=>{ const after=$('#content [data-review-id]'); const el=after?after.querySelector('[data-review-keep]'):$('#tfKwH'); if (el){ if (!el.matches('button')) el.tabIndex=-1; el.focus({preventScroll:true}); } }; + $$('[data-review-keep]',sec).forEach(b=>b.onclick=()=>{ answerTransferReview(b.dataset.reviewKeep,'keep'); next(); }); + $$('[data-review-transfer]',sec).forEach(b=>b.onclick=()=>{ answerTransferReview(b.dataset.reviewTransfer,'transfer'); next(); }); + const all=$('#tfRevKeepAll'); + if (all) all.onclick=()=>{ review.forEach(t=>{ const ov=State.txOverrides[t.id]||{}; ov.reviewed=true; State.txOverrides[t.id]=ov; }); lsSet('txOverrides',State.txOverrides); renderActive(); next(); }; + } + if (pairs.length) { const paired=pairs.filter(p=>p.paired), out=pairs.filter(p=>!p.paired&&p.from&&!p.to); const sum=a=>a.reduce((s,p)=>s+p.amount,0); @@ -1355,7 +1407,7 @@ registerView('transfers','Transfers',(root)=>{
-

Transfer keywords

Unpaired transactions whose description contains one of these count as transfers, not spending.

+

Transfer keywords

Two opposite amounts in different accounts pair as a transfer up to 2 days apart when either description contains a keyword (1 day without). A match with no other leg is money sent or received: it counts as spending or income, under its own category or “Money sent” / “Money received”. For a move to an account you haven’t imported, set its type to Transfer.

${kws.map((k,i)=>`${escapeHtml(k)}`).join('')}

Changes re-run transfer detection on all transactions.

diff --git a/js/views-settings.js b/js/views-settings.js index 692c2e9..8360007 100644 --- a/js/views-settings.js +++ b/js/views-settings.js @@ -71,7 +71,7 @@ function _renderGeneralSettings(root) { ``).join('')} -
VersionFinLens v1.18.1
+
VersionFinLens v1.18.2
`); const opts=$$('[data-theme-opt]',root); opts.forEach((b,i)=>{