Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 14 additions & 6 deletions CLAUDE.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.*
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -1258,7 +1259,7 @@
<div class="folder-banner-wrap" id="folderBanner" hidden></div>
<div class="content" id="content"></div>
</main>
<footer class="appfoot">v1.18.1 · FinLens</footer>
<footer class="appfoot">v1.18.2 · FinLens</footer>

<!-- side drawer: transaction detail, bill and plan forms (ui.js openDrawer) -->
<div class="drawer-scrim" id="drawerScrim" onclick="closeDrawer()" hidden></div>
Expand Down
59 changes: 51 additions & 8 deletions js/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', []),
Expand Down Expand Up @@ -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') : [];
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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] || {};
Expand All @@ -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];
}
}

Expand Down
Loading