Commit Graph

152 Commits

Author SHA1 Message Date
null 1457de487f feat(security): show a key fingerprint (not the key) in the encryption status
The encryption key must never be retrievable through the app (that would undo
the whole point of TOKEN_ENCRYPTION_KEY — a stolen session/XSS could then read
the master key). Instead surface a non-reversible fingerprint so operators can:
- verify which key is currently active,
- confirm a running instance matches the key they backed up, and
- spot an accidental key change (which would make existing secrets unrecoverable).

- encryptionService.keyFingerprint(): domain-separated SHA-256 prefix of the
  active key (env if set, else the stored DB key); never force-creates a key
  (returns null when none exists), shares nothing with the cipher derivation.
- admin bank-sync-config exposes key_fingerprint alongside encryption_key_source.
- Admin Bank Sync card renders it with backup guidance.
- Test: stable per key, differs by key, not reversible / not the raw key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:15:32 -05:00
null a03216ee55 perf(simplefin): batch sync writes in a transaction + align dedup key
- Wrap each source's account/transaction upserts + the status update in one
  better-sqlite3 db.transaction() — a single commit instead of one implicit
  commit per row (far fewer fsyncs on large syncs) and atomic (a mid-sync
  failure can't leave a half-written account). The write block is synchronous;
  the async SimpleFIN fetch already completed before it.
- Key the dedup lookup on (user_id, provider_transaction_id) to match the UNIQUE
  index and the reconnect-stable provider id. A data_source_id-keyed lookup
  missed the prior row after a disconnect/reconnect, so pending→posted and
  amount-refresh were skipped; now they survive a reconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:54:34 -05:00
null 4b81600afe fix(brand): finish the "BillTracker" → "Bill Tracker" rename (display copy)
The v0.42.0 brand refresh renamed client copy but missed a few user-facing
server strings:
- routes/about.js: the /api/about `name` (rendered as the About page title) was
  still 'BillTracker' → 'Bill Tracker'.
- userDbImportService.js: two import-error messages referenced 'BillTracker'.
- Remove the now-unused APP_NAME export from lib/version.ts (0 consumers;
  lib/brand.ts BRAND.name is the canonical source).

Left intentionally as identifiers (not display copy): the updateCheckService
User-Agent 'BillTracker/<version>' and all repository URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:31:14 -05:00
null 7e5e012434 feat(settings): default landing page (plan Tier 6)
- New per-user 'default_landing_page' setting (tracker default / calendar /
  summary / spending), registered in the whitelist + defaults.
- Implemented as a post-login redirect (keeps /=Tracker so the Home link and
  direct nav are unaffected). Precedence: admin → deep-link they were bounced
  from (state.from) → default landing → /. Resolved by fetching settings in the
  login success path, with a safe / fallback so a fetch failure never blocks login.
- Settings: Select in the Regional section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:03:01 -05:00
null 01030fe7d0 feat(settings): configurable "Due soon" window (plan Tier 4)
- statusService: resolveDueSoonDays (clamp 0–31, default 3) replaces the
  hardcoded 3-day threshold in calculateStatus; threaded via options.dueSoonDays
  through rowOptions in trackerService (×2) and routes/calendar.
- New per-user 'due_soon_days' setting registered in the whitelist + defaults.
- Settings: numeric "Due soon window" row next to Grace period, with min/max +
  clamp guard. Tooltips added to Grace period, Due-soon, and Price-change
  sensitivity explaining each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:00:32 -05:00
null 0f24016bcf feat(settings): first day of week (plan Tier 3)
- New per-user 'week_start' setting (0=Sun default / 1=Mon), registered in
  USER_SETTING_KEYS + USER_SETTING_DEFAULTS.
- CalendarPage reads it and offsets the month grid: leading-blank count
  (getDay()-weekStart+7)%7 and a rotated weekday header. Purely presentational.
- Settings: Sun/Mon Select in the new "Regional" section. Also aligns the
  grace_period_days client default (3→5) with the server seed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:57:35 -05:00
null ef566b12f0 feat(server-ts): paymentValidation → .cts, proving the CJS-module path (Phase 2)
The payment-input gate (well-tested by Phase 1) migrates to TypeScript as
a .cts (CommonJS) module: keeps require/module.exports, imports the branded
Cents type from money.mts, and casts the require(esm) result so toCents/
fromCents keep their branded signatures. This proves the low-churn path for
the ~90 remaining CJS services/routes (no require→import rewrite needed).

Both server-TS patterns now proven end-to-end: .mts (ESM, type-source) +
.cts (CJS). typecheck:server clean; suite 225 + probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:51:37 -05:00
null 3f9a6cf23e feat(server-ts): utils/dates → TypeScript (.mts); utils/ dir now TS (Phase 2)
dates.mts (ESM, typed) joins money.mts — the two most-required server
leaves are now TypeScript. Added .cts to the server tsconfig for the
CJS-module migration path. typecheck:server clean; suite 225 + probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:48:44 -05:00
null 200c6373f5 feat(server-ts): migrate the money boundary to TypeScript on Node's native runtime (Phase 2)
Establishes the server-TypeScript foundation and migrates utils/money →
utils/money.mts with branded Cents/Dollars (mirroring the client), so the
cents↔dollars boundary — the origin of the reconciliation bug class — is a
compile error for any typed server caller.

Approach (no build step): Node 25 runs .ts natively (type-stripping).
Migrated modules use the explicit-ESM .mts extension (the project is
"type":"commonjs", which disables .ts ESM auto-detection) and are required
from the CJS callers via Node's require(esm) — verified working. Infra:
tsconfig.server.json (allowJs + checkJs:false → incremental like the
client), npm run typecheck:server, check:server extended to .mts, wired
into ci. 28 require sites repointed to money.mts.

typecheck:server clean; full server suite 225 + e2e probe 17/17 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:44:04 -05:00
null 995f635d35 refactor(tracker): consolidate isPaidStatus + rowOutstanding + toast gap (T5)
Added a single isPaidStatus(status) (+ PAID_STATUSES) to statusService and a
matching client helper in trackerUtils, routing the unambiguous settled-status
checks through it (trackerService, StatusBadge, CalendarPage, rowIsPaid). The
intentionally paid-only counts stay distinct. Replaced two inline
Math.max(r.balance||0,0) with rowOutstanding, and gave the Tracker settings
load a quiet toast instead of a silent swallow. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:36:30 -05:00
null 9f4b53d37a perf(tracker): batch getTracker per-bill queries (kill N+1) (P1)
getTracker ran a payments query per bill plus computeAmountSuggestion per bill
(up to 12 queries each: 6 months x 2) inside bills.map — ~70-450 queries for a
35-bill account per load. Now one query fetches all cycle payments (grouped in
JS per bill range) and two compute all amount suggestions
(computeAmountSuggestionsBatch). Behavior-preserving.

Test: tests/amountSuggestionService.test.js pins batch == per-bill output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:25:55 -05:00
null 73631ab812 fix(tracker): route error handling + autopay write atomicity (T2)
routes/tracker.js had no try/catch and returned a plain {error} shape; the
three GET handlers now wrap in try/catch + standardizeError (including the
invalid-month path). applyAutopaySuggestions ran INSERT + applyBalanceDelta
as two un-transactional steps on a GET — wrapped both in one db.transaction.

Tests: autopay creates one payment + drops balance (idempotent), route
returns standardized error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:21:28 -05:00
null 10e159352a fix(notifications): honor per-bill reminder_days_before + expose for all bills (BM3)
The notifier used a hard-coded 3-day early reminder and never read
reminder_days_before, so the modal's 'Reminder Days' control was a no-op.
The early reminder now fires at the bill's own lead (>= 2 days so it never
collides with the 1-day/same-day reminders); email subject+body say 'due in
N days'. Lead-time selection extracted to a pure exported reminderTypeFor()
for unit testing. The Reminder Days control now shows for every bill and a
non-subscription save no longer clobbers the column to 3.

Test: tests/notificationLeadTime.test.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:16:10 -05:00
null 4a38cc8614 fix(tracker): gate bank card unpaid/remaining by occurrence + add trackerService tests (T1)
buildBankTracking summed expected_amount for all active unpaid bills with no
resolveDueDate gate, so annual/off-month bills inflated unpaid_this_month and
the bank remaining (same class as QA-B5-02, live on the bank path). getTracker
now derives the unpaid total from the already-gated rows (netting partials) and
passes it in. summary.remaining/total_remaining now use the bank card's own
remaining in bank mode (agreeing with safe-to-spend), and a stray balance/100
is now fromCents.

New tests/trackerService.test.js: gating fix, summary totals, bank-mode
remaining agreement, cents<->dollars, getOverdueCount gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:10:28 -05:00
null db4d33513e fix(debt): honest payoff date for unpayable debts + math tests (Snowball #1,#5)
The payoff simulation reported months_to_freedom by taking Math.max over each
debt's payoff month — but a debt whose minimum never overcomes its interest (and
that the rolling snowball can't cover) never reaches $0, so its "never" counted
as 0 and the projection showed the OTHER debts' last month as the freedom date.
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 + the same guard in aprService calculateMinimumOnly).

Also adds tests/snowballMath.test.js (12) — the debt-payoff engine had zero
coverage. Hand-calculated examples for amortization (0% + 12% APR), snowball
rolling, avalanche vs snowball interest, skip reasons, APR snapshot, and the
unpayable-debt edge. Server suite 151 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:01:17 -05:00
null d53a64b604 feat(data): "Erase my data" danger zone (Batch 5)
New services/userDataService.js eraseUserData() permanently wipes a user's
financial + imported data in one transaction (child → parent order for FK
safety): bills (+ cascading payments/monthly_bill_state/bill_history_ranges),
transactions/accounts/data_sources, categories/groups, templates, snowball,
spending rules/budgets, merchant rules, imports, and per-user hint tables. It
PRESERVES the account, sessions, 2FA/WebAuthn, login history and preferences —
this resets your data, not your account — then re-seeds default categories and
writes an audit row to import_history.

- POST /api/user/erase-data — rate-limited (demoDataLimiter), requires a
  type-to-confirm token ("ERASE"), structured errors.
- UI: EraseDataSection danger-zone card (Export & backups pane) — red-accented,
  "download a backup first" nudge, type-to-confirm AlertDialog, toasts; on
  success DataPage reloads all state.

Tests: tests/eraseUserData.test.js — wipes user A only, preserves user B +
account + session, re-seeds categories, audited. Server 139 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:21:07 -05:00
null bd1eee00b0 feat(import): OFX/QFX transaction import (Batch 3)
New services/ofxImportService.js parses OFX 1.x (SGML, unclosed leaf tags),
OFX 2.x (XML) and QFX (+ Intuit tags ignored) into the same normalized shape the
CSV path produces, then writes through the SAME shared primitives (session table,
(user_id, data_source_id, provider_transaction_id) dedupe, import_history) — now
exported from csvTransactionImportService (additive; CSV tests still pass).

- Routes POST /api/import/ofx/{preview,commit} mirror the CSV two-step (raw
  upload → structured commit; no column mapping since OFX is structured).
- UI: ImportOfxSection (upload → preview list → import) in the Import pane;
  amounts shown via formatCentsUSD; toasts on preview/commit/malformed.
- Gap handling: signed TRNAMT → signed cents; DTPOSTED → YYYY-MM-DD; FITID →
  stable provider id (hash fallback); non-OFX / empty files rejected clearly.

Tests: tests/ofxImportService.test.js (SGML + XML/QFX parse, entity decode,
signed cents, preview→commit, re-import dedupe, import_history). Server 129 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:11:59 -05:00
null fa2432265c refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).

Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).

Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.

Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
null b3168fca70 fix(qa): retention GC orphaned matched transactions on bill purge (QA-B5-04)
Found probing a copy of the live SimpleFIN DB: 3 transactions were
match_status='matched' with matched_bill_id=NULL. Bills are soft-deleted
(retained for recovery), then the retention GC 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.

pruneSoftDeletedFinancialRecords now releases those matches back to
'unmatched' in the same transaction and self-heals pre-existing orphans;
retention behaviour is unchanged. Verified on a live-DB copy (3→0 orphans,
0 transactions lost). Regression: 3 tests in backupAndCleanup.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:04:59 -05:00
null 2963d11d1b fix(qa): version check is opt-out-able (QA-B16-01)
- updateCheckService: gate the external request on `update_check_enabled`
  (default on); when off, no network call, returns { disabled: true }
- aboutAdmin: GET/PUT /update-check-setting (admin-only) to toggle it
- StatusPage: a Switch on the admin System Status card to enable/disable
- privacy.js: state that an admin can disable it (was called "optional" with
  no actual opt-out)
- tests/updateCheckOptOut.test.js: proves no external fetch when disabled
- docs: archive QA-B16-01, B16 

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:05:37 -05:00
null c31d8cbe9e fix(qa): escape bill name in reminder email HTML — XSS via bill name (B14-04)
- notificationService buildEmailHtml: the message line interpolated bill.name
  raw (`<strong>${bill.name}</strong> is due…`) while the detail table escaped
  it; a `<img src=x onerror=…>` name landed unescaped in the email HTML. Now
  escaped everywhere. (self-XSS — reminders go to the bill's owner — but a clear
  inconsistent-escaping defect)
- expose buildEmailHtml via _email; add an escaping test across all 4 email types
- docs: archive QA-B14-04

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:18:05 -05:00
null 2050e13407 fix(qa): notification _push export was clobbered → "Send test push" 500'd (B10-01)
- notificationService: `module.exports._push = {...}` was set BEFORE the final
  `module.exports = {...}`, which wiped it, so routes/notifications.js got
  `_push || {}` → sendTestPush undefined → POST /api/notifications/test-push
  always threw "Push service not initialised". Scheduled reminders were fine
  (in-scope calls). Moved the _push assignment after the reassignment.
- add tests/notificationDelivery.test.js (7 tests: ntfy/gotify/discord payloads,
  dispatch, error handling, unknown channel, no token leak in the body)
- docs: archive QA-B10-01

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:11:34 -05:00
null 1bd282f47b fix(qa): Analytics "expected" gates by occurrence (matches Tracker/Summary)
- analyticsService: only add a bill's expected_amount in months it actually
  occurs (resolveDueDate), so annual / off-month quarterly bills no longer
  inflate the expected-vs-actual line every month (QA-B5-03, same root as B5-01)
- add a Tracker<->Analytics reconciliation guard to e2e/api.probe.spec.js
- docs: archive QA-B5-03; cycle log

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:23:37 -05:00
null 127b69ffc2 chore(qa): vendor chunk splitting, remove unused markdown deps, remove dead totalInterestPaid (batch 0.41.0 QA cleanup) 2026-07-02 20:47:50 -05:00
null 029c227685 fix(qa): seed demo data amounts, bill amount validation, negative USD format, a11y aria-labels, Playwright E2E setup (batch 0.41.0 QA) 2026-07-02 20:36:09 -05:00
null 35e5d185de feat(spending): category groups, YNAB-style spending page overhaul, 3-month averages, cover overspending (batch 0.41.0) 2026-06-14 19:21:34 -05:00
null 81ddcb5fc1 feat(banking): bank transactions page with merchant/store matching, transaction matching refactor, bank sync improvements (batch 0.40.0) 2026-06-14 15:15:31 -05:00
null 8ef794a94a feat(settings): safe-to-spend toggle, move notifications from Profile to Settings, fix dark-mode readability 2026-06-12 01:52:48 -05:00
null dc49eb9633 feat(cashflow): safe-to-spend projection with timeline, vitest setup, package upgrades 2026-06-12 01:32:28 -05:00
null d6639f1385 feat(money): cents migration stage 2 — schema flip to integer cents (batch 0.38.4) 2026-06-11 20:12:31 -05:00
null bf66ab1ee6 feat(money): migrate services to cent-exact money.js helpers (batch 0.38.3) 2026-06-10 20:14:13 -05:00
null 947fa3bdf8 feat(auth): add per-username rate limiter, migrate dates to local-time utils (batch 0.38.1) 2026-06-10 19:42:51 -05:00
null 38c8bbd472 feat(server): add trust proxy, CSRF HTTPS detection, error formatting, dates util (batch 0.38.0) 2026-06-10 19:37:19 -05:00
null ca514e5f26 fix(tracker): BillModal save/close race and pending badge logic
- Use controlled Dialog state (setDialogOpen) instead of immediate onClose()
  to let Radix cleanup properly before unmount
- Amber 'Pending' badge now only shows for bank-linked bills — unlinked
  bills skip the pending-cleared check and show 'Paid' directly
- TrackerPage onSave no longer nullifies edit state before BillModal can
  animate closed

(batch 0.37.4)
2026-06-08 16:33:48 -05:00
null fab4945d50 fix(tracker): bank pending counts, overdue center cleanup, and payment source labels
- Add bank_pending_count to tracker rows showing pending bank transaction
  matches for bills with merchant rules
- Remove snoozed-only state from OverdueCommandCenter (always show when
  overdue rows exist)
- Display 'Synced' label for transaction-matched payments in BillModal
- Prioritize 'Pending' badge over StatusBadge when bank has pending matches
- Exclude bank-synced and transaction-matched payments from pending_cleared

(batch 0.37.3)
2026-06-08 16:05:31 -05:00
null 626459322f fix(tracker): live sync label truncation and due_day fallback on partial update
- Shorten 'Live Sync' label to 'Live' for space-constrained layouts
- Add existing bill due_day fallback in validateBillData to prevent
  spurious required-field errors during partial PATCH updates

(batch 0.37.2)
2026-06-08 12:24:51 -05:00
null 80ef1208ae fix(tracker): update payment progress and bills service (batch 0.37.1) 2026-06-08 11:54:47 -05:00
null 426b0fd932 fix(admin): admin/profile routes and services 2026-06-07 21:18:02 -05:00
null 79b51b1c9a fix(bank-sync): transaction matching, services, and worker updates 2026-06-07 20:07:27 -05:00
null 31be51e77f fix(bank-sync): admin config, matching, and worker updates 2026-06-07 19:41:17 -05:00
null 12bcd1d8f3 fix(auth): oidc service updates 2026-06-07 18:05:09 -05:00
null f7ad1c1ebb fix(tracker): table columns and settings improvements 2026-06-07 17:23:14 -05:00
null 13e41aec74 feat: iCal feed for bills (Apple/Google calendar export) 2026-06-07 15:53:46 -05:00
null e1082145ab feat: tracker payment flow and mobile row improvements 2026-06-07 14:49:39 -05:00
null d9cf499dba feat: search filter panel component, search preference persistence, page integration 2026-06-07 01:28:35 -05:00
null ab5e3fbf1f feat: profile settings UI, auth service refactor, schema migration, route tests 2026-06-07 01:17:49 -05:00
null 6811eb8be5 feat: payment accounting service, SQL schema + migration, backend route refactor, test updates 2026-06-07 01:05:48 -05:00
null f1817a520b fix: status service edge case handling 2026-06-07 00:24:43 -05:00
null 8c2ecdb313 fix: subscription service error handling, SubscriptionsPage cleanup 2026-06-07 00:11:00 -05:00
null 4dd01c13c4 feat: live transaction search in merchant rules, link-import preference toggle, tracker row tweaks 2026-06-06 23:04:53 -05:00