routes/tracker.js had no try/catch and returned a plain {error} shape; the
three GET handlers now wrap in try/catch + standardizeError (including the
invalid-month path). applyAutopaySuggestions ran INSERT + applyBalanceDelta
as two un-transactional steps on a GET — wrapped both in one db.transaction.
Tests: autopay creates one payment + drops balance (idempotent), route
returns standardized error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The notifier used a hard-coded 3-day early reminder and never read
reminder_days_before, so the modal's 'Reminder Days' control was a no-op.
The early reminder now fires at the bill's own lead (>= 2 days so it never
collides with the 1-day/same-day reminders); email subject+body say 'due in
N days'. Lead-time selection extracted to a pure exported reminderTypeFor()
for unit testing. The Reminder Days control now shows for every bill and a
non-subscription save no longer clobbers the column to 3.
Test: tests/notificationLeadTime.test.js
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
buildBankTracking summed expected_amount for all active unpaid bills with no
resolveDueDate gate, so annual/off-month bills inflated unpaid_this_month and
the bank remaining (same class as QA-B5-02, live on the bank path). getTracker
now derives the unpaid total from the already-gated rows (netting partials) and
passes it in. summary.remaining/total_remaining now use the bank card's own
remaining in bank mode (agreeing with safe-to-spend), and a stray balance/100
is now fromCents.
New tests/trackerService.test.js: gating fix, summary totals, bank-mode
remaining agreement, cents<->dollars, getOverdueCount gating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The payoff simulation reported months_to_freedom by taking Math.max over each
debt's payoff month — but a debt whose minimum never overcomes its interest (and
that the rolling snowball can't cover) never reaches $0, so its "never" counted
as 0 and the projection showed the OTHER debts' last month as the freedom date.
Now months_to_freedom/payoff_date are null when any active debt never clears, the
result is flagged capped, and each such debt is marked never_paid
(services/snowballService.js + the same guard in aprService calculateMinimumOnly).
Also adds tests/snowballMath.test.js (12) — the debt-payoff engine had zero
coverage. Hand-calculated examples for amortization (0% + 12% APR), snowball
rolling, avalanche vs snowball interest, skip reasons, APR snapshot, and the
unpayable-debt edge. Server suite 151 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New services/userDataService.js eraseUserData() permanently wipes a user's
financial + imported data in one transaction (child → parent order for FK
safety): bills (+ cascading payments/monthly_bill_state/bill_history_ranges),
transactions/accounts/data_sources, categories/groups, templates, snowball,
spending rules/budgets, merchant rules, imports, and per-user hint tables. It
PRESERVES the account, sessions, 2FA/WebAuthn, login history and preferences —
this resets your data, not your account — then re-seeds default categories and
writes an audit row to import_history.
- POST /api/user/erase-data — rate-limited (demoDataLimiter), requires a
type-to-confirm token ("ERASE"), structured errors.
- UI: EraseDataSection danger-zone card (Export & backups pane) — red-accented,
"download a backup first" nudge, type-to-confirm AlertDialog, toasts; on
success DataPage reloads all state.
Tests: tests/eraseUserData.test.js — wipes user A only, preserves user B +
account + session, re-seeds categories, audited. Server 139 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New services/ofxImportService.js parses OFX 1.x (SGML, unclosed leaf tags),
OFX 2.x (XML) and QFX (+ Intuit tags ignored) into the same normalized shape the
CSV path produces, then writes through the SAME shared primitives (session table,
(user_id, data_source_id, provider_transaction_id) dedupe, import_history) — now
exported from csvTransactionImportService (additive; CSV tests still pass).
- Routes POST /api/import/ofx/{preview,commit} mirror the CSV two-step (raw
upload → structured commit; no column mapping since OFX is structured).
- UI: ImportOfxSection (upload → preview list → import) in the Import pane;
amounts shown via formatCentsUSD; toasts on preview/commit/malformed.
- Gap handling: signed TRNAMT → signed cents; DTPOSTED → YYYY-MM-DD; FITID →
stable provider id (hash fallback); non-OFX / empty files rejected clearly.
Tests: tests/ofxImportService.test.js (SGML + XML/QFX parse, entity decode,
signed cents, preview→commit, re-import dedupe, import_history). Server 129 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found probing a copy of the live SimpleFIN DB: 3 transactions were
match_status='matched' with matched_bill_id=NULL. Bills are soft-deleted
(retained for recovery), then the retention GC hard-deletes them past the
30-day window. transactions.matched_bill_id is ON DELETE SET NULL, so the
purge nulled the pointer but left match_status='matched' — a limbo row
excluded from spending/analytics (match_status != 'matched') yet attributed
to no bill, silently dropping that spend.
pruneSoftDeletedFinancialRecords now releases those matches back to
'unmatched' in the same transaction and self-heals pre-existing orphans;
retention behaviour is unchanged. Verified on a live-DB copy (3→0 orphans,
0 transactions lost). Regression: 3 tests in backupAndCleanup.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- updateCheckService: gate the external request on `update_check_enabled`
(default on); when off, no network call, returns { disabled: true }
- aboutAdmin: GET/PUT /update-check-setting (admin-only) to toggle it
- StatusPage: a Switch on the admin System Status card to enable/disable
- privacy.js: state that an admin can disable it (was called "optional" with
no actual opt-out)
- tests/updateCheckOptOut.test.js: proves no external fetch when disabled
- docs: archive QA-B16-01, B16 ✅
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- notificationService buildEmailHtml: the message line interpolated bill.name
raw (`<strong>${bill.name}</strong> is due…`) while the detail table escaped
it; a `<img src=x onerror=…>` name landed unescaped in the email HTML. Now
escaped everywhere. (self-XSS — reminders go to the bill's owner — but a clear
inconsistent-escaping defect)
- expose buildEmailHtml via _email; add an escaping test across all 4 email types
- docs: archive QA-B14-04
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- notificationService: `module.exports._push = {...}` was set BEFORE the final
`module.exports = {...}`, which wiped it, so routes/notifications.js got
`_push || {}` → sendTestPush undefined → POST /api/notifications/test-push
always threw "Push service not initialised". Scheduled reminders were fine
(in-scope calls). Moved the _push assignment after the reassignment.
- add tests/notificationDelivery.test.js (7 tests: ntfy/gotify/discord payloads,
dispatch, error handling, unknown channel, no token leak in the body)
- docs: archive QA-B10-01
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- analyticsService: only add a bill's expected_amount in months it actually
occurs (resolveDueDate), so annual / off-month quarterly bills no longer
inflate the expected-vs-actual line every month (QA-B5-03, same root as B5-01)
- add a Tracker<->Analytics reconciliation guard to e2e/api.probe.spec.js
- docs: archive QA-B5-03; cycle log
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Use controlled Dialog state (setDialogOpen) instead of immediate onClose()
to let Radix cleanup properly before unmount
- Amber 'Pending' badge now only shows for bank-linked bills — unlinked
bills skip the pending-cleared check and show 'Paid' directly
- TrackerPage onSave no longer nullifies edit state before BillModal can
animate closed
(batch 0.37.4)
- Add bank_pending_count to tracker rows showing pending bank transaction
matches for bills with merchant rules
- Remove snoozed-only state from OverdueCommandCenter (always show when
overdue rows exist)
- Display 'Synced' label for transaction-matched payments in BillModal
- Prioritize 'Pending' badge over StatusBadge when bank has pending matches
- Exclude bank-synced and transaction-matched payments from pending_cleared
(batch 0.37.3)
- Shorten 'Live Sync' label to 'Live' for space-constrained layouts
- Add existing bill due_day fallback in validateBillData to prevent
spurious required-field errors during partial PATCH updates
(batch 0.37.2)