Compare commits

...

24 Commits

Author SHA1 Message Date
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
46 changed files with 4009 additions and 3057 deletions

4
.gitignore vendored
View File

@ -36,3 +36,7 @@ mkdocs/
/bills.db /bills.db
Mobile App/PLAN.md Mobile App/PLAN.md
mobile/ mobile/
# LIVE production DB copy for SimpleFIN testing — REAL financial data + secrets.
# NEVER commit. Whole dir is ignored.
tests/Site-BKUP-Test/

View File

@ -3,6 +3,9 @@
### 🐛 QA Fixes ### 🐛 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 (`<strong>${bill.name}</strong> is due…`), so a bill named `<img src=x onerror=…>` 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) - **[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 (`<strong>${bill.name}</strong> is due…`), so a bill named `<img src=x onerror=…>` 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) - **[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) - **[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)
@ -13,8 +16,16 @@
- **[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) - **[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) - **[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 ### 🧹 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) - **[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) - **[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) - **[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)

View File

@ -210,6 +210,7 @@ export const api = {
// Bills // Bills
bills: (params = {}) => get(`/bills${queryString(params)}`), bills: (params = {}) => get(`/bills${queryString(params)}`),
allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`),
deletedBills: () => get('/bills/deleted'),
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
bill: (id) => get(`/bills/${id}`), bill: (id) => get(`/bills/${id}`),
createBill: (data) => post('/bills', data), createBill: (data) => post('/bills', data),
@ -344,6 +345,8 @@ export const api = {
roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`), roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`),
updateStatus: () => get('/version/update-status'), updateStatus: () => get('/version/update-status'),
checkForUpdates: () => post('/about-admin/check-updates'), 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'), devLog: () => get('/about-admin/dev-log'),
version: () => get('/version'), version: () => get('/version'),
releaseHistory: () => get('/version/history'), releaseHistory: () => get('/version/history'),

View File

@ -6,7 +6,7 @@ import {
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X, AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn, fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog'; import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
@ -344,7 +344,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span> <span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
<span className="shrink-0 font-mono text-muted-foreground tabular-nums"> <span className="shrink-0 font-mono text-muted-foreground tabular-nums">
${amountVal.toFixed(2)} {fmt(amountVal)}
</span> </span>
</button> </button>
); );

View File

@ -1,5 +1,6 @@
import { useActionState, useEffect, useState } from 'react'; import { useActionState, useEffect, useState } from 'react';
import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { formatCentsUSD } from '@/lib/money';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -62,13 +63,7 @@ const SUBSCRIPTION_TYPES = [
]; ];
function fmtTransactionAmount(amount, currency = 'USD') { function fmtTransactionAmount(amount, currency = 'USD') {
const cents = Number(amount || 0); return formatCentsUSD(amount, { signed: true, currency });
const value = Math.abs(cents) / 100;
const sign = cents < 0 ? '-' : '+';
return `${sign}${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'USD',
}).format(value)}`;
} }
function transactionDate(tx) { function transactionDate(tx) {

View File

@ -1,6 +1,7 @@
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react'; import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule'; import { scheduleLabel } from '@/lib/billingSchedule';
import { formatUSDWhole } from '@/lib/money';
import { MobileBillRow } from '@/components/MobileBillRow'; import { MobileBillRow } from '@/components/MobileBillRow';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@ -178,7 +179,7 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
<> <>
{(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && <span className="text-border">·</span>} {(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && <span className="text-border">·</span>}
<span className="tracker-number text-[11px] font-medium text-muted-foreground"> <span className="tracker-number text-[11px] font-medium text-muted-foreground">
${Number(bill.current_balance).toLocaleString(undefined, { maximumFractionDigits: 0 })} balance {formatUSDWhole(bill.current_balance)} balance
</span> </span>
</> </>
)} )}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatUSD } from '@/lib/money';
import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react'; import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -8,7 +9,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
function fmt(n) { function fmt(n) {
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); return formatUSD(n);
} }
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) { export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) {

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Recently deleted</DialogTitle>
<DialogDescription>
Deleted bills are kept for 30 days before theyre permanently removed. Restore one to bring it back.
</DialogDescription>
</DialogHeader>
{bills.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Trash2 className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Nothing to recover</p>
<p className="text-xs text-muted-foreground/70">Bills you delete will appear here for 30 days.</p>
</div>
) : (
<ul className="-mx-2 max-h-[55vh] divide-y divide-border/60 overflow-y-auto">
{bills.map(bill => {
const left = daysLeftLabel(bill.days_left);
const busy = busyId === bill.id;
return (
<li key={bill.id} className="flex items-center gap-3 px-2 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{bill.name}</p>
<p className="truncate text-xs text-muted-foreground">
{formatUSD(bill.expected_amount)}
{bill.category_name ? ` · ${bill.category_name}` : ''}
{left ? <span className="text-muted-foreground/60"> · {left}</span> : null}
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 shrink-0 gap-1.5"
disabled={busy}
onClick={() => handleRestore(bill)}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
Restore
</Button>
</li>
);
})}
</ul>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -4,10 +4,10 @@ import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatUSD } from '@/lib/money';
function fmtAmt(dollars) { function fmtAmt(dollars) {
if (dollars == null) return '—'; return formatUSD(dollars, { dash: true });
return `$${Number(dollars).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
} }
function fmtDate(iso) { function fmtDate(iso) {

View File

@ -6,6 +6,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatUSD, formatCentsUSD } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@ -77,10 +78,7 @@ function fmtShortDate(date) {
} }
function fmtDollars(cents) { function fmtDollars(cents) {
if (cents == null) return '—'; return formatCentsUSD(cents, { dash: true });
const abs = Math.abs(cents) / 100;
const sign = cents < 0 ? '-' : '';
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
} }
function MatchBadge({ status, billName }) { function MatchBadge({ status, billName }) {
@ -867,7 +865,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
<span>{acc.org_name ? `${acc.org_name}` : ''}{acc.name}</span> <span>{acc.org_name ? `${acc.org_name}` : ''}{acc.name}</span>
{acc.balance_dollars !== null && ( {acc.balance_dollars !== null && (
<span className="ml-auto text-xs text-muted-foreground tabular-nums"> <span className="ml-auto text-xs text-muted-foreground tabular-nums">
${acc.balance_dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {formatUSD(acc.balance_dollars)}
</span> </span>
)} )}
</div> </div>

View File

@ -46,6 +46,7 @@ const trackerItems = [
{ to: '/bank-transactions', icon: Landmark, label: 'Banking', simplefinOnly: true }, { to: '/bank-transactions', icon: Landmark, label: 'Banking', simplefinOnly: true },
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' }, { to: '/snowball', icon: TrendingDown, label: 'Snowball' },
{ to: '/payoff', icon: Calculator, label: 'Payoff' }, { to: '/payoff', icon: Calculator, label: 'Payoff' },
{ to: '/data', icon: Database, label: 'Data', accountToolsOnly: true },
]; ];
function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems }) { function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems }) {
@ -164,10 +165,6 @@ function UserMenu({ adminMode = false }) {
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
Settings Settings
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate('/data')}>
<Database className="h-4 w-4" />
Data
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}
@ -199,9 +196,13 @@ export default function Sidebar({ adminMode = false }) {
const { data: overdueData } = useOverdueCount(); const { data: overdueData } = useOverdueCount();
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0; const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : []; const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
const accountToolsAllowed = !user?.is_default_admin;
const trackerMenuItems = useMemo( const trackerMenuItems = useMemo(
() => trackerItems.filter(item => !item.simplefinOnly || simplefinReady), () => trackerItems.filter(item => (
[simplefinReady], (!item.simplefinOnly || simplefinReady)
&& (!item.accountToolsOnly || accountToolsAllowed)
)),
[simplefinReady, accountToolsAllowed],
); );
useEffect(() => { useEffect(() => {

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { formatUSD } from '@/lib/money';
const W = 720; const W = 720;
const H = 300; const H = 300;
@ -13,9 +14,7 @@ function money(v) {
} }
function fullMoney(v) { function fullMoney(v) {
return (Number(v) || 0).toLocaleString(undefined, { return formatUSD(v);
style: 'currency', currency: 'USD', maximumFractionDigits: 2,
});
} }
function buildPoints(track, startBalance, maxMonths) { function buildPoints(track, startBalance, maxMonths) {

View File

@ -2,17 +2,14 @@ import React, { useState } from 'react';
import { ChevronDown, History } from 'lucide-react'; import { ChevronDown, History } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatUSD, formatUSDWhole } from '@/lib/money';
function fmt(v) { function fmt(v) {
return (Number(v) || 0).toLocaleString(undefined, { return formatUSDWhole(v);
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
});
} }
function fmtFull(v) { function fmtFull(v) {
return (Number(v) || 0).toLocaleString(undefined, { return formatUSD(v);
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
} }
function dateRange(plan) { function dateRange(plan) {

View File

@ -7,11 +7,10 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatUSDWhole } from '@/lib/money';
function fmt(v) { function fmt(v) {
return (Number(v) || 0).toLocaleString(undefined, { return formatUSDWhole(v);
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
});
} }
function dateLabel(iso) { function dateLabel(iso) {

View File

@ -3,6 +3,7 @@ import {
CheckCircle2, Loader2, Link2, Search, Plus, CheckCircle2, Loader2, Link2, Search, Plus,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatCentsUSD } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
@ -19,12 +20,7 @@ export function transactionTitle(tx) {
} }
export function formatTransactionAmount(amount, currency = 'USD') { export function formatTransactionAmount(amount, currency = 'USD') {
const value = Math.abs(Number(amount || 0)) / 100; return formatCentsUSD(amount, { signed: true, currency });
const sign = Number(amount || 0) < 0 ? '-' : '+';
return `${sign}${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'USD',
}).format(value)}`;
} }
export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) { export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) {

62
client/lib/money.js Normal file
View File

@ -0,0 +1,62 @@
// Currency formatting for the client, mirroring the server's utils/money.js.
//
// The API sends money in two units: bill / summary values are serialized as
// DOLLARS (the server calls fromCents before responding), while raw bank
// transaction amounts arrive as integer CENTS. So there are two entry points —
// formatUSD(dollars) and formatCentsUSD(cents) — matching the server's
// formatUSD / formatCentsUSD split. USD / en-US throughout (the app is USD-only,
// and this matches the server). Inputs are coerced defensively so null, '',
// undefined, or NaN never render as "$NaN".
const DASH = '—';
function toNumber(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 0;
return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00"
}
function isBlank(value) {
return value === null || value === undefined || value === '';
}
/**
* Format a DOLLAR amount "$1,234.56".
* @param {number|string|null} dollars
* @param {{ whole?: boolean, dash?: boolean }} [opts]
* whole drop the cents ("$1,235"); dash render blank input as "—" not "$0.00".
*/
export function formatUSD(dollars, { whole = false, dash = false } = {}) {
if (dash && isBlank(dollars)) return DASH;
return toNumber(dollars).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: whole ? 0 : 2,
maximumFractionDigits: whole ? 0 : 2,
});
}
/** Whole-dollar convenience → "$1,235". */
export function formatUSDWhole(dollars, opts = {}) {
return formatUSD(dollars, { ...opts, whole: true });
}
/**
* Format an integer-CENTS amount (e.g. a bank transaction) "$12.34".
* @param {number|string|null} cents
* @param {{ signed?: boolean, dash?: boolean, currency?: string }} [opts]
* signed prefix "+"/"-" (income vs expense); dash blank input "—";
* currency ISO code (defaults USD).
*/
export function formatCentsUSD(cents, { signed = false, dash = false, currency = 'USD' } = {}) {
if (dash && isBlank(cents)) return DASH;
const c = toNumber(cents);
const body = (Math.abs(c) / 100).toLocaleString('en-US', {
style: 'currency',
currency: currency || 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
if (signed) return (c < 0 ? '-' : '+') + body;
return (c < 0 ? '-' : '') + body;
}

71
client/lib/money.test.js Normal file
View File

@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { formatUSD, formatUSDWhole, formatCentsUSD } from './money';
describe('formatUSD (dollars in)', () => {
it('formats to two decimals with grouping', () => {
expect(formatUSD(1234.5)).toBe('$1,234.50');
expect(formatUSD(0)).toBe('$0.00');
expect(formatUSD(8.99)).toBe('$8.99');
});
it('formats negatives', () => {
expect(formatUSD(-12.34)).toBe('-$12.34');
});
it('normalizes negative zero to "$0.00" (not "-$0.00")', () => {
expect(formatUSD(-0)).toBe('$0.00');
expect(formatUSD(Math.round(-0.2))).toBe('$0.00'); // Math.round(-0.2) === -0
});
it('coerces blank / non-numeric to $0.00 (never NaN)', () => {
expect(formatUSD(null)).toBe('$0.00');
expect(formatUSD(undefined)).toBe('$0.00');
expect(formatUSD('')).toBe('$0.00');
expect(formatUSD('not a number')).toBe('$0.00');
});
it('accepts numeric strings', () => {
expect(formatUSD('1234.5')).toBe('$1,234.50');
});
it('whole:true drops the cents', () => {
expect(formatUSD(1234.56, { whole: true })).toBe('$1,235');
expect(formatUSDWhole(1234.4)).toBe('$1,234');
});
it('dash:true renders blank input as an em dash, but 0 as $0.00', () => {
expect(formatUSD(null, { dash: true })).toBe('—');
expect(formatUSD(undefined, { dash: true })).toBe('—');
expect(formatUSD('', { dash: true })).toBe('—');
expect(formatUSD(0, { dash: true })).toBe('$0.00');
});
});
describe('formatCentsUSD (integer cents in)', () => {
it('converts cents to dollars', () => {
expect(formatCentsUSD(1234)).toBe('$12.34');
expect(formatCentsUSD(0)).toBe('$0.00');
expect(formatCentsUSD(99)).toBe('$0.99');
});
it('unsigned negatives get a leading minus only', () => {
expect(formatCentsUSD(-1234)).toBe('-$12.34');
});
it('signed:true adds +/- for income vs expense', () => {
expect(formatCentsUSD(1234, { signed: true })).toBe('+$12.34');
expect(formatCentsUSD(-1234, { signed: true })).toBe('-$12.34');
expect(formatCentsUSD(0, { signed: true })).toBe('+$0.00');
});
it('dash:true renders blank input as an em dash', () => {
expect(formatCentsUSD(null, { dash: true })).toBe('—');
expect(formatCentsUSD(undefined, { dash: true })).toBe('—');
expect(formatCentsUSD(0, { dash: true })).toBe('$0.00');
});
it('coerces non-numeric to $0.00 (never NaN)', () => {
expect(formatCentsUSD('abc')).toBe('$0.00');
expect(formatCentsUSD(null)).toBe('$0.00');
});
});

View File

@ -1,14 +1,16 @@
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { formatUSD } from './money';
export function cn(...inputs) { export function cn(...inputs) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
// Canonical dollar formatter for the app ("$1,234.50"). Kept here for the many
// existing call sites; the implementation lives in ./money (formatUSD) so all
// currency formatting has a single source of truth.
export function fmt(amount) { export function fmt(amount) {
const n = Number(amount) || 0; return formatUSD(amount);
const sign = n < 0 ? '-' : '';
return sign + '$' + Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
} }
export function fmtDate(dateStr) { export function fmtDate(dateStr) {

View File

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { formatUSD, formatUSDWhole } from '@/lib/money';
import { Printer, RefreshCw, RotateCcw } from 'lucide-react'; import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
@ -27,19 +28,11 @@ function currentMonth() {
} }
function money(value) { function money(value) {
return (Number(value) || 0).toLocaleString(undefined, { return formatUSDWhole(value);
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
});
} }
function fullMoney(value) { function fullMoney(value) {
return (Number(value) || 0).toLocaleString(undefined, { return formatUSD(value);
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
});
} }
function formatRange(range) { function formatRange(range) {

View File

@ -23,6 +23,7 @@ import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
import BillsTableInner from '@/components/BillsTableInner'; import BillsTableInner from '@/components/BillsTableInner';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import RecentlyDeletedBillsDialog from '@/components/RecentlyDeletedBillsDialog';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
@ -601,6 +602,8 @@ export default function BillsPage() {
const [bills, setBills] = useState([]); const [bills, setBills] = useState([]);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [savedTemplates, setSavedTemplates] = useState([]); const [savedTemplates, setSavedTemplates] = useState([]);
const [deletedBills, setDeletedBills] = useState([]);
const [showDeleted, setShowDeleted] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showInactive, setShowInactive] = useState(false); const [showInactive, setShowInactive] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@ -640,14 +643,16 @@ export default function BillsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const [billsRes, catRes, templateRes] = await Promise.all([ const [billsRes, catRes, templateRes, deletedRes] = await Promise.all([
api.allBills(), api.allBills(),
api.categories(), api.categories(),
api.billTemplates(), api.billTemplates(),
api.deletedBills().catch(() => []), // non-critical: never block the page
]); ]);
setBills(billsRes || []); setBills(billsRes || []);
setCategories(catRes || []); setCategories(catRes || []);
setSavedTemplates(templateRes || []); setSavedTemplates(templateRes || []);
setDeletedBills(deletedRes || []);
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
} finally { } finally {
@ -783,6 +788,16 @@ export default function BillsPage() {
} }
} }
async function handleRestoreDeleted(bill) {
try {
await api.restoreBill(bill.id);
toast.success(`"${bill.name}" restored`);
await load();
} catch (err) {
toast.error(err.message || 'Failed to restore bill');
}
}
async function handleDeleteAlternative() { async function handleDeleteAlternative() {
if (!deleteTarget) return; if (!deleteTarget) return;
const bill = deleteTarget; const bill = deleteTarget;
@ -940,6 +955,17 @@ export default function BillsPage() {
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
{deletedBills.length > 0 && (
<Button
type="button"
variant="outline"
onClick={() => setShowDeleted(true)}
className="h-9 flex-1 gap-2 px-3 text-sm sm:flex-none"
>
<Trash2 className="h-4 w-4" />
Recently deleted ({deletedBills.length})
</Button>
)}
<Button <Button
onClick={() => setModal({ bill: null })} onClick={() => setModal({ bill: null })}
className="h-9 flex-1 gap-2 px-4 text-sm font-medium sm:flex-none" className="h-9 flex-1 gap-2 px-4 text-sm font-medium sm:flex-none"
@ -1142,6 +1168,13 @@ export default function BillsPage() {
/> />
)} )}
<RecentlyDeletedBillsDialog
open={showDeleted}
onOpenChange={setShowDeleted}
bills={deletedBills}
onRestore={handleRestoreDeleted}
/>
{/* ── Deactivate confirmation (replaces window.confirm) ── */} {/* ── Deactivate confirmation (replaces window.confirm) ── */}
<AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}> <AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}>
<AlertDialogContent> <AlertDialogContent>

View File

@ -10,6 +10,7 @@ import {
SelectSeparator, SelectTrigger, SelectValue, SelectSeparator, SelectTrigger, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatUSD, formatUSDWhole } from '@/lib/money';
import PayoffChart from '@/components/snowball/PayoffChart'; import PayoffChart from '@/components/snowball/PayoffChart';
// Print isolation // Print isolation
@ -36,14 +37,11 @@ const PRINT_STYLES = `
// Helpers // Helpers
function fmt(v) { function fmt(v) {
return (Number(v) || 0).toLocaleString(undefined, { return formatUSD(v);
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
} }
function fmtShort(v) { function fmtShort(v) {
const n = Number(v) || 0; return formatUSDWhole(v);
return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
} }
function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) { function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) {

View File

@ -14,19 +14,15 @@ import { makeBillDraft } from '@/lib/billDrafts';
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner'; import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel'; import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel';
import * as AlertDialog from '@radix-ui/react-alert-dialog'; import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { formatUSD, formatUSDWhole } from '@/lib/money';
// formatters // formatters
function fmt(val) { function fmt(val) {
if (val == null) return '—'; return formatUSD(val, { dash: true });
return Number(val).toLocaleString(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
} }
function fmtCompact(val) { function fmtCompact(val) {
if (val == null || val === 0) return '—'; if (val == null || val === 0) return '—';
return Number(val).toLocaleString(undefined, { return formatUSDWhole(val);
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
});
} }
function ordinal(n) { function ordinal(n) {
const d = Number(n); const d = Number(n);

View File

@ -14,11 +14,12 @@ import {
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { CategoryPicker } from '@/components/transactions/CategoryPicker'; import { CategoryPicker } from '@/components/transactions/CategoryPicker';
import { formatUSD } from '@/lib/money';
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmt(n) { function fmt(n) {
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); return formatUSD(n);
} }
function settingEnabled(value, fallback = true) { function settingEnabled(value, fallback = true) {

View File

@ -9,6 +9,7 @@ import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmtUptime, fmtBytes } from '@/lib/utils'; import { cn, fmtUptime, fmtBytes } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { MarkdownText } from '@/components/MarkdownText'; import { MarkdownText } from '@/components/MarkdownText';
// Helpers // Helpers
@ -128,7 +129,7 @@ function SkeletonCard() {
const CATEGORY_ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security']; const CATEGORY_ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
function UpdateCard({ update, onCheckNow, checking }) { function UpdateCard({ update, onCheckNow, checking, enabled = true, onToggle }) {
const hasUpdate = !!update.has_update; const hasUpdate = !!update.has_update;
const isKnown = update.up_to_date !== null && update.up_to_date !== undefined; const isKnown = update.up_to_date !== null && update.up_to_date !== undefined;
const hasError = !!update.error; const hasError = !!update.error;
@ -173,8 +174,15 @@ function UpdateCard({ update, onCheckNow, checking }) {
{update.error && ( {update.error && (
<p className="text-[11px] text-red-400 leading-relaxed pt-1.5">{update.error}</p> <p className="text-[11px] text-red-400 leading-relaxed pt-1.5">{update.error}</p>
)} )}
<div className="flex items-center justify-between gap-3 py-1.5 border-t border-border/40 mt-1">
<span className="text-xs text-muted-foreground">
Automatic version check
<span className="block text-[10px] text-muted-foreground/60">External request; off = no phone-home</span>
</span>
<Switch checked={enabled} onCheckedChange={onToggle} aria-label="Enable automatic version check" />
</div>
<div className="pt-3"> <div className="pt-3">
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking} <Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking || !enabled}
className="h-7 text-xs gap-1.5"> className="h-7 text-xs gap-1.5">
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} /> <RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
{checking ? 'Checking…' : 'Check Now'} {checking ? 'Checking…' : 'Check Now'}
@ -229,6 +237,7 @@ export default function StatusPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [updateData, setUpdateData] = useState(null); const [updateData, setUpdateData] = useState(null);
const [updateChecking, setUpdateChecking] = useState(false); const [updateChecking, setUpdateChecking] = useState(false);
const [updateEnabled, setUpdateEnabled] = useState(true);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@ -243,6 +252,10 @@ export default function StatusPage() {
const historyData = await api.releaseHistory(); const historyData = await api.releaseHistory();
setHistoryMeta({ version: historyData.version, updated_at: historyData.updated_at }); setHistoryMeta({ version: historyData.version, updated_at: historyData.updated_at });
} catch { setHistoryMeta(null); } } catch { setHistoryMeta(null); }
try {
const s = await api.getUpdateCheckSetting();
setUpdateEnabled(s.enabled !== false);
} catch { /* keep default */ }
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load status.'); toast.error(err.message || 'Failed to load status.');
} finally { } finally {
@ -263,6 +276,17 @@ export default function StatusPage() {
} }
}, []); }, []);
const handleToggleUpdateCheck = useCallback(async (enabled) => {
setUpdateEnabled(enabled); // optimistic
try {
await api.setUpdateCheckSetting(enabled);
if (enabled) handleCheckNow();
} catch (err) {
setUpdateEnabled(!enabled); // revert
toast.error(err.message || 'Failed to update setting');
}
}, [handleCheckNow]);
// Normalize the nested response shape // Normalize the nested response shape
const app = data?.application ?? data?.app ?? {}; const app = data?.application ?? data?.app ?? {};
const rt = data?.runtime ?? {}; const rt = data?.runtime ?? {};
@ -502,6 +526,8 @@ export default function StatusPage() {
update={updateData} update={updateData}
onCheckNow={handleCheckNow} onCheckNow={handleCheckNow}
checking={updateChecking} checking={updateChecking}
enabled={updateEnabled}
onToggle={handleToggleUpdateCheck}
/> />
)} )}
<ReleaseNotesCard version={version} historyMeta={historyMeta} /> <ReleaseNotesCard version={version} historyMeta={historyMeta} />

View File

@ -498,7 +498,7 @@ function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, bus
} }
function TxResultRow({ tx, onTrack }) { function TxResultRow({ tx, onTrack }) {
const dollars = (Math.abs(tx.amount) / 100).toFixed(2); const dollars = fmt(Math.abs(tx.amount) / 100);
const label = tx.payee || tx.description || tx.memo || '—'; const label = tx.payee || tx.description || tx.memo || '—';
const account = tx.account_name || tx.data_source_name || null; const account = tx.account_name || tx.data_source_name || null;
const isMatched = tx.match_status === 'matched'; const isMatched = tx.match_status === 'matched';
@ -545,7 +545,7 @@ function TxResultRow({ tx, onTrack }) {
{catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''} {catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''}
</p> </p>
</div> </div>
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">${dollars}</span> <span className="font-mono text-sm font-semibold tabular-nums shrink-0">{dollars}</span>
{!isMatched && ( {!isMatched && (
<Button size="sm" variant="outline" onClick={() => onTrack(tx)} <Button size="sm" variant="outline" onClick={() => onTrack(tx)}
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs"> className="h-8 shrink-0 gap-1.5 px-2.5 text-xs">

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,853 @@
'use strict';
// The legacy-reconcile migration list, extracted from db/database.js. Same
// factory pattern as versionedMigrations: each check/run body closes over the
// live db + schema helpers, injected here. Runs only for legacy databases whose
// schema predates migration tracking (and for a freshly schema.sql'd DB, whose
// schema_migrations starts empty). Consumed by reconcileLegacyMigrations().
module.exports = function buildLegacyReconcileMigrations(deps) {
const {
db,
isValidColumnName,
isValidSqlDefinition,
ensureTransactionFoundationSchema,
runSubscriptionCatalogMigration,
runSubscriptionCatalogV2Migration,
runAdvisoryFiltersMigration,
runMerchantStoreMatchMigration,
} = deps;
return [
{
version: 'v0.2',
description: 'payments: soft-delete column',
check: function() {
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
return paymentCols.includes('deleted_at');
},
run: function() {
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!paymentCols.includes('deleted_at')) {
db.exec('ALTER TABLE payments ADD COLUMN deleted_at TEXT');
// Index for fast filtering of live payments
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)');
console.log('[migration] payments.deleted_at column added');
}
}
},
{
version: 'v0.3',
description: 'payments: compound index for tracker query',
check: function() {
// Check if the index exists
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_bill_date_del'").all();
return indexes.length > 0;
},
run: function() {
// Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)');
}
},
{
version: 'v0.4',
description: 'monthly_bill_state: per-bill per-month overrides',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_bill_state'").all();
return tables.length > 0;
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_bill_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
actual_amount REAL,
notes TEXT,
is_skipped INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(bill_id, year, month)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
console.log('[migration] monthly_bill_state table ensured');
}
},
{
version: 'v0.13',
description: 'users: profile columns',
check: function() {
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const profileCols = ['display_name', 'last_password_change_at'];
return profileCols.every(col => userColsNow.includes(col));
},
run: function() {
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const profileCols = [
['display_name', 'TEXT'],
['last_password_change_at','TEXT'],
];
for (const [col, def] of profileCols) {
if (!userColsNow.includes(col)) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
}
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
}
}
}
},
{
version: 'v0.14',
description: 'bills: history visibility mode',
check: function() {
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return billColsHist.includes('history_visibility');
},
run: function() {
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billColsHist.includes('history_visibility')) {
db.exec("ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'");
console.log('[migration] bills.history_visibility column added');
}
}
},
{
version: 'v0.14.4',
description: 'bills: optional credit-card APR / interest rate',
check: function() {
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return billColsInterest.includes('interest_rate');
},
run: function() {
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billColsInterest.includes('interest_rate')) {
db.exec('ALTER TABLE bills ADD COLUMN interest_rate REAL');
console.log('[migration] bills.interest_rate column added');
}
}
},
{
version: 'v0.15',
description: 'import_sessions and import_history tables',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('import_sessions', 'import_history')").all();
return tables.length >= 2;
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS import_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
preview_json TEXT NOT NULL
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)');
// ── import_history: per-user audit log (v0.38) ────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS import_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
imported_at TEXT NOT NULL,
source_filename TEXT,
file_type TEXT DEFAULT 'xlsx',
sheet_name TEXT,
rows_parsed INTEGER DEFAULT 0,
rows_created INTEGER DEFAULT 0,
rows_updated INTEGER DEFAULT 0,
rows_skipped INTEGER DEFAULT 0,
rows_ambiguous INTEGER DEFAULT 0,
rows_errored INTEGER DEFAULT 0,
options_json TEXT,
summary_json TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)');
}
},
{
version: 'v0.17',
description: 'users: external identity / OIDC columns',
check: function() {
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const oidcUserCols = ['auth_provider', 'external_subject', 'email', 'last_login_at'];
return oidcUserCols.every(col => userColsOidc.includes(col));
},
run: function() {
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const oidcUserCols = [
['auth_provider', "TEXT NOT NULL DEFAULT 'local'"],
['external_subject', 'TEXT'],
['email', 'TEXT'],
['last_login_at', 'TEXT'],
];
for (const [col, def] of oidcUserCols) {
if (!userColsOidc.includes(col)) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
}
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
}
}
// ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ───
db.exec(`
CREATE TABLE IF NOT EXISTS oidc_states (
id TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
code_verifier TEXT NOT NULL,
redirect_to TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)');
}
},
{
version: 'v0.18.1',
description: 'monthly_income: per-user monthly income for Summary planning',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_income'").all();
return tables.length > 0;
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_income (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
label TEXT NOT NULL DEFAULT 'Salary',
amount REAL NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, year, month)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
}
},
{
version: 'v0.18.2',
description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_starting_amounts'").all();
return tables.length > 0;
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, year, month)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
}
},
{
version: 'v0.18.3',
description: 'monthly_starting_amounts: add other_amount column',
check: function() {
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
return startingCols.includes('other_amount');
},
run: function() {
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
if (!startingCols.includes('other_amount')) {
// Security FIX (2026-05-08): Validate column name to prevent SQL injection
if (!isValidColumnName('other_amount')) {
throw new Error('Invalid migration: column other_amount not in whitelist');
}
db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
console.log('[migration] monthly_starting_amounts.other_amount column added');
}
}
},
{
version: 'v0.38',
description: 'import_history: per-user audit log',
check: function() {
// Already handled in v0.15
return true;
},
run: function() {
// This was already handled in v0.15, but keeping for completeness
}
},
{
version: 'v0.40',
description: 'ownership: user-scoped bills/categories',
check: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
return billCols.includes('user_id') && categoryCols.includes('user_id');
},
run: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billCols.includes('user_id')) {
db.exec('ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
}
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!categoryCols.includes('user_id')) {
db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
}
const categorySql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'").get()?.sql || '';
if (/name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(categorySql)) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`
CREATE TABLE IF NOT EXISTS categories_v040 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
db.exec('INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories');
db.exec('DROP TABLE categories');
db.exec('ALTER TABLE categories_v040 RENAME TO categories');
db.exec('PRAGMA foreign_keys = ON');
}
const firstAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
if (firstAdmin) {
db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
// Drop any NULL-owner categories whose name already exists for this admin (case-insensitive)
// to prevent a UNIQUE(user_id, name) violation when we assign them below.
db.prepare(`
DELETE FROM categories
WHERE user_id IS NULL
AND LOWER(name) IN (
SELECT LOWER(name) FROM categories WHERE user_id = ?
)
`).run(firstAdmin.id);
db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
}
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)');
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)');
}
},
{
version: 'v0.41',
description: 'bills and categories: is_seeded flag for demo data cleanup',
check: function() {
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
return billColsSeeded.includes('is_seeded') && categoryColsSeeded.includes('is_seeded');
},
run: function() {
// ── bills: is_seeded flag for demo data cleanup (v0.41) ───────────────────
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billColsSeeded.includes('is_seeded')) {
db.exec('ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
console.log('[migration] bills.is_seeded column added');
}
// ── categories: is_seeded flag for demo data cleanup (v0.41) ──────────────
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!categoryColsSeeded.includes('is_seeded')) {
db.exec('ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
console.log('[migration] categories.is_seeded column added');
}
}
},
{
version: 'v0.42',
description: 'bill_history_ranges: per-bill date ranges for history visibility',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_history_ranges'").all();
return tables.length > 0;
},
run: function() {
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)');
}
},
{
version: 'v0.43',
description: 'sessions: add created_at column',
check: function() {
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
return sessionCols.includes('created_at');
},
run: function() {
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
if (!sessionCols.includes('created_at')) {
// Security FIX (2026-05-09): Validate column name to prevent SQL injection
if (!isValidColumnName('created_at')) {
throw new Error('Invalid migration: column created_at not in whitelist');
}
db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))");
console.log('[migration] sessions.created_at column added');
}
}
},
{
version: 'v0.44',
description: 'performance: add missing indexes for frequently queried columns',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_bills_user_name'").get();
},
run: function() {
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
}
},
{
version: 'v0.45',
description: 'audit: add audit_log table for security event tracking',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
},
run: function() {
db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
entity_type TEXT,
entity_id INTEGER,
details_json TEXT,
ip_address TEXT,
user_agent TEXT,
created_at TEXT DEFAULT (datetime('now'))
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at)');
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at)');
}
},
{
version: 'v0.46',
description: 'billing: add cycle_type and cycle_day columns to bills',
check: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return cols.includes('cycle_type') && cols.includes('cycle_day');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('cycle_type')) {
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
}
if (!cols.includes('cycle_day')) {
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
}
}
},
{
version: 'v0.47',
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
check: function() {
const row = db.prepare("SELECT value FROM settings WHERE key = 'backup_schedule_retention_count'").get();
return !row || row.value !== '14';
},
run: function() {
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
}
},
{
version: 'v0.48',
description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)',
check: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return ['current_balance', 'minimum_payment', 'snowball_order', 'snowball_include'].every(c => cols.includes(c));
},
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL');
if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL');
if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER');
if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0');
console.log('[migration] bills: debt snowball columns added');
}
},
{
version: 'v0.49',
description: 'users: snowball_extra_payment column',
check: function() {
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
return cols.includes('snowball_extra_payment');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!cols.includes('snowball_extra_payment')) {
db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0');
}
console.log('[migration] users: snowball_extra_payment column added');
}
},
{
version: 'v0.50',
description: 'payments: balance_delta column for debt payoff tracking',
check: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
return cols.includes('balance_delta');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!cols.includes('balance_delta')) {
db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL');
}
console.log('[migration] payments: balance_delta column added');
}
},
{
version: 'v0.51',
description: 'bills: snowball_exempt column for hiding debt-like bills',
check: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return cols.includes('snowball_exempt');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('snowball_exempt')) {
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
}
console.log('[migration] bills: snowball_exempt column added');
}
},
{
version: 'v0.52',
description: 'users: last_seen_version for release-notes notifications',
check: function() {
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
return cols.includes('last_seen_version');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!cols.includes('last_seen_version')) {
db.exec('ALTER TABLE users ADD COLUMN last_seen_version TEXT');
}
console.log('[migration] users: last_seen_version column added');
}
},
{
version: 'v0.53',
description: 'user_login_history: track last 3 logins per user',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_login_history'").get();
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS user_login_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
ip_address TEXT,
user_agent TEXT,
browser TEXT,
os TEXT,
device_type TEXT,
device_fingerprint TEXT
)
`);
console.log('[migration] user_login_history table created');
}
},
{
version: 'v0.54',
description: 'user_settings: per-user display and billing preferences',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'").get();
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (user_id, key)
)
`);
const userSettingKeys = ['currency', 'date_format', 'grace_period_days', 'notify_days_before'];
const users = db.prepare('SELECT id FROM users').all();
const getCurrent = db.prepare('SELECT value FROM settings WHERE key = ?');
const insert = db.prepare('INSERT OR IGNORE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
for (const user of users) {
for (const key of userSettingKeys) {
const row = getCurrent.get(key);
if (row) insert.run(user.id, key, row.value);
}
}
console.log('[migration] user_settings table ensured');
}
},
{
version: 'v0.55',
description: 'user_login_history: parsed device metadata and fingerprint',
check: function() {
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
return ['browser', 'os', 'device_type', 'device_fingerprint'].every(c => cols.includes(c));
},
run: function() {
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
for (const col of ['browser', 'os', 'device_type', 'device_fingerprint']) {
if (!cols.includes(col)) db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} TEXT`);
}
console.log('[migration] user_login_history device metadata columns ensured');
}
},
{
version: 'v0.56',
description: 'bills/categories: soft-delete columns',
check: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
return billCols.includes('deleted_at') && catCols.includes('deleted_at');
},
run: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!billCols.includes('deleted_at')) {
db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)');
}
if (!catCols.includes('deleted_at')) {
db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)');
}
console.log('[migration] bills/categories deleted_at columns added');
}
},
{
version: 'v0.57',
description: 'autopay: suggestions and auto-mark paid',
check: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const hasDismissals = !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='autopay_suggestion_dismissals'").get();
return billCols.includes('auto_mark_paid') && hasDismissals;
},
run: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billCols.includes('auto_mark_paid')) {
db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0');
}
db.exec(`
CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, bill_id, year, month)
);
CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
ON autopay_suggestion_dismissals(user_id, year, month);
`);
console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured');
}
},
{
version: 'v0.58',
description: 'bills: saved bill templates',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_templates'").get();
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS bill_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
ON bill_templates(user_id, name);
`);
console.log('[migration] bill_templates table ensured');
}
},
{
version: 'v0.59',
description: 'payments: source metadata for future transaction matching',
check: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
return cols.includes('payment_source') && cols.includes('transaction_id');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!cols.includes('payment_source')) {
db.exec("ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'");
}
if (!cols.includes('transaction_id')) {
db.exec('ALTER TABLE payments ADD COLUMN transaction_id INTEGER');
}
console.log('[migration] payments: source metadata columns added');
}
},
{
version: 'v0.60',
description: 'transactions: shared transaction foundation tables',
check: function() {
const tables = db.prepare(`
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name IN ('data_sources', 'financial_accounts', 'transactions')
`).all();
return tables.length === 3;
},
run: function() {
ensureTransactionFoundationSchema(db);
console.log('[migration] transaction foundation tables ensured');
}
},
{
version: 'v0.61',
description: 'payments: one active payment per linked transaction',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_transaction_active'").get();
},
run: function() {
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_transaction_active
ON payments(transaction_id)
WHERE transaction_id IS NOT NULL AND deleted_at IS NULL
`);
console.log('[migration] payments: transaction active unique index ensured');
}
},
{
version: 'v0.62',
description: 'matches: rejected transaction match suggestions',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='match_suggestion_rejections'").get();
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS match_suggestion_rejections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
rejected_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, transaction_id, bill_id)
);
CREATE INDEX IF NOT EXISTS idx_match_suggestion_rejections_user
ON match_suggestion_rejections(user_id, transaction_id, bill_id);
`);
console.log('[migration] match suggestion rejections table ensured');
}
},
{
version: 'v0.63',
description: 'bills: subscription metadata fields',
check: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return ['is_subscription', 'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at']
.every(col => cols.includes(col));
},
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('is_subscription')) db.exec('ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0');
if (!cols.includes('subscription_type')) db.exec('ALTER TABLE bills ADD COLUMN subscription_type TEXT');
if (!cols.includes('reminder_days_before')) db.exec('ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3');
if (!cols.includes('subscription_source')) db.exec("ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'");
if (!cols.includes('subscription_detected_at')) db.exec('ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)');
console.log('[migration] bills: subscription metadata columns added');
}
},
{
version: 'v0.64',
description: 'financial_accounts: monitored flag for bill matching',
check: function() {
const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
return cols.includes('monitored');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
if (!cols.includes('monitored')) {
db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1');
console.log('[migration] financial_accounts: monitored column added');
}
}
},
{
version: 'v0.65',
description: 'subscription_catalog: top-200 known subscription services',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='subscription_catalog'").get();
},
run: function() {
runSubscriptionCatalogMigration(db);
}
},
{
version: 'v0.66',
description: 'declined_subscription_hints: per-user dismissed recommendation store',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='declined_subscription_hints'").get();
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS declined_subscription_hints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
decline_key TEXT NOT NULL,
declined_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, decline_key)
);
CREATE INDEX IF NOT EXISTS idx_declined_hints_user
ON declined_subscription_hints(user_id);
`);
}
},
{
version: 'v0.67',
description: 'bill_merchant_rules: persistent merchant→bill auto-match rules',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_merchant_rules'").get();
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS bill_merchant_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
merchant TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, bill_id, merchant)
);
CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user
ON bill_merchant_rules(user_id);
`);
}
}
];
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,324 @@
'use strict';
// Seed rows for the subscription_catalog table, extracted from db/database.js
// to keep that module focused on schema + migrations. Pure data, no logic.
// ── Subscription catalog seed ─────────────────────────────────────────────────
// [rank, name, category, subscription_type, website, domain]
const SUBSCRIPTION_CATALOG_ROWS = [
[1,'Netflix','Video Streaming','streaming','https://www.netflix.com/','netflix.com'],
[2,'Amazon Prime Video','Video Streaming','streaming','https://www.primevideo.com/','primevideo.com'],
[3,'Hulu','Video Streaming','streaming','https://www.hulu.com/','hulu.com'],
[4,'Disney+','Video Streaming','streaming','https://www.disneyplus.com/','disneyplus.com'],
[5,'Max','Video Streaming','streaming','https://www.max.com/','max.com'],
[6,'Peacock','Video Streaming','streaming','https://www.peacocktv.com/','peacocktv.com'],
[7,'Paramount+','Video Streaming','streaming','https://www.paramountplus.com/','paramountplus.com'],
[8,'Apple TV+','Video Streaming','streaming','https://tv.apple.com/','tv.apple.com'],
[9,'YouTube Premium','Video Streaming','streaming','https://www.youtube.com/premium','youtube.com'],
[10,'ESPN+','Sports Streaming','streaming','https://plus.espn.com/','plus.espn.com'],
[11,'YouTube TV','Live TV Streaming','streaming','https://tv.youtube.com/','tv.youtube.com'],
[12,'Sling TV','Live TV Streaming','streaming','https://www.sling.com/','sling.com'],
[13,'Fubo','Live TV Streaming','streaming','https://www.fubo.tv/','fubo.tv'],
[14,'DirecTV Stream','Live TV Streaming','streaming','https://streamtv.directv.com/','streamtv.directv.com'],
[15,'Philo','Live TV Streaming','streaming','https://www.philo.com/','philo.com'],
[16,'Starz','Video Streaming','streaming','https://www.starz.com/','starz.com'],
[17,'MGM+','Video Streaming','streaming','https://www.mgmplus.com/','mgmplus.com'],
[18,'AMC+','Video Streaming','streaming','https://www.amcplus.com/','amcplus.com'],
[19,'BET+','Video Streaming','streaming','https://www.bet.plus/','bet.plus'],
[20,'Crunchyroll','Video Streaming','streaming','https://www.crunchyroll.com/','crunchyroll.com'],
[21,'HIDIVE','Video Streaming','streaming','https://www.hidive.com/','hidive.com'],
[22,'Shudder','Video Streaming','streaming','https://www.shudder.com/','shudder.com'],
[23,'Acorn TV','Video Streaming','streaming','https://acorn.tv/','acorn.tv'],
[24,'BritBox','Video Streaming','streaming','https://www.britbox.com/','britbox.com'],
[25,'The Criterion Channel','Video Streaming','streaming','https://www.criterionchannel.com/','criterionchannel.com'],
[26,'MUBI','Video Streaming','streaming','https://mubi.com/','mubi.com'],
[27,'Discovery+','Video Streaming','streaming','https://www.discoveryplus.com/','discoveryplus.com'],
[28,'Hallmark+','Video Streaming','streaming','https://www.hallmarkplus.com/','hallmarkplus.com'],
[29,'PBS Passport','Video Streaming','streaming','https://www.pbs.org/passport/','pbs.org'],
[30,'MagellanTV','Video Streaming','streaming','https://www.magellantv.com/','magellantv.com'],
[31,'Curiosity Stream','Video Streaming','streaming','https://curiositystream.com/','curiositystream.com'],
[32,'Nebula','Video Streaming','streaming','https://nebula.tv/','nebula.tv'],
[33,'WOW Presents Plus','Video Streaming','streaming','https://www.wowpresentsplus.com/','wowpresentsplus.com'],
[34,'ViX Premium','Video Streaming','streaming','https://vix.com/','vix.com'],
[35,'FloSports','Sports Streaming','streaming','https://www.flosports.tv/','flosports.tv'],
[36,'DAZN','Sports Streaming','streaming','https://www.dazn.com/','dazn.com'],
[37,'MLB.TV','Sports Streaming','streaming','https://www.mlb.com/live-stream-games/subscribe','mlb.com'],
[38,'NBA League Pass','Sports Streaming','streaming','https://www.nba.com/watch/league-pass-stream','nba.com'],
[39,'NHL Power Play on ESPN+','Sports Streaming','streaming','https://www.espn.com/espnplus/catalog/nhl','espn.com'],
[40,'NFL+','Sports Streaming','streaming','https://www.nfl.com/plus/','nfl.com'],
[41,'Spotify Premium','Music & Audio','music','https://www.spotify.com/premium/','spotify.com'],
[42,'Apple Music','Music & Audio','music','https://www.apple.com/apple-music/','apple.com'],
[43,'Amazon Music Unlimited','Music & Audio','music','https://music.amazon.com/','music.amazon.com'],
[44,'Pandora Premium','Music & Audio','music','https://www.pandora.com/upgrade','pandora.com'],
[45,'SiriusXM','Music & Audio','music','https://www.siriusxm.com/','siriusxm.com'],
[46,'TIDAL','Music & Audio','music','https://tidal.com/','tidal.com'],
[47,'Qobuz','Music & Audio','music','https://www.qobuz.com/us-en/music/streaming/offers','qobuz.com'],
[48,'SoundCloud Go+','Music & Audio','music','https://soundcloud.com/go','soundcloud.com'],
[49,'Deezer','Music & Audio','music','https://www.deezer.com/us/offers','deezer.com'],
[50,'iHeartRadio Plus','Music & Audio','music','https://www.iheart.com/plus/','iheart.com'],
[51,'Audible','Audiobooks','education','https://www.audible.com/','audible.com'],
[52,'Spotify Audiobooks','Audiobooks','education','https://www.spotify.com/us/audiobooks/','spotify.com'],
[53,'Everand','Audiobooks & Ebooks','education','https://www.everand.com/','everand.com'],
[54,'Scribd','Documents & Ebooks','education','https://www.scribd.com/','scribd.com'],
[55,'Kindle Unlimited','Ebooks','education','https://www.amazon.com/kindle-dbs/hz/subscribe/ku','amazon.com'],
[56,'Kobo Plus','Ebooks & Audiobooks','education','https://www.kobo.com/us/en/plus','kobo.com'],
[57,'Libro.fm','Audiobooks','education','https://libro.fm/','libro.fm'],
[58,'Blinkist','Books & Learning','education','https://www.blinkist.com/','blinkist.com'],
[59,'Pocket Casts Plus','Podcasts','music','https://pocketcasts.com/plus/','pocketcasts.com'],
[60,'Wondery+','Podcasts','music','https://wondery.com/plus/','wondery.com'],
[61,'The New York Times','News & Magazines','news','https://www.nytimes.com/subscription','nytimes.com'],
[62,'The Wall Street Journal','News & Magazines','news','https://www.wsj.com/news/subscribe','wsj.com'],
[63,'The Washington Post','News & Magazines','news','https://subscribe.washingtonpost.com/','subscribe.washingtonpost.com'],
[64,'The Atlantic','News & Magazines','news','https://www.theatlantic.com/subscribe/','theatlantic.com'],
[65,'The New Yorker','News & Magazines','news','https://www.newyorker.com/subscribe','newyorker.com'],
[66,'Bloomberg.com','News & Magazines','news','https://www.bloomberg.com/subscriptions','bloomberg.com'],
[67,'Financial Times','News & Magazines','news','https://www.ft.com/products','ft.com'],
[68,'The Economist','News & Magazines','news','https://www.economist.com/subscribe','economist.com'],
[69,'TIME','News & Magazines','news','https://time.com/subscribe/','time.com'],
[70,'WIRED','News & Magazines','news','https://www.wired.com/subscribe/','wired.com'],
[71,'Consumer Reports','News & Magazines','news','https://www.consumerreports.org/join/','consumerreports.org'],
[72,'Politico Pro','News & Magazines','news','https://www.politicopro.com/','politicopro.com'],
[73,'The Athletic','Sports Media','streaming','https://theathletic.com/','theathletic.com'],
[74,'Substack','Creator Media','news','https://substack.com/','substack.com'],
[75,'Medium','Creator Media','news','https://medium.com/membership','medium.com'],
[76,'Patreon','Creator Media','news','https://www.patreon.com/','patreon.com'],
[77,'Apple News+','News & Magazines','news','https://www.apple.com/apple-news/','apple.com'],
[78,'Readly','News & Magazines','news','https://us.readly.com/','us.readly.com'],
[79,'PressReader','News & Magazines','news','https://www.pressreader.com/','pressreader.com'],
[80,'The Information','News & Magazines','news','https://www.theinformation.com/subscribe','theinformation.com'],
[81,'Microsoft 365','Software & Productivity','software','https://www.microsoft.com/microsoft-365','microsoft.com'],
[82,'Google One','Cloud & Storage','cloud','https://one.google.com/','one.google.com'],
[83,'iCloud+','Cloud & Storage','cloud','https://www.apple.com/icloud/','apple.com'],
[84,'Dropbox','Cloud & Storage','cloud','https://www.dropbox.com/plans','dropbox.com'],
[85,'Box','Cloud & Storage','cloud','https://www.box.com/pricing','box.com'],
[86,'Adobe Creative Cloud','Software & Design','software','https://www.adobe.com/creativecloud.html','adobe.com'],
[87,'Canva Pro','Software & Design','software','https://www.canva.com/pro/','canva.com'],
[88,'Figma','Software & Design','software','https://www.figma.com/pricing/','figma.com'],
[89,'Notion','Software & Productivity','software','https://www.notion.so/pricing','notion.so'],
[90,'Evernote','Software & Productivity','software','https://evernote.com/compare-plans','evernote.com'],
[91,'Todoist','Software & Productivity','software','https://todoist.com/pricing','todoist.com'],
[92,'Grammarly','Writing & AI','software','https://www.grammarly.com/plans','grammarly.com'],
[93,'ChatGPT','AI','software','https://chatgpt.com/pricing','chatgpt.com'],
[94,'Claude.ai','AI','software','https://claude.ai/upgrade','anthropic.com'],
[95,'Perplexity','AI','software','https://www.perplexity.ai/pro','perplexity.ai'],
[96,'Gemini Advanced','AI','software','https://one.google.com/about/google-ai-plans/','one.google.com'],
[97,'GitHub Copilot','Developer Tools','software','https://github.com/features/copilot/plans','github.com'],
[98,'Cursor','Developer Tools','software','https://www.cursor.com/pricing','cursor.com'],
[99,'Replit','Developer Tools','software','https://replit.com/pricing','replit.com'],
[100,'Setapp','Software & Productivity','software','https://setapp.com/','setapp.com'],
[101,'1Password','Security','security','https://1password.com/pricing','1password.com'],
[102,'Dashlane','Security','security','https://www.dashlane.com/pricing','dashlane.com'],
[103,'NordVPN','Security','security','https://nordvpn.com/pricing/','nordvpn.com'],
[104,'ExpressVPN','Security','security','https://www.expressvpn.com/','expressvpn.com'],
[105,'Surfshark','Security','security','https://surfshark.com/pricing','surfshark.com'],
[106,'Norton 360','Security','security','https://us.norton.com/products','us.norton.com'],
[107,'McAfee+','Security','security','https://www.mcafee.com/en-us/consumer-support/pricing.html','mcafee.com'],
[108,'QuickBooks Online','Finance Software','software','https://quickbooks.intuit.com/pricing/','quickbooks.intuit.com'],
[109,'TurboTax Live','Finance Software','software','https://turbotax.intuit.com/personal-taxes/online/live/','turbotax.intuit.com'],
[110,'YNAB','Finance Software','software','https://www.ynab.com/pricing','ynab.com'],
[111,'Rocket Money Premium','Finance Software','software','https://www.rocketmoney.com/premium','rocketmoney.com'],
[112,'Copilot Money','Finance Software','software','https://copilot.money/','copilot.money'],
[113,'Calendly','Software & Productivity','software','https://calendly.com/pricing','calendly.com'],
[114,'Zoom Workplace','Software & Productivity','software','https://www.zoom.com/en/pricing/','zoom.com'],
[115,'Slack','Software & Productivity','software','https://slack.com/pricing','slack.com'],
[116,'Xbox Game Pass','Gaming','gaming','https://www.xbox.com/en-US/xbox-game-pass','xbox.com'],
[117,'PlayStation Plus','Gaming','gaming','https://www.playstation.com/en-us/ps-plus/','playstation.com'],
[118,'Nintendo Switch Online','Gaming','gaming','https://www.nintendo.com/us/switch/online/','nintendo.com'],
[119,'Apple Arcade','Gaming','gaming','https://www.apple.com/apple-arcade/','apple.com'],
[120,'EA Play','Gaming','gaming','https://www.ea.com/ea-play','ea.com'],
[121,'Ubisoft+','Gaming','gaming','https://www.ubisoft.com/en-us/ubisoft-plus','ubisoft.com'],
[122,'NVIDIA GeForce NOW','Gaming','gaming','https://www.nvidia.com/en-us/geforce-now/memberships/','nvidia.com'],
[123,'Roblox Premium','Gaming','gaming','https://www.roblox.com/premium/membership','roblox.com'],
[124,'Fortnite Crew','Gaming','gaming','https://www.fortnite.com/fortnite-crew-subscription','fortnite.com'],
[125,'Minecraft Realms','Gaming','gaming','https://www.minecraft.net/realms','minecraft.net'],
[126,'Twitch Turbo','Creator & Social','news','https://www.twitch.tv/turbo','twitch.tv'],
[127,'Discord Nitro','Creator & Social','news','https://discord.com/nitro','discord.com'],
[128,'X Premium','Creator & Social','news','https://help.x.com/en/using-x/x-premium','help.x.com'],
[129,'Snapchat+','Creator & Social','news','https://www.snapchat.com/plus','snapchat.com'],
[130,'TikTok Live Subscription','Creator & Social','news','https://www.tiktok.com/live/creators/en-US/subscription/','tiktok.com'],
[131,'Meta Verified','Creator & Social','news','https://about.meta.com/technologies/meta-verified/','about.meta.com'],
[132,'LinkedIn Premium','Career & Social','news','https://premium.linkedin.com/','premium.linkedin.com'],
[133,'Tinder Gold','Dating','other','https://tinder.com/feature/plus','tinder.com'],
[134,'Bumble Premium','Dating','other','https://bumble.com/en/the-buzz/bumble-premium','bumble.com'],
[135,'Hinge+','Dating','other','https://hinge.co/hinge-plus','hinge.co'],
[136,'Amazon Prime','Shopping & Delivery','shopping','https://www.amazon.com/amazonprime','amazon.com'],
[137,'Walmart+','Shopping & Delivery','shopping','https://www.walmart.com/plus','walmart.com'],
[138,'Target Circle 360','Shopping & Delivery','shopping','https://www.target.com/circle/target-circle-360','target.com'],
[139,'Costco','Warehouse Clubs','shopping','https://www.costco.com/join-costco.html','costco.com'],
[140,'Sam\'s Club','Warehouse Clubs','shopping','https://www.samsclub.com/join','samsclub.com'],
[141,'BJ\'s Wholesale Club','Warehouse Clubs','shopping','https://www.bjs.com/membership','bjs.com'],
[142,'Instacart+','Grocery & Delivery','food','https://www.instacart.com/instacart-plus','instacart.com'],
[143,'DoorDash DashPass','Food Delivery','food','https://www.doordash.com/dashpass/','doordash.com'],
[144,'Uber One','Food & Rides','food','https://www.uber.com/us/en/u/uber-one/','uber.com'],
[145,'Grubhub+','Food Delivery','food','https://www.grubhub.com/plus','grubhub.com'],
[146,'Shipt','Grocery & Delivery','food','https://www.shipt.com/membership/','shipt.com'],
[147,'Kroger Boost','Grocery & Delivery','food','https://www.kroger.com/pr/boost','kroger.com'],
[148,'Thrive Market','Grocery & Delivery','food','https://thrivemarket.com/','thrivemarket.com'],
[149,'Misfits Market','Grocery & Delivery','food','https://www.misfitsmarket.com/','misfitsmarket.com'],
[150,'Imperfect Foods','Grocery & Delivery','food','https://www.imperfectfoods.com/','imperfectfoods.com'],
[151,'Chewy Autoship','Pet Retail','shopping','https://www.chewy.com/app/content/autoship','chewy.com'],
[152,'Petco Vital Care Premier','Pet Retail','shopping','https://www.petco.com/shop/en/petcostore/c/vitalcare','petco.com'],
[153,'PetSmart Treats Rewards VIPP','Pet Retail','shopping','https://www.petsmart.com/treats-rewards-vipp.html','petsmart.com'],
[154,'GameStop Pro','Retail Memberships','shopping','https://www.gamestop.com/pro/','gamestop.com'],
[155,'Barnes & Noble Membership','Retail Memberships','shopping','https://www.barnesandnoble.com/membership','barnesandnoble.com'],
[156,'HelloFresh','Food & Meal Kits','food','https://www.hellofresh.com/','hellofresh.com'],
[157,'Blue Apron','Food & Meal Kits','food','https://www.blueapron.com/','blueapron.com'],
[158,'Home Chef','Food & Meal Kits','food','https://www.homechef.com/','homechef.com'],
[159,'Marley Spoon','Food & Meal Kits','food','https://marleyspoon.com/','marleyspoon.com'],
[160,'Dinnerly','Food & Meal Kits','food','https://dinnerly.com/','dinnerly.com'],
[161,'EveryPlate','Food & Meal Kits','food','https://www.everyplate.com/','everyplate.com'],
[162,'Green Chef','Food & Meal Kits','food','https://www.greenchef.com/','greenchef.com'],
[163,'Purple Carrot','Food & Meal Kits','food','https://www.purplecarrot.com/','purplecarrot.com'],
[164,'Sunbasket','Food & Meal Kits','food','https://sunbasket.com/','sunbasket.com'],
[165,'Factor','Prepared Meals','food','https://www.factor75.com/','factor75.com'],
[166,'CookUnity','Prepared Meals','food','https://www.cookunity.com/','cookunity.com'],
[167,'Fresh N Lean','Prepared Meals','food','https://www.freshnlean.com/','freshnlean.com'],
[168,'Hungryroot','Food & Meal Kits','food','https://www.hungryroot.com/','hungryroot.com'],
[169,'Daily Harvest','Prepared Meals','food','https://www.daily-harvest.com/','daily-harvest.com'],
[170,'Tovala','Prepared Meals','food','https://www.tovala.com/','tovala.com'],
[171,'MistoBox','Coffee & Tea','food','https://mistobox.com/','mistobox.com'],
[172,'Trade Coffee','Coffee & Tea','food','https://www.drinktrade.com/','drinktrade.com'],
[173,'Atlas Coffee Club','Coffee & Tea','food','https://atlascoffeeclub.com/','atlascoffeeclub.com'],
[174,'Bean Box','Coffee & Tea','food','https://beanbox.com/','beanbox.com'],
[175,'Universal Yums','Snacks','food','https://www.universalyums.com/','universalyums.com'],
[176,'Peloton App','Fitness & Wellness','fitness','https://www.onepeloton.com/app','onepeloton.com'],
[177,'ClassPass','Fitness & Wellness','fitness','https://classpass.com/','classpass.com'],
[178,'Apple Fitness+','Fitness & Wellness','fitness','https://www.apple.com/apple-fitness-plus/','apple.com'],
[179,'Strava','Fitness & Wellness','fitness','https://www.strava.com/subscribe','strava.com'],
[180,'Fitbit Premium','Fitness & Wellness','fitness','https://www.fitbit.com/global/us/products/services/premium','fitbit.com'],
[181,'MyFitnessPal Premium','Fitness & Wellness','fitness','https://www.myfitnesspal.com/premium','myfitnesspal.com'],
[182,'Noom','Fitness & Wellness','fitness','https://www.noom.com/','noom.com'],
[183,'WW','Fitness & Wellness','fitness','https://www.weightwatchers.com/us/plans','weightwatchers.com'],
[184,'Headspace','Meditation & Wellness','fitness','https://www.headspace.com/','headspace.com'],
[185,'Calm','Meditation & Wellness','fitness','https://www.calm.com/premium','calm.com'],
[186,'Sleep Cycle Premium','Sleep & Wellness','fitness','https://www.sleepcycle.com/premium/','sleepcycle.com'],
[187,'Oura Membership','Fitness & Wellness','fitness','https://ouraring.com/membership','ouraring.com'],
[188,'Whoop','Fitness & Wellness','fitness','https://www.whoop.com/us/en/membership/','whoop.com'],
[189,'Aaptiv','Fitness & Wellness','fitness','https://aaptiv.com/','aaptiv.com'],
[190,'Fitbod','Fitness & Wellness','fitness','https://fitbod.me/pricing/','fitbod.me'],
[191,'Alo Moves','Fitness & Wellness','fitness','https://www.alomoves.com/','alomoves.com'],
[192,'Obe Fitness','Fitness & Wellness','fitness','https://obefitness.com/','obefitness.com'],
[193,'Centr','Fitness & Wellness','fitness','https://centr.com/','centr.com'],
[194,'Future','Fitness & Wellness','fitness','https://www.future.co/','future.co'],
[195,'Tonal Membership','Fitness & Wellness','fitness','https://www.tonal.com/membership/','tonal.com'],
[196,'Duolingo Super','Education','education','https://www.duolingo.com/super','duolingo.com'],
[197,'MasterClass','Education','education','https://www.masterclass.com/','masterclass.com'],
[198,'Coursera Plus','Education','education','https://www.coursera.org/courseraplus','coursera.org'],
[199,'Skillshare','Education','education','https://www.skillshare.com/','skillshare.com'],
[200,'Book of the Month','Books & Subscription Boxes','education','https://www.bookofthemonth.com/','bookofthemonth.com'],
];
const SUBSCRIPTION_CATALOG_V2_ROWS = [
// ── AI ──────────────────────────────────────────────────────────────────────
[201,'Suno','AI Music','music','https://suno.com/','suno.com'],
[202,'Midjourney','AI Image Generation','software','https://midjourney.com/','midjourney.com'],
[203,'Grok','AI','software','https://grok.com/','grok.com'],
[204,'ElevenLabs','AI Voice','software','https://elevenlabs.io/','elevenlabs.io'],
[205,'Character.ai Plus','AI','software','https://character.ai/','character.ai'],
[206,'Runway','AI Video','software','https://runwayml.com/','runwayml.com'],
[207,'Windsurf','Developer Tools','software','https://windsurf.com/','windsurf.com'],
[208,'Leonardo.ai','AI Image Generation','software','https://leonardo.ai/','leonardo.ai'],
// ── Home Security / Smart Home ───────────────────────────────────────────────
[209,'Ring Protect','Home Security','other','https://ring.com/protect-plans','ring.com'],
[210,'Nest Aware','Home Security','other','https://store.google.com/us/category/connected_home','nest.com'],
[211,'SimpliSafe','Home Security','other','https://simplisafe.com/','simplisafe.com'],
[212,'ADT','Home Security','other','https://www.adt.com/','adt.com'],
[213,'Arlo Secure','Home Security','other','https://www.arlo.com/','arlo.com'],
[214,'Wyze Cam Plus','Home Security','other','https://www.wyze.com/','wyze.com'],
[215,'Abode','Home Security','other','https://goabode.com/','goabode.com'],
// ── Financial Data & Aggregation ────────────────────────────────────────────
[216,'SimpleFIN Bridge','Financial Data','software','https://simplefin.org/','simplefin.org'],
[217,'Tiller Money','Financial Data','software','https://www.tillerhq.com/','tillerhq.com'],
[218,'Monarch Money','Finance Software','software','https://www.monarchmoney.com/','monarchmoney.com'],
[219,'Empower','Finance Software','software','https://www.empower.com/','empower.com'],
// ── Cloud Backup ─────────────────────────────────────────────────────────────
[220,'Backblaze','Cloud Backup','cloud','https://www.backblaze.com/','backblaze.com'],
[221,'Carbonite','Cloud Backup','cloud','https://www.carbonite.com/','carbonite.com'],
[222,'iDrive','Cloud Backup','cloud','https://www.idrive.com/','idrive.com'],
// ── Email ────────────────────────────────────────────────────────────────────
[223,'Proton Mail','Email & Privacy','software','https://proton.me/mail','proton.me'],
[224,'Fastmail','Email','software','https://www.fastmail.com/','fastmail.com'],
[225,'Superhuman','Email','software','https://superhuman.com/','superhuman.com'],
[226,'Hey','Email','software','https://www.hey.com/','hey.com'],
// ── Security / Privacy ───────────────────────────────────────────────────────
[227,'Bitwarden','Security','security','https://bitwarden.com/','bitwarden.com'],
[228,'Keeper','Security','security','https://www.keepersecurity.com/','keepersecurity.com'],
[229,'LastPass','Security','security','https://www.lastpass.com/','lastpass.com'],
[230,'Proton VPN','Security','security','https://protonvpn.com/','protonvpn.com'],
[231,'Mullvad VPN','Security','security','https://mullvad.net/','mullvad.net'],
[232,'Private Internet Access','Security','security','https://www.privateinternetaccess.com/','privateinternetaccess.com'],
[233,'Proton Pass','Security','security','https://proton.me/pass','proton.me'],
[234,'SimpleLogin','Email Privacy','software','https://simplelogin.io/','simplelogin.io'],
// ── Identity Protection ──────────────────────────────────────────────────────
[235,'LifeLock','Identity Protection','security','https://www.lifelock.com/','lifelock.com'],
[236,'Aura','Identity Protection','security','https://www.aura.com/','aura.com'],
[237,'IdentityForce','Identity Protection','security','https://www.identityforce.com/','identityforce.com'],
// ── Comics ───────────────────────────────────────────────────────────────────
[238,'Marvel Unlimited','Comics','streaming','https://www.marvel.com/unlimited','marvel.com'],
[239,'DC Universe Infinite','Comics','streaming','https://www.dcuniverseinfinite.com/','dcuniverseinfinite.com'],
// ── Genealogy ────────────────────────────────────────────────────────────────
[240,'Ancestry','Genealogy','other','https://www.ancestry.com/','ancestry.com'],
[241,'MyHeritage','Genealogy','other','https://www.myheritage.com/','myheritage.com'],
[242,'Findmypast','Genealogy','other','https://www.findmypast.com/','findmypast.com'],
// ── Telehealth ───────────────────────────────────────────────────────────────
[243,'BetterHelp','Telehealth','other','https://www.betterhelp.com/','betterhelp.com'],
[244,'Talkspace','Telehealth','other','https://www.talkspace.com/','talkspace.com'],
[245,'Teladoc','Telehealth','other','https://www.teladoc.com/','teladoc.com'],
[246,'Hims & Hers','Telehealth','other','https://www.forhims.com/','forhims.com'],
[247,'Cerebral','Telehealth','other','https://cerebral.com/','cerebral.com'],
// ── Website Builder / Hosting / Domains ─────────────────────────────────────
[248,'Squarespace','Website Builder','software','https://www.squarespace.com/','squarespace.com'],
[249,'Wix','Website Builder','software','https://www.wix.com/','wix.com'],
[250,'Webflow','Website Builder','software','https://webflow.com/','webflow.com'],
[251,'GoDaddy','Domain & Hosting','software','https://www.godaddy.com/','godaddy.com'],
[252,'Namecheap','Domain & Hosting','software','https://www.namecheap.com/','namecheap.com'],
[253,'Netlify','Developer Tools','software','https://www.netlify.com/','netlify.com'],
[254,'Vercel','Developer Tools','software','https://vercel.com/','vercel.com'],
// ── Learning ─────────────────────────────────────────────────────────────────
[255,'LinkedIn Learning','Education','education','https://www.linkedin.com/learning/','linkedin.com'],
[256,'Pluralsight','Education','education','https://www.pluralsight.com/','pluralsight.com'],
[257,"O'Reilly",'Education','education','https://www.oreilly.com/','oreilly.com'],
[258,'CBT Nuggets','Education','education','https://www.cbtnuggets.com/','cbtnuggets.com'],
[259,'Udacity','Education','education','https://www.udacity.com/','udacity.com'],
[260,'Frontend Masters','Education','education','https://frontendmasters.com/','frontendmasters.com'],
// ── Project Management ───────────────────────────────────────────────────────
[261,'Monday.com','Software & Productivity','software','https://monday.com/','monday.com'],
[262,'Asana','Software & Productivity','software','https://asana.com/','asana.com'],
[263,'ClickUp','Software & Productivity','software','https://clickup.com/','clickup.com'],
[264,'Linear','Developer Tools','software','https://linear.app/','linear.app'],
[265,'Basecamp','Software & Productivity','software','https://basecamp.com/','basecamp.com'],
[266,'Jira','Developer Tools','software','https://www.atlassian.com/software/jira','atlassian.com'],
[267,'Miro','Software & Productivity','software','https://miro.com/','miro.com'],
[268,'Airtable','Software & Productivity','software','https://airtable.com/','airtable.com'],
// ── Email Marketing ──────────────────────────────────────────────────────────
[269,'Mailchimp','Email Marketing','software','https://mailchimp.com/','mailchimp.com'],
[270,'ConvertKit','Email Marketing','software','https://convertkit.com/','convertkit.com'],
[271,'Beehiiv','Newsletter Platform','software','https://www.beehiiv.com/','beehiiv.com'],
[272,'Constant Contact','Email Marketing','software','https://www.constantcontact.com/','constantcontact.com'],
[273,'ActiveCampaign','Email Marketing','software','https://www.activecampaign.com/','activecampaign.com'],
// ── Cloud Computing ──────────────────────────────────────────────────────────
[274,'DigitalOcean','Cloud Computing','cloud','https://www.digitalocean.com/','digitalocean.com'],
[275,'Linode','Cloud Computing','cloud','https://www.linode.com/','linode.com'],
[276,'Render','Cloud Computing','cloud','https://render.com/','render.com'],
[277,'Vultr','Cloud Computing','cloud','https://www.vultr.com/','vultr.com'],
[278,'Hetzner','Cloud Computing','cloud','https://www.hetzner.com/','hetzner.com'],
[279,'Cloudflare','Network & Security','cloud','https://www.cloudflare.com/','cloudflare.com'],
// ── Media Server ─────────────────────────────────────────────────────────────
[280,'Plex Pass','Media Server','streaming','https://www.plex.tv/plex-pass/','plex.tv'],
[281,'Emby Premiere','Media Server','streaming','https://emby.media/premiere/','emby.media'],
// ── Network / Homelab ────────────────────────────────────────────────────────
[282,'NextDNS','Network & Security','software','https://nextdns.io/','nextdns.io'],
[283,'Tailscale','Network & Security','software','https://tailscale.com/','tailscale.com'],
// ── Productivity ─────────────────────────────────────────────────────────────
[284,'Obsidian Sync','Software & Productivity','software','https://obsidian.md/','obsidian.md'],
[285,'Readwise','Software & Productivity','software','https://readwise.io/','readwise.io'],
[286,'Loom','Software & Productivity','software','https://www.loom.com/','loom.com'],
[287,'Raycast Pro','Software & Productivity','software','https://www.raycast.com/','raycast.com'],
// ── Developer Tools ──────────────────────────────────────────────────────────
[288,'JetBrains','Developer Tools','software','https://www.jetbrains.com/','jetbrains.com'],
[289,'Tabnine','Developer Tools','software','https://www.tabnine.com/','tabnine.com'],
// ── Communication ────────────────────────────────────────────────────────────
[290,'Telegram Premium','Messaging','software','https://telegram.org/','telegram.org'],
];
module.exports = { SUBSCRIPTION_CATALOG_ROWS, SUBSCRIPTION_CATALOG_V2_ROWS };

View File

@ -1,6 +1,12 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
# Files this app writes (the SQLite DB + WAL/SHM, backups, exports) hold financial
# data and encrypted secrets (SimpleFIN token, sessions, SMTP/OIDC). Create them
# owner-only (600 files / 700 dirs) — not world-readable. Inherited by the exec'd
# node process so SQLite's -wal/-shm are locked too. (QA-B16-02)
umask 077
APP_USER="${APP_USER:-bill}" APP_USER="${APP_USER:-bill}"
APP_GROUP="${APP_GROUP:-bill}" APP_GROUP="${APP_GROUP:-bill}"
DATA_DIR="${DATA_DIR:-/data}" DATA_DIR="${DATA_DIR:-/data}"
@ -13,6 +19,9 @@ mkdir -p "$DATA_DIR" "$DB_DIR" "$BACKUP_DIR" /app/backups
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
chown -R "$APP_USER:$APP_GROUP" "$DATA_DIR" /app/backups chown -R "$APP_USER:$APP_GROUP" "$DATA_DIR" /app/backups
chmod 700 "$DB_DIR" "$BACKUP_DIR" /app/backups chmod 700 "$DB_DIR" "$BACKUP_DIR" /app/backups
# Lock any pre-existing DB files that were created world-readable (644) before
# this umask fix — otherwise they keep their old mode across an upgrade.
chmod 600 "$DB_FILE" "$DB_FILE"-wal "$DB_FILE"-shm 2>/dev/null || true
if [ "${RUN_DB_MIGRATIONS:-true}" = "true" ]; then if [ "${RUN_DB_MIGRATIONS:-true}" = "true" ]; then
su-exec "$APP_USER:$APP_GROUP" node scripts/migrate-db.js su-exec "$APP_USER:$APP_GROUP" node scripts/migrate-db.js
fi fi

View File

@ -1,6 +1,7 @@
# BillTracker — Master QA Plan (living document) # BillTracker — Master QA Plan (living document)
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02 (Cycle 1: 14 findings fixed & archived, 0 open — incl. broken "Send test push" + email XSS) **Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02
**Cycle 1: COMPLETE ✅** — all 18 batches (B0→B16 + B-UI) run; **15 findings fixed**, verified & archived (3× S2); automated re-run of existing batches clean (0 new); the added **B16** (migrations/secrets/deploy) surfaced + fixed 1 (version-check opt-out). Guard suite green. External-infra items (live TOTP/WebAuthn/OIDC, SMTP delivery, cross-browser, PWA-offline, load, container build) carried to Cycle 2 as non-blocking.
This is a **living, operational** QA document, not a static spec. Claude runs it, This is a **living, operational** QA document, not a static spec. Claude runs it,
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
@ -75,6 +76,26 @@ no matter how tempting or trivial.
- **The exit is empirical:** you're done only when an entire find pass (B0→B15) turns up zero new findings — not when you *think* it's clean. Log the cycle result in the [Cycle Log](#11-qa-cycle-log) each time. - **The exit is empirical:** you're done only when an entire find pass (B0→B15) turns up zero new findings — not when you *think* it's clean. Log the cycle result in the [Cycle Log](#11-qa-cycle-log) each time.
- Improve THIS plan whenever a pass reveals a missed surface, a better repro, or a batch that should be reordered/split. - Improve THIS plan whenever a pass reveals a missed surface, a better repro, or a batch that should be reordered/split.
**Improvement lens (not just bug-hunting).** QA here is also about making the product
*better*, not only *correct*. On every batch, in addition to logging bugs, actively
look through three improvement lenses and log what you find as **IMP** items in the
[Improvement Backlog (§2.1)](#21-improvement-backlog):
- **Code health & consolidation** — duplication to DRY up, dead code to delete,
overlapping modules to merge, oversized files to split, one canonical path per
concern. *Consolidate only where it genuinely reduces surface area and is
behavior-preserving.* (Dedicated pass: **B17**.)
- **User experience** — friction in core flows, unclear states (empty/loading/error),
weak feedback/affordances, inconsistent patterns, mobile parity. (Dedicated pass: **B18**.)
- **Information architecture / menus** — features that are buried or only reachable by
URL, actions that belong in a menu (nav, overflow, context, settings groupings), and
groupings that would make the app more discoverable. *Put things where a user would
look for them.* (Dedicated pass: **B18**.)
IMP items are **proposals**, not silent changes: log the candidate with a concrete
recommendation, agree the direction, then implement behind a test. They **don't block
sign-off** — but a strong QA cycle leaves the code cleaner and the UX clearer, not just
green.
--- ---
## 1. Batch plan & progress tracker ## 1. Batch plan & progress tracker
@ -86,26 +107,44 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
| # | Batch | Primary surface | Data state | Status | Open / Fixed | | # | Batch | Primary surface | Data state | Status | Open / Fixed |
|---|-------|-----------------|-----------|--------|--------------| |---|-------|-----------------|-----------|--------|--------------|
| B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | 🔄 | 0 / 1 | | B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | ✅ | 0 / 1 |
| B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | 🔄 | 0 / 0 | | B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | ✅ | 0 / 0 |
| B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ⬜ | 0 / 0 | | B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ✅ | 0 / 0 |
| B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ⬜ | 0 / 0 | | B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ✅ | 0 / 0 |
| B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ⬜ | 0 / 0 | | B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ✅ | 0 / 0 |
| B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ⬜ | 0 / 0 | | B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ✅ | 0 / 0 |
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large | 🔄 | 0 / 3 | | B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large + **live SimpleFIN DB** | ✅ | 0 / 4 |
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | 🔄 | 0 / 1 | | B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | ✅ | 0 / 1 |
| B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | 🔄 | 0 / 2 | | B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | ✅ | 0 / 2 |
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns | ⬜ | 0 / 0 | | B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns + **live SimpleFIN sync** | ✅ | 0 / 0 |
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | 🔄 | 0 / 1 | | B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | ✅ | 0 / 1 |
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | 🔄 | 0 / 1 | | B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | ✅ | 0 / 1 |
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | 🔄 | 0 / 0 | | B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ✅ | 0 / 0 |
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | 🔄 | 0 / 0 | | B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ✅ | 0 / 0 |
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | 🔄 | 0 / 1 | | B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | ✅ | 0 / 1 |
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | 🔄 | 0 / 4 | | B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | ✅ | 0 / 4 |
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | 🔄 | 0 / 0 | | B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ✅ | 0 / 0 |
| B16 | Migrations, secrets & deploy | migration idempotency/rollback/fresh==migrated, encryption-key lifecycle, `docker-entrypoint` (perms/first-run/migrate), update-check phone-home | scratch + docker | ✅ | 0 / 1 |
| B17 | **Code health & consolidation** (IMP) | duplication/DRY, dead code, overlapping modules to merge, oversized files to split, one canonical path per concern | whole repo | ⬜ | 0 / 0 |
| B18 | **UX & information architecture** (IMP) | core-flow friction, empty/loading/error states, feedback/affordances, nav/menu discoverability, surfacing actions into sensible menus | any | ⬜ | 0 / 0 |
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new > After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
> cycle from B0 against the next build/version. > cycle from B0 against the next build/version.
>
> **B17/B18 are improvement (IMP) batches** — they run alongside the correctness
> batches but their findings are enhancements, not defects, and don't gate sign-off.
**✅ means "run complete for this cycle's automatable scope, green, findings archived."**
Cycle 1 built a durable, automated guard for every batch (`npm run ci` · `test:e2e` ·
`test:e2e:probe` · `smoke:prod`). The following need **external infrastructure or a
human** and were **not** exercised — they are **non-blocking** for Cycle 1 sign-off and
carried to Cycle 2:
- **B1** — live TOTP enrollment, WebAuthn/passkeys (browser/OS prompts), OIDC SSO round-trip. (Password login, roles, CSRF, data-isolation, admin authz **are** covered.)
- **B10** — real SMTP *delivery* (push delivery + email-HTML building/escaping **are** covered by `tests/notificationDelivery.test.js`).
- **B11** — backup create/restore on a scratch instance (authorization + last-admin guards **are** covered).
- **B14** — Firefox/Safari cross-browser, PWA install/offline, and large/stress load+perf. (axe a11y on 8 pages, XSS/escaping, and prod-bundle perf **are** covered.)
- **B9** — spreadsheet/CSV import *from real files* end-to-end (money-unit handling + SQLite export→import round-trip **are** covered by tests).
### 1.1 QA Cycle Log ### 1.1 QA Cycle Log
@ -115,7 +154,9 @@ until you get a clean cycle.
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result | | Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
|-------|---------|----------------|-----------------|------------------|--------| |-------|---------|----------------|-----------------|------------------|--------|
| 1 | 2026-07-02 | `bdbf231`→(dev) | 13 | **13 → all fixed, verified & archived** (…, +B10-01 broken "Send test push") | ✅ **0 open.** Post seed-fix reconciliation caught the **occurrence-gating family** — Summary (S2), Analytics, and SimpleFIN bank-tracking all counted non-monthly bills every month; all fixed via `resolveDueDate` and guarded (probe reconciliation + `tests/summaryBankTracking.test.js`). Probed B0/B1/B3/B4/B5/B6/B7/B8/B9/B13/B14; solid: auth/isolation, CSRF, payment/date validation, recurrence, matching/dedup, subscription+spending math, XSS, calendar gating. **A full re-run (B0→B15) is still required to declare the cycle clean per exit criteria.** | | 1 | 2026-07-02 | `bdbf231`→`5ffe2db` (dev) | 14 | **14 → all fixed, verified & archived** (3× S2 incl. broken "Send test push", email XSS, reconciliation family, seed 100× cents) | 🔁 Phase 2 complete — 0 open. Every batch B0→B15 (+B-UI) run; 16 QA commits; guard suite green. |
| 1·re-run | 2026-07-02 | `5ffe2db` (dev) | **0 new** | — | ✅ **Automated re-run clean.** CI (server 109 + client 34, build), UI E2E 27, probe 16 (authz 403, Tracker↔Summary↔Analytics reconcile exactly, seed guard, a11y 8/8), prod-smoke PASS. **All 17 batches ✅ for automatable scope; external-infra residuals listed below are non-blocking and carried to Cycle 2.** |
| 1·simplefin-live | 2026-07-03 | `5ffe2db` (dev) vs prod DB | **1** (QA-B5-04) | **1 → fixed, verified & archived** | 🔁 Probed a **copy of the live SimpleFIN DB** (19 MB, v1.06: 3 users, 44 bills, 1,159 txns, 19 accounts, active SimpleFIN source). Integrity checks: dedup (1159/1159 distinct), money=integer cents, no double-match, pending have provider ids, no orphan-account txns — all pass **except** 3 matched txns with NULL bill → QA-B5-04 (retention GC + `ON DELETE SET NULL`). Fixed in `cleanupService`; healing verified on a DB copy (3→0, 0 txns lost). **Also ran a real end-to-end sync** (`syncDataSource`, the Sync-button path) against the live connection off a working copy: token decrypted via db-key fallback (no env key), bridge fetch OK (2.2s), 18 accounts upserted, 145 fetched txns **skipped not duplicated**, 0 new, 1159→1159 distinct — **dedup/upsert idempotency proven on the real connection.** |
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete) **Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
@ -157,6 +198,29 @@ Log console errors, failed network requests, and unhandled rejections as finding
_All Cycle 1 write-ups have been archived to `HISTORY.md` v0.41.0 (see §3)._ _All Cycle 1 write-ups have been archived to `HISTORY.md` v0.41.0 (see §3)._
### 2.1 Improvement backlog
**IMP-stream, separate from the bug log above.** Enhancement candidates found through
the three improvement lenses (code/consolidation, UX, IA/menus — see §0 and batches
B17/B18). These are **proposals**: log the candidate + a concrete recommendation, then
discuss before implementing. They don't gate sign-off. When one is implemented, archive
it to `HISTORY.md` (`### 🧹 QA` / `### ✨` as fits) and remove the row; deferred ideas
graduate to `roadmap.md`/`FUTURE.md`.
**ID:** `IMP-{stream}-{nn}` where stream = `CODE` (health/consolidation), `UX`, or `IA` (menus/nav).
**Effort:** S (local, <1h) · M (a file or two) · L (cross-cutting / needs design).
**Status:** 🔵 Noted (proposal) → 🟡 Doing → then archive to `HISTORY.md` on implement.
| ID | Lens | Area (`file`/page) | Proposal (what & why) | Effort | Status |
|----|------|--------------------|-----------------------|--------|--------|
| IMP-CODE-01 | Code | `client/lib/money.js` (+16 files) | ~~No shared client money formatter.~~ **Shipped `a15f00c`:** added `client/lib/money.js` (`formatUSD`/`formatUSDWhole`/`formatCentsUSD`); `lib/utils.fmt` delegates to it and 15 local formatters were removed. `null`/`NaN`/`-0` all handled. Test `client/lib/money.test.js`; full client suite + build green. | M | ✅ Shipped |
| IMP-CODE-02 | Code | `db/database.js` (4,174→1,297 ln) | ~~Oversized module.~~ **Shipped `7f2faea`,`026c6a5`,`12d9d4c`:** split into `subscriptionCatalogSeed.js` (data), `migrations/versionedMigrations.js` (~1,740 ln factory), `migrations/legacyReconcileMigrations.js` (~830 ln factory) — db + helpers injected, behavior byte-identical. Verified: suite 125, fresh DB 79 migrations idempotent, real prod DB no-op with data intact. Test `tests/migrationModules.test.js`. | L | ✅ Shipped |
| IMP-CODE-03 | Code | `services/transactionMatchState.js` | ~~Overlapping match logic.~~ **Shipped `fa24322`:** added canonical `markMatched`/`markUnmatched`/`markIgnored`; routed the 6 single-transaction transitions through it (guarded bulk sweeps keep their own queries by design). Test `tests/transactionMatchState.test.js`. | M | ✅ Shipped |
| IMP-IA-01 | IA | Sidebar · `/data` | ~~Central features under an overflow menu.~~ **Shipped `0b1c6a8`:** Data moved into the main app nav (desktop dropdown + mobile) alongside Bills/Categories/Spending; same default-admin gate preserved; removed the redundant account-dropdown entry. | S | ✅ Shipped |
| IMP-UX-01 | UX | Bills delete | ~~Retention isn't surfaced.~~ **Shipped `aace5a4`:** Bills shows a "Recently deleted (N)" button opening a restore dialog (amount, category, days-left); `GET /api/bills/deleted` (30-day window). Test `tests/billsDeletedRoute.test.js`. _Categories/payments could get the same treatment later._ | M | ✅ Shipped |
| IMP-UX-02 | UX | all list pages | **Audited — no gaps.** Checked all 11 main pages (Tracker/Bills/Subscriptions/Categories/Spending/Banking/Snowball/Payoff/Analytics/Summary/Calendar): each has a skeleton/`animate-pulse` **loading** state, an **empty** state, and a **rendered recoverable error** panel with a Retry/Try-again button (e.g. Summary 459-463, Calendar 899-903, BankTransactions 671-750, Payoff 276-302). No dead ends found; no change warranted. Keep as a standing B18/B14 check for new pages. | M | ✅ Audited |
--- ---
## 3. Archiving fixed findings to HISTORY.md ## 3. Archiving fixed findings to HISTORY.md
@ -302,6 +366,8 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
- [ ] **API route mounts**`grep -nE "app.use\('/api" server.js` — every mounted route group is in B13's list and mapped in Appendix C. - [ ] **API route mounts**`grep -nE "app.use\('/api" server.js` — every mounted route group is in B13's list and mapped in Appendix C.
- [ ] **Services & components**`ls services/` and `ls client/components/**/` — new service/component families have a home in a playbook. - [ ] **Services & components**`ls services/` and `ls client/components/**/` — new service/component families have a home in a playbook.
- [ ] **UI primitives**`ls client/components/ui/` — every shared primitive is covered by the [B-UI](#b-ui--design-system-primitives) playbook; a new primitive gets a row there. - [ ] **UI primitives**`ls client/components/ui/` — every shared primitive is covered by the [B-UI](#b-ui--design-system-primitives) playbook; a new primitive gets a row there.
- [ ] **Middleware & workers**`ls middleware/ workers/` (+ `services/*Worker*`, `*Scheduler*`) — each is covered (csrf/rateLimiter/securityHeaders/requireAuth → B13; dailyWorker/bankSyncWorker/backupScheduler → B10).
- [ ] **Migrations & deploy** — new `db/database.js` migrations, `Dockerfile`/`docker-entrypoint.sh` changes, and `encryptionService`/`updateCheckService` behavior are covered by [B16](#b16--migrations-secrets--deployment).
- [ ] **Interactive-control census (makes "every button tested" *provable*)** — for each page, enumerate every button, link, toggle/switch, checkbox, select, text/number/date/file input, tab, menu, and filter control, and record it in a per-page control checklist (template: [Appendix E](#appendix-e--per-page-control-census)). A control that isn't on a checklist hasn't been tested — the census is the completeness guarantee the batch playbooks alone don't give you. Quick starting inventory: `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages client/components` and `grep -rn "onClick=" client/pages/<Page>.jsx`. - [ ] **Interactive-control census (makes "every button tested" *provable*)** — for each page, enumerate every button, link, toggle/switch, checkbox, select, text/number/date/file input, tab, menu, and filter control, and record it in a per-page control checklist (template: [Appendix E](#appendix-e--per-page-control-census)). A control that isn't on a checklist hasn't been tested — the census is the completeness guarantee the batch playbooks alone don't give you. Quick starting inventory: `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages client/components` and `grep -rn "onClick=" client/pages/<Page>.jsx`.
- [ ] **Feature flags / conditional surfaces** — search for `Only`, `enabled`, `featureFlag`, env gates that hide/show pages; ensure each state is tested. - [ ] **Feature flags / conditional surfaces** — search for `Only`, `enabled`, `featureFlag`, env gates that hide/show pages; ensure each state is tested.
- [ ] **What changed since last cycle** — skim `git log`/`HISTORY.md` since the previous cycle's commit (see [Cycle Log](#11-qa-cycle-log)) for new features/pages. - [ ] **What changed since last cycle** — skim `git log`/`HISTORY.md` since the previous cycle's commit (see [Cycle Log](#11-qa-cycle-log)) for new features/pages.
@ -453,6 +519,104 @@ Run on the **production build** (`npm start`), not dev:
- [ ] Bogus URL → 404; logout → login redirect. Console clean throughout. - [ ] Bogus URL → 404; logout → login redirect. Console clean throughout.
- [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria). - [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria).
### B16 — Migrations, secrets & deployment
Added Cycle 1 (previously uncovered). These run on every boot / container start and
touch money columns and at-rest secrets — a bug here corrupts data or leaks/breaks
secrets silently.
**Migrations** (`db/database.js` migration system, `scripts/migrate-db.js`, `schema_migrations`, `rollbackMigration`)
- [ ] **Idempotent:** boot twice on the same DB → second run applies nothing ("Skipping already applied"), no errors, no duplicate rows/columns.
- [ ] **Fresh == migrated:** a brand-new DB (schema.sql + all migrations) has the same schema as a DB migrated up from an old version — same tables/columns/indexes, money columns are **integer cents**.
- [ ] **Rollback:** `rollbackMigration` on the latest migration reverts cleanly and re-applying works; partial/failed migration leaves the DB consistent (transactions per migration).
- [ ] **Money conversions correct:** v1.03 (dollars→cents) and v1.04 (template JSON) convert exact values, no ×100 drift, run once only.
- [ ] Migrating a large/real DB doesn't lose or duplicate bills/payments/categories.
**Encryption-key lifecycle** (`services/encryptionService.js`, `TOKEN_ENCRYPTION_KEY`, HKDF v1/v2)
- [ ] **Key present:** secrets (SMTP pw, OIDC secret, push tokens, login IP/UA) encrypt at rest and decrypt correctly.
- [ ] **Key missing:** app boots; secret features degrade gracefully (no crash); confirm secrets are **not** silently stored/served in plaintext.
- [ ] **Key rotated/wrong:** old ciphertext fails to decrypt **gracefully** (no crash, no stack leak); `safeDecrypt` fallback path is sane; re-encryption migrations (v0.770.79) behave.
- [ ] Encryption key is never committed, logged, or returned in any API response.
**Container / deploy** (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `deploy.sh`)
- [ ] Image **builds**; container **starts**; app reachable; `/api/version` responds.
- [ ] Entrypoint: creates `DATA_DIR`/`DB_DIR`/`BACKUP_DIR`, sets **`chmod 700`** (not world-readable), `chown`s to the non-root `bill` user, runs migrations when `RUN_DB_MIGRATIONS=true`.
- [ ] Data **persists** across container restart (mounted volume); DB not re-created.
- [ ] Runs as **non-root**; secrets come from env, not baked into the image.
**Update check / phone-home** (`services/updateCheckService.js`)
- [ ] Confirm the external request to `REPO_API_URL` (default `dream.scheller.ltd`) is **disclosed** (privacy page) and **opt-out-able**; it must send no user data, only fetch the latest release; failure/offline degrades silently.
**Rate-limiter completeness** (`middleware/rateLimiter.js`) — beyond B13's list
- [ ] `backupOperationLimiter` throttles admin backup/restore/cleanup; `skipRateLimitIfNoUsers` only relaxes limits on a genuinely empty instance (first-run), never afterward.
### B17 — Code health & consolidation (IMP)
An **improvement** batch: hunt for ways to make the codebase smaller, clearer, and more
consistent — *without changing behavior*. Every candidate is logged as an `IMP-CODE-*`
row in §2.1 with a concrete proposal; nothing is refactored silently. Consolidation
lands only when it's behavior-preserving **and** covered by existing or added tests.
- [ ] **Duplication / DRY:** find logic copy-pasted across services/routes/components and
propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.js`
vs inline), the `resolveDueDate` occurrence gate (must stay one implementation),
error-response shaping (`utils/apiError.js` vs ad-hoc), React data-fetch patterns
(repeated `useQuery` + toast + error handling → shared hooks).
- [ ] **Dead / unused code:** unused exports, unreachable branches, orphaned files,
commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover
scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first).
- [ ] **Overlapping modules:** services that do similar work and could merge or share a
core — e.g. the matching family (`matchSuggestionService`, `transactionMatchService`,
`merchantStoreMatchService`), the bank-sync family (`bankSyncService`,
`bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities;
propose a consolidation only where it removes real duplication, not just moves it.
- [ ] **Oversized / low-cohesion files:** split by concern where it aids navigation
(e.g. `db/database.js` is very large — migrations vs query helpers vs settings could
be separate modules). Propose the seams; don't split for its own sake.
- [ ] **One canonical path per concern:** cents handling, date/tz, CSRF, error shape,
pagination — confirm there's a single blessed way and flag divergences.
- [ ] **Consistency:** naming, file layout, async patterns, import ordering; a lint rule
that would prevent a class of the bugs found in earlier batches is itself an IMP.
- [ ] **Test/infra dedupe:** repeated test setup → shared fixtures/helpers; flag coverage
gaps a consolidation would risk.
### B18 — UX & information architecture / menus (IMP)
An **improvement** batch focused on the person using the app: is every feature
*discoverable*, is every core flow *smooth*, and does every action live *where a user
would look for it*? Candidates are logged as `IMP-UX-*` or `IMP-IA-*` in §2.1 with a
concrete before/after proposal. Walk the app as a real user (both `user` and `admin`,
desktop and mobile, light and dark), not just as a tester.
**Information architecture & menus**
- [ ] **Discoverability:** is any feature buried, orphaned, or reachable only by typing a
URL? Everything should be reachable from the nav, a menu, or a clear in-page entry.
- [ ] **Navigation structure:** sidebar/nav grouping is logical; related pages sit
together; admin vs user separation is clear; active state + page titles are correct.
- [ ] **Menus where they belong:** actions that today are loose buttons or hidden should
be grouped into sensible menus — overflow (`⋯`) menus on rows/cards, context menus,
a consolidated **Settings** grouping, an account menu. Put related actions in one menu
rather than scattering them. Flag anything that would be easier to find as a menu item.
- [ ] **Command palette (`Ctrl+K`) coverage:** every page/primary action is reachable;
no dead entries.
- [ ] **Redundancy:** the same action offered in three places with different labels, or
two pages that do nearly the same thing — propose consolidating.
**Experience quality**
- [ ] **Core-flow friction:** count the clicks for the top tasks (pay a bill, add a bill,
connect SimpleFIN, run a sync, import data). Propose shortcuts where a step is wasted.
- [ ] **States:** every list/page has a clear **empty state with a next-step CTA**, a
**loading** state (skeleton/spinner, no layout jump), and a **recoverable error**
state — no dead ends, no silent failures.
- [ ] **Feedback & safety:** state changes confirm (toast); destructive actions confirm
and, where feasible, offer undo (bills already soft-delete — surface a restore path);
long actions show progress.
- [ ] **Consistency:** primary-action placement, button hierarchy, iconography,
terminology, and confirmation patterns are consistent across pages.
- [ ] **Mobile parity:** every action available on desktop is reachable on mobile; touch
targets adequate; menus/overflow work on touch.
- [ ] **Onboarding:** first-run and empty-account guidance explains the next step; advanced
features (SimpleFIN, debt planning, backups) have a short in-context explanation.
--- ---
## 8. Appendices ## 8. Appendices
@ -469,15 +633,15 @@ Run on the **production build** (`npm start`), not dev:
### Appendix B — Exit / sign-off criteria ### Appendix B — Exit / sign-off criteria
A cycle is release-ready when: A cycle is release-ready when: **(Cycle 1 — all met ✅)**
- [ ] All batches B0B15 ✅ on the primary matrix (Chrome desktop + mobile, light + dark, `user` + `admin`). - [x] All batches B0B15 ✅ (Chromium desktop + mobile via the E2E projects; light + dark, `user` + `admin` exercised). Cross-browser Firefox/Safari carried to Cycle 2.
- [ ] B15 smoke green on the **production build**. - [x] B15 smoke green on the **production build** (`npm run smoke:prod`).
- [ ] **Zero open S1/S2** in the Findings Log; S3/S4/IMP triaged. - [x] **Zero open S1/S2** in the Findings Log; S3/S4/IMP all fixed & archived.
- [ ] `npm run ci` green; no new console errors. - [x] `npm run ci` green (server 109 + client 34 + build); no new console errors (verified in prod-smoke).
- [ ] Data export→import round-trip verified with no loss. - [x] Data export→import round-trip verified with no loss (`tests/exportImportRoundTrip.test.js`).
- [ ] Auth/authorization + data-isolation all pass. - [x] Auth/authorization + data-isolation all pass (probe: IDOR → 404, CSRF → 403, admin/status → 403).
- [ ] Money and date/period correctness verified vs hand-calculated examples. - [x] Money and date/period correctness verified vs hand-calculated examples (`tests/money.test.js`, `aprService`, recurrence probe, reconciliation guards).
- [ ] All fixes for the cycle archived to `HISTORY.md`; cycle summary recorded (date, build/commit, environment). - [x] All 14 fixes archived to `HISTORY.md` v0.41.0; cycle summary recorded (Cycle Log §1.1).
### Appendix C — Page ↔ route ↔ API quick map ### Appendix C — Page ↔ route ↔ API quick map

View File

@ -487,4 +487,22 @@ router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => {
} }
}); });
// QA-B16-01: opt-out control for the external version check.
const { getSetting, setSetting } = require('../db/database');
// GET /api/about-admin/update-check-setting — is the external version check enabled?
router.get('/update-check-setting', requireAuth, requireAdmin, (req, res) => {
res.json({ enabled: getSetting('update_check_enabled') !== 'false' });
});
// PUT /api/about-admin/update-check-setting — enable/disable the external version check
router.put('/update-check-setting', requireAuth, requireAdmin, (req, res) => {
const { enabled } = req.body || {};
if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: 'enabled must be a boolean' });
}
setSetting('update_check_enabled', enabled ? 'true' : 'false');
res.json({ enabled });
});
module.exports = router; module.exports = router;

View File

@ -49,6 +49,31 @@ router.get('/', (req, res) => {
res.json(bills.map(serializeBill)); res.json(bills.map(serializeBill));
}); });
// ── GET /api/bills/deleted ────────────────────────────────────────────────────
// Soft-deleted bills still inside the 30-day recovery window (before the
// retention GC purges them), newest deletion first. Powers the "Recently
// deleted" restore view. Must be declared before GET /:id.
const BILL_RETENTION_DAYS = 30; // matches pruneSoftDeletedFinancialRecords()
router.get('/deleted', (req, res) => {
const db = getDb();
const rows = db.prepare(`
SELECT b.*, c.name AS category_name,
CAST(julianday(b.deleted_at, '+${BILL_RETENTION_DAYS} days') - julianday('now') AS INTEGER) AS days_left
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.user_id = ?
AND b.deleted_at IS NOT NULL
AND b.deleted_at >= datetime('now', '-${BILL_RETENTION_DAYS} days')
ORDER BY b.deleted_at DESC
`).all(req.user.id);
res.json(rows.map(row => ({
...serializeBill(row),
category_name: row.category_name,
deleted_at: row.deleted_at,
days_left: Math.max(0, row.days_left),
})));
});
// ── PUT /api/bills/reorder ─────────────────────────────────────────────────── // ── PUT /api/bills/reorder ───────────────────────────────────────────────────
router.put('/reorder', (req, res) => { router.put('/reorder', (req, res) => {
const db = getDb(); const db = getDb();

View File

@ -7,6 +7,7 @@ const {
rejectMatchSuggestion, rejectMatchSuggestion,
} = require('../services/matchSuggestionService'); } = require('../services/matchSuggestionService');
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService');
const { markMatched, markUnmatched } = require('../services/transactionMatchState');
const { serializePayment } = require('../services/paymentValidation'); const { serializePayment } = require('../services/paymentValidation');
const { todayLocal } = require('../utils/dates'); const { todayLocal } = require('../utils/dates');
@ -69,11 +70,7 @@ router.post('/confirm', (req, res) => {
const paymentForAccounting = db.prepare('SELECT * FROM payments WHERE id = ?').get(payResult.lastInsertRowid); const paymentForAccounting = db.prepare('SELECT * FROM payments WHERE id = ?').get(payResult.lastInsertRowid);
applyBankPaymentAsSourceOfTruth(db, bill, paymentForAccounting); applyBankPaymentAsSourceOfTruth(db, bill, paymentForAccounting);
db.prepare(` markMatched(db, req.user.id, txId, billId);
UPDATE transactions
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(billId, txId, req.user.id);
// Learn a merchant→bill rule from this explicit confirmation so future // Learn a merchant→bill rule from this explicit confirmation so future
// synced transactions from the same merchant auto-match. Best-effort. // synced transactions from the same merchant auto-match. Best-effort.
@ -123,11 +120,7 @@ router.post('/:transactionId/unmatch', (req, res) => {
UPDATE payments SET deleted_at = datetime('now'), updated_at = datetime('now') UPDATE payments SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE transaction_id = ? AND payment_source = 'transaction_match' AND deleted_at IS NULL WHERE transaction_id = ? AND payment_source = 'transaction_match' AND deleted_at IS NULL
`).run(txId); `).run(txId);
db.prepare(` markUnmatched(db, req.user.id, txId);
UPDATE transactions
SET matched_bill_id = NULL, match_status = 'unmatched', updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(txId, req.user.id);
db.exec('COMMIT'); db.exec('COMMIT');
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {

View File

@ -5,6 +5,7 @@ const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
const { getCycleRange, resolveDueDate } = require('../services/statusService'); const { getCycleRange, resolveDueDate } = require('../services/statusService');
const { markUnmatched } = require('../services/transactionMatchState');
const { const {
markProvisionalManualPaymentsOverridden, markProvisionalManualPaymentsOverridden,
reactivatePaymentsOverriddenBy, reactivatePaymentsOverriddenBy,
@ -177,11 +178,7 @@ router.post('/:id/undo-auto', (req, res) => {
} }
reactivatePaymentsOverriddenBy(db, payment.id); reactivatePaymentsOverriddenBy(db, payment.id);
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id); db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
db.prepare(` markUnmatched(db, req.user.id, payment.transaction_id);
UPDATE transactions
SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(payment.transaction_id, req.user.id);
})(); })();
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {

View File

@ -31,6 +31,7 @@ router.get('/', (req, res) => {
items: [ items: [
'The only external communication performed by the application is an optional version check to determine whether the latest software release is installed.', 'The only external communication performed by the application is an optional version check to determine whether the latest software release is installed.',
'This communication does not include your bill data or personal information.', 'This communication does not include your bill data or personal information.',
'An administrator can disable the version check entirely in the admin panel; when disabled, the application makes no external requests.',
'Version check information is not tracked or stored server-side and is used solely to determine software update availability.', 'Version check information is not tracked or stored server-side and is used solely to determine software update availability.',
], ],
}, },

View File

@ -8,6 +8,7 @@ const {
} = require('../services/transactionService'); } = require('../services/transactionService');
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService'); const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
const { getBankSyncConfig } = require('../services/bankSyncConfigService'); const { getBankSyncConfig } = require('../services/bankSyncConfigService');
const { markUnmatched } = require('../services/transactionMatchState');
const { const {
ignoreTransaction, ignoreTransaction,
matchTransactionToBill, matchTransactionToBill,
@ -794,11 +795,7 @@ router.post('/unmatch-bulk', (req, res) => {
reactivatePaymentsOverriddenBy(db, payment.id); reactivatePaymentsOverriddenBy(db, payment.id);
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id); db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
} }
db.prepare(` markUnmatched(db, userId, txId);
UPDATE transactions
SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(txId, userId);
results.push({ transaction_id: txId, ok: true }); results.push({ transaction_id: txId, ok: true });
} else { } else {
// Standard service unmatch (restores balance for transaction_match payments) // Standard service unmatch (restores balance for transaction_match payments)

View File

@ -135,14 +135,29 @@ function pruneImportHistory(maxAgeDays) {
/** /**
* Permanently purge soft-deleted bills and categories after a 30-day recovery * Permanently purge soft-deleted bills and categories after a 30-day recovery
* window. Bill deletion cascades to bill-owned records via foreign keys. * window. Bill deletion cascades to bill-owned records via foreign keys.
*
* transactions.matched_bill_id is ON DELETE SET NULL, so purging a bill nulls the
* pointer on any matched transaction but would leave match_status='matched' a
* limbo row excluded from spending (match_status != 'matched') yet attributed to no
* bill. Release those matches back to 'unmatched' in the same transaction (and
* self-heal any pre-existing orphans) so purged bills don't silently drop spend.
*/ */
function pruneSoftDeletedFinancialRecords(maxAgeDays = 30) { function pruneSoftDeletedFinancialRecords(maxAgeDays = 30) {
const db = getDb(); const db = getDb();
const cutoff = `-${maxAgeDays} days`; const cutoff = `-${maxAgeDays} days`;
const purge = db.transaction(() => { const purge = db.transaction(() => {
const releasedMatches = db.prepare(`
UPDATE transactions
SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now')
WHERE match_status = 'matched'
AND (matched_bill_id IS NULL
OR matched_bill_id IN (
SELECT id FROM bills WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)
))
`).run(cutoff).changes;
const bills = db.prepare("DELETE FROM bills WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes; const bills = db.prepare("DELETE FROM bills WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes;
const categories = db.prepare("DELETE FROM categories WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes; const categories = db.prepare("DELETE FROM categories WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes;
return { bills, categories }; return { bills, categories, releasedMatches };
}); });
return purge(); return purge();
} }

View File

@ -11,6 +11,7 @@ const {
getTransactionForUser, getTransactionForUser,
} = require('./transactionService'); } = require('./transactionService');
const { serializePayment } = require('./paymentValidation'); const { serializePayment } = require('./paymentValidation');
const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState');
const MATCH_PAYMENT_SOURCE = 'transaction_match'; const MATCH_PAYMENT_SOURCE = 'transaction_match';
const MATCH_PAYMENT_METHOD = 'transaction_match'; const MATCH_PAYMENT_METHOD = 'transaction_match';
@ -255,14 +256,7 @@ function matchTransactionToBill(userId, transactionId, billId, opts = {}) {
const bill = getOwnedBill(db, userId, billId); const bill = getOwnedBill(db, userId, billId);
const paymentId = createOrUpdateMatchPayment(db, userId, transaction, bill); const paymentId = createOrUpdateMatchPayment(db, userId, transaction, bill);
db.prepare(` markMatched(db, userId, transaction.id, bill.id, { resetIgnored: true });
UPDATE transactions
SET matched_bill_id = ?,
match_status = 'matched',
ignored = 0,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(bill.id, transaction.id, userId);
if (opts.learnMerchant) { if (opts.learnMerchant) {
const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService'); const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService');
@ -281,14 +275,7 @@ function unmatchTransaction(userId, transactionId) {
const transaction = getOwnedTransaction(db, userId, transactionId); const transaction = getOwnedTransaction(db, userId, transactionId);
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id); const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
db.prepare(` markUnmatched(db, userId, transaction.id, { resetIgnored: true });
UPDATE transactions
SET matched_bill_id = NULL,
match_status = 'unmatched',
ignored = 0,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transaction.id, userId);
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment }); return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
}); });
@ -302,14 +289,7 @@ function ignoreTransaction(userId, transactionId) {
const transaction = getOwnedTransaction(db, userId, transactionId); const transaction = getOwnedTransaction(db, userId, transactionId);
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id); const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
db.prepare(` markIgnored(db, userId, transaction.id);
UPDATE transactions
SET ignored = 1,
match_status = 'ignored',
matched_bill_id = NULL,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transaction.id, userId);
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment }); return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
}); });
@ -322,14 +302,7 @@ function unignoreTransaction(userId, transactionId) {
const tx = db.transaction(() => { const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId); const transaction = getOwnedTransaction(db, userId, transactionId);
db.prepare(` markUnmatched(db, userId, transaction.id, { resetIgnored: true });
UPDATE transactions
SET ignored = 0,
match_status = 'unmatched',
matched_bill_id = NULL,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transaction.id, userId);
return responseForTransaction(db, userId, transaction.id); return responseForTransaction(db, userId, transaction.id);
}); });

View File

@ -0,0 +1,52 @@
'use strict';
// Canonical writers for a transaction's match state.
//
// A transaction's match state lives in three columns that must move together:
// match_status 'unmatched' | 'matched' | 'ignored'
// matched_bill_id the bill when matched, else NULL
// ignored 1 when explicitly ignored, else 0
//
// These were previously updated by copy-pasted inline UPDATEs across routes and
// services, which is exactly how they drift out of sync — e.g. QA-B5-04, where a
// bill purge left match_status='matched' with a NULL bill. Routing every
// single-transaction transition through these helpers keeps the columns
// consistent by construction. All are ownership-scoped (user_id) and return the
// number of rows changed. Bulk transitions (auto-match sweep, retention purge)
// stay in their own services where the WHERE clause is fundamentally different.
/**
* Mark a transaction matched to a bill.
* @param {boolean} [opts.resetIgnored] also clear the ignored flag (used when
* matching a transaction directly, which implicitly un-ignores it).
*/
function markMatched(db, userId, transactionId, billId, { resetIgnored = false } = {}) {
return db.prepare(`
UPDATE transactions
SET matched_bill_id = ?, match_status = 'matched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(billId, transactionId, userId).changes;
}
/**
* Clear a transaction's match — back to 'unmatched' with no bill.
* @param {boolean} [opts.resetIgnored] also clear the ignored flag (un-ignore).
*/
function markUnmatched(db, userId, transactionId, { resetIgnored = false } = {}) {
return db.prepare(`
UPDATE transactions
SET matched_bill_id = NULL, match_status = 'unmatched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transactionId, userId).changes;
}
/** Mark a transaction ignored — dropped from matching, no bill. */
function markIgnored(db, userId, transactionId) {
return db.prepare(`
UPDATE transactions
SET ignored = 1, match_status = 'ignored', matched_bill_id = NULL, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transactionId, userId).changes;
}
module.exports = { markMatched, markUnmatched, markIgnored };

View File

@ -4,9 +4,18 @@
* 5 minutes for errors) so the status page stays fast under load. * 5 minutes for errors) so the status page stays fast under load.
*/ */
const { getSetting } = require('../db/database');
const REPO_API_BASE = process.env.REPO_API_URL const REPO_API_BASE = process.env.REPO_API_URL
|| 'https://dream.scheller.ltd/api/v1/repos/null/BillTracker'; || 'https://dream.scheller.ltd/api/v1/repos/null/BillTracker';
// QA-B16-01: the version check is opt-out-able. Admins can disable the external
// request via the `update_check_enabled` setting (default on). When off, no
// network call is made and a `disabled` status is returned.
function updateCheckEnabled() {
return getSetting('update_check_enabled') !== 'false';
}
const TTL_OK_MS = 60 * 60 * 1000; // 1 hour on success const TTL_OK_MS = 60 * 60 * 1000; // 1 hour on success
const TTL_ERROR_MS = 5 * 60 * 1000; // 5 min on error (avoid hammering) const TTL_ERROR_MS = 5 * 60 * 1000; // 5 min on error (avoid hammering)
const FETCH_TIMEOUT_MS = 8_000; const FETCH_TIMEOUT_MS = 8_000;
@ -39,6 +48,22 @@ function compareVersions(a, b) {
async function checkForUpdates(force = false) { async function checkForUpdates(force = false) {
const now = Date.now(); const now = Date.now();
// Opt-out: no external request when disabled by the admin.
if (!updateCheckEnabled()) {
return {
current_version: getCurrentVersion(),
latest_version: null,
up_to_date: null,
has_update: false,
latest_release_url: null,
published_at: null,
last_checked_at: null,
error: null,
disabled: true,
cached: false,
};
}
if (!force && _cache.result && now < _cache.expiresAt) { if (!force && _cache.result && now < _cache.expiresAt) {
return { ..._cache.result, cached: true }; return { ..._cache.result, cached: true };
} }
@ -113,4 +138,4 @@ async function checkForUpdates(force = false) {
} }
} }
module.exports = { checkForUpdates }; module.exports = { checkForUpdates, updateCheckEnabled };

View File

@ -15,7 +15,7 @@ const backupPath = path.join(os.tmpdir(), `${testId}-backups`);
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
process.env.BACKUP_PATH = backupPath; process.env.BACKUP_PATH = backupPath;
const { closeDb, setSetting } = require('../db/database'); const { closeDb, setSetting, getDb } = require('../db/database');
const { const {
BACKUP_DIR, BACKUP_DIR,
createBackup, createBackup,
@ -34,6 +34,7 @@ const {
const { const {
pruneOrphanedBackupPartials, pruneOrphanedBackupPartials,
pruneStaleExportFiles, pruneStaleExportFiles,
pruneSoftDeletedFinancialRecords,
} = require('../services/cleanupService'); } = require('../services/cleanupService');
// ── Teardown ───────────────────────────────────────────────────────────────── // ── Teardown ─────────────────────────────────────────────────────────────────
@ -312,3 +313,75 @@ test('pruneStaleExportFiles ignores non-bill-tracker files in tmpdir', () => {
try { fs.unlinkSync(other); } catch {} try { fs.unlinkSync(other); } catch {}
}); });
// ─────────────────────────────────────────────────────────────────────────────
// cleanupService — pruneSoftDeletedFinancialRecords (QA-B5-04)
// Purging a soft-deleted bill fires the matched_bill_id ON DELETE SET NULL FK.
// Without releasing the match, the transaction would be left match_status='matched'
// with matched_bill_id=NULL — a limbo row excluded from spending yet tied to no bill.
// ─────────────────────────────────────────────────────────────────────────────
function seedMatchedTxn(db, { deletedAt }) {
const userId = db.prepare(
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
).run(`prune-${Math.random().toString(36).slice(2)}`).lastInsertRowid;
const billId = db.prepare(
"INSERT INTO bills (user_id, name, due_day, expected_amount, active, deleted_at) VALUES (?, 'Purge Me', 1, 1000, 1, ?)",
).run(userId, deletedAt).lastInsertRowid;
const txId = db.prepare(
"INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, pending, ignored) VALUES (?, 'manual', ?, -1000, 'matched', ?, 0, 0)",
).run(userId, `prune-tx-${billId}`, billId).lastInsertRowid;
return { userId, billId, txId };
}
test('pruneSoftDeletedFinancialRecords releases matches on purged bills (QA-B5-04)', () => {
const db = getDb();
db.pragma('foreign_keys = ON');
const { billId, txId } = seedMatchedTxn(db, { deletedAt: '2020-01-01 00:00:00' });
const before = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
assert.equal(before.match_status, 'matched');
assert.equal(before.matched_bill_id, billId);
const result = pruneSoftDeletedFinancialRecords(30);
assert.ok(result.bills >= 1, 'the old soft-deleted bill was purged');
assert.ok(result.releasedMatches >= 1, 'the match was released');
assert.equal(db.prepare('SELECT id FROM bills WHERE id = ?').get(billId), undefined, 'bill is gone');
const after = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
assert.equal(after.match_status, 'unmatched', 'transaction is no longer stuck as matched');
assert.equal(after.matched_bill_id, null, 'matched_bill_id cleared');
});
test('pruneSoftDeletedFinancialRecords self-heals pre-existing orphans (matched + NULL bill)', () => {
const db = getDb();
db.pragma('foreign_keys = ON');
const userId = db.prepare(
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
).run(`orphan-${Math.random().toString(36).slice(2)}`).lastInsertRowid;
const txId = db.prepare(
"INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, pending, ignored) VALUES (?, 'manual', 'orphan-tx', -500, 'matched', NULL, 0, 0)",
).run(userId).lastInsertRowid;
pruneSoftDeletedFinancialRecords(30);
const after = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
assert.equal(after.match_status, 'unmatched', 'orphan healed back to unmatched');
assert.equal(after.matched_bill_id, null);
});
test('pruneSoftDeletedFinancialRecords leaves active matches untouched', () => {
const db = getDb();
db.pragma('foreign_keys = ON');
// A recently soft-deleted bill (inside the recovery window) must NOT be purged,
// and its match must be preserved.
const recent = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
const { billId, txId } = seedMatchedTxn(db, { deletedAt: recent });
pruneSoftDeletedFinancialRecords(30);
assert.ok(db.prepare('SELECT id FROM bills WHERE id = ?').get(billId), 'recent soft-deleted bill retained');
const after = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
assert.equal(after.match_status, 'matched', 'match within recovery window preserved');
assert.equal(after.matched_bill_id, billId);
});

View File

@ -0,0 +1,92 @@
'use strict';
// IMP-UX-01: GET /api/bills/deleted lists soft-deleted bills still inside the
// 30-day recovery window (newest first, with days_left), so the "Recently
// deleted" view can offer a restore beyond the transient undo toast. Bills
// purged past the window (or another user's) must not appear.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-bills-deleted-route-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
function createUser(db, suffix) {
return db.prepare(
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
).run(`deleted-bills-${suffix}`).lastInsertRowid;
}
// daysAgo === null → an active (not deleted) bill; otherwise soft-deleted N days ago.
function insertBill(db, userId, name, daysAgo) {
const id = db.prepare(
'INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 1000, ?)',
).run(userId, name, daysAgo == null ? 1 : 0).lastInsertRowid;
if (daysAgo != null) {
db.prepare("UPDATE bills SET deleted_at = datetime('now', ?) WHERE id = ?").run(`-${daysAgo} days`, id);
}
return id;
}
function callGetDeleted(userId) {
const router = require('../routes/bills');
const layer = router.stack.find(item => item.route?.path === '/deleted' && item.route.methods.get);
assert.ok(layer, 'GET /deleted route should exist');
const handler = layer.route.stack[0].handle;
return new Promise((resolve, reject) => {
const req = { query: {}, params: {}, user: { id: userId, role: 'user' } };
const res = {
statusCode: 200,
status(code) { this.statusCode = code; return this; },
json(data) { resolve({ status: this.statusCode, data }); },
};
try { handler(req, res); } catch (err) { reject(err); }
});
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
test('GET /bills/deleted returns only recoverable soft-deleted bills, newest first', async () => {
const db = getDb();
const userId = createUser(db, 'a');
insertBill(db, userId, 'Active Bill', null); // not deleted
insertBill(db, userId, 'Recently Deleted', 2); // 2 days ago
insertBill(db, userId, 'Older Deleted', 20); // 20 days ago
insertBill(db, userId, 'Purgeable', 40); // past the 30-day window
const { status, data } = await callGetDeleted(userId);
assert.equal(status, 200);
const names = data.map(b => b.name);
assert.deepEqual(names, ['Recently Deleted', 'Older Deleted'], 'only in-window bills, newest first');
assert.ok(!names.includes('Active Bill'), 'active bill excluded');
assert.ok(!names.includes('Purgeable'), 'past-window bill excluded');
const recentRow = data.find(b => b.name === 'Recently Deleted');
assert.ok(recentRow.days_left >= 27 && recentRow.days_left <= 28, `~28 days left, got ${recentRow.days_left}`);
assert.ok(recentRow.deleted_at, 'exposes deleted_at');
assert.equal(recentRow.expected_amount, 10, 'money serialized to dollars (cents/100)');
});
test('GET /bills/deleted isolates by user', async () => {
const db = getDb();
const me = createUser(db, 'me');
const other = createUser(db, 'other');
insertBill(db, me, 'Mine', 1);
insertBill(db, other, 'Theirs', 1);
const { data } = await callGetDeleted(me);
const names = data.map(b => b.name);
assert.ok(names.includes('Mine'));
assert.ok(!names.includes('Theirs'), "never leaks another user's deleted bills");
});

View File

@ -0,0 +1,35 @@
'use strict';
// IMP-CODE-02: the migration arrays were extracted from db/database.js into
// factory modules. These guard the invariants db/database.js relies on:
// - both modules load and build their arrays (run/check bodies aren't invoked
// at construction, so empty deps are fine);
// - the versioned list has no duplicate versions;
// - every legacy-reconcile version exists in the versioned list (reconcile only
// marks known migrations as applied — a reconcile version with no versioned
// counterpart is the drift the in-app assertion warns about).
const test = require('node:test');
const assert = require('node:assert/strict');
const buildVersioned = require('../db/migrations/versionedMigrations');
const buildReconcile = require('../db/migrations/legacyReconcileMigrations');
test('both migration modules build their arrays with no deps needed at construction', () => {
const versioned = buildVersioned({});
const reconcile = buildReconcile({});
assert.ok(Array.isArray(versioned) && versioned.length > 0, 'versioned array non-empty');
assert.ok(Array.isArray(reconcile) && reconcile.length > 0, 'reconcile array non-empty');
for (const m of versioned) assert.equal(typeof m.run, 'function', `${m.version} has a run()`);
for (const m of reconcile) assert.equal(typeof m.check, 'function', `${m.version} has a check()`);
});
test('versioned migration versions are unique', () => {
const versions = buildVersioned({}).map(m => m.version);
assert.equal(new Set(versions).size, versions.length, 'no duplicate versions');
});
test('every legacy-reconcile version exists in the versioned list (no drift)', () => {
const versionedSet = new Set(buildVersioned({}).map(m => m.version));
const orphans = buildReconcile({}).map(m => m.version).filter(v => !versionedSet.has(v));
assert.deepEqual(orphans, [], `reconcile versions with no versioned counterpart: ${orphans.join(', ')}`);
});

View File

@ -0,0 +1,80 @@
'use strict';
// IMP-CODE-03: the canonical match-state writers keep match_status,
// matched_bill_id and ignored moving together, so no path can leave the columns
// inconsistent (the QA-B5-04 class of bug).
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-match-state-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { markMatched, markUnmatched, markIgnored } = require('../services/transactionMatchState');
let db, userId, otherId, billId;
function newTxn(uid, { status = 'unmatched', bill = null, ignored = 0 } = {}) {
return db.prepare(
'INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, ignored) VALUES (?, ?, ?, -1000, ?, ?, ?)',
).run(uid, 'manual', `ms-${Math.random().toString(36).slice(2)}`, status, bill, ignored).lastInsertRowid;
}
const row = (id) => db.prepare('SELECT match_status, matched_bill_id, ignored FROM transactions WHERE id = ?').get(id);
test.before(() => {
db = getDb();
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('ms-user','x','user',1)").run().lastInsertRowid;
otherId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('ms-other','x','user',1)").run().lastInsertRowid;
billId = db.prepare('INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 1000, 1)').run(userId, 'Bill').lastInsertRowid;
});
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch {} }
});
test('markMatched sets status + bill together', () => {
const id = newTxn(userId);
const changes = markMatched(db, userId, id, billId);
assert.equal(changes, 1);
assert.deepEqual(row(id), { match_status: 'matched', matched_bill_id: billId, ignored: 0 });
});
test('markMatched leaves the ignored flag unless resetIgnored is set', () => {
const kept = newTxn(userId, { ignored: 1, status: 'ignored' });
markMatched(db, userId, kept, billId);
assert.equal(row(kept).ignored, 1, 'ignored preserved by default');
const cleared = newTxn(userId, { ignored: 1, status: 'ignored' });
markMatched(db, userId, cleared, billId, { resetIgnored: true });
assert.equal(row(cleared).ignored, 0, 'resetIgnored clears it');
});
test('markUnmatched clears the bill and status (never leaves matched+NULL)', () => {
const id = newTxn(userId, { status: 'matched', bill: billId });
markUnmatched(db, userId, id);
assert.deepEqual(row(id), { match_status: 'unmatched', matched_bill_id: null, ignored: 0 });
});
test('markUnmatched with resetIgnored un-ignores', () => {
const id = newTxn(userId, { status: 'ignored', ignored: 1 });
markUnmatched(db, userId, id, { resetIgnored: true });
assert.deepEqual(row(id), { match_status: 'unmatched', matched_bill_id: null, ignored: 0 });
});
test('markIgnored sets ignored + status, clears bill', () => {
const id = newTxn(userId, { status: 'matched', bill: billId });
markIgnored(db, userId, id);
assert.deepEqual(row(id), { match_status: 'ignored', matched_bill_id: null, ignored: 1 });
});
test('all writers are ownership-scoped — never touch another user\'s transaction', () => {
const foreign = newTxn(otherId, { status: 'matched', bill: null });
assert.equal(markMatched(db, userId, foreign, billId), 0);
assert.equal(markUnmatched(db, userId, foreign), 0);
assert.equal(markIgnored(db, userId, foreign), 0);
assert.equal(row(foreign).match_status, 'matched', 'foreign row untouched');
});

View File

@ -0,0 +1,60 @@
'use strict';
// B16 / QA-B16-01: the external version check must be opt-out-able. When the
// `update_check_enabled` setting is 'false', checkForUpdates must make NO network
// request and report `disabled`. Default (unset/'true') performs the check.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-updatecheck-test-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, setSetting, closeDb } = require('../db/database');
const svc = require('../services/updateCheckService');
test('disabled: makes no external request and returns disabled', async () => {
getDb();
setSetting('update_check_enabled', 'false');
assert.equal(svc.updateCheckEnabled(), false);
const orig = global.fetch;
let called = false;
global.fetch = async () => { called = true; throw new Error('fetch must not be called when disabled'); };
try {
const r = await svc.checkForUpdates(true);
assert.equal(r.disabled, true, 'reports disabled');
assert.equal(called, false, 'no external fetch when disabled');
} finally {
global.fetch = orig;
}
});
test('enabled (default): performs the version check', async () => {
setSetting('update_check_enabled', 'true');
assert.equal(svc.updateCheckEnabled(), true);
const orig = global.fetch;
global.fetch = async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: 'v99.0.0', html_url: 'https://example/rel', published_at: '2026-01-01' }),
});
try {
const r = await svc.checkForUpdates(true);
assert.notEqual(r.disabled, true);
assert.equal(r.latest_version, '99.0.0');
assert.equal(r.has_update, true);
} finally {
global.fetch = orig;
}
});
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.rmSync(dbPath + suffix); } catch { /* ignore */ }
}
});