The demo seeder created bills + categories only, so nearly every page
rendered empty; clear-demo had a data-loss bug and error-protection gaps.
Seed now populates every user-facing surface: bills with autopay states,
3-6 months of payment history (with drift + debt paydown), monthly
skips/overrides/snoozes/notes, a demo bank source + accounts + matched/
unmatched/subscription transactions, categorised spending + budgets +
rules, planning income/starting-amounts, category groups, an active
snowball plan (+ extra-payment), merchant rules, and a calendar feed —
so a new user can explore Tracker (incl. overdue/drift), Analytics,
Summary, Spending, Snowball, Payoff, Transactions, Matches, Subscriptions,
Banking and Calendar with realistic data.
Correct removal (the emphasis):
- Migration v1.07 adds an is_seeded marker to the 12 user-scoped,
non-cascading demo tables (bill-children cascade with their seeded bill).
- clearSeededDemoData() (new, in userDataService alongside eraseUserData)
removes ONLY seeded rows in one transaction, child->parent, and keeps a
seeded category if a user's own bill still references it.
- Fixes the category collision: seed marks ONLY categories it creates, so
clear no longer deletes the user's default categories / uncategorises
their real bills.
Error protection: idempotency guard on seeded (not all) bills -> 409
instead of a fake "created 0"; whole seed wrapped in one transaction
(atomic); rate-limit + audit + standardizeError on all three routes;
seeded-status reports payments/transactions counts; dead --force removed.
Client copy/counts corrected.
Verified: typecheck/check clean; server suite 232/232 (+6 new); client
typecheck/lint/build clean; e2e probe 17/17; on a COPY of the real prod
DB the v1.07 migration applies (integrity ok) and a seed->clear round-trip
on the data-rich user restores their real data byte-for-byte with no
orphans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the services/ TypeScript migration (0 .js remain). These four are the
largest, most dynamic modules (subscription-catalog matcher, xlsx/spreadsheet
parser, OIDC protocol client, SQLite user-DB importer); converted to .cts with a
top-of-file @ts-nocheck so they run via Node type-stripping now, with rich typing
deferred as an incremental follow-up. The other ~46 services are fully typed.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CSV parser + import primitives (also shared by the OFX importer).
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tracker aggregation core (getTracker, safe-to-spend, bank tracking,
overdue count). Aggregation-heavy so money helpers are plain-required (any)
to avoid Dollars|null arithmetic friction, matching analyticsService.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two of the large foundational services. billsService (validation/normalization,
balance math) is required by many others; notificationService (email + push +
drift digest) uses the shared http types indirectly.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Batch of low-blast-radius modules: utils/apiError, and services statusRuntime,
loginFingerprint, auditService, transactionMatchState, updateCheckService,
advisoryFilterService, bankSyncConfigService.
- types/http.d.ts: shared permissive Express-ish Req/Res/Next/Router types
(@types/express isn't installed; handlers are dynamic) — for apiError's
errorHandler now and the routes/middleware batches next.
- Every require of a migrated module updated to the explicit .cts extension.
- Confirmed + documented the Node rule: a .cts needs at least one `import`
statement or type-stripping never activates (node --check fails on the first
type token); import-less modules lead with an `import type`.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Overdue Command Center and Price-Change (drift) panels reset to
expanded on every load — their collapse arrows used local useState. Now
the collapsed state is remembered per-user via two new settings
(tracker_overdue_collapsed / tracker_drift_collapsed), mirroring the
search panel's useSearchPanelPreference pattern. Each panel header also
gains an inline "Hide" control (EyeOff, like the search panel) that writes
the existing tracker_show_* setting and fires an Undo toast; hidden
sections come back from Settings → Tracker Layout.
- services/userSettings.js: allowlist + defaults for the two collapse keys
- TrackerPage: read collapse state, hide handlers with Undo, wire props
- OverdueCommandCenter/DriftInsightPanel: controlled collapse + header hide
Verified: typecheck 0, lint 0 errors, build, and a live settings round-trip
(collapse persists, hide writes, Undo restores, allowlist rejects junk keys).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
- 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>
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>
- 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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
- 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>
- 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)
- 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)
- 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)