# Bill Tracker — Changelog
## v0.41.0

### 🚀 Express 4 → 5 (Phase 2) — modern framework + native async error handling

- **[Server] Upgraded Express 4.18 → 5.2.** The maintained line, and — the reason it's more than a version bump — Express 5 **forwards a rejected async handler to the error middleware automatically**, closing the class of gap where a throw inside an `async (req,res)` handler in Express 4 became an unhandled rejection that *hung the request*. (This is why the throwaway `asyncHandler` wrapper was never written — the framework now does it.) The migration surface was genuinely small: a scan found no removed APIs (`res.json(body,status)`/`req.param()`/`app.del`/`res.sendfile`/`redirect('back')` — none) and every `req.query` use is a read; the only breakage was three bare-`*` routes that path-to-regexp v8 rejects — `app.all('/api/*')`, `app.all(['/legacy','/legacy/*'])`, and the SPA catch-all `app.get('*')` — rewritten to the named-wildcard form (`/api/*splat`, `/legacy/*splat`, `/{*splat}`, the last verified to still match the root `/`). Full server suite (225, incl. the router-introspection route tests) + e2e probe (17/17: routing, auth, error shapes, cross-surface money recon, axe on 8 routes) green.

### 🛡️ Trust & Integrity — regression tests for the untested critical core (Phase 1)

- **[Tests] Locked in the highest-stakes, previously-untested services** — the review found that the code most catastrophic-if-broken had zero coverage. Added focused `node:test` suites (all green, no defects surfaced — the implementations were sound; this pins them against future regressions): **`encryptionService`** (the AES-256-GCM + HKDF crypto for bank tokens/2FA/SMTP/OIDC secrets + PII — round-trip on both key paths, GCM tamper rejection, legacy decrypt, env-key-required error, and the `reEncryptWithEnvKey` startup migration: migrate/skip/idempotent/corrupt-tolerant), **`authService`** (bcrypt password round-trip, hashed-not-raw session storage, the create/lookup/expire/rotate/invalidate/prune lifecycle, and `rotateSessionId` ownership enforcement), **`paymentValidation`** (dollars→cents, positive-amount + real-calendar-date rejection, the require* option matrix), **`billMerchantRuleService`** (the bank-sync payment *creator*: rule-match → one `provider_sync` payment, idempotent via match-status + the `UNIQUE(transaction_id)` index), **`spendingService`** budgets, and **`totpService`** (token verify + single-use recovery codes + single-use/expiring challenges). This is the de-risking prerequisite for the server-TypeScript migration. Suite 225 green. (WebAuthn full-verify deferred — needs an authenticator harness.)

### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint

- **[Client] Auth: `User` moved to its proper home + auth endpoints typed.** `User` lived oddly in `@/hooks/useAuth`; moved it to `@/types` (re-exported from `useAuth` so the 7 importers keep working) and typed `me`/`login`/`totpChallenge`/`hasUsers` with `get<T>`/`post<T>`, dropping those casts on the Admin + Login pages. *(Deliberate stop: the remaining single-consumer, non-money casts — `adminUsers` with its page-local `AdminUser`, and the one-off `version`/`about`/`privacy`/`health`/`importHistory` doc shapes — are left as-is; central types add ~nothing for a single reader, and typing them is the low-value tail. Likewise the **optimistic-UI/`toast.promise` rewrite** (planned Track D) is intentionally not done — it's behavior-visible churn on already-working handlers, the worst risk/reward of the remaining work.)*
- **[Server] Error-shape consistency (Track D).** The global 500 handler ([server.js](server.js)) returned `{ error: 'Internal server error' }` with no `code`; added `code: 'INTERNAL_ERROR'` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx.
- **[Client] Snowball projection + settings typed.** Promoted `SnowballProjection` (+ `SnowballProjectionDetail`/`AvalancheProjectionDetail`/`SnowballDebtProjection`) and `SnowballSettings` into `@/types`, and wired `snowball` (→ `Bill[]`), `snowballSettings`/`saveSnowballSettings` (→ `SnowballSettings`), and `snowballProjection` (→ `SnowballProjection`). Removed the projection/settings/bills casts. (The plan-lifecycle endpoints still cast to `SnowballPlan`, which is a component-owned type in `PlanHistoryPanel` — left as-is to avoid a cross-file type move for marginal gain.)
- **[Client] Subscriptions domain typed.** Promoted the `SubscriptionsPage` shapes into `@/types` (`Subscription`, `SubscriptionSummary`/`…TopType`, `SubscriptionsResponse`, `Recommendation` + `RecommendationEvidence`/`…Transaction`/`ExistingBillMatch`/`CatalogMatch`, `SubscriptionTx`) and wired `subscriptions`/`subscriptionRecommendations`/`subscriptionTransactionMatches`/`createSubscriptionFromRecommendation`/`matchRecommendationToBill` with `get<T>`/`post<T>`. Dropped the `(r: any)` in `useSubscriptionRecommendations` (now typed) and the 5 page casts; removed an unused `CatalogMatch` import (Track E).
- **[Client] Spending domain typed.** Promoted `SpendingPage`'s local response interfaces into `@/types` (`SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`/`…Response`, `SpendingIncomeTx`/`…Response`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse`) and wired `spendingSummary`/`spendingTransactions`/`spendingIncome`/`spendingCategoryRules`/`copySpendingBudgets`/`categoryGroups` with `get<T>`/`post<T>`. `useSpendingSummary`/`useSpendingTransactions`/`useCategoryGroups` now return typed data; the 6 spending `as {…}` casts are gone. Removed 2 now-unused type aliases the cast-removal orphaned (Track E).
- **[Client] Bank-ledger domain typed end-to-end.** Promoted the `BankTransactionsPage` local interfaces (`Tx`/`Account`/`Ledger`/`LedgerSummary`/`CategoryBreakdown`/auto-categorize preview) into `@/types` as `BankLedgerTransaction` (extends the existing `BankTransaction`, so raw amounts stay branded `Cents`), `BankAccount`, `BankLedger`, `BankLedgerSummary`, `BankCategoryBreakdown`, `AutoCategorizePreview`. Wired the endpoints in `api.ts` (`bankTransactionsLedger` → `get<BankLedger>`, `matchTransaction`/`unmatchTransaction` → `{ transaction }`, `ignoreTransaction`/`unignoreTransaction` → the transaction, `applyTransactionMerchantMatch`/`autoCategorizeTransactions` → their result shapes), so `useBankLedger`'s `data` is now typed and **all 9 `as {…}` casts in the page are gone** — a server field rename now fails in `@/types`, not silently at 9 call sites. Dropped a now-dead `Cents` import (Track E). typecheck/lint/build clean.

### 🧮 Cross-surface money reconciliation harness (Track B) — pins "same number, same truth"

- **[Money integrity] Added `tests/reconciliation.test.js` — the totals every surface shows for a month must agree.** The app has repeatedly shipped "same number, different truth" drift (QA-B5/B9) where the Tracker, Summary, Analytics, Bills, and bank ledger each gate a bill's monthly occurrence slightly differently — so an annual/off-month bill inflates one surface's "expected" but not another's. The harness hand-seeds the exact shapes that historically broke gating (a $900 **off-month annual** bill, a **quarterly** bill that *does* fall in the month, plus plain monthly bills with a full + a partial payment) and pins the invariants, **comparing in integer cents** (never post-`fromCents` floats, which drift on rounding): `Analytics.expected` == `Tracker.total_expected`, `Σ resolveDueDate-gated Bills` == `Tracker.total_expected`, `Summary.expense_total` (via the real `GET /summary` handler) == `Tracker.total_expected`, `Analytics.actual` == `Tracker.total_paid`, and — the linchpin — the off-month annual bill inflates **no** surface. All four money surfaces reconcile today (the QA-B5 fixes hold); the harness makes a future regression fail loudly. (Bank-ledger occurrence gating is already pinned by `trackerService.test.js`.) Full suite 193 green.

### 💰 Payment atomicity audit (Track A) — every balance mutation is now transactional

- **[Money integrity] Wrapped the four remaining non-atomic payment paths in `db.transaction()`.** Quick-pay and bulk-pay were already atomic (X1), but the audit found the INSERT/UPDATE + `applyBalanceDelta` pair split across two un-transactional statements on **manual `POST /payments`**, **autopay-suggestion confirm**, **`DELETE` (unpay)**, and **`POST /:id/restore`** — plus **`PUT` (edit)**, where the bill-balance write and the payment `UPDATE` weren't atomic. A crash/constraint failure between the two statements could leave a payment without its balance adjustment (or a balance reversed without the row deleted). Each is now a single transaction: the row write and the balance mutation land together or neither. Behavior-preserving — `tests/paymentsRoute.test.js` pins the create → delete → restore → edit balance round-trip and the double-restore no-op.
- **[Money integrity] Manual `POST /payments` now flags a suspected duplicate instead of blindly creating one — without silently dropping a legitimate one.** The deliberate manual-add path had *no* dedupe guard, so a double-submit created a second payment. But unlike the automated one-click paths (`/quick`, `/bulk`, autopay-confirm — which dedupe *silently* because a repeat there is a retry), the manual path may legitimately record two identical payments on the same day, so silently deduping would *lose a real payment* — itself a money bug. Resolution: the server returns **`409 DUPLICATE_SUSPECTED`** (with the existing payment attached) on a same `bill+date+amount`; a shared client helper (`client/lib/paymentActions.ts`, used by the tracker inline cell, the partial-payment ledger dialog, and the bill modal) turns that into an **"add anyway?"** toast that resends with `allow_duplicate`. Best of both: retry-safety on the auto paths, no lost-write on the deliberate one. (A DB `UNIQUE` index was rejected — it would forbid legitimately-identical payments; an idempotency-key header is the documented upgrade if this ever goes multi-process.) Full server suite 188 green; e2e probe 17/17. (Track A)

### 🔷 TypeScript foundation + branded money types

- **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig` → `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19.
- **[Client] Branded `Cents`/`Dollars` types** — converted `client/lib/money.js` → `money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100×-too-big bug — cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green.
- **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed), and a batch of pure leaf utilities — `reorder.ts` (generic `moveInArray<T>`), `billingSchedule.ts` (a `Schedule` union), `billDrafts.ts` (`SourceBill`/`Template`/`Category` shapes for `makeBillDraft`), `trackerTableColumns.ts`, and `cashflowUtils.ts` (typed SVG timeline geometry for the Safe-to-Spend card). The `.test.js` suites that import these resolve the `.ts` transparently and still pass (48 client tests). Also converted two small hooks — `useAutoSave.ts` (a generic `useAutoSave<T>` debounced-save hook with an `AutoSaveStatus` union) and `useSearchPanelPreference.ts` (a typed `[boolean, setter]` tuple) — and `version.ts` (release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline).
- **[Client] Typed the API client (`client/api.js` → `api.ts`) — the fetch layer's infrastructure** — the central request layer is now TypeScript: a generic `_fetch<T>` and `get`/`post`/`put`/`patch`/`del<T>` helpers, an `ApiError` interface carrying the server's `status`/`code`/`details`/`data`, a `QueryParams` type for the query-string builder, and `File`-typed upload helpers. Endpoint **response** shapes are typed incrementally — most methods resolve to `Promise<unknown>` for now (callers narrow), with the handful already consumed by typed `.ts` files given real shapes: `quickPay` → `PaymentRecord` (`id`), `togglePaid` → `TogglePaidResult` (`paymentId?`), `settings` → a settings map. Doing so immediately surfaced a **latent narrowing bug** in `usePaymentActions`: the un-pay Undo closure read `result.paymentId` (now typed `number | undefined`) inside a deferred async callback where TS correctly re-widens the `if`-narrowed value — fixed by capturing the id in a const. The 16 files that imported `@/api.js` with an explicit extension were normalized to `@/api` so Vite/TS resolve the `.ts`. Verified end-to-end: typecheck + lint + build + 48 client tests, and the **17/17 e2e probe** (every page renders, all API paths respond) confirms the central-module rename is runtime-safe.
- **[Client] Typed the React Query hooks (`hooks/useQueries.js` → `.ts`) — and it caught dead config** — the shared `useTracker`/`useBills`/`useSummary`/`useSpending*`/`useSnowball*`/`useBankLedger`/etc. hooks are now TypeScript (typed params + `QueryParams`-typed query builders, exported from `api.ts`). Converting surfaced that three hooks passed **`cacheTime`** to `useQuery` — an option **React Query v5 renamed to `gcTime` and silently ignores** (so those caches were being GC'd at the 5-min default, not the intended 30 min / 2 hr). TypeScript rejected the unknown property; renamed to `gcTime` so the intended cache-retention actually takes effect (memory-retention only — no fetch/correctness change). Query result data is `unknown` until the endpoint response shapes are typed. Verified: typecheck + lint + build + 48 tests + 17/17 e2e probe.

- **[Client] Phase A — typed the API response shapes with branded money** — new `client/types.ts` domain module: every money field branded (`Dollars` for bill/payment/tracker/summary amounts; **`Cents` for raw bank-transaction amounts, which stay cents on the wire** — the complement that keeps `formatCentsUSD` and `formatUSD` from being swapped). Reverse-engineered from the server serializers: `TrackerResponse` (summary/bank_tracking/cashflow/rows), `Bill`, `Payment`, `Category`, `DriftBill`, `AutopaySuggestion`, `AmountSuggestion`, `AutopayStats`, `BankTransaction`, `TimelineBill`. `api.tracker/bills/payments/categories/…` now return these, so the brand flows from the fetch layer into `trackerUtils` (row money upgraded from `number` → `Dollars`; `paymentSummary` re-brands via `asDollars`).

- **[Client] Phase B — converted every component + page `.jsx` → `.tsx` (COMPLETE — `find client -name '*.jsx'` returns 0)** — done: **all 22 `ui/*` primitives** + `ThemeContext`; the **entire `components/tracker/` dir** (18 files — the home page) where `fmt(row.expected_amount)` now type-checks against `Dollars`; the **entire `components/bill-modal/` dir + `BillModal.tsx`** (the 1092-line add/edit form reached from 9 entry points); the **app shell** (`App`/`main`/`useAuth`/`Layout`/`Sidebar`/routing; `index.html` entry → `main.tsx`); app-wide commons (`ErrorBoundary` class, `PageTransition`, `StatusBadge`, `MarkdownText`, `SummaryCard`, `ReleaseNotesDialog`); the `snowball/` + `transactions/` dirs; the **entire `components/admin/` dir** (11 cards incl. the OIDC/auth-methods form, bank-sync, backup manager); the **entire `components/data/` dir** (14 files — the Data page's SimpleFIN connect/sync workbench, transaction-matching, and the CSV / OFX / XLSX-spreadsheet / SQLite import flows, with `Cents`-branded bank-transaction amounts flowing through); and several pages. **A required infra fix:** Tailwind's `content` glob was `./client/**/*.{js,jsx}` — it never scanned `.tsx`, so any class used *only* in a converted file generated **no CSS** (the release-notes dialog's `left-[50%] top-[50%]` centering vanished → modal rendered off-screen). typecheck/build/48-tests all passed; only the **e2e probe** caught it. Fixed to `{js,jsx,ts,tsx}`. **The final page batch** finished the job: the 5 largest/most-stateful pages — `SnowballPage`, `BankTransactionsPage` (the `Cents`-based bank ledger, with a local `Tx` structurally compatible with `BankTransaction` so it still satisfies `MatchBillDialog`), `SpendingPage` (a `RealCatEntry` narrowing splits the budget-indexed real categories from the `null`/`'other'` pseudo-rows), `BillsPage`, and `SubscriptionsPage` (1814 lines — `useOptimistic<Subscription[]>` reducer, the virtualizer's `FlatItem` discriminated union, and drag-reorder typed via the shared `DragProps`/`MoveControls`). Every page: local domain interfaces, React Query `setQueryData<T>` for the optimistic caches, cast-at-boundary for `api.*` (`Promise<unknown>`) results, and the local `fmt(v: number|null|undefined)` branded-`Dollars` wrapper so page money still type-checks. Verified: `npm run typecheck` (0) + `npm run lint` (0 errors) + `npm run build` + the 17/17 e2e probe (incl. axe on `/`, `/spending`, `/bills`, `/snowball`, `/summary`, `/analytics`, `/categories`, `/data`).

### 🏆 What the TypeScript migration caught (real defects, not just types)

- **The cents/dollars bug class is now unrepresentable** — the whole point of the branded `Dollars`/`Cents` types: a typed caller **physically cannot** format cents as dollars (the 100×-too-big bug, cf. QA-B9-01) or pass a bank-transaction amount to `formatUSD`. A bare number at a `fmt()` boundary is a compile error.
- **Dead React Query v5 config** — three hooks passed `cacheTime`, silently ignored since the v5 `gcTime` rename; caches were GC'd at the 5-min default instead of the intended 30 min / 2 hr. Fixed.
- **A latent narrowing bug** — the un-pay Undo closure in `usePaymentActions` read `result.paymentId` inside a deferred async callback where TS correctly re-widens the `if`-narrowed value; captured the id in a const.
- **A dead prop** — `App` passed `mainContentId` to `TrackerPage`, which takes no params. Removed.
- **A `never`-typed context** — `createContext(null)` made `useContext`'s post-guard return `never`, so `setTheme` (and the auth context methods) were uncallable. Typed the context value.
- **A CSS build gap invisible to typecheck/build/tests** — the Tailwind `.tsx` glob omission above; the **e2e probe** was the only gate that caught it, reinforcing that a passing build ≠ runtime-safe for a `.tsx` rename touching many files.
- **Dead code with broken calls** — `PrivacyAdminCard` was imported nowhere yet called non-existent `api.privacySettings`/`setPrivacySettings` (the geolocation toggle actually lives on `/profile/settings`). Deleted rather than converted.
- **A missing import that crashed a live view** — `ImportTransactionCsvSection` rendered `<CountPill>` **6×** (in the CSV preview + results panels) but never imported it. In the `.jsx` this is a `ReferenceError: CountPill is not defined` the moment you preview a CSV — a runtime crash `react/jsx-no-undef` wasn't configured to catch, but `tsc` flagged immediately as `TS2304: Cannot find name 'CountPill'`. Added the import. (Same file also carried two genuinely-unused imports — `CountPill`/`fmt` in the XLSX section — that TS surfaced and we dropped.)
- **A dead `total` state on the Spending page** — `IncomeSection` kept a `const [total, setTotal] = useState(0)` and dutifully `setTotal(d.total || 0)` on every income-page load, but nothing ever read `total` — a pointless render-triggering write. The `noUnusedLocals`-adjacent lint surfaced it during the `.tsx` conversion; removed the state and its setter. (The rest of the final 5-page batch type-checked clean on the first or second pass — a good signal the pages were already sound.)

### 🧪 Money-service test coverage

- **[Tests] Covered two untested money-critical services** — the manual-vs-bank payment source-of-truth (`paymentAccountingService`) and the analytics month-window math (`analyticsService`) had no dedicated tests. Added `tests/paymentAccountingService.test.js` (bank-backed predicate, the accounting-active SQL fragment, and the core invariant: a bank payment overrides a provisional manual payment so it isn't double-counted, restores the balance, and the manual payment counts again with its balance re-applied when the override is removed) and `tests/analyticsService.test.js` (month key/label/addMonths/monthEndDate/buildMonths with year-boundary + leap-year edges, and `validateSummaryQuery` defaults/range errors). (Tracker X2)

### ⚡ Tracker performance

- **[Tracker] Killed the getTracker N+1 (was ~2–3 DB round-trips × N bills every home-page load)** — inside `bills.map`, `getTracker` ran a payments query per bill (`fetchPaymentsForBillCycle`) plus `computeAmountSuggestion` per bill, and the suggestion alone fired up to 12 queries per bill (6 months × 2) — roughly 70–450 queries for a 35-bill account on every Tracker load. Now one query fetches all bills' cycle payments (grouped in JS by each bill's own range) and two queries compute all amount suggestions (`computeAmountSuggestionsBatch`), replacing the per-bill loops. Behavior-preserving — `tests/amountSuggestionService.test.js` pins the batched suggestion to be byte-identical to the per-bill function, and the `trackerService` tests still pass unchanged. (Tracker P1)

### ⚛️ React Query — deeper adoption

- **[Client] Completed the React Query adoption with global error handling, prefetching, and mutations** — building on the page migration: (1) a global `QueryCache.onError` handler surfaces a toast when a *background refetch* fails while stale data is on screen (pages still render inline errors on the initial load), restoring the load-error feedback centrally; (2) `usePrefetchTracker()` warms the adjacent month's cache on month-nav hover/focus, so clicking prev/next is instant; (3) the quick-pay action — previously duplicated verbatim across the desktop and mobile tracker rows — is now a shared `useQuickPay()` `useMutation` hook (its `isPending` replaces a manual busy flag, and the tracker/badge caches invalidate on settle). (React Query mutation follow-ups: pay/skip and other writes can adopt the same hook pattern incrementally.)

### ⚛️ React Query migration (all manual-fetch pages)

- **[Client] Moved the 7 remaining manual-fetch pages onto React Query** — Analytics, Bills, Subscriptions, Summary, Snowball, Spending, and Bank transactions each fetched with hand-rolled `useEffect` + `load()` + `useState(data/loading/error)`. Migrated them all to `useQuery` hooks (in `client/hooks/useQueries.js`) whose keys encode the params, so React Query now provides caching, request dedup, cancellation, and out-of-order-response safety for free — the manual request-sequence guards added earlier were removed. `keepPreviousData` keeps the last result visible while a new month/filter/page loads. Mutations that previously called `load()` now `invalidateQueries`, and optimistic edits (list reorder/delete, categorize, budget/plan changes) write through `queryClient.setQueryData` wrappers so every existing call site works unchanged. Editable form fields seeded from the data (Summary starting-amounts/income, Snowball settings, Spending budgets) are now seeded via a data-synced effect; pagination (Spending/Bank) is a page-keyed query. Bonus: because Bills reuses the shared `['bills']` cache, bill mutations there now also refresh the Tracker/overdue badge live. Each page was its own commit, verified with `npm run lint` (0 errors) + `npm run test:client` + `npm run build`. (R5)

### ✅ React correctness audit + ESLint enforcement

- **[Client] Added ESLint + react-hooks enforcement and fixed everything it found** — there was no linting at all, so nothing enforced the two rules that catch real React bugs across 125 components/pages: **rules-of-hooks** (conditional-hook crashes) and **exhaustive-deps** (stale closures). Added an ESLint 9 flat config (`eslint.config.mjs`) with `eslint-plugin-react-hooks`/`react-refresh`, an `npm run lint` script, and wired it into `npm run ci`. First run: **0 rules-of-hooks violations** (clean), and the rest fixed:
  - **Real latent bugs (were runtime-throwing / dead):** `ImportTransactionCsvSection` called `importErrorState()` without importing it (its own error handler would `ReferenceError` on a failed import); a dead duplicate `client/components/MobileTrackerRow.jsx` (unused, with undefined-setter refs) was deleted; `StatusPage`'s `dbOk` had a dead `?? true` branch; plus orphaned dead logic (an unrendered autopay-badge `useMemo`, an unwired categories keyboard handler).
  - **All 13 `exhaustive-deps` fixed**, incl. a real stale-value bug: `BankSyncSection`'s save read `btLateGraceDays` outside its `useCallback` deps, so it could persist a stale late-grace value. Others stabilized derived arrays/objects feeding `useMemo` (TrackerPage `filters`/`rows`, HealthPage `bills`, BankTransactionsPage `transactions`) and documented intentional id/filter-scoped effects.
  - **Out-of-order response guards (R3):** the month/filter-driven loaders on Analytics, Summary, and Spending now use the same request-sequence guard as the Bank ledger, so a slow response for old params can't overwrite fresher data on rapid navigation.
  - Verified-clean (no change needed): event-listener cleanup is balanced everywhere, the always-mounted dialogs' `open`-guarded reset effects are correct, and the CSV preview's index-keys are fine (read-only sample). *(The larger React Query migration of the 7 manual-fetch pages remains as a follow-up enhancement — the async-race correctness is already handled.)*

### 🧹 Bill modal decompose

- **[Bill modal] Broke the 1,733-line God-component into focused sub-components** — `BillModal.jsx` did bill fields + payment CRUD + linked-transaction unmatch + merchant rules + templates + debt/autopay all in one file. Extracted seven presentational components under `client/components/bill-modal/` (`DebtDetailsSection`, `AutopayTrustIndicator`, `PaymentHistoryList`, `PaymentFormFields`, `UnmatchDialogs`, `LinkedTransactionsSection`, `TemplateSection`) plus a shared `transactionDisplay.js` helper module, threading state/handlers via props. State stays in the parent (the save action is unchanged), so it's **behavior-preserving** — each extraction was its own commit, verified with `npm run build` + `npm run test:client`. BillModal is now **1,090 lines (from 1,733, −37%)** and the big interwoven blocks (payments, linked transactions, unmatch flows, debt) are each their own testable file. (Tracker BM2)

### 🎨 Tracker summary cards

- **[Tracker] Fixed the two identical "Paid" boxes + surfaced Remaining + month-progress** — the summary row rendered `SummaryCard type="paid"` **twice** (this month and last month), visually indistinguishable, while the actionable *remaining* number only lived in the CashFlow card below. Now the previous-month figure is a muted **"Last month: $X" hint** under Total Paid (not a second green box), and a **Remaining** card (the existing-but-unused blue card type) surfaces `summary.remaining` when there's no bank hero (in bank mode the hero already shows the projected remaining, so it isn't duplicated). Added a compact **"$X of $Y paid · N bills left"** progress line under the cards. Respects the `tracker_show_summary_cards` setting; light/dark + a11y unchanged. *(The Playwright visual baseline for `/` may need a refresh via `npm run test:e2e:update` since the card row changed intentionally.)* (Tracker T3)

### 🚀 Tracker modern UX

- **[Tracker] Optimistic pay/skip + `toast.promise` on long syncs** — marking a bill paid/unpaid now flips the row **instantly** (optimistic local state) and rolls back on error, instead of waiting for the server round-trip before updating — on both the desktop and mobile rows. And the bank sync (Tracker) and the bill-modal "Sync" now use sonner's `toast.promise` — one toast that transitions loading → done → error, replacing the manual spinner-flag + separate success/error toasts. (Tracker M1/M2)

### ✨ Tracker features & toast quality

- **[Tracker] "Pay all due" per bucket + reversible quick-pay with specific toasts** — each bucket header now has a **Pay all due (N)** action that records a payment for every unpaid, occurrence-gated bill in that bucket in one `bulkPay` call, behind a confirm dialog (showing the count + total) and a single **Undo** toast that deletes the just-created payments. Quick-pay (the inline "Add" on a row) and mark-paid now show a **specific** toast ("Rent — $1,200 paid") and quick-pay gained an **Undo** action to match un-pay — it was the only reversible action without one. (Per-bill snooze on overdue rows already exists in the Overdue Command Center.) (Tracker T4)

### 🧹 Tracker cleanup

- **[Tracker] Consolidated the "paid or autodraft = done" check + tidied a few spots** — the settled-status test (`status === 'paid' || status === 'autodraft'`) was copy-pasted across the server and client; added a single `isPaidStatus(status)` (+ `PAID_STATUSES`) to `services/statusService.js` and a matching `isPaidStatus` to `client/lib/trackerUtils.js`, and routed the unambiguous call sites through it (`trackerService`, `StatusBadge`, `CalendarPage`, and `rowIsPaid`) — the intentionally paid-*only* counts (`count_paid`, `count_autodraft`) are left distinct. Also replaced two inline `Math.max(r.balance || 0, 0)` sums in `getTracker` with the existing `rowOutstanding` helper, and gave the Tracker's display-settings load a quiet toast on failure instead of a silent swallow. Behavior-preserving; full server + client suites green. (Tracker T5)

### 🐛 Tracker & bill-modal hardening

- **[Bill modal] Correctness + error/toast + validator cleanup** — several small fixes in `BillModal`: `handleBlur` used positional guessing that defaulted every unmapped field to `interestRate`'s value (latent, masked by inline validators) — now takes the field value explicitly; the three copy-pasted money validators collapsed into one shared `validateNonNegativeMoney(val, label)` in `client/lib/money.js` (the expected-amount message also went from "positive number" to "non-negative", since 0 is allowed); the save action's duplicate due-day/interest-rate re-validation (which re-checked with toasts what `validateForm` already field-validated) was removed; the save/deactivate/verify-autopay `toast.error(err.message)` calls got fallbacks so a missing message never shows "undefined"; and the save toasts now name the bill ("Rent added" / "Rent updated"). Tests: `client/lib/money.test.js` covers the shared validator. (Tracker BM1)
- **[Tracker] Route error handling + autopay write atomicity** — `routes/tracker.js` had no try/catch and returned a plain `{error}` shape unlike the rest of the API; its three GET handlers now wrap in try/catch and return `standardizeError` (`{error, message, field, code}`), including the invalid-month validation path. Separately, `applyAutopaySuggestions` (which runs on a `GET /tracker`) inserted the auto-mark-paid payment and applied the balance delta as two un-transactional steps — a mid-way failure could leave a payment without its balance adjustment. Wrapped the INSERT + `applyBalanceDelta` in a single `db.transaction`. Covered by new cases in `tests/trackerService.test.js` (auto-mark creates one payment + drops the balance, idempotent on re-load; route returns the standardized error). (Tracker T2)
- **[Tracker] Sidebar overdue badge (and drift/bills) went stale after row actions** — the app never called `queryClient.invalidateQueries`; a Tracker mutation only `refetch()`'d the single tracker query passed down as `refresh`. So after paying/skipping/editing a bill, the sidebar **overdue badge** (`['overdue-count']`, 2-minute staleTime) kept its old number for up to two minutes — you could clear your last overdue bill and still see "3" — and the drift report / bills list didn't update either. Added a `useInvalidateTrackerData()` hook (invalidates `tracker` + `overdue-count` + `drift-report` + `bills`) and wired it in place of the bare `refetch` handed to the rows, `BillModal.onSave`, bank-sync, reorder, and the payment/late-attribution handlers, so the whole shell updates live. (Tracker X3)
- **[Notifications] "Reminder days before" was a dead setting — the notifier ignored it** — every bill has a `reminder_days_before` column (default 3) and the bill modal exposed a "Reminder Days" control for subscriptions, but `services/notificationService.js` used a hard-coded schedule (early reminder always at exactly 3 days out) and never read the column. A user who set "remind me 7 days before" still only got the fixed 3-day/1-day/today reminders. The early reminder now fires at the bill's own `reminder_days_before` lead (only when ≥ 2 days, so it never collides with the 1-day/same-day reminders), and the email subject + body say "due in N days" using that value. The lead-time selection was pulled into a pure, exported `reminderTypeFor(bill, diffDays)` so it's unit-tested directly (`tests/notificationLeadTime.test.js`) — default 3 stays backwards-compatible. The **"Reminder Days Before" control now shows for every bill** (not just subscriptions), and saving a non-subscription bill no longer clobbers the column back to 3. (Tracker BM3)
- **[Bill modal/SimpleFIN] Importing bank payments didn't refresh the payment list or the Tracker** — the two flows in the bill modal that *create* payments — **Sync** (`syncBillSimplefinPayments`) and a **merchant-rule historical import** (`onRulesChanged` → `importHistoricalPayments`) — only reloaded the linked-transactions list, unlike the unmatch handlers which correctly reload payments *and* linked transactions *and* call `onSave`. So after importing, say, 3 payments from bank history, the modal's Payment History stayed stale and the Tracker row behind it kept showing "due/overdue" even though the bill was now covered — until you closed and reopened. Both paths now `await Promise.all([loadPayments(), loadLinkedTransactions()])` then `onSave?.()`, matching the unmatch pattern, so imported payments appear immediately and the Tracker updates live. (The SimpleFIN *search/preview/candidate* flow was already correct.) (Tracker BM4)
- **[Tracker/SimpleFIN] Bank card's "unpaid this month" and "remaining" over-counted off-month bills** — `buildBankTracking` (`services/trackerService.js`) summed `expected_amount` for *all* active unpaid bills via SQL with no occurrence gate, so an annual or off-month quarterly bill inflated `unpaid_this_month` (and therefore the bank `remaining`) even though the Tracker rows beside it correctly excluded it — the same class of bug as QA-B5-02, still live on the bank path. `getTracker` now derives the unpaid total from the already-gated rows (via `resolveDueDate`), netting partial payments, and passes it into `buildBankTracking`. Also made `summary.remaining` / `total_remaining` use the bank card's own remaining when bank tracking is on (they previously used manual starting-amount math even in bank mode, disagreeing with safe-to-spend), and switched a stray `balance / 100` to `fromCents`. New test file `tests/trackerService.test.js` covers the gating fix, summary totals, the bank-mode remaining agreement, cents↔dollars integrity, and `getOverdueCount` gating — the dense Tracker aggregation had no dedicated tests before. (Tracker T1)
- **[Payments] Quick-pay could create duplicate payments and double-drop the balance** — `POST /api/payments/quick` (the one-click "pay" behind every Tracker row) had **no duplicate guard** and its INSERT + balance update weren't atomic, unlike `POST /api/payments/bulk`. A double-click, a retry, or two open tabs made a *second* payment for the same bill/date/amount and applied the balance drop twice; a failure between the INSERT and the balance write left a payment with no balance adjustment. Quick-pay now checks the same `bill_id + paid_date + amount` composite key (returning the existing payment idempotently, HTTP 200) and wraps the INSERT + `applyBalanceDelta` in a single `db.transaction`. A different amount on the same day is still a legitimate new payment. Test: `tests/paymentsQuickRoute.test.js`. (Tracker X1)

### ✨ Data page overhaul

- **[Data] Redesigned the Data page (modern, goal-oriented, 2026)** — replaced the dense tabbed/collapsible-card layout with a settings-style **two-pane** shell: a sticky goal-nav (Bank sync · Transactions · Import · Export & backups) beside one active pane, collapsing to a segmented control on mobile. Adds a **connection hero** with five distinct states (loading / server-disabled / error+retry / not-connected / connected±needs-attention) so a network blip is never mistaken for "not connected" and a server without SimpleFIN never shows a dead Connect button; **`?section=` deep-linking** (URL source of truth, back-button friendly, migrates the old tab key); plain-language titles + icons; a live **"N to review"** badge and **health dot** on the nav; at-a-glance transaction count + "syncs automatically" reassurance; **lazy-loaded** import panes; reduced-motion-aware transitions; and command-palette section links. `/data` is now covered by the authed axe sweep (zero critical/serious). Shell only — every section component (and the SimpleFIN sync buttons) is reused unchanged. `SectionCard` chrome modernized (icon chip, sentence-case titles).
- **[Import] OFX / QFX transaction import** — many banks export `.ofx`/`.qfx`; you can now import them (OFX 1.x SGML, OFX 2.x XML, and QFX). `services/ofxImportService.js` parses to the same normalized shape as the CSV path and reuses the shared session + dedupe (`user_id, data_source_id, provider_transaction_id`) + `import_history` primitives. Routes `POST /api/import/ofx/{preview,commit}`; UI in the Import pane (upload → preview → import). Test `tests/ofxImportService.test.js`.
- **[Export] Richer export — JSON + date ranges** — `GET /api/export` now accepts a `from`/`to` date range (in addition to `year`) for CSV or JSON, and there's a new `GET /api/export/user-json` full portable JSON export (same assembly as the SQLite/Excel exports). The Download pane gains a JSON card and a filtered "Payments export" (date range + CSV/JSON). Test `tests/exportRicher.test.js`.
- **[Data] "Erase my data" (danger zone)** — a self-service reset: `POST /api/user/erase-data` (rate-limited, type-to-confirm) transactionally wipes your financial + imported data (bills, payments, transactions, bank connections, categories, imports, rules) while **preserving your account, login, 2FA and preferences**, then re-seeds default categories and audits the action. Red-accented card in Export & backups with a type-to-confirm dialog. Test `tests/eraseUserData.test.js` (wipes only the requesting user, preserves others + the account).

### 🔍 Snowball review

- **[Debt] Snowball page surfaces projection failures + smaller polish** — the payoff-projection panel silently swallowed fetch errors (`catch { /* non-fatal */ }`), so a failed load showed nothing with no way to recover; it now shows a "Couldn't load … Try again" state (and a subtle "showing the last result" banner when a *refresh* fails). `loadProjection` now uses the amount currently typed (not just the last-saved value), matching the debounced live preview. The over-50-years banner now reads "one or more debts won't pay off at this rate" (accurate for the unpayable-debt case too), and the extra-payment validation copy says "non-negative" (0 is allowed). (Snowball review #2, #3, #8; #9 was already handled by the input's on-blur auto-save.)
- **[Debt] Payoff projection no longer fakes a "freedom" date when a debt can't be paid off** — if a debt's minimum payment never overcomes its interest and the rolling snowball can't cover it either, it never reaches $0. The simulation previously excluded that debt from `months_to_freedom` (a `Math.max` over payoff months treated "never" as 0), so it reported the *other* debts' last month as your payoff date — misleadingly optimistic. Now `months_to_freedom`/`payoff_date` are `null` when any active debt never clears, the result is flagged `capped`, and each such debt is marked `never_paid` (`services/snowballService.js`, `services/aprService.js`). (Snowball review #1)
- **[Debt] Snowball plan API: consistent errors, less duplication, no N+1** — the four plan-lifecycle endpoints (pause/resume/complete/abandon) were near-identical copies and returned a plain `{error}` shape unlike the rest of the API; folded them into one `transitionPlan` helper returning `standardizeError` `{message, code}` with proper state guards + ownership scoping. `enrichPlanWithProgress` fetched each snapshot bill in its own query (`GET /plans` = plans × debts queries) and wasn't user-scoped — now one `WHERE id IN (…) AND user_id = ?` batch. Test: `tests/snowballPlanRoute.test.js`. (Snowball review #4, #6, #7)
- **[Debt] Added the missing test coverage for all payoff math** — the debt-payoff engine (`calculateSnowball`/`calculateAvalanche`/`calculateMinimumOnly`/`amortizationSchedule`/`monthsToPayoff`/`debtAprSnapshot`) had **zero** automated tests despite being the most math-heavy code in the app. Added `tests/snowballMath.test.js` (12 tests) with hand-calculated examples (0% and 12% APR amortization rows, snowball rolling, avalanche-beats-snowball interest, skip reasons, APR breakdown) and the unpayable-debt edge above. (Snowball review #5)

### 🐛 QA Fixes

- **[SimpleFIN] Purging a soft-deleted bill orphaned its matched transactions** — found on the live SimpleFIN DB: 3 transactions were `match_status='matched'` with `matched_bill_id=NULL`. Root cause: bills are soft-deleted (retained for recovery), then the retention GC (`pruneSoftDeletedFinancialRecords`, `services/cleanupService.js`) hard-deletes them past the 30-day window. `transactions.matched_bill_id` is `ON DELETE SET NULL`, so the purge nulled the pointer but left `match_status='matched'` — a limbo row **excluded from spending/analytics (`match_status != 'matched'`) yet attributed to no bill**, silently dropping that spend. The purge now releases those matches back to `'unmatched'` in the same transaction and self-heals any pre-existing orphans; retention behaviour is unchanged. Verified on a copy of the live DB (3→0 orphans, 0 transactions lost). Regression: 3 tests in `tests/backupAndCleanup.test.js`. (QA-B5-04)
- **[Security] SQLite DB was created world-readable (644)** — `docker-entrypoint.sh` locked the data *directory* (`chmod 700`) but never the DB *file*, and SQLite created `bills.db`/`-wal`/`-shm` under the default umask (644). On a real deploy the DB (financial data + encrypted SimpleFIN token, sessions, SMTP/OIDC secrets) was world-readable, shielded only by the parent dir's 700. Added `umask 077` to the entrypoint (DB, WAL/SHM, backups, exports now created 600/700) plus an explicit `chmod 600` for pre-existing files on upgrade. (QA-B16-02)
- **[Privacy] The version check is now opt-out-able** — the privacy policy described the external version check as "optional", but there was no way to disable it (it hit a hardcoded upstream host whenever the About/Status/version page loaded). Added an **admin toggle**: an `update_check_enabled` setting gates the request in `services/updateCheckService.js` (default on — when off, **no external request is made**), exposed via `GET`/`PUT /api/about-admin/update-check-setting` and a switch on the admin **System Status** page. Privacy policy updated to state an admin can disable it. Test: `tests/updateCheckOptOut.test.js`. (was QA-B16-01)
- **[Security] Bill name could inject HTML into reminder emails** — `buildEmailHtml` (`services/notificationService.js`) escaped the bill name in the detail table but interpolated it **raw** into the reminder message line (`<strong>${bill.name}</strong> is due…`), so a bill named `<img src=x onerror=…>` landed unescaped in the email HTML. Self-XSS (reminder emails go to the bill's owner), but a clear inconsistent-escaping bug — now escaped everywhere. Covered by `tests/notificationDelivery.test.js`. (was QA-B14-04)
- **[Notifications] "Send test push" was completely broken** — `services/notificationService.js` attached its `_push` export (the ntfy/Gotify/Discord/Telegram helpers) *before* the final `module.exports = {…}`, which clobbered it, so `require('…/notificationService')._push` was `undefined`. `routes/notifications.js` (`const { sendTestPush } = require(…)._push || {}`) therefore always hit `throw 'Push service not initialised'` → **`POST /api/notifications/test-push` always returned 500** for every user testing their push channel. Scheduled reminders were unaffected (they call `sendPushToUser` in-scope). Moved the `_push` assignment after the reassignment. Covered by `tests/notificationDelivery.test.js` (per-channel payloads, dispatch, error handling, and a check that the auth token never leaks into the message body). (was QA-B10-01)
- **[Summary/Analytics] Non-monthly bills were counted in every month** — the Summary expense list/total and the Analytics "expected vs actual" line both counted annual (and off-month quarterly) bills for months they weren't due, over-stating the obligation and disagreeing with the Tracker (e.g. a yearly insurance bill inflated every month). Both `routes/summary.js` and `services/analyticsService.js` now gate bills by `resolveDueDate` — the same occurrence check the Tracker uses. Guarded by Tracker↔Summary and Tracker↔Analytics reconciliation checks in `e2e/api.probe.spec.js`. The SimpleFIN bank-tracking `unpaid_this_month` metric had the same gap and is fixed the same way (fetch + JS `resolveDueDate` filter, since SQL can't call it), covered by `tests/summaryBankTracking.test.js`. (was QA-B5-01, QA-B5-02, QA-B5-03)
- **[Data] Seed Demo Data amounts were 100× too small** — `scripts/seedDemoData.js` inserted demo dollar amounts straight into `bills.expected_amount` / `current_balance` / `minimum_payment`, which became integer-cents columns in the v1.03 migration, so a seeded "$85" bill showed as $0.85 (a whole demo month totalled ~$32 instead of ~$3,200). Now wraps demo values in `toCents()` before insert. Regression guard added in `e2e/api.probe.spec.js`. (was QA-B9-01)
- **[Bills] `expected_amount` was unvalidated** — POST/PUT `/api/bills` accepted negative amounts, non-numeric input (silently coerced to $0), and absurd values (`1e15` → cents past `Number.MAX_SAFE_INTEGER`). `validateBillData` (`services/billsService.js`) now rejects non-numeric / negative / out-of-range with a structured `VALIDATION_ERROR`, accepting `0`…`$100,000,000`. Regression assertions in `e2e/api.probe.spec.js`. (was QA-B13-01)
- **[UI] Negative amounts rendered as "$-50.00"** — client `fmt()` (`client/lib/utils.js`) and server `formatUSD()` (`utils/money.js`) placed the minus sign after the currency symbol; now render the conventional "-$50.00". Test added in `client/lib/utils.test.js`. (was QA-B6-01)
- **[a11y] Icon-only controls and chart SVGs had no accessible name** — Radix Select filter/sort triggers (Tracker, Bills) and the Spending month-nav buttons rendered with no discernible text (screen readers announced a bare "button"); Analytics chart `<svg role="img">`s had no name; a Snowball drag-handle `<div>` used a prohibited `aria-label`. Added `aria-label`s to the triggers/buttons and a `label` prop to the Analytics `SvgFrame`, and switched the drag-handle to `title`. Clears axe **critical `button-name`** and **serious `svg-img-alt` / `aria-prohibited-attr`** across those pages; guarded by `e2e/a11y.authed.spec.js`. (was QA-B14-01, part of QA-B14-02)
- **[Money] `toCents` lost a cent on 3-decimal half values** — `toCents(1.005)` returned `100` ($1.00) instead of `101`, because `Math.round(n * 100)` inherits binary-float error (`1.005 * 100 === 100.4999…`). Now rounds off the shortest decimal string that round-trips the value (half away from zero); output is **identical** for every integer / ≤2-decimal / `"$1,234.56"` input, so no downstream behavior changes. Added `tests/money.test.js` (9 tests). (was QA-B7-01)
- **[a11y] Nested interactive controls in Categories & Snowball rows** — Categories rows were `role="button"` (and draggable) yet wrapped their own move/edit/delete buttons, and the Snowball plan-status header wrapped its action buttons *inside* the collapsible trigger button (axe **serious `nested-interactive`**). Restructured both: Categories now use a plain container with a dedicated chevron toggle button (click-anywhere still expands via mouse; keyboard/SR use the chevron); the Snowball header splits into a name/progress toggle, sibling action buttons, and a chevron toggle. Expand/collapse verified by `e2e/categories.spec.js`; all 8 authenticated pages now pass axe. (completes QA-B14-02)

### ✨ QA Improvements

- **[Bills] "Recently deleted" restore view** — deleted bills are retained for 30 days, but the only way back was the transient "Undo" toast; once it faded, a bill deleted earlier was unrecoverable from the UI. Bills now shows a **Recently deleted (N)** button (when any exist) that opens a dialog listing each deleted bill with its amount, category, and days left before purge, and a **Restore** action. Backed by `GET /api/bills/deleted` (user-scoped, 30-day window, newest first). Test: `tests/billsDeletedRoute.test.js`. (was IMP-UX-01)
- **[Nav] Data is now a first-class menu item** — import/export/backups (`/data`) was only reachable from the account overflow dropdown and the command palette, easy to miss for how central it is. It now lives in the main app nav (desktop dropdown + mobile) alongside Bills/Categories/Spending. Same visibility gate as before (hidden for the default-admin account). (was IMP-IA-01)

### 🧹 QA Cleanup

- **[DB] Split `database.js` from 4,174 → 1,297 lines** — the DB module was dominated by three big blocks, now each in its own file: the static `subscription_catalog` seed (`db/subscriptionCatalogSeed.js`), the ~1,740-line versioned migration list (`db/migrations/versionedMigrations.js`), and the ~830-line legacy-reconcile list (`db/migrations/legacyReconcileMigrations.js`). The two migration lists are `build*(deps)` factories: each `run`/`check` body closes over the live `db` + a few schema helpers, injected so behavior is byte-identical. Verified behavior-preserving — full server suite (125) passes, a fresh DB applies all 79 migrations and is idempotent, and the real production DB copy (v1.06) migrates as a clean no-op with data intact. `tests/migrationModules.test.js` locks the version-sync invariant between the two lists. (was IMP-CODE-02)
- **[Banking] One canonical writer for transaction match state** — a transaction's `match_status`, `matched_bill_id` and `ignored` columns must move together, but they were updated by copy-pasted inline `UPDATE`s across six routes/services — the same drift that produced QA-B5-04 (a `matched` row with a `NULL` bill). Added `services/transactionMatchState.js` (`markMatched`/`markUnmatched`/`markIgnored`, ownership-scoped) and routed every single-transaction transition (match, unmatch, ignore, unignore, and unmatch-on-payment-delete) through it. Guarded bulk auto-match sweeps and the retention purge keep their own queries by design (their `WHERE` carries idempotency guards). Test: `tests/transactionMatchState.test.js`. (was IMP-CODE-03)
- **[Client] One source of truth for money formatting** — the client had ~15 hand-rolled currency formatters (local `fmt`/`money`/`fmtFull`/`fmtDollars`/… across pages and components) alongside a canonical `fmt` in `lib/utils` used at ~190 sites — the same rules copy-pasted with inconsistent handling of negatives, whole-dollar, cents-vs-dollars, and blank input. Added `client/lib/money.js` (`formatUSD` / `formatUSDWhole` / `formatCentsUSD`, with `signed`/`dash`/`whole` options) as the single implementation; inputs are coerced so `null`/`''`/`NaN` never render as `$NaN` and `-0` never shows as `-$0.00`. `lib/utils.fmt` now delegates to it (byte-identical — guarded by the existing `utils.test.js` suite) and the 15 local formatters were removed in favor of it. Tests: `client/lib/money.test.js`. (was IMP-CODE-01)
- **[Build] Split the vendor bundle** — the main `index` chunk was ~659 kB (over Vite's 500 kB warning). Added `build.rollupOptions.output.manualChunks` (`vite.config.mjs`) to split React, Radix, framer-motion, and TanStack into separately-cacheable vendor chunks; the index chunk dropped to ~334 kB and the warning is gone. (was QA-B0-01)
- **[Deps] Removed unused markdown libraries** — `react-markdown`, `rehype-sanitize`, and `remark-gfm` were declared but never imported (client markdown is rendered by a custom `MarkdownText` component). Removed all three and corrected the security notes: the actual XSS defense is React auto-escaping + the restrictive custom renderer (https-only link hrefs, no `dangerouslySetInnerHTML` anywhere), not rehype-sanitize. (was QA-B14-03)
- **[Debt] Removed dead `totalInterestPaid`** — unused outside its own export, and its unrounded accumulation diverged from `amortizationSchedule`'s per-month rounding. Removed from `services/aprService.js`. (was QA-B7-02)

### ✨ Spending

- **Category groups** — Organize spending categories into named groups (e.g. "Bills", "Everyday", "Subscriptions"). New `category_groups` table with CRUD endpoints. Categories can be assigned to a group via the Spending page or API. Groups appear as collapsible headers in the category breakdown.
- **YNAB-style spending page** — Complete overhaul: all spending-enabled categories are always visible (even with $0 activity), budget/spent/remaining overview strip, Ready to Assign card, 3-month spending averages per category, progress bars with color-coded levels (ok/warn/over), available/overspent labels.
- **Cover overspending** — When a category is over budget, a "Cover from…" dropdown shows other categories with available budget. Clicking one reallocates budget between the two categories.
- **Spending page settings** — Toggle Ready to Assign, 3-month averages, Cover overspending, and Category groups via a dropdown menu. Settings persist per user.
- **"Other categories" bundle** — Outflows assigned to non-spending-enabled categories are bundled into a single read-only row so totals stay accurate without polluting the spending-enabled list.
- **Error states** — Summary, transactions, and categories each have their own error state with retry button. Skeleton loading for all sections.

### 🧪 Tests

- `tests/categoryGroups.test.js` — 4 tests: create/list/rename/delete, duplicate name rejection, group ownership validation, category assignment
- `tests/spendingSummary.test.js` — 4 tests: budgeted category with no activity, non-spending category bundling, 3-month average calculation, category groups in response

## v0.38.4

### ✨ Money

- **Cents Migration Stage 2 — schema flip to integer cents** — The 12 dollar (REAL) columns across 8 tables defined in the cents-migration plan are now integer cents at rest. Migration v1.03 converts and back-fills existing rows; the schema, ~288 query sites in routes and services, CSV/spreadsheet import inserts, `userDbImportService` unit detection, and test fixtures are all cents-aware. CSV export divides by 100 for display. Float dollars are eliminated before the data grows further, and the units now match SimpleFIN transactions/accounts (already cents). Stage 1's `utils/money.js` remains the single source of truth for arithmetic. The full plan, migration SQL, and verification checklist live in `docs/cents-migration-plan.md`.

### 🧹 Cleanup

- **Retired static UI removed** — Deleted the old `legacy/` and root `public/` static UI copies, including the broken duplicate `public/js/api.js` client. The server now returns `410 Gone` for `/legacy` instead of serving stale assets, the hidden About-page legacy link is gone, and current docs no longer list the retired static UI as part of the runtime structure.

## v0.37.0

### ✨ Added

- **SimpleFIN pending transactions** — Bank sync now requests pending (not-yet-settled) transactions, which SimpleFIN excludes by default. The fetch URL gains `pending=1`, and `normalizeTransaction` records a `pending` flag (migration v1.01 adds `transactions.pending` + a partial index). Pending charges are imported and shown with a sky-blue "Pending" badge on the Data page transaction list, but are deliberately **excluded from auto-matching** — both the merchant-rule and score-based match passes skip `pending = 1` rows so an unsettled charge can never create a phantom payment that later vanishes. `insertTransactionIfNew` is replaced by an idempotent `upsertTransaction`: when a pending charge later settles under the same id, the existing row is updated in place (gains its `posted_date`, refreshed amount, `pending → 0`) rather than duplicated — and only then becomes eligible for matching. To handle banks that re-post a pending charge under a *new* id, each sync prunes pending rows for an account that are no longer present in the feed (tracked via a per-account seen-id set), so stale pending rows can't accumulate. The sync result and worker logs now report settled and pending-cleared counts. Debug logging traces each pending insert/settle/prune step.

- **Autopay trust indicator** — Autopay is no longer treated as a binary flag. The tracker row's AP badge now shows a confidence score derived from the last 12 months of payment history: "✓ 12/12 successful" on a healthy bill, or "⚠ 11/12" in amber when even one payment was recorded as an autopay failure. A clock nudge appears when autopay hasn't been manually confirmed in over 90 days (or never). Hovering the badge shows a tooltip with full detail: success rate, last failure date and notes, and verification status. Inside BillModal, an autopay trust panel appears below the Autodraft Status select for existing bills: it surfaces the same confidence rate, shows a staleness warning if verification is overdue, and provides a "Mark verified" button that calls the new `POST /api/bills/:id/verify-autopay` endpoint and updates the timestamp in place. New migration v0.99 adds `bills.autopay_verified_at` (timestamp of last user confirmation) and `payments.autopay_failure` (flag marking a payment that was made because autopay silently failed). The PaymentModal edit dialog now shows an "Autopay failed — paid manually" checkbox on autopay-enabled bills or when the payment method is set to autopay, enabling retroactive flagging of past failures.

- **Trend sparkline on amount column** — Each tracker row now renders a 44×16 px inline SVG polyline to the right of the expected amount showing the last 6 months of actual payment totals, oldest-to-newest, normalized to the row's min/max range. No chart library is required — the sparkline is computed server-side in `trackerService.fetchSparklines` (a single batched `GROUP BY bill_id, month_str` query for all bill IDs at once) and rendered as a `<polyline>` on the client. The chart appears only when two or more months of data exist.

- **"Changed" badge on drifted bills** — When the drift service detects that a bill's actual amount differs from the prior month's by more than the configured threshold, the amount column in the tracker now shows a small amber "Changed" badge with a trending-up icon alongside the sparkline. This reuses the existing `useDriftReport()` data already fetched by TrackerPage — no new API call is needed. The set of drifted bill IDs is derived once in TrackerPage, passed through TrackerBucket, and evaluated per row as a boolean `isDrifted` prop.

- **Cancellation tracker** — When deactivating a bill from the Bills page, the confirmation dialog now includes an optional reason dropdown: "Moved to spouse", "Switched providers", "Paid off", "Cancelled", or "Other". The selected reason is stored in the new `bills.inactive_reason` column (migration v0.99) alongside `bills.inactivated_at`. The `PUT /api/bills/:id` endpoint already writes both fields when the active flag flips from 1 to 0.

- **Tracker table sorting** — The Tracker page now supports URL-backed sorting by bill name, due date, expected amount, last-month paid, paid amount, remaining amount, paid date, and status. Desktop table headers are clickable with active direction indicators, while the filter bar provides a compact sort selector for mobile and tablet. Status sorting uses the tracker lifecycle order (missed, late, due soon, upcoming, autodraft, paid, skipped) instead of plain alphabetical labels, and manual bill reordering is paused while a sorted view is active.

- **Bank payments override provisional manual tracker payments** — Manual payments entered from the Tracker count immediately while waiting for bank sync. When a matching bank-backed payment clears for the same bill cycle, the bank payment becomes the accounting source of truth and the manual payment is preserved as history only with override metadata and a BillModal badge/note. Overridden manual payments are excluded consistently from Tracker, Summary, Calendar, Analytics, Categories, starting amount summaries, drift checks, notifications, status counts, bank pending deductions, trends, overdue checks, and debt balance deltas. If the bank match is undone, the provisional manual payment is reactivated.

- **Animated page, modal, and tracker reorder transitions** — Added `framer-motion` and a shared `PageTransition` wrapper for user/admin route content. Dialog and AlertDialog content now use Framer entry motion, while Tracker bucket rows/cards use layout animation so sorted and reordered bill groups move smoothly instead of snapping.

- **Remembered collapsible search/filter panels** — Added a shared `SearchFilterPanel` and `useSearchPanelPreference` hook backed by the per-user `search_bars_collapsed` setting. Tracker, Bills, and Subscriptions search/filter areas can now be collapsed, default open, and the user's expanded/collapsed preference is remembered across pages and sessions.

- **Profile display-name persistence** — Profile name updates now save to `users.display_name` in the database and are returned consistently as `display_name`, `displayName`, and `name` aliases from auth/profile responses. The Profile page and Sidebar now display the saved name reliably after reloads and new sessions, with profile route coverage added.

- **Private calendar subscription feed** — Added a token-protected `feed.ics` calendar subscription flow for Apple Calendar, Google Calendar/Android, Outlook, and generic ICS clients. The Calendar page now opens an in-place "Subscribe Calendar" dialog that explains what will happen, previews upcoming feed events, and lets users create/copy the feed without losing context. Settings keeps the Calendar Feed management card with regenerate, revoke, preview, platform guidance, and bearer-link privacy copy. The public feed endpoint does not require session cookies, uses stable per-bill-cycle event UIDs to avoid duplicate subscribed events, emits all-day DATE events with CRLF/no-BOM/no-VTIMEZONE ICS output, and is backed by the new idempotent `calendar_tokens` migration.

- **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog instead of immediately removing the match. Option 1 ("Unmatch this payment only") confirms via a single AlertDialog and removes only that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog where each similar match is pre-checked. Users can deselect individual transactions to keep them matched, use All/None quick-selects, and optionally check a "Remove merchant rule" checkbox (shown only when a merchant rule matching the payee pattern exists on the bill). The confirm button shows the count of selected transactions and is disabled when nothing is selected. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` (standard unmatch service) entries in a single database transaction.

- **Service Catalog page for subscription matching** — The full known-service catalog moved out of the main Subscriptions page and into its own dedicated route at `/subscriptions/catalog`. The catalog now acts as an advanced matching tool instead of a second subscriptions list: tracked entries appear under a "Tracking" header with price drift indicators, each linked entry can be edited in BillModal, Re-link opens a searchable dialog to swap or remove the catalog link, and Custom bank descriptors let users add exact payee strings from their bank statements to improve future matching. Untracked catalog entries remain searchable/filterable and can still be tracked individually or in bulk. The Subscriptions page now shows a compact "Improve Matching" card that links to the Service Catalog when users need to tune descriptors, fix a wrong service link, or connect an existing bill to a known service. Catalog load failures now show both inline error state and toast feedback. New migration v0.96 adds `bills.catalog_id FK` (backfilled for existing subscriptions via name matching) and the `user_catalog_descriptors` table for per-user custom payee strings; user descriptors are merged into `loadCatalog` so they improve auto-matching for only that user's account.

- **Subscription catalog: bank descriptors + pricing (2026 researched dataset)** — `subscription_catalog` now carries researched bank statement descriptor strings and common nicknames/slang from a 200-service dataset. Migration v0.95 adds four columns (`subcategory`, `starting_monthly_usd`, `starting_annual_usd`, `price_notes`) and a new `subscription_catalog_descriptors` table (1,501 rows: 1,069 bank descriptors + 432 slang terms). `loadCatalog` joins descriptors into each catalog entry at load time. `lookupCatalog` now checks bank statement descriptors first (score 2000+) before falling back to name/domain fuzzy-match (1000/500); this resolves cases where bank payee strings like "NETFLIX *SUBSCRIPTION" or "AMZN PRIME VIDEO" bore no obvious similarity to the service name. `catalogMatchPayload` now includes `starting_monthly_usd`.

- **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog. Option 1 ("Unmatch this payment only") shows a confirmation AlertDialog and removes just that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog: each similar match is pre-checked, users can deselect ones to keep, All/None quick-selects are available, and an optional "Remove merchant rule" checkbox appears when a matching merchant rule exists. Confirm shows the count of selected transactions. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` entries in a single database transaction.


- **Auto-match review panel** — Merchant-rule auto-matches (payment_source = provider_sync) are now surfaced in a collapsible "Auto-matched — review" panel in the Data → Bank Sync section. Each entry shows the payee, date, amount, and the bill it was matched to. An Undo button reverses the match: the payment is soft-deleted, the bill balance is restored (including any interest), and the transaction reverts to unmatched. The panel appears only when there are reviewable items in the last 7 days, disappears when the list is empty, and automatically refreshes after each Sync Now or Backfill. New endpoints: `GET /api/payments/recent-auto` (fetch the review list), `POST /api/payments/:id/undo-auto` (reverse one match).

- **Auto-learn merchant rules from manual matches** — When a user explicitly confirms a transaction→bill match (via the confirm-match button or the transaction match endpoint with `learnMerchant: true`), a normalized merchant rule is automatically created so future synced transactions from the same payee auto-match without manual intervention. The derived rule is gated by `learnableMerchantFromTransaction()` which rejects payee text that is too short (<4 chars) or composed entirely of generic financial tokens (ach, atm, transfer, fee, etc.), preventing a rule like "ACH Payment" from becoming a catch-all. Background auto-matching (via `applyMerchantRules`) never creates rules — only explicit user actions do. Generic API endpoint tokens are also now collapsed in `normalizeMerchant` (apostrophes stripped so "Sam's Club" → "sams club").

- **Ambiguous merchant-rule matching protection** — `applyMerchantRules` now checks whether the most-specific tier of matching merchant rules maps to more than one distinct bill (e.g., two bills both named "Amazon" with matching rules). When ambiguous, the transaction is skipped and left for manual review rather than silently attributing to the wrong bill.

- **Session token hashing** — Session tokens are no longer stored in plaintext in the `sessions` table. The database now stores SHA-256(token); only the cookie retains the raw token. Existing sessions are invalidated on first startup after this version (all users must re-login once). New admin privacy page allows opting into login location recording.

- **Geolocation opt-in privacy setting** — Login IP geolocation (city, country, region, ISP) was previously always-on for all logins. It now requires an explicit privacy setting toggle (default off). Admin → Privacy card controls the setting. When disabled, no geolocation requests are made and no location data is stored.

- **TOKEN_ENCRYPTION_KEY env var** — Encryption key separation added as a security option. See changed section.

- **Auto-match review panel** — Merchant-rule auto-matches (payment_source = provider_sync) are now surfaced in a collapsible "Auto-matched — review" panel in the Data → Bank Sync section. Each entry shows the payee, date, amount, and the bill it was matched to. An Undo button reverses the match: the payment is soft-deleted, the bill balance is restored (including any interest), and the transaction reverts to unmatched. The panel appears only when there are reviewable items in the last 7 days, disappears when the list is empty, and automatically refreshes after each Sync Now or Backfill. New endpoints: `GET /api/payments/recent-auto` (fetch the review list), `POST /api/payments/:id/undo-auto` (reverse one match).

### 🔧 Changed

- **Mobile tracker view polish** — The mobile bill tracker now mirrors desktop drift context with the "Changed" badge and compact amount sparkline, prevents duplicate quick-pay taps while a save is in progress, and lets bucket headers wrap cleanly on narrow screens. The old unused mobile monthly-state dialog path was removed so the row actions stay focused on the supported tracker flows.

- **Empty state polish** — Every dashed-border centered text empty state across the app has been replaced. The `border border-dashed border-border/...` treatment read as a broken UI rather than an intentional empty state. All instances now use a `bg-muted/20` filled card with no border. Empty states that were purely informational also received a CTA or action hint: the BillModal payment history empty state now reads "No payments yet — use the form below to record the first payment"; the PaymentLedgerDialog shows "No payments yet — add one below"; the Summary page "no bills" state links to `/bills` with an "Add a bill →" arrow. Filter-state empty copy in TrackerBucket was reworded from the passive "No bills match this bucket and filter set" to the directive "No bills match — try adjusting your filters." The Categories page empty-category row already had an "Open Bills" button — its label was updated to "Add a bill →" for consistency. Dashed borders were stripped (content preserved) on SnowballPage, PayoffPage (both `EmptyDebts` and the chart placeholder), CalendarPage day panel, and RoadmapPage issue/activity-log empties.

- **React 19 upgrade** — Upgraded from React 18.3.1 to React 19. The `useOptimistic` polyfill in `client/hooks/useOptimistic.js` was deleted in favour of the native React 19 hook. `BillModal` was refactored from a manual `handleSubmit` + `useState(busy)` pattern to `useActionState` with a form `action` prop — the async action handles validation, bill creation/update, template save, and error toasts without a separate busy flag. All 15 shadcn/ui component files (`button`, `badge`, `card`, `checkbox`, `collapsible`, `dialog`, `input`, `label`, `select`, `separator`, `Skeleton`, `table`, `tabs`, `theme-toggle`, `tooltip`) had `React.forwardRef(...)` replaced with plain function components accepting `ref` as a named prop, removing the deprecated pattern.

- **Subscription recommendations narrowed to bank-backed known services** — The Recommendations panel now only shows high-confidence (`90%+`) bank transaction matches that resolve to a known subscription catalog entry. Unknown recurring merchant patterns are no longer mixed into primary recommendations; those can be reviewed separately later without diluting the "known service" signal. Recommendation confidence now separates identity, amount, and cadence evidence instead of relying on a single recurrence score: user-added descriptors and researched bank descriptors score strongest, service name/domain/slang matches score lower, catalog starting monthly/annual pricing is used to judge amount plausibility, and recurring cadence/amount stability add confidence when multiple charges exist. A single exact known bank descriptor with a plausible amount can still appear as a 90%+ recommendation, but weak one-off name/domain matches no longer do. Recommendation cards now expose the evidence to the user with badges and reason chips for descriptor type, price check, recurring cadence, amount range, catalog starting price, account, last seen date, and average amount.

- **Subscription recommendation review details** — Recommendation scoring now includes an ambiguity evidence bucket for broad merchants and very short service names such as Amazon, Apple, Google, Walmart, Disney, Microsoft, Target, and Max. Exact known/user bank descriptors remain strong, but weaker broad name/domain/slang matches receive a confidence penalty and surface a "Review" badge with reasons. Recommendation payloads now include the matched transaction previews, and the Subscriptions page has a Details dialog showing identity evidence, price evidence, cadence evidence, ambiguity notes, amount range, catalog pricing, source accounts, and the specific bank transactions behind the recommendation before the user tracks, declines, or links it to an existing bill.

- **Subscription recommendations learn and prefer existing bills** — Recommendations now inspect the user's existing bills instead of hiding matches when a bill name already exists. If an existing bill matches the known service or bank merchant, and especially when amount and due day line up, the recommendation's preferred action becomes "Link existing" rather than "Track new"; linking can also backfill the bill's `catalog_id` when the bill was named correctly but not yet connected to the catalog. New migration v0.97 adds `subscription_recommendation_feedback` so accepts, declines, existing-bill links, catalog relinks/unlinks, and descriptor additions/removals become per-user learning signals. Future scoring gets a modest user-specific boost or penalty from that history while hard-declined recommendations remain suppressed. Edge-case coverage now pins broad Apple/Amazon/Google one-off purchases, annual known-service charges, exact user descriptors, existing-bill link preference, and feedback-driven confidence changes.

- **Subscription page actions simplified** — Recommendation cards now show one clear primary action (`Track Subscription` or `Link Existing Bill`), a Details icon, and a compact More menu for secondary actions such as choosing another bill, tracking as new, or dismissing. Tracked subscription rows now use a cleaner Edit + More menu pattern for pause/resume, and dialog/header/search/catalog action buttons use more consistent sizing and icon spacing. The noisy full reason-chip list was removed from the recommendation card surface and remains available in the Details dialog, making the page easier to scan.

- **Subscription cadence sort** — The Tracked Subscriptions panel now has a compact Custom/Cadence segmented control. Custom keeps the saved manual order and drag controls; Cadence groups subscriptions by Weekly, Biweekly, Monthly, Quarterly, Yearly, and Other, sorted by next due date within each group. Reordering is automatically disabled while Cadence sort is active so grouped views are never accidentally saved as manual order.

- **Subscription edits update in place** — Saving a bill from the Subscriptions page no longer reloads the full subscriptions/recommendations payload. `BillModal` already returns the saved bill, so the page now updates the edited row, local bill cache, next due date, monthly/yearly totals, paused count, and top subscription type in memory. Modal-internal payment or unmatch actions no longer accidentally close the modal and refresh the page through the same callback.

- **Subscriptions page simplified** — Removed the full known-service catalog from the main Subscriptions page to reduce overload. The page now focuses on tracked subscriptions, strict known-service recommendations from bank data, transaction search, and a small "Improve Matching" link to the dedicated Service Catalog. Supporting failures that were previously quiet now surface toasts: loading bills for the link-to-bill dialog, transaction search errors, and catalog load errors.

- **Vite API proxy respects alternate API ports** — `vite.config.mjs` now reads `API_PORT` or `PORT` when configuring the `/api` proxy instead of hardcoding port `3000`. This lets local development run the Bill Tracker API on another port when `3000` is already occupied.

- **SimpleFIN transaction deduplication stable across disconnect/reconnect** — `provider_transaction_id` was built as `simplefin:{data_source_id}:{account_id}:{tx_id}`. When a user disconnected (deleting the data source) and reconnected (creating a new data source with a new ID), the ID in the key changed, so nothing matched the old orphaned rows and the full transaction history was duplicated. Changed the key format to `simplefin:{account_id}:{tx_id}` (no data_source_id) — the SimpleFIN account ID and transaction ID are stable identifiers assigned by the financial institution. The unique dedupe index was changed from `(data_source_id, provider_transaction_id)` to `(user_id, provider_transaction_id)` to match the new scope. Migration v0.93 rewrites all existing keys (stripping the numeric data_source_id segment), deduplicates any rows that are now identical after the key change (preserving the linked row over the orphan), and replaces the index atomically.

- **Transaction currency from account, not hardcoded USD** — `normalizeTransaction` hardcoded `currency: 'USD'` for every imported transaction even though `normalizeAccount` correctly reads `rawAccount.currency`. Non-USD users' transactions were always mislabeled. The currency is now read from the account's own currency field; the `'USD'` fallback only applies when the account has no currency data.

- **Interest on debt bills charged once per calendar month, not once per payment** — `computeBalanceDelta` applied a full month of interest on every call. Making two payments in the same month on a credit-card or loan bill charged interest twice, inflating the tracked balance. `computeBalanceDelta` now checks `bill.interest_accrued_month` against the current month (YYYY-MM) and skips the interest component when they match. The month is updated atomically in `bills` whenever interest accrues (via the new `applyBalanceDelta` helper, which uses `COALESCE` to leave the column alone when no interest is charged). `interest_delta` is now stored on each payment row so the delete/restore/edit paths can correctly reverse only the payment component, not the already-charged interest. Migration v0.93 adds `bills.interest_accrued_month TEXT` and `payments.interest_delta REAL`. All payment-writing paths updated: `routes/payments.js` (create, quick, autopay-confirm, bulk, edit, delete, restore), `routes/matches.js`, `routes/bills.js`, `services/trackerService.js`, `services/billMerchantRuleService.js`, `services/transactionMatchService.js`, `services/spreadsheetImportService.js`. For backward compatibility, payment rows with `interest_delta IS NULL` (written before this version) fall back to the prior full-reversal behavior on edit/delete.

- **Sync lookback window — single source of truth, accurate UI copy** — The SimpleFIN lookback window was described with three different wrong numbers, none of which matched the code: the Data page showed a "90d Backfill" button, a "pulled from the last 90 days" toast, and a 90-day "History window" stat; the Admin → Bank Sync card's "Initial connect & backfill" panel showed a "6 days" badge with body copy reading "60 days" and "60-day hard limit" twice. Actual behavior is a **44-day** seed/backfill window (one day under SimpleFIN Bridge's **45-day** hard limit). Root cause was duplicated constants: `bankSyncService` defined its own `SEED_SYNC_DAYS = 44` / `ROUTINE_SYNC_DAYS = 30` independently of `bankSyncConfigService`'s `SYNC_DAYS_EFFECTIVE` / `SYNC_DAYS_DEFAULT` / `SYNC_DAYS_MAX`, and the UI strings were hardcoded and drifted. `bankSyncConfigService` is now the single source of truth — it exports the three constants and `getBankSyncConfig()` returns `seed_days` (44) and `sync_days_max` (45) alongside `sync_days`. `bankSyncService` imports the shared constants instead of redefining them. The user-facing `GET /simplefin/status` now returns `seed_days`, and the admin config endpoint already spreads the full config, so the backfill button label/title/toast, the admin badge and hard-limit copy, the routine-lookback input `max`/clamp, the at-limit warning, and the validation messages all render the backend values. Stale `90`/`30` UI fallbacks corrected so no wrong number is ever shown.

- **Encryption key separation — TOKEN_ENCRYPTION_KEY restored** — The encryption key was previously auto-generated and stored in the database under `_auto_encryption_key`, co-located with the ciphertext it protects. Anyone with a database backup or file-level read could decrypt SimpleFIN access URLs, SMTP passwords, TOTP secrets, push notification tokens, and login history geolocation — encryption-at-rest provided no protection against backup theft. `TOKEN_ENCRYPTION_KEY` env var support is restored: when set, new encryptions use it (prefix `e2:`); when absent, the DB key is used (prefix `v2:`). On startup, if `TOKEN_ENCRYPTION_KEY` is set, all DB-key-encrypted secrets are automatically re-encrypted with the env key in a single transaction — covering `data_sources.encrypted_secret`, `users.totp_secret/totp_recovery_codes/push_url/push_token`, `settings.notify_smtp_password/oidc_client_secret`, and all `user_login_history` encrypted columns. Migration is idempotent (already-migrated `e2:` values are skipped). The Admin → Bank Sync card now shows which key source is active: a warning when the DB key is in use, a confirmation when the env key is loaded.

- **Bank balance freshness timestamp on TrackerPage** — The bank budget tracking card's pulsing indicator label is renamed from "Live" to "Live Sync", and the card and compact status bar now both show "as of [date, time]" sourced from `bank_tracking.last_updated` (already returned by the summary API) so users can see how fresh the balance is. `CalendarPage` already showed this field; `TrackerPage` now matches.

- **SimpleFIN fetch retries transient failures with backoff** — A single network blip or 5xx response previously flipped the connection to `status: 'error'` immediately. `fetchAccountsAndTransactions` now retries up to 3 attempts with 1 s / 2 s delays between them. Network errors and timeouts retry unconditionally; 5xx responses retry; 403 (revoked credentials) and other 4xx responses fail immediately since retrying won't help. `claimSetupToken` is not retried — a setup token is single-use and returns 403 on re-claim.

- **SimpleFIN requests now time out after 30 seconds** — `claimSetupToken` and `fetchAccountsAndTransactions` used bare `fetch` with no timeout, so a hung SimpleFIN response would stall the sync indefinitely and hold the worker's `running` flag, blocking all subsequent auto-sync cycles. Both calls now pass `signal: AbortSignal.timeout(30000)`; a `TimeoutError` propagates through the existing `sanitizeError` handler in each function.

- **Manual sync and auto-sync now produce identical match results** — `autoMatchForUser` (score-based auto-matching) was only called by the background worker after each sync, not by the routes used for manual "Sync Now". `runSync` in `bankSyncService` now calls `autoMatchForUser` directly alongside `applyMerchantRules` and `applySpendingCategoryRules`, so every sync path — manual, sync-all, initial connect, and the timer — runs all three matching passes. The redundant `autoMatchForUser` call removed from `bankSyncWorker`.

- **Match suggestion rejections now expire correctly** — `match_suggestion_rejections` has always stored `rejected_at`, but two queries incorrectly referenced `created_at` (added by a v0.90 migration that should never have been needed). `matchSuggestionService.loadRejections` was throwing and falling back to loading all rejections with no time filter, so rejected suggestions were suppressed forever instead of resurfacing after 90 days. `cleanupService` prune was also throwing and silently catching, so old rejection rows were never deleted. Both queries corrected to use `rejected_at`; the fallback dead-code in `loadRejections` removed.

### 🔒 Security

- **Session tokens hashed in the database** — `sessions.id` previously stored the raw UUID that was set as the session cookie. Any attacker with read access to the database file (backup theft, direct file access) could extract those UUIDs and replay them as valid session cookies. Sessions now store `SHA-256(token)` as the primary key; the raw token stays only in the cookie and is never written to disk. All session operations in `authService` hash the cookie value before querying or mutating the table: `getSessionUser`, `logout`, `rotateSessionId`, `invalidateOtherSessions`, and the two `INSERT` paths in `login`/`createSession`. Migration v0.94 deletes all existing plaintext sessions, forcing a one-time re-login for all users. This matches the pattern already used for `session_fingerprint` in `user_login_history`.

- **IP geolocation made opt-in, disabled by default** — `recordLogin` called `http://ip-api.com/json/{ip}` (plain HTTP, no opt-out) on every new-device login, sending the user's IP to a third party without notice. The call is now guarded by a `geolocation_enabled` admin setting (default: `false`). When disabled, no outbound request is made and the `location_*` columns in `user_login_history` are simply left null. The toggle is exposed in Admin → Privacy. Migration v0.94 seeds the setting at `false` for all installations including existing ones.

- **Rate limiting on sync/backfill endpoints** — `POST /:id/sync`, `POST /sync-all`, and `POST /:id/backfill` had no rate limit despite being able to trigger outbound SimpleFIN requests on every call. A new `syncLimiter` (10 requests per 15 minutes, keyed by authenticated user ID rather than IP) is applied inline to all three routes. GET routes on the same router are unaffected. The limiter is included in `allLimiters` so its store is reset alongside the others in tests.

- **WebAuthn / FIDO2 hardware security key 2FA** — Migration v0.92 adds `webauthn_enabled` and `webauthn_user_id` columns to `users`, a `webauthn_credentials` table (per-user, multiple keys supported — stores credential ID, CBOR public key as base64url, sign counter, transports, backup eligibility, friendly name, and AAGUID), and a `webauthn_challenges` table for short-lived registration, authentication, and login challenges. The new `webauthnService.js` handles the full lifecycle via `@simplewebauthn/server`: generating registration options (with `excludeCredentials` to prevent re-registering existing keys), verifying attestation responses, generating authentication options (passing allowed credentials and transports), verifying assertion responses (updating the sign counter on each use to detect cloned authenticators), and issuing/consuming login challenge tokens. The login flow mirrors TOTP exactly — after password verification succeeds, if `webauthn_enabled` is set, the server returns `requires_webauthn: true` alongside a `challenge_token` (a short-lived login challenge) and `webauthn_options` (the pre-generated assertion options); the client calls `startAuthentication()` from `@simplewebauthn/browser`, and `POST /api/auth/webauthn/challenge` verifies the assertion and creates a session. Six new endpoints added to `routes/auth.js`: `GET /webauthn/status` (enabled flag + credential count), `GET /webauthn/credentials` (list registered keys with name, AAGUID, backup flags, and timestamps), `GET /webauthn/setup` (begin registration — returns options + challengeId), `POST /webauthn/enable` (complete registration — verifies attestation, stores credential, sets `webauthn_enabled = 1`), `DELETE /webauthn/credentials/:credentialId` (remove one key — requires password confirmation; auto-disables WebAuthn when last key is removed), `POST /webauthn/disable` (remove all keys — requires password confirmation). RP ID and origin are configurable via `WEBAUTHN_RP_ID` and `WEBAUTHN_ORIGIN` env vars (default to `localhost` for dev). `publicUser()` in `authService.js` now includes `webauthn_enabled` so the frontend login flow knows to prompt for a security key tap. Expired WebAuthn challenges are pruned in the daily worker alongside expired sessions. OIDC and single-user mode are unaffected. `@simplewebauthn/server` and `@simplewebauthn/browser` v13 added to dependencies.
### Release Image

![Doing my part](/img/doingmypart.jpg)

---

## v0.36.0

### 🔧 Changed

- **Bump** — `0.35.1` → `0.36.0`

- **Login history: encrypted at rest + geolocation + new device alerts + failed attempt tracking + session detection + 10 records** — `user_login_history` now stores `ip_address` and `user_agent` encrypted with AES-256-GCM (same `encryptionService` used for SMTP, OIDC, and bank tokens). Migration v0.84 retroactively encrypts any existing plaintext rows and adds four new location columns (`location_city`, `location_country`, `location_region`, `location_isp`). On each new login, a non-blocking fire-and-forget request to `ip-api.com` resolves the IP to a city/region/country/ISP and stores it encrypted. Private and loopback IPs are skipped for geolocation. The login-history API decrypts all fields server-side before returning them — only the authenticated user can see their own data. History limit increased from 3 to 10 records. The Profile page now shows city, region, country, and ISP below each login entry. Migration v0.85 adds `success` (1 = successful login, 0 = failed attempt) and `session_fingerprint` (SHA-256 of the session ID) columns. Failed login attempts with wrong passwords are now recorded and displayed in the history modal with a red "Failed attempt" badge. The current active session is marked with a "This session" badge by comparing the session cookie fingerprint against stored values. New device logins (device fingerprint not seen in previous 10 logins) trigger a push notification via the user's configured channel (ntfy, Gotify, Discord, or Telegram). The summary card on the Profile page now shows location and always reflects the most recent successful login, not a failed attempt.

- **Login history for single-user mode** — In single-user mode the app bypasses the session system entirely, so `recordLogin` was never called and login history was always empty. Fixed by issuing a `bt_single_session` presence cookie (httpOnly, 30-day, same security flags as the regular session cookie) on the first request from each new browser. `recordLogin` fires once per new cookie via `setImmediate` so it never delays page load. Return visits reuse the cookie and don't create duplicate entries. The login-history route now uses `bt_single_session` when `req.singleUserMode` is set so the "This session" fingerprint comparison works correctly without a real session cookie. OIDC logins were also missing the `sessionId` parameter passed to `recordLogin`, so "This session" never matched for OIDC sessions; fixed by passing `session.sessionId` through the OIDC callback.

- **TOTP / Authenticator App 2FA for local login** — Migration v0.86 adds `totp_enabled`, `totp_secret` (encrypted), and `totp_recovery_codes` (encrypted) columns to the `users` table, and a `totp_challenges` table for short-lived (5-minute) challenge tokens. The new `totpService.js` handles secret generation, QR code rendering via `qrcode`, token verification via `otplib`, and recovery code lifecycle. Setup lives in the Profile page under a new "Two-Factor Authentication" section: scan a QR code with Google Authenticator, Authy, 1Password, Bitwarden, or any TOTP app; enter the 6-digit code to confirm; receive 8 one-time recovery codes (shown once, stored as SHA-256 hashes encrypted at rest). Once enabled, the login flow adds a second step after password verification — the server issues a challenge token instead of creating a session, the client shows a TOTP code input, and the session is only created after the code is verified via `POST /api/auth/totp/challenge`. Recovery codes can be used in place of the authenticator app. Disabling 2FA requires a valid TOTP code. OIDC and single-user mode are unaffected. `otplib` and `qrcode` are included in the Docker image.

- **No Login mode — admin UI redesign** — The `LoginModeCard` in the Admin page is now a clean radio-group selector with two first-class options: **Require Login** (multi-user, shows the OIDC/local-login settings card below) and **No Login — Single User** (hides the auth methods card, shows a user picker and an amber security warning). An inline confirmation dialog is shown before enabling No Login mode. The `AuthMethodsCard` is conditionally hidden when single-user mode is active since OIDC and local-login settings are irrelevant when there is no login screen. The backend lockout validation (which previously blocked saving when all login methods were disabled) now skips entirely when `auth_mode === 'single'`. The `LoginPage` no longer flashes the sign-in form in single-user mode — it renders a neutral loading state while the auth-mode check is in-flight and redirects before the form ever appears.

- **Spending page — bank transaction categorization and budgets** — Migration v0.87 adds a `spending_category_id` column to `transactions`, a `spending_category_rules` table (merchant → category auto-assignment rules), and a `spending_budgets` table (per-category monthly budgets). Eight default spending categories (Groceries, Dining, Fuel & Transport, Shopping, Entertainment, Health, Travel, Other) are seeded per user on first migration. A new `/spending` page appears in the sidebar (between Categories and Snowball) showing: a month navigator; a three-card overview strip (total spending, uncategorized, income received); a category breakdown list where each row shows amount, transaction count, a budget progress bar (red when over), and an inline budget editor; and a paginated transaction list with an inline category picker dropdown per row. Selecting a category in the breakdown filters the transaction list. Categorizing a transaction can optionally save a merchant rule — the same word-boundary matching used for bill rules — which immediately back-fills all existing unmatched transactions from that merchant and auto-categorizes new ones on every future sync. The bank sync worker now calls `applySpendingCategoryRules` after `applyMerchantRules` on every sync. The `api.js` helper layer gained a `patch` shorthand and `get` now accepts query-param objects via `queryString`.

- **Spending page: merchant rules manager and "remember merchant" prompt** — The spending category picker (shown on each transaction row) now prompts "Always categorize [payee] as [category]? Save rule / Dismiss" immediately after a category is chosen. The prompt auto-dismisses after 7 seconds. Saving a rule back-fills all existing unmatched transactions from that merchant, marks the category as spending-enabled, and auto-categorizes all future syncs. A collapsible "Merchant Rules" section at the bottom of the spending page lists all saved rules grouped by merchant name with delete buttons and an "Add rule" form. Error handling: all load/delete/add operations have try/catch with toast feedback.

- **Spending categories separated from bill categories** — Migration v0.88 adds a `spending_enabled INTEGER DEFAULT 0` column to `categories`. Only categories with `spending_enabled = 1` appear in the spending page category picker and breakdown. Migration v0.89 seeds the eight default spending categories (Groceries, Dining, Fuel & Transport, Shopping, Entertainment, Health, Travel, Other) for any user who already had their own bill categories before v0.87 ran (v0.87 only seeded for users with no categories). On the Categories page each category now has a shopping-cart icon button — green means it shows in spending, grey means it doesn't; hover tooltip explains the toggle. Auto-enables a category when a spending merchant rule is saved against it. Empty state on the spending page links to the Categories page when no spending-enabled categories exist.

- **SimpleFIN matching pipeline fixes** — Migration v0.90 bundles four corrections to the matching pipeline: (1) `normalizeMerchant()` now strips `&` without adding a space — "AT&T" previously normalized to `"at t"` (two words) while banks report "ATT" → `"att"` (one word), so AT&T bills never auto-matched; (2) `matchSuggestionService.addNameScore()` replaced bidirectional `.includes()` with word-boundary regex matching (`(^|\s)TERM(\s|$)`) to align with the fix already applied to `billMerchantRuleService` — the score pipeline fed `autoMatchForUser()` and could silently create wrong payments at score ≥ 80; (3) merchant rules are now sorted by length descending before matching so the longer (more specific) rule always wins when multiple rules could match a transaction; (4) `lateAttributionCandidate()` hardcoded a 5-day window ignoring the user's `bank_late_attribution_days` setting for days 6+. Existing stored merchant rules in `bill_merchant_rules` and `spending_category_rules` are re-normalized using the updated function. `match_suggestion_rejections` gains a `created_at` column and rejections older than 90 days are now filtered out and pruned in the daily cleanup worker. `GET /api/bills/merchant-rules` endpoint added — returns all bill merchant rules across all bills grouped by bill name. `BillRulesManager` component added to the DataPage "Sync & Match" tab showing all rules with merchant name, per-rule auto-late toggle, and delete button.

- **Database performance: composite indexes** — Migration v0.91 adds four indexes that were missing on frequently queried columns: `idx_categories_user_deleted ON categories(user_id, deleted_at)`, `idx_bills_user_deleted ON bills(user_id, deleted_at)`, `idx_bills_user_active ON bills(user_id, active, deleted_at)`, and `idx_payments_bill_deleted ON payments(bill_id, deleted_at)`. Without these, every category listing, bill listing, and payment query was a full table scan.

- **Worker health improvements** — (1) `workers/dailyWorker.js` N+1 query fixed: the autopay-marking loop previously ran one `SELECT payments WHERE bill_id = ?` per bill. Replaced with a single batch query fetching all payments for active bills within a 90-day window, then grouped in memory and filtered per bill's cycle range. (2) `statusRuntime.js` now persists worker state to the settings table (`_worker_last_run_at`, `_worker_next_run_at`, `_worker_started_at`, `_worker_last_error`) and seeds in-memory state from those keys on startup. The admin Status page now correctly shows the last run time and next scheduled run after a container restart instead of showing "never / unknown". (3) `notificationService.runNotifications()` N+1 fixed: replaced per-bill payment query and per-bill-per-recipient `hasNotification()` calls with two batch queries (one for all payments, one for all notifications sent today), both checked in-memory via a Set. Reduces notification run from O(N + N×M) to O(3) DB calls. (4) The backup status route was reading `backup_enabled` (a legacy key, always null) instead of `backup_schedule_enabled` (what the admin UI writes), causing scheduled backups to always show as "Disabled" even when running correctly. Fixed by reading the correct key. (5) Status page timezone bug fixed: SQLite `datetime('now')` returns `"YYYY-MM-DD HH:MM:SS"` (no timezone marker); JS Date parses this as local time while `next_run_at` is a proper ISO-Z string parsed as UTC — mixing baselines made "Next Check" appear before "Last Sync". A `toIso()` helper appends `Z` to all SQLite-format timestamps before they leave the server.

- **SpendingPage double-fetch fix** — Two `useEffect` hooks both depended on `useCallback` function references that were recreated when year/month changed, causing back-to-back duplicate API calls on month navigation. Replaced with a single effect depending on primitive values (`year`, `month`, `activeCat`). Added a `cancelled` flag so in-flight requests are discarded when the month changes before they resolve.

- **Income breakdown modal on TrackerPage** — When SimpleFIN bank tracking is enabled, the green bank balance card on the main Tracker page is now a clickable button. Clicking it opens an "Income Breakdown" modal showing: how the effective starting balance was calculated (raw bank balance → pending deduction → effective balance); all positive unmatched SimpleFIN transactions for the month (paychecks, deposits, etc.) with date, payee, and amount; an eye-off icon to exclude a transaction (marks it ignored — useful for internal transfers); a "Show excluded (N)" toggle to view and restore previously excluded transactions. Error handling: load failures show an error state with a Retry button instead of the misleading "No transactions found" empty state; ignore/restore actions fail cleanly without corrupting the list.

- **Monthly income tracking UI on Summary page** — The `monthly_income` table and `PUT /api/summary/income` endpoint have existed since early development but were never connected to a UI. A new "Monthly Income" section now appears on the Summary page above Expenses. It shows the current income label and amount in green, and when both income and total expenses are set it shows "After expenses" remainder right-aligned in the same card. An Edit button reveals an inline form with a label field and amount field. Saves via the existing endpoint. Validates non-negative amount, toasts on error.

- **Copy last month's budgets** — A "Copy last month" button in the spending page category breakdown header copies all spending budget entries from the prior month into the current month in a single `POST /api/spending/budgets/copy` request. Non-destructive: existing budgets for the current month are overwritten (they're already visible so the user knows what they're replacing). Updates the UI immediately from the server response. Shows count toast ("3 budgets copied") or info toast if the previous month had no budgets.

- **`payments.js` SQL fragment renamed for clarity** — `const LIVE = 'deleted_at IS NULL'` was renamed to `const SQL_NOT_DELETED` and given a 4-line comment explaining why SQL fragment interpolation is safe here, why parameterisation is not applicable to SQL fragments (only values can be bound, not column conditions), and explicitly warning future developers not to replace the pattern with dynamic input.

- **Migration version sync assertion** — `_runMigrationVersions` module-level variable is now populated by `runMigrations()` before its loop runs. `reconcileLegacyMigrations()` — which runs after `runMigrations()` on legacy-DB upgrade paths — compares its own version array against the stored list and throws a descriptive error if any version appears in one array but not the other. Catches drift between the two migration arrays at startup rather than silently misconfiguring a legacy schema.

- **Late-attribution coverage extended to single-bill sync and BillModal** — `syncBillPaymentsFromSimplefin` (called by the Sync button in BillModal's Bank Matching Rules section) now runs the same late-attribution detection as the full sync. When a single-bill sync finds a payment that crossed a month boundary, it returns `late_attributions` in its response. The BillModal sync handler dispatches a `tracker:late-attributions` DOM event; TrackerPage listens and appends those to the existing queue, so the attribution dialog appears regardless of whether the sync came from the tracker header button or from inside a BillModal. Late attributions from both sources are processed in the same queue.

- **`BillMerchantRules` preview shows error state instead of silently failing** — The debounced preview API call previously swallowed all errors with `.catch(() => {})`, causing the preview badge to simply disappear on network or server failure. Added `previewError` state: on failure a red "Error" chip appears in the input, and typing again clears it.

- **Late-attribution prompt for bank-synced payments that just missed month end** — When `applyMerchantRules` auto-matches a transaction and the payment's `posted_date` falls within 5 days into a new month while the bill's `due_day` was in the prior month, the payment is flagged as a late-attribution candidate. After "Sync Bank" completes on the TrackerPage, a dialog appears for each candidate: "AT&T payment of $332.97 posted June 1 — should it count for May?" The user can accept (moves `paid_date` to the last day of the prior month so the tracker shows it as paid that month) or dismiss (keeps the original date). Multiple candidates are queued and shown one at a time. The date-only reclassification goes through a new `PATCH /api/payments/:id/attribute-to-month` endpoint that is specifically allowed for `provider_sync` payments (the existing PUT endpoint rejects transaction-linked payments). Amount and bank link are never changed.

- **Encryption key fully app-managed — no env var required** — `TOKEN_ENCRYPTION_KEY` environment variable support removed entirely. The auto-generated DB key (`_auto_encryption_key` in the settings table) is now the primary mechanism, not a fallback. The `[security]` warning that fired on every startup when no env var was set is gone. On first startup a 48-byte cryptographically random key is generated and persisted to the database; subsequent restarts reuse it. All existing encrypted data (SMTP password, OIDC secret, SimpleFIN tokens, push notification tokens) continues to decrypt correctly.

- **TrackerPage crash fixed — `activeTotalExpected` temporal dead zone** — The `cashflow` block added in an earlier change referenced `activeTotalExpected` and `activePaidTowardDue` before their `const` declarations. JavaScript's temporal dead zone caused `Cannot access 'activeTotalExpected' before initialization` on every tracker load. Fixed by moving the four `active*` declarations above the cashflow block that depends on them.

- **Bank merchant rule matching — `balance_delta`, `current_balance`, and error handling** — `billMerchantRuleService.js` was the only payment creation path still missing `balance_delta` computation and `current_balance` update after the #49 fix. Both `applyMerchantRules` (full-user batch matching, called on every sync) and `syncBillPaymentsFromSimplefin` (single-bill retroactive match) now read the bill fresh before each payment, compute `computeBalanceDelta`, include `balance_delta` in the INSERT, and update `bills.current_balance`. `payment_source` corrected from `'auto_match'` (not in `VALID_PAYMENT_SOURCES`) to `'provider_sync'`. DB migration `v0.82` updates all historical `auto_match` records. Both functions also gained full error handling — `txRows` queries, the fallback notes query, and `db.transaction()` blocks are all wrapped with try/catch that logs to console and returns safe zero-match defaults instead of propagating exceptions to the sync worker or API caller.

- **`applyMerchantRules` returns matched bill names** — The function now returns `{ matched, matched_bills: ['AT&T', 'Netflix'] }` instead of just `{ matched }`. Bill name is fetched via JOIN in the rules query. The name set is deduplicated via `Map` keyed by bill_id (so one bill matched by multiple transactions still appears once). The name list is bubbled through `bankSyncService.runSync` and the `POST /api/data-sources/sync-all` endpoint (using a `Set` to dedupe across multiple SimpleFIN sources). TrackerPage Sync Bank toast now reads **"Synced — AT&T, Netflix ✓"** instead of "3 payments matched", with a `(+N more)` suffix when there are more matched bills than bill names to display. Toast duration extended to 5 seconds.

- **BillModal — bank matching Sync button moved, fixed, and made reactive** — The old "SimpleFIN payment history" sync button in the Subscriptions tab was removed (it didn't call `loadLinkedTransactions()` after success and was less discoverable). A new **Sync** button lives in the Bank Matching Rules section header of the Transactions tab, visible whenever `localHasRules` is true. A local `localHasRules` state initialises from `sourceBill.has_merchant_rule` but flips to `true` immediately when `onRulesChanged` fires — so the button appears right after adding the first rule without closing and reopening the modal. After a successful sync, `loadLinkedTransactions()` is called to refresh the Linked transactions list below, and `refetch()` updates the parent tracker view.

- **Pin Due — urgent bills float to top of tracker** — A "Pin Due" toggle button in the TrackerPage header sorts overdue and due-soon bills to the top of each bucket when enabled. Priority order: `missed` → `late` → `due_soon` → `upcoming` → everything else; ties broken by `due_day`. The sort runs after filtering but before the bucket split, so each half-month bucket is sorted independently. The button uses `variant="default"` (solid) when active and `variant="outline"` when off so the current mode is always visible. Preference persists across sessions via `localStorage` under `tracker_pin_upcoming`. Drag reorder is automatically disabled while the toggle is on (`reorderEnabled` now also requires `!pinUpcoming`) since the two modes conflict.

- **Bank tracking pending deduction corrected — no double-counting** — The pending payments window was subtracting all recent payments regardless of source, including bank-synced ones (`payment_source = 'provider_sync'`). Since the live bank balance already reflects those outgoing transactions, subtracting them again produced a balance ~$2k lower than reality. Fixed in both `trackerService.js` and `summary.js` by adding `AND (p.payment_source IS NULL OR p.payment_source != 'provider_sync')` to the pending query. Only manually-entered payments and transaction-matched payments are now counted as pending — those are payments the user recorded in the tracker before the bank has seen them. The `pending_cleared` badge on tracker rows was given the same fix. Additionally, `b.name` was missing from the `SELECT` in `getOverdueCount`, causing the tooltip to show `null` names — corrected.

- **Bank tracking error handling hardened** — `buildBankTracking()` in `trackerService.js` and `buildBankTrackingSummary()` in `summary.js` had no try/catch. A DB lock, schema inconsistency, or bad account state would throw an unhandled exception and crash the entire tracker or summary API response for the user. Both functions are now wrapped: `buildBankTracking` returns `{ enabled: false }` on error (tracker falls back to manual starting amounts gracefully), and `buildBankTrackingSummary` returns `null` (summary page omits bank data but still loads). Console error logged in both cases for debugging.

- **Merchant rule word-boundary matching — fixes false positives** — The merchant matching logic used simple substring `.includes()` which caused "suno" (Suno AI music) to match "sunoco" (gas station) because "sunoco" contains "suno" as a substring. All four matching sites replaced with a word-boundary regex check: `(^|\s)rule(\s|$)` must match within the transaction string. Now "suno" does not match "sunoco" but still correctly matches "suno inc" and similar. DB migration `v0.83` adds `auto_attribute_late` column to `bill_merchant_rules`.

- **Merchant rule: "Auto-fix month crossing" toggle** — Some bills consistently post to the bank 1–5 days after month end (e.g. AT&T due May 29, posts June 1). Previously this required a manual prompt every cycle. Each merchant rule now has an "Auto-fix" toggle in the Bill Modal Bank Matching Rules section. When on, payments that cross a month boundary are automatically moved to the last day of the prior month on sync — no popup, no manual intervention. The flag is stored as `auto_attribute_late` in `bill_merchant_rules` (migration v0.83) and persists. A `PATCH /api/bills/:id/merchant-rules/:ruleId/auto-attribute` endpoint controls it.

- **Historical payment import dialog** — When a merchant rule is added in the Bill Modal, a dialog now opens automatically asking how to handle past transactions: "Import all N payments" (bank as truth), "Choose which ones" (checkbox list showing each transaction's date, amount, and current match status), or "Skip — future only". The "choose" view shows already-handled transactions dimmed at the bottom for context. Backend: `GET /api/bills/:id/merchant-rules/candidates` returns all matching transactions regardless of current match status; `POST /api/bills/:id/merchant-rules/import-historical` imports a selected list.

- **Bank tracking status bar and card on TrackerPage** — When SimpleFIN bank tracking is enabled, the TrackerPage now shows: (B) a slim colored status bar between the header and the filter panel — pulsing live indicator, account name, balance, pending amount, and projected figure color-coded green/red; (C) the Starting summary card is replaced by a bank-styled card with an emerald top bar, account name label, `Landmark` icon, pulsing "Live" badge, effective balance as the main number, and projected-after-bills in the hint line. Number sizes unchanged from other summary cards.

- **`refetch is not defined` in BillModal fixed** — Two `refetch?.()` calls were added to `BillModal` during an earlier session but `refetch` is not a prop or variable in that component's scope. JavaScript throws `ReferenceError: refetch is not defined` before optional chaining can evaluate. Both calls removed — `loadLinkedTransactions?.()` is sufficient since the parent's `onSave` callback handles any full tracker refresh.

- **BillMerchantRules suggestion list now uses inline rendering** — The suggestions dropdown previously used `position: absolute` which was clipped by BillModal's `overflow-y-auto` container. Clicks on suggestions appeared to land on the scroll container behind them. Replaced with inline block rendering (no absolute/fixed positioning) so suggestions are part of the normal document flow and always clickable. `onMouseDown + e.preventDefault()` keeps the input focused during selection.

- **Bank tracking: projected month-end balance added** — The CalendarPage Monthly Money Map (bank mode) now shows a dedicated "Projected Month-End Balance" row below the four metrics. It displays the full equation inline — `$5,461 bank − $1,737 pending − $3,696 remaining bills` — so the source of the number is always visible. The row uses a green/red border depending on whether the projection is positive or negative. The TrackerPage Starting card hint also updated from `account_name · live balance` to `account_name · projected $X after bills` so the projection is visible without navigating to Calendar.

- **Bank tracking settings and account picker fixed** — The `loadBankTracking` function in `BankSyncSection` called `api.getSettings()` but the API method is `api.settings()`. The wrong name threw `TypeError: api.getSettings is not a function`, silently caught by the outer `catch {}`, so neither the toggle state nor the account list ever loaded. Renamed to `api.settings()`. All 19 financial accounts now appear in the picker and the enabled/account/pending-days settings persist correctly across page loads.

- **Overdue badge: "due today" no longer counts as overdue + tooltip added** — `getOverdueCount` changed `dueDate > todayStr` to `dueDate >= todayStr` so bills due today are not counted as past due — only bills strictly in the past trigger the badge. The function now also returns `names: [...]` alongside the count. Both the sidebar `TrackerMenu` (desktop) and `NavPill` (mobile) wrap the badge in a `<Tooltip>` that shows "2 past due · Discord Nitro · Camry" on hover, so the number is immediately explained without navigating anywhere. Up to 5 names are shown with a "+N more" overflow line.

- **Column labels larger and lighter in tracker table** — `TableHead` elements in `TrackerBucket` changed from `text-[10px] font-semibold tracking-widest` to `text-xs font-medium tracking-wider`. Size increased from 10 px to 12 px for readability; weight dropped from semibold to medium so the labels don't visually compete with the bold bill name text in each row.

- **Tracker row keyboard navigation** — Tracker rows (desktop table view) are now keyboard navigable. Each row has `tabIndex={0}`, `data-tracker-row`, `aria-rowindex`, and an `aria-label` announcing the bill name, status, and due day. A `focus-visible:ring-2 ring-primary/60 ring-inset` focus ring appears on keyboard focus only. Key bindings: `↓`/`j` focuses the next row, `↑`/`k` the previous (both cross bucket boundaries via `querySelectorAll('[data-tracker-row]')`), `Enter` opens the edit modal, `P` toggles paid/unpaid (skipped bills ignored, `Ctrl+P`/`Cmd+P` passes through to the browser), `Esc` blurs the row. The `onKeyDown` handler guards against firing on nested interactive elements with `if (e.target !== e.currentTarget) return`.

- **Bills list query optimised and merchant rule index added** — `GET /api/bills` replaced a correlated `EXISTS(SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id)` per bill row with a single `LEFT JOIN (SELECT DISTINCT bill_id FROM bill_history_ranges) hr`. DB migration `v0.81` adds composite index `idx_bill_merchant_rules_user_bill ON bill_merchant_rules(user_id, bill_id)` — the existing index only covered `user_id`, making the EXISTS check in `GET /api/bills/:id` scan by user then filter by bill; the composite index makes it a direct point lookup. Pagination was not added — the UI depends on all bills being loaded at once and personal-scale data volumes don't warrant it.

- **`rotateSessionId` uses `db.transaction()` instead of raw SQL** — `rotateSessionId()` in `authService.js` managed its DELETE + INSERT pair with explicit `db.prepare('BEGIN').run()` / `COMMIT` / `ROLLBACK` calls. This is fragile: if the rollback itself throws (e.g. connection in a bad state), the transaction is left open. Replaced with better-sqlite3's `db.transaction()` wrapper, which commits automatically on success and rolls back automatically on any thrown error with no manual try/catch required.

### 🔒 Security

- **OIDC client secret encrypted at rest** — The OIDC client secret was stored as plaintext in the `settings` table alongside all other application settings. It is now encrypted using the same AES-256-GCM + HKDF pipeline already in use for SMTP passwords and SimpleFIN tokens. A new `getOidcClientSecret()` helper in `oidcService.js` decrypts on read (with a plaintext fallback for legacy values), and the write path calls `encryptSecret()` before `setSetting`. DB migration `v0.79` encrypts any existing plaintext value on first startup — no manual action required. Env-var-sourced secrets (`OIDC_CLIENT_SECRET`) are unaffected and bypass the DB path entirely.

- **Admin user routes: integer validation on all ID params** — `PUT /api/admin/users/:id/password`, `/role`, `/active`, `/username` and `DELETE /api/admin/users/:id` previously accepted arbitrary strings as the user ID — some routes used raw `req.params.id` in SQL queries, others called `Number()` without verifying the result was a positive integer. A shared `parseUserId()` helper (`parseInt` + `Number.isInteger` + `> 0`) now gates all five handlers, returning `400 Invalid user ID` immediately on any non-integer or non-positive input. Backup routes that take filename-style IDs are intentionally unchanged.

### ✨ Features

- **Bill due notifications — push channels (ntfy / Gotify / Discord / Telegram)** — The email notification system (`runNotifications()`) was already fully built and running in the daily worker at 6 AM. What was missing was push notification support. Four new channels are now wired in: ntfy (HTTP POST with priority/tags headers and optional Bearer auth), Gotify (JSON message with urgency-mapped priority 4–9), Discord (webhook embed with urgency-matched color and timestamp), and Telegram (Bot API `sendMessage` with Markdown). Urgency levels (`upcoming`, `soon`, `today`, `overdue`) map to channel-appropriate priorities and ntfy tags (`bell`, `warning`, `rotating_light`, `red_circle`). The early-exit guard in `runNotifications()` was loosened — notifications now fire if either SMTP or any user's push is configured. Each recipient can receive both email and push for the same bill. `push_url` and `push_token` are encrypted at rest with the existing AES-256-GCM service. DB migration `v0.80` adds five columns to `users`: `notify_push_enabled`, `push_channel`, `push_url`, `push_token`, `push_chat_id`. The Profile page gains a "Push Notifications" card with a master toggle, pill-button channel picker, context-aware inputs (URL, token shown as "✓ saved" rather than pre-filled, Telegram-only chat ID field), and a "Send test" button that fires `POST /api/notifications/test-push` and surfaces the exact error if the channel is misconfigured.

- **Cash flow projection** — A new `CashFlowCard` on the Calendar page answers "what will I have left after all my bills clear?" — distinct from the existing remaining balance which only reflects what's already been paid. The card shows two panels in the first half of the month (by period end, by month end) and collapses to one panel in the second half since the dates converge. Progress bars are amount-based (`$420 of $650 paid`) rather than count-based so high-value bills are weighted correctly. When any projection goes negative a prominent red alert banner appears with the shortfall amount and a prompt to review unpaid bills or adjust starting amounts. The "X unpaid →" count is a live link that opens the Tracker pre-filtered to exactly those bills for that period. On TrackerPage the Starting card hint now shows `→ $1,247 projected by Jun 14` when cashflow data is available, surfacing the projection without leaving the tracker view. When bank tracking is active the projection uses the live effective bank balance as its starting point. Backend: a `cashflow` block added to the `trackerService` response containing period and month projections, amount-paid totals, paid/total counts, and an `end_label` string for the period cutoff date.

- **Sync Bank button on Tracker** — A "Sync Bank" button appears in the TrackerPage header toolbar when three conditions are all true: SimpleFIN Bridge is enabled (admin setting), the user has at least one connected SimpleFIN source, and the user has at least one bill merchant matching rule. Clicking it calls `POST /api/data-sources/sync-all`, which syncs every connected SimpleFIN source in sequence, aggregates the results, and after each source runs `applyMerchantRules` to auto-match new transactions. The button shows a spinning icon while syncing and toasts a specific result: "3 payments matched", "5 new transactions, no automatic matches", or "no new transactions". The tracker refetches automatically so newly matched payments appear without a page reload. `GET /api/data-sources/simplefin/status` was extended with two new fields — `has_connections` and `has_merchant_rules` — so the single status call drives the button's visibility with no additional requests.

- **Bill bank matching rules** — Bills can now be linked to bank transaction patterns so payments import automatically without manual matching. A new "Bank matching rules" section in the Bill Modal (Transactions tab) shows all existing patterns for a bill as removable chips and lets the user add new ones by typing a merchant name or picking from a dropdown of recent unmatched transactions. As the user types, a live preview badge shows how many existing unmatched transactions the pattern would match (debounced, updates as-you-type). If the pattern is already claimed by another bill a conflict warning appears inline with the other bill's name, prompting the user to be more specific. On save the rule is applied retroactively — `syncBillPaymentsFromSimplefin` runs immediately and a green feedback banner reports how many historical payments were imported (e.g. "3 existing payments imported from your transaction history"). Bills with at least one active rule show a green **Bank** chip in the bill list with a tooltip. Four new endpoints: `GET /api/bills/:id/merchant-rules` (list rules + suggestions), `GET /api/bills/:id/merchant-rules/preview?merchant=X` (match count + conflict check), `POST /api/bills/:id/merchant-rules` (add + retroactive apply), `DELETE /api/bills/:id/merchant-rules/:ruleId` (remove).

- **SimpleFIN bank budget tracking** — A new opt-in mode replaces manually-entered starting amounts with the live balance of a connected bank account. When enabled from the Bank Sync settings page (Data → Bank Sync → Bank Budget Tracking), the user selects which financial account to track and configures a pending payment window (0–7 days, default 3). Budget remaining is calculated as: `bank balance − pending payments − unpaid bills this month`. Bills already marked paid are not double-counted — the bank balance already reflects them. Payments made within the pending window appear in the tracker with an amber **Pending** badge, flagging that the bank may not have processed the debit yet. The CalendarPage Monthly Money Map switches to four live metrics (Balance / Pending / Unpaid Bills / After Bills) when bank mode is active. The TrackerPage starting-amounts card shows the account name and "live balance" hint; the manual-edit button is hidden since there is nothing to manually set. Implementation: three new `user_settings` keys (`bank_tracking_enabled`, `bank_tracking_account_id`, `bank_tracking_pending_days`), a new `GET /api/data-sources/accounts/all` endpoint for the account picker, `buildBankTrackingSummary()` in both `summary.js` and `trackerService.js`, and `pending_cleared` flag on tracker rows.

- **404 page** — Unknown routes previously silently redirected to `/` with no feedback. Replaced both catch-all routes (`path="*"` inside the auth layout and at the top level) with a dedicated `NotFoundPage`. The page is standalone (no sidebar), theme-aware, and works for authenticated and unauthenticated users alike. Design features: a glitch counter that cycles each digit of "404" through random numbers before snapping to value (staggered by 180 ms per digit), a CSS mesh gradient background with primary-color radial glows, a 48 px grid overlay faded with a radial mask, a gradient clip-text `404` that scales from `6rem` to `14rem` via `clamp()`, and smart CTAs — "Go back" only appears when browser history exists, and the home button adapts to auth state. The bad path is shown inline in a `<code>` tag so the user knows what they typed.

### 🐛 Fixed

- **Bills list query optimised and merchant rule index added** — The issue requested pagination on `GET /api/bills`, `GET /api/categories`, `GET /api/tracker`, and `GET /api/subscriptions`. Pagination was not implemented — the entire UI (tracker buckets, snowball list, drag reorder, BillModal) depends on all bills being loaded at once, and a personal bill tracker with 20–50 bills has no performance problem at this scale. The genuine fix was in two parts. First, `GET /api/bills` replaced a correlated `EXISTS(SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id)` per bill row with a single `LEFT JOIN (SELECT DISTINCT bill_id FROM bill_history_ranges) hr` — same result, one scan instead of N subqueries. Second, DB migration `v0.81` adds a composite index `idx_bill_merchant_rules_user_bill ON bill_merchant_rules(user_id, bill_id)` — the existing index only covered `user_id`, so the EXISTS check in `GET /api/bills/:id` and the snowball debt query had to filter by user then scan for bill_id; the composite index makes it a direct point lookup.

- **Imported payments use correct `payment_source = 'file_import'`** — When issue #49 was fixed (imported payments not updating debt balance), `payment_source` was set to `'import'` — a value not in the canonical `VALID_PAYMENT_SOURCES` set (`manual`, `file_import`, `provider_sync`). Corrected to `'file_import'` in both INSERT paths in `spreadsheetImportService.js`. Payment history now shows the correct source for imported payments and the value round-trips correctly through the import/export and user DB import paths.

- **Mortgage and housing categories now auto-detected as debt** — `DEBT_LIKE_CLAUSES` in `routes/snowball.js` matched `%credit%`, `%loan%`, and `%debt%` category names but not `%mortgage%` or `%housing%`. A real user who created a bill under a "Mortgage" or "Housing" category would never see it on the Snowball page unless they manually toggled `snowball_include`. Demo data hid the bug because the seed bill has `snowball_include` pre-set. The frontend's `mortgageIncluded` warning in `SnowballPage.jsx` already checked for mortgage/housing in category and bill name — it just never fired because those bills were filtered out before reaching the page. Added both patterns to `DEBT_LIKE_CLAUSES`; the warning now works as intended, correctly flagging when a mortgage is present so users see the Ramsey Baby Step 2 note about excluding the house.

- **Imported payments now update debt balance** — Every payment creation path except spreadsheet import correctly computed `balance_delta` and updated `bills.current_balance`. Imported payments were permanently orphaned from debt tracking: the snowball page balance stayed wrong after an Excel import, delete/restore was broken (the restore path checks `balance_delta IS NULL` and silently skips the reversal), and any debt-related reporting was incorrect. Three paths were fixed. In `spreadsheetImportService.js` `createPaymentFromImport()`: added `computeBalanceDelta` import, fetches the bill fresh on every call (critical for sequential month imports so each payment sees the post-previous-payment balance), includes `balance_delta` in the INSERT, updates `bills.current_balance`, and sets `payment_source = 'import'` (was null). The `create_payment` action path in the same file had the identical gap and received the same treatment. `routes/matches.js` manual transaction confirm also had no `balance_delta` or `current_balance` update — fixed with the same `computeBalanceDelta` call inside the existing transaction block.

- **Daily worker cycle range bugs for quarterly and annual bills** — `dailyWorker.js` had two bugs affecting non-monthly billing cycles. First, `getCycleRange(year, month)` was called without a bill argument, always producing the calendar-month range for payment lookups. A quarterly bill paid in January would be invisible to the autopay check in February and March because the worker only searched that calendar month — a payment that existed was treated as missing. Fixed by calling `getCycleRange(year, month, bill)` per-bill so quarterly bills look at their full 3-month window and annual bills look at the full year. Second, `buildTrackerRow()` returns `null` for bills whose cycle does not apply in the current month (quarterly/annual bills in non-due months), and the code immediately accessed `row.due_date` with no null check. JavaScript's `&&` short-circuit masked the crash for non-autopay bills, but any autopay-enabled quarterly or annual bill in a non-applicable month would throw `TypeError: Cannot read properties of null`. Fixed with an early `continue` when `getCycleRange` returns null and a defensive guard after `buildTrackerRow()`. The issue description incorrectly stated that `resolveDueDate()` and `getCycleRange()` ignore cycle types — both functions already handle quarterly, annual, biweekly, and weekly correctly; the tracker already filters non-applicable bills via `.filter(Boolean)`; no new scheduler was needed.

- **Client snowball projection replaced with server call** — `computeLiveProjection()` in `SnowballPage.jsx` was an 86-line client-side reimplementation of `snowballService.js`. A comment acknowledged the duplication but there was no mechanism to detect drift — a bug fix on the server would silently diverge from the client preview. The function has been deleted. `GET /api/snowball/projection` now accepts an optional `?extra=N` query parameter that overrides the stored extra payment for that request without saving it, giving the client a way to preview an unsaved amount using the authoritative server simulation. The `useMemo` live projection is replaced with a 220 ms debounced `useEffect` that calls the endpoint; the existing `projectionLoading` state and loading indicator fire naturally. Drift between client and server projections is now mechanically impossible.

- **Snowball order PATCH validates all rows before writing** — `PATCH /api/snowball/order` previously iterated through the submitted array with a `continue` on invalid entries, silently skipping bad rows and always returning `{ success: true }`. Any item with a non-integer or negative `id`/`snowball_order` now immediately returns `400` with the specific index and value that failed. The transaction only runs after all items pass validation. Response now includes `updated` count. Soft-deleted bills are also excluded from the UPDATE (`deleted_at IS NULL`), which simultaneously closes issue #53.

- **`isRamseyMode()` called once per request** — `getDebtBills()` previously hid the `isRamseyMode()` DB query inside itself. Routes that also needed the mode value (or called `getDebtBills` alongside other `isRamseyMode` calls) triggered multiple identical queries per request. `getDebtBills` now accepts an optional pre-fetched `ramseyMode` parameter; the `GET /`, `GET /projection`, and `POST /plans` routes call `isRamseyMode` once and pass the result in. `PATCH /settings` uses the body value directly when `ramsey_mode` was part of the request, falling back to a DB read only when it wasn't.

- **Month navigation brackets the month name** — In TrackerPage the month navigation pill previously showed `< Today >` — the arrows flanked a "Today" button rather than the current month. The pill now shows `< MAY 2026 >` with the month and year as a static label between the arrows, and "Today" promoted to a standalone `variant="outline"` button beside the pill. In CalendarPage the pill already had the correct structure (`< MONTH YEAR >`) but `min-w-40 px-3` (160 px minimum + 24 px of padding) made the label too wide, leaving the arrows visually disconnected from the text. Reduced to `min-w-[8rem] px-1` so the arrows bracket the text tightly. Both labels gain `tabular-nums` (prevents width jitter on month change) and `select-none` (prevents accidental text selection when clicking arrows quickly).

### Release Image

![Doing my part](/img/doingmypart.jpg)
---

## v0.35.1

### 🔧 Changed

- **Bump** — `0.35.0` → `0.35.1`

- **Roadmap pulls from Forgejo issues** — The Admin Roadmap tab now fetches live open issues from the Forgejo repository instead of parsing `FUTURE.md`. Issues are grouped into the same priority lanes (`CRITICAL` → `NICE TO HAVE`) using `priority:*` labels. Each card shows the issue title (priority prefix stripped), a 2-line body preview, all label chips rendered in their actual Forgejo colors, creation time, comment count, and a click-through link to the issue. Results are cached server-side for 5 minutes; a ↻ refresh button bypasses the cache on demand. On fetch failure the last cached result is served with a stale indicator.

- **OIDC login error logging improved** — `Issuer.discover()` failures previously produced a blank log line because the error was an `AggregateError` (empty `.message`, real causes in `.errors[]`). Both the `/login` and `/callback` handlers now log the full error, expand `err.errors[]` entries, and surface `err.cause` so network-level failures (e.g. `ETIMEDOUT`, `ENOTFOUND`) are visible in the server log.

### Release Image

![Doing my part](/img/doingmypart.jpg)
---


## v0.35.0

### 🔧 Changed

- **Bump** — `0.34.3` → `0.35.0`

### 🔒 Security

- **TOKEN_ENCRYPTION_KEY deployment guidance** — When `TOKEN_ENCRYPTION_KEY` is not set, `encryptionService.js` auto-generates a random key and persists it in the `user_settings` table — placing the key and the ciphertext in the same SQLite file. Anyone with database read access has everything needed to decrypt. The threat is bounded (filesystem access = game over regardless), but is now explicitly surfaced: a one-time `console.warn` fires on first use of the auto-generated key path, directing operators to set the env var. `.env.example` gains a `TOKEN_ENCRYPTION_KEY` section with a generation command and a plain-English explanation of the trade-off.

- **HKDF key derivation with automatic migration** — `encryptionService.js` previously derived the AES-256-GCM key from raw input via `SHA-256(ikm)`, which lacks domain separation and offers no protection if a low-entropy passphrase is supplied. Replaced with **HKDF-SHA-256** (RFC 5869) using info label `bill-tracker-token-encryption-v1`. New ciphertext carries a `v2:` prefix; `decryptSecret` uses it to choose the correct derivation path, so legacy and new ciphertext coexist transparently. DB migration `v0.78` re-encrypts all existing secrets (`data_sources.encrypted_secret` and `notify_smtp_password`) to the v2 format on first startup — no manual action required.

- **CSRF token moved out of readable cookie** — The CSRF cookie previously defaulted to `httpOnly: false` so the SPA could read it from `document.cookie`. Any XSS vulnerability could steal the token from there and bypass CSRF protection entirely. The cookie is now `httpOnly: true` by default, removing it from the XSS-accessible cookie surface. The SPA instead fetches the token once from `GET /api/auth/csrf-token` on startup and stores it in a module-level memory cache; all mutations continue to send it in the `x-csrf-token` header unchanged. The server-side double-submit validation (`header == cookie`) is identical. `CSRF_HTTP_ONLY=false` remains available in `.env` for compatibility, but is no longer the default.

### Release Image

![Doing my part](/img/doingmypart.jpg)
---

## v0.34.3

### 🔧 Changed

- **Bump** — `0.34.2.1` → `0.34.3`

- **TrackerPage refactored into focused components** — `TrackerPage.jsx` was a 2 386-line monolith containing ~13 co-located sub-components. Each has been extracted to its own file in `client/components/tracker/`:
  - `FilterChip`, `StatusBadge`, `SummaryCards` (TrendIndicator / SummaryCard / TrendCard) — visual primitives
  - `EditableCell`, `PaymentProgress`, `LowerThisMonthButton`, `PaymentLedgerDialog`, `NotesCell` — payment sub-components
  - `AutopaySuggestionActions`, `TrackerRow`, `MobileTrackerRow` — bill row (desktop and mobile)
  - `TrackerBucket` — bucket container
  - Helper functions and constants (`rowEffectiveStatus`, `paymentSummary`, `ROW_STATUS_CLS`, etc.) extracted to `client/lib/trackerUtils.js`
  - `TrackerPage.jsx` is now **477 lines** (page layout + routing only)

- **TrackerPage filter/nav state is now URL-first** — `year`, `month`, `search`, and all 8 filter flags (`autopay`, `firstBucket`, `fifteenthBucket`, `unpaid`, `overdue`, `debt`, `category`, `cycle`) are stored in URL search params instead of local React state. Views are now bookmarkable, shareable, and survive back/forward navigation. Example: `/tracker?year=2026&month=5&ov=1&un=1`. The `search` param was previously partially URL-backed; this completes the pattern.

---

## v0.34.2.1

### 🚀 Features

- **Overview quick-add bill** — The Monthly Overview header now includes a plus-button shortcut that opens the existing Add Bill modal and refreshes the tracker after saving.

### 🔧 Changed

- **Bump** — `0.34.2` → `0.34.2.1`

### 🐛 Bug Fixes

- **Async error handling hardened** — Five route handlers that called `bcrypt.compare()` or `hashPassword()` without a surrounding try/catch now return a clean 500 instead of leaving the promise rejection unhandled. Affected routes: `POST /api/auth/change-password`, `POST /api/admin/users` (auth router), `POST /api/admin/users` (admin router), `PUT /api/admin/users/:id/password`, `POST /api/profile/change-password`. A `process.on('unhandledRejection')` safety-net logger was also added to `server.js`. All other routes cited in the original bug report already had try/catch and required no changes.

- **SMTP password encrypted at rest** — `notify_smtp_password` was stored as plaintext in the `settings` table, exposing credentials in database backups or direct file access. It is now encrypted with AES-256-GCM via the existing `encryptionService` (same mechanism as SimpleFIN tokens). The route encrypts on save, the notification service decrypts on read with a legacy plaintext fallback, and migration v0.77 encrypts any existing plaintext password at startup. The masked `••••••••` API response is unchanged.

- **User deletion now cleans up audit_log** — The `DELETE /api/admin/users/:id` route was not explicitly deleting rows from `audit_log` (which has no `ON DELETE CASCADE` foreign key to `users`). Deleting a user left their audit trail orphaned in the database, referencing a non-existent user id. The route now explicitly deletes `audit_log`, `import_sessions`, and `import_history` rows for the user before removing the user row; the remaining ~20 user-owned tables are handled by `ON DELETE CASCADE`.

- **Payment mutations scoped to owner** — The `PUT`, `DELETE`, and `POST /:id/restore` handlers in `routes/payments.js` performed their `UPDATE payments` and bill balance `SELECT` queries using only the payment id, without re-asserting ownership in the SQL itself. Ownership was already verified via a JOIN before each mutation (not exploitable in practice), but the SQL provided no independent protection. All three mutation statements now include `AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)`, and the bills balance re-fetch in DELETE and restore now includes `AND user_id = ?`. The response `SELECT` at the end of PUT and restore was also updated to use the ownership JOIN consistent with `GET /api/payments/:id`.

- **JSON body limit made explicit** — `express.json()` in `server.js` now declares `{ limit: '100kb' }` explicitly rather than relying on Express's implicit default. No behaviour change; import routes continue to override this per-endpoint (2 MB – 10 MB).

- **Silent `.catch(() => {})` replaced with console logging** — Twelve instances of empty catch handlers across the client codebase swallowed errors with no logging or user feedback: `LoginPage.jsx` (authMode/session pre-checks), `useAuth.jsx` (authMode check), `SnowballPage.jsx` (plan history load), `SubscriptionsPage.jsx` (bills load on mount and after reorder), `BankSyncSection.jsx` (bills load for match picker), `ImportSpreadsheetSection.jsx` (bills and categories load for import controls), `TransactionMatchingSection.jsx` (categories load), `Layout.jsx` (SimpleFIN status badge), and `ReleaseNotesDialog.jsx` (acknowledge-version fire-and-forget). All are background ambient data loads whose fallback silent state is correct for the user experience; they now log `console.error('[ComponentName] context message', err)` so failures are visible to developers without surfacing disruptive toasts.

- **Monetary aggregation rounding hardened** — Floating-point rounding was already applied per-payment in `statusService`, `billsService`, and `payments.js`, but the aggregation layer was unprotected: `reduce()` sums and subtraction results in `trackerService`, `routes/summary.js`, and `routes/monthly-starting-amounts.js` were returned without rounding, allowing IEEE 754 artifacts (e.g. `12.10 - 0.20 = 12.100000000000001`) to leak into API responses. All computed monetary aggregates (`total_paid`, `total_expected`, `paid_toward_due`, `remaining`, `total_remaining`, `overdue`, `combined_amount`, `*_remaining`, `expense_total`, `result`, etc.) are now passed through `Math.round(x * 100) / 100` before being returned. `roundMoney` is also now exported from `statusService` so other modules can share the same implementation instead of re-implementing it.

### Release Image

![Doing my part](/img/doingmypart.jpg)
---

## v0.34.2

### 🔧 Changed

- **Bump** — `0.34.1.3` → `0.34.2`

- **Subscription badge on Tracker** — The "S" subscription badge now appears consistently on all bill rows in the Tracker page. The reorder-mode row variant was missing the badge even though it showed the "AP" autopay badge — both badges now render in all row contexts.

- **Data page workflow tabs** — Data now groups tools into Sync & Match, Import Data, and Export & History tabs, with remembered collapsible cards and a compact status strip.
---

## v0.34.1.3

### 🚀 Features

- **Reordering across management pages** — Bills, Subscriptions, Categories, and Snowball now expose tracker-style drag/up/down controls. Bill-backed pages persist through `sort_order`; Categories adds its own persisted `sort_order` API.

- **Snowball readiness warning** — Clicking "Start Snowball Plan" when any readiness checklist items are still incomplete now shows an AlertDialog listing the pending items and asking for confirmation before proceeding.

- **Payoff Simulator — all bills load** — Simulator now loads from `api.bills()` (all active bills) instead of the snowball-only endpoint, so any bill with a `current_balance` appears in the dropdown regardless of category.

- **Payoff Simulator — Custom mode** — Added a "Custom — not in Bill Tracker" option to the dropdown. Selecting it reveals Name (optional) and Balance (required) inputs, letting users simulate any loan or debt without creating a bill. Apply-to-budget and minimum-payment UI are hidden in custom mode.

- **Payoff Simulator — print** — Added a Print button (top-right of page header) that triggers `window.print()`. Print styles isolate the simulator region, hide interactive controls, and inject a summary line showing the simulated parameters.

- **Summary bill ordering** — Summary expenses now use the persisted bill order and support tracker-style drag/up/down reordering, while hiding reorder controls from printed/PDF summaries.
- **Unified bill schedule editing** — Edit Bill now uses one canonical "Billing Schedule" field instead of separate Billing Cycle and Cycle Type controls.


### 🔧 Changed

- **Bump** — `0.34.1.2` → `0.34.1.3`

- **Payoff Simulator — subscriptions excluded** — Added explicit `!is_subscription` guard to the bill filter so subscription bills never appear in the payoff dropdown even if a balance is accidentally set on one.

- **SimpleFIN sync window corrected** — Hard limit updated from 90 → 45 days (actual SimpleFIN Bridge cap). Initial seed and backfill use 44 days (1-day buffer). Routine sync default remains 30 days. The admin `sync_days` setting was previously stored but never read — it now correctly drives routine auto-sync and manual "Sync Now" lookback.
- **Backups status badge fixed** — System Status page Backups card previously showed "Enabled" (green) whenever `backup_enabled` was true, even with no schedule configured. Badge now reflects the scheduler state: "Scheduled" (green) when automatic backups are active, "Manual Only" (amber) when enabled but unscheduled, "Disabled" (amber) when off. Added Schedule and Next Backup rows to the card.

- **SimpleFIN admin card — sync explainer** — "Transaction history" field replaced with two clearly labelled blocks: Initial connect & backfill (fixed 44 days, read-only) and Routine sync lookback (editable, 1–45 days, default 30). Amber warning appears when the routine value reaches the 45-day limit. Persistent info note keeps the hard limit visible at all times.

- **SimpleFIN account monitoring** — Turning off tracking for an account now prevents new transaction ingestion for that account and excludes existing transactions from matching, merchant-rule sync, and subscription recommendation/search flows.
- **Snowball extra payment focus** — The extra monthly budget input now uses a brighter, professional focus panel with the live monthly amount called out.

- **Snowball drag behavior** — Snowball custom ordering now uses the same native drag/drop pattern and visual feedback as the Tracker page.

- **Scheduled backup retention** — The database backup scheduler now starts with the server only when enabled in Admin settings and prunes only scheduled backups, keeping the configured default of 2 without deleting manual, imported, or pre-restore backups.
- **Billing schedule migration** — Added migration v0.76 to backfill legacy billing-cycle values into `cycle_type`, normalize `cycle_day`, and derive the legacy `billing_cycle` value from the canonical schedule.
- **Subscription recommendations** — Possible subscription matches now list the bank account that produced the matching transaction activity.

---

## v0.34.1

### 🚀 Features

- **Persistent tracker bill ordering** — Added `sort_order` on bills, `PUT /api/bills/reorder`, and tracker drag/up/down controls so bill order can be changed and remembered.

- **Bill archive endpoint** — Added `PUT /api/bills/:id/archived` to hide or restore bills without deleting them.

- **Subscription catalog matching** — Subscription recommendations now use the DB-backed `subscription_catalog` as a strong matching signal alongside the existing recurrence algorithm. Known services can surface as high-confidence recommendations, with catalog name/type/website carried into the Track flow.

- **Claude.ai catalog seed** — Updated the known subscription catalog so Claude.ai/Anthropic transaction descriptors match the Claude Pro subscription entry.

- **Subscription transaction match search** — Added `/api/subscriptions/transaction-matches` for the Subscriptions page. Bank transaction search now annotates known catalog hits, shows "Known: service" badges, and pre-fills new subscriptions from catalog metadata when available.
- **Payoff Simulator page** — New `/payoff` route in sidebar. Select any debt from a dropdown; inputs auto-populate from bill rate, minimum, and expected amount (all editable). Live-updating custom SVG chart with 3 tracks: slate dashed (min-only), indigo dashed (snowball plan), amber solid (simulation). Stats cards show interest saved vs minimum, time saved, and total paid breakdown. "Apply to budget" pushes sim payment back to bill's expected amount with undo support.
- **Snowball plan lifecycle** — Snowball page now supports committing to a plan. "Start Snowball Plan" button appears once ≥3 readiness items are checked. Active plan shows a collapsible emerald banner with pulsing status dot, per-debt progress bars, and on-track/ahead/behind indicators computed from the plan's initial snapshot vs. current balances. Actions: Pause · Resume · Complete · Abandon · New Plan (with AlertDialog confirmation).

- **Snowball plan history** — Collapsible history panel at the bottom of the Snowball page lists all past plans (completed, abandoned, paused) with status badges, date ranges, and expandable debt snapshot tables showing starting balance, projected payoff, projected interest, and current balance with "Paid off ✓" on cleared debts.
- **`snowball_plans` table** — Migration v0.73 adds persistent plan storage: status, method, extra_payment, started/paused/completed timestamps, and a JSON plan_snapshot of the initial projection and per-debt starting balances. 8 new API endpoints under `/api/snowball/plans`.
- **Price Change Insights panel** — Tracker page now shows a collapsible amber panel when recurring bills have been paid at a different amount than expected for 2+ consecutive months. Per-bill "Update to $X.XX" action (with undo toast) and "Dismiss" (hidden for 30 days). TrendingUp/TrendingDown icons and teal palette for decreases.
- **Drift detection service** — `driftService.getDriftReport()` computes a rolling median of the last 3 months of payments per bill and compares it against `expected_amount`. Flags when `|delta| ≥ $1 AND |drift%| ≥ threshold`.
- **Price-change email digest** — Daily worker now calls `runDriftNotifications()`, sending a single amber-styled digest email per user listing all bills with changed amounts (old → new, Δ%).
- **Drift snooze persistence** — `drift_snoozed_until` column on `bills` (migration v0.71). `POST /api/bills/:id/snooze-drift` sets a 30-day snooze server-side.
- **"Notify on price changes" toggle** — New notification preference in ProfilePage, backed by `notify_amount_change` column on `users` (migration v0.71).
- **Price change sensitivity setting** — "Price change sensitivity" `%` input in SettingsPage Billing Behavior section. Stored as `drift_threshold_pct` in per-user settings (default 5%, range 1–25%).

### 🧹 Roadmap Audit

- **Audited all FUTURE.md items** against current codebase:
  - Removed: Architecture: Business Logic Extraction (IS_IMPLEMENTED)
  - Removed: Debt Snowball Readiness Checklist (IS_IMPLEMENTED)
  - Updated status: Keyboard Navigation/Shortcuts → partial (Esc + Cmd+K done, arrow-key grid not)
  - Confirmed not implemented: Projected Cash Flow, Recurring Payment Rules (partial), Calendar Agenda, Filtered Exports, Payment Method Tracking, Unit Tests, Bill Grouping, Form State Management — all remain in FUTURE.md

### 🛠 Internal

- **Migration hardening** — Made late snooze/drift migrations idempotent for fresh databases.
- **Subscription matching tests** — Added coverage for known catalog recommendations and catalog-annotated subscription transaction search.

### 🔧 Changed

- **Bump** — `0.34.0` → `0.34.1`

---

## v0.34.0

### 🚀 Features

- **Overdue Command Center** — Tracker page now shows a collapsible command center for overdue bills. Per-bill Pay Now, Skip, and Snooze (1/3/7 days). Snoozed bills hidden with a count hint in the header.
- **Sidebar overdue badge** — Tracker menu item shows live overdue count on desktop nav pill and mobile drawer.
- **Snooze persistence** — `snoozed_until` column on `monthly_bill_state` (migration v0.70). Backend validates and persists snooze dates through `PUT /bills/:id/monthly-state`.
- **Overdue count endpoint** — `GET /api/tracker/overdue-count` with client-side polling (2-min stale, 5-min poll, tab-only).
- **Instant bill search and filters** — Bills and Tracker now include search inputs for quickly finding bills by name, category, notes, or amount. Bills adds category, billing-cycle, autopay, due-bucket, debt, and inactive filters; Tracker adds current-month category, billing-cycle, unpaid, overdue, autopay, due-bucket, and debt filters.
- **Command palette bill lookup** — Added a global Ctrl+K command palette for finding bills by name, category, notes, due details, or amount, with quick jumps into Bills or the current Tracker search.
- **Partial payment tracking** — Tracker now shows paid-versus-expected progress for each bill, supports multiple payments in the same month, and includes a payment ledger for adding, editing, and reviewing installment payments.
- **Tracker overpayment remaining math** — Paying more than a bill's due amount no longer makes Tracker remaining/progress math look reversed. Overpayments now cap paid-toward-due calculations at the amount owed while still showing the extra amount as overpaid.

### 🔧 Changed

- **CleanupService** — Uses `BACKUP_DIR` import from backupService directly. Added `.xlsx` export file sweep with 24h cutoff.
- **BackupScheduler** — `computeNextRun` now exported for external use.
- **Roadmap grid** — Sizes from populated priority lanes only. With a single lane (e.g. only LOW items), lane uses full width instead of narrow 5-column slot.
- **Bump** — `0.33.8.7` → `0.34.0`

---

## v0.33.8.7

### 🛠 Reverted

- **Compact tracker mode** — Removed `hasBoth` side-by-side buckets, `compact` prop from Row/Bucket, `2xl:min-w-[700px]` table override, `2xl:hidden` on Last Month, and narrower Notes/Actions/Due column widths. Restored original single-column layout with `min-w-[1120px]`, Due 10%, Actions 10%, Notes 23%.
- **Wider max-width at 2xl+** — Removed `2xl:max-w-[2000px]` from Layout, Sidebar, AdminShell, footer, mobile nav. All back to `max-w-[1500px]`.

### Kept

- **"S" badge** — Subscription badge shortened to "S" in all four locations.
- **Bucket remaining/Done header** — Per-bucket remaining balance and Done label.
- **MkDocs directory** added to `.gitignore`.

---

## v0.33.8.6

### 🎨 Design

- **Wider max-width at 2xl+** — All layout containers (Sidebar, Layout, AdminShell, footer, mobile nav) now cap at 2000px at ≥1536px instead of 1500px. With `lg:px-8`, content area grows from ~1436px to ~1936px — each tracker bucket gets ~958px, enough for all 8 columns with Notes clearly readable.

---

## v0.33.8.5

### 🎨 Design

- **"S" badge (compact)** — Subscription badge shortened to "S" in all four locations (desktop tracker, mobile tracker, desktop bills table, mobile bills row), now with matching border style.
- **Tracker bucket side-by-side** — When both buckets (1st–14th, 15th–31st) have bills, they render in a 2-column grid at 2xl+ instead of stacked.
- **Compact bucket mode** — `compact` prop narrows table min-width to 700px at 2xl+, hides Last Month column, shrinks Notes (23%→16%) and Actions (10%→8%) columns, Due trimmed (10%→9%).
- **Bucket header: remaining summary** — Shows "Remaining" and "Done" labels alongside paid/total/overpaid in bucket headers.

### 🛠 Internal

- Removed standalone `Remaining` summary card from the summary row (redundant with bucket header).
- `Row` and `Bucket` components accept `compact = false` prop.

---

## v0.33.8.4

### 🚀 Features

- **90-day backfill button** — New "90d Backfill" button per SimpleFIN connection pulls up to 89 days of transaction history. Available alongside Sync Now; both disable while either is in-flight.
- **Auto-seed on first connect** — New connections automatically get an 89-day seed sync on first connect (vs 30-day routine syncs).

### 🐛 Bug Fixes

- **Status page error logic** — `errorRow` query now adds `AND status = 'error'`. Erlist advisories stored in `last_error` on `status = 'active'` sources no longer show "Error" on the SimpleFIN Sync card.

### 🎨 Design

- **Status page redesign** — Cards now have colored top borders matching their tone, icons in headers, organized into Infrastructure/Services/App Health/Software/Errors sections with section labels and dividers. Health banner with glowing dot indicator. Consistent padding, text sizing, and spacing throughout.

### 🛠 Internal

- `sinceEpoch()` replaced with `sinceEpochDays(days)` — explicit parameter instead of reading config.
- `runSync()` now accepts `{ days }` option; detects first sync (89 days) vs routine (30 days).
- New `backfillDataSource()` export — forces 89 days regardless.
- New `POST /api/data-sources/:id/backfill` route.
- New `api.backfillDataSource(id)` client method.

---

## v0.33.8.3

### 🚀 Features

- **Subscription badge (indigo)** — `Sub` badge in all four locations (desktop tracker, mobile tracker, desktop bills table, mobile bills row), toggleable via Bills page display preferences.
- **SimpleFIN Sync status card** — New card in Operations grid on Status page showing connections, accounts, last sync, next check, interval, and any errors.
- **Daily worker now starts** — `dailyWorker.start()` was never called; autopay marking, notifications, session pruning, and scheduled cleanup are now active.

### 🐛 Bug Fixes

- **Tracker overdue count** — `overdue_count` now runs a real SQL query: active monthly bills where due_day < today, no payment this month, and not skipped.
- **Status page accuracy** — Header subtitle reflects `data.ok` ("All systems operational" / "One or more systems need attention"). Application card shows "Degraded" in red when not ok. Worker status pill now checks `last_error` — a running worker with errors shows "Error" red instead of "Running" green. "Stopped" renamed to "Error" for accuracy.
- **SimpleFIN Recommendations title** — Removed redundant "SimpleFIN" prefix from card title on Subscriptions page.

### 🛠 Internal

- `routes/status.js` — `bank_sync` block returns config, worker state, DB aggregates.

---

## v0.33.8.2

### 🐛 Bug Fixes

- **Georgia font bleed eliminated** — Removed `Georgia` from `--font-serif` fallback stack (replaced with `ui-serif`). Georgia now has exactly one entry point: the `GeorgiaDigits` @font-face with `unicode-range` for digits and currency only.
- **`--font-mono` gets GeorgiaDigits** — Added `'GeorgiaDigits'` to the mono stack so the snowball extra payment input fields also get Georgia for digit characters.

---

## v0.33.8.1

### 🚀 Features

- **Subscription catalog v2** — 90 new services across 16 new categories: AI (Suno, Midjourney, Grok, ElevenLabs, Character.ai, Runway, Windsurf, Leonardo.ai), home security (Ring, Nest, SimpliSafe, ADT, Arlo, Wyze, Abode), financial data (SimpleFIN Bridge, Tiller, Monarch, Empower), cloud backup (Backblaze, Carbonite, iDrive), email (Proton Mail, Fastmail, Superhuman, Hey), security/privacy (Bitwarden, Keeper, LastPass, Proton VPN, Mullvad, PIA, Proton Pass, SimpleLogin), identity protection, comics, genealogy, telehealth, website builders, learning platforms, project management, email marketing, cloud compute, media server, homelab/network, and productivity tools.
- **Category corrections** — Discord Nitro (`news` → `software`), Twitch Turbo (`news` → `streaming`), X Premium (`news` → `software`).
- **GeorgiaDigits font-face** — Browser loads Georgia only for digit/currency codepoints via `unicode-range`. Letters continue with Inter/Roboto. Applied globally via `--font-sans` and `.tracker-number`.

### 🛠 Internal

- Migration `v0.69` — seeds `SUBSCRIPTION_CATALOG_V2_ROWS`, fixes 3 existing category bugs, skips duplicates.

---

## v0.33.8.0

### 🚀 Features

- **Advisory non-bill filter system** — New `advisoryFilterService.js` with lazy in-memory cache checks transaction titles against 5,000+ advisory patterns and 83 bill-like override terms. High-confidence matches suppress "Create Bill" in favor of "Probably not a bill · create anyway" text link. Medium confidence mutes the Create Bill button. Lazy-cached on first use, seeded on startup via migration v0.68.

### 🐛 Bug Fixes

- **BillModal onSave now returns saved bill** — `onSave` callback receives the saved/updated bill object, enabling downstream actions like auto-matching a transaction after bill creation.
- **Transaction list includes advisory_filter** — Each row returns `advisory_filter: null | { confidence, category, rationale }` from the server.

### 🛠 Internal

- Migration `v0.68` — seeds `advisory_non_bill_filters` and `advisory_bill_like_overrides` from `docs/advisory_non_bill_transaction_filters_us_ms_5000.json`. Idempotent (skips if already seeded).
- New tables: `advisory_non_bill_filters` (pattern, confidence, category, rationale) and `advisory_bill_like_overrides` (override terms).

---

## v0.33.7.3

### 🐛 Bug Fixes

- **SimpleFIN transaction table now uses fixed column sizing** — Long transaction text no longer pushes action buttons off-screen. Action buttons are compact icon-only with aria-label/title for accessibility. Long matched bill names are truncated.

---

## v0.33.7.2

### 🚀 Features

- **SimpleFIN payment backfill** — The bill edit/subscription modal now shows a "Sync payments" button for bills that have a merchant rule stored (`has_merchant_rule === 1`). This covers both "Track from recommendation" and "Link to bill" flows. Clicking it calls `POST /api/bills/:id/sync-simplefin-payments` which scans unmatched negative transactions matching the bill's merchant rules and auto-creates `payment_source = 'auto_match'` records with the transaction's date and amount.

### 🐛 Bug Fixes

- **Subscription -> bill flow now creates payments** — Both `POST /api/subscriptions/recommendations/match-bill` and the "Create subscription from recommendation" path now insert auto-match payment records alongside the transaction match update.

### 🛠 Internal

- `GET /api/bills/:id` now returns `has_merchant_rule` boolean for conditional UI rendering.
- New helper `syncBillPaymentsFromSimplefin()` in `billMerchantRuleService.js` handles merchant extraction, rule fallback from notes, transaction scanning, and payment creation.

---

## v0.28.01

### 🏆 Major Features

- **Transaction foundation and CSV import** — New `data_sources`, `financial_accounts`, and `transactions` tables provide the shared data layer for manual, file-import, and provider-sync workflows. CSV transaction import on the Data page offers upload → preview → column mapping → commit with SHA-256 dedupe and import-history logging.

### 🚀 Features

- **Manual bill payment history** — The Bills edit/detail modal now shows a payment history ledger with paid date, amount, method, notes, and payment source.
- **Bills-side payment management** — Users can add, edit, soft-delete, and restore manual payments from the bill modal using the existing payment APIs.
- **Payment source metadata** — The existing `payments` table now carries `payment_source` and `transaction_id` metadata so manual records can become the canonical base for later import and sync work without adding a new payment table.
- **Transaction CSV import** — The Data page now includes a transaction CSV importer with preview, column mapping, commit counts, duplicate skipping, and import-history logging into the shared `transactions` table.

### 🌟 Enhancements

- **Canonical payment ledger** — Payment responses now include source metadata while preserving existing REAL-dollar payment amounts, partial-payment status derivation, and Tracker payment behavior.
- **Import controls** — `DATA_IMPORT_ENABLED=false` now disables import preview/apply/commit endpoints, and CSV import is available through both `/api/import/csv/*` and `/api/imports/csv/*`.

### Release Image

![Doing my part](/img/doingmypart.jpg)

## v0.28.0

### 🏆 Major Features

- **Current 0.28.0 update highlights** — Replaced older release-card content with only the latest major v0.28.0 highlights so the popup focuses on the current payment/settings, privacy/login, security, release-note, roadmap, and surface-polish changes.
- **Recoverable deletes for financial records** — Bill and category deletes now use confirmation dialogs, move records into a 30-day recovery window instead of immediately destroying history, and show undo actions from the success toast. Payment removal now also requires confirmation and uses the existing soft-delete/restore path with undo.


### 🚀 Features

- **Bill Health page** — Added `/health` with a backend `/api/bills/audit` check that flags incomplete bill setups, including missing due days, missing categories, debt bills without minimum payments, autopay bills without reference details, and debt balances without APRs.
- **Login device details** — Login history now stores parsed browser, OS, device type, and a short privacy-preserving device fingerprint hash derived from user-agent plus coarse IP prefix. The Profile login history dialog shows the richer device details to help users recognize their own sign-ins.
- **Profile last-login card** — The Profile summary now shows the most recent login date, IP address, device type, browser, and OS inline. Clicking the modern last-login card opens the full login history modal with device IDs and recent sign-in details.
- **Public privacy page** — Added public `/api/privacy` and `/privacy` pages with a modern, scan-friendly policy layout, plus an About page Privacy button. The Profile login-history modal now notes that login device details are shown only to the user and are not shared with admins in the app UI.
- **Release notes image support** — Release notes now render Markdown image blocks in a centered responsive frame, and the in-app release notes dialog displays the `doingmypart.jpg` release image at the end of the v0.28.0 entry.
- **Backend-controlled update card reset** — The “What’s new” card now relies only on the backend `last_seen_version` check. `/api/auth/me` exposes the active `release_notes_version`, acknowledging an update stores that version, and any future package-version update will automatically show the card again for users who have not seen it.
- **Instant bill search and filters** — Bills and Tracker now include search inputs for quickly finding bills by name, category, notes, or amount. Bills adds category, billing-cycle, autopay, due-bucket, debt, and inactive filters; Tracker adds current-month category, billing-cycle, unpaid, overdue, autopay, due-bucket, and debt filters.
- **Command palette bill lookup** — Added a global Ctrl+K command palette for finding bills by name, category, notes, due details, or amount, with quick jumps into Bills or the current Tracker search.
- **Partial payment tracking** — Tracker now shows paid-versus-expected progress for each bill, supports multiple payments in the same month, and includes a payment ledger for adding, editing, and reviewing installment payments.
- **One-click lower monthly bill handling** — Tracker now shows a `Bill was lower` shortcut when a payment is below the expected amount. Clicking it sets that month’s actual amount to the paid total, marking the bill settled for the month without editing the full bill or opening the monthly state dialog.
- **Autopay suggestions and auto-mark paid** — Autopay-heavy users can now reduce manual tracking for bills they do not normally think about. Autopay/autodraft bills with `autodraft_status = assumed_paid` create due-date payment suggestions on Tracker, users can confirm or dismiss each suggestion, and dismissed suggestions are remembered per bill/month. Bills can also opt into the new `auto_mark_paid` setting to automatically record the expected payment on the due date via the Tracker due-date check. Suggested autopay actions now use a compact modern pill with confirm/dismiss icon controls on desktop and mobile.
- **Bill duplication and templates** — Bills can now be duplicated from row actions or the edit modal, created from built-in Utility, Credit Card, Subscription, and Loan templates, and saved as reusable user templates for later bill setup.

### 🌟 Enhancements

- **Richer demo debt seed** — Demo data now includes named credit-card debts such as Discover It, Capital One Quicksilver, and Chase Freedom with balances, APRs, minimum payments, and Snowball inclusion/order already populated.
- **Cleaner category defaults** — Default category seeding now includes Food, Beauty, Entertainment, and Pets, and the Categories page hides empty categories by default with a quick inline show-empty toggle.
- **Debt Snowball Ramsey Mode** — Snowball now defaults to a strict Dave Ramsey flow: smallest balance first, no drag reordering while Ramsey Mode is on, and mortgage/housing categories no longer auto-enter Baby Step 2 unless manually included. Turning Ramsey Mode off restores custom drag ordering and saved order behavior.
- **Snowball guardrails** — The Snowball page now highlights the next win, shows the current attack amount, warns when Custom Order drifts from smallest-balance-first, calls out debts that have a balance but no minimum payment, and includes an inline Ramsey readiness checklist for current bills, starter emergency fund, consumer debts, minimum payments, and extra snowball budget.
- **Roadmap responsiveness** — Roadmap lanes now use a five-column layout only on very wide screens, a balanced three-column layout on normal desktop/admin-shell widths, two columns on tablets, and a single column on mobile. Long titles, notes, and file names now wrap or scroll instead of compressing cards.
- **Calmer app surfaces** — Darkened the top navigation, shared cards, table surfaces, and page gradients slightly so the interface feels less glowy while keeping the same design language across app, admin, About, Privacy, and Release Notes pages.
- **Tracker period balance** — The Tracker summary no longer shows one generic remaining balance. Before the 15th it shows the `1st balance`; on and after the 15th it shows the `15th balance`, using the matching starting amount and paid total for that period.
- **Calendar money map redesign** — Calendar now starts with a monthly money map showing available money, extra income, assigned bills, and after-bills remaining so users coming from budgeting apps can understand the month without hunting across pages. The 1st and 15th available-money amounts are marked directly on the calendar, day details show available-money context, and the sidebar includes a compact Snowball payoff glance with a link to the full Snowball page.

### 🐛 Bug Fixes

- **Payment input validation** — Centralized payment validation now requires positive finite amounts and real `YYYY-MM-DD` payment dates. Manual payment creation, quick pay, bulk payment creation, payment edits, and bill toggle-paid all reject malformed dates, impossible calendar dates, zero/negative amounts, `Infinity`, `NaN`, and partial numeric strings before data reaches SQLite.
- **Tracker overpayment remaining math** — Paying more than a bill’s due amount no longer makes Tracker remaining/progress math look reversed. Overpayments now cap paid-toward-due calculations at the amount owed while still showing the extra amount as overpaid.
- **Recurring autopay status preservation** — Confirming suggested autopay payments, quick-paying, or manually recording payments no longer changes bill-level `assumed_paid` autodraft settings to one-time `confirmed`, so autopay suggestions and auto-marking continue working in future months.
- **Cycle-aware tracker recurrence** — Tracker due dates now respect stored `cycle_type` and `cycle_day` values. Weekly and biweekly bills use their selected weekday, quarterly and annual bills only appear in their assigned months, and cycle-aware payment ranges prevent non-monthly bills from being treated as ordinary monthly bills.
- **User-scoped settings** — `/api/settings` now stores display and billing preferences in a `user_settings` table keyed by `user_id` instead of writing shared global settings. Existing global values are migrated into per-user rows, and tracker/calendar status calculations now use each user's own `grace_period_days`.
- **CSRF logout-all protection** — Removed the unnecessary `/api/auth/logout-all` CSRF bypass. Logout-all now requires the SPA's normal `x-csrf-token` header like other authenticated state-changing requests.
- **Password validation alignment** — Admin user creation, admin password reset, add-user validation, and first-login password-change validation now require 8 characters in the frontend to match backend password rules.
- **CSRF SPA documentation** — Updated CSRF comments and docs to state that this SPA intentionally keeps the CSRF token cookie readable for the double-submit header flow. `CSRF_HTTP_ONLY=true` is no longer recommended unless token delivery changes.
- **API and tooling cleanup** — Removed the duplicate `updateBillSnowball` client API key. The Vite config now uses an `.mjs` module file, and `npm run check` validates backend CommonJS files with `node --check` while using the Vite build for frontend ESM/JSX.
- **Bills-tab import duplicates** — The `/data` XLSX **Bills** tab now tracks which matched rows were already handled during the current preview, imports only the remaining rows on repeat clicks, and changes the button to `All imported` when nothing is left. Matching the same bill/month/amount again is now counted as a skipped duplicate instead of a fresh update.
- **Roadmap route and layout** — Added the admin-protected `/roadmap` route, updated navigation to use it, widened the admin shell, and tightened roadmap breakpoints so priority lanes display cleanly on desktop, tablet, and mobile without runtime `matchMedia` issues.
- **Bill template hardening** — Saved bill templates are now validated before storage, bad template JSON can no longer break the templates endpoint, and duplicated/template-created bills gracefully fall back when a saved category is no longer available.

### Release Image

![Doing my part](/img/doingmypart.jpg)

## v0.27.04

### Added

- **Bills page redesign** — Replaced the dense HTML table with a modern card-based layout. Each bill shows name, category badge, autopay/2FA indicators, due day, billing cycle, APR (colour-coded: amber ≥15%, red ≥25%), and current balance inline. Icon action buttons (edit, deactivate/activate, history, delete) are always visible.
- **Column visibility** — A "Columns" button in the Bills header opens a persistent display-options panel. Each of the nine fields (category, due day, amount, billing cycle, APR, balance, min payment, autopay badge, 2FA badge) can be individually toggled. Selections are saved to `localStorage` and restored on every visit.
- **Login history** — Every successful sign-in (local and OIDC) is recorded with timestamp, IP address, and browser/OS. Only the last 3 logins per user are kept. The Last Login field on the Profile page is now a clickable link that opens a modal showing the full history with device icons and confidence indicators.
- **Import by bill** — The XLSX import page now has a **Bills** tab alongside the existing Rows tab. It lists every existing bill that has matching rows in the uploaded file, with match counts and a date/amount preview. Clicking **Import N** immediately applies all rows for that bill — no row-by-row review required.
- **APR projection engine** — `aprService.js` provides pure-math utilities: `monthlyInterest`, `monthsToPayoff`, `totalInterestPaid`, `amortizationSchedule`, and `calculateMinimumOnly`. The snowball projection now returns three methods (snowball, avalanche, minimum-only) with a `comparison` block showing months saved and interest saved vs paying minimums only. Each debt result includes an `apr_snapshot` (monthly interest, principal per payment, interest % of minimum). `GET /api/bills/:id/amortization` returns a full month-by-month schedule.
- **Live snowball projection panel** — The entire projection sidebar (not just the attack card) now updates instantly as you type the extra monthly budget, with no network round-trip.
- **Drag visual feedback** — Snowball cards now visually lift off the surface when grabbed (scale up, deep shadow, primary ring). The landing slot dims and shows a ring simultaneously.
- **Authentik logo** — The OIDC login button shows the Authentik brand icon when the provider name contains "authentik".
- **About page improvements** — Update status (up to date / update available) is now shown to all users, not just admins. The Sign In button is hidden when already signed in. The Back button navigates to the app when authenticated.

### Fixed

- **Duplicate route** — Removed a duplicate `PATCH /api/bills/:id/snowball` handler that would have silently overwritten both fields instead of doing a partial update.
- **jsconfig deprecation** — Added `"ignoreDeprecations": "6.0"` to suppress the TypeScript `baseUrl` deprecation warning.
- **Roadmap page** — Chevron icons now correctly rotate when a card is expanded (`group-data-[state=open]:rotate-180` replaces the broken `group-aria-expanded` variant). "Expand/Collapse All" now works by remounting cards with the new default state via a `forceKey`. The `laneForPriority` normalisation no longer misclassifies "nice to have" items as "low" priority. The dev log tab no longer leaks stale cancellation flags. About page version now shows immediately from the Vite-injected `APP_VERSION` constant with a proper loading state for the update check (spinner → Up to date / Update available / Could not check).

## v0.27.03

### Added

- **APR calculation service** — New `aprService.js` with pure math utilities: `monthlyInterest`, `monthsToPayoff`, `totalInterestPaid`, `amortizationSchedule`, `calculateMinimumOnly`, and `debtAprSnapshot`.
- **Minimum-only projection** — `GET /api/snowball/projection` now returns a third method (`minimum_only`) showing what happens if each debt is paid independently at its minimum with no snowball rolling. This is the "do nothing extra" baseline.
- **Snowball comparison block** — Projection response includes a `comparison` object with `months_saved`, `years_saved`, and `interest_saved` vs the minimum-only baseline — the headline motivation numbers for the Dave Ramsey method.
- **Per-debt APR snapshot** — Every debt in all three projection methods now includes an `apr_snapshot` block: `monthly_interest` (dollars accruing this month), `principal_per_min_pmt` (principal reduction from the minimum payment), `interest_pct_of_min` (% of minimum that goes to interest), and `annual_interest_estimate`.
- **Bill amortization endpoint** — `GET /api/bills/:id/amortization` returns a full month-by-month schedule `{ month, payment, principal, interest, balance }` for a single debt. Optional `?payment=X` models higher payments; `?max_months=N` caps the response length.

## v0.27.02

### Added

- **Debt Snowball page** — New page built around Dave Ramsey's debt snowball method. Drag-and-drop card ordering via pointer events (touch and mouse), auto-arrange by smallest balance, and a sticky projection sidebar showing per-debt payoff dates and overall debt-free date.
- **Avalanche comparison** — Projection sidebar shows the snowball result alongside the avalanche method (highest rate first), including interest saved and months faster or slower.
- **Live payoff preview** — Typing the extra monthly budget updates all debt payoff dates and the debt-free date instantly client-side with no network round-trip.
- **Debt Details on Bills** — Edit Bill modal has a collapsible "Debt / Credit Details" section with Current Balance (inline-editable on the Snowball page), Minimum Payment, and APR. Bills in Credit Cards, Loans, Mortgage, or Housing categories are auto-detected.
- **Payment → balance sync** — Recording a payment on a debt bill reduces its current balance by the principal portion (payment minus one month of accrued interest). Un-marking, deleting, or restoring a payment reverses or re-applies the change exactly.
- **APR colour coding** — Interest rates ≥ 15% shown amber, ≥ 25% shown red on the Snowball page.
- **Update check** — Backend fetches the latest release from the Forgejo repository (cached 1 hour). Admin status page shows current vs. latest version with a force-refresh "Check Now" button. About page shows update status for all users.
- **Release notes notification** — `users.last_seen_version` tracks which version each user last acknowledged. First login after an update opens the "What's new" dialog; dismissing it calls `POST /auth/acknowledge-version`.
- **Snowball exempt** — Bills in debt categories can be individually hidden from the Snowball page using the toggle in Edit Bill.

### Changed

- **Version source of truth** — `package.json` is the single version source. Vite injects it into the client bundle at build time; `GET /api/version` always returns `pkg.version` regardless of HISTORY.md state.
- **About page** — Shows update status for all users. Sign In button hidden when already authenticated. Back button returns to the app when logged in.

### Fixed

- **`GET /api/version` version field** — Previously read from HISTORY.md header; now always returns the running `package.json` version.

## v0.26.0

### Added
- **Dual-column XLSX import** — Spreadsheets with two side-by-side bill tables (bills due ~1st and ~15th) are now both imported. Left half defaults `due_day` to 1, right half defaults to 15.
- **Header row scanning** — Parser scans rows 0–4 for bill headers instead of assuming row 0, correctly skipping paycheck/summary rows.
- **Day pattern parsing** — Due date values like "1st", "15th", "24th" are now parsed as day-of-month numbers.
- **Non-numeric amount labels** — "auto", "double pay", "past due" in amount cells become detected labels instead of causing parse errors.

### Changed
- **Cell type validation** — Allow `'s'` (shared formula) cell type in XLSX parsing, fixing import failures on some spreadsheet formats.

### Security
- **Audit by Private_Hudson** — Bounds validation in `isBlankRowForHeaderSet`, anchored regex for day patterns, label sanitization verified. All checks PASS.

## v0.25.0

### Added
- **Roadmap Page** — Kanban-style priority lanes (CRITICAL → NICE TO HAVE) with collapsible items, lazy-loaded activity log tab, admin-only `/api/about/roadmap` and `/api/about/dev-log` endpoints. Replaces AdminDashboard.

### Fixed
- **Import CSRF failure** — XLSX, SQLite, and backup file imports now include `x-csrf-token` header in all three raw `fetch()` calls (`importAdminBackup`, `previewSpreadsheetImport`, `previewUserDbImport`). Previously returned "session expired or fraudulent" 403 on every import attempt.

### Removed
- **AdminDashboard.jsx** — Replaced by RoadmapPage with kanban layout.

## v0.24.4

### Changed
- **Analytics page mobile layout** — Charts, heatmap, controls, donut chart, and checkbox grid now display properly on mobile screens. Heatmap columns narrowed, responsive breakpoints added throughout.

### Fixed
- **Previous month payment toggle** — Clicking payment badges (Missed, Late, Due Soon, Upcoming) on previous months now creates/removes payments for the correct month instead of always using today's date. Backend scopes payment lookup to the viewed year/month; frontend passes year/month context.
- **Mobile tracker row toggle** — MobileTrackerRow StatusBadge was missing clickable/onClick props; now wired up to toggle paid/unpaid.

## v0.24.3

### Changed
- **Status badge toggle is instant** — removed the AlertDialog confirmation popup. Clicking Late/Due Soon/Upcoming/Missed badges now toggles paid/unpaid directly.

## v0.24.2

### Fixed
- **StatusBadge toggle-paid broken** — `handleTogglePaid()` was using `row.bill_id` instead of `row.id`, causing the API call to fail with an undefined bill ID. Clicking Late/Due Soon/Upcoming/Missed badges now correctly toggles paid/unpaid status.

## v0.24.1

### Added
- **Export privacy warning** — Amber alert banner on Download My Data section warning that exports may contain sensitive account metadata (website URLs, usernames, account info). Updated "What's included" list to show monthly starting amounts and history ranges.

## v0.24.0

### Fixed
- **Admin toggle-paid restricted** — Admins can no longer toggle payments on other users' bills. All bill payment mutations now require ownership (`routes/bills.js`).
- **Analytics crash fix** — Imported missing `standardizeError` in `routes/analytics.js`. Invalid query params now return 400 instead of crashing with ReferenceError.
- **Export data integrity** — User exports (Excel and SQLite) now include `cycle_type`, `cycle_day`, and `bill_history_ranges`. Previously, non-monthly recurrence settings and history visibility ranges were lost on export/import.
- **Single-user mode lockout** — Fixed `getSingleModeUser()` joining sessions table unnecessarily. When a configured user had only expired sessions, the join excluded them. Now validates only user existence, active status, and role.
- **Password rate limiter scoped** — `passwordLimiter` moved from all `/api/profile` routes to only `POST /change-password`. Normal profile reads/updates no longer hit password-change rate limits.
- **Profile password change session invalidation** — Fixed `routes/profile.js` referencing `req.sessionId` (never set by requireAuth). Now uses `req.cookies?.[COOKIE_NAME]` consistent with the auth route, so other sessions are properly invalidated on password change.
- **CSRF defaults aligned** — `CSRF_HTTP_ONLY` default changed from `true` to `false`. The SPA uses a double-submit pattern reading `document.cookie`, so `httpOnly=true` was always broken without the docker-compose override. Default now matches actual usage.
- **CSRF protection on password change** — Removed `csrfSkip` exemptions for `/api/auth/change-password` and `/api/profile/change-password`. These are sensitive state mutations and should have CSRF protection like all other authenticated writes.
- **Notification due-day math** — Fixed `runNotifications()` comparing raw timestamps instead of calendar days. Bills due today could be classified as `overdue` instead of `due_today` when checked after midnight had passed. Now normalizes both dates to local date-only before comparison.
- **Upcoming bills validation** — `GET /api/tracker/upcoming` now clamps `days` to `1–365` and defaults invalid/NaN input to 30. Negative or non-numeric values no longer produce empty results.

## v0.23.4

### Fixed
- **Clear Demo Data button now works** — Removed misleading "coming soon" placeholder text. The Clear Demo Data button with AlertDialog confirmation is now accessible from the seeded state view.
- **Seed script user ID bug** — Fixed `seedDemoData.js` creating bills with wrong user ID (`userId` instead of `targetUserId`).
- **Removed duplicate seed endpoint** — Deleted redundant `/api/settings/seed-demo-data` route (canonical endpoint is `/api/user/seed-demo-data`).

## v0.23.3

### Changed
- **Replaced native `confirm()` with shadcn/ui AlertDialog** — TrackerPage (mark as paid) and DataPage (import confirmation) now use themed, accessible AlertDialog components instead of browser-native `confirm()` dialogs. Consistent with the app's design system and supports dark/light mode.
- **STRUCTURE.md tech stack corrected** — Updated from "Next.js App Router" to the actual stack (Vite + React + Tailwind + shadcn/ui + Sonner)

## v0.23.2

### Security
- **CRITICAL: Notification privacy leak fix** — In per-user notification mode, bills were sent to all opted-in recipients regardless of ownership. Added ownership filter (`bill.user_id !== recipient.id`) and orphaned bill guard. Security audit by Private_Hudson confirmed the fix is airtight.
- **Duplicate login route removed** — Deleted `routes/authLogin.js`, consolidating login logic into `routes/auth.js` only.

### Changed
- `services/notificationService.js`: Added per-user ownership filter and null `user_id` guard in notification runner
- `routes/authLogin.js`: Removed (consolidated into `routes/auth.js`)
- `docs/Engineering_Reference_Manual.md`: Removed stale `authLogin.js` duplicate route note, updated version to 0.23.2
- `README.md`: Updated to reflect current features, env vars, security notes, project structure, and known limitations

---

## v0.23.1

### Added
- **Migration Rollback** — New `rollbackMigration()` function in database.js and `POST /api/admin/migrations/rollback` endpoint for admin-only migration rollback
- Rollback support for v0.44 (performance indexes), v0.45 (audit_log table), v0.46 (cycle columns)
- Transaction-wrapped rollback with detailed logging (`[rollback]`, `[rollback-error]`)
- Audit logging for rollback events: `migration.rollback` and `migration.rollback.failure`
- Error codes: `NOT_APPLIED` (404), `ROLLBACK_NOT_SUPPORTED` (422)

### Fixed
- **Duplicate migrationStartTime declaration** — Removed duplicate variable declaration causing syntax error
- **Duplicate else block** — Removed duplicated migration skip branch in `runMigrations()`
- **DB path exposure** — Changed `Opening DB at:` log to use `path.basename()` instead of full path

### Changed
- `routes/admin.js`: Added `rollbackMigration` import and `/migrations/rollback` endpoint
- `db/database.js`: Added `rollbackMigration()` function with transaction support and rollback SQL map

---

## v0.23.0

### Added
- **Migration Logging Enhancement** — Detailed logging for each migration step including timing, error logging with timing, and total migration time reporting
- **Circular Dependency Fix** — Lazy import pattern via `getLogAudit()` function prevents circular dependency with auditService
- **Logging Categories** — `[migration]`, `[migration-error]`, `[migration-failure]` with timing in milliseconds

### Changed
- `db/database.js`: Added `[migration] Applying {version}` log before each migration
- `db/database.js`: Added `[migration] {version} completed in Xms` log after each migration
- `db/database.js`: Added `[migration] All migrations completed in Xms` log after all migrations
- `db/database.js`: Added `[migration-error] Failed after Xms: ...` log on migration failures
- `db/database.js`: Added lazy `getLogAudit()` function with try/catch to avoid circular dependency
- `db/database.js`: Unversioned user notification columns migration now logs timing

### Security
- Audit log injection: ✅ PASS — getLogAudit() only used after initSchema completes
- Lazy import safety: ✅ PASS — try/catch wrapper, fallback empty function
- SQL injection: ✅ PASS — logging only, no dynamic SQL
- Timing manipulation: ✅ PASS — Date.now() local to migration loop
- Circular dependency: ✅ PASS — lazy import avoids require cycle
- Error logging completeness: ✅ PASS — both success and failure paths logged
- Audit logging safety: ✅ PASS — try/catch prevents audit errors from crashing migration

---

## v0.22.3

### Fixed
- **ENV-Seeded Users First-Login Bug** — Admin and regular users created via `INIT_ADMIN_USER`/`INIT_ADMIN_PASS` and `INIT_REGULAR_USER`/`INIT_REGULAR_PASS` environment variables no longer see the first-login/force-password-change flow on container restarts

### Changed
- `setup/firstRun.js`: `runFromEnv()` now resets `first_login=0, must_change_password=0` when updating existing admin and regular users
- `server.js`: Seed logic resets `first_login=0, must_change_password=0` when updating existing regular users  
- `db/database.js`: `[init] Reset password` code now sets `must_change_password=0` instead of `1` to match intended behavior

### Added
- Audit logging (`seed.flag_reset` action) for flag resets in `setup/firstRun.js` and `server.js`
- `db/database.js` init-time flag resets use `console.log` (avoids circular dependency with auditService during DB initialization)

---

## v0.22.2

### Added
- **Session Invalidation on Password Change** — All other sessions are terminated when you change your password; current session gets a new ID
- **Logout All Devices** — New `POST /api/auth/logout-all` endpoint to sign out from every device at once

### Changed
- `invalidateOtherSessions()` helper in authService.js
- Both change-password routes (auth + profile) now rotate session ID
- Added `last_password_change_at` to auth.js change-password for consistency with profile.js
- Audit logging for `logout.all` and `password.change` events

## v0.22.1

### Changed
- **N+1 Query Optimization** — Batch queries replace per-bill loops in tracker and analytics (monthly states, payments, previous month, upcoming)
- Empty bill list edge case handled with `billIds.length > 0` guards

## v0.22.0

### Added
- **React Query Migration** — TrackerPage now uses TanStack Query (useQuery) for data fetching with caching, stale-while-revalidate, and auto-refetch
- **Custom Query Hooks** — `useTracker()`, `useBills()`, `useCategories()` in `client/hooks/useQueries.js`
- **Query DevTools** — React Query DevTools available in development mode
- **QueryClientProvider** — Global config with 2min staleTime, 1 retry, refetchOnWindowFocus disabled

### Changed
- TrackerPage: replaced manual `useState`/`useEffect` with `useTracker()` hook
- `load()` callback replaced by `refetch()` from React Query
- Error handling: `useEffect` + `useRef` pattern prevents duplicate toast notifications

## v0.21.1

### Added
- **Loading Skeletons** — Tracker and Bills pages show skeleton placeholders during data loading with `aria-busy` attributes
- Reusable `Skeleton` component with line, circle, card, button, input variants

## v0.21.0

### Added
- **3-Month Trend Indicator** — Tracker shows up/down/flat trend vs 3-month average with percentage change (↑ green, ↓ red, → gray)
- Trend card with purple gradient header and TrendingUp icon
- Backend: 3-month payment aggregation with year-wrapping, ±2% threshold for "flat"

## v0.20.9

### Added
- **Previous Month Paid** — "Last Month" column on Tracker shows last month's paid amount per bill; summary card shows previous month total
- Backend: `previous_month_paid` per bill row, `previous_month_total` in summary, year-wrapping for January

## v0.20.8

### Added
- **Billing Cycle Sub-categories** — `cycle_type` (monthly/weekly/biweekly/quarterly/annual) and `cycle_day` columns on bills, conditional day selector in UI (ordinal dropdown for monthly, weekday dropdown for weekly/biweekly, free text for quarterly/annual)
- Migration v0.46 adds `cycle_type` and `cycle_day` columns
- Server-side validation of cycle_type values
- Smart defaults: cycle_day auto-sets when cycle_type changes

## v0.20.7

### Added
- **Skip-to-content link** — keyboard users can skip navigation directly to main content
- **ARIA accessibility** — `aria-expanded` and `aria-haspopup` on Tracker menu, `aria-label` on footer, `role="main"` on layout wrapper
- **Main landmark** — proper `<main>` element with unique `id` for skip navigation target

## v0.20.6

### Added
- **Audit logging** — security event tracking via `audit_log` table (migration v0.45)
- **`logAudit()` service** — safe logging function that never crashes the app
- **Logged events:** `login.success`, `login.failure`, `logout`, `password.change`, `role.change`, `csrf.failure`, `profile.update`, `profile.settings.update`
- **Indexes:** `idx_audit_log_user` and `idx_audit_log_action` for query performance

## v0.20.5

### Added
- **Bulk payment validation** — `/api/payments/bulk` now requires `{ payments: [...] }` format
- **Max 50 items per request** — prevents abuse via oversized bulk requests
- **Per-item input validation** — `bill_id` must be integer, `paid_date` must be YYYY-MM-DD, `amount` must be >= 0
- **Duplicate detection** — payments with same `bill_id + paid_date + amount` are skipped, not duplicated
- **Structured response** — `{ created: [...], skipped: [...], errors: [...] }`

## v0.20.4

### Added
- **Migration dependency management** — All 17 versioned migrations now have explicit `dependsOn` fields defining their dependency chain
- **`validateMigrationDependencies()` function** — Validates that a migration's prerequisites have been applied before running it
- **Dependency check logging** — Migrations log `[migration] vX depends on [vY] — satisfied` when dependencies are met
- **Missing dependency handling** — Migrations with unmet dependencies are skipped with a clear error log instead of crashing

## v0.20.3

### Added
- **Database performance indexes** — v0.44 migration adds 4 indexes on frequently queried columns:
  - `idx_bills_user_name` on `bills(user_id, name)` — user-scoped bill lookups
  - `idx_payments_method` on `payments(method)` — payment method filtering
  - `idx_monthly_starting_amounts_user` on `monthly_starting_amounts(user_id)` — user starting amounts
  - `idx_import_history_imported_at` on `import_history(imported_at)` — time-based import queries

## v0.20.2

### Added
- **Transaction wrapping for database migrations** — All migrations (versioned, legacy, and unversioned) now run within BEGIN/COMMIT transactions with ROLLBACK on failure, ensuring atomic schema changes
- **PRAGMA foreign_keys safety** — v0.40 migration uses try/finally to guarantee FK checks are always re-enabled, even on failure

### Fixed
- **Hudson audit fix** — v0.40 migration now restores foreign_keys = ON in a finally block, preventing FK checks from being left disabled if migration fails

## v0.20.1

### Added
- **Code splitting** — All page components (except LoginPage) now lazy-load via React.lazy + Suspense, reducing initial bundle size
- **PageLoader component** — Minimal loading spinner for lazy-loaded routes
- **Version badge on Roadmap page** — Admins see the current version at the top of the dashboard
- **Version in /api/about-admin** — API now returns version from package.json
- **Roadmap nav link** — Admins see "Roadmap" in dropdown menu and admin sidebar
- **/admin/roadmap route** — Direct URL to admin dashboard

## v0.20.0

### Added
- **Admin Dashboard** — New admin-only dashboard with roadmap and activity log sections:
  - **Roadmap section**: Parses FUTURE.md with color-coded priority cards (🔴🟠🟡🔵💭), collapsible, CRITICAL/HIGH expanded by default
  - **Activity Log section**: Parses DEVELOPMENT_LOG.md, reverse chronological, collapsible entries
  - SimpleCollapsible component (custom, no external deps)
- **Priority color coding**: CRITICAL (🔴), HIGH (🟠), MEDIUM (🟡), LOW (🔵), NICE TO HAVE (💭)
- **Responsive scrollbars**: Smooth scrolling for roadmap and activity log sections

### Changed
- **AboutPage.jsx**: Modified to conditionally render AdminDashboard for admin users only; non-admin users see standard About page
- **FUTURE.md**: Updated to v0.20.0

### Security
- **Admin-only access**: AdminDashboard only accessible to authenticated admin users
- **Input validation**: Markdown parsing handles all FUTURE.md and DEVELOPMENT_LOG.md content safely

---

## v0.19.4

### Added
- **Session token expiry cleanup** — Expired sessions are now purged automatically on startup, every 24 hours, and per-user on login. Prevents `sessions` table bloat and potential token reuse.
- **`created_at` column on sessions** — v0.43 migration adds `created_at` to the sessions table for better cleanup targeting.
- **`SESSION_CLEANUP_INTERVAL_MS` env var** — Configurable cleanup interval (default 24h, max 7 days). Invalid values are rejected with a warning.

### Security
- **Input validation on `SESSION_CLEANUP_INTERVAL_MS`** — Rejects 0, negative, and >7-day values to prevent DoS via event loop starvation (Hudson finding).

## v0.19.3

### Fixed
- **Legacy database login now works** — When `INIT_ADMIN_PASS` is set, the default admin's password is reset and `must_change_password=1` is enforced. This solves the case where a legacy DB has users with unknown passwords.
- **Legacy migrations now actually run** — Every entry in `reconcileLegacyMigrations()` now has a `run()` function. Migrations whose changes aren't present in the DB (like `is_seeded` columns) are executed instead of silently skipped.
- **v0.40 ownership migration assigns to admin** — Unowned bills/categories now go to the first admin user instead of the first regular user. Prevents data being assigned to a non-admin account.

### Security
- **Removed username from password reset log** — `[init] Reset password for default admin user` no longer includes the username (Hudson finding)
- **Password reset is always explicit** — If `INIT_ADMIN_PASS` is set, the reset happens. If not set, no reset. No silent side-effects.

## v0.19.2

### Added
- **React Error Boundaries** — `ErrorBoundary` component wraps all routes in `App.jsx`. Shows friendly fallback UI with "Try Again" and "Reload Page" buttons instead of a white screen crash. Logs component stack to console for debugging.

### Fixed
- **Legacy database migration login failure** — Users upgrading from pre-migration-tracking databases (before v0.19.1) now log in successfully. The startup flow now detects legacy databases (tables exist but `schema_migrations` is empty), reconciles all previously-applied migrations by checking actual DB state, and marks them as applied without re-running destructive operations.
- **Migration idempotency** — All migrations now check whether their changes are already present before applying, preventing `ALTER TABLE ADD COLUMN` failures on legacy databases.

### Security
- **Migration reconciliation is read-only** — No user data is modified or deleted during legacy detection. All `PRAGMA table_info()` and `sqlite_master` queries use hardcoded identifiers (no user input). Try/catch wrappers prevent partial state on failure. (Verified by Private_Hudson)

## v0.19.1

### Added
- **Regular User Seed Environment Variables** — `INIT_REGULAR_USER` and `INIT_REGULAR_PASS` create a non-admin user on first run for role-based testing
- **Non-admin Test User** — Added `INIT_REGULAR_USER` and `INIT_REGULAR_PASS` env vars for role-based testing

### Changed
- **Database Migration v0.42** — `bill_history_ranges` table creation moved into versioned migration system

### Security
- **Admin-only `/about` endpoint** — Added `/api/about-admin` endpoint serving FUTURE.md and DEVELOPMENT_LOG.md to admins only
- **Rate limiting** — `adminActionLimiter` (30 req/15min per IP) applied to `/api/about-admin`
- **Content sanitization** — Path traversal protection, internal IP/password redaction, error sanitization in `routes/aboutAdmin.js`
- **XSS prevention** — `rehype-sanitize` added to ReactMarkdown component in AboutPage.jsx
- **Route guards** — `/admin/about` route protected with `RequireAuth role="admin"` in client/App.jsx

### Fixed
- First-time login rate limiting bypass when no users exist
- Password change rate limiter only applies to actual password change routes (not login)
- CSRF middleware properly exempts login endpoint
- Admin user auto-creation using bcryptjs
- Backup operation rate limiter scoped to backup routes only

### Notes
- Regular user seed occurs only if both `INIT_REGULAR_USER` and `INIT_REGULAR_PASS` are set
- Regular users are created with `role='user'` and `is_default_admin=0`
- Migration system now handles `bill_history_ranges` table creation via v0.42
- Admin about endpoint is fully protected and only serves project documentation files

## v0.19.0

### Added
- **Demo Data Seeding** — Users can seed their account with 20 realistic demo bills and 8 demo categories from the Data section for testing purposes
- **Demo Data Removal** — Users can clear only their seeded demo data (user-created bills remain unaffected)
- **CSRF Protection** — Configurable CSRF token handling for SPA mode (`CSRF_HTTP_ONLY`, `CSRF_SAME_SITE` env vars)
- **UI Improvements** — Mobile-responsive sidebar navigation, loading skeletons for Settings, improved BillModal mobile layout
- **Click-to-Toggle Paid Status** — Users can click on Paid/Unpaid status in Tracker to toggle payment status with confirmation dialog
- **Performance** — React.memo() optimization applied to StatusBadge, SummaryCard, MobileBillRow, MobileTrackerRow, NavPill, and BrandBlock components to prevent unnecessary re-renders
- **Documentation** — Added CSRF-SPA-Setup.md, Authentik-Integration.md, UI_IMPROVEMENTS.md, RATE_LIMITING_ENHANCEMENT.md

### Security
- Rate limiting applied to demo data operations (3 per 15 minutes)
- Audit logging for demo data clear operations
- Private_Hudson security review completed — all critical/high issues resolved

### Security (2026-05-09)
- **Admin-only `/admin/about` route guard** — React `RequireAuth` middleware protects `/admin/about` route
- **Rate limiting on `/api/about-admin`** — `adminActionLimiter` (30 req/15min per IP) applied to prevent brute-force attempts
- **XSS prevention** — `rehype-sanitize` added to ReactMarkdown component in AboutPage.jsx
- **Content redaction** — `routes/aboutAdmin.js` sanitizes paths, redacts internal IPs, passwords, API keys
- **Error sanitization** — Error messages exclude paths to prevent path disclosure
- **Non-admin test user** — Added `INIT_REGULAR_USER` and `INIT_REGULAR_PASS` env vars for role-based testing

### Fixed
- First-time login rate limiting bypass when no users exist
- Password change rate limiter only applies to actual password change routes (not login)
- CSRF middleware properly exempts login endpoint
- Admin user auto-creation using bcryptjs
- Backup operation rate limiter scoped to backup routes only

### Notes
- Toaster notifications now use Tailwind CSS exclusively (removed inline styles)
- Seed data is user-scoped with `is_seeded` column tracking
- All agent contributions documented in REVIEW.md

## v0.18.4

### Added
- Added user active/inactive management in Admin. Inactive users cannot log in and active sessions are invalidated when they are disabled.
- Added a durable default-admin marker so the built-in default admin remains an admin-only operator account.
- Admin users created after first run can now sign in directly to Tracker while retaining Admin Panel access from the menu.
- Admins can delete any other user, including other admins, with a destructive 2026 warning that all user-owned bill data will be permanently removed.
- Added an `Other` monthly starting amount alongside the 1st and 15th amounts.
- New `monthly_starting_amounts` records store user-scoped, month-specific starting cash with `first_amount`, `fifteenth_amount`, and `other_amount`.
- New `GET /api/monthly-starting-amounts` and `PUT /api/monthly-starting-amounts` endpoints manage monthly starting balances.
- Tracker renamed the “Total Expected” card to “Starting” and shows the selected month’s combined starting amount.
- The Tracker Starting card now has an edit control for setting 1st, 15th, and Other monthly amounts.
- Summary now uses monthly starting balances as the planning base and shows a Starting Balance section.
- Remaining balances deduct paid bills: due days 1-14 from the 1st bucket, due days 15-31 from the 15th bucket, and total paid from combined remaining.
- Added monthly starting amounts to user SQLite and Excel exports, and to user SQLite imports.
- Added a public About page with app version, stack, AI-assistance note, and Release Notes access.
- Release Notes are now available without login.

### Notes
- Starting balances are not bills and are not payments.
- Remaining values can go negative when paid bills exceed starting cash; overages are not blocked.
- Previous month remaining is exposed to Summary as informational context only when available.
- Navigation now groups Overview, Summary, Bills, and Categories under Tracker, and groups Profile, Settings, and Data in the user menu.
- System Status is admin-only and appears in the Admin Panel navigation.
- Profile now focuses on account details, display name, password, and notification preferences in a narrower modern layout.
- Data is restored as a dedicated import/export/history page instead of redirecting into Profile.
- Fixed Admin Panel availability when all managed users have been promoted to admin.
- The default admin account is blocked from Tracker/user-data routes; non-default admins keep regular Tracker access.
- No payment behavior, bill behavior, Calendar, or Analytics behavior was changed.

## v0.18.1

### Changed
- Updated Admin authentik/OIDC issuer help text to show the authentik discovery URL example and clarify that issuer base or full discovery URL can be used.
- Updated the default category seed list to the top 10 common bill categories, safely filling missing user-scoped defaults without renaming or deleting existing categories.
- Categories now return user-scoped active/inactive bill counts, payment counts, bill name previews, and compact bill detail data.
- Categories page now shows compact stat chips for active bills, inactive bills, and payments with a subtle legend.
- Removed the category-level total paid chip from Categories while keeping bill-level paid totals in expanded details.
- Category rows now expand to show bills in that category, with hover/tap summaries for chips and bill names.
- Improved Categories page mobile and tablet layout so chips wrap cleanly and expanded bill details stay readable without page-level horizontal scrolling.
- Added a Summary page for monthly planning with income, expenses, paid expense count, result/savings, and browser Print / PDF output.
- Added minimal user-scoped monthly income support for the Summary page.
- Added a user-scoped `GET /api/summary` endpoint and income save endpoint using existing bills, payments, and monthly bill state data.
- Summary includes a simple income, expenses, and savings chart without adding a new chart library.
- Cleaned up the Summary page layout with a centered planner view, display-first Monthly Plan card, compact income editing, cleaner expense rows, and a calmer chart card.
- Summary Print / PDF behavior remains browser-based and no backend/payment behavior was changed.
- Added a Calendar page with a month grid for user-owned bills and payments, compact day indicators, a legend, monthly progress summary, and day detail dialog.
- Added a user-scoped `GET /api/calendar` endpoint for one-month calendar data using existing bills, payments, categories, and monthly bill state records without schema changes.
- Calendar status and totals respect monthly actual amount overrides, skipped bills, existing due-day clamping, and existing tracker-style late/missed status behavior.
- Added Calendar to the top navigation after Tracker while preserving the existing desktop and mobile nav behavior.
- Improved mobile and tablet responsive rendering across the top navigation, page headers, dialogs, dense tables, Tracker, Bills, Categories, Settings, Status, Admin, Analytics, and Login views.
- Preserved the current desktop layout by keeping existing desktop-oriented layouts at `lg` and above while adding mobile/tablet stacking, scrolling, and tap-friendly controls below that breakpoint.
- Tablet navigation now uses the compact menu to avoid horizontal overflow; user menu, theme toggle, and admin-only navigation remain reachable.
- Dialogs and destructive confirmations now respect mobile viewport width/height and scroll internally when content is long.
- Dense Tracker, Bills, Admin, Analytics, and import/history style tables use horizontal scrolling or mobile stacking so actions remain reachable on smaller screens.
- Tracker and Bills now use stacked mobile/tablet bill rows below `lg`, reducing sideways scrolling for normal bill review, quick payment, and bill actions while preserving the desktop table layouts.
- Tracker mobile notes stay contained in each bill row, so long notes can truncate or scroll locally without forcing the whole bill list sideways.

### Notes
- No auth behavior, tracker/payment/bill business logic, admin permissions, or desktop redesign changes were made.
- No Tracker, Bills, payment, analytics, calendar, auth, or admin behavior was changed for the Categories page updates.
- No Tracker, Bills, payment, Calendar, Analytics, auth, or admin behavior was changed for the Summary page updates.

## v0.18

### Branding
- Replaced the top-navbar dollar-sign placeholder and duplicate text/version brand stack with the selected `/img/logo.png` BillTracker logo.
- The logo now serves as the BillTracker brand in the top navigation while preserving the existing navbar height and route behavior.
- Login now uses the BillTracker logo, shows linked build/version information near the login actions, and uses the authentik icon for OIDC login.
- Admin Authentication Methods now uses subtle authentik branding in the OIDC toggle/configuration/test-login controls.
- Cropped transparent padding from the BillTracker logo asset so it renders larger and more readably in the unchanged-height navbar.
- Promoted the transparent `logo_cut.png` artwork to the served `/img/logo.png` asset and enlarged the login-page logo while keeping the login card layout compact.
- Login logo sizing now follows the login form width so the brand grows and shrinks with the sign-in column instead of rendering too small.
- Legacy `/login.html` now redirects to the modern React `/login` screen so the old static login page is no longer served by stale links.
- Vite now copies only modern React public assets from `client/public`, preventing legacy `public/*.html`, CSS, and JS files from being emitted into `dist`.
- No backend, auth, tracker, bills, categories, settings, status, admin, or navigation-link behavior was changed.

### Analytics
- Added a user-scoped Analytics API at `GET /api/analytics/summary` using existing bills, payments, categories, and monthly bill state data without schema changes.
- Added an Analytics page with date range controls, category and bill filters, inactive/skipped toggles, chart visibility toggles, and a line/area trend option.
- Added monthly spending trend, expected vs actual spend, category spending donut, and pay-on-time heatmap views.
- Added print and browser save-as-PDF report output with print CSS that hides navigation, controls, and interactive actions.
- Analytics queries are scoped to the signed-in user and do not accept or expose cross-user aggregation.

### Security
- **OIDC ID token signature verification** now uses `openid-client@5` for full cryptographic validation via JWKS: signature, issuer, audience, expiry, nonce, and `sub` presence — tokens without a valid signature are rejected
- **OIDC client cache** invalidation path added; cache is keyed by issuer/client/redirect so Admin panel credential changes pick up a fresh client
- OIDC-provisioned accounts (empty `password_hash`, `auth_provider='oidc'`) continue to be blocked from local password login
- Tokens, auth codes, and client secrets are never logged at any point in the OIDC flow
- Session cookies no longer become `Secure` solely because `NODE_ENV=production`; this preserves login on plain-HTTP Docker deployments while still supporting `COOKIE_SECURE=true`, `HTTPS=true`, and HTTPS reverse-proxy detection.

### Added
- **Docker startup volume repair**: runtime now starts through `docker-entrypoint.sh`, creates `/data/db` and `/data/backups`, fixes `/data` ownership for the non-root `bill` user, then drops privileges before launching Node. This prevents SQLite migrations from failing with `SQLITE_READONLY` on mounted volumes.
- **Docker startup migrations**: entrypoint now runs `scripts/migrate-db.js` as the non-root app user before starting the server, so required SQLite schema migrations and seeded defaults complete before the app listens for requests. Set `RUN_DB_MIGRATIONS=false` only for special maintenance runs.
- **Database writability preflight**: startup now checks that `DB_PATH` and its parent directory are writable before opening SQLite, producing a clearer error if a bind mount or volume is genuinely read-only.
- **Release notes Markdown rendering**: the release notes viewer now renders inline Markdown such as `**bold**`, backticked code, and HTTPS links instead of showing the raw markers.
- **authentik configuration testing**: Admin Authentication Methods now includes a live OIDC discovery test for the entered issuer/client/redirect settings and a direct authentik login test button once OIDC is enabled.
- **authentik setup guardrails**: the Admin form now fills the Redirect URI from the current app origin, offers a "Use Current" reset, and warns when the Issuer URL looks like an authorize/token/userinfo endpoint instead of the provider issuer.
- **authentik client auth method**: Admin OIDC settings now include an advanced `client_secret_basic` / `client_secret_post` token endpoint authentication method selector. The default remains `client_secret_basic`, matching the previous `openid-client` behavior.
- **Admin user role management**: Admin Users table now lets an admin promote another user to `admin` or demote an admin back to `user`, with protections against changing your own role or removing the last admin account.
- **Single-user mode recovery**: User Settings now shows a Login Mode section while single-user mode is active, allowing the default user to restore multi-user login without needing access to Admin routes.
- **Admin navigation parity**: Admin users now keep the normal app navigation and get an Admin link after Status; `/admin` uses the same top nav so admins can return to Tracker/Bills/Categories/Profile/Settings/Status without typing a URL. Backend `/admin` protection remains unchanged.
- **Admin-controlled auth method toggles** in Admin panel (Authentication Methods card):
  - `local_login_enabled` — enable/disable local username/password login (default: enabled)
  - `oidc_login_enabled` — enable/disable OIDC/authentik login (default: disabled)
  - Database-backed authentik/OIDC provider settings: provider name, issuer URL, client ID, client secret, redirect URI, scopes, auto-provision, admin group, default role
  - Lockout protection: admin cannot disable all login methods; cannot disable local login unless OIDC is configured, enabled, and has an admin group mapping
  - Client secret is write-only in the UI/API; Admin GET returns only `oidc_client_secret_set`
- **`GET /api/admin/auth-mode`** extended: returns `local_login_enabled`, `oidc_login_enabled`, `oidc_configured`, `can_disable_local`, `warnings`, safe OIDC settings, and the client-secret marker
- **`PUT /api/admin/auth-mode`** extended: accepts all new provider settings, allows setting a new client secret, keeps the existing secret when blank, and supports explicit saved-secret clearing
- **`GET /api/auth/mode`** extended: returns `local_enabled`; returns OIDC provider name and login URL only when OIDC is enabled and fully configured
- **`POST /api/auth/login`** now checks `local_login_enabled` setting and returns 403 if admin has disabled local login
- **Login page OIDC button** uses `/api/auth/mode` so local-only, OIDC-only, and mixed login states are reflected safely
- **OIDC login and callback routes** now check both DB-backed effective OIDC config and `oidc_login_enabled` before proceeding
- Eleven auth settings keys are seeded: `local_login_enabled`, `oidc_login_enabled`, `oidc_provider_name`, `oidc_issuer_url`, `oidc_client_id`, `oidc_client_secret`, `oidc_redirect_uri`, `oidc_scopes`, `oidc_auto_provision`, `oidc_admin_group`, `oidc_default_role`
- **`scripts/test-oidc-smoke.js`** — 42 smoke tests covering PKCE, redirect sanitization, DB/env OIDC config precedence, safe secret handling, incomplete config behavior, provisioning, email linking, role/group mapping, OIDC-only local-login denial, and lockout logic; all pass

### Changed
- `services/oidcService.js` rewritten to use `openid-client@5` throughout:
  - `getOidcClient(config)` — builds and caches an openid-client `Client` after OIDC discovery
  - `buildAuthorizationUrl()` uses `client.authorizationUrl()` (uses discovered `authorization_endpoint`)
  - `exchangeAndVerifyTokens()` replaces manual exchange + claims-only validation with `client.callback()` which does the full PKCE exchange, JWKS signature verification, and all claim checks in one call
  - `getOidcConfig()` resolves provider config from DB settings first, env fallback second, safe defaults last
  - `mapRoleFromClaims()` reads the effective admin group at runtime; default role remains `user`, and admin is granted only by explicit group match
  - `findOrProvisionUser()` uses effective `oidc_auto_provision` and creates local OIDC users on first valid authentik login when enabled

### Notes
- Local username/password login remains supported and protected by lockout checks
- OIDC environment variables remain optional fallback/bootstrap values only; once a DB field is set, the DB value takes precedence
- Client secret is stored in the existing `settings` table, never returned through public endpoints, and never displayed in the UI
- Valid authentik users auto-provision local app users when enabled; unknown users receive a safe 403 when disabled
- Admin role is never granted by default; requires explicit OIDC admin group membership or local admin account
- OIDC signature verification/security is preserved through `openid-client@5` JWKS-backed validation, issuer/audience/expiry/nonce checks, PKCE, and state replay protection
- Live authentik SSO cannot be verified without a running authentik instance; all local testable code paths are covered by the smoke test script
- Admin single/multi user mode behavior was not changed

---

## v0.17

### Security
- **Rate limiting** added via `express-rate-limit`: login (10/15 min), password change (5/15 min), import (20/15 min), export (30/15 min), admin mutations (30/15 min), OIDC (20/15 min) — all per-IP, in-memory
- **Security headers** added globally: `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Permitted-Cross-Domain-Policies: none`; `X-Powered-By` removed; `Strict-Transport-Security` added when `HTTPS=true`
- **CORS locked down**: `cors({ credentials: true })` (wide-open) replaced with opt-in via `CORS_ORIGIN` env var; without it, no CORS headers are sent and the browser's same-origin policy applies
- **Cookie `secure` flag**: session cookie now sets `secure: true` in production (`NODE_ENV=production` or `HTTPS=true`), preventing transmission over plain HTTP in deployed environments
- **Settings endpoint hardened**: `GET /api/settings` now returns only the four user-facing keys (`currency`, `date_format`, `grace_period_days`, `notify_days_before`); previously returned all settings rows including SMTP password hash and backup paths
- **DB path removed from status response**: `database.path`/`database.file` fields removed from `GET /api/status`; filesystem paths are no longer exposed to any authenticated user
- **OIDC-only account protection**: `login()` now rejects local-password login attempts for accounts provisioned via external OIDC, preventing bypass of SSO-only accounts
- **Error handler hardened**: global Express error handler logs `err.message` internally but no longer calls `console.error(err)` (which could log full stack traces with paths); user-facing errors remain useful but safe
- **CSP deferred**: Content-Security-Policy requires auditing inline styles from Tailwind and Radix event handlers; deferred to a dedicated hardening pass

### Added
- **Backend OIDC/authentik preparation** — disabled by default; activated only when `OIDC_ENABLED=true` plus all required env vars are present:
  - `GET /api/auth/oidc/login` — generates PKCE code verifier + challenge, stores one-time state in `oidc_states` DB table, redirects to the identity provider's authorization endpoint
  - `GET /api/auth/oidc/callback` — validates state, exchanges authorization code for tokens (PKCE), validates ID token claims (issuer, audience, expiry, nonce), maps to local user, creates session, redirects to frontend
  - `GET /api/auth/mode` now includes `oidc_enabled`, `oidc_provider_name`, and `oidc_login_url` when OIDC is configured
  - `services/oidcService.js`: OIDC discovery caching, PKCE helpers, login-state management, token exchange, claim validation, role/group mapping, user auto-provisioning
  - `middleware/rateLimiter.js`: rate limiter factory for all endpoint categories
  - `middleware/securityHeaders.js`: global security headers middleware
  - `authService.createSession(userId)`: creates a server-side session for a user who has already been authenticated externally (used by OIDC callback)
- **Schema migrations** (v0.17, additive — safe on existing databases):
  - `users.auth_provider TEXT DEFAULT 'local'` — tracks identity source (`local` | `oidc`)
  - `users.external_subject TEXT` — stores OIDC `sub` claim for stable identity mapping
  - `users.email TEXT` — stores email for OIDC-linked accounts and optional local-user email linking
  - `users.last_login_at TEXT` — updated on every successful login (local or OIDC)
  - `oidc_states` table: short-lived (5 min TTL) PKCE/nonce state for in-flight OIDC logins; pruned on each new login attempt

### Changed
- `authService.login()` now updates `last_login_at` on each successful local login
- `requireAuth` single-user-mode query now includes `display_name` so the top nav shows correctly in single-user mode

### Notes
- Local username/password authentication continues to work regardless of OIDC configuration
- OIDC requires `OIDC_ENABLED=true`, `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`
- JWT signature verification (JWKS) is not implemented in this pass — ID token *claims* are validated but the cryptographic signature is not. Install `openid-client@5` and upgrade `validateIdToken` in `services/oidcService.js` for full production-grade signature verification
- Admin full-database backup/restore was not changed
- No frontend SSO login button was added in this pass

---

## v0.16.2

### Added
- User SQLite data import backend for exports created by this app:
  - `POST /api/import/user-db/preview`
  - `POST /api/import/user-db/apply`
- SQLite import preview validates the uploaded file, confirms it is a BillTracker user data export, summarizes counts, warnings, and proposed create/skip/conflict actions, and writes no live data
- SQLite import apply uses the preview session, imports only into the signed-in user's account, creates missing user-owned records, skips duplicates/conflicts by default, and records import history
- Regression coverage for user SQLite preview, apply, conflict skipping, and invalid file rejection

### Changed
- Profile > My Data now shows Import Spreadsheet History and Import SQLite Data Export side by side on desktop and stacked on mobile
- Export My Data now appears below the two import tools
- The SQLite import UI includes file selection, preview summary, warnings/conflicts, confirmation before apply, and result/error states

### Security
- User SQLite import does not use admin backup/restore endpoints and does not perform a full system restore
- User SQLite import does not import users, password hashes, sessions, cookies, admin/global settings, SMTP credentials, backup files, server paths, or other users' data
- Uploaded SQLite files are read as data through parameterized queries, stored temporarily only for preview parsing, and cleaned up without exposing server paths

---

## v0.16.1

### Added
- Bills now support an optional `interest_rate` field for credit-card APR values; blank remains allowed for non-credit-card bills
- The bills database schema, API create/update routes, bill responses, and user exports now preserve `interest_rate`
- Bills page bill names now open the shared Edit Bill dialog directly

### Changed
- Edit Bill due-date editing now uses a recurring day-of-month number instead of a full calendar date picker
- Removed the old due-date wording from the shared Edit Bill UI; the field is now labeled "Due day of month"
- Bills page editing no longer relies on a separate Edit button as the primary action
- Bills page and Tracker both use the same shared Edit Bill dialog with the corrected due-day and APR fields
- Tracker due-date calculation now uses the recurring `due_day` field and clamps to shorter months using the existing month-end handling

### Notes
- The legacy `override_due_date` column remains in the database for compatibility, but the shared Edit Bill dialog no longer edits it and current tracker due-date calculation ignores it
- Payment, monthly state, bill delete, deactivate, reactivate, and global grace-period behavior were not changed
- Per-bill grace periods were not added

---

## v0.16

### Added
- Admin Cleanup / Maintenance panel in the Admin area with settings for all four cleanup tasks, last-run summary, Save Settings, and Run Cleanup Now button
- Import history trimming shows an explicit destructive-action warning when enabled
- Read-only Maintenance card on the user Status page showing last cleanup run timestamp and per-task result counts (import sessions pruned, temp files removed, backup partials removed); no admin controls exposed
- Tracker page: clicking a bill name opens the existing Edit Bill dialog for that bill; saving refreshes the tracker for the current month; monthly gear (⚙) still opens monthly state, not global bill edit
- Edit Bill dialog is now a shared component (`components/BillModal.jsx`); Bills page and Tracker page both import it — no duplicate dialog

### Changed
- `GET /api/auth/me` now returns `display_name` so the top-nav user menu always shows the current display name after Profile save without requiring logout/login
- `GET /api/status` now includes a safe read-only `cleanup` section: `last_run_at` and `last_result` task counts
- Edit Bill due-date helper text clarified that grace period is a global Setting

### Notes
- Per-bill grace period days are not a backend-supported field; grace period remains a global app setting in Settings
- Admin cleanup controls (Save Settings, Run Cleanup Now) are admin-only and do not appear on the user Status page
- Profile `display_name` save already merged correctly into local state; the auth session fix ensures `refresh()` also returns the updated value

---

## v0.15

### Added
- `services/cleanupService.js` — new cleanup service with four independent tasks:
  - **Expired import sessions** — deletes `import_sessions` rows past their 24-hour expiry (previously only pruned on next preview request; now also runs daily)
  - **Stale export temp files** — removes orphaned `bill-tracker-user-*.sqlite` files from the OS temp directory that were not deleted after an interrupted download; configurable max age (default 2 hours)
  - **Orphaned backup partials** — removes `.partial` and `.upload` files left in the backup directory after a server crash; uses a fixed 2-hour safety cutoff so in-progress operations are never interrupted
  - **Import history trimming** — optionally deletes `import_history` rows older than a configurable threshold; disabled by default
- Daily worker now runs all enabled cleanup tasks each morning after notifications; cleanup errors are caught and logged but do not fail the worker
- Admin cleanup API:
  - `GET /api/admin/cleanup` — returns current cleanup settings and last run result
  - `PUT /api/admin/cleanup` — update any combination of cleanup settings (partial updates supported)
  - `POST /api/admin/cleanup/run` — trigger all enabled cleanup tasks immediately and return the result
- Eight new `cleanup_*` keys in the `settings` table with safe defaults (seeded via `INSERT OR IGNORE`)

### Notes
- No frontend admin UI for cleanup settings in this pass — backend and APIs only
- All cleanup tasks are independently toggled; disabling one does not affect others
- Import history trimming is off by default to preserve audit history; enable explicitly via `PUT /api/admin/cleanup`
- Backup partial pruning always uses a 2-hour minimum age regardless of settings so a live backup in progress is never removed

---

## v0.14.3

### Changed
- Added the shadcn Material Design theme registry setup and made Material Design the default light theme for the app
- `:root` now represents the Material Design light tokens used by the existing Tailwind/shadcn CSS variable system
- Aligned dark mode to the same Material-inspired design language so light and dark mode feel related
- Modernized shared theme surfaces and app shell styling, including the top navigation, cards, dialogs, dropdowns, buttons, inputs, badges, tables, and focus/hover/disabled states

### Notes
- The app still uses the existing Tailwind, shadcn, and Radix framework; no new UI framework was introduced
- Material Design is not an optional user-selected variant; it is the default light theme
- Backend behavior, routes, tracker, bills, payments, import/export logic, and database behavior were not changed

---

## v0.14.2

### Added
- Inactive bills now have a History Visibility editor on the Bills page
- History Visibility supports Default, Show all history, Show no history, and Show only selected date ranges
- Selected ranges mode supports adding, editing, deleting, labeling, and saving multiple year/month ranges

### Changed
- Bills with non-default history visibility or saved history ranges continue to show the history visibility indicator after saving

### Notes
- The editor is available for inactive bills only; active bills may show the indicator but do not expose the editor
- Delete, deactivate, and reactivate behavior was not changed
- No backend behavior changed

---

## v0.14.1

### Added
- Bills page now exposes permanent bill deletion behind a strong confirmation dialog
- Delete confirmation explains that payments, monthly history, notes, and history ranges are permanently deleted and cannot be undone
- Delete confirmation requires an explicit acknowledgement checkbox before the destructive action is enabled
- Bills with historical visibility metadata now show a small history visibility indicator in the Bills table

### Notes
- Deactivate/reactivate remains the safe non-destructive option and still uses the existing active/inactive update behavior
- The delete dialog offers Deactivate instead for active bills and Activate instead for inactive bills
- No backend delete, deactivate, reactivate, payment, monthly state, or history range behavior changed

---

## v0.14

### Added
- Bill hard-delete: `DELETE /api/bills/:id` now permanently removes the bill and all associated payments, monthly state, and history ranges — inactivation (`PUT` with `active: 0`) remains the safer non-destructive alternative
- Bill history visibility: bills now carry a `history_visibility` field (`default`, `all`, `ranges`, `none`) for future UI control over which historical data is shown for inactive bills
- `bill_history_ranges` table: per-bill, multi-range date records for fine-grained history visibility control
- `GET /api/bills/:id/history-ranges` — list all history ranges for a bill
- `POST /api/bills/:id/history-ranges` — add a date range (start year/month, optional end year/month, optional label)
- `PUT /api/bills/:id/history-ranges/:rangeId` — update a history range
- `DELETE /api/bills/:id/history-ranges/:rangeId` — remove a history range
- Bills list and detail responses now include `history_visibility` and `has_history_ranges` flag for future UI icon support

### Changed
- `DELETE /api/bills/:id` changed from soft-delete (set active=0) to hard-delete; clients that need deactivation should use `PUT /api/bills/:id` with `{ active: 0 }` (unchanged behavior)
- AP flag badge on the Bills page is now emerald/green and uses boolean coercion to prevent the SQLite integer `0` from rendering as a visible "0" next to the badge

### Notes
- No delete-confirmation UI added in this pass — backend only for the delete/history changes
- No history-visibility UI added — backend and data model only
- All history-range queries are scoped to the bill's owning user
- Deleting a bill cascades automatically via database foreign keys (`ON DELETE CASCADE`)

---

## v0.13.3

### Changed
- Moved authenticated navigation from a left-sidebar-first shell to a top-navigation-first app header
- Aligned authenticated pages under the new shared top-nav shell with consistent page width, padding, background, and card surface styling
- Modernized the shared app background, top nav active states, user menu, theme toggle placement, and mobile navigation menu

### Notes
- Profile remains the user/account hub, and user Data tools remain under Profile > My Data
- Settings remains focused on app-level preferences
- Admin/system controls remain separated from regular user Profile and continue to be shown only in the admin area
- No backend import/export, tracker, bill, category, or payment behavior changed

---

## v0.13.2

### Changed
- User-owned spreadsheet import, user data export, user data import placeholder, and import history tools now live under Profile > My Data
- Removed the top-level Data item from the regular sidebar; `/data` now redirects to `/profile` for backward-compatible deep links
- Settings is narrowed to app-level preferences: appearance, currency, date format, and billing grace period

### Notes
- Admin/system backup and restore controls remain separate from regular user Profile
- Status remains a standalone operational/system health page

---

## v0.13.1

### Added
- Profile page frontend at `/profile` with profile summary, display-name editing, notification preferences, password change, user-owned exports, and import history
- Sidebar signed-in user name now links to the Profile page
- Profile API helpers for profile details, profile settings, password changes, export metadata, and import history
- User export download buttons for SQLite and Excel exports from the Profile page

### Fixed
- Startup crash: `UPDATE categories SET user_id = ?` now removes orphaned NULL-owner categories whose names already exist for the target user before assigning ownership, preventing a `UNIQUE(user_id, name)` constraint violation on databases that had previously completed the v0.12 migration

### Notes
- Profile page uses user-owned export endpoints only and does not include admin backup controls or backup paths
- Password values are only kept in local form state for submission and are cleared after successful password change

---

## v0.13

### Added
- Profile backend foundation: `GET /api/profile` returns safe user data (id, username, display_name, role, created_at, updated_at, last_password_change_at, notification preferences, export links)
- `PATCH /api/profile` — updates `display_name` (only safe user-owned field)
- `GET /api/profile/settings` — returns user-owned notification preferences from the users table
- `PATCH /api/profile/settings` — updates user notification preferences (partial update; omitted fields are preserved)
- `POST /api/profile/change-password` — strict password change requiring `current_password`, `new_password`, and `confirm_new_password`; always verifies the current password regardless of account state; records `last_password_change_at` on success
- `GET /api/profile/exports` — returns metadata and links for user data export actions (SQLite and Excel)
- `GET /api/profile/import-history` — returns the signed-in user's import history (delegates to existing import service)
- `display_name` and `last_password_change_at` columns added to the users table via additive migration

### Changed
- `PUT /api/settings` no longer accepts backup-related keys (`backup_enabled`, `backup_frequency_days`, `backup_keep_count`, `backup_path`) from regular users; those settings are admin-only and remain manageable through the admin backup routes

### Security
- All `/api/profile/*` endpoints require authentication; user identity is always derived from the session — `user_id` is never accepted from the request body
- Profile responses never include password hashes, session tokens, SMTP credentials, backup paths, or other users' data
- `POST /api/profile/change-password` always requires the current password; no bypass path exists

### Notes
- No frontend Profile UI in this pass — backend APIs only
- Admin full-database backup/restore behavior was not changed
- Existing `POST /api/auth/change-password` preserved for the first-login forced-reset flow

---

## v0.12

### Added
- Bills and categories now have database ownership fields so imported and manually created bills belong to the signed-in user
- User data exports are now enabled for SQLite and Excel formats
- User exports include only the signed-in user's bills, payments, categories, monthly bill state, notes, and export metadata

### Fixed
- Bill, category, payment, tracker, spreadsheet import, and export routes now filter user-owned data instead of using global bill/category records

### Notes
- Existing unowned bills/categories are assigned to the first regular user during migration when one exists
- Admin full-system backup/export behavior was not changed

---

## v0.11.4

### Added
- Creating a new bill from an XLSX import row now also imports other paid months for the same detected bill name from the current preview
- Related paid months create monthly state and payment records on the newly created bill

### Notes
- Related-month import is limited to exact normalized bill-name matches in the reviewed XLSX preview
- Rows for other bill names remain skipped and are not imported automatically

---

## v0.11.3.2

### Fixed
- Login now updates the shared auth state before navigating, preventing the app from sitting on the login flow after successful sign-in
- First-login password change now sends the backend field name expected by the auth route
- Privacy acknowledgment refreshes auth state before continuing to the tracker

---

## v0.11.3.1

### Fixed
- XLSX import history and apply summary now record the number of rows skipped during review even though skipped rows are not sent as apply decisions
- Import review still applies only confirmed non-skipped rows, preserving the safer focused apply payload

### Tests
- Added regression coverage that omitted reviewed skips are counted in both apply results and import history

---

## v0.11.3

### Added
- XLSX import now detects `Paid Date` / `Date Paid` columns separately from due-date columns
- Confirmed XLSX imports now create payment records from detected paid dates and paid amounts so imported bills can appear paid in the tracker
- Import review rows now show and allow editing detected paid date and paid amount before Apply

### Notes
- Payment creation still only happens after the user confirms and applies the reviewed row
- Duplicate payment records with the same bill, amount, and paid date are skipped unless overwrite is enabled

---

## v0.11.2.5

### Fixed
- XLSX import apply now sends only rows the user chose to import instead of also sending every skipped preview row
- Import request parser failures on `/api/import/*` now return safe import-specific JSON errors instead of the generic `Internal server error`

### Notes
- Skipped preview rows remain visible and editable on the review screen, but they are not applied or created silently
- User confirmation is still required before creating a new bill, matching an existing bill, or applying any XLSX import row

---

## v0.11.2.4

### Fixed
- Fixed XLSX import month detection for real workbook tabs that combine month and year without a separator, such as `July2017`, `August2017`, and `September2017`
- Added tolerance for known month-name typos found in the test workbook, including `Januaru`, `Febuary`, and `Novevmber`
- All-sheets XLSX preview now skips obvious non-bill tabs such as `info`, tax/debt summary sheets, generic `Sheet13`-style tabs, and `home ownership expenses`

### Notes
- Electric rows from `Test_Data/monthly bills.xlsx` now resolve to the correct 2017 month values instead of becoming ambiguous because of tab-name parsing
- Import recommendations and apply remain confirmation-only; no auto-apply behavior was added

---

## v0.11.2.3

### Fixed
- Fixed remaining XLSX import apply error paths by normalizing and validating import decision fields before SQL writes
- Import apply now returns structured validation errors with row/field details when possible instead of generic Internal Server Error responses
- Import review now keeps the preview visible after apply failure so decisions can be corrected and retried

### Tests
- Added regression coverage for unsupported actions, unknown row IDs, missing create-new-bill names, invalid year/month values, bulk-style create-new-bill payloads without category IDs, frontend/backend match payloads, and skip rows without bill/month fields

---

## v0.11.2.2

### Fixed
- Fixed XLSX import apply validation for matching rows to existing bills
- Invalid match-existing-bill decisions now return clear validation errors instead of falling through to generic server errors

### Tests
- Added regression coverage for matched existing bill applies with numeric and string bill IDs, invalid match payloads, monthly state writes, bill template preservation, create-new-bill, and skip-row behavior

---

## v0.11.2.1

### Changed
- XLSX import bulk row tools are now visually scoped inside the XLSX Review Table, directly above the preview rows
- Bulk controls no longer use page-level sticky positioning, making it clearer they belong only to the current XLSX preview

### Notes
- Bulk actions still affect only selected XLSX preview rows and do not auto-apply imports

---

## v0.11.2

### Added
- Import review now supports bulk row selection and bulk row actions for the current XLSX preview
- Users can bulk mark selected preview rows as Skip
- Users can bulk mark selected preview rows as Create New Bill with editable prefilled name, category, due day, expected amount, actual amount, and notes when available
- Selected rows can be reset back to their recommendation/default decision

### Notes
- Bulk actions only update review decisions; no auto-apply or silent bill creation was added
- Per-row confirmation and editing remain available before Apply
- Rows without usable bill names remain unresolved after bulk Create New Bill until the user enters a name or skips the row
- Ambiguous rows are not auto-matched by bulk actions

---

## v0.11.1.1

### Changed
- Import review rows now let users override recommended matches and choose Create New Bill even when a possible existing bill match is suggested
- Create New Bill action switching now keeps the row expanded, hides the existing-bill selector, and sends a create-new-bill payload without the recommended bill ID

### Notes
- Recommendations remain confirmation-only and are not auto-applied
- Ambiguous rows still block Apply until the user chooses a valid action or skips the row

---

## v0.11.1

### Added
- XLSX import preview rows now include a `recommendation` object for pre-filling the review screen without applying changes automatically
- Smarter bill matching tolerates casing, punctuation, token order, and common abbreviations, including examples like `Capital` / `Cap One` → `Capital One` and `Discover Austin` → `Austin Discover`
- Create-new-bill recommendations now prefill likely bill name, due day, expected amount, and detected monthly amount when available
- Category suggestions use existing categories only, from explicit category columns, obvious keywords, or strongly matched similar bills
- Due-day suggestions are parsed from spreadsheet date/due-date cells when a valid day can be detected
- Amount recommendations carry detected spreadsheet amounts into confirmed monthly state decisions, and create-new-bill expected amounts when appropriate

### Changed
- Import review UI now shows the recommendation action, confidence, reason, and warnings before apply
- Apply payloads now include confirmed `category_id`, `due_day`, `expected_amount`, `actual_amount`, month/year, and notes when the user accepts or adjusts recommendations
- Weak or multiple possible bill matches remain unresolved until the user chooses a bill, creates a new bill, or skips the row

### Notes
- Recommendations are never auto-applied; the user must still review the preview screen and press Apply
- Ambiguous rows are not auto-applied and continue to block Apply until resolved
- Categories are not auto-created in this pass
- Existing bills are not silently updated, including due days or expected amounts; mismatches are shown as warnings
- Date intent is still heuristic: columns that look like payment dates lower confidence and warn rather than treating the date as a definite due date

---

## v0.11

### Added
- New dedicated **Data** page (`/data`) accessible from the sidebar with a FolderInput icon
- **Import Spreadsheet History** section: XLSX file picker with parse-all-sheets toggle, default year/month inputs, and a Preview Import workflow
- Preview UI shows workbook summary (sheet names, detected year/month, row counts, status badges), grouped by sheet tab in multi-sheet mode
- Per-row decision controls: auto-preselects high-confidence bill matches; ambiguous rows surface as "Needs decision" and block apply until resolved; user can choose match existing bill, create new bill, update monthly record, add monthly note, record as payment, or skip
- Suggested-match quick buttons and bill selector using optgroup (suggested matches / all bills) for fast selection
- Apply bar shows live summary of rows to apply, skipped, and unresolved; Apply button disabled until all rows are resolved
- Post-apply result card with created/updated/skipped/error counts; "New Import" button to start over
- **Download My Data** section (moved from Settings): SQLite and Excel export cards (Coming soon badges while backend endpoints are pending)
- **Import My Data Export** section: placeholder card explaining user-scoped SQLite import with Coming Soon state; clearly labelled as distinct from admin DB restore
- **Import History** section: table of all past imports for the current user with timestamps, file names, sheet names, and row outcome counts
- API helpers added: `api.previewSpreadsheetImport()`, `api.applySpreadsheetImport()`, `api.importHistory()`

### Changed
- Removed "Download My Data" section from Settings page — now lives exclusively on the Data page
- Settings page icon imports trimmed to only what the page uses

### Notes
- Admin full-database backup/restore remains exclusively in the Admin panel and is not accessible from the Data page
- User SQL import (user-scoped SQLite restore) shows a Coming Soon card; backend endpoints `POST /api/import/user-db/preview` and `POST /api/import/user-db/apply` are still needed
- User data exports (SQLite + Excel) remain Coming Soon; backend endpoints `GET /api/export/user-db` and `GET /api/export/user-excel` are still needed

---

## v0.10.1

### Added
- Multi-sheet preview mode: pass `?parse_all_sheets=true` to `POST /api/import/spreadsheet/preview` to parse every tab in one XLSX upload
- `parseSheetName()` — detects year/month from worksheet tab names supporting: `Jan 2026`, `January 2026`, `2026-01`, `01-2026`, `May`, `May Bills`, `Bills May 2026`, `2026 May`, and any combination of month name (full or abbreviated) with an optional 4-digit year
- Known non-data sheet names (Summary, Totals, Dashboard, Notes, Categories, Settings, Overview, Template, etc.) are automatically skipped in multi-sheet mode with `status: "skipped"` in the response
- `resolveYearMonth()` — tracks where each row's year/month came from; new `year_month_source` field on every preview row: `row_date`, `sheet_name`, `default`, or `ambiguous`
- Every row now includes `sheet_name` identifying which worksheet it came from
- Multi-sheet workbook response includes a `sheets` array with per-tab metadata: detected year, detected month, status (`parsed`, `parsed_month_only`, `ambiguous`, `skipped`), and row count
- Rows on ambiguous tabs (no detectable month) carry `year_month_source: "ambiguous"` and a warning; apply treats them normally using decision-level year/month override if provided
- `resolveYearMonth` and `parseSheetName` exported from service module for direct testing without DB
- Multi-sheet XLSX fixture (`scripts/test-import-multi-fixture.xlsx`) with Jan 2026, Feb 2026, Summary (skipped), May (month-only), and Misc Data (ambiguous) tabs

### Changed
- Single-sheet preview response now includes `parse_mode: "single_sheet"` in workbook metadata for consistency
- `parseXlsxBuffer()` now returns the full workbook object; raw-row extraction moved to `getSheetRows()`; `parseSheetRows()` extracted as reusable helper
- All preview rows now include `sheet_name` and `year_month_source` fields (single-sheet mode gets the selected sheet name and correct source)
- Row IDs in multi-sheet mode use `s{sheetIndex}_r{rowNumber}` format to guarantee uniqueness across tabs; single-sheet mode keeps existing `row_{N}` format

### Notes
- Apply behavior unchanged — `previewRow.detected_year` and `detected_month` already carried sheet-derived values through the session; no apply-path code changes needed
- Single-sheet preview behaviour is fully backward-compatible
- "1st–14th" style bucket names are treated as ambiguous (no month detected); provide `?month=N` default if needed

---

## v0.10

### Added
- XLSX spreadsheet import backend for importing historical bill data from Google Sheets exports (no direct Sheets API connection; user uploads an exported XLSX file)
- `POST /api/import/spreadsheet/preview` — parses an uploaded XLSX file safely, classifies rows, detects bill names/amounts/dates/labels, matches against existing bills, and returns a structured preview with proposed actions; writes no data
- `POST /api/import/spreadsheet/apply` — accepts confirmed import decisions from a preview session and applies only those decisions in a single transaction; ambiguous, conflicting, and skipped rows are never applied without explicit user confirmation
- `GET /api/import/history` — returns the authenticated user's import history (last 100 imports)
- Import session table (`import_sessions`) stores temporary preview state scoped to the uploading user; sessions expire after 24 hours and are cleaned up on next preview
- Import history table (`import_history`) records a per-user audit log of every apply: filename, sheet, row counts by outcome, and a decision summary
- Bill matching: exact normalized-name matches proposed automatically; partial/token-overlap matches require user confirmation; multiple matches mark row as requires_user_decision
- Duplicate detection for payments and monthly bill state; existing records are preserved by default unless `overwrite: true` is passed
- XLSX magic-bytes validation and formula parsing disabled (`cellFormula: false`) to prevent formula execution
- Test script (`scripts/test-import.js`) covering parseAmount, parseDate, detectLabels, normalizeName, row classification, XLSX round-trip, and fixture generation; also saves a test XLSX file for manual API testing

### Security
- Import endpoint requires authenticated user session; user_id is always taken from session, never from request body
- XLSX formula parsing disabled; all cells treated as plain string data
- File size limited to 10 MB; magic-bytes check rejects non-XLSX uploads
- Import sessions and history are scoped by user_id; sessions validate user ownership on load
- Temp/session data is not the original binary file; parsed row data is stored in the session table and cleaned up after apply or expiry

### Notes
- No frontend UI yet; this is the backend foundation for the import workflow
- No direct Google Sheets API integration in this pass; input is a user-exported XLSX file
- The `bills` table does not have a `user_id` column (shared household design); imported bills enter the shared pool, consistent with existing app behavior. Import history and sessions are per-user
- The `xlsx` (SheetJS) Community Edition has known prototype-pollution and ReDoS CVEs with no available OSS patch; mitigations applied (formula parsing off, size cap, magic-bytes check, authenticated-only access). Consider migrating to `exceljs` if stricter isolation is required

---

## v0.9

### Added
- "Download My Data" section in Settings with user-facing export cards for SQLite and Excel formats
- Export cards display "Coming soon" status pills and disabled buttons until backend endpoints are implemented
- "What's included" and "What's not included" info panels clarify that exports contain only the signed-in user's own data, not system backups
- Placeholder comments in `api.js` documenting the needed `GET /api/export/user-db` and `GET /api/export/user-excel` endpoints

---

## v0.8.2

### Fixed
- Docker runtime image now creates writable `/data/db`, `/data/backups`, and fallback `/app/backups` directories for the non-root app user
- Docker Compose now builds the project Dockerfile and mounts persistent storage at `/data` instead of bypassing the image setup with a plain Node image
- Docker Compose no longer requires a present `.env` file; explicit service environment defaults remain in the compose file
- Docker Compose now stores the SQLite database at `/data/db/bills.db`, matching the persistent `/data` volume and Dockerfile defaults

---

## v0.8.1

### Fixed
- Backup storage now respects `BACKUP_PATH` and otherwise derives a writable backup directory from the configured database path, preventing container permission errors from attempts to create `/app/backups`

---

## v0.8

### Added
- Admin backup management UI for creating, importing, listing, downloading, restoring, and deleting managed SQLite backups
- Admin scheduled-backup controls for enabling daily or weekly scheduled backups, choosing run time, setting scheduled-backup retention, saving settings, and running a scheduled backup immediately
- Scheduled backup worker using existing cron support; scheduled backups use managed `scheduled-backup-...sqlite` IDs and the same safe backup directory
- Admin backup settings endpoints for reading/saving schedule settings and running a scheduled backup on demand

### Changed
- Backup metadata now includes a backup type: manual, scheduled, imported, or pre-restore
- Backup status includes scheduled backup settings, next run, retention count, and last error when available
- Settings writes now update `updated_at` correctly so scheduled backup settings can be saved

### Security
- Backup delete is admin-only and only deletes managed backup IDs inside the controlled backup directory
- Scheduled retention only deletes old scheduled backups, never manual, imported, or pre-restore backups

---

## v0.7

### Added
- Admin-only `POST /api/admin/backups/import` endpoint for importing uploaded SQLite backup files into the managed backup directory
- Imported backups use server-generated `imported-backup-...sqlite` IDs, checksum metadata, and the same path validation as managed backups

### Security
- Uploaded backup imports are written to controlled temporary files, SQLite integrity-checked, and only promoted after validation succeeds
- Invalid or empty uploaded backup files are rejected without leaving temporary artifacts behind

---

## v0.6.1

### Security
- Backup status metadata now uses managed backup IDs instead of exposing filesystem paths
- Invalid SQLite backup files now fail restore validation with a safe HTTP 400 error

### Changed
- Backup status counts now use the backup service's managed backup list instead of scanning arbitrary files in the backup directory

---

## v0.6

### Added
- Admin-only SQLite database backup endpoints for creating, listing, downloading, and restoring backups
- Secure backup service with generated timestamped backup IDs, checksum metadata, SQLite integrity validation, and controlled backup directory handling
- Restore flow that creates a pre-restore safety backup before replacing the live database

### Security
- Backup download and restore IDs are strictly validated to prevent path traversal, absolute paths, nested paths, and arbitrary file access
- Backup API returns safe metadata only and does not expose backup directory paths

---

## v0.5

### Added
- `GET /api/version/history` returns the full `HISTORY.md` contents, current package version, and changelog last-modified timestamp
- Dedicated Release Notes page at `/release-notes` with loading, error, empty, and full-history display states
- Status page Release Notes card with current version, changelog last-updated timestamp, preview, and link to the full Release Notes page
- Frontend API helper `releaseHistory()` for fetching full release history

### Changed
- Status page release notes area now stays compact and links to the dedicated full-history view

---

## v0.4

### Added
- Status page operations cards for daily worker, notifications, backups, server clock, tracker health, and recent errors
- `/api/status` now returns backward-compatible operational status sections: `worker`, `notifications`, `backups`, `server`, `tracker`, and `recent_errors`
- Lightweight in-memory status runtime for worker state, notification test/error state, and recent backend errors

### Changed
- Quick Pay and inline payment creation on the Tracker page now scope new payments to the selected tracker month/year
- Status page runtime memory now reads `runtime.memory_mb` from the backend
- Status backend now includes database `last_modified`, server time, timezone, and current-month tracker counts

### Fixed
- Quick Pay no longer records payments against today's real month when viewing a previous or future tracker month

---

## v0.3

### Added
- `monthly_bill_state` table: stores per-bill, per-month overrides for actual_amount, notes, is_skipped
- `GET /api/bills/:id/monthly-state?year=&month=` — retrieve monthly state for a bill (returns defaults if no state set)
- `PUT /api/bills/:id/monthly-state` — upsert monthly state (actual_amount, notes, is_skipped) for a specific bill+month
- Tracker response now includes `actual_amount`, `monthly_notes`, and `is_skipped` fields per row from monthly_bill_state
- Export CSV now includes `Actual Amount` and `Monthly Notes` columns

### Changed
- `GET /api/tracker` rows now include monthly override fields when set; fall back to bill defaults when not set
- `GET /api/export` CSV output includes two new columns (backward-compatible, new columns appended)

---

## v0.2.1

### Fixed

- `dailyWorker.js`: Payment query for auto-draft status detection was missing `deleted_at IS NULL`, causing soft-deleted payments to count toward bill status in the daily worker
- `notificationService.js`: Payment query for notification suppression was missing `deleted_at IS NULL`, allowing a soft-deleted payment to incorrectly suppress due/overdue email notifications
- `payments.js GET /`: Year and month query parameters were interpolated directly into SQL string instead of using parameterized queries (SQL injection risk); replaced with bound parameters and proper validation
- `payments.js GET /`: End-of-month boundary was hardcoded as day 31 for all months; now computed using actual days-in-month per year/month

### Changed

- `payments.js POST /`, `POST /quick`, `POST /bulk`: Amount is now validated as a positive number (> 0); zero and negative amounts are rejected with HTTP 400
- `tracker.js GET /`: Year and month query parameters are now validated (year 2000–2100, month 1–12); invalid values return HTTP 400 instead of silently computing an invalid date range
- `payments.js GET /`: Year and month query parameters are now validated with the same rules; partial provision of only one is rejected with HTTP 400
- `export.js GET /`: Year query parameter is now validated (2000–2100); invalid values return HTTP 400
- DB schema: Added compound index `idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)` to accelerate the core tracker query pattern (`WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL`)

---

## v0.2

### Added

- `GET /api/version` — serves version and structured release notes from HISTORY.md
- **Paid Date column** in tracker table — shows payment date in green for paid bills
- `POST /api/payments/bulk` — batch payment recording in a single request
- Soft-delete for payments: `deleted_at` column + `POST /api/payments/:id/restore` endpoint
- `GET /api/tracker/upcoming?days=30` — upcoming bills feed sorted by due date
- `GET /api/bills/:id/payments?page=&limit=` — paginated payment history per bill
- `GET /api/export?year=YYYY&format=csv` — CSV export of all payments for a year
- Status page now displays current version and release notes from HISTORY.md

### Changed

- Payments schema: `deleted_at` column added (migration runs automatically on startup)
- `DELETE /api/payments/:id` now soft-deletes (sets `deleted_at`) instead of hard-deleting
- All payment queries now exclude soft-deleted records

---

## v0.1

### Added

- Initial release: React + Vite + Tailwind CSS + shadcn/ui frontend
- Three-theme system: Light, Dark, Dark Purple — persisted to localStorage
- Collapsible sidebar with Ctrl+B / ⌘+B keyboard shortcut
- Stripe-style layered layout with centered max-width container
- Full page set: Tracker, Bills, Categories, Settings, Status
- Admin panel with user management and onboarding wizard
- First-run terminal wizard + env-var non-interactive setup
- Single-user mode (bypass login for household use)
- Email notification system via SMTP (per-user or global recipient)
- Three-level auth: admin (user management only), user (full tracker access)
- First-login privacy notice informing users of admin limitations
- Docker deployment with persistent volume for DB and backups
- Legacy UI preserved at /legacy ("Remember When" mode)
- Release notes one-time dialog on version upgrade

---

## Version Bump Convention

### Version Format

Bill Tracker follows [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PATCH`

### Version Bump Rules

| Bump Type | When to Bump | Examples |
|-----------|-------------|----------|
| **Patch (x.y.Z)** | Bug fixes, security patches, hotfixes | v0.19.0 → v0.19.1 |
| **Minor (x.Y.z)** | New features, new endpoints, new environment variables | v0.19.0 → v0.20.0 |
| **Major (X.y.z)** | Breaking changes, schema changes, API changes | v0.19.0 → v1.0.0 |

### Version Updates

| Change | Version Bump | HISTORY.md Entry |
|--------|--------------|------------------|
| Security fix in `routes/*.js` | Patch | Under current minor version |
| New API endpoint | Minor | Under current minor version |
| New env variable (`INIT_REGULAR_USER`) | Minor | Under current minor version |
| Breaking change to frontend | Major | Under new major version |
| Database schema change | Major | Under new major version |

### Version Sync

The version in `package.json` and top of `HISTORY.md` must always be in sync. After any change that qualifies for a bump, update both files and document in HISTORY.md under the appropriate version section.
