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)