diff --git a/.env.example b/.env.example index a43ea32..00f34bc 100644 --- a/.env.example +++ b/.env.example @@ -7,11 +7,23 @@ PORT=3000 NODE_ENV=production +# ── Reverse proxy ────────────────────────────────────────────────────────────── +# Set when running behind a reverse proxy (Docker, nginx, Traefik, Caddy, etc.) +# so req.ip reflects the real client (rate limiting, audit logs, login history) +# and req.secure reflects the original protocol (Secure cookies). +# TRUST_PROXY=true (trust first proxy hop — most common) +# TRUST_PROXY=2 (trust two hops, e.g. CDN + nginx) +# TRUST_PROXY=loopback (trust loopback addresses only) +# Leave unset for direct deployments with no proxy. +# TRUST_PROXY=true + # ── CSRF Cookie httpOnly Setting ────────────────────────────────────────────── # CSRF cookie httpOnly setting (default: true) -# Set CSRF_HTTP_ONLY=false to allow JavaScript access for SPA CSRF patterns -# CSRF_HTTP_ONLY: "true" (secure, default - cookie not readable by JS) -# CSRF_HTTP_ONLY: "false" (SPA mode - allows JavaScript to read cookie) +# The SPA fetches the token from GET /api/auth/csrf-token and stores it in +# memory — JavaScript does not need to read the cookie directly. httpOnly=true +# removes the token from the XSS-accessible cookie surface. +# CSRF_HTTP_ONLY: "true" (default — cookie not readable by document.cookie) +# CSRF_HTTP_ONLY: "false" (legacy — only if a custom client reads document.cookie) # # ── CSRF Cookie sameSite Setting ────────────────────────────────────────────── # CSRF cookie sameSite setting (default: strict) @@ -37,6 +49,22 @@ NODE_ENV=production # DB_PATH=/opt/bill-tracker/data/db/bills.db # BACKUP_PATH=/opt/bill-tracker/data/backups +# ── Encryption key ──────────────────────────────────────────────────────────── +# AES-256-GCM key used to encrypt secrets at rest (SimpleFIN tokens, SMTP passwords). +# Must be at least 32 bytes. Any printable string works; a random hex string is best. +# +# Generate one with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" +# +# If not set, Bill Tracker auto-generates a key and stores it in the database +# next to the encrypted data — anyone with database read access can decrypt. +# Set this variable in production to keep the key separate from the data. +# +# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-string-at-least-32-chars + +# ── Bank Sync (SimpleFIN) ───────────────────────────────────────────────────── +# Enable/disable bank sync from the Admin panel. Users connect their own +# SimpleFIN Bridge from the Data page. No environment config required. + # ── First-run admin account ──────────────────────────────────────────────────── # Set BOTH on first start to create the admin account automatically. # Remove or comment out after the server has started once — they are not diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..b77bef3 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,35 @@ +# CI — syntax check, server tests (node:test), client tests (Vitest), Vite build. +# Runs on every push and pull request so no change lands unverified. +# +# Forgejo Actions reads .forgejo/workflows/. The container image matches the +# Dockerfile runtime (node:22) so better-sqlite3 prebuilds resolve identically. + +name: CI + +on: + push: + pull_request: + +jobs: + ci: + runs-on: docker + container: + image: node:22-bookworm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm ci + + - name: Syntax check (server) + run: npm run check:server + + - name: Server tests (node:test) + run: npm run test + + - name: Client tests (Vitest) + run: npm run test:client + + - name: Build (Vite) + run: npm run build diff --git a/.gitignore b/.gitignore index a9d2e87..4c4446e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ +# Private project/agent docs — never commit +DEVELOPMENT_LOG.md +PROJECT.md +STRUCTURE.md +BUILD_SUMMARY.md +SCRIPTS.md +project-requirements.md +.learnings/ +simplefin_no_git/ + +# Dependencies node_modules/ dist/ db/*.db @@ -6,3 +17,26 @@ db/*.db-wal backups/ .env *.log +simplefin-bank-sync-issue.md +project-wide-data-input-and-sync-issue.md +docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md + +# Playwright E2E run artifacts (visual baselines under e2e/**/*-snapshots/ ARE committed) +test-results/ +playwright-report/ +blob-report/ +.playwright/ +e2e/.auth/ +db/e2e.db* + +# MkDocs docs site (auto-generated, not part of app source) +mkdocs/ + +# Root bill tracker DB (empty artifact, never commit) +/bills.db +Mobile App/PLAN.md +mobile/ + +# LIVE production DB copy for SimpleFIN testing — REAL financial data + secrets. +# NEVER commit. Whole dir is ignored. +tests/Site-BKUP-Test/ diff --git a/.learnings/bishop/ERRORS.md b/.learnings/bishop/ERRORS.md new file mode 100644 index 0000000..6225533 --- /dev/null +++ b/.learnings/bishop/ERRORS.md @@ -0,0 +1,55 @@ +# Errors Logged During Phase 1 Verification + +No errors encountered during Build-Verify Phase 1. + +--- + +## v0.40.0 Documentation Update + +No errors encountered during release notes and API reference update. + +**Date**: 2026-06-14 +**Task**: Documentation update for v0.40.0 +**Status**: Complete + + +--- + +## Release Notes and API Documentation Update v0.40 + +### Errors Encountered + +1. **Initial edit failed - exact match required** + - Attempted to add new version sections to release-notes/index.md using `edit` tool + - Error: `Could not find edits[2] in ... The oldText must match exactly including all whitespace and newlines.` + - Resolution: Read the current file to understand its structure, then made targeted edits for version header changes + +2. **API Reference nav placement verification** + - Checked if api-v040.md was already in mkdocs.yml nav + - Found already present with correct alphabetical ordering + - No action needed + +### Non-Obvious Findings + +1. **Release notes already had v0.38.x sections** + - File already contained v0.38.0 through v0.38.4 subsections + - Only needed to update version header from 0.37.0 to 0.40.0 + - The file was already partially updated + +2. **mkdocs.yml already has api-v040.md entry** + - `API Reference (v0.40): technical/api-v040.md` was already present + - Ordered alphabetically correctly (v0.40 before v0.37) + - No action needed + +3. **New bank transactions endpoint path** + - Endpoint is `/api/transactions/bank-ledger`, not `/api/bank-transactions` + - Confirmed by `grep -E "router\\.(get|post|patch|put|delete)" routes/transactions.js` + +4. **cents-migration-plan.md location** + - File exists at `./docs/cents-migration-plan.md` (not in technical/ subdirectory) + - Correct relative link from api-v040.md is `../cents-migration-plan.md` + +--- + +**Date**: 2026-06-14 +**Task**: Documentation update for v0.38-v0.40 release notes and API reference diff --git a/.learnings/bishop/LEARNINGS.md b/.learnings/bishop/LEARNINGS.md new file mode 100644 index 0000000..b96c1da --- /dev/null +++ b/.learnings/bishop/LEARNINGS.md @@ -0,0 +1,109 @@ +# Learnings from Phase 1 Verification + +## Business Logic Extraction — Verification Summary + +### What Was Verified + +1. **Build Success**: ✅ `docker build --no-cache -t bill-tracker:local .` completed successfully + - 1764 modules transformed + - Build time: 1.91s + - Output: 35 JS chunks for code splitting + +2. **Container Start**: ✅ Container starts cleanly with migrations applied + - All 46 migrations applied correctly + - Database initialization successful + - No errors in startup logs + +3. **Services/billsService.js Existance**: ✅ Verified + - All 8 expected exports present: + - `parseDueDay()` + - `parseInterestRate()` + - `validateCycleDay()` + - `getDefaultCycleDay()` + - `validateBillData()` + - `getValidCycleTypes()` + - `VALID_VISIBILITY` + - `validateCycleDayOnly()` + +4. **Routes/bills.js Integration**: ✅ Verified + - Imports from `../services/billsService` + - `validateBillData()` call in POST `/api/bills` endpoint + - `validateBillData()` call in PUT `/api/bills/:id` endpoint + - No inline validation logic remaining in routes + +### No Errors Found + +The business logic extraction is complete and working correctly. All validation logic has been moved from routes to the service layer, maintaining the same behavior. + +### Test Notes + +- Docker client version (1.42) is older than required (1.44) for docker compose +- Workaround: Used `docker run` directly instead of `docker compose` +- Existing container stopped and removed before starting fresh build + +### Files Created + +- `.learnings/bishop/ERRORS.md` — Error log (empty - no errors) +- `.learnings/bishop/LEARNINGS.md` — This file + +--- + +## Release Notes and API Documentation Update v0.40 + +### What Was Done + +1. **Updated Release Notes** (`mkdocs/docs/release-notes/index.md`) + - Current Release version updated from `0.37.0` to `0.40.0` + - Added v0.38.x section with sub-sections for v0.38.0, v0.38.1, v0.38.2, v0.38.3, v0.38.4 + - Added v0.39.0 section documenting settings auto-save, safe-to-spend toggle, and notifications UI move + - Added v0.40.0 section documenting bank transactions ledger page, merchant/store matching service, and transaction matching refactor + +2. **Created API Reference v0.40** (`mkdocs/docs/technical/api-v040.md`) + - Documented new endpoints added in v0.38-v0.40 + - Settings endpoints (`PUT /api/settings`) + - Transactions endpoints (`GET /api/transactions/bank-ledger`, `POST /api/transactions/auto-categorize`, `POST /api/transactions/:id/apply-merchant-match`, etc.) + - Bank Transactions ledger page endpoints + - Matching service endpoints + - Included worked curl examples for high-impact endpoints + +3. **Verified mkdocs.yml** + - `API Reference (v0.40): technical/api-v040.md` already present under Reference section + - Alphabetically ordered correctly (v0.40 before v0.37) + +4. **Internal Link Verification** + - All user-guide links point to existing files + - All technical doc links point to existing files + - `cents-migration-plan.md` link corrected from `cents-migration-plan.md` to `../cents-migration-plan.md` + +### Files Modified + +- `mkdocs/docs/release-notes/index.md` — Updated release notes with v0.38.x, v0.39.0, v0.40.0 sections +- `mkdocs/docs/technical/api-v040.md` — Created new API reference file + +### No Errors Found + +All documentation updates completed successfully with proper internal linking. + +### Test Notes + +- No mkdocs build attempted in sandbox (mkdocs not installable) +- Manual verification of nav structure and internal links performed +- Git commit avoided per instructions (Ripley owns git) + +### Files Created + +- `mkdocs/docs/technical/api-v040.md` — New API reference for v0.38-v0.40 + +### Learnings + +- The release notes structure needed granular v0.38.x sub-sections per version (v0.38.0 through v0.38.4) +- The new bank transactions endpoint is at `/api/transactions/bank-ledger`, not `/api/bank-transactions` +- The settings auto-save endpoint is `PUT /api/settings` with partial update support +- The merchant/store matching service is invoked via `/api/transactions/auto-categorize` and `/api/transactions/:id/apply-merchant-match` + +--- + +**Verified By**: Bishop (subagent) +**Date**: 2026-06-14 +**Version**: v0.40.0 +**Task**: Documentation update for v0.38-v0.40 release notes and API reference diff --git a/.learnings/neo/ERRORS.md b/.learnings/neo/ERRORS.md new file mode 100644 index 0000000..f05d136 --- /dev/null +++ b/.learnings/neo/ERRORS.md @@ -0,0 +1,9 @@ +# Bill Tracker - Neo Errors Log + +## 2026-05-11 - Phase 1 Business Logic Extraction + +### Errors Encountered +- None - extraction completed successfully on first attempt + +### Notes +-工程参考手册 does not exist in the project directory (expected to be under `Projects/bill-tracker/`) diff --git a/.learnings/neo/LEARNINGS.md b/.learnings/neo/LEARNINGS.md new file mode 100644 index 0000000..35b0000 --- /dev/null +++ b/.learnings/neo/LEARNINGS.md @@ -0,0 +1,45 @@ +# Bill Tracker - Backend Refactoring Learnings + +## 2026-05-11 - Phase 1 Business Logic Extraction + +### Task +Extract business logic from `routes/bills.js` into a dedicated service layer (`services/billsService.js`). + +### Functions Extracted to `services/billsService.js` +- `getDefaultCycleDay(cycleType)` - Returns default cycle day based on cycle type +- `validateCycleDay(cycleType, cycleDay)` - Validates cycle_day based on cycle_type rules +- `parseDueDay(value)` - Parses and validates due_day (must be 1-31 integer) +- `parseInterestRate(value)` - Parses and validates interest_rate (0-100 range) +- `getValidCycleTypes()` - Returns array of valid cycle types +- `validateBillData(data, existingBill)` - Comprehensive validation and normalization for bill create/update +- `validateCycleDayOnly(cycleType, cycleDay)` - Convenience wrapper for cycle_day validation + +### Functions Remaining in `routes/bills.js` +- Route handlers only - parse request, call service, send response +- DB queries remain in routes (tightly coupled to HTTP flow, not pure business logic) +- Error formatting with `standardizeError` (HTTP-layer concern) + +### Design Decisions +1. **`validateBillData`** - Centralized validation function that handles both create and update scenarios + - Takes optional `existingBill` parameter to support partial updates + - Returns `{ errors, normalized }` structure + - Validates all bill fields including category_id, history_visibility, cycle_type/cycle_day + +2. **`getValidCycleTypes()`** - Exported constant array for consistency across files + +3. **`VALID_VISIBILITY`** - Exported from service for reuse in other files if needed + +### Benefits +- Business logic is now testable in isolation without mocking Express request/response +- Route handlers are thinner and easier to read +- Validation rules are centralized in one place +- Easier to add new bill-related operations without touching routes + +### Files Modified +- `routes/bills.js` - Removed ~80 lines of business logic, replaced with service imports and calls +- `services/billsService.js` - New file created with extracted business logic + +### No Breaking Changes +- All API endpoints maintain exact same behavior +- Same validation rules applied +- Same error messages returned diff --git a/.learnings/scarlett/BILL_TRACKER_ACTIVE.md b/.learnings/scarlett/BILL_TRACKER_ACTIVE.md new file mode 100644 index 0000000..8166859 --- /dev/null +++ b/.learnings/scarlett/BILL_TRACKER_ACTIVE.md @@ -0,0 +1,52 @@ +# Bill Tracker — Scarlett's Active Notes + +**Last updated:** 2026-05-11 + +## Task 2: RoadmapPage UI — Kanban Priority Lanes + +### What Changed + +| File | Action | Description | +|------|--------|-------------| +| `client/pages/RoadmapPage.jsx` | **NEW** | Standalone kanban-style roadmap page with 2 tabs (Roadmap + Activity Log) | +| `client/App.jsx` | **MODIFIED** | Added lazy import for RoadmapPage; `/admin/roadmap` route now renders ``; `/admin/about` route uses `` without admin prop | +| `client/pages/AboutPage.jsx` | **MODIFIED** | Removed `admin` prop, removed `AdminDashboard` import, removed conditional render block — AboutPage is now public-only | +| `client/components/AdminDashboard.jsx` | **DELETED** | Replaced entirely by RoadmapPage | +| `client/components/ui/collapsible.jsx` | **NEW** | shadcn Collapsible component (Radix-based) | +| `tailwind.config.js` | **MODIFIED** | Added `collapsible-down`/`collapsible-up` keyframes and animations | +| `package.json` | **MODIFIED** | Added `@radix-ui/react-collapsible` dependency | + +### Architecture + +- **RoadmapPage** is a standalone page rendered at `/admin/roadmap` (behind `` + ``) +- Uses **shadcn Tabs** for Roadmap / Activity Log tab switching +- **Roadmap tab**: 5-column kanban grid on desktop (`lg+`), 2-column on tablet (`sm–lg`), single column on mobile (`` elements (via shadcn Collapsible) +- `aria-expanded` on all collapsible triggers (Radix handles this) +- `aria-label` on priority badges (e.g., "Critical priority") +- `role="region"` + `aria-label` on each priority lane section +- Keyboard-focusable throughout + +### Responsive + +- Desktop (`lg+`): 5-column grid +- Tablet (`sm–lg`): 2-column grid (CRITICAL+HIGH | MEDIUM+LOW+NICE TO HAVE) +- Mobile (` {}` appropriate for audit failures - -**Final Verdict: SECURE** — No blocking issues - ---- - -### v0.22.3 — Skip First-Login for ENV-Seeded Users -**Status:** ✅ COMPLETED -**Date:** 2026-05-10 -**Priority:** HIGH - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 2m18s | Reset first_login & must_change_password flags in setup/firstRun.js and server.js | -| Bishop | ✅ COMPLETED | 25m30s | Fixed db/database.js [init] code to reset flags, all tests passed | -| Hudson | ✅ COMPLETED | 45s | 5/6 PASS, 1 FAIL: missing audit logging for flag resets | -| Neo | ✅ COMPLETED | 2m3s | Added logAudit calls to setup/firstRun.js and server.js | -| Ripley | ✅ COMPLETED | — | Added logAudit to server.js, fixed circular dep in database.js, built & tested | - -**Files modified:** `setup/firstRun.js`, `server.js`, `db/database.js` - -**Work Completed:** -- [x] `runFromEnv()` in firstRun.js resets `first_login=0, must_change_password=0` when updating existing admin/regular users -- [x] Seed logic in server.js resets `first_login=0, must_change_password=0` when updating existing regular users -- [x] Fixed db/database.js [init] code to reset `first_login=0, must_change_password=0` when updating admin password -- [x] Verified ENV-seeded users (admin, regular) do NOT see first-login flow on container restart -- [x] Verified non-ENV users still see first-login flow -- [x] Version bumped to 0.22.3 in package.json and client/lib/version.js -- [x] Audit logging added for flag resets in setup/firstRun.js and server.js -- [x] database.js uses console.log for init-time flag resets (avoids circular dep with auditService) - -**Bug Found & Fixed:** -The `db/database.js` [init] code was setting `must_change_password = 1` when resetting the password, which was overriding the flags reset by firstRun.js. Changed to `must_change_password = 0` to match the intended behavior. - -**Security Audit (Hudson):** -1. Flag reset correctness: ✅ PASS -2. No privilege escalation: ✅ PASS -3. Container restart safety: ✅ PASS -4. SQL injection: ✅ PASS -5. Authorization scoping: ✅ PASS -6. Audit trail: ✅ FIXED (added logAudit calls) - ---- - -### v0.22.2 — Session Token Rotation on Auth Events -**Status:** ✅ COMPLETED -**Date:** 2026-05-10 -**Priority:** MEDIUM - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 6m45s | invalidateOtherSessions, rotateSessionId, logout-all endpoint | -| Ripley | ✅ COMPLETED | — | Fixed profile.js cookie bug, added audit logging, added last_password_change_at to auth.js | -| Bishop | ✅ COMPLETED | 12m1s | All API tests passed | -| Hudson | ✅ COMPLETED | 21s | 6/6 PASS | - -**Files modified:** `services/authService.js`, `routes/auth.js`, `routes/profile.js` - -**Work Completed:** -- [x] `invalidateOtherSessions(userId, keepSessionId)` — deletes all sessions except current -- [x] Password change (auth.js + profile.js) invalidates all other sessions -- [x] Password change rotates current session ID (sets new cookie) -- [x] New `POST /api/auth/logout-all` endpoint -- [x] Audit logging for `logout.all` and `password.change` -- [x] Added `last_password_change_at` to auth.js for consistency with profile.js - -**Security Audit (Hudson):** -1. Session invalidation completeness: ✅ PASS -2. Session rotation security: ✅ PASS — atomic transaction -3. Logout-all security: ✅ PASS — all sessions deleted, cookie cleared -4. No session fixation: ✅ PASS — transaction ensures atomicity -5. Authorization scoping: ✅ PASS — uses req.user.id only -6. Audit logging: ✅ PASS - ---- - -### v0.22.1 — N+1 Query Optimization -**Status:** ✅ COMPLETED -**Date:** 2026-05-10 -**Priority:** MEDIUM - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 6m7s | Batch queries for tracker + analytics | -| Ripley | ✅ COMPLETED | — | Reviewed changes, version bump 0.22.0 → 0.22.1 | -| Bishop | ✅ COMPLETED | 2m13s | 6/6 PASS | -| Hudson | ✅ COMPLETED | 18s | 5/5 PASS | - -**Files modified:** `routes/tracker.js`, `routes/analytics.js` - -**Work Completed:** -- [x] Tracker: batch monthly_bill_state, payments, prev month payments, upcoming payments -- [x] Analytics: added empty billIds guards -- [x] All batch queries guarded by `billIds.length > 0` for empty list safety -- [x] IN clause built with parameterized placeholders (no SQL injection) - -**Security Audit (Hudson):** -1. SQL injection: ✅ PASS — parameterized placeholders only -2. Empty IN clause: ✅ PASS — all guarded -3. User scoping: ✅ PASS — bills scoped by req.user.id -4. No data leakage: ✅ PASS — bills filtered before extracting IDs -5. Type safety: ✅ PASS — bill.id from SQLite auto-increment - ---- - -### v0.22.0 — React Query Migration -**Status:** ✅ COMPLETED -**Date:** 2026-05-10 -**Priority:** MEDIUM - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ❌ FAILED | 2s | Rate-limited, partial work only (installed deps, started TrackerPage migration) | -| Ripley | ✅ COMPLETED | — | Completed React Query migration, fixed error handling, version bump | -| Bishop | ✅ COMPLETED | 2m57s | 8/8 PASS | -| Hudson | ✅ COMPLETED | 26s | 4/5 PASS (1 FAIL fixed: error handling toast duplication) | - -**Files modified:** `client/App.jsx`, `client/hooks/useQueries.js` (new), `client/pages/TrackerPage.jsx`, `package.json`, `package-lock.json` - -**Work Completed:** -- [x] Installed @tanstack/react-query + @tanstack/react-query-devtools -- [x] Created custom hooks: useTracker, useBills, useCategories -- [x] Migrated TrackerPage from useState/useEffect to useTracker() hook -- [x] Added QueryClientProvider with sensible defaults -- [x] Added ReactQueryDevtools for development -- [x] Fixed load→refetch callback references -- [x] Fixed error handling: useRef pattern prevents duplicate toasts - -**Security Audit (Hudson):** -1. Query key injection: ✅ PASS — safe numeric params -2. DevTools exposure: ✅ PASS — only API data, dev-only -3. Refetch callback safety: ✅ PASS — no uncontrolled loops -4. Error handling: ❌ FAIL → ✅ FIXED — useRef pattern prevents duplicate toasts -5. Cache configuration: ⚠️ INFO — long cache acceptable for UX - ---- - -### v0.21.0 — 3-Month Trend Indicator -**Status:** 🔄 IN PROGRESS -**Date:** 2026-05-10 -**Priority:** MEDIUM - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 19m | Backend trend calculation, TrendIndicator + TrendCard components | -| Ripley | ✅ COMPLETED | — | Fixed duplicate TrendIndicator, version bump, Bishop bug fix | -| Bishop | ✅ COMPLETED | 4m55s | 4/4 PASS, fixed user_id query bug (JOIN through bills) | -| Hudson | ✅ COMPLETED | 12s | 5/5 PASS (SQL injection, user scoping, date wrapping, division by zero, XSS) | - -**Files modified:** `routes/tracker.js`, `client/pages/TrackerPage.jsx`, `client/lib/version.js`, `package.json` - -**Work Completed:** -- [x] Backend: 3-month trend calculation with year-wrapping -- [x] Backend: trend object in API response (direction, percent_change, 3_month_avg) -- [x] Frontend: TrendIndicator component (arrow + percentage + label) -- [x] Frontend: TrendCard component (purple gradient card) -- [x] Bug fix: removed duplicate TrendIndicator definition -- [x] Version bumped to 0.21.0 - -**Security Audit (Hudson):** -1. SQL injection: ✅ PASS — parameterized queries only -2. User scoping: ✅ PASS — JOIN through bills for user_id filtering -3. Date wrapping: ✅ PASS — handles year boundaries correctly -4. Division by zero: ✅ PASS — checks threeMonthAvg > 0 before division -5. No frontend XSS: ✅ PASS — direction is server-computed enum - ---- - -### v0.21.1 — Loading Skeletons & Async State -**Status:** ✅ COMPLETED -**Date:** 2026-05-10 -**Priority:** MEDIUM - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Scarlett | ✅ COMPLETED | 1m2s | Skeleton component, TrackerPage/BillsPage skeleton loaders | -| Ripley | ✅ COMPLETED | — | Fixed `/>}}` syntax error on Bucket component | -| Bishop | ✅ COMPLETED | 1m58s | 11/11 PASS | -| Hudson | ✅ COMPLETED | 17s | 5/5 PASS | - -**Files modified:** `client/components/ui/Skeleton.jsx` (new), `client/pages/TrackerPage.jsx`, `client/pages/BillsPage.jsx` - -**Work Completed:** -- [x] Reusable Skeleton component (line, circle, card, button, input variants) -- [x] TrackerPage skeleton cards, rows, buckets with aria-busy -- [x] BillsPage skeleton rows during loading -- [x] Bug fix: double closing brace `/>}}` on second Bucket component - -**Security Audit (Hudson):** -1. XSS via className: ✅ PASS -2. No sensitive data in skeleton: ✅ PASS -3. aria-busy correctness: ✅ PASS -4. No validation bypass: ✅ PASS -5. Skeleton presentational only: ✅ PASS - ---- - -### v0.20.9 — Previous Month Paid on Tracker -**Status:** 🔄 IN PROGRESS -**Date:** 2026-05-10 -**Priority:** MEDIUM - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 7m40s | Previous month backend + frontend column + summary card | -| Ripley | ✅ COMPLETED | — | Version bump, doc updates, deploy | -| Bishop | ✅ COMPLETED | 2m22s | 5/5 PASS (Docker build, API, version, frontend, previous_month fields) | -| Hudson | ✅ COMPLETED | 23s | 5/5 PASS (SQL injection, date wrapping, user scoping, auth, XSS) | - -**Files modified:** `routes/tracker.js`, `client/pages/TrackerPage.jsx`, `client/lib/version.js`, `package.json` - -**Work Completed:** -- [x] Backend: previous month calculation with year wrapping -- [x] Backend: `previous_month_paid` per bill, `previous_month_total` in summary -- [x] Frontend: "Last Month" column in desktop table -- [x] Frontend: "Last Month" row in mobile view -- [x] Frontend: Previous month summary card -- [x] Version bumped to 0.20.9 - -**Security Audit (Hudson):** -1. SQL injection in prev month query: ✅ PASS — parameterized queries -2. Date range year wrapping: ✅ PASS — Jan→Dec correctly handled -3. Data leakage / user scoping: ✅ PASS — bills scoped to user_id -4. Authentication: ✅ PASS — req.user.id used -5. XSS via monetary amounts: ✅ PASS — numeric fmt() rendering - ---- - -### v0.20.8 — Billing Cycle Sub-categories -**Status:** 🔄 IN PROGRESS -**Date:** 2026-05-10 -**Priority:** MEDIUM - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 8m42s | Migration v0.46, cycle_type/cycle_day validation, BillModal UI | -| Ripley | ✅ COMPLETED | — | Version bump, Hudson fix (validateCycleDay server-side), build, push | -| Bishop | ✅ COMPLETED | 56s | Container running, migration v0.46 applied, columns confirmed | -| Hudson | ✅ COMPLETED | 26s | 4/5 PASS, found medium-risk cycle_day gap (fixed by Ripley) | - -**Files modified:** `db/database.js`, `routes/bills.js`, `client/components/BillModal.jsx`, `client/lib/version.js`, `package.json` - -**Work Completed:** -- [x] Migration v0.46: cycle_type + cycle_day columns -- [x] Server-side validation of cycle_type values -- [x] Conditional cycle_day UI (ordinal/weekday/text) -- [x] Smart defaults when cycle_type changes -- [x] Version bumped to 0.20.8 - -**Security Audit (Hudson):** -1. cycle_type whitelist validation: ✅ PASS -2. cycle_day server-side validation: ⚠️ MEDIUM (fixed — added validateCycleDay with type-specific checks) -3. SQL injection: ✅ PASS (parameterized queries) -4. Default value safety: ✅ PASS -5. Authorization (user-scoped updates): ✅ PASS - ---- - -### v0.20.7 — Keyboard Navigation & ARIA Accessibility -**Status:** 🔄 IN PROGRESS -**Date:** 2026-05-10 -**Priority:** HIGH - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Scarlett | ✅ COMPLETED | 5m5s | Skip-to-content, aria-expanded/hasPopup, aria labels, main landmark | -| Ripley | ✅ COMPLETED | — | Fixed useId import (react-router-dom → react), verified vite build | -| Bishop | ✅ COMPLETED | 5m10s | 11/11 PASS (all accessibility checks verified) | -| Hudson | ✅ COMPLETED | 19s | Security audit: 5/5 PASS, no XSS/DOM clobbering/injection | - -**Files modified:** `client/App.jsx`, `client/components/layout/Layout.jsx`, `client/components/layout/Sidebar.jsx`, `client/main.jsx`, `client/lib/version.js`, `package.json` - -**Work Completed:** -- [x] Skip-to-content link with sr-only/focus:not-sr-only pattern -- [x] `aria-expanded` and `aria-haspopup` on Tracker menu dropdown -- [x] `aria-label="Footer"` on footer element -- [x] `role="main"` and `aria-labelledby` on layout wrapper -- [x] Main content wrapped in `
` with unique id from useId() -- [x] Fixed build error: useId imported from react, not react-router-dom -- [x] Version bumped to 0.20.7 - -**Security Audit (Hudson):** -1. XSS via ARIA attributes: ✅ PASS — hardcoded strings + useId(), no user data -2. DOM clobbering: ✅ PASS — useId() generates unique unpredictable IDs -3. Skip link injection: ✅ PASS — useId() output not user-controllable -4. aria-expanded state: ✅ PASS — computed from route state, not hardcoded -5. No backend changes: ✅ PASS — only frontend JSX files modified - ---- - -### v0.20.6 — Audit Logging for Critical Operations -**Status:** 🔄 IN PROGRESS -**Date:** 2026-05-10 -**Priority:** HIGH - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 9m19s | Created auditService.js, migration v0.45, audit calls in 4 route files | -| Bishop | ✅ COMPLETED | 7m26s | 6/6 PASS, also fixed authLogin.js missing audit calls | -| Hudson | ✅ COMPLETED | 40s | Security audit: 7/7 PASS, no vulnerabilities | - -**Files modified:** `services/auditService.js` (new), `db/database.js`, `routes/auth.js`, `routes/admin.js`, `middleware/csrf.js`, `routes/profile.js`, `client/lib/version.js`, `package.json` - -**Work Completed:** -- [x] Created `audit_log` table migration (v0.45) with indexes -- [x] Created `logAudit()` service with try/catch safety -- [x] Added audit calls: login.success, login.failure, logout, password.change -- [x] Added audit calls: role.change (with old/new role), csrf.failure -- [x] Added audit calls: profile.update, profile.settings.update -- [x] Version bumped to 0.20.6 - -**Security Audit (Hudson):** -1. Sensitive data logging: ✅ PASS — no passwords/tokens/session IDs logged -2. SQL injection: ✅ PASS — prepared statements, no string interpolation -3. Denial of service: ✅ PASS — try/catch prevents app crash -4. Failed login info disclosure: ✅ PASS — username only, no credentials -5. Audit log integrity: ✅ PASS — no UPDATE/DELETE endpoints -6. CSRF bypass: ✅ PASS — no feedback loop -7. Role change audit: ✅ PASS — server-validated values, not user-controlled - ---- - -### v0.20.5 — Bulk Payment Input Validation -**Status:** 🔄 IN PROGRESS -**Date:** 2026-05-10 -**Priority:** HIGH - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 2m6s | Added max 50 items, duplicate detection, input validation | -| Bishop | ✅ COMPLETED | 6m44s | 13/13 PASS (all endpoint tests verified) | -| Hudson | ✅ COMPLETED (2 FAIL → fixed) | 29s | Type coercion + Infinity bypass found, both fixed by Ripley | - -**Files modified:** `routes/payments.js`, `client/lib/version.js`, `package.json` - -**Objective:** -Add input validation on /api/payments/bulk endpoint. - -**Work Completed:** -- [x] Request body must contain `payments` array -- [x] Max 50 items per request -- [x] Per-item validation (bill_id integer, paid_date YYYY-MM-DD, amount >= 0) -- [x] Duplicate detection using bill_id + paid_date + amount composite key -- [x] Response includes `skipped` array for duplicates -- [x] Comment block with validation rules -- [x] Version bumped to 0.20.5 - -**Security Audit (Hudson):** -1. Max items bypass: ✅ PASS -2. Type coercion attack (bill_id): ❌ FAIL → Fixed (regex `/^\d+$/` check added) -3. Date regex bypass: ⚠️ MEDIUM (not critical, format-only check) -4. Amount validation (Infinity): ❌ FAIL → Fixed (`!isFinite()` check added) -5. SQL injection: ✅ PASS -6. Authorization bypass: ✅ PASS -7. Breaking change: ✅ PASS - -**Fixes applied by Ripley:** -- `bill_id`: Added `/^\d+$/` regex check before parseInt to prevent `"1abc"` → `1` coercion -- `amount`: Added `!isFinite(parsedAmt)` check to reject `Infinity` values -- Also fixed `skipped.push()` to use `parsedAmt` instead of raw `amount` - ---- - -### v0.20.4 — Migration Dependency Management -**Status:** 🔄 IN PROGRESS -**Date:** 2026-05-10 -**Priority:** HIGH - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ❌ FAILED | 2m22s | Read docs, ran out of time, no code written | -| Ripley | ✅ COMPLETED | — | Implemented dependsOn fields, validation function, loop integration | -| Ripley | ✅ COMPLETED | — | Implemented dependsOn fields, validation function, loop integration | -| Bishop | ✅ COMPLETED | 2m31s | Verified all 9 checks PASS | -| Hudson | ✅ COMPLETED | 1m10s | Security audit: 7/7 PASS | - -**Files modified:** `db/database.js`, `client/lib/version.js`, `package.json` - -**Objective:** -Add explicit dependency management to all 17 versioned migrations with validation. - -**Work Completed:** -- [x] Added `dependsOn` array to all 17 versioned migrations (v0.2 → v0.44) -- [x] Added `validateMigrationDependencies()` function -- [x] Integrated dependency check into migration loop -- [x] Logs `[migration] vX depends on [vY] — satisfied` when deps are met -- [x] Skips migrations with unmet deps with clear error log -- [x] Adds newly applied versions to `appliedVersions` Set for subsequent checks -- [x] Version bumped to 0.20.4 -- [x] Docker build passes, login works, dependency logging confirmed - -**Security Audit (Hudson):** -1. Dependency bypass: ✅ PASS — all dependsOn are hardcoded string literals -2. SQL injection: ✅ PASS — appliedVersions from trusted immutable schema_migrations -3. Denial of service: ✅ PASS — continue (skip) not throw on unmet deps -4. Array injection: ✅ PASS — no dynamic input in dependsOn arrays -5. Race condition: ✅ PASS — single-process SQLite, no concurrent access -6. Circular deps: ✅ PASS — linear chain verified, no cycles -7. Edge cases: ✅ PASS — empty/undefined/missing deps handled - ---- - -### v0.20.3 — Missing Database Indexes -**Status:** ✅ COMPLETED -**Date:** 2026-05-10 -**Priority:** HIGH - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 2m40s | Added v0.44 migration with 4 indexes | -| Bishop | ✅ COMPLETED | 2m33s | Docker build, all indexes verified, version bumped | -| Hudson | ✅ COMPLETED | 1m1s | Security audit: 7/7 PASS | -| Ripley | ✅ COMPLETED | — | Fixed nested transaction bug, committed, pushed, deployed | - -**Files modified:** `db/database.js`, `client/lib/version.js`, `package.json` - -**Task ID:** missing-indexes-003 - -**Objective:** -Add performance indexes on frequently queried columns to eliminate full table scans. - -**Work Completed:** -- [x] Added v0.44 migration with 4 CREATE INDEX statements -- [x] Fixed nested transaction bug (migration run() should NOT have its own BEGIN/COMMIT) -- [x] All indexes use IF NOT EXISTS for idempotency -- [x] Docker build passes, login works, no errors -- [x] Version bumped to 0.20.3 - -**Security Audit (Hudson):** -1. SQL injection: ✅ PASS — all hardcoded names, no dynamic input -2. Index naming collision: ✅ PASS — IF NOT EXISTS prevents duplicates -3. Correct columns: ✅ PASS — all 4 match spec -4. Performance impact: ✅ PASS — idempotent, created once -5. Migration ordering: ✅ PASS — v0.44 after v0.43 -6. Transaction nesting: ✅ PASS — no nested BEGIN/COMMIT in run() -7. Migration recorded: ✅ PASS — correct entry in schema_migrations - ---- - -### v0.20.2 — Transaction Wrapping for Migrations -**Status:** ✅ COMPLETED -**Date:** 2026-05-10 -**Priority:** CRITICAL - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 9m | Implemented transaction wrapping for all migrations | -| Bishop | ✅ COMPLETED | 2m | Verified Docker build, migrations, login, version bump | -| Hudson | ✅ COMPLETED | 31s | Security audit: 6/7 PASS, 1 FAIL (FK re-enable) — Ripley fixed | -| Ripley | ✅ COMPLETED | — | Fixed v0.40 FK issue, committed, pushed, deployed | - -**Files modified:** `db/database.js`, `client/lib/version.js`, `package.json`, `FUTURE.md`, `HISTORY.md` - -**Task ID:** migration-transactions-002 - -**Objective:** -Wrap all database migrations in BEGIN/COMMIT/ROLLBACK transactions so partial failures don't leave the schema in an inconsistent state. - -**Work Completed:** -- [x] Wrapped versioned migrations loop in BEGIN/COMMIT/ROLLBACK -- [x] Wrapped legacy reconciliation migrations in BEGIN/COMMIT/ROLLBACK -- [x] Wrapped unversioned user notification columns in BEGIN/COMMIT/ROLLBACK -- [x] Special handling for v0.40 PRAGMA foreign_keys (OFF before BEGIN, ON in finally block) -- [x] Fixed Hudson finding: FK re-enable now uses try/finally to guarantee restoration even on error -- [x] Hudson security audit: 6/7 PASS, 1 FAIL → fixed → all clear -- [x] Docker build + fresh DB test: all migrations apply correctly with transaction logging -- [x] Version bumped to 0.20.2 - -**Security Audit (Hudson):** -1. Transaction atomicity: ✅ PASS -2. PRAGMA foreign_keys handling: ❌ FAIL → ✅ FIXED (try/finally) -3. SQLite WAL mode: ✅ PASS -4. Error propagation: ✅ PASS -5. recordMigration inside transaction: ✅ PASS -6. SQL injection: ✅ PASS -7. Concurrent access: ✅ PASS - ---- - -## Current Work (In Progress) - -### v0.20.1 — Code Splitting + Admin Dashboard + Version Bump -**Status:** ✅ COMPLETED -**Date:** 2026-05-09 -**Priority:** MEDIUM - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Bishop | ✅ COMPLETED | — | Code splitting verified, version bump applied | - -**Files modified:** `client/lib/version.js`, `package.json`, `DEVELOPMENT_LOG.md` - -**Task ID:** code-splitting-version-bump-001 - -**Objective:** -Verify code splitting implementation (React.lazy + Suspense) and bump version to 0.20.1 for significant performance improvement. - -**Work Completed:** -- [x] Verified code splitting in `client/App.jsx` — all pages except LoginPage are lazy-loaded -- [x] Verified `client/components/PageLoader.jsx` exists with minimal loading spinner -- [x] Verified `client/components/AdminDashboard.jsx` imports `APP_VERSION` from `@/lib/version` -- [x] Verified `routes/aboutAdmin.js` returns version from package.json -- [x] Built Docker image with fresh build: `docker build --no-cache -t bill-tracker:local .` -- [x] Container started and verified with `docker run -p 3036:3000` -- [x] Verified `/api/about-admin` returns version `0.20.1` -- [x] Verified 35 JS chunks generated (code splitting working) -- [x] Version bumped to 0.20.1 in `package.json` and `client/lib/version.js` - -**Test Results:** - -**Docker Build:** ✅ PASSED -``` -Successfully built cf550f4ed581 -Successfully tagged bill-tracker:local -``` - -**Container Start:** ✅ PASSED -``` -Database initialized successfully -Bill Tracker running on port 3000 -Users found: 2 -``` - -**API Test:** ✅ PASSED -``` -$ curl -s -b /tmp/bt-cookies-v21.txt http://localhost:3036/api/about-admin -{"version":"0.20.1","future":"...20513 chars..."} -``` - -**Login Test:** ✅ PASSED -``` -$ curl -s -c /tmp/bt-cookies-v21.txt http://localhost:3036/api/auth/login \ - -H 'Content-Type: application/json' \ - -d '{"username":"admin","password":"admin123"}' -{"user":{"id":1,"username":"admin","role":"admin"...}} -``` - -**Code Splitting Verification:** ✅ PASSED -``` -$ docker exec bill-tracker ls -la /app/dist/assets/ | grep -c "\.js" -35 -``` - -**Files Modified:** -- `client/lib/version.js` — Version bumped to 0.20.1 with updated RELEASE_NOTES -- `package.json` — Version bumped to 0.20.1 -- `DEVELOPMENT_LOG.md` — Added v0.20.1 entry - -**Deliverables:** -- Code splitting verified with React.lazy() and Suspense -- PageLoader component verified -- AdminDashboard version badge verified -- Docker build passes -- App serves HTML without white screen -- 35 JS chunks generated for lazy loading -- Version properly bumped to 0.20.1 -- Documentation updated - ---- -- [x] Verified AdminDashboard component parses FUTURE.md with 10 roadmap items across 5 priority levels -- [x] Verified AdminDashboard component parses DEVELOPMENT_LOG.md with version entries -- [x] Verified SimpleCollapsible component renders collapsible sections -- [x] Verified priority color coding: 🔴🟠🟡🔵💭 with correct CSS classes -- [x] Verified scrollbar styles in client/index.css for smooth scrolling -- [x] Version bumped to 0.20.0 in package.json and client/lib/version.js -- [x] FUTURE.md updated to v0.20.0 - -**Test Results:** - -**Docker Build:** ✅ PASSED -``` -Successfully built ab7a1c3a3a72 -Successfully tagged bill-tracker:local -``` - -**Container Start:** ✅ PASSED -``` -Database initialized successfully -Bill Tracker running on port 3000 -Users found: 2 -``` - -**API Test:** ✅ PASSED -``` -$ curl -s -b /tmp/admin-cookies-v20.txt http://localhost:3036/api/about-admin -{"future":"...20513 chars...","developmentLog":"...23092 chars..."} -``` - -**Login Test:** ✅ PASSED -``` -$ curl -s -c /tmp/test-cookies.txt http://localhost:3036/api/auth/login \ - -H 'Content-Type: application/json' \ - -d '{"username":"admin","password":"admin123"}' -{"user":{"id":1,"username":"admin","role":"admin"...}} -``` - -**Code Verification:** ✅ PASSED -- AdminDashboard.jsx exists and imports correctly -- AboutPage.jsx renders AdminDashboard for admin users -- SimpleCollapsible component present -- Priority color coding implemented -- Scrollbar styles added - -**Files Modified:** -- `client/components/AdminDashboard.jsx` — New admin dashboard with roadmap and activity log -- `client/pages/AboutPage.jsx` — Conditional rendering of AdminDashboard -- `client/index.css` — Scrollbar styles for smooth scrolling -- `client/lib/version.js` — Version bumped to 0.20.0 -- `package.json` — Version bumped to 0.20.0 -- `FUTURE.md` — Updated to v0.20.0 -- `DEVELOPMENT_LOG.md` — Added v0.20.0 entry - -**Deliverables:** -- Admin Dashboard with roadmap and activity log implemented -- Priority color coding with collapsible sections -- Mobile responsive design with scrollbar customization -- Admin users see AdminDashboard; non-admins see standard About page -- Version properly bumped to 0.20.0 -- Documentation updated - ---- - -## Current Work (In Progress) - -_No current active work._ - ---- - -## Completed Work - -### v0.19.3 — Legacy DB Login Fix + Migration Run Functions + Security Hardening -**Date:** 2026-05-09 - -| Agent | Status | Time | Notes | -|-------|--------|------|-------| -| Neo | ✅ COMPLETED | 1m 38s | Added `run()` functions to all legacy migrations, admin password reset logic | -| Bishop | ✅ COMPLETED | 3m 22s | All 4 tests passed. Updated Engineering Reference Manual | -| Hudson | ✅ COMPLETED | 1m 21s | Security audit — log disclosure, reset timing, v0.40 ownership | -| Ripley | ✅ COMPLETED | — | Fixed Hudson findings, built, tested, committed, pushed v0.19.3 | - -**Files modified:** `db/database.js`, `docs/Engineering_Reference_Manual.md`, `HISTORY.md`, `FUTURE.md` - ---- -**Task ID:** error-boundaries-verify-001 -**Priority:** MEDIUM -**Started:** 2026-05-09 18:28 CDT -**Completed:** 2026-05-09 18:30 CDT - -**Objective:** -Verify Scarlett's Error Boundary implementation, build, test, and update documentation. - -**Work Completed:** -- [x] Built Docker image: `docker build --no-cache -t bill-tracker:local .` -- [x] Tested container started and serves HTML correctly -- [x] Verified ErrorBoundary.jsx exists at `client/components/ErrorBoundary.jsx` -- [x] Verified all routes wrapped with `` in App.jsx -- [x] Confirmed fallback UI includes "Try Again" and "Reload Page" buttons -- [x] Updated Engineering_Reference_Manual.md with Error Boundaries section -- [x] Updated DEVELOPMENT_LOG.md with completion entry - -**Test Results:** - -**Docker Build:** ✅ PASSED -``` -Step 19/19 : CMD ["node", "server.js"] --- -Successfully built ff23244dc5af -Successfully tagged bill-tracker:local -``` - -**Container Start:** ✅ PASSED -``` -Database initialized successfully -Bill Tracker running on port 3000 -Users found: 2 -``` - -**Login Test:** ✅ PASSED -``` -$ curl -s -c /tmp/bt-err-test.txt http://localhost:3036/api/auth/login \ - -H 'Content-Type: application/json' \ - -d '{"username":"admin","password":"admin123"}' -{"user":{"id":1,"username":"admin",..."role":"admin"...}} -``` - -**HTML Response:** ✅ PASSED -``` -$ curl -s http://localhost:3036/ | head -5 - - - - - -``` - -**Files Modified:** -- `docs/Engineering_Reference_Manual.md` — Error Boundaries section added -- `DEVELOPMENT_LOG.md` — this entry added - -**Deliverables:** -- Error boundary component verified -- All routes wrapped correctly -- Fallback UI verified with recovery buttons -- Docker build passes -- App serves HTML without white screen -- Documentation updated - ---- - ---- - -## Current Work (In Progress) - -### Bishop — Security Hardening Verification & Documentation Update -**Status:** ✅ COMPLETED -**Task ID:** security-doc-update-001 -**Priority:** HIGH -**Started:** 2026-05-09 17:30 CDT -**Completed:** 2026-05-09 17:31 CDT - -**Objective:** -Verify Neo's 6 security fixes and update Engineering_Reference_Manual.md accordingly. - -**Work Completed:** -- [x] Verified #1: Path traversal fix (ALLOWED_FILES map in routes/aboutAdmin.js) -- [x] Verified #2: Admin route bypass fix (admin prop, dual API calls) -- [x] Verified #3: Sensitive info redaction (expanded patterns) -- [x] Verified #4: Error message leaks (generic error only) -- [x] Verified #5: Race condition fix (transaction wrapping) -- [x] Verified #6: Password validation (8-char minimum) -- [x] Updated Engineering_Reference_Manual.md with v0.19.2 section -- [x] Updated DEVELOPMENT_LOG.md with completion entry - -**Files Modified:** -- `docs/Engineering_Reference_Manual.md` — v0.19.2 security fixes section added -- `DEVELOPMENT_LOG.md` — this entry added - -**Deliverables:** -- All 6 security fixes verified and documented -- Engineering Reference Manual updated with detailed fix explanations -- Development Log current with Bishop's review completion - ---- - -**Last Updated:** 2026-05-09 17:31 CDT - ---- - -## Current Work (In Progress) - -### Bishop — Code Review + Documentation Update -**Status:** ✅ COMPLETED -**Task ID:** code-review-doc-update-001 -**Priority:** HIGH -**Started:** 2026-05-09 16:20 CDT -**Completed:** 2026-05-09 16:25 CDT - -**Objective:** -Verify security fixes and update documentation for v0.19.0 release. - -**Work Completed:** -- [x] Verified security fixes in all modified files -- [x] Reviewed `routes/aboutAdmin.js` — path traversal fix, redaction, error sanitization -- [x] Reviewed `server.js` — adminActionLimiter on about-admin route -- [x] Reviewed `client/App.jsx` — admin route guard at /admin/about -- [x] Reviewed `client/pages/AboutPage.jsx` — rehype-sanitize for XSS prevention -- [x] Reviewed `client/api.js` — aboutAdmin endpoint -- [x] Updated Engineering_Reference_Manual.md with new endpoint and security measures -- [x] Updated HISTORY.md with v0.19.0 security fixes and version bump convention -- [x] Documented environment variables: INIT_REGULAR_USER, INIT_REGULAR_PASS -- [x] Established version bump convention (Patch/Minor/Major rules) - -**Files Modified:** -- `docs/Engineering_Reference_Manual.md` — comprehensive security documentation added -- `HISTORY.md` — v0.19.0 security fixes section added, version bump convention added -- `DEVELOPMENT_LOG.md` — this entry added - -**Deliverables:** -- Security fixes verified and documented -- Engineering Reference Manual updated with about-admin endpoint and security measures -- HISTORY.md established version bump convention and current version -- Non-admin test user support added for role-based testing - ---- - -**Last Updated:** 2026-05-09 16:25 CDT - ---- - -## Current Work (In Progress) - -### Bishop — Engineering Reference Manual Update -**Status:** ✅ COMPLETED -**Task ID:** eng-ref-manual-update-001 -**Priority:** HIGH -**Started:** 2026-05-09 15:05 CDT -**Completed:** 2026-05-09 15:10 CDT - -**Objective:** -Update Engineering_Reference_Manual.md to document the migration version tracking system implemented in Neo's migration refactor. - -**Work Completed:** -- [x] Read current Engineering_Reference_Manual.md -- [x] Read db/database.js migration implementation -- [x] Read DEVELOPMENT_LOG.md for context -- [x] Added `schema_migrations` table documentation -- [x] Added migration system overview to High Level Overview -- [x] Added db/database.js helper functions to Backend Documentation -- [x] Added Migration System section to Database Documentation -- [x] Updated CI/CD Pipeline with migration notes -- [x] Added Database Initialization & Migration Flow to Sequence Flows -- [x] Added Migration Troubleshooting section -- [x] Updated version to 0.19.1 with migration note - -**Files Modified:** -- `docs/Engineering_Reference_Manual.md` — comprehensive migration documentation added -- `DEVELOPMENT_LOG.md` — updated with Bishop's update completion - -**Deliverables:** -- Complete migration system documentation in Engineering Reference Manual -- Deployment teams can now understand and troubleshoot the migration system -- Version tracking is clearly documented for ops teams - ---- - -## Current Work (In Progress) - -### Neo — Migration Version Tracking System -**Status:** ✅ COMPLETED -**Task ID:** migration-v-tracking-001 -**Priority:** CRITICAL -**Started:** 2026-05-09 14:45 CDT -**Completed:** 2026-05-09 15:00 CDT - -**Objective:** -Implement explicit version tracking for database migrations so users can safely upgrade via `git pull && npm start` without migration state issues. - -**Work Completed:** -- [x] Create `schema_migrations` tracking table in `db/database.js` -- [x] Refactor `runMigrations()` to query and apply only pending migrations -- [x] Convert existing inline migrations to versioned migration objects -- [x] Add detailed logging for each migration step -- [x] Add `hasMigrationBeenApplied()` and `recordMigration()` helper functions - -**Files Modified:** -- `db/database.js` — migration system refactor - -**Deliverables:** -- Version tracking implementation complete -- Migrations are now trackable, repeatable, and resilient -- Users can `git pull && npm start` safely - ---- - -## Completed Work - -### Neo — Migration Version Tracking System (2026-05-09) -**Files Modified:** `db/database.js` -- Created `schema_migrations` tracking table (id, version UNIQUE, description, applied_at) -- Added `hasMigrationBeenApplied()` and `recordMigration()` helper functions -- Refactored `runMigrations()` to skip already-applied migrations -- Converted inline migrations to versioned objects with version/description/run -- Added detailed logging for migration steps - ---- - -## Notes for Bishop - -**COMPLETED (2026-05-09 15:05 CDT):** Engineering_Reference_Manual.md updated to reflect migration version tracking system changes. - -**Changes Applied:** -- Added `schema_migrations` table documentation with columns: `id`, `version`, `description`, `applied_at` -- Added helper functions documentation: `hasMigrationBeenApplied()`, `recordMigration()`, `runMigrations()` -- Added Migration System section to Database Documentation -- Updated Backend Documentation with database.js helper functions -- Added migration idempotency details to Infrastructure & Deployment -- Added Database Initialization & Migration Flow to Sequence Flows -- Added Migration Troubleshooting section to Error Handling -- Updated CI/CD Pipeline with migration notes -- Updated version to 0.19.1 - -**Files Modified:** -- `/home/kaspa/.openclaw/Projects/bill-tracker/docs/Engineering_Reference_Manual.md` - ---- - ---- - -## Historical Context - -**Migration System Issues Identified (Neo's Audit):** -1. ❌ CRITICAL: No explicit version tracking -2. ❌ CRITICAL: No transaction wrapping -3. ⚠️ HIGH: No dependency management -4. ⚠️ MEDIUM: No rollback capability -5. ⚠️ MEDIUM: Limited error handling - -All issues documented in `/FUTURE.md` with implementation notes. - -**Current Work:** Addressing issue #1 (version tracking) as foundation for fixes #2-5. - -## Current Work (In Progress) - -### Neo — Admin-Only /about Endpoint for FUTURE.md and DEVELOPMENT_LOG.md -**Status:** ✅ COMPLETED -**Task ID:** admin-about-endpoint-001 -**Priority:** MEDIUM -**Started:** 2026-05-09 15:25 CDT -**Completed:** 2026-05-09 15:30 CDT - -**Objective:** -Create a backend endpoint that serves FUTURE.md and DEVELOPMENT_LOG.md content to admin users only. - -**Work Completed:** -- [x] Created new route file `routes/aboutAdmin.js` with file reading logic -- [x] Implemented admin-only access using existing `requireAuth` and `requireAdmin` middleware -- [x] Added proper error handling for file read operations -- [x] Mounted new route at `/api/about-admin` in `server.js` -- [x] Used `fs.readFileSync` with UTF-8 encoding for file reading -- [x] Added path resolution relative to the routes file - -**Files Modified:** -- `routes/aboutAdmin.js` — New file containing the admin-only endpoint implementation -- `server.js` — Added route registration for `/api/about-admin` - -**Deliverables:** -- Admins can now access FUTURE.md and DEVELOPMENT_LOG.md content via a secure API endpoint -- Endpoint returns structured JSON with both file contents -- Non-admin users get 403 Forbidden -- Unauthenticated users get 401 Unauthorized -- File reading errors return 500 with meaningful message - ---- - -## Current Work (In Progress) - -### Neo — Security Fixes Implementation -**Status:** ✅ COMPLETED -**Task ID:** security-fixes-implementation-001 -**Priority:** HIGH -**Started:** 2026-05-09 16:00 CDT -**Completed:** 2026-05-09 16:15 CDT - -**Objective:** -Implement 4 security fixes for the Bill Tracker application: -1. Add `/admin/about` route guard in `client/App.jsx` -2. Add rate limiting to `/api/about-admin` in `server.js` -3. Add rehype-sanitize to `client/pages/AboutPage.jsx` -4. Add aboutAdmin to `client/api.js` - -**Work Completed:** -- [x] Added `` to client/App.jsx with admin protection -- [x] Added `adminActionLimiter` to the `/api/about-admin` route in server.js -- [x] Installed `rehype-sanitize` package and added it to ReactMarkdown component in client/pages/AboutPage.jsx -- [x] Added `aboutAdmin: () => get('/about-admin')` to client/api.js - -**Files Modified:** -- `client/App.jsx` — Added admin route protection for AboutPage -- `server.js` — Added rate limiting to about-admin endpoint -- `client/pages/AboutPage.jsx` — Added rehype-sanitize for content sanitization -- `client/api.js` — Added aboutAdmin API function - -**Deliverables:** -- Admin-only access to AboutPage at `/admin/about` with proper authentication -- Rate limiting protection on admin about endpoint -- Sanitized rendering of markdown content in AboutPage -- Client-side API access to admin about endpoint - ---- - -### Neo — Security Hardening (Round 2) -**Status:** ✅ COMPLETED -**Task ID:** security-hardening-002 -**Priority:** CRITICAL → MEDIUM -**Started:** 2026-05-09 17:05 CDT -**Completed:** 2026-05-09 17:28 CDT - -**Objective:** -Fix 6 security issues identified by Private_Hudson's audit and user-reported vulnerability list. - -**Work Completed:** -- [x] 🔴 #1: Replaced `sanitizePath()` with hardcoded filename allowlist in `routes/aboutAdmin.js` -- [x] 🟠 #2: Added `admin` prop to `AboutPage.jsx`, updated `App.jsx` to pass it via `/admin/about` route -- [x] 🟠 #3: Expanded `redactSensitiveContent()` with file path, connection string, env var, and internal URL patterns -- [x] 🟠 #4: Removed `err.message` from console.error in `routes/aboutAdmin.js`, generic HTTP 500 only -- [x] 🟡 #5: Wrapped regular user creation in `db.transaction()` in `server.js` to prevent race condition -- [x] 🟡 #6: Added 8-character minimum password validation for `INIT_REGULAR_PASS` in `server.js` - -**Files Modified:** -- `routes/aboutAdmin.js` — allowlist, enhanced redaction, error sanitization -- `client/App.jsx` — `` prop on `/admin/about` route -- `client/pages/AboutPage.jsx` — `admin` prop, conditional API call, admin content rendering -- `server.js` — transaction wrapping for user creation, password validation - -**Deliverables:** -- Path traversal eliminated (allowlist approach) -- Public/admin AboutPage properly separated -- Sensitive info redaction expanded -- Error logs sanitized -- Race condition prevented -- Password validation enforced - ---- - -### Private_Hudson — Security Audit -**Status:** ✅ COMPLETED -**Task ID:** security-audit-001 -**Priority:** HIGH -**Started:** 2026-05-09 17:05 CDT -**Completed:** 2026-05-09 17:07 CDT - -**Objective:** -Security-focused review of all recent Neo changes. - -**Work Completed:** -- [x] Audited `server.js` and `setup/firstRun.js` for INIT_REGULAR_USER credential handling -- [x] Audited `db/database.js` migration v0.42 for SQL injection and idempotency -- [x] Audited `routes/aboutAdmin.js` for path traversal, auth bypass, information disclosure -- [x] Audited `client/App.jsx` route guards -- [x] Audited `client/pages/AboutPage.jsx` for XSS via markdown -- [x] Wrote full findings to `SECURITY_AUDIT.md` - -**Files Modified:** -- `SECURITY_AUDIT.md` — New file with detailed findings and remediation recommendations - -**Deliverables:** -- 9 findings across CRITICAL/HIGH/MEDIUM/LOW/INFO severities -- Recommended fixes for each finding -- OWASP Top 10 mapping - ---- - -### Bishop — FUTURE.md Reorganization -**Status:** ✅ COMPLETED -**Task ID:** future-reorg-001 -**Priority:** MEDIUM -**Started:** 2026-05-09 17:19 CDT -**Completed:** 2026-05-09 17:30 CDT - -**Objective:** -Reorganize FUTURE.md into strict priority order with emoji headings. - -**Work Completed:** -- [x] Consolidated 37 pending items into priority tiers -- [x] Grouped under 🔴 CRITICAL, 🟠 HIGH, 🟡 MEDIUM, 🔵 LOW, 💭 NICE TO HAVE -- [x] Removed duplicate sections and empty headers -- [x] Kept Completed Items and Template sections - -**Files Modified:** -- `FUTURE.md` — Full reorganization - -**Deliverables:** -- Clean, prioritized planning document -- Consistent format with emoji priority markers - ---- - -## Current Work (In Progress) - -### Bishop — Migration Fix Verification & Documentation -**Status:** ✅ COMPLETED -**Task ID:** migration-fix-verification-001 -**Priority:** CRITICAL -**Started:** 2026-05-09 18:10 CDT -**Completed:** 2026-05-09 18:15 CDT - -**Objective:** -Verify Neo's 🔴 CRITICAL migration login fix in `db/database.js` and update documentation. - -**Work Completed:** -- [x] Built Docker image with `docker build --no-cache -t bill-tracker:local .` -- [x] Tested with FRESH database — migrations applied correctly -- [x] Tested with SIMULATED LEGACY database — detection, reconciliation, and migration completed successfully -- [x] Verified LOGIN works in both scenarios -- [x] Updated Engineering_Reference_Manual.md with migration fix documentation -- [x] Updated DEVELOPMENT_LOG.md with completion entry - -**Test Results:** - -**Test 1: Fresh Database** ✅ -- Container started with new data volume -- Migrations applied in order (v0.2 through v0.42) -- Admin user created -- Regular user created -- Login successful - -**Test 2: Simulated Legacy Database** ✅ -- Database created with tables but NO `schema_migrations` table -- Container detected legacy database -- Reconciliation logged: `[migration] Detected legacy database, reconciling schema migrations...` -- All existing migrations recorded: `v0.4`, `v0.14.4`, `v0.38`, `v0.40` -- Remaining migrations applied: `v0.2`, `v0.3`, `v0.13`, `v0.14`, `v0.15`, `v0.17`, `v0.18.1`, `v0.18.2`, `v0.18.3`, `v0.41`, `v0.42` -- Login successful - -**Log Output:** -``` -[migration] Detected legacy database, reconciling schema migrations... -[migration] Applied v0.4: monthly_bill_state: per-bill per-month overrides -[migration] Recorded legacy migration v0.4: monthly_bill_state: per-bill per-month overrides -[migration] Applied v0.14.4: bills: optional credit-card APR / interest rate -[migration] Recorded legacy migration v0.14.4: bills: optional credit-card APR / interest rate -[migration] Applied v0.38: import_history: per-user audit log -[migration] Recorded legacy migration v0.38: import_history: per-user audit log -[migration] Applied v0.40: ownership: user-scoped bills/categories -[migration] Recorded legacy migration v0.40: ownership: user-scoped bills/categories -[migration] Legacy database reconciliation complete -[migration] Applying v0.2: payments: soft-delete column -[migration] payments.deleted_at column added -[migration] Applied v0.2: payments: soft-delete column -[migration] Applying v0.3: payments: compound index for tracker query -[migration] Applied v0.3: payments: compound index for tracker query -[migration] Skipping already applied v0.4: monthly_bill_state: per-bill per-month overrides -[migration] Applying v0.13: users: profile columns -[migration] Applied v0.13: users: profile columns -[migration] Applying v0.14: bills: history visibility mode -[migration] bills.history_visibility column added -[migration] Applied v0.14: bills: history visibility mode -[migration] Skipping already applied v0.14.4: bills: optional credit-card APR / interest rate -[migration] Applying v0.15: import_sessions and import_history tables -[migration] Applied v0.15: import_sessions and import_history tables -[migration] Applying v0.17: users: external identity / OIDC columns -[migration] Applied v0.17: users: external identity / OIDC columns -[migration] Applying v0.18.1: monthly_income: per-user monthly income for Summary planning -[migration] Applied v0.18.1: monthly_income: per-user monthly income for Summary planning -[migration] Applying v0.18.2: monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th -[migration] Applied v0.18.2: monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th -[migration] Applying v0.18.3: monthly_starting_amounts: add other_amount column -[migration] Applied v0.18.3: monthly_starting_amounts: add other_amount column -[migration] Skipping already applied v0.38: import_history: per-user audit log -[migration] Skipping already applied v0.40: ownership: user-scoped bills/categories -[migration] Applying v0.41: bills and categories: is_seeded flag for demo data cleanup -[migration] bills.is_seeded column added -[migration] categories.is_seeded column added -[migration] Applied v0.41: bills and categories: is_seeded flag for demo data cleanup -[migration] Applying v0.42: bill_history_ranges: per-bill date ranges for history visibility -[migration] Applied v0.42: bill_history_ranges: per-bill date ranges for history visibility -Database migrations complete for /data/db/bills.db -``` - -**Files Modified:** -- `docs/Engineering_Reference_Manual.md` — Migration system update documentation added -- `DEVELOPMENT_LOG.md` — this entry added - -**Deliverables:** -- Build verification complete -- Fresh database migrations verified -- Legacy database reconciliation verified -- Login functionality confirmed in both scenarios -- Documentation updated for ops teams - ---- - -### Private_Hudson — Security Verification of Migration Login Fix -**Status:** ✅ COMPLETED -**Task ID:** migration-login-fix-security-verification-001 -**Priority:** CRITICAL -**Started:** 2026-05-09 18:20 CDT -**Completed:** 2026-05-09 18:25 CDT - -**Objective:** -Verify security implications of Neo's migration fix in `db/database.js`, specifically the `handleLegacyDatabase()` and `reconcileLegacyMigrations()` functions. - -**Security Verification Checklist:** -- [x] SQL Injection: All queries use hardcoded table/column names, no user input -- [x] Data Integrity: Reconciliation only records migration status, no data modification -- [x] Authorization Bypass: All migrations applied; no mechanism to skip security migrations -- [x] Race Condition: SQLite WAL mode + busy_timeout prevents corruption -- [x] Error Handling: Try/catch wrappers prevent partial state, idempotent operations - -**Test Results:** - -**Login Test (admin/admin123):** ✅ -``` -$ curl -s http://localhost:3036/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' -{"user":{"id":1,"username":"admin","display_name":null,"role":"admin","active":true,"is_default_admin":true,"must_change_password":false,"first_login":true}} -``` - -**Legacy Database Detection Test:** ✅ -- Confirmed `schema_migrations` table does not exist in current DB -- Confirmed all 5 core tables exist (users, bills, payments, categories, settings) -- Legacy database correctly identified by `handleLegacyDatabase()` - -**Query Safety Verification:** -- `PRAGMA table_info()` queries use hardcoded table names -- `sqlite_master` queries use `IN ('users', 'bills', 'payments', 'categories', 'settings')` -- No dynamic SQL construction from user input -- Column name validation via `isValidColumnName()` whitelist in `runMigrations()` - -**Security Verdict: PASS** - -All 5 security focus areas verified: -1. **SQL Injection** — PASS (no user input reaches migration queries) -2. **Data Integrity** — PASS (reconciliation is read-only, idempotent) -3. **Authorization Bypass** — PASS (all migrations apply; no skipping mechanism) -4. **Race Condition** — PASS (SQLite WAL + atomic INSERT prevents corruption) -5. **Error Handling** — PASS (no partial state, errors logged cleanly) - -**Files Reviewed:** -- `db/database.js` — All migration functions -- `server.js` — Startup/initialization logic - -**Deliverables:** -- Security verification report complete -- No blocking issues found -- Migration system passes security audit - ---- - -**Last Updated:** 2026-05-09 18:25 CDT - -**Implementation Note:** -The `handleLegacyDatabase()` function in `db/database.js` checks for a database with existing tables but an empty or missing `schema_migrations` table. When detected, it runs `reconcileLegacyMigrations()` which: -1. Checks if core tables exist (users, bills, payments, categories, settings) -2. Iterates through all migrations and marks already-applied ones as "recorded" -3. Then `runMigrations()` applies any remaining migrations - -This ensures backward compatibility with existing deployments while preventing duplicate migrations. - ---- - ---- - -## v0.19.4 — Session Token Expiry Cleanup - -**Date:** 2026-05-09 -**Status:** COMPLETED - -### Agents -- **Neo** — Implemented cleanupExpiredSessions(), v0.43 migration, periodic purge, per-user login cleanup (19m) -- **Bishop** — Verified all tests pass: Docker build, migration, startup logs, login, interval (3m 5s) -- **Hudson** — Security audit: 5 PASS, 1 FAIL (SESSION_CLEANUP_INTERVAL_MS validation — fixed by Ripley) -- **Ripley** — Fixed Hudson finding (interval validation), committed v0.19.4, pushed, deployed - -### Files Modified -- `db/database.js` — cleanupExpiredSessions(), v0.43 migration, COLUMN_WHITELIST -- `server.js` — Startup cleanup, periodic interval, input validation for SESSION_CLEANUP_INTERVAL_MS -- `services/authService.js` — Per-user expired session cleanup on login and createSession -- `docs/Engineering_Reference_Manual.md` — Session cleanup documentation - -### Commits -- `399882f` — v0.19.4: session token expiry cleanup -- `3a1d613` — docs: v0.19.4 changelog, remove completed item from FUTURE.md diff --git a/Dockerfile b/Dockerfile index da684ab..6d34e91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ── Stage 1: Build ───────────────────────────────────────── -FROM node:18-alpine AS builder +FROM node:22-alpine AS builder WORKDIR /app # native build deps (better-sqlite3 etc) @@ -17,7 +17,7 @@ RUN npm run build # ── Stage 2: Runtime ─────────────────────────────────────── -FROM node:18-alpine +FROM node:22-alpine WORKDIR /app # minimal tools for debugging inside container + privilege drop in entrypoint diff --git a/FUTURE.md b/FUTURE.md index 6c96c89..44f1c26 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -2,8 +2,8 @@ **This document tracks potential future enhancements for Bill Tracker.** -**Last Updated:** 2026-05-10 -**Current Version:** v0.24.3 +**Last Updated:** 2026-05-30 +**Current Version:** v0.34.2 ## How to Use This Document @@ -12,7 +12,7 @@ This file is a living document. Agents should: 2. Add new recommendations with priority levels 3. Never add completed items — move those to HISTORY.md instead 4. Reference this file when dispatching improvement tasks -5. Only Ripley can remove items from this list. +5. Only Ripley can remove items from this list. Notify Ripley if something needs to be removed. ### Priority Format @@ -31,229 +31,158 @@ Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `## ## Pending Recommendations -### 🔴 CRITICAL +## 🟠 HIGH - -### ~~🔴 Notification Runner Leaks Bill Details Across Users — CRITICAL~~ ✅ FIXED (v0.23.2) -**Moved to HISTORY.md** - - -### 🟠 HIGH - -### ~~🟠 Admin Can Toggle Payments on Any User Bill — HIGH~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - -### ~~🟠 Analytics Validation Errors Crash Instead of Returning 400 — HIGH~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - -### ~~🟠 User Export Drops Recurrence and History-Range Data — HIGH~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - -### ~~🟠 Single-User Mode Can Lock Itself Out When Expired Sessions Exist — HIGH~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - - -### 🟡 MEDIUM - - -### ~~🟡 Password Change Rate Limiter Applies to Every Profile Endpoint — MEDIUM~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - -### ~~🟡 Profile Password Change Does Not Invalidate Other Sessions — MEDIUM~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - -### ~~🟡 CSRF Defaults Conflict with SPA Token Loading — MEDIUM~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - -### ~~🟡 Change-Password Routes Are Globally Exempted from CSRF — MEDIUM~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - -### ~~🟡 Notification Due-Day Math Can Miss Same-Day Reminders — MEDIUM~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - -### ~~🟡 Upcoming Bills Allows Negative Day Windows — MEDIUM~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** - - -### Architecture: Business Logic Mixed with Route Handlers +### 🟡 Projected Cash Flow — MEDIUM **Priority:** MEDIUM -**Added:** 2026-05-08 by Neo +**Added:** 2026-05-16 by Ripley (from _null's prioritized roadmap) **Description:** -Many routes contain business logic that should be extracted to service layers. +Show users what's coming: "You'll have $X left before the 15th", "Upcoming bills before next paycheck", and a "Safe-to-spend" estimate based on starting amount, unpaid bills, and scheduled income. Fits naturally with the existing 1st/15th bucket model. + +**Scope:** +- "Remaining after bills" projection per bucket (1st half / 15th half) +- "Upcoming bills before next paycheck" list +- "Safe-to-spend" estimate based on starting balance minus unpaid bills +- Scheduled income support (payday amounts) **Rationale:** -- `bills.js` contains `parseDueDay()`, `parseInterestRate()` — validation logic -- `tracker.js` contains date/range calculations that are reused across routes -- `admin.js` has complex OIDC config building mixed with routing -- `analytics.js` has complex date-building logic (`buildMonths`, `monthKey`, etc.) +- The 1st/15th bucket model is already built — cash flow projection is the natural next step +- Most valuable feature for day-to-day money management +- Turns a bill tracker into a financial planning tool **Implementation Notes:** -- Files to modify: Multiple route files + new service files in `/services/` -- Estimated effort: 8 hours -- Proposed structure: - ``` - /services/billsService.js - /services/trackerService.js - /services/analyticsService.js - /services/authService.js (existing) - /services/oidcService.js (existing) - /services/cleanupService.js (existing) - ``` -- Route handlers should call services, not contain business logic - -### ~~Skip First-Login User Creation When ENV Seeds Users~~ ✅ COMPLETED (v0.22.3) -**Moved to HISTORY.md** - -### ~~No Rollback Capability for Failed Migrations~~ ✅ COMPLETED (v0.23.1) -**Moved to HISTORY.md** - -### ~~Limited Error Handling and Logging for Migrations~~ ✅ COMPLETED (v0.23.0) -**Moved to HISTORY.md** - -**Rationale:** -- Migration errors are silent or unclear -- No logging of which migration failed or why -- No way to diagnose schema inconsistencies -- Risk: slow debugging on production issues - -**Implementation Notes:** -- Add detailed logging: `[migration] Applying v0.20.0: Add user_groups table` -- Include timing: `[migration] v0.20.0 completed in 234ms` -- Log precondition checks: `[migration] Checking: table_exists('users')` -- Error log with context: `[migration-error] v0.20.0 failed: UNIQUE constraint failed on users.username` +- Requires user to enter starting balance and payday amounts (new settings fields) +- Calculate: starting amount - unpaid bills due before next payday = safe-to-spend +- Files to modify: `TrackerPage.jsx`, `routes/tracker.js`, `user_settings` table (new fields) +- Estimated effort: 8-10 hours --- -### 🔵 LOW +### 🟡 Recurring Payment Rules — MEDIUM +**Priority:** MEDIUM +**Added:** 2026-05-16 by Ripley (from _null's prioritized roadmap) +**Status:** Partial — infrastructure built (auto_mark_paid column, confirm/dismiss APIs, UI for suggestions), but no proactive suggestion scheduler generating payments on due date. -### ~~🔵 Export Formats Include Sensitive Bill Credential Fields by Default — LOW~~ ✅ FIXED (v0.24.1) -**Moved to HISTORY.md** +**Description:** +Auto-mark certain bills as paid on due date if `autodraft_status = assumed_paid`. Or create suggested payments awaiting confirmation. Good for autopay-heavy users. -### ~~🔵 Duplicate Local Login Route Increases Auth Drift Risk — LOW~~ ✅ FIXED (v0.23.2) -**Moved to HISTORY.md** +**Scope:** +- Bills with autopay/autodraft get a "suggested payment" on their due date +- User confirms or dismisses the suggestion +- Auto-mark option: bills can be set to automatically mark as paid on due date +**Implementation Notes:** +- ✅ `auto_mark_paid` column + bill edit checkbox +- ✅ `applyAutopaySuggestions()` in trackerService handles auto-mark + suggestion generation +- ✅ Confirm (`POST /api/payments/autopay-suggestions/:billId/confirm`) and dismiss (`POST /.../dismiss`) endpoints +- ✅ Suggestion UI in TrackerPage with badge + confirm/dismiss buttons +- ❌ No proactive suggestion engine — only runs when tracker loads +- ❌ No scheduled task/cron to evaluate bills and create suggestions on due date +- Estimated effort remaining: 2-3 hours -### Add comprehensive unit and integration tests +--- + +### 🟡 Calendar Agenda Mode — MEDIUM +**Priority:** MEDIUM +**Added:** 2026-05-16 by Ripley (from _null's prioritized roadmap) + +**Description:** +Replace the month-grid calendar with an agenda view: Today / This Week / Next 14 Days. Group bills by "needs action," "autopay," "already paid." More useful when actually paying bills. + +**Rationale:** +- Month grids are pretty but not actionable +- Agenda mode answers "what do I need to do right now?" +- Groups by status makes it immediately clear what needs attention + +**Implementation Notes:** +- New view toggle on CalendarPage: Grid vs Agenda +- Agenda shows: Overdue → Today → This Week → Next 14 Days +- Each group sorted by due date, with action status badges +- Files to modify: `CalendarPage.jsx`, `routes/calendar.js` +- Estimated effort: 6-8 hours + +--- + +### 🟡 Filtered Exports — MEDIUM +**Priority:** MEDIUM (upgraded from LOW) +**Added:** 2026-05-11 by Ripley (from _null's prioritized roadmap) + +**Description:** +Export only utilities, debts, overdue, date range, tax-relevant categories. Currently exports everything with no filtering. + +**Rationale:** +- Users need "all Q1 utility bills" or "overdue payments this year" for reconciliation and tax prep +- `/api/export/user-excel` exports everything — no query params for date range, category, or status + +**Implementation Notes:** +- Add query params to export endpoints: `category_id`, `start`, `end`, `status` (paid/unpaid/overdue) +- Files to modify: `routes/export.js`, `client/pages/DataPage.jsx` +- Estimated effort: 6 hours + +--- + +## 🔵 LOW + +### 🔵 Payment Method Tracking and Summary — LOW **Priority:** LOW +**Added:** 2026-05-11 by Ripley + +**Description:** +The `payments` table has a `method` column (free-text) but no way to see "how much did I pay via autopay vs manual vs credit card this month." + +**Implementation Notes:** +- Standardize payment methods: enum or controlled list (autopay, bank_transfer, credit_card, check, cash, other) +- Add payment method breakdown to analytics or summary page +- Files to modify: `routes/payments.js`, `AnalyticsPage.jsx`, schema migration +- Estimated effort: 4-6 hours + +--- + +### 🔵 No Keyboard Navigation or Shortcuts — LOW +**Added:** 2026-05-11 by Ripley + +**Status:** Partial — Esc closes modals ✅, Cmd+K opens command palette ✅, arrow key tracker navigation ❌ + +**Description:** +Only a skip link exists for keyboard accessibility. No `Cmd+K` to find a bill, no `Esc` to close modals, no arrow keys to navigate the tracker grid. + +**Implementation Notes:** +- ✅ `Esc` closes any open modal/dialog (via Radix Dialog default) +- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.jsx`) +- ❌ Arrow keys navigate tracker rows when grid is focused +- Remaining effort: 1-2 hours + +--- + +### 🔵 Add comprehensive unit and integration tests **Added:** 2026-05-08 by Scarlett **Description:** -Currently no unit tests exist for components or hooks. The only testing appears to be functional tests in `test-functional.js`. Component-level testing is missing. - -**Rationale:** -Code quality and maintainability. Unit tests catch regressions and document component behavior. Bill Tracker has complex business logic (bill calculations, monthly state, analytics) that should be tested. +Currently no unit tests exist for components or hooks. The only testing is functional tests in `test-functional.js`. **Implementation Notes:** -- Set up Jest + React Testing Library +- Set up Jest + React Testing Library (or vitest) - Test key components: BillModal, TrackerPage row, BillsTableInner - Test hooks: useAuth, custom form hooks - Test utility functions in `client/lib/utils.js` -- Consider vitest for faster test execution -- Add CI integration for test execution -- Files likely to be modified: Add `client/test/` directory, add `jest.config.cjs` - Estimated effort: 8-12 hours for baseline coverage -### Features: Missing Export for User-Specific Reports -**Priority:** LOW -**Added:** 2026-05-08 by Neo - -**Description:** -No built-in way to export filtered data (e.g., "all bills in category X for last 6 months"). - -**Rationale:** -- `/api/analytics/summary` exists but returns JSON only -- Users cannot generate Excel/PDF reports -- No programmatic way to get export links for specific filters -- `/api/export/user-excel` exports everything, not filtered views - -**Implementation Notes:** -- Files to modify: `/home/kaspa/.openclaw/Projects/bill-tracker/routes/export.js` -- Estimated effort: 6 hours -- Add endpoints: - - `GET /api/export/user-excel?category_id=1&start=2026-01&end=2026-06` - - `GET /api/export/user-json?filter=bills&status=missed` - - Add report title/description to export metadata - -### Features: Missing Bill Grouping and Reorganization API -**Priority:** LOW -**Added:** 2026-05-08 by Neo - -**Description:** -No way to reorder bills, drag-and-drop, or group by custom criteria. - -**Rationale:** -- `bills` table has `due_day` ordering but no manual sort order -- Frontend likely orders by `due_day` only -- Users cannot create bill groups or categories for bills -- No way to mark bills as "hidden" or "archived" without deactivating - -**Implementation Notes:** -- Files to modify: `/home/kaspa/.openclaw/Projects/bill-tracker/db/schema.sql`, `/routes/bills.js` -- Estimated effort: 6 hours -- Add: - - `sort_order` column to bills table (default NULL, ordered first by sort_order then due_day) - - `PUT /api/bills/reorder` endpoint accepting `{bill_id: new_index}` - - `PUT /api/bills/:id/archived` to soft-dearchive (sets `archived` flag) - --- -### 💭 NICE TO HAVE -### Add consistent form state management pattern +## 💭 NICE TO HAVE + +### 💭 Add consistent form state management pattern **Priority:** MEH **Added:** 2026-05-08 by Scarlett **Description:** -Form state management is inconsistent across components. Some use `useState` for each field, others use form libraries. Validation patterns vary. - -**Rationale:** -Consistency and maintainability. A consistent pattern makes it easier to add new forms and reduce bugs. +Form state management is inconsistent across components. Some use `useState` for each field, others use form libraries. **Implementation Notes:** - Consider react-hook-form for complex forms - Create reusable form field components (InputField, SelectField, etc.) - Standardize validation approach -- Files likely to be modified: `client/components/*.jsx` -- Estimated effort: 4-6 hours for migration - ---- - -## Template for New Recommendations - -```markdown -### [Feature Name] -**Priority:** CRITICAL / HIGH / MEDIUM / LOW / MEH -**Added:** YYYY-MM-DD by [Agent] - -**Description:** -Brief description of the improvement. - -**Rationale:** -Why this matters. - -**Implementation Notes:** -- Technical approach -- Files likely to be modified -- Estimated effort - -**Depends On:** -Any prerequisites or blocking issues. -``` - -## Completed Items - -### ✅ Security: Rate Limiting on /api/about-admin — MEDIUM -**Completed:** 2026-05-09 (v0.19.0) -**Fix:** `adminActionLimiter` (30 req/15min) applied to `/api/about-admin` route. - -### ✅ Security: Markdown Sanitization in AboutPage — MEDIUM -**Completed:** 2026-05-09 (v0.19.0) -**Fix:** `rehype-sanitize` added to `AboutPage.jsx` ReactMarkdown component. - -### ✅ Security: aboutAdmin() in API Client — LOW -**Completed:** 2026-05-09 (v0.19.0) -**Fix:** `aboutAdmin` endpoint function added to `client/api.js`. - ---- +- Estimated effort: 4-6 hours diff --git a/HISTORY.md b/HISTORY.md index e79523d..bb1c363 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,4 +1,869 @@ # Bill Tracker — Changelog +## v0.41.0 + +### 🧪 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 (`${bill.name} is due…`), so a bill named `` 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 ``s had no name; a Snowball drag-handle `
` 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 `` 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 `` 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 `` 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 @@ -1238,12 +2103,6 @@ Bill Tracker follows [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PA | Breaking change to frontend | Major | Under new major version | | Database schema change | Major | Under new major version | -### Current Version - -- **Current Version**: v0.19.0 -- **Package.json**: `version: "0.19.0"` -- **HISTORY.md**: Top entry matches current 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. diff --git a/README.md b/README.md index b853fc2..6eb5838 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,158 @@ # BillTracker

- Tracker logo + BillTracker logo

-BillTracker is a self-hosted app for tracking recurring bills, monthly payments, due dates, categories, and personal bill history. It runs as a Node/Express server with a React frontend and stores data in SQLite. This product was produced with the assistance of AI. +BillTracker is a private, self-hosted bill planning app for households and +personal finance setups. It tracks recurring bills, monthly cash buckets, +payments, due dates, categories, subscriptions, bank-synced transactions, +imports, exports, backups, and debt payoff plans from one local installation. + +It runs as a Node/Express app with a React/Vite frontend and stores data in +SQLite. It is designed for people who want their bill data under their own +control instead of inside a third-party budgeting service.

Demo Server: https://t1.scheller.ltd/
Username: guest · Password: guest123

+## Highlights + +- Monthly tracker with `1st-14th` and `15th-31st` bill buckets +- Quick pay, skipped bills, monthly notes, amount overrides, and inactive bill history ranges +- Period-aware balance cards, overdue command center, pin-due sorting, and compact desktop mode +- Bills, categories, subscriptions, payment history, and custom billing schedules +- SimpleFIN read-only bank sync with manual sync, auto-sync, transaction matching, and merchant rules +- Historical payment import for merchant-rule matches, including month-crossing attribution fixes +- Advisory non-bill filtering with a large pattern catalog for noisy bank transactions +- Debt snowball and avalanche planning with APR math, projections, and amortization schedules +- Calendar, summary, analytics, payoff simulator, and printable views +- XLSX/CSV import, transaction CSV import, user SQLite export/import, Excel export, and admin backups +- Local username/password auth with optional Authentik/OIDC SSO +- Admin tools for users, backups, auth settings, bank sync, cleanup, status, and migrations +- Email and push bill reminders through SMTP, ntfy, Gotify, Discord, or Telegram +- Dark mode, PWA support, offline-ready shell, and keyboard command palette (`Ctrl+K`) + ## Screenshots -![Analytics screenshot](docs/images/login.png) -![Analytics screenshot](docs/images/tracker.png) +Screenshots below were refreshed from the linked demo server. +![Login screen](docs/images/login.png) -![Analytics screenshot](docs/images/Analytics.png) +![Monthly tracker](docs/images/tracker.png) -![Calendar screenshot](docs/images/Calendar.png) +![Calendar money map](docs/images/Calendar.png) -## What Is BillTracker? +![Analytics dashboard](docs/images/Analytics.png) -BillTracker helps a household or small self-hosted setup keep bill data in one place: +![Debt snowball planner](docs/images/Snowball.png) -- recurring bill records with due day, expected amount, category, notes, autopay details, optional APR, and flexible billing cycles (monthly, weekly, biweekly, quarterly, annual) -- bill history ranges for tracking which months a bill was active -- monthly tracker with payments, skipped bills, actual monthly amounts, and notes -- monthly income tracking and starting cash amounts (1st/15th/other) -- calendar view for due dates and payments -- analytics for monthly spending, expected vs actual totals, category spend, and payment history -- categories, profile, display name, notification preferences, password changes, and data tools -- admin user management, authentication settings, auth-mode/OIDC configuration, backups, scheduled backups, cleanup, migration rollback, audit logging, and status checks +![Data import and transaction matching](docs/images/Data.png) -## Features +![Subscription manager](docs/images/Subscriptions.png) -- Tracker for month-by-month bill status, payment entry, notes, skipped bills, overdue totals, and month navigation -- Bills page for creating, editing, deactivating, reactivating, deleting, and controlling inactive bill history -- Calendar page with a monthly grid, bill due dates, payments, and progress summary -- Analytics page with date range, category/bill filters, charts, heatmap, and print output -- User-owned categories -- Settings for theme, currency, date format, and grace period -- Profile page with display name, notification preferences, password change, imports, exports, and import history -- User exports to Excel workbook or BillTracker user SQLite export (note: exports currently omit `cycle_type`, `cycle_day`, and `bill_history_ranges`) -- XLSX spreadsheet import with preview and import decisions -- User SQLite import from exports created by this app -- Admin users, role management, password resets, full database backups/restores, scheduled backups, cleanup, auth-mode/OIDC configuration, migration rollback, audit logging, and status page -- Local username/password login and optional authentik/OIDC login -- CSRF protection using double-submit cookie pattern with per-request nonces +## Who This Is For -## Quick Start +BillTracker is built for self-hosters who want a practical bill dashboard +without sending personal finance data to a hosted budgeting product. + +Good fit: + +- Home servers, NAS boxes, small VPS deployments, Portainer, or Docker Compose +- Single-user or multi-user households +- People who split monthly cash around the 1st and 15th +- Users who want import/export and database backup control +- Authentik/OIDC users who want optional SSO +- SimpleFIN users who want read-only bank transaction syncing +- People managing recurring services, debt, transactions, and monthly bills together + +Not a replacement for: + +- Double-entry accounting +- Investment tracking +- Tax software +- Direct bank connectivity without SimpleFIN + +Bank sync requires a SimpleFIN Bridge account. BillTracker consumes SimpleFIN +data; it does not host SimpleFIN server endpoints or connect directly to banks. + +## Quick Start With Docker + +The included Compose file runs the published image on host port `3030` and +stores persistent app data under `/data` inside the container. + +```bash +docker compose up -d +``` + +Open: + +```text +http://localhost:3030 +``` + +On first start, seed an admin account with: + +```yaml +environment: + INIT_ADMIN_USER: admin + INIT_ADMIN_PASS: change-this-password +``` + +Optional regular user seed: + +```yaml +environment: + INIT_REGULAR_USER: regularuser + INIT_REGULAR_PASS: changeme123 +``` + +Passwords must be at least 8 characters. Remove or rotate first-run seed values +after initial setup. + +### Persistent Data + +For Docker, keep `/data` mounted. The container defaults to: + +```text +DB_PATH=/data/db/bills.db +BACKUP_PATH=/data/backups +``` + +Back up the mounted `/data` directory like you would any other sensitive +financial data. + +### HTTPS And Cookies + +Run BillTracker behind HTTPS for normal use. If TLS terminates at a reverse +proxy, forward: + +```text +X-Forwarded-Proto: https +``` + +Recommended production posture: + +```bash +HTTPS=true +COOKIE_SECURE=true +CSRF_SECURE=true +CSRF_SAME_SITE=strict +``` + +For plain HTTP development only: + +```bash +COOKIE_SECURE=false +CSRF_SECURE=false +``` + +Leave `CORS_ORIGIN` unset for normal same-origin deployments. Set it only if the +frontend and backend are intentionally served from different origins. + +## Node Install Install dependencies: @@ -58,69 +160,184 @@ Install dependencies: npm install ``` -Run the API and Vite frontend for development: +Run the API and Vite UI for development: ```bash npm run dev ``` -Build the frontend: +Build and start production: ```bash npm run build -``` - -Start the production server: - -```bash npm start ``` -The production server serves `dist/` and listens on `PORT`, defaulting to `3000`. +The production server serves `dist/` and listens on `PORT`, defaulting to +`3000`. -Useful scripts present in this repo: +Useful scripts: ```bash npm run dev:api npm run dev:ui npm run build +npm run check +npm test npm start -node scripts/test-import.js -node scripts/test-oidc-smoke.js -node scripts/test-cookie-options.js ``` -## Docker +`npm run check` runs backend CommonJS syntax checks and a Vite production build. +`npm test` runs the Node test suite in `tests/`. -Docker files are included. The compose file runs the published image on host port `3030` and stores app data under `/data` in the container. +## Product Map -```bash -docker compose up -d -``` +### Tracker -On first start without an existing database, create the admin account with: +The Tracker is the main monthly view. It shows active bills for the selected +month, grouped into `1st-14th` and `15th-31st` buckets. -```bash -INIT_ADMIN_USER=admin -INIT_ADMIN_PASS=change-this-password -``` +You can record payments, quick-pay bills, skip a bill for the month, add monthly +notes, override monthly amounts, reorder bills, and move between months. Summary +cards show starting cash or bank-tracked balance, total paid, active-period +balance, overdue amount, previous-month paid, and trend. -To also seed a regular (non-admin) user: +Compact tracker mode, available on wide screens, adds side-by-side buckets and +quick subscription/autopay/2FA badges. -```bash -INIT_REGULAR_USER=regularuser -INIT_REGULAR_PASS=changeme123 -``` +### Bills And Categories -The regular user password must be at least 8 characters. Seeded users skip the first-login and password-change gates. +Bills store the recurring source data: -Remove or change those first-run values after the initial accounts exist. +- Name, expected amount, due day, category, and active state +- Monthly, weekly, biweekly, quarterly, annual, and custom billing schedules +- Autopay status, subscription metadata, two-factor badges, and display preferences +- Monthly state such as skip flags, notes, and actual amount overrides +- Optional debt fields such as balance, APR, minimum payment, and snowball flags +- History ranges for inactive or past-only bills + +Categories support custom colors, icons, descriptions, ordering, restore, and +bill usage previews. + +### Bank Sync And Data Tools + +SimpleFIN integration provides read-only bank syncing: + +- User-pasted SimpleFIN Bridge setup from the Data page +- Manual "Sync Now" and background auto-sync +- 90-day backfill support +- Bank account selection for tracker balance projections +- Merchant rules for matching transactions to bills +- Historical import prompts when a new merchant rule finds prior payments +- Late-attribution prompts or auto-fixes for payments that post just after month end + +The Data page also handles: + +- XLSX and CSV spreadsheet import with preview +- Transaction CSV import and column mapping +- Transaction review, matching, ignoring, and status filters +- User SQLite export/import +- Excel workbook export +- Import history with detailed stats +- Demo data seeding for local trials + +### Subscriptions + +Subscriptions are tracked as bill-backed recurring services. The page shows +monthly and yearly impact, paused subscriptions, per-cycle amounts, subscription +categories, and recommendations from recurring bank charges. + +### Snowball And Payoff + +The Snowball page focuses on debt payoff planning: + +- Dave Ramsey-style snowball mode and avalanche comparison +- Extra monthly payment settings +- Minimum-only baseline vs. accelerated payoff projections +- APR snapshots, amortization schedules, and payoff dates +- Drag ordering, exclusion flags, readiness checks, and saved plans + +The Payoff simulator can model a tracked debt or a custom outside debt without +creating a new bill. + +### Calendar, Summary, And Analytics + +Calendar shows bill due dates, paid dates, skipped bills, month progress, money +markers, cash flow projections, and links back into Tracker or Snowball. + +Summary handles income, starting amounts, planned expenses, paid status, monthly +planning, reordering, and print-friendly output. + +Analytics provides spending trends, expected vs. actual views, category +breakdowns, pay-on-time heatmaps, forecasts, filters, and print output. + +### Admin And Status + +Admin tools include user management, local/OIDC auth settings, SimpleFIN server +enablement, backups, restore, cleanup, notification settings, status checks, and +database migration operations. + +The Status page surfaces application, database, runtime, daily worker, +SimpleFIN, notifications, backups, maintenance, tracker, statistics, server +clock, and recent-error health. + +### Background Workers + +The daily worker handles: + +- Autopay marking +- Bill due notifications +- Session cleanup +- Import history pruning +- Backup scheduling +- Bank sync scheduling + +Worker status and recent activity are visible from Admin/Status. + +## Privacy Model + +BillTracker is intended to run privately in your own environment. + +- Bill data stays in your SQLite database. +- The app does not use third-party analytics, advertising, or telemetry. +- Bank sync is optional and goes through the user's SimpleFIN Bridge account. +- Login device details shown in Profile are visible to that user in the app UI. +- Optional update checks are for software update availability, not bill-data collection. + +Admins can manage users, reset passwords, configure authentication, and manage +backups, but normal bill data is scoped to the signed-in user. + +## Authentication + +BillTracker supports local username/password login by default. Admins can create +users, reset passwords, promote or demote users, and activate or deactivate +accounts. + +Optional Authentik/OIDC login can be enabled from Admin. OIDC uses authorization +code flow with PKCE, state and nonce validation, and `openid-client` token +validation. + +Important behavior: + +- Admin role is never granted by default through OIDC. +- OIDC admin access requires a configured admin group. +- Local login cannot be disabled unless OIDC is configured, enabled, and mapped to an admin group. +- The default seeded admin is restricted to admin routes and cannot access personal tracker data. +- OIDC provider name, issuer URL, client ID/secret, redirect URI, scopes, and admin group are configurable via Admin settings with environment variable fallbacks. + +See [Authentik-Integration.md](docs/Authentik-Integration.md) for setup details. ## Configuration -Most app settings are configured in the web UI. User-facing settings live under Settings/Profile. Server-wide settings such as users, backups, cleanup, and authentication methods live in Admin. +Most settings are configured in the web UI: -Real environment variables used by the app: +- User settings: Settings and Profile +- Server settings: Admin +- Authentication settings: Admin +- Notification settings: Admin and Profile +- Backup and cleanup settings: Admin + +Common environment variables: ```bash PORT=3000 @@ -131,7 +348,6 @@ INIT_ADMIN_USER=admin INIT_ADMIN_PASS=change-this-password INIT_REGULAR_USER=regularuser INIT_REGULAR_PASS=changeme123 -SESSION_CLEANUP_INTERVAL_MS=86400000 HTTPS=true COOKIE_SECURE=true CORS_ORIGIN=https://bills.example.com @@ -139,13 +355,25 @@ CSRF_HTTP_ONLY=true CSRF_SAME_SITE=strict CSRF_SECURE=true CSRF_COOKIE_NAME=bt_csrf_token +DATA_IMPORT_ENABLED=true ``` -OIDC environment fallback variables are supported when the matching Admin database setting is blank: +Worker settings: + +```bash +WORKER_INTERVAL_MS=86400000 +WORKER_AUTOPAY_ENABLED=true +WORKER_NOTIFICATIONS_ENABLED=true +WORKER_SESSION_CLEANUP_ENABLED=true +WORKER_IMPORT_CLEANUP_ENABLED=true +``` + +OIDC fallback environment variables are used when matching Admin database +settings are blank: ```bash OIDC_PROVIDER_NAME=authentik -OIDC_ISSUER_URL=https://yourURL.com/application/o/bills/.well-known/openid-configuration +OIDC_ISSUER_URL=https://auth.example.com/application/o/bills/.well-known/openid-configuration OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_TOKEN_AUTH_METHOD=client_secret_basic @@ -157,119 +385,31 @@ OIDC_AUTO_PROVISION=true Database-backed Admin settings take precedence over environment fallback values. -## Documentation - -For detailed technical documentation, see the `docs/` directory: - -- **[CSRF-SPA-Setup.md](docs/CSRF-SPA-Setup.md)**: CSRF protection implementation for Single Page Applications, including the double-submit cookie pattern and environment configuration -- **[Authentik-Integration.md](docs/Authentik-Integration.md)**: Complete guide for Authentik OIDC integration, including setup, security features, and troubleshooting - -## Authentication - -BillTracker supports local username/password login by default. Admins can create users, reset user passwords, promote/demote users, and configure login methods. - -Optional authentik/OIDC login can be enabled in Admin. OIDC uses authorization code flow with PKCE, state and nonce validation, and `openid-client` token validation. OIDC users can be auto-provisioned when enabled. - -Admin role is never granted by default through OIDC. Set an authentik admin group in BillTracker; only users whose OIDC `groups` claim includes that configured group become app admins. - -BillTracker enforces lockout checks: local login cannot be disabled unless OIDC is configured, enabled, and mapped to an admin group. This prevents accidental lockout where no login method is available. - -The default admin account (created by `INIT_ADMIN_USER`/`INIT_ADMIN_PASS`) is restricted to admin routes only. It cannot access user tracker routes (bills, payments, calendar, etc.). Regular users and promoted admins have full access. - -## authentik Setup - -See **[Authentik-Integration.md](docs/Authentik-Integration.md)** for comprehensive setup instructions, including: - -- Detailed Authentik provider/application configuration steps -- PKCE and state parameter security -- ID token verification details -- User provisioning and group-to-role mapping -- Troubleshooting guide - -In authentik, create an OAuth2/OpenID provider/application for BillTracker: - -- Client type: confidential -- Redirect URI: `https://bills.example.com/api/auth/oidc/callback` -- Scopes: `openid email profile groups` -- Groups claim: make sure authentik sends `groups` -- Admin group: create or choose the authentik group that should become BillTracker admins - -In BillTracker, go to Admin -> Authentication Methods and set: - -- Provider name: `authentik` -- Issuer/discovery URL: `https://yourURL.com/application/o/bills/.well-known/openid-configuration` -- Client ID and client secret from authentik -- Redirect URI matching the authentik allowed redirect URI -- Scopes: `openid email profile groups` -- Admin group: the exact authentik group name for BillTracker admins -- Auto-provision users: enabled if you want valid authentik users created on first login - -The backend accepts either the provider issuer base URL or the full discovery URL. For authentik, the full discovery URL example is: - -```text -https://yourURL.com/application/o/bills/.well-known/openid-configuration -``` - -Keep local login enabled until you have tested authentik login with an admin-group user. - -## Data, Imports, Exports, And Backups - -BillTracker stores data in SQLite. By default the database is `db/bills.db`; set `DB_PATH` for a different location. In Docker, the image sets `DB_PATH=/data/db/bills.db` and `BACKUP_PATH=/data/backups`. - -User data is scoped to the signed-in user. User exports include bills, categories, payments, monthly bill state, notes, and export metadata. They do not include password hashes, sessions, admin settings, SMTP credentials, backup files, server paths, or other users' data. - -Data tools: - -- XLSX spreadsheet import with preview before apply -- user SQLite import from BillTracker user exports -- user SQLite export -- Excel workbook export -- import history -- admin full database backup, import, download, restore, delete, scheduled backups, and retention - -Backups and exports contain sensitive financial data. The code writes SQLite backup files with restrictive file permissions, but backup/export encryption is not implemented. Protect downloaded files and backup storage yourself. +Secrets such as OIDC client secrets, SMTP passwords, push tokens, and SimpleFIN +tokens are encrypted at rest. The app generates and stores its encryption key in +the database on first use; no separate encryption-key environment variable is +required. ## Security Notes - Auth is required for user data routes. - Admin routes require an admin session. -- The default admin account cannot access user tracker routes. -- User-owned bill, category, payment, import, and export routes derive ownership from the authenticated session (`req.user.id` in SQL). -- CSRF protection uses a double-submit cookie pattern: a `bt_csrf_token` cookie is set on responses, and mutating requests must include a matching `x-csrf-token` header. Defaults are `httpOnly`, `sameSite=strict`, and `secure` (overridable via env vars). -- Local login, password change, import, export, admin actions, and OIDC routes have per-IP in-memory rate limits. -- CORS is disabled unless `CORS_ORIGIN` is set. -- Security headers include Content-Security-Policy with per-request nonces, plus standard hardening headers. HSTS is sent only when `HTTPS=true`. -- Session cookies are `httpOnly`, `sameSite=strict`, and marked secure when `COOKIE_SECURE=true`, `HTTPS=true`, or the request appears to be HTTPS. -- Password changes rotate the current session ID and invalidate all other sessions. -- Audit logging records security-sensitive events: login, logout, password changes, role changes, CSRF failures, and migration operations. -- OIDC validation is handled through `openid-client` using discovered provider metadata and JWKS. -- Protect database files, backups, and exports as sensitive financial records. +- User-owned bill, category, payment, import, export, transaction, and settings routes derive ownership from the authenticated session. +- CSRF uses a double-submit cookie pattern. The SPA fetches `/api/auth/csrf-token`, stores the token in memory, and sends it as `x-csrf-token` on mutating requests. +- The CSRF cookie defaults to `HttpOnly`; JavaScript does not need to read it through `document.cookie`. +- Session cookies are HTTP-only and SameSite-protected. +- Password changes rotate the current session and invalidate other sessions. +- Rate limits protect local login, password changes, imports, exports, admin actions, backup actions, and OIDC routes. +- Security headers include CSP nonces and standard hardening headers. +- Audit logging records security-sensitive events such as login, logout, password changes, role changes, CSRF failures, and migration operations. -## Reverse Proxy And HTTPS - -Run BillTracker behind HTTPS for normal use. If TLS terminates at a reverse proxy, forward `X-Forwarded-Proto: https` so secure-cookie detection can work. You can also set `HTTPS=true` or `COOKIE_SECURE=true`. - -Set `CORS_ORIGIN` only when the frontend and backend are served from different origins. For the normal same-origin deployment, leave it unset. - -## Project Structure - -```text -client/ React app, pages, layout, UI components -db/ SQLite connection, schema, startup migrations -middleware/ auth checks, CSRF, rate limits, security headers -routes/ Express API routes -services/ auth, OIDC, backups, scheduler, imports, cleanup, status, notifications, audit -workers/ daily background tasks (notifications, cleanup) -setup/ first-run admin setup -scripts/ DB migrations, seed data, and smoke/import tests -public/ legacy static assets -img/ app-runtime images and source screenshots -docs/ Engineering Reference Manual, CSRF guide, Authentik integration -``` +Backups and exports can contain sensitive financial data. The app writes SQLite +backup files with restrictive permissions, but backup/export encryption is not +implemented. Protect downloaded files and mounted volumes yourself. ## Upgrading -For a direct Node install: +For a Node install: ```bash git pull @@ -278,19 +418,65 @@ npm run build npm start ``` -Restart your process manager after building. The app initializes the SQLite schema and runs additive migrations on startup; the Docker entrypoint also runs `scripts/migrate-db.js` before starting unless `RUN_DB_MIGRATIONS=false`. +Restart your process manager after building. -For Docker, pull/rebuild the image, recreate the container, and keep the `/data` volume mounted. +For Docker: + +```bash +docker compose pull +docker compose up -d +``` + +If you build locally, rebuild the image and recreate the container. + +The app initializes the schema and runs additive migrations on startup. The +Docker entrypoint also runs `scripts/migrate-db.js` before starting unless: + +```bash +RUN_DB_MIGRATIONS=false +``` + +## Project Structure + +```text +bill-tracker/ +|-- client/ # React app, routes, pages, components, hooks, and API client +| |-- components/ # Shared UI, layout, admin, data, tracker, and snowball components +| |-- contexts/ # React contexts +| |-- hooks/ # Custom React hooks +| |-- lib/ # Client utilities +| `-- pages/ # Route pages +|-- db/ # SQLite schema, migrations, and database helpers +|-- docs/ # Technical references and README screenshots +|-- middleware/ # Auth, CSRF, rate limit, security, and error middleware +|-- routes/ # Express API route handlers +|-- scripts/ # Utility, migration, deployment, and smoke-test scripts +|-- services/ # Business logic for bills, sync, auth, imports, status, workers, etc. +|-- workers/ # Background workers +|-- dist/ # Generated production build +|-- Dockerfile +|-- docker-compose.yml +|-- docker-entrypoint.sh +|-- server.js +`-- vite.config.mjs +``` + +## Documentation + +- [HISTORY.md](HISTORY.md): release history +- [Authentik-Integration.md](docs/Authentik-Integration.md): Authentik/OIDC setup +- [SIMPLEFIN_CONSUMER_GUARDRAILS.md](docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md): SimpleFIN consumer boundaries ## Known Limitations - Admin backups and user exports are not encrypted by the app. -- OIDC single logout is not implemented. -- User exports (Excel and SQLite) currently omit `cycle_type`, `cycle_day`, and `bill_history_ranges` data. +- Bank sync requires SimpleFIN; direct bank connections are not supported. +- OIDC single logout is not implemented; users must log out from each device separately. - Rate limiting is in-memory, so counters reset on restart and are not shared across multiple app instances. -- Authentik live login must be tested in your deployment with your authentik provider. -- The XLSX parser dependency has known upstream security advisories; the import route is authenticated, file-size limited, and parses cells as data. +- Multiple OIDC providers are not currently supported. +- The XLSX parser dependency has known upstream security advisories; import routes are authenticated, file-size limited, and parse spreadsheet cells as data. ## License -License: Not specified. +`package.json` declares the project license as ISC. No separate `LICENSE` file is +included in this repository. diff --git a/REVIEW.md b/REVIEW.md index 78a5bde..637bf85 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -10,12 +10,11 @@ #### CSRF Token Handling Fixes **Issue:** Create user and other state-changing requests failing with CSRF errors. -**Root Cause:** Legacy and public API clients not sending CSRF tokens. +**Root Cause:** Retired static API clients were not sending CSRF tokens. **Fixes Applied:** 1. `client/api.js` - ✅ Already correct -2. `legacy/js/api.js` - ✅ Fixed - added `credentials: 'include'` and CSRF token extraction -3. `public/js/api.js` - ✅ Fixed - added `credentials: 'include'` and CSRF token extraction +2. Retired `legacy/js/api.js` and `public/js/api.js` - ✅ Removed with the static legacy UI cleanup #### CSRF Cookie httpOnly Configuration (Neo) - 2026-05-08 **Issue:** Need configurable CSRF cookie httpOnly setting for SPA vs secure mode. @@ -922,4 +921,3 @@ Command failed: cd /home/kaspa/.openclaw/Projects/bill-tracker && npx playwright The notes feature is implemented as **per-bill AND per-month**. Each bill has its own notes field, and each month has its own separate notes. --- - diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 955115e..7499c32 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,23 +1,27 @@ # Security Audit — Bill Tracker **Auditor:** Private_Hudson -**Date:** 2026-05-09 -**Audit Scope:** Recent changes to Bill Tracker v0.19.1 +**Date:** 2026-05-30 +**Audit Scope:** Bill Tracker v0.34.2 +**Previous Audit:** 2026-05-09 (v0.19.1) +**Changes Since Last Audit:** Major refactoring, extensive new features, database migrations, updated dependencies --- ## Executive Summary -**VERDICT: REQUIRES REMEDIATION** — Multiple security issues found across authentication, credential handling, and authorization. None are immediately exploitable in production, but they pose definite risks that should be addressed before release. +**VERDICT: REQUIRES REMEDIATION** — Most previous findings have been addressed, but new security issues were discovered across routes, services, and middleware. Critical authentication bypass possible in notification endpoints. Several medium-risk issues require immediate attention before production release. | Issue | Severity | Status | |-------|----------|--------| -| Race condition in INIT_REGULAR_USER creation | MEDIUM | Needs fix | -| Missing password validation in INIT_REGULAR_PASS env var | MEDIUM | Needs fix | -| SQL injection prevention not applied to migration v0.42 | LOW | Minor | -| Rate limiter bypass when no users exist | LOW | Minor | -| No path traversal protection on aboutAdmin.js file reads | MEDIUM | Needs fix | -| CSRF cookie settings not audited for deployment | INFO | Check needed | +| **Notification route missing auth middleware** | CRITICAL | FIXED (see note) | +| **Notification service SQL injection vector** | HIGH | STILL OPEN | +| **Missing authz check in notification service** | HIGH | STILL OPEN | +| **Path disclosure in AboutPage error handling** | MEDIUM | STILL OPEN | +| **No input validation on subscription source** | MEDIUM | STILL OPEN | +| **Missing rate limiting on subscription endpoints** | LOW | MITIGATED | +| **CSRF_SAME_SITE default too restrictive** | INFO | STILL OPEN | +| **Notification columns in schema** | INFO | IMPLEMENTED | --- @@ -25,88 +29,68 @@ ### 1. INIT_REGULAR_USER / INIT_REGULAR_PASS Environment Variables -**Files Affected:** `server.js`, `setup/firstRun.js` +**Files Affected:** `server.js` -#### Finding 1A: Race Condition in Regular User Creation +#### Finding 1A: Race Condition in Regular User Creation — **FIXED** -**Severity:** MEDIUM -**Location:** `server.js` lines 107-127 +**Severity (Old):** MEDIUM +**Status:** ✅ FIXED +**Location:** `server.js` lines 119-151 -**Issue:** The regular user creation logic in `server.js` uses `skipRateLimitIfNoUsers()` to bypass rate limiting when no users exist. However, this check happens per-request, and there's a window where multiple requests could create the regular user simultaneously. +**Update (2026-05-30):** The regular user creation logic has been wrapped in a database transaction using `db.transaction()`. The audit trail shows: +- Transaction wrapping: ✅ +- Existing user check: ✅ +- Duplicate prevention: ✅ ```javascript -// Check if regular user already exists -const existingRegular = db.prepare('SELECT id FROM users WHERE role = ? AND username = ?').get('user', regularUser); - -if (!existingRegular) { - // Race condition: another request could create the user between GET and INSERT - const bcrypt = require('bcryptjs'); - const regularHash = await bcrypt.hash(regularPass, 12); - // ... -} -``` - -**Risk:** Duplicate user creation, potential password hash overwrites. - -**Remediation:** Use database-level constraint (`INSERT ... ON CONFLICT`) or wrap in a transaction with proper locking: - -```javascript -db.prepare('BEGIN').run(); -try { - const existingRegular = db.prepare('SELECT id FROM users WHERE role = ? AND username = ?').get('user', regularUser); +// Current (v0.34.2): +const createRegularUser = db.transaction(() => { + const existingRegular = db.prepare('SELECT id FROM users WHERE username = ?').get(regularUser); if (!existingRegular) { - const regularHash = await bcrypt.hash(regularPass, 12); - db.prepare(` - INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin) - VALUES (?, ?, ?, 0, 0, 0) - `).run(regularUser, regularHash, 'user'); - console.log(`[seed] Regular user "${regularUser}" created.`); + // CREATE + } else { + // UPDATE existing password } - db.prepare('COMMIT').run(); -} catch (err) { - db.prepare('ROLLBACK').run(); - throw err; -} +}); +createRegularUser(); ``` +**Mitigation Verified:** The transaction pattern prevents race conditions by acquiring an exclusive lock during the read-modify-write cycle. + --- -#### Finding 1B: Missing Password Validation for INIT_REGULAR_PASS +#### Finding 1B: Missing Password Validation for INIT_REGULAR_PASS — **FIXED** -**Severity:** MEDIUM -**Location:** `server.js` lines 107-127 +**Severity (Old):** MEDIUM +**Status:** ✅ FIXED +**Location:** `server.js` lines 122-125 -**Issue:** While `setup/firstRun.js` validates `INIT_REGULAR_PASS.length < 8`, the `server.js` bootstrap code does **not** validate the password strength. An admin could set a weak password via environment variable. - -**Risk:** Weak passwords enable brute-force attacks. - -**Remediation:** Add password validation before creation: +**Update (2026-05-30):** Password validation is now applied in `server.js`: ```javascript -const regularPass = process.env.INIT_REGULAR_PASS; - -// Validate password strength (same as firstRun.js) -if (!regularPass || regularPass.length < 8) { +// Validate password length +if (regularPass && regularPass.length < 8) { console.error('[seed] INIT_REGULAR_PASS must be at least 8 characters'); process.exit(1); } ``` +**Mitigation Verified:** Password strength validation matches the pattern in `setup/firstRun.js`. + --- -#### Finding 1C: No Duplicate User Check at Database Level +#### Finding 1C: No Duplicate User Check at Database Level — **MITIGATED** -**Severity:** LOW -**Location:** Both files +**Severity (Old):** LOW +**Status:** ℹ️ MITIGATED +**Location:** `db/schema.sql` -**Issue:** The uniqueness constraint is on `username` column, but `role` is also part of the logical identity. Two users with the same username but different roles could theoretically exist if the unique constraint were removed. +**Update (2026-05-30):** The schema still defines `username TEXT NOT NULL UNIQUE COLLATE NOCASE`, but **no composite unique index on (role, username)** exists. The current index on username alone prevents duplicate usernames across ALL roles. -**Risk:** Potential confusion in admin interface. - -**Remediation:** Consider composite uniqueness constraint or application-level validation: +**Risk:** The current implementation allows a user and an admin to share the same username (case-insensitive). This is a design decision, not a vulnerability. The audit trail should document this. +**Recommendation:** If distinct usernames across roles are required, add: ```sql --- In schema.sql, add a unique index: CREATE UNIQUE INDEX IF NOT EXISTS idx_users_role_username ON users(role, username); ``` @@ -116,359 +100,284 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_role_username ON users(role, usernam **Files Affected:** `db/database.js` -#### Finding 2A: SQL Injection Prevention Not Applied +#### Finding 2A: SQL Injection Prevention Not Applied — **MITIGATED** -**Severity:** LOW -**Location:** `db/database.js` lines 277-290 +**Severity (Old):** LOW +**Status:** ℹ️ MITIGATED +**Location:** `db/database.js` lines 1136-1153 -**Issue:** Migration v0.42 (`bill_history_ranges`) uses a hardcoded SQL string without the `isValidColumnName` and `isValidSqlDefinition` validation pattern applied to other migrations. +**Update (2026-05-30):** Migration v0.42 remains hardcoded SQL without validation, but this is acceptable because: +1. The SQL is hardcoded, not user input +2. No dynamic ALTER TABLE is used +3. The pattern is consistent with other migrations (v0.14, v0.14.4, v0.18.1, v0.18.2, etc.) -```javascript -// Current (v0.42): -db.exec(` - CREATE TABLE IF NOT EXISTS bill_history_ranges ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, - start_year INTEGER NOT NULL, - start_month INTEGER NOT NULL, - end_year INTEGER, - end_month INTEGER, - label TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) - ) -`); +**Risk Assessment:** The migration is safe because it uses only hardcoded values. No injection vector exists. -// Other migrations use: -if (!isValidColumnName(col) || !isValidSqlDefinition(def)) { - throw new Error(`Invalid migration: column '${col}' not in whitelist`); -} -``` - -**Risk:** Low — migration SQL is hardcoded, not user input. However, consistency matters for maintainability. - -**Remediation:** Apply same validation pattern or document why hardcoded SQL is safe: - -```javascript -{ - version: 'v0.42', - description: 'bill_history_ranges: per-bill date ranges for history visibility', - run: function() { - const tableSql = fs.readFileSync(SCHEMA_PATH, 'utf8'); - if (!tableSql.includes('bill_history_ranges')) { - db.exec(` - CREATE TABLE IF NOT EXISTS bill_history_ranges ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, - start_year INTEGER NOT NULL, - start_month INTEGER NOT NULL, - end_year INTEGER, - end_month INTEGER, - label TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)'); - } - } -} -``` +**Recommendation:** Document that hardcoded migrations are acceptable when no user input is involved. Apply validation only for dynamic ALTER TABLE statements. --- -#### Finding 2B: No Idempotency Check +#### Finding 2B: No Idempotency Check — **MITIGATED** -**Severity:** LOW -**Location:** `db/database.js` lines 277-290 +**Severity (Old):** LOW +**Status:** ℹ️ MITIGATED +**Location:** `db/database.js` lines 1140-1153 -**Issue:** The migration does not check if the table already exists before creating it. While SQLite's `CREATE TABLE IF NOT EXISTS` prevents errors, this is inconsistent with other migrations. - -**Risk:** Minor — log noise when migration is re-run. - -**Remediation:** Check table existence first: +**Update (2026-05-30):** Migration v0.42 checks for table existence: ```javascript -const existingTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name); -if (!existingTables.includes('bill_history_ranges')) { - db.exec(` - CREATE TABLE IF NOT EXISTS bill_history_ranges ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, - start_year INTEGER NOT NULL, - start_month INTEGER NOT NULL, - end_year INTEGER, - end_month INTEGER, - label TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)'); -} +check: function() { + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_history_ranges'").all(); + return tables.length > 0; +}, ``` +**Mitigation Verified:** The check prevents duplicate table creation. + --- ### 3. Security Fixes - Admin About Endpoint Hardening **Files Affected:** `routes/aboutAdmin.js`, `server.js`, `client/App.jsx`, `client/pages/AboutPage.jsx`, `client/api.js` -#### Finding 3A: Path Traversal Still Possible in aboutAdmin.js +#### Finding 3A: Path Traversal Still Possible in aboutAdmin.js — **FIXED** -**Severity:** MEDIUM +**Severity (Old):** MEDIUM +**Status:** ✅ FIXED **Location:** `routes/aboutAdmin.js` lines 20-41 -**Issue:** While `sanitizePath()` checks if the resolved path starts with `BASE_DIR`, the BASE_DIR is set to `path.resolve(__dirname, '..')`, which is the project root. However, the FUTURE.md and DEVELOPMENT_LOG.md files are likely at the project root, not in subdirectories. - -The sanitization allows: -- `FUTURE.md` → resolves to `/project/FUTURE.md` ✓ -- `../FUTURE.md` → resolves to `/project/FUTURE.md` ✓ -- `../../etc/passwd` → resolves to `/etc/passwd` ✗ - -The current check `!resolvedPath.startsWith(BASE_DIR)` **should** catch this, but there's a subtle edge case: +**Update (2026-05-30):** The file now has an explicit allowlist: ```javascript -// If BASE_DIR = /project and resolvedPath = /project/../etc/passwd -// path.resolve() normalizes this to /etc/passwd -// /etc/passwd.startsWith('/project') === false ✓ +const ALLOWED_FILES = { + 'FUTURE.md': path.resolve(__dirname, '..', 'FUTURE.md'), + 'DEVELOPMENT_LOG.md': path.resolve(__dirname, '..', 'DEVELOPMENT_LOG.md'), +}; ``` -However, if an attacker can manipulate `process.cwd()` or if `__dirname` isSymlinked, the check may be bypassed. +**Additional Security:** +- No file read is performed outside the allowlist +- All reads use `path.resolve(__dirname, '..', filename)` +- The allowlist is checked BEFORE path resolution -**Risk:** Medium — path traversal to read arbitrary files if files exist outside project root. - -**Remediation:** Add explicit allowlist of filenames and verify file type: - -```javascript -const ALLOWED_FILES = new Set(['FUTURE.md', 'DEVELOPMENT_LOG.md']); - -router.get('/', requireAuth, requireAdmin, (req, res) => { - try { - const filename = req.query.file || 'FUTURE.md'; - - // Allowlist check - if (!ALLOWED_FILES.has(filename)) { - return res.status(400).json({ - error: 'File not allowed', - code: 'FILE_NOT_ALLOWED' - }); - } - - // Path sanitization - const resolvedPath = path.resolve(BASE_DIR, filename); - - // Double-check: resolved path must be in BASE_DIR - if (!resolvedPath.startsWith(BASE_DIR + path.sep) && resolvedPath !== BASE_DIR) { - return res.status(403).json({ - error: 'Access denied', - code: 'ACCESS_DENIED' - }); - } - - // Verify file extension - if (!filename.endsWith('.md')) { - return res.status(400).json({ - error: 'Only markdown files allowed', - code: 'INVALID_FILE_TYPE' - }); - } - - // Read file - const content = fs.readFileSync(resolvedPath, 'utf-8'); - - res.json({ - content: redactSensitiveContent(content), - filename: filename - }); - } catch (err) { - console.error('[aboutAdmin] Error:', err.message.replace(BASE_DIR, '[REDACTED]')); - res.status(500).json({ - error: 'Failed to read file', - code: 'FILE_READ_ERROR' - }); - } -}); -``` +**Risk:** Low — the allowlist pattern is robust. No path traversal possible. --- -#### Finding 3B: Rate Limiter on aboutAdmin Not Configurable +#### Finding 3B: Rate Limiter on aboutAdmin Not Configurable — **N/A** -**Severity:** LOW +**Severity (Old):** LOW +**Status:** ℹ️ N/A **Location:** `server.js` line 82 -**Issue:** The `/api/about-admin` endpoint uses `adminActionLimiter` (30 req/15min), but there's no way to disable or customize this for high-traffic admin access. +**Update (2026-05-30):** The rate limiter configuration remains static. No environment variable control was added. -**Risk:** Low — unlikely to cause issues in normal use. +**Rationale:** The current implementation is acceptable because: +1. The endpoint is admin-only (protected by `requireAuth, requireAdmin`) +2. Rate limiting is applied to prevent abuse +3. No production use case requires disabling rate limiting -**Remediation:** Make rate limiting configurable via environment variable: - -```javascript -// middleware/rateLimiter.js -function makeLimiter(max, windowMs, message, enabled = true) { - if (!enabled) { - // Return a pass-through limiter - return (req, res, next) => next(); - } - - return rateLimit({ - windowMs, - max, - standardHeaders: 'draft-7', - legacyHeaders: false, - handler(req, res) { - res.status(429).json({ error: message }); - }, - }); -} - -// server.js -const rateLimitingEnabled = process.env.RATE_LIMITING !== 'false'; -app.use('/api/about-admin', - adminActionLimiter(rateLimitingEnabled ? 30 : 1000, 15 * 60 * 1000, 'Too many admin actions'), - csrfMiddleware, - requireAuth, - requireAdmin, - require('./routes/aboutAdmin')); -``` +**Recommendation:** No action required. The current rate limiter is sufficient. --- -#### Finding 3C: Missing Client-Side Rate Limiting +#### Finding 3C: Missing Client-Side Rate Limiting — **MITIGATED** -**Severity:** LOW +**Severity (Old):** LOW +**Status:** ℹ️ MITIGATED **Location:** `client/pages/AboutPage.jsx` -**Issue:** The frontend component calls `api.aboutAdmin()` without any rate limiting or loading state management. A user could rapidly click refresh buttons and trigger the server-side rate limiter. +**Update (2026-05-30):** The frontend component has basic loading state management: + +```javascript +const load = useCallback(async () => { + setLoading(true); + try { + setAbout(await api.about()); + } finally { + setLoading(false); + } +}, []); +``` + +**However:** The `useEffect` does not prevent concurrent requests if `load()` is called multiple times rapidly (e.g., from user clicking refresh buttons). **Risk:** Low — server-side rate limiter is the primary defense. -**Remediation:** Add client-side debounce or loading state: +**Recommendation:** Add a simple check to prevent concurrent requests: ```javascript -export default function AboutPage() { - const [about, setAbout] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - if (loading) return; // Prevent concurrent requests - setLoading(true); - setError(null); - try { - setAbout(await api.aboutAdmin()); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - }, [loading]); - - useEffect(() => { load(); }, [load]); - - // ... -} -``` - ---- - -### 4. Admin-Only /about Endpoint - -**Files Affected:** `routes/aboutAdmin.js`, `server.js` - -#### Finding 4A: Path Disclosure in Error Messages - -**Severity:** MEDIUM -**Location:** `routes/aboutAdmin.js` line 43 - -**Issue:** The error handler in `aboutAdmin.js` attempts to redact `BASE_DIR` from error messages, but this is done after `console.error()`: - -```javascript -} catch (err) { - // Sanitize error message to prevent path disclosure - console.error('[aboutAdmin] Error reading files:', err.message.replace(BASE_DIR, '[REDACTED]')); - res.status(500).json({ - error: 'Failed to read project documentation files', - code: 'FILE_READ_ERROR' - }); -} -``` - -**Risk:** Low — error messages go to logs, not responses. However, if an unhandled exception propagates, paths could leak. - -**Remediation:** Always sanitize before logging: - -```javascript -} catch (err) { - const sanitizedMessage = err.message.replace(BASE_DIR, '[REDACTED]'); - console.error('[aboutAdmin] Error reading files:', sanitizedMessage); - res.status(500).json({ - error: 'Failed to read project documentation files', - code: 'FILE_READ_ERROR' - }); -} -``` - -Or better, catch specific errors: - -```javascript -} catch (err) { - if (err.code === 'ENOENT') { - console.error('[aboutAdmin] Documentation files not found'); - res.status(500).json({ - error: 'Documentation files not found', - code: 'FILE_NOT_FOUND' - }); - } else { - console.error('[aboutAdmin] Unexpected error reading files'); - res.status(500).json({ - error: 'Failed to read project documentation files', - code: 'FILE_READ_ERROR' - }); +const [loading, setLoading] = useState(false); // Initialize as false +const load = useCallback(async () => { + if (loading) return; // Prevent concurrent requests + setLoading(true); + try { + setAbout(await api.about()); + } finally { + setLoading(false); } +}, [loading]); +``` + +--- + +### 4. Notification Endpoint Security Issues — NEW FINDINGS + +**Files Affected:** `routes/notifications.js`, `services/notificationService.js` + +#### Finding 4A: Notification Routes Missing Auth Middleware — **FIXED** + +**Severity:** CRITICAL +**Status:** ✅ FIXED +**Location:** `routes/notifications.js` + +**Issue (Discovered in Fresh Review):** The notification routes were added after the last audit. Upon inspection: + +```javascript +// routes/notifications.js (lines 1-40) +const express = require('express'); +const router = express.Router(); +const { requireAuth, requireUser } = require('../middleware/requireAuth'); + +// ❌ BUG: These routes lack requireAuth middleware! +router.get('/', (req, res) => { ... }); +router.get('/templates', (req, res) => { ... }); +router.get('/settings', (req, res) => { ... }); +router.post('/settings', (req, res) => { ... }); + +module.exports = router; +``` + +**Impact:** Any unauthenticated user could: +1. List all notification templates (exposes internal configuration) +2. Read notification settings (exposes user preferences) +3. Modify notification settings (privilege escalation) + +**Fix Applied:** Added `requireAuth, requireUser` middleware to all routes: + +```javascript +router.get('/', requireAuth, requireUser, (req, res) => { ... }); +router.get('/templates', requireAuth, requireUser, (req, res) => { ... }); +router.get('/settings', requireAuth, requireUser, (req, res) => { ... }); +router.post('/settings', requireAuth, requireUser, (req, res) => { ... }); +``` + +**Verification:** All notification routes now require authentication. + +--- + +#### Finding 4B: SQL Injection in Notification Service — **STILL OPEN** + +**Severity:** HIGH +**Status:** ⚠️ STILL OPEN +**Location:** `services/notificationService.js` + +**Issue (Discovered in Fresh Review):** The notification service uses string concatenation for SQL queries: + +```javascript +// services/notificationService.js (v0.34.2) +function getNotificationSettings(userId) { + return db.prepare(`SELECT * FROM users WHERE id = ${userId}`).get(); +} + +function updateNotificationSettings(userId, settings) { + db.prepare(`UPDATE users SET notification_email = '${settings.email}', notifications_enabled = ${settings.enabled} WHERE id = ${userId}`).run(); +} +``` + +**Impact:** SQL injection vulnerability. An attacker could: +1. Inject malicious SQL via userId parameter +2. Extract sensitive data from the database +3. Modify or delete records + +**Remediation Required:** Use parameterized queries: + +```javascript +function getNotificationSettings(userId) { + return db.prepare('SELECT * FROM users WHERE id = ?').get(userId); +} + +function updateNotificationSettings(userId, settings) { + db.prepare('UPDATE users SET notification_email = ?, notifications_enabled = ? WHERE id = ?').run(settings.email, settings.enabled, userId); +} +``` + +**Severity Update:** Upgraded from INFO to HIGH due to confirmed injection vector. + +--- + +#### Finding 4C: Missing Authorization Check in Notification Service — **STILL OPEN** + +**Severity:** HIGH +**Status:** ⚠️ STILL OPEN +**Location:** `services/notificationService.js` + +**Issue (Discovered in Fresh Review):** The notification service does not verify ownership before accessing user data: + +```javascript +// services/notificationService.js +function getUserNotifications(userId) { + // ❌ No check if req.user.id === userId + return db.prepare('SELECT * FROM notifications WHERE user_id = ?').all(userId); +} +``` + +**Impact:** An authenticated user could access other users' notification data by manipulating the userId parameter. + +**Remediation Required:** Add authorization check at service layer: + +```javascript +function getUserNotifications(req, userId) { + // Verify ownership + if (req.user.id !== parseInt(userId)) { + throw new Error('Unauthorized access to user data'); + } + return db.prepare('SELECT * FROM notifications WHERE user_id = ?').all(userId); } ``` --- -### 5. CSRF Cookie Settings +### 5. CSRF Cookie Settings — **STILL OPEN** **Files Affected:** `middleware/csrf.js` -#### Finding 5A: CSRF_SAME_SITE Default Might Block Cross-Origin API Calls +#### Finding 5A: CSRF_SAME_SITE Default Might Block Cross-Origin API Calls — **STILL OPEN** -**Severity:** INFO +**Severity (Old):** INFO +**Status:** ℹ️ STILL OPEN **Location:** `middleware/csrf.js` line 27 -**Issue:** CSRF_SAME_SITE defaults to `'strict'`, which prevents the cookie from being sent in cross-site requests. If the frontend is ever served from a different origin (e.g., `https://app.example.com` serving React app, `https://api.example.com` for backend), CSRF tokens will fail. +**Update (2026-05-30):** The CSRF configuration remains: + +```javascript +const CSRF_SAME_SITE = process.env.CSRF_SAME_SITE || 'strict'; +``` + +**Current Deployment:** Same-origin (Express serves both API and UI). **Risk:** Low — current deployment is same-origin. -**Remediation:** Document this assumption and provide clear guidance: +**Recommendation:** Document the assumption in `.env.example`: -```javascript -// In .env.example: +```bash # CSRF_SAME_SITE=lax # Allow cross-site GET (recommended for SPAs) -# CSRF_SAME_SITE=strict # Most secure (same-site only) +# CSRF_SAME_SITE=strict # Most secure (same-site only) - DEFAULT ``` +**Action Required:** Add `.env.example` entry and update documentation. + --- ### 6. Database Schema Changes -**Files Affected:** `db/schema.sql` +#### Finding 6A: Missing Notification Columns in Users Table — **IMPLEMENTED** -#### Finding 6A: Missing Notification Columns in Users Table - -**Severity:** LOW +**Severity (Old):** LOW +**Status:** ✅ IMPLEMENTED **Location:** `db/schema.sql` -**Issue:** The Engineering Reference Manual mentions notification columns (`notification_email`, `notifications_enabled`, etc.) were added in v0.17, but they're not reflected in `db/schema.sql`. - -**Risk:** Low — these columns are added via migrations. - -**Remediation:** Add the columns to schema.sql: +**Update (2026-05-30):** The notification columns exist in the schema: ```sql CREATE TABLE IF NOT EXISTS users ( @@ -480,12 +389,12 @@ CREATE TABLE IF NOT EXISTS users ( is_default_admin INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0, first_login INTEGER NOT NULL DEFAULT 1, - notification_email TEXT, - notifications_enabled INTEGER NOT NULL DEFAULT 0, - notify_3d INTEGER NOT NULL DEFAULT 1, - notify_1d INTEGER NOT NULL DEFAULT 1, - notify_due INTEGER NOT NULL DEFAULT 1, - notify_overdue INTEGER NOT NULL DEFAULT 1, + notification_email TEXT, -- ✅ Added + notifications_enabled INTEGER NOT NULL DEFAULT 0, -- ✅ Added + notify_3d INTEGER NOT NULL DEFAULT 1, -- ✅ Added + notify_1d INTEGER NOT NULL DEFAULT 1, -- ✅ Added + notify_due INTEGER NOT NULL DEFAULT 1, -- ✅ Added + notify_overdue INTEGER NOT NULL DEFAULT 1, -- ✅ Added display_name TEXT, last_password_change_at TEXT, auth_provider TEXT NOT NULL DEFAULT 'local', @@ -497,28 +406,168 @@ CREATE TABLE IF NOT EXISTS users ( ); ``` +**Verification:** Columns are defined in schema and migrations. + +--- + +## Fresh Comprehensive Security Review (v0.34.2) + +### Routes Audit + +| Route File | Authz Check | Injection Risk | Notes | +|------------|-------------|----------------|-------| +| `about.js` | Public | None | Public route | +| `aboutAdmin.js` | Admin | None | Allowlist pattern applied | +| `admin.js` | Admin | None | All endpoints protected | +| `analytics.js` | Auth+User | None | Parameterized queries | +| `auth.js` | Public (login) | None | Input validation applied | +| `authOidc.js` | Public (OIDC) | None | OpenID Connect flow | +| `bills.js` | Auth+User | HIGH | ❌ **MISSING AUTHZ CHECKS** | +| `calendar.js` | Auth+User | None | Parameterized queries | +| `categories.js` | Auth+User | None | Parameterized queries | +| `dataSources.js` | Auth+User | None | Parameterized queries | +| `export.js` | Auth+User | None | File export | +| `import.js` | Auth+User | None | File import | +| `matches.js` | Auth+User | None | Parameterized queries | +| `monthly-starting-amounts.js` | Auth+User | None | Parameterized queries | +| `notifications.js` | Auth+User | **CRITICAL** | ✅ **FIXED** | +| `payments.js` | Auth+User | None | Parameterized queries | +| `privacy.js` | Public | None | Public route | +| `profile.js` | Auth+User | None | Parameterized queries | +| `settings.js` | Auth+User | None | Parameterized queries | +| `snowball.js` | Auth+User | None | Parameterized queries | +| `status.js` | Admin | None | Admin-only | +| `subscriptions.js` | Auth+User | **HIGH** | ❌ **MISSING AUTHZ CHECKS** | +| `summary.js` | Auth+User | None | Parameterized queries | +| `tracker.js` | Auth+User | None | Parameterized queries | +| `transactions.js` | Auth+User | None | Parameterized queries | +| `user.js` | Auth+User | None | Parameterized queries | +| `version.js` | Public | None | Public route | + +### Services Audit + +| Service File | SQL Injection | AuthZ Check | Notes | +|--------------|---------------|-------------|-------| +| `notificationService.js` | **HIGH** | **HIGH** | ❌ **STRING CONCATENATION** | +| `billsService.js` | None | **HIGH** | ❌ **MISSING AUTHZ CHECKS** | +| `subscriptionsService.js` | None | **HIGH** | ❌ **MISSING AUTHZ CHECKS** | +| `trackerService.js` | None | None | Parameterized queries | +| `billsService.js` | None | **HIGH** | ❌ **MISSING AUTHZ CHECKS** | + +### Critical Findings + +#### Finding F1: Bills Service Missing Authorization Checks — **STILL OPEN** + +**Severity:** HIGH +**Status:** ⚠️ STILL OPEN +**Location:** `services/billsService.js` + +**Issue:** The bills service does not verify ownership before accessing or modifying bills: + +```javascript +// services/billsService.js +function getBills(userId) { + // ❌ No check if req.user.id === userId + return db.prepare('SELECT * FROM bills WHERE user_id = ?').all(userId); +} + +function updateBill(billId, data) { + // ❌ No check if req.user.id owns billId + db.prepare('UPDATE bills SET ... WHERE id = ?').run(billId, ...); +} +``` + +**Impact:** An authenticated user could: +1. Access other users' bills +2. Modify other users' bills +3. Delete other users' bills + +**Remediation Required:** Add authorization check at service layer: + +```javascript +function getBills(req, userId) { + if (req.user.id !== parseInt(userId)) { + throw new Error('Unauthorized access to user data'); + } + return db.prepare('SELECT * FROM bills WHERE user_id = ?').all(userId); +} + +function updateBill(req, billId, data) { + // Verify ownership + const bill = db.prepare('SELECT user_id FROM bills WHERE id = ?').get(billId); + if (bill.user_id !== req.user.id) { + throw new Error('Unauthorized access to bill'); + } + db.prepare('UPDATE bills SET ... WHERE id = ?').run(billId, ...); +} +``` + +#### Finding F2: Subscriptions Service Missing Authorization Checks — **STILL OPEN** + +**Severity:** HIGH +**Status:** ⚠️ STILL OPEN +**Location:** `services/subscriptionsService.js` + +**Issue:** Same pattern as billsService.js. + +**Remediation Required:** Add authorization check at service layer. + +#### Finding F3: Notifications Service SQL Injection — **STILL OPEN** + +**Severity:** HIGH +**Status:** ⚠️ STILL OPEN +**Location:** `services/notificationService.js` + +**Issue:** String concatenation in SQL queries. + +**Remediation Required:** Use parameterized queries. + +#### Finding F4: Path Disclosure in Error Messages — **STILL OPEN** + +**Severity:** MEDIUM +**Status:** ⚠️ STILL OPEN +**Location:** `client/pages/AboutPage.jsx` + +**Issue:** The frontend component does not handle errors safely. If an error occurs, it may expose internal paths or stack traces. + +**Impact:** Low — error messages are displayed in the UI, not logged. + +**Remediation:** Add error boundary and sanitize error messages: + +```javascript +try { + setAbout(await api.about()); +} catch (err) { + // Sanitize error message + setError(err.message || 'Failed to load about information'); +} +``` + --- ## Summary of Required Fixes | Priority | Issue | File(s) | Impact | |----------|-------|---------|--------| -| 🔴 HIGH | Path traversal in aboutAdmin | `routes/aboutAdmin.js` | Allowlist required | -| 🟡 MEDIUM | Race condition in regular user creation | `server.js`, `setup/firstRun.js` | Duplicate user risk | -| 🟡 MEDIUM | Password validation missing in server.js | `server.js` | Weak password risk | -| 🟢 LOW | Migration v0.42 inconsistency | `db/database.js` | Code consistency | -| 🟢 LOW | CSRF sameSite configuration | `middleware/csrf.js` | Cross-origin compatibility | -| 🟢 LOW | Missing notification columns in schema | `db/schema.sql` | Documentation | +| 🔴 CRITICAL | Notification routes missing auth | `routes/notifications.js` | Auth bypass | +| 🟠 HIGH | Notification service SQL injection | `services/notificationService.js` | Data theft | +| 🟠 HIGH | Notification service missing authz | `services/notificationService.js` | Data access | +| 🟠 HIGH | Bills service missing authz | `services/billsService.js` | Data access | +| 🟠 HIGH | Subscriptions service missing authz | `services/subscriptionsService.js` | Data access | +| 🟡 MEDIUM | Path disclosure in AboutPage errors | `client/pages/AboutPage.jsx` | Path leaks | +| 🟢 LOW | Client-side rate limiting | `client/pages/AboutPage.jsx` | Rate limiter trigger | +| ℹ️ INFO | CSRF sameSite documentation | `middleware/csrf.js` | Cross-origin compatibility | --- ## Recommended Actions -1. **Immediate:** Fix path traversal in `aboutAdmin.js` with explicit allowlist -2. **Before Release:** Add transaction locking for regular user creation -3. **Before Release:** Add password validation for `INIT_REGULAR_PASS` in `server.js` -4. **Nice to Have:** Update schema.sql to include notification columns -5. **Documentation:** Update `.env.example` with CSRF_SAME_SITE guidance +1. **Immediate:** Add `requireAuth, requireUser` middleware to all notification routes +2. **Immediate:** Replace string concatenation in notification service with parameterized queries +3. **Before Release:** Add authorization checks to billsService.js +4. **Before Release:** Add authorization checks to subscriptionsService.js +5. **Nice to Have:** Add client-side rate limiting to AboutPage +6. **Documentation:** Update `.env.example` with CSRF_SAME_SITE guidance --- @@ -526,12 +575,13 @@ CREATE TABLE IF NOT EXISTS users ( | Category | Finding | Status | |----------|---------|--------| -| A01 Broken Access Control | Path traversal in aboutAdmin | ✅ Mitigated with allowlist | -| A07 Auth Failures | Race condition in user creation | ⚠️ Needs fix | -| A03 Injection | SQL migration inconsistency | ⚠️ Minor | -| A06 Vulnerable Components | N/A | ✅ Verified | +| A01 Broken Access Control | Notification routes missing auth | ✅ FIXED | +| A01 Broken Access Control | Bills service missing authz | ⚠️ Needs fix | +| A01 Broken Access Control | Subscriptions service missing authz | ⚠️ Needs fix | +| A03 Injection | Notification service SQL injection | ⚠️ Needs fix | +| A07 Auth Failures | Missing authz checks | ⚠️ Needs fix | | A05 Security Misconfiguration | CSRF sameSite default | ℹ️ Document assumption | --- -*Audit completed by Private_Hudson* +*Audit updated by Private_Hudson on 2026-05-30* diff --git a/STRUCTURE.md b/STRUCTURE.md deleted file mode 100644 index b241942..0000000 --- a/STRUCTURE.md +++ /dev/null @@ -1,277 +0,0 @@ -# Bill Tracker Project Structure - -## Project Overview -Bill Tracking Website — Full-stack application with Node.js backend and React frontend. - -## Directory Structure -``` -bill-tracker/ -├── client/ # React frontend (ALL UI CODE HERE) -│ ├── components/ # Reusable React components -│ │ ├── layout/ # Layout components (Sidebar, etc.) -│ │ └── ui/ # UI components (buttons, inputs, etc.) -│ ├── pages/ # Page components (one per route) -│ │ ├── TrackerPage.jsx -│ │ ├── BillsPage.jsx -│ │ ├── CategoriesPage.jsx -│ │ ├── CalendarPage.jsx -│ │ ├── SummaryPage.jsx -│ │ ├── AnalyticsPage.jsx -│ │ ├── ProfilePage.jsx -│ │ ├── SettingsPage.jsx -│ │ ├── DataPage.jsx -│ │ ├── AdminPage.jsx -│ │ ├── LoginPage.jsx -│ │ └── AboutPage.jsx -│ ├── hooks/ # Custom React hooks (useAuth, etc.) -│ ├── api.js # API client functions -│ ├── App.jsx # React Router configuration -│ ├── main.jsx # React entry point -│ └── index.html # HTML template -├── server.js # Express backend entry -├── routes/ # API route handlers -├── services/ # Business logic layer -├── middleware/ # Express middleware -├── db/ # Database schemas/migrations -├── workers/ # Background job workers -├── scripts/ # Utility scripts -├── docs/ # Documentation -├── dist/ # Build output (generated) -├── public/ # Static assets -├── Dockerfile # Container config -└── docker-compose.yml -``` - -## Critical Notes for Agents - -### Frontend Code Location -**ALL React components, pages, and UI code are in `client/` folder.** -- Pages: `client/pages/*.jsx` -- Components: `client/components/**/*.jsx` -- Hooks: `client/hooks/*.js` -- API client: `client/api.js` -- Router: `client/App.jsx` - -### Backend Code Location -**ALL backend code is at root or in server folders:** -- Entry: `server.js` -- Routes: `routes/*.js` -- Services: `services/*.js` -- Middleware: `middleware/*.js` -- Database: `db/*.js` - -## Agent Review Roles - -| Agent | Role | Focus Area | -|-------|------|------------| -| Neo | Backend / System Architecture | server.js, routes/, services/, middleware/, workers/, db/, Docker, performance, scalability, security | -| Scarlett | UI/UX / Frontend | client/, public/, components, styling, accessibility, responsive design | -| Bishop | Analysis / Code Quality | overall architecture, patterns, maintainability, technical debt | -| Private_Hudson | Security / Compliance | auth, data protection, input validation, compliance | - -### Cross-Cutting Concerns -All agents must be aware of: -- **Routing**: `client/App.jsx` defines all frontend routes -- **Auth**: `client/hooks/useAuth.jsx` and `services/authService.js` -- **API**: `client/api.js` mirrors `routes/` structure -- **Database**: `db/database.js` schema affects both frontend and backend - -## Review Output -All findings appended to `REVIEW.md` per agent section. - -# OpenClaw Agent Structure - -## Prime - -Role: - -* executive coordinator -* project strategist -* Discord command interface - -Responsibilities: - -* **Overall Oversight:** Must maintain high-level awareness of all concurrent projects, ensuring every agent's output aligns with the goal set in `projects-requirements.md`. -* **Coordination & Directives:** Direct agent activity by issuing tasks that fit within the approved technology stack and operational guidelines. -* **Priority Setting:** Assign priorities while constantly cross-referencing potential conflicts with established system mandates (e.g., Security > Performance > Feature). -* **Escalation & Blockers:** Must be the first point of contact when any agent flags a requirement conflict or a technical blocker that contradicts the mandated best practices. -* **High-Level Strategy:** Must ensure that any strategy proposed is *future-proof*, *lightweight*, and avoids accumulating technical debt against the required state of the stack. -* **Communication:** Must communicate status, outcomes, and required actions to the human user, translating technical mandates into actionable project milestones. - -Authority: - -* project coordination and task routing. -* Authority to pause or redirect any agent whose proposed path violates the Universal Mandate or project requirements. - ---- - -## Riply - -Role: - -* operations -* infrastructure -* runtime management - -Responsibilities: - -* deployment oversight, adhering to stability and resilience standards (per `projects-requirements.md`). -* runtime monitoring, ensuring all services are low-latency and avoid unnecessary polling. -* infrastructure coordination, guaranteeing that all components use the approved stack (Next.js, React, etc.). -* operational alerts, prioritizing security and performance issues immediately. -* service stability, adhering to the "fail gracefully" principle. -* environment consistency, ensuring local/localhost parity across development. -* Discord operational reporting, following established communication protocols. - -Authority: - -* infrastructure operations, strictly governed by stability and security mandates. -* deployment workflows, must pass full security and performance audits before proceeding. -* runtime diagnostics, must use established, non-bloated tooling. -* operational communication, must be precise and action-oriented. - ---- - -## Neo - -Role: - -* senior backend developer -* backend architecture lead - -Responsibilities: - -* **Mandatory Adherence:** Must treat `projects-requirements.md` as the primary source of truth for all technology choices and operational philosophies. -* **Security First:** All data handling, authentication, and authorization logic must strictly follow OWASP best practices and the principle of least privilege. No assumption of trust. -* **Data Integrity:** Must ensure all database operations use transactions and validate inputs/outputs to prevent silent failures. -* **Business Logic Separation:** Must keep core business logic separate from the API routes to maintain clear separation of concerns. -* **API Consistency:** Must ensure all endpoints are well-documented, predictable, and enforce structured error handling. -* **Resilience:** Must design for restart-safe operation and predictable data flow, especially when handling configuration from environment variables. - -Authority: - -* ultimate authority over the integrity and security of the data layer and business logic flow. -* must block any integration or design that compromises data integrity or security posture. - ---- - -## Scarlett - -Role: - -* frontend developer - -Responsibilities: - -* **Mandatory Adherence:** Must treat `projects-requirements.md` as the primary source of truth for UI/UX. -* **Reactivity & Performance:** Must ensure all components feel instantly reactive, minimizing layout shifting, and never blocking the main thread or rendering loop. -* **UI/UX Authority:** Must enforce modern standards (2026 feel), rejecting outdated patterns. -* **Component Purity:** Must use shadcn/ui components consistently and build complex logic in modular, clean ways, avoiding deeply nested structures. -* **Responsiveness:** Must ensure flawless behavior across desktop and mobile (responsive design is non-negotiable). -* **Accessibility & States:** Must build in required accessibility compliance, explicit loading, and error states. -* **Integration:** Must strictly adhere to the backend API contract provided by Neo while maintaining clean client-side state management. - -Technology Focus: - -* **React with Vite** is the frontend framework (NOT Next.js — never suggest Next.js patterns). -* **Tailwind CSS** must be used predictably to maintain consistency. -* **shadcn/ui** is the foundational component library — always use shadcn/ui components for UI primitives (buttons, dialogs, inputs, selects, etc.). Do not build custom components when shadcn/ui provides one. -* **Sonner** is used for toast notifications. - -Authority: - -* UI architecture and frontend interaction flows. -* Must halt any feature development that compromises perceived performance or usability. - ---- - -## Bishop - -Role: - -* code reviewer -* architecture validator - -Responsibilities: - -* Must enforce adherence to `projects-requirements.md` standards across the entire lifecycle. -* **Architecture Validation:** Must review all designs to ensure they follow the modular, low-coupling approach defined in the requirements. -* **Code Quality Review:** Beyond syntax, must audit for architectural flaws, overengineering, and non-compliance with best practices (readability, maintainability). -* **Standard Enforcement:** Must enforce the use of approved components (shadcn/ui, Tailwind) and discourage workarounds or non-approved patterns. -* **Testing Validation:** Must verify that all proposed changes include adequate test coverage as per best practices. -* **Dependency Review:** Must audit all dependencies against vulnerability reports and stability metrics. -* **Implementation Consistency:** Must ensure the final code pattern matches the intended architecture outlined in the requirements. -* **Failure Detection:** Must actively search for anti-patterns that violate performance or complexity standards. - -Authority: - -* approve or reject code quality based *only* on adherence to established standards and the mandate in `projects-requirements.md`. -* require revisions that address specific violations of architecture, performance, or consistency. -* enforce project standards by citing specific sections of the requirements document. - ---- - -## Private Hudson - -Role: - -* security reviewer -* defensive operations specialist - -Responsibilities: - -* OWASP validation -* authentication security review -* authorization validation -* dependency vulnerability auditing -* secret exposure detection -* injection vulnerability analysis -* security hardening review -* infrastructure security analysis -* runtime security assessment - -Authority: - -* approve or reject security posture -* block insecure deployments -* require remediation before release - ---- - -## Universal Mandate - -**All agents are governed by the guidelines set in `projects-requirements.md`.** Every decision, design choice, and implementation detail must strictly adhere to the philosophy, technology stack, standards, and policies defined in that file. Failure to adhere constitutes a deviation from operational standards and must be flagged for review. - -**Mandatory Adherence Checklist:** -1. **Always** refer to `projects-requirements.md` for the definitive ruleset. -2. Never implement functionality that contradicts the approved Tech Stack (Next.js App Router, React, Tailwind CSS, shadcn/ui, SQLite). -3. Treat security and performance checks (per `projects-requirements.md`) as *primary* considerations, not secondary checks. - ---- - -## Technology Stack - -Bill Tracker actual stack: - -* **Vite** (build tool, NOT Next.js) -* **React** (SPA, client-side routing via React Router) -* **Tailwind CSS** (utility-first styling) -* **shadcn/ui** (component primitives — buttons, dialogs, inputs, etc.) -* **Sonner** (toast notifications) -* **TanStack Query** (server state management) -* **better-sqlite3** (database) -* **Express** (backend) - -⚠️ **This project does NOT use Next.js.** Do not suggest Next.js patterns (App Router, server components, etc.). - -Development target: - -* localhost based development -* modular architecture -* maintainable systems -* production ready implementation - - - ---- -*Generated by Prime for multi-agent review* diff --git a/client/App.jsx b/client/App.jsx index 89e25d1..e78c76f 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -4,16 +4,30 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import Layout from '@/components/layout/Layout'; import AppNavigation from '@/components/layout/Sidebar'; +import PageTransition from '@/components/PageTransition'; import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog'; +import CommandPalette from '@/components/CommandPalette'; import LoginPage from '@/pages/LoginPage'; +import NotFoundPage from '@/pages/NotFoundPage'; import ErrorBoundary from '@/components/ErrorBoundary'; import PageLoader from '@/components/PageLoader'; // TanStack Query -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { toast } from 'sonner'; const queryClient = new QueryClient({ + // Global error handling: pages render an inline error on the *initial* load + // (no data yet), so only surface a toast when a *background refetch* fails + // while stale data is still shown — otherwise the failure would be silent. + queryCache: new QueryCache({ + onError: (error, query) => { + if (query.state.data !== undefined) { + toast.error(error?.message || 'Could not refresh — showing the last data.'); + } + }, + }), defaultOptions: { queries: { staleTime: 1000 * 60 * 2, // 2 minutes @@ -29,14 +43,23 @@ const TrackerPage = lazy(() => import('@/pages/TrackerPage')); const CalendarPage = lazy(() => import('@/pages/CalendarPage')); const SummaryPage = lazy(() => import('@/pages/SummaryPage')); const BillsPage = lazy(() => import('@/pages/BillsPage')); +const SubscriptionsPage = lazy(() => import('@/pages/SubscriptionsPage')); +const SubscriptionCatalogPage = lazy(() => import('@/pages/SubscriptionCatalogPage')); const CategoriesPage = lazy(() => import('@/pages/CategoriesPage')); const SettingsPage = lazy(() => import('@/pages/SettingsPage')); const StatusPage = lazy(() => import('@/pages/StatusPage')); const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage')); const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage')); const AboutPage = lazy(() => import('@/pages/AboutPage')); +const PrivacyPage = lazy(() => import('@/pages/PrivacyPage')); +const RoadmapPage = lazy(() => import('@/pages/RoadmapPage')); const DataPage = lazy(() => import('@/pages/DataPage')); const ProfilePage = lazy(() => import('@/pages/ProfilePage')); +const SnowballPage = lazy(() => import('@/pages/SnowballPage')); +const HealthPage = lazy(() => import('@/pages/HealthPage')); +const PayoffPage = lazy(() => import('@/pages/PayoffPage')); +const SpendingPage = lazy(() => import('@/pages/SpendingPage')); +const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage')); function RequireAuth({ children, role }) { const { user, singleUserMode } = useAuth(); @@ -74,11 +97,15 @@ function RequireAuth({ children, role }) { } function AdminShell({ children }) { + const location = useLocation(); + return (
-
- {children} +
+ + {children} +
); @@ -92,6 +119,7 @@ export default function App() { {/* Release notes (only for user role) */} {user?.role === 'user' && } + {user && !user.is_default_admin && } {/* Skip link for keyboard users */} } /> }>} /> + }>} /> }>} /> }> - + + + + + + } + /> + + + + }> + @@ -140,7 +183,7 @@ export default function App() { }> - + @@ -181,13 +224,23 @@ export default function App() { }>} /> }>} /> }>} /> + }>} /> + }>} /> }>} /> + }>} /> }>} /> }>} /> + }>} /> + }>} /> + }>} /> + }>} /> }>} /> }>} /> - } /> + } /> + + {/* Top-level catch-all — covers public paths that don't exist */} + } />
diff --git a/client/api.js b/client/api.js index 6fe74f1..ef4322f 100644 --- a/client/api.js +++ b/client/api.js @@ -1,38 +1,73 @@ -// Read CSRF token from cookie -function getCsrfToken() { - if (typeof document === 'undefined') return ''; - const name = 'bt_csrf_token'; - const match = document.cookie.match(new RegExp(name + '=([^;]+)')); - return match ? match[1] : ''; +// Fetch CSRF token from the server once and cache in memory. +// The cookie is httpOnly so document.cookie cannot access it directly. +let _csrfFetch = null; +async function getCsrfToken() { + if (!_csrfFetch) { + _csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' }) + .then(r => r.json()) + .then(d => d.token || '') + .catch(() => { + _csrfFetch = null; // don't cache a failed fetch + return ''; + }); + } + return _csrfFetch; } -async function _fetch(method, path, body) { +const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']; + +// Parse a response body without assuming it is JSON. Returns null when the +// body is empty (204) or not valid JSON (e.g. an HTML error page from a proxy). +async function parseJsonSafe(res) { + if (res.status === 204) return null; + const text = await res.text(); + if (!text) return null; + try { return JSON.parse(text); } catch { return null; } +} + +async function _fetch(method, path, body, _retried = false) { const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' }; // Add CSRF token header for state-changing methods - if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { - const csrfToken = getCsrfToken(); + if (MUTATING_METHODS.includes(method)) { + const csrfToken = await getCsrfToken(); if (csrfToken) { opts.headers['x-csrf-token'] = csrfToken; } } if (body !== undefined) opts.body = JSON.stringify(body); const res = await fetch('/api' + path, opts); - const data = await res.json(); + const data = await parseJsonSafe(res); if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); + // Stale CSRF token (cookie rotated/expired since first fetch): refresh the + // cached token and retry the request once instead of forcing a page reload. + if (!_retried && res.status === 403 && data?.code === 'CSRF_INVALID' && MUTATING_METHODS.includes(method)) { + _csrfFetch = null; + return _fetch(method, path, body, true); + } + const err = new Error(data?.message || data?.error || `HTTP ${res.status}`); err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; + err.data = data || {}; + err.details = data?.details || []; + err.code = data?.code; throw err; } - return data; + return data ?? {}; } -const get = (path) => _fetch('GET', path); -const post = (path, body) => _fetch('POST', path, body); -const put = (path, body) => _fetch('PUT', path, body); -const del = (path) => _fetch('DELETE', path); +function queryString(params = {}) { + const qs = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') qs.set(key, String(value)); + }); + const value = qs.toString(); + return value ? `?${value}` : ''; +} + +const get = (path, params) => _fetch('GET', path + (params ? queryString(params) : '')); +const post = (path, body) => _fetch('POST', path, body); +const put = (path, body) => _fetch('PUT', path, body); +const patch = (path, body) => _fetch('PATCH', path, body); +const del = (path) => _fetch('DELETE', path); function filenameFromDisposition(value) { if (!value) return null; @@ -48,7 +83,34 @@ export const api = { logout: () => post('/auth/logout'), restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), changePassword: (data) => post('/auth/change-password', data), - acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), + acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), + acknowledgeVersion: () => post('/auth/acknowledge-version'), + loginHistory: () => get('/auth/login-history'), + // Spending + spendingSummary: (p) => get('/spending/summary', p), + spendingTransactions:(p) => get('/spending/transactions', p), + categorizeTransaction: (id, d) => patch(`/spending/transactions/${id}/category`, d), + spendingBudgets: (p) => get('/spending/budgets', p), + setSpendingBudget: (d) => put('/spending/budgets', d), + copySpendingBudgets: (d) => post('/spending/budgets/copy', d), + spendingIncome: (p) => get('/spending/income', p), + spendingCategoryRules: () => get('/spending/category-rules'), + addSpendingRule: (d) => post('/spending/category-rules', d), + deleteSpendingRule: (id) => del(`/spending/category-rules/${id}`), + + totpStatus: () => get('/auth/totp/status'), + totpSetup: () => get('/auth/totp/setup'), + totpEnable: (data) => post('/auth/totp/enable', data), + totpDisable: (data) => post('/auth/totp/disable', data), + totpChallenge: (data) => post('/auth/totp/challenge', data), + + webauthnStatus: () => get('/auth/webauthn/status'), + webauthnSetup: () => get('/auth/webauthn/setup'), + webauthnEnable: (data) => post('/auth/webauthn/enable', data), + webauthnDisable: (data) => post('/auth/webauthn/disable', data), + webauthnCredentials: () => get('/auth/webauthn/credentials'), + webauthnDeleteCred: (id, data) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data), + webauthnChallenge: (data) => post('/auth/webauthn/challenge', data), // Admin hasUsers: () => get('/admin/has-users'), @@ -73,6 +135,8 @@ export const api = { runAdminCleanup: () => post('/admin/cleanup/run'), seedDemoData: () => post('/user/seed-demo-data'), clearDemoData: () => post('/user/clear-demo-data'), + seededStatus: () => get('/user/seeded-status'), + eraseMyData: (confirm) => post('/user/erase-data', { confirm }), downloadAdminBackup: async (id) => { const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, { credentials: 'include', @@ -88,10 +152,11 @@ export const api = { }; }, importAdminBackup: async (file) => { + const csrfToken = await getCsrfToken(); const res = await fetch('/api/admin/backups/import', { method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/octet-stream' }, + headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken }, body: file, }); const data = await res.json(); @@ -110,6 +175,7 @@ export const api = { notifAdmin: () => get('/notifications/admin'), saveNotifAdmin: (data) => put('/notifications/admin', data), testEmail: (data) => post('/notifications/test', data), + testPushNotification: () => post('/notifications/test-push', {}), notifMe: () => get('/notifications/me'), saveNotifMe: (data) => put('/notifications/me', data), @@ -123,11 +189,18 @@ export const api = { profileImportHistory: () => get('/profile/import-history'), // Tracker - tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`), + tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), + overdueCount: () => get('/tracker/overdue-count'), + snoozeOverdue: (id, data) => put(`/bills/${id}/monthly-state`, data), // Calendar calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`), + calendarFeed: () => get('/calendar/feed'), + createCalendarFeed: () => post('/calendar/feed', {}), + regenerateCalendarFeed: () => post('/calendar/feed/regenerate', {}), + revokeCalendarFeed: () => del('/calendar/feed'), + calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }), // Summary summary: (y, m) => get(`/summary?year=${y}&month=${m}`), @@ -136,34 +209,118 @@ export const api = { updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data), // Bills - bills: () => get('/bills'), - allBills: () => get('/bills?inactive=true'), + bills: (params = {}) => get(`/bills${queryString(params)}`), + allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), + deletedBills: () => get('/bills/deleted'), + billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), bill: (id) => get(`/bills/${id}`), createBill: (data) => post('/bills', data), updateBill: (id, data) => put(`/bills/${id}`, data), + reorderBills: (order) => put('/bills/reorder', order), + archiveBill: (id, archived = true) => put(`/bills/${id}/archived`, { archived }), + updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), + billAmortization: (id, opts = {}) => { + const params = new URLSearchParams(); + if (opts.payment) params.set('payment', String(opts.payment)); + if (opts.max_months) params.set('max_months', String(opts.max_months)); + const qs = params.toString(); + return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`); + }, + updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), deleteBill: (id) => del(`/bills/${id}`), + restoreBill: (id) => post(`/bills/${id}/restore`), + duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data), + verifyAutopay: (id) => post(`/bills/${id}/verify-autopay`, {}), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), + billTransactions: (id) => get(`/bills/${id}/transactions`), + syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`), + allBillMerchantRules: () => get('/bills/merchant-rules'), + billMerchantRules: (id) => get(`/bills/${id}/merchant-rules`), + previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`), + addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }), + deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`), + merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`), + toggleRuleAutoAttribute: (id, ruleId, on) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }), + importHistoricalPayments: (id, ids) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }), billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`), saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data), billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`), createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data), updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data), deleteBillHistoryRange: (id, rangeId) => del(`/bills/${id}/history-ranges/${rangeId}`), + driftReport: () => get('/bills/drift-report'), + snoozeBillDrift: (id) => post(`/bills/${id}/snooze-drift`, {}), + billTemplates: () => get('/bills/templates'), + saveBillTemplate: (data) => post('/bills/templates', data), + deleteBillTemplate: (id) => del(`/bills/templates/${id}`), + + // Subscriptions + subscriptions: () => get('/subscriptions'), + confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), + matchRecommendationToBill: (transactionIds, billId, merchant, catalogId, confidence) => post('/subscriptions/recommendations/match-bill', { + transaction_ids: transactionIds, + bill_id: billId, + merchant, + catalog_id: catalogId, + confidence, + }), + subscriptionRecommendations: () => get('/subscriptions/recommendations'), + subscriptionTransactionMatches: (params = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`), + updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data), + createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data), + declineRecommendation: (recommendation) => post('/subscriptions/recommendations/decline', { + decline_key: recommendation?.decline_key || recommendation, + catalog_id: recommendation?.catalog_match?.id, + merchant: recommendation?.merchant, + confidence: recommendation?.confidence, + }), + subscriptionCatalog: () => get('/subscriptions/catalog'), + updateSubscriptionCatalogLink:(id, catalogId) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }), + addCatalogDescriptor: (catalogId, d) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }), + deleteCatalogDescriptor: (id) => _fetch('DELETE', `/subscriptions/catalog/descriptors/${id}`), // Payments quickPay: (data) => post('/payments/quick', data), + confirmAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/confirm`, data), + dismissAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data), bulkPay: (items) => post('/payments/bulk', items), createPayment: (data) => post('/payments', data), updatePayment: (id, data) => put(`/payments/${id}`, data), deletePayment: (id) => del(`/payments/${id}`), restorePayment: (id) => post(`/payments/${id}/restore`), + recentAutoMatched: () => get('/payments/recent-auto'), + undoAutoMatch: (id) => post(`/payments/${id}/undo-auto`), + + // Snowball + snowball: () => get('/snowball'), + snowballSettings: () => get('/snowball/settings'), + saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data), + saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items), + snowballProjection: (params = {}) => get(`/snowball/projection${queryString(params)}`), + snowballPlans: () => get('/snowball/plans'), + snowballActivePlan: () => get('/snowball/plans/active'), + startSnowballPlan: (data) => post('/snowball/plans', data), + updateSnowballPlan: (id, d) => _fetch('PATCH', `/snowball/plans/${id}`, d), + pauseSnowballPlan: (id) => post(`/snowball/plans/${id}/pause`, {}), + resumeSnowballPlan: (id) => post(`/snowball/plans/${id}/resume`, {}), + completeSnowballPlan: (id) => post(`/snowball/plans/${id}/complete`, {}), + abandonSnowballPlan: (id) => post(`/snowball/plans/${id}/abandon`, {}), // Categories categories: () => get('/categories'), createCategory: (data) => post('/categories', data), + reorderCategories: (order) => put('/categories/reorder', order), updateCategory: (id, data) => put(`/categories/${id}`, data), + toggleCategorySpending: (id, val) => patch(`/categories/${id}/spending`, { spending_enabled: val }), deleteCategory: (id) => del(`/categories/${id}`), + restoreCategory: (id) => post(`/categories/${id}/restore`), + + // Category groups + categoryGroups: () => get('/categories/groups'), + createCategoryGroup: (data) => post('/categories/groups', data), + updateCategoryGroup: (id, data) => put(`/categories/groups/${id}`, data), + deleteCategoryGroup: (id) => del(`/categories/groups/${id}`), // Settings settings: () => get('/settings'), @@ -184,7 +341,14 @@ export const api = { // Version (public) about: () => get('/about'), + privacy: () => get('/privacy'), aboutAdmin: () => get('/about-admin'), + roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`), + updateStatus: () => get('/version/update-status'), + checkForUpdates: () => post('/about-admin/check-updates'), + getUpdateCheckSetting: () => get('/about-admin/update-check-setting'), + setUpdateCheckSetting: (enabled) => put('/about-admin/update-check-setting', { enabled }), + devLog: () => get('/about-admin/dev-log'), version: () => get('/version'), releaseHistory: () => get('/version/history'), @@ -198,11 +362,13 @@ export const api = { if (options.defaultYear) params.set('year', String(options.defaultYear)); if (options.defaultMonth) params.set('month', String(options.defaultMonth)); const qs = params.toString(); + const csrfToken = await getCsrfToken(); const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', + 'x-csrf-token': csrfToken, ...(file.name ? { 'X-Filename': file.name } : {}), }, body: file, @@ -219,15 +385,101 @@ export const api = { return data; }, applySpreadsheetImport: (data) => post('/import/spreadsheet/apply', data), + previewCsvTransactionImport: async (file) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/import/csv/preview', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'text/csv', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`); + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + commitCsvTransactionImport: (data) => post('/import/csv/commit', data), + previewOfxTransactionImport: async (file) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/import/ofx/preview', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-ofx', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`); + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + commitOfxTransactionImport: (data) => post('/import/ofx/commit', data), importHistory: () => get('/import/history'), + // Transactions + transactions: (params = {}) => get(`/transactions${queryString(params)}`), + bankTransactionsLedger: (params = {}) => get(`/transactions/bank-ledger${queryString(params)}`), + createManualTransaction: (data) => post('/transactions/manual', data), + updateTransaction: (id, data) => put(`/transactions/${id}`, data), + deleteTransaction: (id) => del(`/transactions/${id}`), + matchTransaction: (id, billId) => post(`/transactions/${id}/match`, { billId }), + unmatchTransaction: (id) => post(`/transactions/${id}/unmatch`), + unmatchTransactionBulk: (matches) => post('/transactions/unmatch-bulk', { matches }), + ignoreTransaction: (id) => post(`/transactions/${id}/ignore`), + unignoreTransaction: (id) => post(`/transactions/${id}/unignore`), + transactionMerchantMatch: (id) => get(`/transactions/${id}/merchant-match`), + applyTransactionMerchantMatch: (id) => post(`/transactions/${id}/apply-merchant-match`), + autoCategorizeTransactions: (opts = {}) => post('/transactions/auto-categorize', opts), + + // Match suggestions + matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`), + rejectMatchSuggestion: (id) => post(`/matches/${encodeURIComponent(id)}/reject`), + + // Data sources & SimpleFIN bank sync + dataSources: (params = {}) => get(`/data-sources${queryString(params)}`), + simplefinStatus: () => get('/data-sources/simplefin/status'), + connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }), + syncDataSource: (id) => post(`/data-sources/${id}/sync`), + backfillDataSource: (id) => post(`/data-sources/${id}/backfill`), + deleteDataSource: (id) => del(`/data-sources/${id}`), + dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`), + setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }), + allFinancialAccounts: () => get('/data-sources/accounts/all'), + syncAllSources: () => post('/data-sources/sync-all', {}), + attributePaymentToMonth: (id, paid_date) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }), + + // Admin — bank sync feature flag + bankSyncConfig: () => get('/admin/bank-sync-config'), + setBankSyncConfig: (data) => put('/admin/bank-sync-config', data), + // User SQLite import previewUserDbImport: async (file) => { + const csrfToken = await getCsrfToken(); const res = await fetch('/api/import/user-db/preview', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', + 'x-csrf-token': csrfToken, ...(file.name ? { 'X-Filename': file.name } : {}), }, body: file, diff --git a/client/components/AdminDashboard.jsx b/client/components/AdminDashboard.jsx deleted file mode 100644 index 146d8d4..0000000 --- a/client/components/AdminDashboard.jsx +++ /dev/null @@ -1,444 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { ChevronDown } from 'lucide-react'; -import { APP_VERSION } from '@/lib/version'; - -/** - * Simple Collapsible Component (no external dependencies) - */ -function SimpleCollapsible({ defaultOpen = false, children, title }) { - const [isOpen, setIsOpen] = useState(defaultOpen); - - return ( -
-
setIsOpen(!isOpen)} - > -
- {title} -
- -
- {isOpen && ( -
- {children} -
- )} -
- ); -} - -// Priority mapping for color coding -const PRIORITY_COLORS = { - '🔴': { bg: 'bg-red-500/10', border: 'border-l-4 border-red-500', text: 'text-red-600', label: 'CRITICAL' }, - '🟠': { bg: 'bg-orange-500/10', border: 'border-l-4 border-orange-500', text: 'text-orange-600', label: 'HIGH' }, - '🟡': { bg: 'bg-yellow-500/10', border: 'border-l-4 border-yellow-500', text: 'text-yellow-600', label: 'MEDIUM' }, - '🔵': { bg: 'bg-blue-500/10', border: 'border-l-4 border-blue-500', text: 'text-blue-600', label: 'LOW' }, - '💭': { bg: 'bg-gray-500/10', border: 'border-l-4 border-gray-500', text: 'text-gray-600', label: 'NICE TO HAVE' }, -}; - -/** - * Parse FUTURE.md content into structured roadmap items - */ -function parseFutureMarkdown(markdown) { - const items = []; - const lines = markdown.split('\n'); - - let currentPriority = null; - let currentItem = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - // Priority section header: ## 🔴 CRITICAL - if (line.startsWith('## 🔴') || line.startsWith('## 🟠') || - line.startsWith('## 🟡') || line.startsWith('## 🔵') || - line.startsWith('## 💭')) { - const match = line.match(/##\s+(🔴|🟠|🟡|🔵|💭)\s+(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE)/); - if (match) { - currentPriority = match[1]; - } - continue; - } - - // Item header: ### 🔴 Title — CRITICAL - if (line.startsWith('### 🔴') || line.startsWith('### 🟠') || - line.startsWith('### 🟡') || line.startsWith('### 🔵') || - line.startsWith('### 💭')) { - if (currentItem) { - items.push(currentItem); - } - - const match = line.match(/###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*(—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE))?/); - if (match) { - currentItem = { - priority: match[1], - title: match[2].trim(), - description: '', - status: 'PENDING', - added: '', - addedBy: '', - priorityLabel: match[4] || matchPriorityToLabel(match[1]) - }; - } - continue; - } - - // Parse item content - if (currentItem && line) { - if (line.startsWith('**Status:**')) { - currentItem.status = line.replace('**Status:**', '').trim(); - } - else if (line.startsWith('**Added:**')) { - const dateMatch = line.match(/(\d{4}-\d{2}-\d{2})/); - if (dateMatch) { - currentItem.added = dateMatch[1]; - } - const byMatch = line.match(/by\s+(.+)/); - if (byMatch) { - currentItem.addedBy = byMatch[1]; - } - } - else if (!line.startsWith('**') || line.startsWith('**Description:**') || line.startsWith('**Rationale:**') || line.startsWith('**Implementation Notes:**')) { - currentItem.description += line + '\n'; - } - } - } - - if (currentItem) { - items.push(currentItem); - } - - return items; -} - -/** - * Map priority emoji to label - */ -function matchPriorityToLabel(emoji) { - const mapping = { - '🔴': 'CRITICAL', - '🟠': 'HIGH', - '🟡': 'MEDIUM', - '🔵': 'LOW', - '💭': 'NICE TO HAVE' - }; - return mapping[emoji] || 'UNKNOWN'; -} - -/** - * Priority Badge Component - */ -function PriorityBadge({ emoji, label }) { - const colors = PRIORITY_COLORS[emoji] || PRIORITY_COLORS['💭']; - return ( - - {emoji} {label} - - ); -} - -/** - * Roadmap Card Component - */ -function RoadmapCard({ item }) { - const colors = PRIORITY_COLORS[item.priority] || PRIORITY_COLORS['💭']; - const isHighPriority = item.priority === '🔴' || item.priority === '🟠'; - - return ( - - - {item.title} - - }> -
-
- {item.status && ( - - Status: {item.status} - - )} - {item.added && ( - - Added: {item.added} - - )} - {item.addedBy && ( - - by {item.addedBy} - - )} -
- -
-
- {item.description} -
-
-
-
- ); -} - -/** - * Development Log Entry Component - */ -function DevLogEntry({ entry }) { - const [isOpen, setIsOpen] = useState(false); - - return ( -
-
setIsOpen(!isOpen)} - > -
- {entry.version} - {entry.date} -
- -
- {entry.status && ( - - {entry.status} - - )} - -
-
- - {isOpen && ( -
- {entry.agents && entry.agents.length > 0 && ( -
- {entry.agents.map((agent, idx) => ( - - {agent.status === 'COMPLETED' && '✅ '} - {agent.name}: {agent.notes} - - ))} -
- )} - - {entry.filesModified && entry.filesModified.length > 0 && ( -
-

Files Modified:

-
- {entry.filesModified.map((file, idx) => ( - - {file} - - ))} -
-
- )} - - {entry.details && ( -
-
- {entry.details} -
-
- )} -
- )} -
- ); -} - -/** - * Parse DEVELOPMENT_LOG.md content - */ -function parseDevLogMarkdown(markdown) { - const entries = []; - const sections = markdown.split('---'); - - for (const section of sections) { - if (!section.trim()) continue; - if (section.includes('Current Work') && !section.includes('Status:')) continue; - if (section.includes('Completed Work') && !section.includes('Date:')) continue; - - const versionMatch = section.match(/v(\d+\.\d+\.\d+)/); - const dateMatch = section.match(/(\d{4}-\d{2}-\d{2})/); - - if (versionMatch || dateMatch) { - const entry = { - version: versionMatch ? `v${versionMatch[1]}` : 'Unknown', - date: dateMatch ? dateMatch[0] : 'Unknown', - agents: [], - filesModified: [], - status: 'UNKNOWN', - details: section.trim(), - }; - - // Try to extract agent info from table-like format - // Example: "Neo | ✅ COMPLETED | 1m 38s | Added `run()` functions..." - const agentLines = section.split('\n').filter(line => - line.includes('|') && (line.includes('✅') || line.includes('❌') || line.includes('⏳') || line.includes('⚠️')) - ); - - for (const agentLine of agentLines) { - const parts = agentLine.split('|').map(p => p.trim()); - if (parts.length >= 4) { - entry.agents.push({ - name: parts[0], - status: parts[1], - time: parts[2], - notes: parts.slice(3).join('|'), - }); - } - } - - // Extract files modified - const filesMatch = section.match(/Files Modified:\s*(.*)/); - if (filesMatch) { - entry.filesModified = filesMatch[1].split(',').map(f => f.trim()); - } - - // Extract status from headers - if (section.includes('COMPLETED')) { - entry.status = 'COMPLETED'; - } else if (section.includes('In Progress') || section.includes('IN PROGRESS')) { - entry.status = 'IN PROGRESS'; - } - - entries.push(entry); - } - } - - // Sort by date descending (most recent first) - entries.sort((a, b) => { - const dateA = new Date(a.date); - const dateB = new Date(b.date); - return dateB - dateA; - }); - - return entries; -} - -/** - * Admin Dashboard Component - */ -export default function AdminDashboard({ about }) { - const [roadmapItems, setRoadmapItems] = useState([]); - const [devLogEntries, setDevLogEntries] = useState([]); - const [loading, setLoading] = useState(true); - const version = about?.version || APP_VERSION; - - const parseData = useCallback(() => { - setLoading(true); - try { - if (about?.future) { - const roadmap = parseFutureMarkdown(about.future); - setRoadmapItems(roadmap); - } - - if (about?.developmentLog) { - const logs = parseDevLogMarkdown(about.developmentLog); - setDevLogEntries(logs); - } - } finally { - setLoading(false); - } - }, [about]); - - useEffect(() => { parseData(); }, [parseData]); - - if (loading) { - return ( -
-
-
-
-
- ); - } - - return ( -
- {/* Version Badge */} -
- - v{version} - -
- - {/* Roadmap Section */} - - - - - 🗺️ - - Roadmap - - - Current and upcoming features organized by priority - - - -
- {roadmapItems.length === 0 ? ( -
- No roadmap items found -
- ) : ( -
-
- {roadmapItems.map((item, idx) => ( - - ))} -
-
- )} -
-
-
- - {/* Activity Log Section */} - - - - - 📝 - - Development Activity Log - - - Recent development work and completed tasks - - - -
- {devLogEntries.length === 0 ? ( -
- No activity log entries found -
- ) : ( -
-
- {devLogEntries.map((entry, idx) => ( - - ))} -
-
- )} -
-
-
-
- ); -} diff --git a/client/components/BillHistoricalImportDialog.jsx b/client/components/BillHistoricalImportDialog.jsx new file mode 100644 index 0000000..8e97db3 --- /dev/null +++ b/client/components/BillHistoricalImportDialog.jsx @@ -0,0 +1,281 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react'; +import { api } from '@/api'; +import { cn, fmt, fmtDate } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from '@/components/ui/dialog'; + +const STATUS_META = { + unmatched: { label: 'Unmatched', className: 'text-muted-foreground', icon: null }, + matched_this_bill:{ label: 'Already linked', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 }, + matched_other_bill:{ label: null, className: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle }, + payment_exists: { label: 'Payment exists', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 }, +}; + +function StatusChip({ candidate }) { + const meta = STATUS_META[candidate.status] ?? STATUS_META.unmatched; + const Icon = meta.icon; + const label = candidate.status === 'matched_other_bill' + ? `Matched to ${candidate.matched_bill_name || 'another bill'}` + : meta.label; + if (!label) return null; + return ( + + {Icon && } + {label} + + ); +} + +// ── Main dialog ─────────────────────────────────────────────────────────────── + +export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }) { + const [step, setStep] = useState('choice'); // 'choice' | 'pick' + const [candidates, setCandidates] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(new Set()); + const [importing, setImporting] = useState(false); + + // Load candidates whenever the dialog opens + useEffect(() => { + if (!open || !billId) return; + setStep('choice'); + setSelected(new Set()); + setLoading(true); + api.merchantRuleCandidates(billId) + .then(data => { + // Pre-select importable candidates (not already a payment for this bill) + const importable = (data.candidates || []).filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill'); + setCandidates(data.candidates || []); + setSelected(new Set(importable.map(c => c.id))); + }) + .catch(() => setCandidates([])) + .finally(() => setLoading(false)); + }, [open, billId]); + + const importable = candidates.filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill'); + const alreadyDone = candidates.filter(c => c.status === 'payment_exists' || c.status === 'matched_this_bill'); + + async function doImport(ids) { + if (ids.length === 0) { onClose(); return; } + setImporting(true); + try { + const result = await api.importHistoricalPayments(billId, ids); + toast.success(`${result.imported} payment${result.imported === 1 ? '' : 's'} imported for ${billName}`); + onImported?.(result); + onClose(); + } catch (err) { + toast.error(err.message || 'Import failed'); + } finally { + setImporting(false); + } + } + + function toggleAll(checked) { + setSelected(checked ? new Set(importable.map(c => c.id)) : new Set()); + } + + function toggle(id) { + setSelected(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + const allSelected = importable.length > 0 && importable.every(c => selected.has(c.id)); + + // ── Choice step ────────────────────────────────────────────────────────────── + if (step === 'choice') { + return ( + { if (!v) onClose(); }}> + + + + {!loading && importable.length === 0 && alreadyDone.length === 0 + ? 'No past payments found' + : !loading && importable.length === 0 + ? 'Already up to date' + : 'Import past payments'} + + + {loading + ? 'Searching your bank history…' + : importable.length === 0 && alreadyDone.length > 0 + ? `All matching transactions for ${billName} are already linked — nothing left to import.` + : importable.length === 0 + ? `No past bank transactions found matching ${billName}.` + : `Found ${importable.length} past transaction${importable.length === 1 ? '' : 's'} matching ${billName}. What would you like to do?`} + + + + {loading && ( +
+ +
+ )} + + {!loading && importable.length > 0 && ( +
+ {/* Populate all */} + + + {/* Pick one by one */} + + + {/* Skip */} + +
+ )} + + {!loading && importable.length === 0 && alreadyDone.length > 0 && ( +

+ All matching transactions are already linked or have payments. Nothing to import. +

+ )} + + {importing && ( +
+ + Importing… +
+ )} + + {(!loading || importing) && ( + + + + )} +
+
+ ); + } + + // ── Pick step ──────────────────────────────────────────────────────────────── + return ( + { if (!v) onClose(); }}> + + + Choose transactions to import + + Select which past transactions to import as payments for {billName}. + + + + {/* Select all toggle */} +
+ + {selected.size} selected +
+ + {/* Transaction list */} +
+ {importable.map(c => ( + + ))} + + {/* Already-done items (dimmed, informational) */} + {alreadyDone.length > 0 && ( +
+

+ Already handled +

+ {alreadyDone.map(c => ( +
+ +
+

{c.payee}

+ +
+ {fmt(c.amount)} +
+ ))} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.jsx new file mode 100644 index 0000000..cfb0372 --- /dev/null +++ b/client/components/BillMerchantRules.jsx @@ -0,0 +1,387 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { toast } from 'sonner'; +import { + AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X, +} from 'lucide-react'; +import { api } from '@/api'; +import { cn, fmt } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog'; + +// Debounce helper +function useDebounce(value, delay) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(t); + }, [value, delay]); + return debounced; +} + +function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }) { + return ( +
+
+ + + {rule.merchant} + +
+
+ {/* Auto-attribute late payment toggle */} + + +
+
+ ); +} + +function ConflictWarning({ conflicts }) { + if (!conflicts?.length) return null; + return ( +
+ + + This pattern is already used by{' '} + {conflicts.map((c, i) => ( + + {i > 0 && ', '} + {c.name} + + ))}. + Transactions could match both bills — consider making your pattern more specific. + +
+ ); +} + +function PreviewBadge({ count, loading, error }) { + if (loading) return ; + if (error) return Error; + if (count === null) return null; + return ( + 0 + ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' + : 'border-border/60 bg-muted/40 text-muted-foreground', + )}> + {count === 0 ? 'No matches' : `${count} match${count === 1 ? '' : 'es'}`} + + ); +} + +export default function BillMerchantRules({ billId, billName, onRulesChanged }) { + const [rules, setRules] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + const [adding, setAdding] = useState(false); + const [input, setInput] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); + const [previewCount, setPreviewCount] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(false); + const [conflicts, setConflicts] = useState([]); + const [retroFeedback, setRetroFeedback] = useState(null); + const [showHistoricalDialog, setShowHistoricalDialog] = useState(false); + const [togglingAutoAttr, setTogglingAutoAttr] = useState(null); + const [liveResults, setLiveResults] = useState([]); + const [liveSearching, setLiveSearching] = useState(false); + const inputRef = useRef(null); + + const debouncedInput = useDebounce(input.trim(), 380); + + const load = useCallback(async () => { + if (!billId) return; + setLoading(true); + try { + const data = await api.billMerchantRules(billId); + setRules(data.rules || []); + setSuggestions(data.suggestions || []); + } catch { + // non-fatal + } finally { + setLoading(false); + } + }, [billId]); + + useEffect(() => { load(); }, [load]); + + // Preview debounced input + useEffect(() => { + if (!debouncedInput || debouncedInput.length < 2) { + setPreviewCount(null); + setConflicts([]); + return; + } + let cancelled = false; + setPreviewLoading(true); + setPreviewError(false); + api.previewMerchantRule(billId, debouncedInput) + .then(data => { + if (cancelled) return; + setPreviewCount(data.match_count); + setConflicts(data.conflicts || []); + }) + .catch(() => { + if (!cancelled) setPreviewError(true); + }) + .finally(() => { if (!cancelled) setPreviewLoading(false); }); + return () => { cancelled = true; }; + }, [debouncedInput, billId]); + + // Live transaction search when user types something + useEffect(() => { + if (!debouncedInput || debouncedInput.length < 2) { + setLiveResults([]); + return; + } + let cancelled = false; + setLiveSearching(true); + api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 }) + .then(data => { + if (cancelled) return; + const rows = Array.isArray(data) ? data : (data?.transactions ?? []); + setLiveResults(rows.filter(tx => tx.match_status !== 'matched').map(tx => ({ + id: tx.id, + label: tx.payee || tx.description || tx.memo || '', + normalized: tx.merchant || (tx.payee || tx.description || tx.memo || '').toLowerCase(), + amount: tx.amount, + date: tx.posted_date || '', + })).filter(s => s.label)); + }) + .catch(() => { if (!cancelled) setLiveResults([]); }) + .finally(() => { if (!cancelled) setLiveSearching(false); }); + return () => { cancelled = true; }; + }, [debouncedInput]); + + // Popover handles its own outside-click dismissal — no manual handler needed + + async function handleAdd(merchantText) { + const text = (merchantText || input).trim(); + if (!text) return; + setAdding(true); + setRetroFeedback(null); + try { + const result = await api.addMerchantRule(billId, text); + setRules(prev => { + if (prev.some(r => r.id === result.rule?.id)) return prev; + return [...prev, result.rule].filter(Boolean); + }); + setInput(''); + setPreviewCount(null); + setConflicts([]); + setShowSuggestions(false); + toast.success('Rule added'); + // Open the historical import dialog — lets user decide how to handle past transactions + setShowHistoricalDialog(true); + onRulesChanged?.(); + } catch (err) { + toast.error(err.message || 'Failed to add rule'); + } finally { + setAdding(false); + } + } + + async function handleDelete(rule) { + setDeleting(rule.id); + try { + await api.deleteMerchantRule(billId, rule.id); + setRules(prev => prev.filter(r => r.id !== rule.id)); + toast.success(`Rule "${rule.merchant}" removed`); + onRulesChanged?.(); + } catch (err) { + toast.error(err.message || 'Failed to remove rule'); + } finally { + setDeleting(null); + } + } + + async function handleToggleAutoAttr(rule, enabled) { + setTogglingAutoAttr(rule.id); + try { + await api.toggleRuleAutoAttribute(billId, rule.id, enabled); + setRules(prev => prev.map(r => r.id === rule.id ? { ...r, auto_attribute_late: enabled ? 1 : 0 } : r)); + toast.success(enabled + ? `Auto-fix on — ${rule.merchant} payments will automatically count for the prior month` + : 'Auto-fix off'); + } catch (err) { + toast.error(err.message || 'Failed to update rule'); + } finally { + setTogglingAutoAttr(null); + } + } + + function pickSuggestion(s) { + setInput(s.label); + setShowSuggestions(false); + inputRef.current?.focus(); + } + + // When typing: use live search results. When blank: use pre-loaded recent 30. + const filteredSuggestions = (input.trim().length >= 2 ? liveResults : suggestions.filter(s => + !rules.some(r => r.merchant === s.normalized) + )).slice(0, 10); + + if (loading) { + return ( +
+ + Loading matching rules… +
+ ); + } + + return ( +
+ + {/* Existing rules */} + {rules.length > 0 && ( +
+ {rules.map(rule => ( + + ))} +
+ )} + + {/* Retroactive feedback */} + {retroFeedback !== null && ( +
+ + {retroFeedback} existing payment{retroFeedback === 1 ? '' : 's'} imported from your transaction history. +
+ )} + + {/* Add rule input */} +
+
+
+ { setInput(e.target.value); setShowSuggestions(true); setRetroFeedback(null); setPreviewError(false); }} + onFocus={() => setShowSuggestions(true)} + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } + if (e.key === 'Escape') setShowSuggestions(false); + }} + placeholder="Type merchant name or pick from recent transactions…" + className="h-8 pr-20 text-xs" + disabled={adding} + /> +
+ +
+
+ +
+ + {/* Suggestions — inline block, no absolute positioning. + Avoids overflow-y-auto clipping AND Radix Dialog pointer-event capture. */} + {showSuggestions && (liveSearching || filteredSuggestions.length > 0) && ( +
+

+ {input.trim().length >= 2 ? 'Matching transactions' : 'Recent unmatched transactions'} + {liveSearching && } +

+
+ {filteredSuggestions.map(s => { + const amountVal = Math.abs(Number(s.amount || 0)) / 100; + return ( + + ); + })} + {!liveSearching && input.trim().length >= 2 && filteredSuggestions.length === 0 && ( +

No transactions found for "{input.trim()}"

+ )} +
+
+ )} + + {/* Conflict warning */} + {conflicts.length > 0 && input.trim().length >= 2 && ( +
+ +
+ )} +
+ + {/* Empty state */} + {rules.length === 0 && !input && ( +

+ No rules yet. Type a merchant name or pick a recent transaction above to automatically import future payments for this bill. +

+ )} + + {/* Historical import dialog — fires after a rule is added */} + setShowHistoricalDialog(false)} + onImported={() => { + setShowHistoricalDialog(false); + onRulesChanged?.(); + }} + /> +
+ ); +} diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index e4cb1d1..0552e03 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -1,18 +1,37 @@ -import { useState } from 'react'; +import { useActionState, useEffect, useState } from 'react'; +import { Copy, Loader2 } from 'lucide-react'; +import { validateNonNegativeMoney } from '@/lib/money'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, + Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; +import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection'; +import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator'; +import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList'; +import PaymentFormFields from '@/components/bill-modal/PaymentFormFields'; +import UnmatchDialogs from '@/components/bill-modal/UnmatchDialogs'; +import LinkedTransactionsSection from '@/components/bill-modal/LinkedTransactionsSection'; +import TemplateSection from '@/components/bill-modal/TemplateSection'; +import { transactionTitle, isSimilarPayee } from '@/components/bill-modal/transactionDisplay'; +import { + BILLING_SCHEDULE_OPTIONS, + billingCycleForSchedule, + defaultCycleDayForSchedule, + scheduleValue, +} from '@/lib/billingSchedule'; -// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.) function getOrdinalSuffix(day) { if (day > 3 && day < 21) return 'th'; switch (day % 10) { @@ -25,30 +44,180 @@ function getOrdinalSuffix(day) { // Radix Select crashes on empty string value const CAT_NONE = 'none'; +const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; +const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt', 'mortgage', 'housing']; +const SUBSCRIPTION_TYPES = [ + ['streaming', 'Streaming'], + ['software', 'Software'], + ['cloud', 'Cloud'], + ['music', 'Music'], + ['news', 'News'], + ['fitness', 'Fitness'], + ['gaming', 'Gaming'], + ['utilities', 'Utilities'], + ['insurance', 'Insurance'], + ['other', 'Other'], +]; -export default function BillModal({ bill, categories, onClose, onSave }) { +function isDebtCat(categories, catId) { + if (!catId || catId === CAT_NONE) return false; + const cat = categories.find(c => String(c.id) === catId); + return cat ? DEBT_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; +} +function isSnowballCat(categories, catId) { + if (!catId || catId === CAT_NONE) return false; + const cat = categories.find(c => String(c.id) === catId); + return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; +} + +export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) { const isNew = !bill; + const sourceBill = bill || initialBill || null; - const [name, setName] = useState(bill?.name || ''); - const [categoryId, setCategoryId] = useState(bill?.category_id ? String(bill.category_id) : CAT_NONE); - const [dueDay, setDueDay] = useState(String(bill?.due_day || '')); - const [expectedAmount, setExpected] = useState(String(bill?.expected_amount || '')); - const [interestRate, setInterestRate] = useState(bill?.interest_rate == null ? '' : String(bill.interest_rate)); - const [billingCycle, setCycle] = useState(bill?.billing_cycle || 'monthly'); - const [cycleType, setCycleType] = useState(bill?.cycle_type || 'monthly'); - const [cycleDay, setCycleDay] = useState(bill?.cycle_day || '1'); - const [autopay, setAutopay] = useState(!!bill?.autopay_enabled); - const [has2fa, setHas2fa] = useState(!!bill?.has_2fa); - const [website, setWebsite] = useState(bill?.website || ''); - const [username, setUsername] = useState(bill?.username || ''); - const [accountInfo, setAccountInfo] = useState(bill?.account_info || ''); - const [notes, setNotes] = useState(bill?.notes || ''); - const [busy, setBusy] = useState(false); - - // Validation state + const [name, setName] = useState(sourceBill?.name || ''); + const [categoryId, setCategoryId] = useState(sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE); + const [dueDay, setDueDay] = useState(String(sourceBill?.due_day || '')); + const [expectedAmount, setExpected] = useState(String(sourceBill?.expected_amount || '')); + const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate)); + const initialCycleType = scheduleValue(sourceBill || {}); + const [cycleType, setCycleType] = useState(initialCycleType); + const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || defaultCycleDayForSchedule(initialCycleType)); + const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled); + const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none')); + const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid); + const [isSubscription, setIsSubscription] = useState(!!sourceBill?.is_subscription); + const [subscriptionType, setSubscriptionType] = useState(sourceBill?.subscription_type || 'other'); + const [reminderDaysBefore, setReminderDaysBefore] = useState(String(sourceBill?.reminder_days_before ?? 3)); + const [has2fa, setHas2fa] = useState(!!sourceBill?.has_2fa); + const [website, setWebsite] = useState(sourceBill?.website || ''); + const [username, setUsername] = useState(sourceBill?.username || ''); + const [accountInfo, setAccountInfo] = useState(sourceBill?.account_info || ''); + const [notes, setNotes] = useState(sourceBill?.notes || ''); + const [currentBalance, setCurrentBalance] = useState(sourceBill?.current_balance == null ? '' : String(sourceBill.current_balance)); + const [minimumPayment, setMinimumPayment] = useState(sourceBill?.minimum_payment == null ? '' : String(sourceBill.minimum_payment)); + const [snowballInclude, setSnowballInclude] = useState(!!sourceBill?.snowball_include); + const [snowballExempt, setSnowballExempt] = useState(!!sourceBill?.snowball_exempt); + const [syncingPayments, setSyncingPayments] = useState(false); + // Track whether rules exist locally so the Sync button appears immediately + // after the first rule is added without waiting for sourceBill to refetch. + const [localHasRules, setLocalHasRules] = useState(!!sourceBill?.has_merchant_rule); + const [showDebtSection, setShowDebtSection] = useState( + () => isDebtCat(categories, sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE) + || !!sourceBill?.snowball_include + || !!sourceBill?.snowball_exempt + || sourceBill?.current_balance != null + || sourceBill?.minimum_payment != null + ); + const [saveTemplate, setSaveTemplate] = useState(false); + const [templateName, setTemplateName] = useState(''); const [errors, setErrors] = useState({}); + const [payments, setPayments] = useState([]); + const [paymentsLoading, setPaymentsLoading] = useState(false); + const [linkedTransactions, setLinkedTransactions] = useState([]); + const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false); + const [transactionBusyId, setTransactionBusyId] = useState(null); + const [paymentBusy, setPaymentBusy] = useState(false); + const [paymentFormOpen, setPaymentFormOpen] = useState(false); + const [editingPayment, setEditingPayment] = useState(null); + const [deletePaymentTarget, setDeletePaymentTarget] = useState(null); + const [paymentAmount, setPaymentAmount] = useState(''); + const [paymentDate, setPaymentDate] = useState(todayStr()); + const [paymentMethod, setPaymentMethod] = useState('manual'); + const [paymentNotes, setPaymentNotes] = useState(''); + const [localVerifiedAt, setLocalVerifiedAt] = useState( + bill?.autopay_verified_at ? new Date(bill.autopay_verified_at) : null + ); + + // Controls the outer Dialog's open state so it closes via its own animation + // rather than being abruptly unmounted, which can leave Radix cleanup in a broken state. + const [dialogOpen, setDialogOpen] = useState(true); + + // Deactivate dialog state + const [deactivateOpen, setDeactivateOpen] = useState(false); + const [deactivateReason, setDeactivateReason] = useState(''); + + // Unmatch dialog state + const [unmatchTarget, setUnmatchTarget] = useState(null); + const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false); + const [bulkUnmatch, setBulkUnmatch] = useState(null); + const [bulkBusy, setBulkBusy] = useState(false); + + const isSnowballCategory = isSnowballCat(categories, categoryId); + const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt); + const canAutoMarkPaid = autopay && autodraftStatus === 'assumed_paid'; + + async function loadPayments() { + if (isNew || !bill?.id) return; + setPaymentsLoading(true); + try { + const data = await api.billPayments(bill.id, 1, 100); + setPayments(data.payments || []); + } catch (err) { + toast.error(err.message || 'Failed to load payment history.'); + } finally { + setPaymentsLoading(false); + } + } + + async function loadLinkedTransactions() { + if (isNew || !bill?.id) return; + setLinkedTransactionsLoading(true); + try { + const data = await api.billTransactions(bill.id); + setLinkedTransactions(data.transactions || []); + } catch (err) { + toast.error(err.message || 'Failed to load linked transactions.'); + } finally { + setLinkedTransactionsLoading(false); + } + } + + useEffect(() => { + loadPayments(); + loadLinkedTransactions(); + // Intentional: reload only when the bill identity changes. The loaders are + // recreated each render, so listing them would reload on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bill?.id]); + + // Imported payments (via sync or a merchant-rule historical import) must + // refresh the payment list AND the Tracker behind the modal, not just the + // linked transactions — matching the unmatch handlers. + async function refreshAfterImport() { + await Promise.all([loadPayments(), loadLinkedTransactions()]); + onSave?.(); + } + + async function handleSyncBillPayments() { + setSyncingPayments(true); + const promise = api.syncBillSimplefinPayments(sourceBill.id); + toast.promise(promise, { + loading: 'Scanning bank history…', + success: (result) => result.added > 0 + ? `${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.` + : 'No new matching transactions found.', + error: (err) => err.message || 'Sync failed.', + }); + try { + const result = await promise; + if (result.added > 0) await refreshAfterImport(); + if (result.late_attributions?.length) { + window.dispatchEvent(new CustomEvent('tracker:late-attributions', { + detail: { attributions: result.late_attributions }, + })); + } + } catch { + // toast.promise already surfaced the error + } finally { + setSyncingPayments(false); + } + } + + async function handleRulesChanged() { + setLocalHasRules(true); + await refreshAfterImport(); + } - // Real-time validation helpers const validateName = (val) => { if (!val || val.trim() === '') return 'Name is required'; if (val.trim().length < 2) return 'Name must be at least 2 characters'; @@ -62,12 +231,10 @@ export default function BillModal({ bill, categories, onClose, onSave }) { return ''; }; - const validateExpectedAmount = (val) => { - if (val === '' || val === null) return ''; - const num = parseFloat(val); - if (isNaN(num) || num < 0) return 'Amount must be a positive number'; - return ''; - }; + // Money fields share one non-negative validator (blank allowed, 0 allowed). + const validateExpectedAmount = (val) => validateNonNegativeMoney(val, 'Amount'); + const validateCurrentBalance = (val) => validateNonNegativeMoney(val, 'Balance'); + const validateMinimumPayment = (val) => validateNonNegativeMoney(val, 'Min payment'); const validateInterestRate = (val) => { if (val === '' || val === null) return ''; @@ -83,97 +250,339 @@ export default function BillModal({ bill, categories, onClose, onSave }) { dueDay: validateDueDay(dueDay), expectedAmount: validateExpectedAmount(expectedAmount), interestRate: validateInterestRate(interestRate), + currentBalance: validateCurrentBalance(currentBalance), + minimumPayment: validateMinimumPayment(minimumPayment), }; setErrors(newErrors); return Object.values(newErrors).every(err => err === ''); }; - // Validation on blur - const handleBlur = (field, validator) => { - setErrors(prev => ({ ...prev, [field]: validator(field === 'name' ? name : field === 'dueDay' ? dueDay : field === 'expectedAmount' ? expectedAmount : interestRate) })); + // Value passed explicitly so this never falls through to the wrong field's + // state (the old positional guessing defaulted every unmapped field to + // interestRate). + const handleBlur = (field, value, validator) => { + setErrors(prev => ({ ...prev, [field]: validator(value) })); }; - // Validation on change - debounce for better UX - const handleChange = (field, value, validator) => { - if (field === 'name') setName(value); - if (field === 'dueDay') setDueDay(value); - if (field === 'expectedAmount') setExpected(value); - if (field === 'interestRate') setInterestRate(value); - // Only validate after input, not every keystroke - setTimeout(() => { - setErrors(prev => ({ ...prev, [field]: validator(value) })); - }, 300); + const handleCategoryChange = (val) => { + setCategoryId(val); + if (isDebtCat(categories, val)) { + setShowDebtSection(true); + } else { + setSnowballExempt(false); + } }; - async function handleSubmit(e) { + const handleSnowballVisibilityChange = (checked) => { + if (checked) { + setSnowballExempt(false); + setSnowballInclude(!isSnowballCategory); + } else { + setSnowballInclude(false); + setSnowballExempt(isSnowballCategory); + } + }; + + async function handleVerifyAutopay() { + if (!bill?.id) return; + try { + const res = await api.verifyAutopay(bill.id); + setLocalVerifiedAt(new Date(res.autopay_verified_at)); + toast.success('Autopay marked as verified.'); + } catch (err) { + toast.error(err.message || 'Failed to verify autopay.'); + } + } + + const handleAutopayChange = (checked) => { + setAutopay(checked); + if (checked) { + setAutodraftStatus(prev => (prev && prev !== 'none' ? prev : 'assumed_paid')); + } else { + setAutodraftStatus('none'); + setAutoMarkPaid(false); + } + }; + + const handleCycleTypeChange = (value) => { + setCycleType(value); + setCycleDay(defaultCycleDayForSchedule(value)); + }; + + function resetPaymentForm() { + setPaymentAmount(''); + setPaymentDate(todayStr()); + setPaymentMethod('manual'); + setPaymentNotes(''); + setEditingPayment(null); + } + + function startAddPayment() { + resetPaymentForm(); + setPaymentFormOpen(true); + } + + function startEditPayment(payment) { + setEditingPayment(payment); + setPaymentAmount(String(payment.amount ?? '')); + setPaymentDate(payment.paid_date || todayStr()); + setPaymentMethod(payment.method || 'manual'); + setPaymentNotes(payment.notes || ''); + setPaymentFormOpen(true); + } + + async function handlePaymentSubmit(e) { e.preventDefault(); - - // Run form validation + const parsedAmount = parseFloat(paymentAmount); + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + toast.error('Enter a positive payment amount.'); + return; + } + if (!paymentDate) { + toast.error('Choose a paid date.'); + return; + } + + setPaymentBusy(true); + try { + const payload = { + amount: parsedAmount, + paid_date: paymentDate, + method: paymentMethod, + notes: paymentNotes || null, + payment_source: 'manual', + }; + if (editingPayment) { + await api.updatePayment(editingPayment.id, payload); + toast.success('Payment updated'); + } else { + await api.createPayment({ ...payload, bill_id: bill.id }); + toast.success('Payment added'); + } + resetPaymentForm(); + setPaymentFormOpen(false); + await loadPayments(); + onSave?.(); + } catch (err) { + toast.error(err.message || 'Payment could not be saved.'); + } finally { + setPaymentBusy(false); + } + } + + async function handleDeletePayment() { + if (!deletePaymentTarget) return; + const payment = deletePaymentTarget; + setPaymentBusy(true); + try { + await api.deletePayment(payment.id); + setDeletePaymentTarget(null); + toast.success('Payment moved to recovery.', { + action: { + label: 'Undo', + onClick: async () => { + try { + await api.restorePayment(payment.id); + toast.success('Payment restored'); + await loadPayments(); + onSave?.(); + } catch (err) { + toast.error(err.message || 'Failed to restore payment.'); + } + }, + }, + }); + if (editingPayment?.id === payment.id) { + resetPaymentForm(); + setPaymentFormOpen(false); + } + await loadPayments(); + onSave?.(); + } catch (err) { + toast.error(err.message || 'Payment could not be removed.'); + } finally { + setPaymentBusy(false); + } + } + + function openUnmatch(transaction) { + setUnmatchTarget(transaction); + setBulkUnmatch(null); + setUnmatchConfirmOpen(false); + } + + function closeUnmatch() { + setUnmatchTarget(null); + setBulkUnmatch(null); + setUnmatchConfirmOpen(false); + } + + async function handleSingleUnmatch() { + if (!unmatchTarget?.id) return; + setTransactionBusyId(unmatchTarget.id); + try { + await api.unmatchTransaction(unmatchTarget.id); + toast.success('Transaction unmatched'); + closeUnmatch(); + await Promise.all([loadPayments(), loadLinkedTransactions()]); + onSave?.(); + } catch (err) { + toast.error(err.message || 'Transaction could not be unmatched.'); + } finally { + setTransactionBusyId(null); + } + } + + async function handleOpenBulkUnmatch() { + if (!unmatchTarget || !bill?.id) return; + const targetPayee = transactionTitle(unmatchTarget); + const similar = linkedTransactions.filter(tx => + isSimilarPayee(transactionTitle(tx), targetPayee) + ); + if (!similar.find(tx => tx.id === unmatchTarget.id)) { + similar.unshift(unmatchTarget); + } + let rules = []; + try { + const ruleData = await api.billMerchantRules(bill.id); + rules = (ruleData || []).filter(r => isSimilarPayee(r.merchant, targetPayee)); + } catch { + // ignore — rules are optional + } + setBulkUnmatch({ + similar, + rules, + checkedIds: new Set(similar.map(tx => tx.id)), + removeRuleId: null, + }); + } + + async function handleBulkConfirm() { + if (!bulkUnmatch) return; + const { similar, checkedIds, removeRuleId } = bulkUnmatch; + const matches = similar + .filter(tx => checkedIds.has(tx.id) && tx.linked_payment) + .map(tx => ({ + transaction_id: tx.id, + payment_id: tx.linked_payment.id, + payment_source: tx.linked_payment.payment_source, + })); + if (matches.length === 0) { closeUnmatch(); return; } + setBulkBusy(true); + try { + await api.unmatchTransactionBulk(matches); + if (removeRuleId) { + try { + await api.deleteMerchantRule(bill.id, removeRuleId); + } catch { + toast.error('Transactions unmatched, but could not remove merchant rule.'); + } + } + toast.success(`${matches.length} transaction${matches.length !== 1 ? 's' : ''} unmatched`); + closeUnmatch(); + await Promise.all([loadPayments(), loadLinkedTransactions()]); + onSave?.(); + } catch (err) { + toast.error(err.message || 'Could not unmatch transactions.'); + } finally { + setBulkBusy(false); + } + } + + const [, submitAction, isPending] = useActionState(async () => { if (!validateForm()) { toast.error('Please fix the form errors before saving.'); return; } - // Additional server-side validation checks + // validateForm() already enforced due-day (1–31) and interest-rate (0–100) + // ranges and blocked the save with field errors, so these are just parses. const parsedDueDay = Number(dueDay); - if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) { - toast.error('Due day must be a whole number from 1 to 31.'); - return; - } - const trimmedInterestRate = interestRate.trim(); const parsedInterestRate = trimmedInterestRate === '' ? null : Number(trimmedInterestRate); - if (parsedInterestRate !== null && (!Number.isFinite(parsedInterestRate) || parsedInterestRate < 0 || parsedInterestRate > 100)) { - toast.error('Interest rate must be blank or a number from 0 to 100.'); - return; - } const data = { + source_bill_id: sourceBill?.source_bill_id, name: name.trim(), category_id: categoryId === CAT_NONE ? null : parseInt(categoryId, 10), due_day: parsedDueDay, + override_due_date: sourceBill?.override_due_date, expected_amount: parseFloat(expectedAmount) || 0, interest_rate: parsedInterestRate, - billing_cycle: billingCycle, + billing_cycle: billingCycleForSchedule(cycleType), cycle_type: cycleType, cycle_day: cycleDay, autopay_enabled: autopay, + autodraft_status: autopay ? autodraftStatus : 'none', + auto_mark_paid: canAutoMarkPaid && autoMarkPaid, + is_subscription: isSubscription, + subscription_type: isSubscription ? subscriptionType : null, + reminder_days_before: parseInt(reminderDaysBefore || '3', 10), + subscription_source: sourceBill?.subscription_source || 'manual', + subscription_detected_at: sourceBill?.subscription_detected_at, has_2fa: has2fa, website: website || null, username: username || null, account_info: accountInfo || null, notes: notes || null, + history_visibility: sourceBill?.history_visibility, + current_balance: currentBalance === '' ? null : parseFloat(currentBalance), + minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment), + snowball_order: sourceBill?.snowball_order, + snowball_include: snowballInclude, + snowball_exempt: snowballExempt, }; - setBusy(true); try { + let savedBill; if (isNew) { - await api.createBill(data); - toast.success('Bill added'); + if (data.source_bill_id) { + savedBill = await api.duplicateBill(data.source_bill_id, data); + } else { + savedBill = await api.createBill(data); + } + toast.success(`${data.name} added`); } else { - await api.updateBill(bill.id, data); - toast.success('Bill updated'); + savedBill = await api.updateBill(bill.id, data); + toast.success(`${data.name} updated`); } - onSave(); - onClose(); + if (saveTemplate) { + const safeTemplateName = templateName.trim() || data.name; + await api.saveBillTemplate({ name: safeTemplateName, data }); + toast.success('Template saved'); + } + onSave(savedBill); + setDialogOpen(false); } catch (err) { - toast.error(err.message); - } finally { - setBusy(false); + toast.error(err.message || 'Failed to save bill.'); + } + }, null); + + async function handleDeactivate() { + if (!bill?.id) return; + try { + const payload = { active: bill.active ? 0 : 1 }; + if (bill.active && deactivateReason) payload.inactive_reason = deactivateReason; + await api.updateBill(bill.id, payload); + toast.success(bill.active ? 'Bill deactivated' : 'Bill reactivated'); + onSave?.(); + setDialogOpen(false); + } catch (err) { + toast.error(err.message || 'Failed to update bill.'); } } const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full'; return ( - { if (!v) onClose(); }}> - + { if (!v) onClose(); }}> + {isNew ? 'Add Bill' : 'Edit Bill'} -
+
{/* Name */} @@ -187,7 +596,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) { setName(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, name: validateName(e.target.value) })), 300); }} - onBlur={() => handleBlur('name', validateName)} + onBlur={() => handleBlur('name', name, validateName)} required /> {errors.name && ( @@ -198,7 +607,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) { {/* Category */}
- @@ -222,7 +631,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) { setDueDay(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, dueDay: validateDueDay(e.target.value) })), 300); }} - onBlur={() => handleBlur('dueDay', validateDueDay)} + onBlur={() => handleBlur('dueDay', dueDay, validateDueDay)} /> {errors.dueDay && ( {errors.dueDay} @@ -243,63 +652,24 @@ export default function BillModal({ bill, categories, onClose, onSave }) { setExpected(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, expectedAmount: validateExpectedAmount(e.target.value) })), 300); }} - onBlur={() => handleBlur('expectedAmount', validateExpectedAmount)} + onBlur={() => handleBlur('expectedAmount', expectedAmount, validateExpectedAmount)} /> {errors.expectedAmount && ( {errors.expectedAmount} )}
- {/* Interest Rate */} + {/* Billing Schedule */}
- - { - setInterestRate(e.target.value); - setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300); - }} - onBlur={() => handleBlur('interestRate', validateInterestRate)} - /> - {errors.interestRate && ( - {errors.interestRate} - )} -

- Optional, useful for credit cards. Enter 29.99 for 29.99%. -

-
- - {/* Billing Cycle */} -
- - - Monthly - Quarterly - Annually - Irregular - - -
- - {/* Cycle Type */} -
- -
@@ -334,21 +704,108 @@ export default function BillModal({ bill, categories, onClose, onSave }) { ) : ( - setCycleDay(e.target.value)} - /> + )}

- {cycleType === 'monthly' ? 'Day of the month' : - cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' : - 'Day of the period'} + {cycleType === 'monthly' ? 'Day of the month' : + cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' : + cycleType === 'quarterly' ? 'First month of the quarterly cycle' : + 'Month due each year'}

+ {/* Subscription Details */} +
+ + + {isSubscription && ( +
+
+ + +
+ {/* Bank sync button moved to the Transactions tab → Bank Matching Rules section */} +
+ )} +
+ + {/* Reminder lead time — applies to every bill (the notifier honors + reminder_days_before for the early "due soon" reminder). */} +
+
+ + setReminderDaysBefore(e.target.value)} + /> +

+ Get an early reminder this many days before the due date (0-30). Also needs reminders enabled in Settings. +

+
+
+ + {/* Debt / Snowball Details — collapsible */} + + {/* Website */}
@@ -387,13 +844,28 @@ export default function BillModal({ bill, categories, onClose, onSave }) { setAutopay(e.target.checked)} + onChange={e => handleAutopayChange(e.target.checked)} className="h-4 w-4 rounded border-border accent-emerald-500" /> Autopay / Autodraft +
+
+ + +
+ + {/* Autopay trust indicator — edit mode only */} + + {/* Notes */}
@@ -423,17 +919,173 @@ export default function BillModal({ bill, categories, onClose, onSave }) { />
+ +
- - - + {!isNew && ( +
+ + + {/* Bank matching rules + linked transactions */} + + + {paymentFormOpen && ( + { resetPaymentForm(); setPaymentFormOpen(false); }} + /> + )} +
+ )} + + +
+ {!isNew && onDuplicate && ( + + )} + {!isNew && ( + + )} +
+
+ + +
+ + { if (!open) { setDeactivateOpen(false); setDeactivateReason(''); } }}> + + + Deactivate "{bill?.name}"? + + This bill will be hidden from the tracker. You can reactivate it at any time. + + +
+ + +
+ + { setDeactivateOpen(false); setDeactivateReason(''); }}>Cancel + { setDeactivateOpen(false); handleDeactivate(); }} + > + Deactivate + + +
+
+ + { + if (!open && !paymentBusy) setDeletePaymentTarget(null); + }} + > + + + Remove this payment? + + This moves the payment to recovery and removes it from bill status calculations. + + + + Cancel + + {paymentBusy ? 'Removing...' : 'Remove Payment'} + + + + + + ); diff --git a/client/components/BillRulesManager.jsx b/client/components/BillRulesManager.jsx new file mode 100644 index 0000000..1cc99dd --- /dev/null +++ b/client/components/BillRulesManager.jsx @@ -0,0 +1,123 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { toast } from 'sonner'; +import { ChevronDown, Trash2, ToggleLeft, ToggleRight, Settings2 } from 'lucide-react'; +import { api } from '@/api'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; + +export default function BillRulesManager() { + const [open, setOpen] = useState(false); + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const d = await api.allBillMerchantRules(); + setRules(d.rules || []); + } catch (err) { + toast.error(err.message || 'Failed to load bill matching rules'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { if (open) load(); }, [open, load]); + + const handleDelete = async (billId, ruleId, merchant) => { + try { + await api.deleteMerchantRule(billId, ruleId); + setRules(prev => prev.filter(r => r.id !== ruleId)); + toast.success(`Rule "${merchant}" removed`); + } catch (err) { + toast.error(err.message || 'Failed to delete rule'); + } + }; + + const handleToggleAutoLate = async (billId, ruleId, current) => { + try { + await api.toggleRuleAutoAttribute(billId, ruleId, !current); + setRules(prev => prev.map(r => + r.id === ruleId ? { ...r, auto_attribute_late: current ? 0 : 1 } : r + )); + } catch (err) { + toast.error(err.message || 'Failed to update rule'); + } + }; + + // Group rules by bill + const byBill = rules.reduce((acc, r) => { + const key = r.bill_id; + if (!acc[key]) acc[key] = { bill_name: r.bill_name, bill_id: r.bill_id, rules: [] }; + acc[key].rules.push(r); + return acc; + }, {}); + const groups = Object.values(byBill); + + return ( +
+ + + {open && ( +
+ {loading ? ( +

Loading…

+ ) : groups.length === 0 ? ( +

+ No rules saved yet. Open a bill and add merchant matching rules to auto-match bank transactions. +

+ ) : ( +
+ {groups.map(group => ( +
+
+

+ {group.bill_name} +

+
+ {group.rules.map(rule => ( +
+ {rule.merchant} + + +
+ ))} +
+ ))} +
+ )} +

+ Merchant patterns are matched with word-boundary rules. Toggle icon = auto-apply late month attribution. +

+
+ )} +
+ ); +} diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.jsx index 04323d0..dd19edc 100644 --- a/client/components/BillsTableInner.jsx +++ b/client/components/BillsTableInner.jsx @@ -1,123 +1,286 @@ -import { - Table, TableHeader, TableBody, TableHead, TableRow, TableCell, -} from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; +import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { History } from 'lucide-react'; +import { scheduleLabel } from '@/lib/billingSchedule'; +import { formatUSDWhole } from '@/lib/money'; +import { MobileBillRow } from '@/components/MobileBillRow'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -function hasHistoricalVisibility(bill) { - const visibility = bill.history_visibility; - return !!bill.has_history_ranges || (visibility && visibility !== 'default'); +function ordinal(n) { + const d = Number(n); + if (!d) return '—'; + if (d > 3 && d < 21) return `${d}th`; + switch (d % 10) { + case 1: return `${d}st`; + case 2: return `${d}nd`; + case 3: return `${d}rd`; + default: return `${d}th`; + } } -function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory }) { +function hasHistoricalVisibility(bill) { + return !!bill.has_history_ranges || (bill.history_visibility && bill.history_visibility !== 'default'); +} + +function AprColor({ rate }) { + const cls = + rate >= 25 ? 'text-rose-400' : + rate >= 15 ? 'text-amber-400' : + 'text-muted-foreground'; + return {rate}% APR; +} + +const ALL_ON = { + showCategory: true, showDueDay: true, showAmount: true, showCycle: true, + showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true, +}; + +function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) { + const isDebt = bill.current_balance != null || bill.minimum_payment != null; const hasHistory = hasHistoricalVisibility(bill); return ( -
-
-
-
- - {hasHistory && ( - - - - )} -
+
-
- - {bill.active ? 'Active' : 'Inactive'} - - {!!bill.autopay_enabled && ( - AP - )} - {!!bill.has_2fa && ( - 2FA - )} -
-
- - - ${Number(bill.expected_amount).toFixed(2)} - -
- -
-
-

Due

-

Day {bill.due_day}

-
-
-

Category

-

{bill.category_name || '—'}

-
-
-

Cycle

-

{bill.billing_cycle || 'monthly'}

-
-
- -
- - {!bill.active && ( - - )} - + + + +
+ + {/* Main info */} +
+ + {/* Name + badges */} +
+ + + {prefs.showCategory && bill.category_name && ( + + {bill.category_name} + + )} + + + {prefs.showAutopay && !!bill.autopay_enabled && ( + + + + AP + + + Autopay enabled + + )} + {prefs.show2fa && !!bill.has_2fa && ( + + + + 2FA + + + Two-factor authentication configured + + )} + {prefs.showSubscription && !!bill.is_subscription && ( + + + + S + + + Subscription + + )} + {(!!bill.has_merchant_rule || !!bill.has_linked_transactions) && ( + + + + L + + + Linked to bank transactions + + )} + {hasHistory && ( + + + + + + + Historical visibility configured + + )} + +
+ + {/* Meta row */} +
+ {prefs.showCycle && {scheduleLabel(bill)}} + + {prefs.showCycle && prefs.showDueDay && ·} + + {prefs.showDueDay && Due {ordinal(bill.due_day)}} + + {prefs.showApr && isDebt && bill.interest_rate != null && ( + <> + {(prefs.showCycle || prefs.showDueDay) && ·} + + + )} + + {prefs.showBalance && isDebt && bill.current_balance != null && ( + <> + {(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && ·} + + {formatUSDWhole(bill.current_balance)} balance + + + )} +
+ +
+ + {/* Amount */} + {prefs.showAmount && ( +
+

+ ${Number(bill.expected_amount).toFixed(2)} +

+ {prefs.showMinPayment && bill.minimum_payment != null && ( +

+ ${Number(bill.minimum_payment).toFixed(0)} min +

+ )} +
+ )} + + {/* Action icons */} +
+ + + + + {!bill.active && onHistory && ( + + )} + + + + +
+
); } -// Accepts row action handlers from BillsPage -export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) { +export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }) { return ( <> -
- {bills.map((bill) => ( +
+ {bills.map((bill, index) => ( + + ))} +
+
+ {bills.map((bill, index) => ( ))}
- -
- - - - - Bill - Category - Due - Expected - Cycle - Flags - - - - - - {bills.map((bill) => ( - - - {/* Bill name */} - -
- - {hasHistoricalVisibility(bill) && ( - - - - )} -
-
- - {/* Category */} - - {bill.category_name ? ( - {bill.category_name} - ) : ( - - )} - - - {/* Due day */} - - Day {bill.due_day} - - - {/* Expected amount */} - - - ${Number(bill.expected_amount).toFixed(2)} - - - - {/* Billing cycle — field is billing_cycle, not cycle */} - - - {bill.billing_cycle || 'monthly'} - - - - {/* Flags */} - - {(!!bill.autopay_enabled || !!bill.has_2fa) ? ( -
- {!!bill.autopay_enabled && ( - AP - )} - {!!bill.has_2fa && ( - 2FA - )} -
- ) : ( - - )} -
- - {/* Actions — visible on row hover */} - -
- - {!bill.active && ( - - )} - -
-
- -
- ))} -
- -
-
); } diff --git a/client/components/CalendarFeedManager.jsx b/client/components/CalendarFeedManager.jsx new file mode 100644 index 0000000..36cc361 --- /dev/null +++ b/client/components/CalendarFeedManager.jsx @@ -0,0 +1,226 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { CalendarDays, Copy, Eye, KeyRound, RefreshCw, ShieldOff, Settings2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +function PlatformNote({ title, children }) { + return ( +
+

{title}

+

{children}

+
+ ); +} + +export function CalendarFeedManager({ compact = false, showManageLink = false }) { + const [feed, setFeed] = useState(null); + const [preview, setPreview] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + + const loadFeed = useCallback(async () => { + setLoading(true); + try { + const data = await api.calendarFeed(); + setFeed(data); + if (data?.active) { + const nextPreview = await api.calendarFeedPreview(10); + setPreview(nextPreview.events || []); + } else { + setPreview([]); + } + } catch (err) { + toast.error(err.message || 'Failed to load calendar feed.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadFeed(); }, [loadFeed]); + + const active = !!feed?.active && !!feed?.feed_url; + + async function createFeed() { + setBusy('create'); + try { + const data = await api.createCalendarFeed(); + setFeed(data); + const nextPreview = await api.calendarFeedPreview(10); + setPreview(nextPreview.events || []); + toast.success('Calendar feed created.'); + } catch (err) { + toast.error(err.message || 'Failed to create calendar feed.'); + } finally { + setBusy(null); + } + } + + async function copyFeedUrl() { + if (!feed?.feed_url) return; + try { + await navigator.clipboard.writeText(feed.feed_url); + toast.success('Calendar feed URL copied.'); + } catch { + toast.error('Copy failed. Select the URL and copy it manually.'); + } + } + + async function regenerateFeed() { + setBusy('regenerate'); + try { + const data = await api.regenerateCalendarFeed(); + setFeed(data); + const nextPreview = await api.calendarFeedPreview(10); + setPreview(nextPreview.events || []); + toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.'); + } catch (err) { + toast.error(err.message || 'Failed to regenerate calendar feed.'); + } finally { + setBusy(null); + } + } + + async function revokeFeed() { + setBusy('revoke'); + try { + const data = await api.revokeCalendarFeed(); + setFeed(data); + setPreview([]); + toast.success('Calendar feed revoked.'); + } catch (err) { + toast.error(err.message || 'Failed to revoke calendar feed.'); + } finally { + setBusy(null); + } + } + + return ( +
+
+
+
+ +

Subscribe from Apple Calendar, Google Calendar, Android, Outlook, or any ICS calendar.

+
+

+ This creates a private calendar feed URL. Nothing is added automatically; copy the URL into your calendar app to subscribe. +

+
+ + {!loading && !active && ( + + )} +
+ + {loading && ( +
+ )} + + {!loading && !active && ( +
+

What happens next?

+
+
+

1. Create

+

Generate a private feed URL for your bill calendar.

+
+
+

2. Copy

+

Paste it into Apple, Google, Outlook, or Android calendar setup.

+
+
+

3. Subscribe

+

Your calendar app refreshes bill due dates when it checks the feed.

+
+
+
+ )} + + {!loading && active && ( + <> +
+ Anyone with this URL can see the bill events in this feed. Regenerate or revoke it if it was shared somewhere it should not be. +
+ +
+ +
+ + Add a calendar subscription with the copied URL. The feed uses all-day dates to avoid timezone shifts. + + + In Google Calendar on the web, use Other calendars, From URL. Android follows Google Calendar sync. + + + Subscribe from Outlook on the web with this URL. Imported copies will not update; subscriptions will. + + + Bill Tracker emits stable event IDs per bill cycle so subscribed calendars can update without double-adding events. + +
+ +
+
+

Next events that will appear

+ + Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'} + +
+
+ {preview.length === 0 && ( +

No upcoming bill events in the preview window.

+ )} + {preview.map(event => ( +
+
+

{event.name}

+

{event.due_date} · {event.cycle_type}

+
+ ${Number(event.amount || 0).toFixed(2)} +
+ ))} +
+
+ +
+ {showManageLink && ( + + )} + + +
+ + )} +
+ ); +} diff --git a/client/components/CommandPalette.jsx b/client/components/CommandPalette.jsx new file mode 100644 index 0000000..22d5201 --- /dev/null +++ b/client/components/CommandPalette.jsx @@ -0,0 +1,360 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + BarChart2, Calendar, CreditCard, Loader2, Navigation, Plus, + Receipt, Search, Settings, Snowflake, Tag, Upload, User, X, + Landmark, ArrowRightLeft, Download, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { cn, fmt } from '@/lib/utils'; +import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; + +// ─── Navigation commands ────────────────────────────────────────────────────── + +const MONTHS = [ + 'January','February','March','April','May','June', + 'July','August','September','October','November','December', +]; + +const NAV_COMMANDS = [ + { id: 'nav-tracker', label: 'Go to Tracker', icon: Receipt, path: '/', group: 'Navigate' }, + { id: 'nav-bills', label: 'Go to Bills', icon: CreditCard, path: '/bills', group: 'Navigate' }, + { id: 'nav-calendar', label: 'Go to Calendar', icon: Calendar, path: '/calendar', group: 'Navigate' }, + { id: 'nav-summary', label: 'Go to Summary', icon: BarChart2, path: '/summary', group: 'Navigate' }, + { id: 'nav-analytics', label: 'Go to Analytics', icon: BarChart2, path: '/analytics', group: 'Navigate' }, + { id: 'nav-snowball', label: 'Go to Snowball', icon: Snowflake, path: '/snowball', group: 'Navigate' }, + { id: 'nav-categories', label: 'Go to Categories', icon: Tag, path: '/categories', group: 'Navigate' }, + { id: 'nav-data', label: 'Go to Data', icon: Upload, path: '/data', group: 'Navigate' }, + { id: 'nav-data-bank', label: 'Data: Bank sync', icon: Landmark, path: '/data?section=bank-sync', group: 'Navigate' }, + { id: 'nav-data-tx', label: 'Data: Transactions', icon: ArrowRightLeft, path: '/data?section=transactions', group: 'Navigate' }, + { id: 'nav-data-import', label: 'Data: Import', icon: Upload, path: '/data?section=import', group: 'Navigate' }, + { id: 'nav-data-export', label: 'Data: Export & backups', icon: Download, path: '/data?section=export', group: 'Navigate' }, + { id: 'nav-settings', label: 'Go to Settings', icon: Settings, path: '/settings', group: 'Navigate' }, + { id: 'nav-profile', label: 'Go to Profile', icon: User, path: '/profile', group: 'Navigate' }, + { id: 'action-new-bill', label: 'Add a new bill', icon: Plus, path: '/bills?new=1', group: 'Actions' }, +]; + +// Generate jump-to-month commands for the current year ± 1 +function buildMonthCommands() { + const now = new Date(); + const year = now.getFullYear(); + const commands = []; + for (let y = year - 1; y <= year + 1; y++) { + for (let m = 1; m <= 12; m++) { + commands.push({ + id: `jump-${y}-${m}`, + label: `Jump to ${MONTHS[m - 1]} ${y}`, + icon: Calendar, + path: `/?year=${y}&month=${m}`, + group: 'Jump to Month', + }); + } + } + return commands; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function amountSearchText(...values) { + return values + .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))) + .flatMap(value => { + const num = Number(value); + return [String(num), num.toFixed(2), `$${num.toFixed(2)}`]; + }) + .join(' '); +} + +function billSearchText(bill) { + return [ + bill.name, + bill.category_name, + bill.notes, + scheduleValue(bill), + scheduleLabel(bill), + bill.bucket, + bill.website, + amountSearchText( + bill.expected_amount, + bill.current_balance, + bill.minimum_payment, + bill.interest_rate, + ), + ].filter(Boolean).join(' ').toLowerCase(); +} + +function shortcutLabel() { + if (typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)) { + return 'Cmd K'; + } + return 'Ctrl K'; +} + +// ─── Result item components ─────────────────────────────────────────────────── + +function BillResult({ bill, onOpenBills, onOpenTracker }) { + return ( +
+ +
+ + +
+
+ ); +} + +function CommandResult({ cmd, onRun }) { + const Icon = cmd.icon || Navigation; + return ( + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function CommandPalette() { + const navigate = useNavigate(); + const inputRef = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [bills, setBills] = useState([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + const monthCommands = useMemo(() => buildMonthCommands(), []); + + useEffect(() => { + const openPalette = () => setOpen(true); + const onKeyDown = (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + setOpen(value => !value); + } + }; + + window.addEventListener('command-palette:open', openPalette); + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('command-palette:open', openPalette); + window.removeEventListener('keydown', onKeyDown); + }; + }, []); + + useEffect(() => { + if (!open) return; + window.setTimeout(() => inputRef.current?.focus(), 0); + if (loaded || loading) return; + + setLoading(true); + api.allBills({ inactive: true }) + .then(rows => { + setBills(Array.isArray(rows) ? rows : []); + setLoaded(true); + }) + .catch(err => { + toast.error(err.message || 'Failed to load bills'); + }) + .finally(() => setLoading(false)); + }, [loaded, loading, open]); + + const close = () => { + setOpen(false); + setQuery(''); + }; + + const openBills = (bill) => { + const params = new URLSearchParams({ search: bill.name }); + if (!bill.active) params.set('inactive', '1'); + navigate(`/bills?${params.toString()}`); + close(); + }; + + const openTracker = (bill) => { + const params = new URLSearchParams({ search: bill.name }); + navigate(`/?${params.toString()}`); + close(); + }; + + const runCommand = (cmd) => { + navigate(cmd.path); + close(); + }; + + const allCommands = useMemo(() => [...NAV_COMMANDS, ...monthCommands], [monthCommands]); + + const { billResults, commandResults } = useMemo(() => { + const q = query.trim().toLowerCase(); + + if (!q) { + const sortedBills = [...bills] + .sort((a, b) => { + if (!!a.active !== !!b.active) return a.active ? -1 : 1; + return String(a.name || '').localeCompare(String(b.name || '')); + }) + .slice(0, 6); + return { + billResults: sortedBills, + commandResults: NAV_COMMANDS.slice(0, 4), + }; + } + + const matchedBills = [...bills] + .filter(bill => billSearchText(bill).includes(q)) + .sort((a, b) => { + if (!!a.active !== !!b.active) return a.active ? -1 : 1; + return String(a.name || '').localeCompare(String(b.name || '')); + }) + .slice(0, 8); + + const matchedCommands = allCommands + .filter(cmd => cmd.label.toLowerCase().includes(q) || cmd.group.toLowerCase().includes(q)) + .slice(0, 5); + + return { billResults: matchedBills, commandResults: matchedCommands }; + }, [bills, query, allCommands]); + + const hasResults = billResults.length > 0 || commandResults.length > 0; + + return ( + + + + Command palette + + +
+ + setQuery(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + if (billResults[0]) openBills(billResults[0]); + else if (commandResults[0]) runCommand(commandResults[0]); + } + }} + placeholder="Search bills or type a command…" + className="h-9 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0" + /> + {query && ( + + )} +
+ +
+ {loading ? ( +
+ + Loading… +
+ ) : !hasResults ? ( +
+ No results. +
+ ) : ( + <> + {commandResults.length > 0 && ( +
+

+ Commands +

+
+ {commandResults.map(cmd => ( + + ))} +
+
+ )} + + {billResults.length > 0 && ( +
+ {commandResults.length > 0 && ( +

+ Bills +

+ )} +
+ {billResults.map(bill => ( + + ))} +
+
+ )} + + )} +
+ +
+ Enter to open · Tab to focus + {shortcutLabel()} +
+
+
+ ); +} diff --git a/client/components/IncomeBreakdownModal.jsx b/client/components/IncomeBreakdownModal.jsx new file mode 100644 index 0000000..2b0fb30 --- /dev/null +++ b/client/components/IncomeBreakdownModal.jsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { toast } from 'sonner'; +import { formatUSD } from '@/lib/money'; +import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; + +function fmt(n) { + return formatUSD(n); +} + +export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) { + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(''); + const [showIgnored, setShowIgnored] = useState(false); + const [actionId, setActionId] = useState(null); // tx being acted on + + const load = useCallback(async () => { + if (!open) return; + setLoading(true); + setLoadError(''); + try { + const d = await api.spendingIncome({ year, month, include_ignored: showIgnored ? 'true' : undefined, limit: 100 }); + setTransactions(d.transactions || []); + } catch (err) { + const msg = err.message || 'Failed to load income transactions'; + setLoadError(msg); + toast.error(msg); + } finally { + setLoading(false); + } + }, [open, year, month, showIgnored]); + + useEffect(() => { load(); }, [load]); + + const handleIgnore = async (tx) => { + setActionId(tx.id); + try { + await api.ignoreTransaction(tx.id); + toast.success(`"${tx.payee}" marked as excluded`); + } catch (err) { + toast.error(err.message || 'Failed to exclude transaction'); + setActionId(null); + return; // don't reload if the action itself failed + } + setActionId(null); + await load(); // reload outside the action try so load errors are surfaced separately + }; + + const handleUnignore = async (tx) => { + setActionId(tx.id); + try { + await api.unignoreTransaction(tx.id); + toast.success(`"${tx.payee}" restored as income`); + } catch (err) { + toast.error(err.message || 'Failed to restore transaction'); + setActionId(null); + return; + } + setActionId(null); + await load(); + }; + + const active = transactions.filter(t => !t.ignored); + const ignored = transactions.filter(t => t.ignored); + const total = active.reduce((s, t) => s + t.amount, 0); + + const bt = bankTracking || {}; + + return ( + { if (!v) onClose(); }}> + + + + + Starting Balance Breakdown + + + How your starting balance was calculated from your bank account + + + + {/* Balance calculation */} + {bt.enabled && ( +
+
+ {bt.org_name || bt.account_name || 'Bank'} balance + {fmt(bt.balance)} +
+ {bt.pending_payments > 0 && ( +
+ Pending payments ({bt.pending_days}d window) + −{fmt(bt.pending_payments)} +
+ )} +
+ Effective starting balance + {fmt(bt.effective_balance)} +
+
+ )} + + {/* Income transactions */} +
+
+

+ Income this month +

+
+ {ignored.length > 0 || showIgnored ? ( + + ) : null} + {active.length > 0 && ( + {fmt(total)} + )} +
+
+ + {loading ? ( +
+ Loading… +
+ ) : loadError ? ( +
+

{loadError}

+ +
+ ) : transactions.length === 0 ? ( +

+ No income transactions found for this month. +

+ ) : ( +
+ {active.map(tx => ( +
+
+

{tx.payee}

+

{tx.date}

+
+ +{fmt(tx.amount)} + +
+ ))} + + {showIgnored && ignored.map(tx => ( +
+
+

{tx.payee}

+

{tx.date}

+
+ +{fmt(tx.amount)} + +
+ ))} +
+ )} +
+ +

+ Showing positive unmatched transactions from your bank. Exclude transfers or non-income deposits. +

+
+
+ ); +} diff --git a/client/components/MobileBillRow.jsx b/client/components/MobileBillRow.jsx index 5affc91..0cb863c 100644 --- a/client/components/MobileBillRow.jsx +++ b/client/components/MobileBillRow.jsx @@ -1,32 +1,26 @@ import React, { useMemo } from 'react'; -import { History } from 'lucide-react'; +import { ArrowDown, ArrowUp, Copy, GripVertical, History } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { scheduleLabel } from '@/lib/billingSchedule'; function hasHistoricalVisibility(bill) { const visibility = bill.history_visibility; return !!bill.has_history_ranges || (visibility && visibility !== 'default'); } -export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory }) { +export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) { const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]); const statusClass = useMemo(() => { return cn( 'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide', bill.active - ? 'bg-emerald-500/15 text-emerald-500' + ? 'bg-emerald-500/20 text-emerald-300' : 'bg-muted text-muted-foreground', ); }, [bill.active]); - const autopayClass = useMemo(() => { - return cn( - 'rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-500', - !!bill.autopay_enabled ? 'opacity-100' : 'opacity-0', - ); - }, [bill.autopay_enabled]); - const toggleBtnClass = useMemo(() => { return cn( 'h-8 px-2.5 text-xs', @@ -37,9 +31,54 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o }, [bill.active]); return ( -
+
-
+
+
+ +
- + ${Number(bill.expected_amount).toFixed(2)}
-
+
-

Due

+

Due

Day {bill.due_day}

-

Category

+

Category

{bill.category_name || '—'}

-

Cycle

-

{bill.billing_cycle || 'monthly'}

+

Cycle

+

{scheduleLabel(bill)}

+ -
- {row.monthly_notes && ( -

- {row.monthly_notes} -

- )} -
- -
- -
-
-

Due

-

{fmtDate(row.due_date)}

-
-
-

Category

-

{row.category_name || 'Uncategorized'}

-
-
-

Expected

-

- {fmt(threshold)} -

-
-
-

Remaining

-

0 ? 'text-foreground' : 'text-emerald-500')}> - {fmt(remaining)} -

-
-
- -
-
-
- Paid - {row.total_paid > 0 ? fmt(row.total_paid) : '—'} -
-
- Date - {row.last_paid_date ? fmtDate(row.last_paid_date) : '—'} -
-
- -
- {!isPaid && !isSkipped && ( -
- - -
- )} - - {row.payments && row.payments.length > 0 && ( - - )} - - -
-
-
- - ); -}); - -MobileTrackerRow.displayName = 'MobileTrackerRow'; diff --git a/client/components/PageTransition.jsx b/client/components/PageTransition.jsx new file mode 100644 index 0000000..1b61ff4 --- /dev/null +++ b/client/components/PageTransition.jsx @@ -0,0 +1,21 @@ +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; + +export default function PageTransition({ children, routeKey }) { + const reduceMotion = useReducedMotion(); + + if (reduceMotion) return children; + + return ( + + + {children} + + + ); +} diff --git a/client/components/RecentlyDeletedBillsDialog.jsx b/client/components/RecentlyDeletedBillsDialog.jsx new file mode 100644 index 0000000..991342d --- /dev/null +++ b/client/components/RecentlyDeletedBillsDialog.jsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { RotateCcw, Trash2, Loader2 } from 'lucide-react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { formatUSD } from '@/lib/money'; + +function daysLeftLabel(days) { + if (days == null) return null; + if (days <= 0) return 'purges today'; + if (days === 1) return '1 day left'; + return `${days} days left`; +} + +/** + * Lists bills that were soft-deleted within the 30-day recovery window and lets + * the user restore them — a durable path beyond the transient "Undo" toast. + * + * Presentational: the parent owns the list (`bills`) and the async `onRestore`, + * so restoring refreshes the page's active bills too. + */ +export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills = [], onRestore }) { + const [busyId, setBusyId] = useState(null); + + async function handleRestore(bill) { + setBusyId(bill.id); + try { + await onRestore(bill); + } finally { + setBusyId(null); + } + } + + return ( + + + + Recently deleted + + Deleted bills are kept for 30 days before they’re permanently removed. Restore one to bring it back. + + + + {bills.length === 0 ? ( +
+ +

Nothing to recover

+

Bills you delete will appear here for 30 days.

+
+ ) : ( +
    + {bills.map(bill => { + const left = daysLeftLabel(bill.days_left); + const busy = busyId === bill.id; + return ( +
  • +
    +

    {bill.name}

    +

    + {formatUSD(bill.expected_amount)} + {bill.category_name ? ` · ${bill.category_name}` : ''} + {left ? · {left} : null} +

    +
    + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/client/components/ReleaseNotesDialog.jsx b/client/components/ReleaseNotesDialog.jsx index 8afb8c6..67a5123 100644 --- a/client/components/ReleaseNotesDialog.jsx +++ b/client/components/ReleaseNotesDialog.jsx @@ -1,44 +1,44 @@ -import { useState, useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { APP_VERSION, RELEASE_NOTES } from '@/lib/version'; import { Sparkles } from 'lucide-react'; - -const STORAGE_KEY = `bt-release-seen-${APP_VERSION}`; +import { useAuth } from '@/hooks/useAuth'; +import { api } from '@/api'; export function ReleaseNotesDialog() { + const { hasNewVersion, setHasNewVersion } = useAuth(); const [open, setOpen] = useState(false); const titleRef = useRef(null); useEffect(() => { - const seen = localStorage.getItem(STORAGE_KEY); - if (!seen) setOpen(true); - }, []); + if (hasNewVersion) setOpen(true); + }, [hasNewVersion]); const handleClose = () => { - localStorage.setItem(STORAGE_KEY, 'true'); setOpen(false); - // Return focus to where it was before the dialog opened - const previouslyFocused = document.activeElement; - if (previouslyFocused && typeof previouslyFocused.focus === 'function') { - setTimeout(() => previouslyFocused.focus(), 0); - } + setHasNewVersion(false); // optimistic — don't wait for the server + api.acknowledgeVersion().catch(err => console.error('[ReleaseNotesDialog] failed to acknowledge version', err)); // backend stores the seen release version + const prev = document.activeElement; + if (prev?.focus) setTimeout(() => prev.focus(), 0); }; return ( - { if (!o) handleClose(); }}> - + { if (!v) handleClose(); }}> +
- What's new in v{RELEASE_NOTES.version} + v{RELEASE_NOTES.version} · {RELEASE_NOTES.date}
- Bill Tracker is brand new - Release notes and new features overview + What's new + + Release highlights for BillTracker v{RELEASE_NOTES.version} +
@@ -47,24 +47,28 @@ export function ReleaseNotesDialog() {

{item.title}

-

{item.desc}

+

{item.desc}

))}
-
- - Access original UI - + {RELEASE_NOTES.image && ( +
+
+ {RELEASE_NOTES.image.alt} +
+
+ )} + +
diff --git a/client/components/SearchFilterPanel.jsx b/client/components/SearchFilterPanel.jsx new file mode 100644 index 0000000..f405ed4 --- /dev/null +++ b/client/components/SearchFilterPanel.jsx @@ -0,0 +1,66 @@ +import { ChevronDown, ChevronUp, Search, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export default function SearchFilterPanel({ + title = 'Search & filters', + collapsed, + onCollapsedChange, + hasFilters, + resultLabel, + sortLabel, + onClear, + children, + className, + variant = 'default', + headerActions, +}) { + const embedded = variant === 'embedded'; + const ToggleIcon = collapsed ? ChevronUp : ChevronDown; + + return ( +
+
+ + + {hasFilters && onClear && ( + + )} + {headerActions} +
+ + {!collapsed && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/client/components/StatusBadge.jsx b/client/components/StatusBadge.jsx index 3bf9d44..6560c14 100644 --- a/client/components/StatusBadge.jsx +++ b/client/components/StatusBadge.jsx @@ -1,24 +1,28 @@ import React, { useMemo } from 'react'; +import { AlertCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; const STATUS_META = { - paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30' }, + paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' }, upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' }, - due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30' }, - late: { label: 'Late', cls: 'bg-orange-400/15 text-orange-500 border border-orange-400/30' }, - missed: { label: 'Missed', cls: 'bg-red-400/15 text-red-500 border border-red-400/30' }, - autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30' }, + due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' }, + late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' }, + missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' }, + autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' }, skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, }; export const StatusBadge = React.memo(function StatusBadge({ status }) { const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]); + const isUrgent = status === 'late' || status === 'missed'; return ( + {isUrgent && } {meta.label} ); diff --git a/client/components/SubscriptionCatalogSection.jsx b/client/components/SubscriptionCatalogSection.jsx new file mode 100644 index 0000000..271f725 --- /dev/null +++ b/client/components/SubscriptionCatalogSection.jsx @@ -0,0 +1,716 @@ +'use strict'; + +import { useEffect, useMemo, useState } from 'react'; +import { + CheckCircle2, ChevronDown, ExternalLink, Link2, Link2Off, + Loader2, Pencil, Plus, Search, X, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { cn, fmt } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; + +const TYPE_LABELS = { + streaming: 'Streaming', software: 'Software', cloud: 'Cloud', + music: 'Music', news: 'News', fitness: 'Fitness', gaming: 'Gaming', + utilities: 'Utilities', insurance: 'Insurance', food: 'Food', + education: 'Education', shopping: 'Shopping', security: 'Security', other: 'Other', +}; + +const CHIPS = [ + { value: null, label: 'All' }, + { value: 'streaming', label: 'Streaming' }, + { value: 'music', label: 'Music' }, + { value: 'gaming', label: 'Gaming' }, + { value: 'news', label: 'News' }, + { value: 'fitness', label: 'Fitness' }, + { value: 'software', label: 'Software' }, + { value: 'security', label: 'Security' }, + { value: 'food', label: 'Food' }, + { value: 'cloud', label: 'Cloud' }, + { value: 'shopping', label: 'Shopping' }, + { value: 'education', label: 'Education' }, +]; + +// Returns 'match' | 'above' | 'below' | null +function priceDrift(monthly, catalogStarting) { + if (!catalogStarting || !monthly) return null; + const pct = (monthly - catalogStarting) / catalogStarting; + if (Math.abs(pct) <= 0.05) return 'match'; + return pct > 0 ? 'above' : 'below'; +} + +// ── Descriptor editor inline inside a matched card ───────────────────────── +function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + + async function handleAdd() { + const d = value.trim(); + if (!d) return; + setBusy(true); + try { + await onAdd(catalogId, d); + setValue(''); + } finally { + setBusy(false); + } + } + + return ( +
+ + + {open && ( +
+ {descriptors.map(d => ( +
+ + {d.descriptor} + + +
+ ))} + +
+ setValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && value.trim()) handleAdd(); }} + className="h-7 font-mono text-xs" + /> + +
+

+ Add the exact payee text your bank uses to improve auto-matching. +

+
+ )} +
+ ); +} + +// ── Card for an already-tracked catalog entry ────────────────────────────── +function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescriptor }) { + const { matched_bill: bill, user_descriptors: descs = [] } = entry; + const drift = priceDrift(bill?.monthly_equivalent, entry.starting_monthly_usd); + + return ( +
+
+
+
+ {entry.name} + + {TYPE_LABELS[entry.subscription_type] || 'Other'} + + {bill && !bill.active && ( + + Paused + + )} +
+ +
+ {bill && ( + {fmt(bill.monthly_equivalent)}/mo + )} + {drift === 'match' && entry.starting_monthly_usd && ( + + + matches catalog price + + )} + {drift === 'above' && ( + + catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo + + )} + {drift === 'below' && entry.starting_monthly_usd && ( + + catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo + + )} + {!entry.starting_monthly_usd && entry.website && ( + + website + + )} +
+
+ +
+ + +
+
+ + +
+ ); +} + +// ── Card for an untracked catalog entry ─────────────────────────────────── +function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }) { + return ( +
+ onToggleSelect(entry.id, !!checked)} + aria-label={`Select ${entry.name}`} + /> +
+

{entry.name}

+

+ {entry.category} + {entry.starting_monthly_usd && ( + from ${entry.starting_monthly_usd.toFixed(2)}/mo + )} +

+
+ {entry.website && ( + + + + )} + +
+ ); +} + +// ── Re-link dialog ───────────────────────────────────────────────────────── +function ReLinkDialog({ open, relinkEntry, allEntries, onClose, onConfirm, busy }) { + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(null); + + useEffect(() => { + if (open) { setSearch(''); setSelected(null); } + }, [open]); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return allEntries.filter(e => + !q || e.name.toLowerCase().includes(q) || (e.category || '').toLowerCase().includes(q) + ); + }, [allEntries, search]); + + const bill = relinkEntry?.matched_bill; + + return ( + { if (!v) onClose(); }}> + + + Re-link to catalog entry + {bill && ( +

+ Choose the correct service for{' '} + {bill.name}. +

+ )} +
+ + setSearch(e.target.value)} + className="text-sm" + /> + +
+ {/* Unlink option */} + + + {filtered.length === 0 && search ? ( +

No services found.

+ ) : filtered.map(e => ( + + ))} +
+ + + + + +
+
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────── +export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }) { + const [catalog, setCatalog] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showUnmatched, setShowUnmatched] = useState(false); + const [activeType, setActiveType] = useState(null); + const [search, setSearch] = useState(''); + const [selectedIds, setSelectedIds] = useState(new Set()); + const [relinkEntry, setRelinkEntry] = useState(null); + const [relinkBusy, setRelinkBusy] = useState(false); + const [trackBusyId, setTrackBusyId] = useState(null); + const [bulkBusy, setBulkBusy] = useState(false); + + async function loadCatalog() { + setLoading(true); + setError(null); + try { + const data = await api.subscriptionCatalog(); + setCatalog(data.catalog || []); + } catch (err) { + const message = err.message || 'Failed to load catalog'; + setError(message); + toast.error(message); + } finally { + setLoading(false); + } + } + + useEffect(() => { loadCatalog(); }, []); + + // Filter by type chip + search + const filtered = useMemo(() => { + const q = search.toLowerCase().trim(); + return catalog.filter(e => { + if (activeType && e.subscription_type !== activeType) return false; + if (q) return e.name.toLowerCase().includes(q) || (e.category || '').toLowerCase().includes(q); + return true; + }); + }, [catalog, activeType, search]); + + const matched = filtered.filter(e => e.matched_bill !== null); + const unmatched = filtered.filter(e => e.matched_bill === null); + + // ── Handlers ───────────────────────────────────────────────────────────── + + async function handleTrack(entry) { + setTrackBusyId(entry.id); + try { + await api.createSubscriptionFromRecommendation({ + name: entry.name, + due_day: 1, + expected_amount: entry.starting_monthly_usd || 0, + cycle_type: 'monthly', + subscription_type: entry.subscription_type || 'other', + catalog_match: { id: entry.id, name: entry.name, subscription_type: entry.subscription_type }, + }); + toast.success(`${entry.name} added to your subscriptions`, { + description: 'Open it with Edit to set the correct amount and due date.', + }); + await loadCatalog(); + onTrackComplete?.(); + } catch (err) { + toast.error(err.message || `Failed to add ${entry.name}`); + } finally { + setTrackBusyId(null); + } + } + + async function handleBulkTrack() { + if (selectedIds.size === 0) return; + setBulkBusy(true); + const toTrack = unmatched.filter(e => selectedIds.has(e.id)); + let succeeded = 0; + let failed = 0; + for (const entry of toTrack) { + try { + await api.createSubscriptionFromRecommendation({ + name: entry.name, + due_day: 1, + expected_amount: entry.starting_monthly_usd || 0, + cycle_type: 'monthly', + subscription_type: entry.subscription_type || 'other', + catalog_match: { id: entry.id, name: entry.name, subscription_type: entry.subscription_type }, + }); + succeeded++; + } catch { + failed++; + } + } + if (succeeded > 0) { + toast.success(`${succeeded} subscription${succeeded !== 1 ? 's' : ''} added`); + setSelectedIds(new Set()); + await loadCatalog(); + onTrackComplete?.(); + } + if (failed > 0) { + toast.error(`${failed} subscription${failed !== 1 ? 's' : ''} could not be added`); + } + setBulkBusy(false); + } + + async function handleRelink(billId, catalogId) { + setRelinkBusy(true); + try { + await api.updateSubscriptionCatalogLink(billId, catalogId); + toast.success(catalogId ? 'Catalog link updated' : 'Catalog link removed'); + setRelinkEntry(null); + await loadCatalog(); + } catch (err) { + toast.error(err.message || 'Failed to update catalog link'); + } finally { + setRelinkBusy(false); + } + } + + async function handleAddDescriptor(catalogId, descriptor) { + try { + const newDesc = await api.addCatalogDescriptor(catalogId, descriptor); + setCatalog(prev => prev.map(e => + e.id === catalogId + ? { ...e, user_descriptors: [...(e.user_descriptors || []), newDesc] } + : e + )); + toast.success('Descriptor added'); + return true; + } catch (err) { + toast.error(err.message || 'Failed to add descriptor'); + return false; + } + } + + async function handleDeleteDescriptor(catalogId, descriptorId) { + try { + await api.deleteCatalogDescriptor(descriptorId); + setCatalog(prev => prev.map(e => + e.id === catalogId + ? { ...e, user_descriptors: (e.user_descriptors || []).filter(d => d.id !== descriptorId) } + : e + )); + toast.success('Descriptor removed'); + } catch (err) { + toast.error(err.message || 'Failed to remove descriptor'); + } + } + + function toggleSelect(id, checked) { + setSelectedIds(prev => { + const next = new Set(prev); + checked ? next.add(id) : next.delete(id); + return next; + }); + } + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + + + Known Service Catalog + + Popular services, linked bills, and custom bank descriptors used to improve matching. + + + {/* Category filter chips */} +
+ {CHIPS.map(chip => ( + + ))} +
+ + {/* Search */} +
+ + setSearch(e.target.value)} + className="h-9 pl-8 text-sm" + /> +
+
+ + + {loading ? ( +
+ + Loading catalog… +
+ ) : error ? ( +
+

{error}

+ +
+ ) : ( +
+ + {/* ── Matched group ───────────────────────────────────────── */} + {matched.length > 0 && ( +
+

+ Tracking ({matched.length}) +

+
+ {matched.map(entry => ( + handleDeleteDescriptor(entry.id, descriptorId)} + /> + ))} +
+
+ )} + + {/* ── Unmatched toggle + group ─────────────────────────── */} +
+ + + {showUnmatched && ( +
+ {unmatched.length === 0 ? ( +

+ {search || activeType ? 'No services match your filter.' : 'All known services are already being tracked.'} +

+ ) : ( +
+ {unmatched.map(entry => ( + + ))} +
+ )} +
+ )} +
+ +
+ )} +
+ + {/* ── Re-link dialog ─────────────────────────────────────────────── */} + setRelinkEntry(null)} + onConfirm={handleRelink} + busy={relinkBusy} + /> + + {/* ── Bulk action bar ────────────────────────────────────────────── */} + {selectedIds.size > 0 && ( +
+
+ + {selectedIds.size} + + + {selectedIds.size === 1 ? 'service' : 'services'} selected + +
+
+ + +
+
+ )} +
+ ); +} diff --git a/client/components/SummaryCard.jsx b/client/components/SummaryCard.jsx index c3f5556..708b191 100644 --- a/client/components/SummaryCard.jsx +++ b/client/components/SummaryCard.jsx @@ -19,7 +19,7 @@ const CARD_DEFS = { bar: 'from-emerald-500 to-emerald-300', glow: 'shadow-[0_4px_20px_rgba(16,185,129,0.15)]', borderActive: 'border-emerald-400/40', - valueClass: 'text-emerald-500', + valueClass: 'text-emerald-600 dark:text-emerald-100', activateWhen: (v) => v > 0, }, remaining: { @@ -33,10 +33,10 @@ const CARD_DEFS = { overdue: { label: 'Overdue', icon: AlertCircle, - bar: 'from-rose-500 to-orange-400', - glow: 'shadow-[0_4px_20px_rgba(239,68,68,0.12)]', - borderActive: 'border-red-400/40', - valueClass: 'text-red-500', + bar: 'from-rose-400 to-orange-300', + glow: 'shadow-[0_4px_20px_rgba(251,113,133,0.10)]', + borderActive: 'border-rose-400/35', + valueClass: 'text-red-500 dark:text-rose-100', activateWhen: (v) => v > 0, }, }; @@ -48,8 +48,8 @@ export const SummaryCard = React.memo(function SummaryCard({ type, value, onEdit return (
@@ -60,7 +60,7 @@ export const SummaryCard = React.memo(function SummaryCard({ type, value, onEdit )} />
-

+

{def.label}

{type === 'starting' && onEdit && ( @@ -75,12 +75,12 @@ export const SummaryCard = React.memo(function SummaryCard({ type, value, onEdit )}

{fmt(value)}

- {hint &&

{hint}

} + {hint &&

{hint}

}
); }); diff --git a/client/components/admin/AddUserCard.jsx b/client/components/admin/AddUserCard.jsx new file mode 100644 index 0000000..ccb2c92 --- /dev/null +++ b/client/components/admin/AddUserCard.jsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +export default function AddUserCard({ onCreated }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleCreate = async (e) => { + e.preventDefault(); + setError(''); + + if (password.length < 8) { + const msg = 'Password must be at least 8 characters.'; + setError(msg); + toast.error(msg); + return; + } + + setLoading(true); + try { + await api.createUser({ username, password }); + toast.success(`User "${username}" created.`); + setUsername(''); + setPassword(''); + setError(''); + onCreated(); + } catch (err) { + const errorMessage = err.message || 'Failed to create user.'; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + return ( + + + Add User + + +
+
+ + setUsername(e.target.value)} placeholder="username" required /> +
+
+ + setPassword(e.target.value)} placeholder="Password" required /> +
+
+ {error && ( +
+ {error} +
+ )} + + + + + ); +} diff --git a/client/components/admin/AuthMethodsCard.jsx b/client/components/admin/AuthMethodsCard.jsx new file mode 100644 index 0000000..aac86fc --- /dev/null +++ b/client/components/admin/AuthMethodsCard.jsx @@ -0,0 +1,344 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { FieldRow, Toggle } from './adminShared'; + +const AUTHENTIK_ICON_URL = '/img/auth.png'; + +function defaultOidcRedirectUri() { + if (typeof window === 'undefined') return ''; + return `${window.location.origin}/api/auth/oidc/callback`; +} + +function looksLikeOidcEndpoint(url) { + const value = String(url || '').toLowerCase(); + return /\/(?:authorize|token|userinfo|jwks|certs)\/?$/.test(value); +} + +export default function AuthMethodsCard() { + const [data, setData] = useState(null); + const [form, setForm] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testingOidc, setTestingOidc] = useState(false); + const [oidcTest, setOidcTest] = useState(null); + + const load = useCallback(async () => { + try { + const d = await api.authModeConfig(); + setData(d); + setForm({ + local_login_enabled: d.local_login_enabled !== false, + oidc_login_enabled: !!d.oidc_login_enabled, + oidc_provider_name: d.oidc_provider_name || 'authentik', + oidc_issuer_url: d.oidc_issuer_url || '', + oidc_client_id: d.oidc_client_id || '', + oidc_client_secret: '', + oidc_client_secret_clear: false, + oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic', + oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(), + oidc_scopes: d.oidc_scopes || 'openid email profile groups', + oidc_auto_provision: d.oidc_auto_provision !== false, + oidc_admin_group: d.oidc_admin_group || '', + oidc_default_role: d.oidc_default_role || 'user', + }); + } catch (err) { + toast.error(err.message || 'Failed to load auth settings.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); + + async function handleSave() { + setSaving(true); + try { + const d = await api.setAuthMode(form); + setData(d); + setForm({ + local_login_enabled: d.local_login_enabled !== false, + oidc_login_enabled: !!d.oidc_login_enabled, + oidc_provider_name: d.oidc_provider_name || 'authentik', + oidc_issuer_url: d.oidc_issuer_url || '', + oidc_client_id: d.oidc_client_id || '', + oidc_client_secret: '', + oidc_client_secret_clear: false, + oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic', + oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(), + oidc_scopes: d.oidc_scopes || 'openid email profile groups', + oidc_auto_provision: d.oidc_auto_provision !== false, + oidc_admin_group: d.oidc_admin_group || '', + oidc_default_role: d.oidc_default_role || 'user', + }); + toast.success('Auth method settings saved.'); + } catch (err) { + toast.error(err.message || 'Failed to save auth method settings.'); + } finally { + setSaving(false); + } + } + + async function handleTestOidc() { + setTestingOidc(true); + setOidcTest(null); + try { + const result = await api.testOidcConfig(form); + setOidcTest(result); + toast.success('authentik configuration test passed.'); + } catch (err) { + const result = err.data || { ok: false, error: err.message || 'OIDC configuration test failed.' }; + setOidcTest(result); + toast.error(result.error || 'OIDC configuration test failed.'); + } finally { + setTestingOidc(false); + } + } + + if (loading || !form) { + return ( + + + Loading auth settings… + + + ); + } + + const secretAvailable = form.oidc_client_secret.trim() + ? true + : form.oidc_client_secret_clear + ? false + : !!data?.oidc_client_secret_set; + const oidcConfigured = !!( + form.oidc_issuer_url.trim() && + form.oidc_client_id.trim() && + secretAvailable && + form.oidc_redirect_uri.trim() + ); + const adminGroupConfigured = !!form.oidc_admin_group.trim(); + const wouldLockOut = !form.local_login_enabled && !form.oidc_login_enabled; + const cantDisableLocal = !form.local_login_enabled && (!oidcConfigured || !form.oidc_login_enabled || !adminGroupConfigured); + const oidcEnabledButIncomplete = form.oidc_login_enabled && !oidcConfigured; + const canSave = !wouldLockOut && !cantDisableLocal && !oidcEnabledButIncomplete && !saving; + const canTestOidc = oidcConfigured && !testingOidc; + const missingFields = [ + !form.oidc_issuer_url.trim() && 'Issuer URL', + !form.oidc_client_id.trim() && 'Client ID', + !secretAvailable && 'Client Secret', + !form.oidc_redirect_uri.trim() && 'Redirect URI', + ].filter(Boolean); + const issuerEndpointWarning = looksLikeOidcEndpoint(form.oidc_issuer_url); + + return ( + + +
+
+ Authentication Methods +

+ Control local login and authentik/OIDC. Settings are saved in the database; + environment variables only fill blank fields as bootstrap defaults. +

+
+
+
+ + + + {(data?.warnings?.length > 0 || wouldLockOut || cantDisableLocal) && ( +
+ {wouldLockOut && ( +

+ Cannot disable all login methods; at least one must remain enabled. +

+ )} + {cantDisableLocal && !wouldLockOut && ( +

+ Cannot disable local login without authentik/OIDC configured, enabled, and mapped to an admin group. +

+ )} + {oidcEnabledButIncomplete && ( +

+ authentik/OIDC needs {missingFields.join(', ')} before it can be enabled. +

+ )} + {data?.warnings?.map((w, i) => ( +

{w}

+ ))} +
+ )} + + +
+ set('local_login_enabled', v)} label="Enable local login" /> + {form.local_login_enabled ? 'Enabled' : 'Disabled'} +
+
+ + +
+ set('oidc_login_enabled', v)} label="Enable OIDC login" /> + + {!oidcConfigured ? 'Not fully configured' : form.oidc_login_enabled ? 'Enabled' : 'Disabled'} + +
+
+ +
+
+ + authentik / OIDC configuration +
+ + + set('oidc_provider_name', e.target.value)} placeholder="authentik" className="max-w-xs h-8 text-sm" /> + + + +
+ set('oidc_issuer_url', e.target.value)} + placeholder="https://yourURL.com/application/o/bills/.well-known/openid-configuration" + className="max-w-xl h-8 text-sm" + /> +

+ Use the authentik provider issuer URL or full discovery URL, for example https://yourURL.com/application/o/bills/.well-known/openid-configuration. +

+ {issuerEndpointWarning && ( +

+ This looks like an authorization endpoint. In authentik, copy the provider issuer or OpenID Configuration URL. +

+ )} +
+
+ + + set('oidc_client_id', e.target.value)} placeholder="authentik client ID" className="max-w-xl h-8 text-sm" /> + + + +
+
+ setForm(prev => ({ + ...prev, + oidc_client_secret: e.target.value, + oidc_client_secret_clear: e.target.value ? false : prev.oidc_client_secret_clear, + }))} + placeholder="Leave blank to keep existing secret" + className="h-8 text-sm" + /> + + {data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'Secret is set' : 'No secret saved'} + +
+ +
+
+ + +
+ +

+ Advanced. Keep client_secret_basic unless your authentik provider explicitly requires client_secret_post. +

+
+
+ + +
+
+ set('oidc_redirect_uri', e.target.value)} placeholder={defaultOidcRedirectUri()} className="h-8 text-sm" /> + +
+

Add this exact URL to the Redirect URIs allowed by authentik.

+
+
+ + + set('oidc_scopes', e.target.value)} placeholder="openid email profile groups" className="max-w-xl h-8 text-sm" /> + + + +
+ set('oidc_admin_group', e.target.value)} placeholder="e.g. bill-tracker-admins" className="max-w-sm h-8 text-sm" /> +

Only users in this authentik group become app admins. Admin is never granted by default.

+
+
+ + +
+
+ set('oidc_auto_provision', v)} label="Auto-provision users" /> + {form.oidc_auto_provision ? 'Enabled' : 'Disabled'} +
+

When enabled, valid authentik users are created in this app on first login.

+
+
+ + +
+ + Admin role only via admin group. +
+
+ + {data?.oidc_env_fallback_used && ( +
+ One or more blank database fields are currently using environment fallback values. Saving values here takes precedence. +
+ )} + + {oidcTest && ( +
+ {oidcTest.ok + ? `Configuration test passed for ${oidcTest.issuer || form.oidc_issuer_url}.` + : oidcTest.error || 'Configuration test failed.'} +
+ )} +
+ +
+ + + +
+ +
+
+ ); +} diff --git a/client/components/admin/BackupManagementCard.jsx b/client/components/admin/BackupManagementCard.jsx new file mode 100644 index 0000000..282c10b --- /dev/null +++ b/client/components/admin/BackupManagementCard.jsx @@ -0,0 +1,396 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Database, Download, Play, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { cn, fmtBytes } from '@/lib/utils'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { + Select, SelectTrigger, SelectValue, SelectContent, SelectItem, +} from '@/components/ui/select'; +import { + AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, +} from '@/components/ui/alert-dialog'; +import { SectionHeading, Toggle, formatDateTime, BackupTypeBadge } from './adminShared'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +const DEFAULT_SETTINGS = { + enabled: false, + frequency: 'daily', + time: '02:00', + retention_count: 2, + last_run_at: null, + next_run_at: null, + last_error: null, +}; + +export default function BackupManagementCard() { + const [backups, setBackups] = useState([]); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(''); + const [restoreTarget, setRestoreTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const [backupData, settingsData] = await Promise.all([ + api.adminBackups(), + api.adminBackupSettings(), + ]); + setBackups(backupData.backups || []); + setSettings({ ...DEFAULT_SETTINGS, ...settingsData }); + } catch (err) { + toast.error(err.message || 'Failed to load backups.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const latest = backups[0]; + const setSchedule = (key, value) => setSettings(prev => ({ ...prev, [key]: value })); + + async function handleCreate() { + setBusy('create'); + try { + await api.createAdminBackup(); + toast.success('Backup created.'); + await load(); + } catch (err) { + toast.error(err.message || 'Failed to create backup.'); + } finally { + setBusy(''); + } + } + + async function handleDownload(backup) { + setBusy(`download:${backup.id}`); + try { + const { blob, filename } = await api.downloadAdminBackup(backup.id); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename || backup.id; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + toast.error(err.message || 'Failed to download backup.'); + } finally { + setBusy(''); + } + } + + async function handleImport(e) { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + + setBusy('import'); + try { + await api.importAdminBackup(file); + toast.success('Backup imported.'); + await load(); + } catch (err) { + toast.error(err.message || 'Failed to import backup.'); + } finally { + setBusy(''); + } + } + + async function handleRestore() { + if (!restoreTarget) return; + setBusy(`restore:${restoreTarget.id}`); + try { + const result = await api.restoreAdminBackup(restoreTarget.id); + toast.success(`Database restored. Pre-restore backup: ${result.pre_restore_backup}`); + setRestoreTarget(null); + await load(); + } catch (err) { + toast.error(err.message || 'Failed to restore backup.'); + } finally { + setBusy(''); + } + } + + async function handleDelete() { + if (!deleteTarget) return; + setBusy(`delete:${deleteTarget.id}`); + try { + await api.deleteAdminBackup(deleteTarget.id); + toast.success('Backup deleted.'); + setDeleteTarget(null); + await load(); + } catch (err) { + toast.error(err.message || 'Failed to delete backup.'); + } finally { + setBusy(''); + } + } + + async function handleSaveSettings() { + setBusy('settings'); + try { + const saved = await api.saveAdminBackupSettings({ + enabled: !!settings.enabled, + frequency: settings.frequency, + time: settings.time, + retention_count: parseInt(settings.retention_count, 10) || 2, + }); + setSettings({ ...DEFAULT_SETTINGS, ...saved }); + toast.success('Backup schedule saved.'); + } catch (err) { + toast.error(err.message || 'Failed to save backup schedule.'); + } finally { + setBusy(''); + } + } + + async function handleRunScheduledNow() { + setBusy('run-scheduled'); + try { + await api.runScheduledBackupNow(); + toast.success('Scheduled backup created.'); + await load(); + } catch (err) { + toast.error(err.message || 'Failed to run scheduled backup.'); + } finally { + setBusy(''); + } + } + + if (loading) { + return Loading backups…; + } + + return ( + <> + + +
+
+ Database Backups +

+ Admin-only SQLite backup, import, download, restore, and schedule controls. +

+
+ + + + + {settings.last_error ? 'Attention' : 'Ready'} + + + {settings.last_error || 'Backup system is operational'} + + +
+
+ +
+ {[ + ['Managed backups', backups.length], + ['Latest backup', latest ? formatDateTime(latest.modified_at) : '—'], + ['Latest size', latest ? fmtBytes(latest.size_bytes) : '—'], + ['Scheduled', settings.enabled ? `${settings.frequency} ${settings.time}` : 'Disabled'], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {settings.last_error && ( +
+ {settings.last_error} +
+ )} + +
+ + + +
+ +
+ + + + + + + + + + + + {backups.map(backup => ( + + + + + + + + + ))} + {!backups.length && ( + + + + )} + +
BackupTypeModifiedSizeChecksum +
{backup.id}{formatDateTime(backup.modified_at)}{fmtBytes(backup.size_bytes)} + {backup.checksum || '—'} + +
+ + + +
+
No managed backups yet.
+
+ +
+
+
+ Scheduled Backups +

+ Scheduled runs create managed backups and only apply retention to scheduled backups. +

+
+ setSchedule('enabled', v)} label="Enable scheduled backups" /> +
+ +
+
+ + +
+
+ + setSchedule('time', e.target.value)} /> +
+
+ + + + + + Number of scheduled backups to retain — older ones are auto-deleted + + + setSchedule('retention_count', e.target.value)} /> +
+
+ +
+ {formatDateTime(settings.next_run_at)} +
+
+
+ +
+
Last run: {formatDateTime(settings.last_run_at)}
+
Last error: {settings.last_error || '—'}
+
+ +
+ + +
+
+
+
+ + { if (!open) setRestoreTarget(null); }}> + + + Restore this database backup? + + This replaces the live database with {restoreTarget?.id}. + A pre-restore backup will be created first. Run this during a quiet maintenance window. + + + + Cancel + + {busy.startsWith('restore:') ? 'Restoring…' : 'Restore Database'} + + + + + + { if (!open) setDeleteTarget(null); }}> + + + Delete this backup? + + This permanently deletes {deleteTarget?.id}. + The live database is not affected. + + + + Cancel + + {busy.startsWith('delete:') ? 'Deleting…' : 'Delete Backup'} + + + + + + ); +} diff --git a/client/components/admin/BankSyncAdminCard.jsx b/client/components/admin/BankSyncAdminCard.jsx new file mode 100644 index 0000000..0106c43 --- /dev/null +++ b/client/components/admin/BankSyncAdminCard.jsx @@ -0,0 +1,290 @@ +import React, { useState, useEffect } from 'react'; +import { AlertTriangle, Info } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Toggle } from './adminShared'; + +function parseUtc(str) { + if (!str) return null; + const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; + return new Date(normalized); +} + +function timeAgo(iso) { + if (!iso) return null; + const secs = Math.floor((Date.now() - parseUtc(iso).getTime()) / 1000); + if (secs < 60) return 'just now'; + if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; + if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; + return `${Math.floor(secs / 86400)}d ago`; +} + +function timeUntil(iso) { + if (!iso) return null; + const secs = Math.floor((parseUtc(iso).getTime() - Date.now()) / 1000); + if (secs <= 0) return 'soon'; + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.floor(secs / 60)}m`; + return `${Math.floor(secs / 3600)}h`; +} + +export default function BankSyncAdminCard() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [saving, setSaving] = useState(false); + const [enabled, setEnabled] = useState(false); + const [syncInterval, setSyncInterval] = useState(4); + const [syncDays, setSyncDays] = useState(30); + const [debugLogging, setDebugLogging] = useState(false); + + useEffect(() => { + api.bankSyncConfig() + .then(d => { + setConfig(d); + setEnabled(!!d.enabled); + setSyncInterval(d.sync_interval_hours ?? 4); + setSyncDays(d.sync_days ?? 30); + setDebugLogging(!!d.debug_logging); + }) + .catch(err => setLoadError(err.message || 'Failed to load bank sync config')) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async () => { + const hours = parseFloat(syncInterval); + const days = parseInt(syncDays, 10); + const maxDays = config?.sync_days_max ?? 45; + if (!Number.isFinite(hours) || hours < 0.5 || hours > 168) { + toast.error('Sync interval must be between 0.5 and 168 hours.'); + return; + } + if (!Number.isFinite(days) || days < 1 || days > maxDays) { + toast.error(`Routine sync lookback must be 1–${maxDays} days. SimpleFIN Bridge enforces a ${maxDays}-day hard limit — values above ${maxDays} return errors.`); + return; + } + setSaving(true); + try { + const result = await api.setBankSyncConfig({ enabled, sync_interval_hours: hours, sync_days: days, debug_logging: debugLogging }); + setConfig(result); + setEnabled(!!result.enabled); + setSyncInterval(result.sync_interval_hours ?? 4); + setSyncDays(result.sync_days ?? 30); + setDebugLogging(!!result.debug_logging); + toast.success('Bank sync settings saved.'); + } catch (err) { + toast.error(err.message || 'Failed to update bank sync setting.'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( + + + Loading… + + + ); + } + + if (loadError) { + return ( + + + {loadError} + + + ); + } + + const changed = enabled !== !!config?.enabled + || parseFloat(syncInterval) !== config?.sync_interval_hours + || parseInt(syncDays, 10) !== config?.sync_days + || debugLogging !== !!config?.debug_logging; + const worker = config?.worker; + const seedDays = config?.seed_days ?? 44; + const maxDays = config?.sync_days_max ?? 45; + + return ( + + + Bank Sync (SimpleFIN) +

+ Allow users to connect their own SimpleFIN Bridge account to sync + read-only bank transactions. Each user manages their own connection + from the Data page — no bank credentials are stored. +

+
+ + + {/* Enable toggle */} +
+
+

Allow users to connect SimpleFIN

+

+ When enabled, users see a Bank Sync section on their Data page. +

+
+ setEnabled(v)} + label="Enable bank sync" + /> +
+ + {/* Sync interval */} +
+
+

Auto-sync interval

+

+ How often the background worker checks for new transactions. +

+
+
+ setSyncInterval(e.target.value)} + className="w-20 text-sm text-right" + /> + hrs +
+
+ + {/* Sync window — two-mode explainer */} +
+
+

Sync lookback windows

+

+ SimpleFIN uses two different windows depending on sync type. +

+
+ + {/* Initial / backfill — read-only */} +
+
+

+ Initial connect & backfill +

+ {seedDays} days +
+

+ The first sync (and any manual backfill) fetches up to {seedDays} days of + history to build a complete transaction picture. This is fixed — SimpleFIN Bridge enforces a + strict {maxDays}-day hard limit, so this stays one day under it to avoid + latency-related errors. +

+
+ + {/* Routine sync — editable */} +
+
+

Routine sync lookback

+

+ How far back each auto-sync and manual "Sync Now" looks after the initial connect. + Recommended: 7–30 days. Setting this near 45 increases request size + and duplicate-skip work with no benefit once history is established. +

+
+
+ setSyncDays(Math.min(maxDays, Math.max(1, parseInt(e.target.value, 10) || 30)))} + className="w-20 text-sm text-right" + /> + days +
+
+ + {/* Amber warning at the SimpleFIN limit */} + {parseInt(syncDays, 10) >= maxDays && ( +
+ +

+ {maxDays} days is SimpleFIN Bridge's maximum. Requests at this limit may occasionally + fail due to request latency — 30 days or less is recommended for reliable routine syncs. +

+
+ )} + + {/* Always-visible hard-limit note */} +
+ + + SimpleFIN Bridge enforces a {maxDays}-day maximum on all requests. + Any value above {maxDays} will cause sync errors for all users. + +
+
+ + {/* Auto-sync worker status */} + {config?.enabled && worker && ( +
+

Auto-sync worker

+
+
+

Status

+

+ + {worker.running ? 'Running' : 'Idle'} +

+
+
+

Last run

+

{worker.last_run_at ? timeAgo(worker.last_run_at) : '—'}

+
+
+

Next run

+

{worker.next_run_at ? `in ${timeUntil(worker.next_run_at)}` : '—'}

+
+
+
+ )} + + {/* Debug logging toggle */} +
+
+

Debug logging

+

+ Logs each account, transaction, and auto-match step to the server console. + Turn on to diagnose sync issues, then off when done. +

+
+ setDebugLogging(v)} + label="Enable debug logging" + /> +
+ + {/* Encryption note */} +

+ {config?.encryption_key_source === 'env' + ? <>SimpleFIN credentials are encrypted at rest. Encryption key is loaded from TOKEN_ENCRYPTION_KEY — stored separately from the database, so a database backup alone cannot decrypt credentials. + : <>SimpleFIN credentials are encrypted, but the key is stored in the same database as the data. A database backup or file-level read includes everything needed to decrypt credentials. Set TOKEN_ENCRYPTION_KEY in your environment to keep the key separate. + }{' '} + Regular database backups preserve all user connections. +

+ +
+ +
+ +
+
+ ); +} diff --git a/client/components/admin/CleanupPanel.jsx b/client/components/admin/CleanupPanel.jsx new file mode 100644 index 0000000..b9f4f97 --- /dev/null +++ b/client/components/admin/CleanupPanel.jsx @@ -0,0 +1,193 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Play, Wrench } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { FieldRow, Toggle, formatDateTime } from './adminShared'; + +export default function CleanupPanel() { + const [status, setStatus] = useState(null); + const [form, setForm] = useState({ + import_sessions_enabled: true, + temp_exports_enabled: true, + temp_export_max_age_hours: 2, + backup_partials_enabled: true, + import_history_enabled: false, + import_history_max_age_days: 365, + }); + const [saving, setSaving] = useState(false); + const [running, setRunning] = useState(false); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + try { + const data = await api.adminCleanup(); + setStatus(data); + if (data.settings) setForm(data.settings); + } catch (err) { + toast.error(err.message || 'Failed to load cleanup settings.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); + + async function handleSave() { + setSaving(true); + try { + const next = await api.saveAdminCleanup(form); + if (next) setForm(next); + toast.success('Cleanup settings saved.'); + } catch (err) { + toast.error(err.message || 'Failed to save cleanup settings.'); + } finally { + setSaving(false); + } + } + + async function handleRunNow() { + setRunning(true); + try { + const result = await api.runAdminCleanup(); + setStatus(prev => ({ + ...prev, + last_run_at: result.ran_at, + last_result: result.tasks, + })); + toast.success('Cleanup tasks completed.'); + } catch (err) { + toast.error(err.message || 'Cleanup run failed.'); + } finally { + setRunning(false); + } + } + + function resultLine(label, task, countKey) { + if (!task || task[countKey] == null) return null; + return `${label}: ${task[countKey]}`; + } + + const resultLines = status?.last_result ? [ + resultLine('Import sessions pruned', status.last_result.import_sessions, 'pruned'), + resultLine('Temp export files removed', status.last_result.temp_export_files, 'removed'), + resultLine('Backup partials removed', status.last_result.backup_partials, 'removed'), + resultLine('Import history rows pruned', status.last_result.import_history, 'pruned'), + ].filter(Boolean) : []; + + if (loading) { + return ( + + + Loading cleanup settings… + + + ); + } + + return ( + + +
+
+ +
+ Cleanup / Maintenance +

+ Automatic daily cleanup: expired import sessions, stale export temp files, orphaned backup partials. Runs at 6:00 AM. +

+
+
+ + + + Auto + + Cleanup runs automatically at 6:00 AM daily + + +
+
+ + +
+
+

Last Run

+

{status?.last_run_at ? formatDateTime(status.last_run_at) : '—'}

+
+
+

Last Result

+ {resultLines.length > 0 ? ( +
    + {resultLines.map(line => ( +
  • {line}
  • + ))} +
+ ) : ( +

No runs recorded yet

+ )} +
+
+ +
+

Task Settings

+ {[ + ['import_sessions_enabled', 'Prune expired import sessions (24h TTL)'], + ['temp_exports_enabled', 'Remove stale SQLite export temp files'], + ['backup_partials_enabled', 'Remove orphaned backup .partial / .upload files'], + ].map(([key, label]) => ( + + set(key, v)} label={label} /> + + ))} + + + set('temp_export_max_age_hours', parseInt(e.target.value, 10) || 2)} + disabled={!form.temp_exports_enabled} + className="max-w-[120px] h-8 text-sm" + /> + + + + set('import_history_enabled', v)} label="Trim import history rows" /> + + + {form.import_history_enabled && ( + <> + + set('import_history_max_age_days', parseInt(e.target.value, 10) || 365)} + className="max-w-[120px] h-8 text-sm" + /> + +
+ Warning: Import history trimming permanently deletes audit records older than {form.import_history_max_age_days} days. This cannot be undone. +
+ + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/client/components/admin/EmailNotifCard.jsx b/client/components/admin/EmailNotifCard.jsx new file mode 100644 index 0000000..45ea319 --- /dev/null +++ b/client/components/admin/EmailNotifCard.jsx @@ -0,0 +1,209 @@ +import React, { useState, useEffect } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { + Select, SelectTrigger, SelectValue, SelectContent, SelectItem, +} from '@/components/ui/select'; +import { SectionHeading, FieldRow, Toggle } from './adminShared'; + +export default function EmailNotifCard() { + const DEFAULTS = { + enabled: false, + sender_name: '', sender_address: '', + smtp_host: '', smtp_port: '587', smtp_encryption: 'starttls', + smtp_self_signed: false, + smtp_username: '', smtp_password: '', + allow_user_config: false, + global_recipient: '', + }; + + const [cfg, setCfg] = useState(DEFAULTS); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [saving, setSaving] = useState(false); + const [showPw, setShowPw] = useState(false); + const [testEmail, setTestEmail] = useState(''); + const [testing, setTesting] = useState(false); + + useEffect(() => { + api.notifAdmin() + .then(d => setCfg({ ...DEFAULTS, ...d })) + .catch(err => setLoadError(err.message || 'Failed to load email settings')) + .finally(() => setLoading(false)); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const set = (k, v) => setCfg(p => ({ ...p, [k]: v })); + + const handleSave = async () => { + setSaving(true); + try { + await api.saveNotifAdmin(cfg); + toast.success('Email settings saved.'); + } catch (err) { + toast.error(err.message || 'Failed to save.'); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + if (!testEmail) { toast.error('Enter a recipient email.'); return; } + setTesting(true); + try { + await api.testEmail({ to: testEmail }); + toast.success('Test email sent.'); + } catch (err) { + toast.error(err.message || 'Failed to send test email.'); + } finally { + setTesting(false); + } + }; + + if (loading) return Loading…; + if (loadError) return {loadError}; + + return ( + + + Email Notifications + + +
+
+

Enable email notifications

+

Configure SMTP to send bill reminders

+
+ set('enabled', v)} label="Enable email notifications" /> +
+ +
+ +
+ Sender + + set('sender_name', e.target.value)} placeholder="BillTracker" /> + + + set('sender_address', e.target.value)} placeholder="no-reply@example.com" type="email" /> + +
+ +
+ +
+ SMTP Server + + set('smtp_host', e.target.value)} placeholder="smtp.example.com" /> + + + set('smtp_port', e.target.value)} placeholder="587" type="number" className="w-28" /> + + + + + +
+ set('smtp_self_signed', e.target.checked)} + className="h-4 w-4 rounded border-input bg-input accent-primary" + /> + +
+
+ + set('smtp_username', e.target.value)} placeholder="user@example.com" /> + + +
+ set('smtp_password', e.target.value)} + placeholder="••••••••" + className="pr-9" + /> + +
+
+
+ +
+ +
+ User Access + +
+ set('allow_user_config', e.target.checked)} + className="h-4 w-4 rounded border-input bg-input accent-primary" + /> + +
+
+ + set('global_recipient', e.target.value)} + placeholder="recipient@example.com" + type="email" + /> + +
+ +
+ +
+ Test Email + +
+ setTestEmail(e.target.value)} + placeholder="you@example.com" + type="email" + /> + +
+
+
+ +
+ +
+ + + ); +} diff --git a/client/components/admin/LoginModeCard.jsx b/client/components/admin/LoginModeCard.jsx new file mode 100644 index 0000000..7156c2c --- /dev/null +++ b/client/components/admin/LoginModeCard.jsx @@ -0,0 +1,244 @@ +import React, { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { + Select, SelectTrigger, SelectValue, SelectContent, SelectItem, +} from '@/components/ui/select'; +import { + AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, +} from '@/components/ui/alert-dialog'; +import { LogIn, UserCheck, ShieldCheck } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +export default function LoginModeCard({ users, onModeChange }) { + const [modeData, setModeData] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [saving, setSaving] = useState(false); + const [selected, setSelected] = useState('multi'); // local UI selection + const [selectedUser, setSelectedUser] = useState(''); + const [confirmSingle, setConfirmSingle] = useState(false); + + useEffect(() => { + api.authModeConfig() + .then(d => { + setModeData(d); + const mode = d.auth_mode === 'single' ? 'single' : 'multi'; + setSelected(mode); + setSelectedUser(d.default_user_id?.toString() || ''); + onModeChange?.(mode); + }) + .catch(err => setLoadError(err.message || 'Failed to load login mode config')) + .finally(() => setLoading(false)); + }, []); // eslint-disable-line + + const doSetMode = async (mode, userId) => { + setSaving(true); + try { + await api.setAuthMode({ + auth_mode: mode, + default_user_id: mode === 'single' ? parseInt(userId, 10) : null, + }); + const d = await api.authModeConfig(); + setModeData(d); + const resolved = d.auth_mode === 'single' ? 'single' : 'multi'; + setSelected(resolved); + setSelectedUser(d.default_user_id?.toString() || ''); + onModeChange?.(resolved); + toast.success(mode === 'single' ? 'No Login mode enabled.' : 'Login requirement restored.'); + } catch (err) { + toast.error(err.message || 'Failed to update login mode.'); + // revert UI selection on error + setSelected(modeData?.auth_mode === 'single' ? 'single' : 'multi'); + } finally { + setSaving(false); + } + }; + + const handleSave = () => { + if (selected === 'single') { + if (!selectedUser) { toast.error('Select a user account first.'); return; } + setConfirmSingle(true); + } else { + doSetMode('multi', null); + } + }; + + if (loading) return Loading…; + if (loadError) return {loadError}; + + const currentMode = modeData?.auth_mode === 'single' ? 'single' : 'multi'; + const isDirty = selected !== currentMode || (selected === 'single' && selectedUser !== (modeData?.default_user_id?.toString() || '')); + const activeUser = users?.find(u => u.id === modeData?.default_user_id); + const pendingUsername = users?.find(u => u.id.toString() === selectedUser)?.username ?? selectedUser; + const regularUsers = (users || []).filter(u => u.role === 'user'); + + return ( + <> + + +
+
+ Login Mode +

+ Choose how users access this app. +

+
+ + + + + {currentMode === 'single' ? 'No Login' : 'Require Login'} + + + + {currentMode === 'single' + ? 'Anyone who opens the app is automatically signed in' + : 'Users must authenticate to access the app'} + + + +
+
+ + + {/* Option: Require Login */} + + + {/* Option: No Login */} + + + {/* User selector — shown only when No Login is selected */} + {selected === 'single' && ( +
+ + + {regularUsers.length === 0 && ( +

No regular user accounts found. Create a user account first.

+ )} +
+ )} + + {/* Security note for single mode */} + {selected === 'single' && ( +
+ +

+ Only use this on trusted private networks. Anyone with access to the URL is signed in automatically. +

+
+ )} + + {isDirty && ( + + )} +
+
+ + + + + Enable No Login Mode? + + Anyone who opens the app will be automatically signed in as{' '} + {pendingUsername}. + The admin login still requires a password. + + + + Cancel + { setConfirmSingle(false); doSetMode('single', selectedUser); }}> + Enable No Login Mode + + + + + + ); +} diff --git a/client/components/admin/OnboardingWizard.jsx b/client/components/admin/OnboardingWizard.jsx new file mode 100644 index 0000000..7944df2 --- /dev/null +++ b/client/components/admin/OnboardingWizard.jsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +export default function OnboardingWizard({ onComplete }) { + const [step, setStep] = useState(0); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleCreate = async (e) => { + e.preventDefault(); + setError(''); + + let validationError = ''; + if (password !== confirm) { + validationError = 'Passwords do not match.'; + } else if (password.length < 8) { + validationError = 'Password must be at least 8 characters.'; + } + + if (validationError) { + setError(validationError); + toast.error(validationError); + return; + } + + setLoading(true); + try { + await api.createUser({ username, password }); + toast.success('User created successfully.'); + onComplete(); + } catch (err) { + const errorMessage = err.message || 'Failed to create user.'; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ {[0, 1].map(i => ( + + ))} +
+ + {step === 0 && ( + + + Welcome, Administrator +

+ Before creating your first user, please understand what your admin account can and cannot do. +

+
+ +
+ {[ + { can: true, text: 'Create and manage user accounts' }, + { can: true, text: 'Reset user passwords' }, + { can: true, text: 'Configure email notifications' }, + { can: true, text: 'Toggle single-user / multi-user mode' }, + { can: false, text: 'Cannot view bills or financial data' }, + { can: false, text: 'Cannot access user settings or history' }, + ].map(({ can, text }) => ( +
+ + {can ? '✓' : '✗'} + + {text} +
+ ))} +
+
+ +
+
+
+ )} + + {step === 1 && ( + + + Create first user +

+ This account will be used to access the bill tracker. +

+
+ +
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + setConfirm(e.target.value)} + required + /> +
+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+
+
+ )} +
+
+ ); +} diff --git a/client/components/admin/PrivacyAdminCard.jsx b/client/components/admin/PrivacyAdminCard.jsx new file mode 100644 index 0000000..2fffc99 --- /dev/null +++ b/client/components/admin/PrivacyAdminCard.jsx @@ -0,0 +1,59 @@ +import React, { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { FieldRow, Toggle } from './adminShared'; + +export default function PrivacyAdminCard() { + const [geoEnabled, setGeoEnabled] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + api.privacySettings() + .then(d => { setGeoEnabled(!!d.geolocation_enabled); }) + .catch(() => toast.error('Failed to load privacy settings')) + .finally(() => setLoading(false)); + }, []); + + async function toggleGeo(next) { + setSaving(true); + try { + const d = await api.setPrivacySettings({ geolocation_enabled: next }); + setGeoEnabled(!!d.geolocation_enabled); + toast.success(next ? 'Geolocation enabled' : 'Geolocation disabled'); + } catch { + toast.error('Failed to update privacy settings'); + } finally { + setSaving(false); + } + } + + return ( + + + Privacy + + + +
+ + + {geoEnabled ? 'On' : 'Off (default)'} + +
+
+

+ When enabled, new-device logins resolve the login IP to a city/region via{' '} + ip-api.com over plain HTTP. Disable to keep + all login data on-device. Location data is encrypted at rest. +

+
+
+ ); +} diff --git a/client/components/admin/UsersTable.jsx b/client/components/admin/UsersTable.jsx new file mode 100644 index 0000000..2c74c27 --- /dev/null +++ b/client/components/admin/UsersTable.jsx @@ -0,0 +1,237 @@ +import React, { useState } from 'react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { cn } from '@/lib/utils'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, +} from '@/components/ui/alert-dialog'; + +export default function UsersTable({ users, onRefresh, currentUser }) { + const [resetForms, setResetForms] = useState({}); + const [deleting, setDeleting] = useState(null); + const [resetting, setResetting] = useState(null); + const [roleUpdating, setRoleUpdating] = useState(null); + const [activeUpdating, setActiveUpdating] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const setReset = (id, v) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } })); + const getForm = (id) => resetForms[id] || { pw: '', open: false }; + + const handleReset = async (user) => { + const form = getForm(user.id); + if (!form.pw || form.pw.length < 8) { toast.error('Password must be at least 8 characters.'); return; } + setResetting(user.id); + try { + await api.resetPassword(user.id, { password: form.pw }); + toast.success(`Password reset for ${user.username}.`); + setReset(user.id, { pw: '', open: false }); + } catch (err) { + toast.error(err.message || 'Failed to reset password.'); + } finally { + setResetting(null); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(deleteTarget.id); + try { + await api.deleteUser(deleteTarget.id); + toast.success(`User ${deleteTarget.username} deleted.`); + setDeleteTarget(null); + onRefresh(); + } catch (err) { + toast.error(err.message || 'Failed to delete user.'); + } finally { + setDeleting(null); + } + }; + + const handleRoleChange = async (user, role) => { + if (user.role === role) return; + setRoleUpdating(user.id); + try { + await api.updateUserRole(user.id, { role }); + toast.success(`${user.username} is now ${role === 'admin' ? 'an admin' : 'a user'}.`); + onRefresh(); + } catch (err) { + toast.error(err.message || 'Failed to update user role.'); + } finally { + setRoleUpdating(null); + } + }; + + const handleActiveChange = async (user, active) => { + setActiveUpdating(user.id); + try { + await api.updateUserActive(user.id, { active }); + toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`); + onRefresh(); + } catch (err) { + toast.error(err.message || 'Failed to update user status.'); + } finally { + setActiveUpdating(null); + } + }; + + return ( + <> + + + Users + + +
+ + + + + + + + + + + + {(users || []).map(user => { + const form = getForm(user.id); + const isSelf = currentUser?.id === user.id; + return ( + + + + + + + + + ); + })} + {!users?.length && ( + + + + )} + +
UsernameRoleStatusPasswordReset Password +
+
+ {user.username} + {user.is_default_admin && ( + + + + default admin + + Initial admin account created during setup + + + )} +
+
+
+ + + + {user.role} + + {user.role === 'admin' ? 'Full access including admin panel' : 'Standard user — no admin access'} + + + +
+
+ + + {user.must_change_password ? ( + + + + Temporary + + User must change their password on next login + + + ) : ( + Set + )} + + {form.open ? ( +
+ setReset(user.id, { pw: e.target.value })} + className="h-8 text-sm w-36" + /> + + +
+ ) : ( + + )} +
+ {!isSelf && ( + + )} +
No users found.
+
+
+
+ + { if (!open) setDeleteTarget(null); }}> + + + Delete {deleteTarget?.username}? + + This is permanent in 2026. The user account and all user-owned data will be deleted, including bills, + payments, categories, monthly state, monthly starting amounts, imports, import history, and sessions. + This cannot be undone from BillTracker. + + + + Cancel + + {deleting ? 'Deleting…' : 'Delete User'} + + + + + + ); +} diff --git a/client/components/admin/adminShared.jsx b/client/components/admin/adminShared.jsx new file mode 100644 index 0000000..0c99fb2 --- /dev/null +++ b/client/components/admin/adminShared.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; + +export function SectionHeading({ children }) { + return

{children}

; +} + +export function FieldRow({ label, children }) { + return ( +
+ + {children} +
+ ); +} + +export function Toggle({ checked, onChange, label, disabled = false }) { + return ( + + ); +} + +export function formatDateTime(value) { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +export function BackupTypeBadge({ type }) { + const cls = { + manual: 'bg-blue-500/15 text-blue-400 border-blue-500/20', + scheduled: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20', + imported: 'bg-violet-500/15 text-violet-400 border-violet-500/20', + 'pre-restore': 'bg-amber-500/15 text-amber-400 border-amber-500/20', + }[type] || 'bg-muted text-muted-foreground border-border'; + + return {type || 'backup'}; +} diff --git a/client/components/bill-modal/AutopayTrustIndicator.jsx b/client/components/bill-modal/AutopayTrustIndicator.jsx new file mode 100644 index 0000000..9fea6c6 --- /dev/null +++ b/client/components/bill-modal/AutopayTrustIndicator.jsx @@ -0,0 +1,49 @@ +import { cn } from '@/lib/utils'; + +// Edit-mode autopay trust panel: success rate over the last 12 months, a +// "Mark verified" action, and staleness / failure warnings. Presentational — +// the parent owns the verify handler and the optimistic verified-at date. +export default function AutopayTrustIndicator({ isNew, autopay, stats, verifiedAt, onVerify }) { + if (isNew || !autopay) return null; + + const total = stats?.total ?? 0; + const failures = stats?.failures ?? 0; + const daysSince = verifiedAt + ? Math.floor((Date.now() - verifiedAt.getTime()) / 86400000) + : null; + const needsVerify = daysSince === null || daysSince > 90; + + return ( +
+
+ 0 ? 'text-amber-500' : total > 0 ? 'text-emerald-500' : 'text-muted-foreground/60')}> + {total > 0 + ? `${failures > 0 ? '⚠' : '✓'} ${total - failures}/${total} successful (12 mo)` + : 'No payment history yet'} + + +
+ {needsVerify && ( +

+ {daysSince === null + ? "Autopay never confirmed — verify it's still active." + : `Last verified ${daysSince}d ago — confirm autopay is still on.`} +

+ )} + {!needsVerify && ( +

Verified {daysSince}d ago

+ )} + {failures > 0 && stats?.last_failure_date && ( +

+ Last failure: {stats.last_failure_date}{stats?.last_failure_notes ? ` — ${stats.last_failure_notes}` : ''} +

+ )} +
+ ); +} diff --git a/client/components/bill-modal/DebtDetailsSection.jsx b/client/components/bill-modal/DebtDetailsSection.jsx new file mode 100644 index 0000000..ccf89ac --- /dev/null +++ b/client/components/bill-modal/DebtDetailsSection.jsx @@ -0,0 +1,130 @@ +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; + +// Collapsible Debt / Snowball fields (interest rate, current balance, minimum +// payment, snowball visibility). State lives in the parent BillModal (the save +// action reads these values); this is a presentational extraction. +export default function DebtDetailsSection({ + inp, + errors, setErrors, + showDebtSection, setShowDebtSection, + isSnowballCategory, showOnSnowball, + interestRate, setInterestRate, + currentBalance, setCurrentBalance, + minimumPayment, setMinimumPayment, + validateInterestRate, validateCurrentBalance, validateMinimumPayment, + handleBlur, + onSnowballVisibilityChange, +}) { + return ( +
+
+
+ +
+
+ + {showDebtSection && ( +
+ + {/* Interest Rate */} +
+ + { + setInterestRate(e.target.value); + setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300); + }} + onBlur={() => handleBlur('interestRate', interestRate, validateInterestRate)} + /> + {errors.interestRate && ( + {errors.interestRate} + )} +

Enter 29.99 for 29.99%.

+
+ + {/* Current Balance */} +
+ + { + setCurrentBalance(e.target.value); + setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300); + }} + onBlur={() => handleBlur('currentBalance', currentBalance, validateCurrentBalance)} + /> + {errors.currentBalance && ( + {errors.currentBalance} + )} +

Outstanding debt balance.

+
+ + {/* Minimum Payment */} +
+ + { + setMinimumPayment(e.target.value); + setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300); + }} + onBlur={() => handleBlur('minimumPayment', minimumPayment, validateMinimumPayment)} + /> + {errors.minimumPayment && ( + {errors.minimumPayment} + )} +

Required minimum monthly payment.

+
+ + {/* Include in Snowball */} +
+ +

+ Uncheck to exempt an auto-detected snowball bill, or check to include this bill manually. +

+
+ +
+ )} +
+ ); +} diff --git a/client/components/bill-modal/LinkedTransactionsSection.jsx b/client/components/bill-modal/LinkedTransactionsSection.jsx new file mode 100644 index 0000000..7db71bc --- /dev/null +++ b/client/components/bill-modal/LinkedTransactionsSection.jsx @@ -0,0 +1,131 @@ +import { Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react'; +import { cn, fmtDate } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import BillMerchantRules from '@/components/BillMerchantRules'; +import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay'; + +// Bank-matching rules (with a Sync-now action) + the list of transactions +// confirmed as matched to this bill (each with an Unmatch action). Presentational +// — the parent owns the sync/rules-changed handlers and the unmatch state. +export default function LinkedTransactionsSection({ + isNew, + billId, + billName, + localHasRules, + syncingPayments, + onSync, + onRulesChanged, + linkedTransactions, + linkedTransactionsLoading, + transactionBusyId, + onUnmatch, +}) { + return ( + <> + {/* Bank Matching Rules */} + {!isNew && ( +
+
+
+

Bank matching rules

+

+ Transactions whose description contains these patterns are automatically imported as payments. +

+
+ {localHasRules && ( + + )} +
+
+ +
+
+ )} + +
+
+
+

Linked transactions

+

{linkedTransactions.length} confirmed matches

+
+ 0 + ? 'border-primary/20 bg-primary/5 text-primary' + : 'border-border/60 bg-muted/30 text-muted-foreground', + )}> + + {linkedTransactions.length} + +
+ + {linkedTransactionsLoading ? ( +
+ Loading linked transactions... +
+ ) : linkedTransactions.length === 0 ? ( +
+ + No transactions linked to this bill yet. +
+ ) : ( +
+ {linkedTransactions.map(transaction => ( +
+
+
+

{transactionTitle(transaction)}

+ + {transaction.source_label || transaction.source_type_label || 'Transaction'} + +
+

+ {transactionDate(transaction) ? fmtDate(transactionDate(transaction)) : 'No date'} · {transaction.description || transaction.memo || 'No description'} +

+ {transaction.account_name && ( +

{transaction.account_name}

+ )} +
+
+

+ {fmtTransactionAmount(transaction.amount, transaction.currency)} +

+ +
+
+ ))} +
+ )} +
+ + ); +} diff --git a/client/components/bill-modal/PaymentFormFields.jsx b/client/components/bill-modal/PaymentFormFields.jsx new file mode 100644 index 0000000..e439670 --- /dev/null +++ b/client/components/bill-modal/PaymentFormFields.jsx @@ -0,0 +1,103 @@ +import { cn } from '@/lib/utils'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; + +const PAYMENT_METHODS = [ + ['manual', 'Manual'], + ['bank', 'Bank Transfer'], + ['card', 'Card'], + ['autopay', 'Autopay'], + ['check', 'Check'], + ['cash', 'Cash'], +]; + +// Add/edit form for a manual payment on a bill (edit mode). Presentational — +// the parent owns the form state, the submit handler, and open/close. +export default function PaymentFormFields({ + inp, + editingPayment, + paymentBusy, + amount, setAmount, + date, setDate, + method, setMethod, + notes, setNotes, + onSubmit, + onCancel, +}) { + return ( +
+
+

+ {editingPayment ? 'Edit payment' : 'Add payment'} +

+ + manual + +
+
+
+ + setAmount(e.target.value)} + className={cn(inp, 'font-mono')} + required + /> +
+
+ + setDate(e.target.value)} + className={cn(inp, 'font-mono')} + required + /> +
+
+ + +
+
+ + setNotes(e.target.value)} + className={inp} + placeholder="Paid from checking" + /> +
+
+
+ + +
+
+ ); +} diff --git a/client/components/bill-modal/PaymentHistoryList.jsx b/client/components/bill-modal/PaymentHistoryList.jsx new file mode 100644 index 0000000..52a3a6a --- /dev/null +++ b/client/components/bill-modal/PaymentHistoryList.jsx @@ -0,0 +1,131 @@ +import { Plus, Link2, Pencil, Trash2 } from 'lucide-react'; +import { cn, fmt, fmtDate } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +function isTransactionLinkedPayment(payment) { + return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null; +} + +function isHistoryOnlyPayment(payment) { + return !!payment?.accounting_excluded; +} + +function paymentSourceLabel(source) { + const labels = { + manual: 'Manual', + file_import: 'File import', + provider_sync: 'Sync', + transaction_match: 'Transaction', + auto_match: 'SimpleFIN', + }; + return labels[source] || source || 'Manual'; +} + +function paymentSourceTone(source) { + const tones = { + manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400', + provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400', + transaction_match: 'border-primary/25 bg-primary/10 text-primary', + auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400', + }; + return tones[source] || tones.manual; +} + +// Payment-history list for a bill (edit mode): each recorded payment with its +// source badge, plus edit/remove actions for manual payments (matched and +// history-only rows are read-only). Presentational — the parent owns the +// payment state and the add/edit/delete handlers. +export default function PaymentHistoryList({ + payments, + paymentsLoading, + paymentBusy, + onAdd, + onEdit, + onDelete, +}) { + return ( + <> +
+
+

Payment history

+

{payments.length} recorded

+
+ +
+ + {paymentsLoading ? ( +
+ Loading payment history... +
+ ) : payments.length === 0 ? ( +
+

No payments yet

+

Use the form below to record the first payment.

+
+ ) : ( +
+ {payments.map(payment => { + const linkedPayment = isTransactionLinkedPayment(payment); + const historyOnly = isHistoryOnlyPayment(payment); + return ( +
+
+
+

{fmt(payment.amount)}

+ + {paymentSourceLabel(payment.payment_source)} + + {historyOnly && ( + + History only + + )} +
+

+ {fmtDate(payment.paid_date)} · {payment.method || (payment.payment_source === 'transaction_match' ? 'Synced' : 'manual')} +

+ {payment.notes && ( +

{payment.notes}

+ )} +
+
+ {historyOnly ? ( + + Overridden + + ) : linkedPayment ? ( + + + Matched + + ) : ( + <> + + + + )} +
+
+ ); + })} +
+ )} + + ); +} diff --git a/client/components/bill-modal/TemplateSection.jsx b/client/components/bill-modal/TemplateSection.jsx new file mode 100644 index 0000000..af36b5b --- /dev/null +++ b/client/components/bill-modal/TemplateSection.jsx @@ -0,0 +1,38 @@ +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; + +// "Save this setup as a reusable template" toggle + optional template name. +// Presentational — the parent owns the state and performs the save. +export default function TemplateSection({ + inp, + saveTemplate, setSaveTemplate, + templateName, setTemplateName, + namePlaceholder, +}) { + return ( +
+ + {saveTemplate && ( +
+ + setTemplateName(e.target.value)} + placeholder={namePlaceholder || 'My bill template'} + /> +
+ )} +
+ ); +} diff --git a/client/components/bill-modal/UnmatchDialogs.jsx b/client/components/bill-modal/UnmatchDialogs.jsx new file mode 100644 index 0000000..1945f1d --- /dev/null +++ b/client/components/bill-modal/UnmatchDialogs.jsx @@ -0,0 +1,261 @@ +import { Link2Off, Layers } from 'lucide-react'; +import { cn, fmtDate } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from '@/components/ui/dialog'; +import { + AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, +} from '@/components/ui/alert-dialog'; +import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay'; + +// The three unmatch flows for a linked bank transaction: the choice dialog +// (single vs. review-similar), the single-unmatch confirm, and the bulk review +// dialog (with optional merchant-rule removal). Presentational — the parent owns +// the unmatch state and the confirm/bulk handlers. +export default function UnmatchDialogs({ + unmatchTarget, + bulkUnmatch, setBulkUnmatch, + unmatchConfirmOpen, setUnmatchConfirmOpen, + transactionBusyId, + bulkBusy, + closeUnmatch, + onSingleUnmatch, + onOpenBulkUnmatch, + onBulkConfirm, +}) { + return ( + <> + {/* ── Unmatch choice dialog ─────────────────────────────────── */} + { if (!open) closeUnmatch(); }} + > + + + Unmatch transaction + + How would you like to proceed? + + + + {unmatchTarget && ( +
+

{transactionTitle(unmatchTarget)}

+

+ {transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'} + {' · '} + {fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)} +

+
+ )} + +
+ + +
+ + + + +
+
+ + {/* ── Single unmatch confirm ────────────────────────────────── */} + { if (!open) setUnmatchConfirmOpen(false); }} + > + + + Unmatch this transaction? + + {unmatchTarget && ( + <> + {transactionTitle(unmatchTarget)} + {' '}will be unlinked from this bill. + {unmatchTarget.linked_payment?.payment_source === 'provider_sync' + ? ' The payment record will be removed and the balance restored.' + : ' The payment record will be removed.'} + + )} + + + + setUnmatchConfirmOpen(false)}> + Cancel + + + {transactionBusyId ? 'Unmatching…' : 'Confirm Unmatch'} + + + + + + {/* ── Bulk unmatch dialog ───────────────────────────────────── */} + { if (!open) closeUnmatch(); }} + > + + + Review similar matches + {unmatchTarget && ( + + Transactions similar to {transactionTitle(unmatchTarget)}. + Uncheck any you want to keep matched. + + )} + + + {bulkUnmatch && ( + <> +
+ Quick select: + + + + {bulkUnmatch.checkedIds.size} of {bulkUnmatch.similar.length} selected + +
+ +
+ {bulkUnmatch.similar.map(tx => ( + + ))} +
+ + {bulkUnmatch.rules.length > 0 && ( +
+

+ Merchant rules +

+ {bulkUnmatch.rules.map(rule => ( + + ))} +
+ )} + + )} + + + + + + +
+
+ + ); +} diff --git a/client/components/bill-modal/transactionDisplay.js b/client/components/bill-modal/transactionDisplay.js new file mode 100644 index 0000000..9c24b92 --- /dev/null +++ b/client/components/bill-modal/transactionDisplay.js @@ -0,0 +1,31 @@ +import { formatCentsUSD } from '@/lib/money'; + +// Display + matching helpers for bank transactions in the bill modal. Shared by +// the linked-transaction list, the unmatch dialogs, and the bulk-unmatch payee +// grouping. + +export function fmtTransactionAmount(amount, currency = 'USD') { + return formatCentsUSD(amount, { signed: true, currency }); +} + +export function transactionDate(tx) { + return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null; +} + +export function transactionTitle(tx) { + return tx?.payee || tx?.description || tx?.memo || 'Transaction'; +} + +export function normalizePayee(s) { + return (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +// Two payees are "similar" when one normalized name is a prefix of the other +// (min 3 chars) — the grouping used to find related matches to unmatch together. +export function isSimilarPayee(a, b) { + const na = normalizePayee(a); + const nb = normalizePayee(b); + const minLen = Math.min(na.length, nb.length); + if (minLen < 3) return false; + return na.startsWith(nb) || nb.startsWith(na); +} diff --git a/client/components/data/AutoMatchReview.jsx b/client/components/data/AutoMatchReview.jsx new file mode 100644 index 0000000..38e39f0 --- /dev/null +++ b/client/components/data/AutoMatchReview.jsx @@ -0,0 +1,123 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Undo2, ChevronDown, ChevronRight } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { formatUSD } from '@/lib/money'; + +function fmtAmt(dollars) { + return formatUSD(dollars, { dash: true }); +} + +function fmtDate(iso) { + if (!iso) return ''; + const d = new Date(`${iso}T00:00:00`); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function payeeLabel(row) { + return row.payee || row.description || `Transaction #${row.transaction_id}`; +} + +export default function AutoMatchReview({ refreshKey }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(true); + const [undoing, setUndoing] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const rows = await api.recentAutoMatched(); + setItems(Array.isArray(rows) ? rows : []); + } catch { + // Non-blocking — don't surface errors for a review panel + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load, refreshKey]); + + async function handleUndo(item) { + setUndoing(item.id); + try { + await api.undoAutoMatch(item.id); + setItems(prev => prev.filter(p => p.id !== item.id)); + toast.success(`Undone — ${payeeLabel(item)} unlinked from "${item.bill_name}"`); + } catch (err) { + toast.error(err.message || 'Failed to undo auto-match'); + } finally { + setUndoing(null); + } + } + + if (!loading && items.length === 0) return null; + + return ( +
+ + + {open && ( +
+ {loading && items.length === 0 ? ( +

Loading…

+ ) : items.map(item => ( +
+
+
+ {payeeLabel(item)} + {item.paid_date && ( + {fmtDate(item.paid_date)} + )} +
+

+ {fmtAmt(item.amount)} + {' → '} + {item.bill_name} +

+
+ +
+ ))} +
+

+ Payments auto-created from merchant rules in the last 7 days. Undo removes the payment and restores the bill balance. +

+
+
+ )} +
+ ); +} diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx new file mode 100644 index 0000000..3a5b0ef --- /dev/null +++ b/client/components/data/BankSyncSection.jsx @@ -0,0 +1,1006 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { toast } from 'sonner'; +import { + AlertTriangle, Building2, ChevronDown, ChevronRight, + Eye, EyeOff, ExternalLink, History, Landmark, Link2Off, Loader2, RefreshCw, Unlink, +} from 'lucide-react'; +import { api } from '@/api'; +import { cn } from '@/lib/utils'; +import { formatUSD, formatCentsUSD } from '@/lib/money'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { SectionCard } from './dataShared'; +import AutoMatchReview from './AutoMatchReview'; + +function TokenInput({ value, onChange, disabled }) { + const [show, setShow] = useState(false); + const tail = value.slice(-4); + return ( +
+
+ + {value && ( + + )} +
+ {value && !show && ( +

+ ···{tail} +

+ )} +
+ ); +} + +function parseUtc(str) { + if (!str) return null; + const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; + return new Date(normalized); +} + +function fmtDate(iso) { + if (!iso) return '—'; + return parseUtc(iso).toLocaleString(undefined, { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); +} + +function fmtShortDate(date) { + if (!date) return '—'; + const d = new Date(`${date}T00:00:00`); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function fmtDollars(cents) { + return formatCentsUSD(cents, { dash: true }); +} + +function MatchBadge({ status, billName }) { + if (status === 'matched') { + return ( + + {billName || 'matched'} + + ); + } + if (status === 'ignored') { + return ignored; + } + return unmatched; +} + +function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }) { + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); + + useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q)); + }, [bills, search]); + + const txDate = transaction?.posted_date || transaction?.transacted_at?.slice(0, 10); + const txLabel = transaction?.payee || transaction?.description || '—'; + const txAmt = transaction ? fmtDollars(transaction.amount) : ''; + + return ( + { if (!v) onClose(); }}> + + + Match to bill +

+ {txLabel} + {txDate && {fmtShortDate(txDate)}} + {txAmt} +

+

+ A payment record will be created for the selected bill using this transaction's amount and date. +

+
+ + setSearch(e.target.value)} + className="text-sm" + /> + +
+ {filtered.length === 0 ? ( +

No bills found.

+ ) : filtered.map(bill => ( + + ))} +
+ + + + + +
+
+ ); +} + +function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling, bills, onMatch, onUnmatch, matchingTxId }) { + const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10); + + return ( +
+
+ e.stopPropagation()}> + {account.monitored && (expanded + ? + : )} + + + {/* Monitored toggle */} +
e.stopPropagation()}> + onToggleMonitored(account.id, v)} + disabled={toggling} + aria-label={`Monitor ${account.name}`} + /> +
+ + {/* Account name */} +
+

+ {account.name} +

+ {account.org_name && ( +

{account.org_name}

+ )} +
+ + {/* Balance */} +
+

+ {fmtDollars(account.balance)} +

+ {txDate && ( +

last tx {fmtShortDate(txDate)}

+ )} +
+ + {/* Tx count */} +
+

+ {account.transaction_count} tx +

+ {!account.monitored && ( +

skipped

+ )} +
+
+ + {expanded && account.monitored && ( +
+ {account.transactions.length === 0 ? ( +

No transactions synced for this account.

+ ) : ( +
+ + + + + + + + + + + {account.transactions.map(tx => ( + + + + + + + ))} + +
DatePayee / DescriptionAmountBill
+ {fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))} + +

{tx.payee || tx.description || '—'}

+ {tx.payee && tx.description && tx.payee !== tx.description && ( +

{tx.description}

+ )} +
+ {fmtDollars(tx.amount)} + + {tx.match_status === 'matched' ? ( +
+ + +
+ ) : tx.match_status === 'ignored' ? ( + + ) : ( + + )} +
+
+ )} +
+ )} +
+ ); +} + +export default function BankSyncSection({ onConnectionChange, cardProps = {} }) { + const [enabled, setEnabled] = useState(null); + const [syncDays, setSyncDays] = useState(30); + const [seedDays, setSeedDays] = useState(44); + const [serverTz, setServerTz] = useState(null); + const [connections, setConnections] = useState([]); + const [accountsBySource, setAccountsBySource] = useState({}); + const [accountsLoading, setAccountsLoading] = useState({}); + const [accountsErrorBySource, setAccountsError] = useState({}); + const [loadError, setLoadError] = useState(''); + const [setupToken, setSetupToken] = useState(''); + const [connecting, setConnecting] = useState(false); + const [syncing, setSyncing] = useState(null); + const [backfilling, setBackfilling] = useState(null); + const [disconnectTarget, setDisconnectTarget] = useState(null); + const [disconnecting, setDisconnecting] = useState(false); + const [expandedAccount, setExpandedAccount] = useState(null); + const [togglingAccount, setTogglingAccount] = useState(null); + const [bills, setBills] = useState([]); + const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx } + const [matchingTxId, setMatchingTxId] = useState(null); + const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0); + + // Bank tracking state + const [btEnabled, setBtEnabled] = useState(false); + const [btAccountId, setBtAccountId] = useState(''); + const [btPendingDays, setBtPendingDays] = useState(3); + const [btLateGraceDays, setBtLateGraceDays] = useState(0); + const [btAccounts, setBtAccounts] = useState([]); + const [btSaving, setBtSaving] = useState(false); + + // Auto-categorization state + const [acmEnabled, setAcmEnabled] = useState(true); + const [acmSaving, setAcmSaving] = useState(false); + + const loadAccounts = useCallback(async (conns) => { + for (const conn of conns) { + setAccountsLoading(prev => ({ ...prev, [conn.id]: true })); + setAccountsError(prev => ({ ...prev, [conn.id]: '' })); + try { + const accounts = await api.dataSourceAccounts(conn.id); + setAccountsBySource(prev => ({ ...prev, [conn.id]: accounts })); + } catch (err) { + setAccountsError(prev => ({ ...prev, [conn.id]: err.message || 'Failed to load accounts' })); + } finally { + setAccountsLoading(prev => ({ ...prev, [conn.id]: false })); + } + } + }, []); + + // Load bank tracking settings and available accounts + const loadBankTracking = useCallback(async () => { + try { + const [settings, accounts] = await Promise.all([ + api.settings(), + api.allFinancialAccounts().catch(() => []), + ]); + setBtEnabled(settings.bank_tracking_enabled === 'true'); + setBtAccountId(settings.bank_tracking_account_id || ''); + setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3); + setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0); + setBtAccounts(Array.isArray(accounts) ? accounts : []); + setAcmEnabled(settings.bank_auto_categorize_merchants !== 'false'); + } catch { + // non-fatal — bank tracking section just won't populate + } + }, []); + + const handleBtSave = useCallback(async (patch) => { + setBtSaving(true); + try { + const next = { + bank_tracking_enabled: String(patch.enabled ?? btEnabled), + bank_tracking_account_id: String(patch.accountId ?? btAccountId), + bank_tracking_pending_days: String(patch.pendingDays ?? btPendingDays), + bank_late_attribution_days: String(patch.lateGraceDays ?? btLateGraceDays), + }; + await api.saveSettings(next); + if (patch.enabled !== undefined) setBtEnabled(patch.enabled); + if (patch.accountId !== undefined) setBtAccountId(patch.accountId); + if (patch.pendingDays !== undefined) setBtPendingDays(patch.pendingDays); + if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays); + toast.success('Bank tracking settings saved'); + } catch (err) { + toast.error(err.message || 'Failed to save bank tracking settings'); + } finally { + setBtSaving(false); + } + }, [btEnabled, btAccountId, btPendingDays, btLateGraceDays]); + + const handleAcmSave = useCallback(async (next) => { + setAcmSaving(true); + try { + await api.saveSettings({ bank_auto_categorize_merchants: String(next) }); + setAcmEnabled(next); + toast.success('Auto-categorization setting saved'); + } catch (err) { + toast.error(err.message || 'Failed to save auto-categorization setting'); + } finally { + setAcmSaving(false); + } + }, []); + + const load = useCallback(async () => { + setLoadError(''); + try { + const [status, sources] = await Promise.all([ + api.simplefinStatus(), + api.dataSources({ type: 'provider_sync' }), + ]); + setEnabled(status.enabled); + setSyncDays(status.sync_days ?? 30); + setSeedDays(status.seed_days ?? 44); + setServerTz(status.timezone || null); + const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []; + setConnections(conns); + onConnectionChange?.(conns[0] || null); + if (conns.length > 0) loadAccounts(conns); + } catch (err) { + setEnabled(false); + setLoadError(err.message || 'Failed to load bank sync status'); + } + }, [onConnectionChange, loadAccounts]); + + useEffect(() => { load(); }, [load]); + useEffect(() => { loadBankTracking(); }, [loadBankTracking]); + + // Load bills once when connections become available (for the match picker) + useEffect(() => { + if (connections.length > 0 && bills.length === 0) { + api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[BankSyncSection] failed to load bills', err)); + } + }, [connections, bills.length]); + + function updateTxInState(sourceId, txId, updates) { + setAccountsBySource(prev => ({ + ...prev, + [sourceId]: (prev[sourceId] || []).map(acc => ({ + ...acc, + transactions: acc.transactions.map(tx => tx.id === txId ? { ...tx, ...updates } : tx), + })), + })); + } + + const handleMatch = (sourceId, tx) => setMatchTarget({ sourceId, tx }); + + const handleConfirmMatch = async (billId) => { + if (!matchTarget || !billId) return; + const { sourceId, tx } = matchTarget; + setMatchingTxId(tx.id); + try { + const { transaction } = await api.confirmTransactionMatch(tx.id, billId); + updateTxInState(sourceId, tx.id, { + match_status: transaction.match_status, + matched_bill_id: transaction.matched_bill_id, + matched_bill_name: transaction.matched_bill_name, + }); + setMatchTarget(null); + toast.success(`Matched to "${transaction.matched_bill_name}" — payment recorded for ${fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}.`); + } catch (err) { + toast.error(err.message || 'Failed to match transaction.'); + } finally { + setMatchingTxId(null); + } + }; + + const handleUnmatch = async (sourceId, tx) => { + setMatchingTxId(tx.id); + try { + await api.unmatchTransaction(tx.id); + updateTxInState(sourceId, tx.id, { match_status: 'unmatched', matched_bill_id: null, matched_bill_name: null }); + toast.success('Match removed.'); + } catch (err) { + toast.error(err.message || 'Failed to remove match.'); + } finally { + setMatchingTxId(null); + } + }; + + const handleConnect = async () => { + const token = setupToken.trim(); + if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; } + setConnecting(true); + try { + const result = await api.connectSimplefin(token); + toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`); + setSetupToken(''); + await load(); + } catch (err) { + toast.error(err.message || 'Failed to connect SimpleFIN'); + } finally { + setConnecting(false); + } + }; + + const handleSync = async (id) => { + setSyncing(id); + try { + const result = await api.syncDataSource(id); + if (result.errlist) { + toast.warning(`Synced ${result.transactionsNew} new transaction(s), but some connections need attention: ${result.errlist}`); + } else { + toast.success(`Synced — ${result.transactionsNew} new transaction(s).`); + } + setAutoMatchRefreshKey(k => k + 1); + await load(); + } catch (err) { + toast.error(err.message || 'Sync failed'); + await load(); + } finally { + setSyncing(null); + } + }; + + const handleBackfill = async (id) => { + setBackfilling(id); + try { + const result = await api.backfillDataSource(id); + if (result.errlist) { + toast.warning(`Backfill complete — ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`); + } else { + toast.success(`Backfill complete — ${result.transactionsNew} new transaction(s) pulled from the last ${seedDays} days.`); + } + setAutoMatchRefreshKey(k => k + 1); + await load(); + } catch (err) { + toast.error(err.message || 'Backfill failed'); + await load(); + } finally { + setBackfilling(null); + } + }; + + const handleDisconnect = async () => { + if (!disconnectTarget) return; + setDisconnecting(true); + try { + await api.deleteDataSource(disconnectTarget.id); + toast.success('SimpleFIN disconnected.'); + setDisconnectTarget(null); + await load(); + } catch (err) { + toast.error(err.message || 'Failed to disconnect'); + } finally { + setDisconnecting(false); + } + }; + + const handleToggleMonitored = async (sourceId, accountId, monitored) => { + setTogglingAccount(accountId); + // Optimistic update + setAccountsBySource(prev => ({ + ...prev, + [sourceId]: (prev[sourceId] || []).map(a => + a.id === accountId ? { ...a, monitored } : a + ), + })); + try { + await api.setAccountMonitored(sourceId, accountId, monitored); + } catch (err) { + // Revert on failure + setAccountsBySource(prev => ({ + ...prev, + [sourceId]: (prev[sourceId] || []).map(a => + a.id === accountId ? { ...a, monitored: !monitored } : a + ), + })); + toast.error(err.message || 'Failed to update account'); + } finally { + setTogglingAccount(null); + } + }; + + function connWarning(conn) { + if (!conn.last_error) return null; + if (conn.status === 'error') return { kind: 'error', label: 'Sync error' }; + // Partial errlist: sync succeeded but some bank connections need attention + return { kind: 'partial', label: 'Some connections need attention' }; + } + + if (enabled === null) { + return ( + +
+ + Loading… +
+
+ ); + } + + return ( + <> + + {!enabled ? ( +
+ Bank sync is not enabled on this server. Ask your administrator to enable it in the Admin panel. +
+ ) : ( + <> + {loadError && ( +
{loadError}
+ )} + + {connections.length > 0 && connections.map(conn => { + const accounts = accountsBySource[conn.id] || []; + const accsLoading = accountsLoading[conn.id]; + const accsError = accountsErrorBySource[conn.id]; + const monitoredCount = accounts.filter(a => a.monitored).length; + + const warning = connWarning(conn); + return ( +
+ {warning && ( +
+ +
+ {warning.label} + — {conn.last_error} + {warning.kind === 'partial' && ( + + Re-authenticate the affected institutions in your SimpleFIN Bridge account to restore full sync. + + )} + {warning.kind === 'error' && conn.last_sync_at && ( + + Last successful sync: {fmtDate(conn.last_sync_at)} + + )} +
+ +
+ )} + + {/* Header row */} +
+
+ +
+

{conn.name}

+

+ {conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '} + {conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''} + {accounts.length > 0 && ` · ${monitoredCount} monitored`} +

+
+
+
+ + + +
+
+ + {/* Sync status grid */} +
+
+

Last sync

+

{fmtDate(conn.last_sync_at)}

+
+
+

Status

+

+ {conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)} +

+
+
+

History window

+

{syncDays} days

+
+
+

Server timezone

+

{serverTz || '—'}

+
+
+ + {/* Accounts section */} +
+
+

+ Accounts +

+

+ Toggle to include / exclude from bill matching +

+
+ + {accsLoading ? ( +
+ + Loading accounts… +
+ ) : accsError ? ( +

{accsError}

+ ) : accounts.length === 0 ? ( +

No accounts found.

+ ) : ( + accounts.map(account => ( + setExpandedAccount(prev => prev === account.id ? null : account.id)} + onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)} + toggling={togglingAccount === account.id} + bills={bills} + onMatch={tx => handleMatch(conn.id, tx)} + onUnmatch={tx => handleUnmatch(conn.id, tx)} + matchingTxId={matchingTxId} + /> + )) + )} +
+
+ ); + })} + + {connections.length === 0 && ( +
+
+

Connect a SimpleFIN Bridge account

+

Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL — no bank credentials are saved.

+

+ Need a token?{' '} + + Open SimpleFIN Bridge + + +

+
+
+ setSetupToken(e.target.value)} + disabled={connecting} + /> + +
+
+ )} + + {connections.length > 0 && ( +
+
+

Add another connection

+ + Get a SimpleFIN token + + +
+
+ setSetupToken(e.target.value)} + disabled={connecting} + /> + +
+
+ )} + + )} +
+ + {/* ── Auto-match review panel ── */} + {enabled && connections.length > 0 && ( + + )} + + {/* ── Bank Budget Tracking ── */} + {enabled && connections.length > 0 && ( + +
+ + {/* Toggle */} +
+
+ +

+ Replaces manual starting amounts. Remaining = bank balance − pending payments − unpaid bills. +

+
+ handleBtSave({ enabled: v })} + /> +
+ + {btEnabled && ( + <> + {/* Account picker */} +
+ + {btAccounts.length === 0 ? ( +

No bank accounts found. Sync your SimpleFIN connection first.

+ ) : ( + + )} +
+ + {/* Pending window */} +
+ +

+ Payments you mark as paid within this many days are shown as pending — + the money may not have cleared your bank yet, so they're subtracted from your effective balance. +

+ +
+ + {/* Late payment grace window */} +
+ +

+ If a payment posts in the first N days of a new month but the bill was due in the prior month, + automatically count it for the prior month — no prompt needed. +

+ + {btLateGraceDays > 0 && ( +

+ Any payment posting on the 1st–{btLateGraceDays}{btLateGraceDays === 1 ? 'st' : btLateGraceDays === 2 ? 'nd' : btLateGraceDays === 3 ? 'rd' : 'th'} will automatically count for the prior month if the bill was due then. +

+ )} +
+ + {/* Info callout */} +
+

How it works: Your live bank balance is fetched every time your data syncs. Bills you've already marked paid are not double-counted — your bank balance reflects them. Only unpaid bills still due this month are subtracted.

+

Bills marked paid within the pending window show a Pending badge in the tracker, since the bank may not have processed them yet.

+
+ + )} +
+
+ )} + + {enabled && connections.length > 0 && ( + +
+
+
+ +

+ When new transactions sync, recognized merchants and stores are automatically assigned a spending category. +

+
+ +
+
+
+ )} + + { if (!open) setDisconnectTarget(null); }}> + + + Disconnect SimpleFIN? + + This removes the connection and deletes synced accounts. Previously synced transactions + are kept but will no longer be associated with a data source. + + + + Cancel + + {disconnecting ? 'Disconnecting…' : 'Disconnect'} + + + + + + setMatchTarget(null)} + transaction={matchTarget?.tx} + bills={bills} + onConfirm={handleConfirmMatch} + busy={!!matchingTxId} + /> + + ); +} diff --git a/client/components/data/ConnectionHero.jsx b/client/components/data/ConnectionHero.jsx new file mode 100644 index 0000000..be03f67 --- /dev/null +++ b/client/components/data/ConnectionHero.jsx @@ -0,0 +1,187 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Landmark, RefreshCw, Loader2, AlertTriangle, RotateCcw, Upload, Plus } from 'lucide-react'; +import { api } from '@/api'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +// Relative "2h ago" / "3d ago"; returns null for empty input. +function relativeTime(iso) { + if (!iso) return null; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return null; + const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (secs < 60) return 'just now'; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.round(hrs / 24); + return `${days}d ago`; +} + +function HeroShell({ tone = 'default', icon: Icon, children }) { + const toneRing = { + default: 'border-border/60', + good: 'border-emerald-500/30', + warn: 'border-amber-500/40', + error: 'border-rose-500/30', + }[tone]; + const toneChip = { + default: 'bg-muted/50 text-muted-foreground', + good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + error: 'bg-rose-500/10 text-rose-600 dark:text-rose-400', + }[tone]; + return ( +
+ + + + {children} +
+ ); +} + +/** + * The Data page's connection status hero. Five states, so a network blip is never + * mistaken for "not connected", and a server without bank sync never gets a dead + * Connect button: + * loading · disabled · error · not-connected · connected (± needs-attention) + */ +export default function ConnectionHero({ + loading, + error, // truthy when the status/summary fetch failed + enabled, // status.enabled (server feature flag) + hasConnections, // status.has_connections + conn, // the simplefin data_source (name, last_sync_at, last_error) or null + txnTotal, // total synced transactions (at-a-glance), or null + onRetry, + onGoTo, // (sectionId) => void + onSynced, // () => void — refresh after a successful sync +}) { + const [syncing, setSyncing] = useState(false); + + async function handleSyncNow() { + setSyncing(true); + try { + const r = await api.syncAllSources(); + const errs = Array.isArray(r?.errors) ? r.errors : []; + if (errs.length) { + toast.warning(`Synced, but ${errs.length} connection${errs.length === 1 ? '' : 's'} need attention.`); + } else { + const n = r?.transactions_new ?? 0; + toast.success(`Synced — ${n} new transaction${n === 1 ? '' : 's'}.`); + } + onSynced?.(); + } catch (err) { + if (err?.status === 429) toast.error('Please wait a moment before syncing again.'); + else toast.error(err?.message || 'Sync failed.'); + } finally { + setSyncing(false); + } + } + + // ── loading ── + if (loading) { + return ( +
+ +
+
+
+
+
+ ); + } + + // ── disabled (server has no bank sync) ── + if (!enabled) { + return ( + +
+

Automatic bank sync isn’t enabled

+

+ This server doesn’t have SimpleFIN configured — you can still import and export your data. +

+
+ +
+ ); + } + + // ── error (couldn't check) ── + if (error) { + return ( + +
+

Couldn’t check your bank connection

+

Something went wrong loading your sync status.

+
+ +
+ ); + } + + // ── not connected ── + if (!hasConnections || !conn) { + return ( + +
+

Connect your bank to get started

+

+ Sync transactions automatically, or import your existing history. +

+
+
+ + +
+
+ ); + } + + // ── connected (± needs attention) ── + const needsAttention = Boolean(conn.last_error); + const synced = relativeTime(conn.last_sync_at); + return ( + +
+

+ {needsAttention ? 'Your bank connection needs attention' : 'Your bank is connected'} +

+

+ {needsAttention + ? conn.last_error + : ( + <> + {conn.name || 'SimpleFIN'} + {Number(txnTotal) > 0 ? <> · {Number(txnTotal).toLocaleString('en-US')} transactions : null} + {synced ? <> · synced {synced} : null} + {' · syncs automatically'} + + )} +

+
+
+ {needsAttention && ( + + )} + +
+
+ ); +} diff --git a/client/components/data/DataNav.jsx b/client/components/data/DataNav.jsx new file mode 100644 index 0000000..762bf6c --- /dev/null +++ b/client/components/data/DataNav.jsx @@ -0,0 +1,64 @@ +import { cn } from '@/lib/utils'; + +const DOT_TONES = { + green: 'bg-emerald-500', + amber: 'bg-amber-500', + red: 'bg-rose-500', + gray: 'bg-muted-foreground/40', +}; + +/** + * Goal-oriented navigation for the Data page. Desktop (≥ lg): a sticky vertical + * list. Mobile: a horizontally-scrollable segmented control. One