Commit Graph

393 Commits

Author SHA1 Message Date
null 4a76eb9b92 feat(tracker): optimistic pay/skip + toast.promise on syncs (M1/M2)
M1: marking a bill paid/unpaid flips the row instantly via local optimistic
state (cleared when fresh data arrives, rolled back on error) on both desktop
and mobile rows, instead of waiting for the server round-trip.

M2: bank sync (Tracker) and the bill-modal Sync use sonner toast.promise —
one toast transitioning loading -> done -> error, replacing the manual
spinner-flag + separate success/error toasts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:47:32 -05:00
null 55b515c401 feat(tracker): Pay-all-due per bucket + reversible quick-pay with specific toasts (T4)
- Each bucket header gains a 'Pay all due (N)' action: one bulkPay for every
  unpaid gated bill in the bucket, behind a confirm (count + total) and a single
  Undo toast that deletes the created payments.
- Quick-pay and mark-paid now show a specific toast ('Rent - $1,200 paid');
  quick-pay gained an Undo action to match un-pay.
- Per-bill snooze already existed in the Overdue Command Center.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:43:01 -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 d92cc38116 fix(bill-modal): correctness + toast fallbacks + validator consolidation (BM1)
- handleBlur now takes the field value explicitly (was positional guessing that
  fell through to interestRate for unmapped fields).
- Three copy-pasted money validators -> one shared validateNonNegativeMoney in
  client/lib/money.js; expected-amount copy 'positive' -> 'non-negative' (0 ok).
- Removed the save action's duplicate due-day/interest-rate re-validation
  (validateForm already covers it); kept the parses.
- Added err.message fallbacks to save/deactivate/verify-autopay toasts.
- Save toasts now name the bill.

Test: client/lib/money.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:32:25 -05:00
null e9c5e4d1d3 test(tracker): cover paymentAccounting + analytics money services (X2)
Added tests for two untested money-critical services:
- paymentAccountingService: bank-backed predicate, accounting-active SQL, and
  the manual-vs-bank override invariant (bank overrides provisional manual so
  it isn't double-counted; balance restored; manual reactivates + re-applies
  balance when the override is removed).
- analyticsService: month-window math (year-boundary + leap edges) and
  validateSummaryQuery defaults/range errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:28:12 -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 c91c97ef41 feat(tracker): live cross-query invalidation for the app shell (X3)
The app never called invalidateQueries; a Tracker mutation only refetch()'d
the one tracker query. So the sidebar overdue badge (['overdue-count'],
2-min staleTime), drift report, and bills list stayed stale after pay/skip/
edit — you could clear your last overdue bill and still see '3' for minutes.
Added useInvalidateTrackerData() (tracker + overdue-count + drift-report +
bills) and wired it into rows, BillModal.onSave, bank-sync, reorder, and the
payment/late-attribution handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:19:30 -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 ad1f5bebf6 fix(bill-modal): SimpleFIN import refreshes payments + Tracker live (BM4)
The Sync button and merchant-rule historical import both CREATE payments but
only reloaded linked transactions, so the modal's Payment History stayed stale
and the Tracker row behind the modal didn't update (kept showing due/overdue)
until close+reopen. Both now await Promise.all([loadPayments(),
loadLinkedTransactions()]) then onSave?.(), matching the unmatch handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:11:28 -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 d689ff6e68 fix(tracker): quick-pay duplicate guard + atomic balance write (X1)
POST /payments/quick had no dedupe and non-atomic INSERT+applyBalanceDelta,
unlike /payments/bulk. A double-click/retry/two-tab pay created a second
payment and dropped the balance twice; a mid-way failure left a payment with
no balance adjustment. Now checks the bill_id+paid_date+amount composite key
(idempotent 200) and wraps the write in db.transaction.

Test: tests/paymentsQuickRoute.test.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:05:19 -05:00
null 836dbdb9ae fix(snowball): surface projection errors + polish (Snowball #2,#3,#8)
- The payoff projection panel swallowed fetch errors silently; now shows a
  "Couldn't load … Try again" state (no projection) and a subtle "showing the
  last result" retry banner when a refresh fails.
- loadProjection() now uses the currently-typed extra payment (via a ref that
  mirrors the input), consistent with the debounced live preview, so refreshing
  after a balance edit never drops an in-progress extra.
- Copy: extra-payment validation says "non-negative" (0 is valid); the capped
  banner now reads "one or more debts won't pay off at this rate" (accurate for
  the unpayable-debt case from the #1 fix, not just >50 years).

(#9 unsaved-preview hint was unnecessary — the input already auto-saves on blur.)
Build clean; client suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:08:41 -05:00
null 2b315f5d18 refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
  -duplicate copies returning a plain {error} shape; folded into one
  transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
  standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
  patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
  -scoped; now a single WHERE id IN (…) AND user_id = ? batch.

Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:04:31 -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 877e4c6d3c docs(data): record the Data page overhaul + sign-off (Batch 6)
HISTORY: "Data page overhaul" section (redesign + OFX/QFX import + richer export
+ erase-my-data). QA plan: cycle-log row.

Sign-off: server 139 + client 46 + build green; axe on /data zero critical/
serious (added to a11y.authed); full probe suite 17/17. No /data visual baseline
existed to update (only login screenshots).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:25:25 -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 314e4ff45e feat(export): JSON export + date-range/format payments export (Batch 4)
- GET /api/export now accepts a date range (?from=&to= on paid_date) in addition
  to ?year=, for CSV or JSON; filename derived from the range. Validates the
  range (both bounds, from<=to).
- New GET /api/export/user-json — full portable JSON of the user's data, reusing
  the same getUserExportData assembly as the SQLite/Excel exports (money via
  fromCents).
- UI (DownloadMyDataSection): a JSON export card plus a "Payments export" with
  From/To dates and a CSV/JSON toggle; shared blob-download helper; toasts and
  client-side range validation.

Tests: tests/exportRicher.test.js (JSON assembly in dollars, year vs range
filtering, CSV filename, bad-range rejection). Server 134 pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:15:36 -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 c7b110cd68 feat(data): actionable badges, health dots, at-a-glance stats, palette links (Batch 2)
- Transactions nav shows a live "N to review" badge (unmatched count from the
  bank-ledger summary, limit:1 so it's cheap; refreshes on sync/import).
- Bank sync nav shows a green/amber/grey health dot (connected / needs-attention
  / off), mirroring the hero tone.
- Connection hero connected line now shows the transaction count at a glance
  ("SimpleFIN · 1,159 transactions · synced 2h ago · syncs automatically").
- Command palette gains Data section deep-links (Bank sync / Transactions /
  Import / Export) via ?section=.
- Count/stat fetch is non-blocking (.catch → 0), never blocks the page.

Build clean; client suite 46 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:05:25 -05:00
null 6a1b2f62b2 feat(data): two-pane shell + connection hero + deep-linking (Batch 1)
Rewrite the Data page shell into a settings-style two-pane layout (sticky goal
-nav on desktop, segmented on mobile) with:
- ConnectionHero — 5 states so a network blip is never mistaken for "not
  connected" and a server without SimpleFIN never shows a dead Connect button
  (loading / disabled / error+retry / not-connected / connected±needs-attention);
  Sync-now handles partial errors, 429, and failure with toasts.
- DataNav — <nav> landmark, aria-current, keyboard, responsive.
- ?section= deep-linking via useSearchParams (URL source of truth → localStorage
  → default; migrates the old 3-tab key), so refresh/back-button work.
- Goal-based regroup into 4 panes with plain-language titles/subtitles/icons
  passed via cardProps (every section component reused unchanged).
- Lazy panes: ImportSpreadsheet/ImportMyData code-split (own chunks) + only the
  active pane mounts; framer-motion cross-fade (reduced-motion aware);
  focus-to-heading on switch.
- Repoint BankTransactions "Open Data" → ?section=bank-sync; add /data to the
  authed axe sweep.

Build clean (heavy panes split into their own chunks); client suite 46 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:02:36 -05:00
null 212117a61a feat(data): modernize SectionCard chrome (Batch 0 — Data overhaul)
Replace the tiny grey uppercase section titles with a modern header: optional
leading icon in a soft chip, sentence-case high-contrast title, calm subtitle,
a right-aligned rotating chevron, and optional statusDot/badge slots. API is
unchanged (title/subtitle/collapsible/summary/storageKey/actions preserved) so
no section internals change — purely the shared card chrome for the Data page.

Build clean; client suite 46 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:57:03 -05:00
null 5aa5c0cc0e docs(qa): mark IMP-CODE-02 fully shipped (database.js 4174 -> 1297)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:34:37 -05:00
null 12d9d4c5a8 refactor(db): extract legacy-reconcile migrations + lock version-sync (IMP-CODE-02)
Second half of the migrations split: moved the ~830-line reconcileLegacyMigrations
array into db/migrations/legacyReconcileMigrations.js via the same
buildLegacyReconcileMigrations(deps) factory (same injected db + helpers; no
inline requires in this one). database.js is now 1,297 lines — down from 4,174
at the start of IMP-CODE-02 (~69%).

Added tests/migrationModules.test.js locking the invariants database.js depends
on: both modules build, versioned versions are unique, and every legacy-reconcile
version has a versioned counterpart (the drift the in-app assertion warns about).

Verified: full suite 125 pass; fresh DB applies all 79 migrations (reconcile
path included — a fresh schema.sql DB triggers it) and is idempotent; real prod
DB copy (v1.06) migrates as a no-op with data intact and no version-sync drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:34:01 -05:00
null 026c6a56b8 refactor(db): extract the versioned migrations array into its own module (IMP-CODE-02)
db/database.js carried a ~1,740-line inline `const migrations = [...]` inside
runMigrations(). Moved it to db/migrations/versionedMigrations.js as a factory,
buildVersionedMigrations(deps), injected with the live `db` connection and the
few schema helpers the migration bodies close over (isValidColumnName,
isValidSqlDefinition, ensureTransactionFoundationSchema, and the four
run*Migration helpers). Behavior is identical — the run/check closures resolve
the same bindings, just passed in rather than captured.

Fixed the two path references that broke by moving one directory deeper: the
inline require('../services/...') calls and the __dirname docs JSON require now
use ../../.

database.js: 3,859 → 2,119 lines. Verified: full server suite 122 pass; a fresh
DB applies all 79 migrations and is idempotent on a second boot; the real prod
DB copy (v1.06) migrates as a clean no-op with data intact and no version-sync
drift between the runMigrations and reconcileLegacyMigrations version lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:31:00 -05:00
null 78ad63dda4 docs(qa): IMP-UX-02 state audit complete — no gaps found
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:07:56 -05:00
null 318ffa368e docs(qa): archive IMP-CODE-02 partial (catalog seed extracted)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:05:44 -05:00
null 7f2faeaede refactor(db): extract subscription-catalog seed data out of database.js (IMP-CODE-02)
db/database.js was 4,174 lines. The largest self-contained block was ~315
lines of static subscription_catalog seed rows (SUBSCRIPTION_CATALOG_ROWS +
SUBSCRIPTION_CATALOG_V2_ROWS) — pure data, no logic. Moved it to
db/subscriptionCatalogSeed.js and imported it; database.js is now 3,859 lines
and the seed data lives in a cohesive, obvious module.

Behavior-preserving: a fresh DB still seeds 291 catalog rows; full server
suite (122) passes — migrations and the catalog seed run on every fresh test DB.

The migrations array (~2,700 lines) is the other big block but is the DB core;
splitting it safely needs a dedicated pass (tracked in the backlog), not a blind
move — a mistake there corrupts every DB on boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:05:16 -05:00
null 9aa312082d docs(qa): archive IMP-CODE-03 (canonical match-state writer)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:02:47 -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 c47638a373 docs(qa): archive IMP-UX-01 (recently-deleted restore view)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:57:08 -05:00
null aace5a4356 feat(bills): "Recently deleted" restore view for the 30-day window (IMP-UX-01)
Bills soft-delete and are retained 30 days, but the only way back was the
transient "Undo" toast — dismiss it and a bill deleted an hour ago was
unrecoverable from the UI (even though the API and retention kept it).

- GET /api/bills/deleted lists soft-deleted bills still inside the recovery
  window, newest first, with days_left (declared before /:id). User-scoped.
- BillsPage shows a "Recently deleted (N)" button when any exist, opening a
  dialog to restore each one; restoring refreshes the active list too.
- The list fetch is non-blocking (never blanks the page); restore is
  try/catch + toast; dialog has empty and per-row busy states.

Tests: tests/billsDeletedRoute.test.js (window filter, ordering, days_left,
money serialization, user isolation). Server 116 pass; client 46; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:56:45 -05:00
null e09025430b docs(qa): archive IMP-IA-01 (Data in primary nav)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:50:43 -05:00
null 0b1c6a8322 feat(nav): surface Data in the primary app menu (IMP-IA-01)
Import/export/backups (/data) was only reachable from the account overflow
dropdown + command palette — buried for how central it is. Move it into the
main Tracker nav menu alongside Bills/Categories/Spending/… so it appears in
both the desktop dropdown and the mobile nav.

Preserves the existing gate: Data stays hidden for the default-admin account
(new `accountToolsOnly` flag on the nav item, filtered by the same
`!user.is_default_admin` check the account dropdown used). Removed the now
-redundant account-dropdown entry; command palette entry unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:50:07 -05:00
null 54484ec8a0 docs(qa): archive IMP-CODE-01 (money formatter consolidation)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:47:07 -05:00
null a15f00c568 refactor(client): single source of truth for money formatting (IMP-CODE-01)
The client had ~15 hand-rolled currency formatters (local `fmt`/`money`/
`fmtFull`/`fmtDollars`/…) plus a canonical `fmt` in lib/utils used at ~190
sites — same rules copy-pasted, with inconsistent handling of negatives,
whole-dollar, cents vs dollars, and blank input.

Add client/lib/money.js as the one implementation:
  - formatUSD(dollars)      — "$1,234.56" (whole/dash options)
  - formatUSDWhole(dollars) — "$1,235"
  - formatCentsUSD(cents)   — from integer cents; signed "+/-" and dash options
Inputs are coerced so null/''/NaN never render as "$NaN", and -0 is
normalized so it never shows as "-$0.00".

lib/utils.fmt now delegates to formatUSD (byte-identical — the existing
utils.test.js fmt suite is the regression guard), and the 15 local
formatters delegate to money.js. No display currency formatting remains
outside money.js; the /100 conversions left behind are calculations
(form prefill), not display.

Tests: client/lib/money.test.js (13). Full client suite 46 pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:46:22 -05:00
null 6f5ad9a015 docs(qa): record real end-to-end SimpleFIN sync validation
Ran syncDataSource (the Sync-button path) against the live SimpleFIN
connection off a working copy of the production DB: token decrypted via the
db-key fallback (no TOKEN_ENCRYPTION_KEY in prod env), bridge fetch OK,
18 accounts upserted, 145 fetched transactions skipped-not-duplicated,
0 new, 1159->1159 distinct. Dedup/upsert idempotency proven on real data.
Updated B8 data-state and the Cycle Log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:26:22 -05:00
null cc6332731f docs(qa): add improvement lens + B17/B18 batches + seeded backlog
Expand the QA plan beyond correctness to also hunt for improvements, per
three lenses woven into the execution model:
  - Code health & consolidation (B17): duplication, dead code, overlapping
    modules, oversized files, one canonical path per concern.
  - UX (B18): core-flow friction, empty/loading/error states, feedback.
  - Information architecture / menus (B18): discoverability, surfacing
    actions into sensible menus, nav grouping.

Adds an Improvement Backlog (§2.1) for IMP proposals (separate from the bug
log; non-gating), detailed B17/B18 playbooks, and batch-table rows. Seeded
the backlog with 6 concrete, code-verified candidates (client money-format
consolidation, db/database.js split, match-service overlap, Data/menu IA,
recently-deleted restore view, empty/loading/error-state audit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:23:37 -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 07b6a04a97 chore: gitignore tests/Site-BKUP-Test/ (live DB copy — never commit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:42:58 -05:00
null a5671ab3be fix(qa): harden DB file permissions — was world-readable 644 (QA-B16-02)
docker-entrypoint chmod 700'd the data dir but never the DB file; SQLite created
bills.db/-wal/-shm at umask 644 (world-readable), holding financial data +
encrypted SimpleFIN token/sessions/secrets. Add `umask 077` (files 600, dirs 700)
+ explicit chmod 600 of any pre-existing DB files on upgrade. Found on the live
nebula deploy (BillTracker.db was 644).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:32:39 -05:00
null fc2daf2e9e docs(qa): Cycle 1 header — 15 findings, B16 complete
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:06:24 -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 e8190170dc docs(qa): B16 execution — log QA-B16-01 (version check "optional" but no opt-out)
Ran the quick B16 checks: encryption-key lifecycle safe (hasKey guard + v2
db-key fallback → graceful, no plaintext), migrations idempotent. Found: the
privacy policy calls the update/version check "optional" but there is no opt-out
setting, and it hits a hardcoded host on About/Status/version load. Logged S4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:55:58 -05:00
null 9876207781 docs(qa): add B16 (migrations, secrets & deploy) — close plan coverage gaps
Gap analysis of the codebase vs the plan surfaced surfaces with no QA home:
- DB migration system (idempotency/rollback/fresh==migrated, money conversions)
- encryption-key lifecycle (missing/rotated key → graceful degrade, no plaintext/leak)
- container deploy (docker-entrypoint: dir perms chmod 700, non-root, run migrations)
- update-check phone-home (external request → disclosed + opt-out)
- rate-limiter completeness (backupOperationLimiter, skipRateLimitIfNoUsers)

Added the B16 batch + playbook, and extended B0 recon to enumerate
middleware/workers/migrations/deploy so future cycles can't miss them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:53:37 -05:00
null d82aa06652 docs(qa): Cycle 1 sign-off — all 17 batches run & complete, re-run clean
- mark every batch B0→B15 + B-UI  (automatable scope run, green, findings archived)
- cycle log: Phase 2 complete (14 fixed) + clean automated re-run (0 new)
- document non-blocking external-infra residuals carried to Cycle 2
- exit criteria all checked

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:13:39 -05:00
null 5ffe2db85a test(qa): export→import round-trip preserves money (B9 data integrity)
- extract buildUserDbExportFile() from routes/export.js so the SQLite user-DB
  export is testable (route behavior unchanged)
- tests/exportImportRoundTrip.test.js: export user A (bill/payment/override) →
  import into fresh user B → assert all money survives exactly in cents. Confirms
  the export(fromCents)/import(toCents) conversion is symmetric — no 100x drift —
  and guards it from regressing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:26:49 -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 972daa9b07 docs(qa): mark B-UI batch probed (primitive behavior spec added)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:04:14 -05:00