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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
- 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>
- 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>
- e2e/b-ui.spec.js: functional checks axe can't assert — Add Bill dialog opens
with a focus trap and Esc cancels with no bill created (Cancel = no side
effect); the category Select opens by mouse and keyboard and lists options;
the sort-direction button stays inert (disabled) in Custom order. Read-only,
so safe in the parallel suite. Directly covers the B-UI batch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- tests/summarySkipOverride.test.js: verifies the Summary excludes skipped bills
from the monthly total and applies per-month amount overrides, alongside the
QA-B5-01 occurrence gate (guards both from regressing together)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- api.probe: assert a regular user is 403 on /api/admin/*, /api/status,
/api/about-admin (read + write) — B1/B11 authorization
- confirmed (static): settings PUT whitelists USER_SETTING_KEYS (no
mass-assignment), notifications route splits requireAdmin/requireUser
- docs: mark B10/B11/B12 probed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- scripts/prod-smoke.js + prod-smoke.sh: build, boot `node server.js` serving
dist/ against a scratch DB, and drive the real artifact with Playwright
(login + lazy routes) to confirm the vendor-chunk split loads in production
- npm run smoke:prod; passes green
- docs: B15 harness command + status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- routes/summary buildBankTracking: fetch unpaid candidates and filter by
resolveDueDate in JS so annual / off-month quarterly bills don't inflate the
SimpleFIN "unpaid this month" metric (completes the occurrence-gating family)
- add tests/summaryBankTracking.test.js (isolated route test)
- docs: archive QA-B5-02; Active Findings Log now empty (0 open)
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>
- routes/summary: filter the expense list by resolveDueDate so annual and
off-month quarterly bills no longer inflate the monthly total / "monthly
result" — the Summary now agrees with the Tracker for the same month (QA-B5-01)
- add a Tracker<->Summary reconciliation guard in e2e/api.probe.spec.js
- docs: archive QA-B5-01; track QA-B5-02 (SimpleFIN unpaid_this_month residual)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- utils/money: toCents rounds off the shortest decimal string instead of
Math.round(n*100), so 1.005 -> 101 (not 100). Output is identical for all
integer / <=2-decimal / "$1,234.56" inputs, so no downstream change (QA-B7-01)
- add tests/money.test.js (9 tests; the money core previously had none)
- docs: archive QA-B7-01 to HISTORY v0.41.0; QA cycle 1 now 0 open findings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CategoriesPage: category rows are now a plain container with a dedicated
chevron toggle button, instead of role=button rows nesting action buttons
- PlanStatusBanner: split the collapsible header into a name/progress toggle,
sibling action buttons, and a chevron toggle (actions no longer nested in the
trigger button)
- add e2e/categories.spec.js expand regression; all 8 authed pages now pass axe
- docs: archive QA-B14-02 to HISTORY v0.41.0; QA plan status/cycle-log
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace all Save buttons on the Settings page with debounced auto-save:
- useAutoSave hook: debounce with latest-payload-wins, flush() for blur,
pending-edit flush on unmount, status machine (idle/saving/saved/error)
with saved fading back to idle. Covered by 6 Vitest tests (fake timers).
- SaveStatus pill (framer-motion) in the page header and notification card
headers — Saving…/Saved/Save failed.
- Timing per control: toggles/selects/channel ~150-400ms; typed inputs
(email, URLs, grace period, drift pct) 900ms + flush on blur.
- Push token never auto-saves mid-type: saves on blur only, so a partial
token can never overwrite a working one.
- Notification cards no longer refetch parent settings on save (would
clobber in-flight edits under auto-save).
- Decision: no undo toast — settings are non-destructive and instantly
re-editable; undo would add noise without safety.
- vitest include now picks up .jsx tests; jsdom + @testing-library/react
added as devDependencies.