Changelog#
All notable changes to SmrtCash are documented here.
This project adheres to Semantic Versioning and the Keep a Changelog format.
[Unreleased]#
Nothing in flight. Next planned arc is 0.25.x — credit-score + retirement scenarios (both gated on new model layers). The renumbered scaling arc is 0.26.x.
[0.24.4] — 2026-05-27 — Polish + matcher-learning release#
Rollup release that wraps the 0.24.x arc. Bundles four follow-on slices on top of the scenario-hub shipped in 0.24.3:
Added — 11 new reports (0.24.4)#
/reports grew from 6 to 17. New entries:
- Year-over-year by category — this period vs same period last year, per category, with delta and % change.
- Month-over-month movers — biggest spending swings between the two most recent completed months.
- Day-of-week spending pattern — total + count + average outflows by weekday.
- Tax-deductible YTD — Schedule C line totals (uses
categories.tax_categoryfrom 0.21.0). - Savings rate by month — income, expenses, net, savings %.
- Income sources breakdown — inflows grouped by category.
- First-time merchants — vendors whose first transaction falls in the period. Catches subscription creep.
- Refunds and chargebacks YTD — uses
transactions.refund_statusfrom 0.21.1; open disputes float to the top. - Bill price drift — active bills whose last 3 paid periods deviate from the stated amount by >max($1, 2%).
- Debt balance by month — mirror of the net-worth report, filtered to credit_card + loan + manual_liability accounts.
- Average transaction by category — count, total, average per category. Useful for budget calibration.
Added — editable per-tenant mileage rates (0.24.5)#
The IRS standard mileage rates were hardcoded in
server/src/routes/mileage.ts as MILEAGE_RATES. Updating them
required a deploy. Migration 073 moves them into a per-tenant
mileage_rates table with business / charity / medical
columns as numeric(7,1) (matches the IRS publication
precision). Existing tenants are backfilled with the same
values the old constant carried; new tenants lazy-seed on first
/api/mileage-rates read.
UI: new "IRS standard mileage rates" section on /mileage with
one row per year. Inline edit / Save / Cancel per row, source
pill (seeded / manual / irs_fetch), last-updated date, and a
"+ Add year" affordance that pre-fills from the most recent
year on file.
Added — daily anomaly scan (0.24.6)#
Anomaly detection used to only fire at import time, which meant
a tenant who didn't import for a few days got no fresh alerts.
The detector now also runs once per ~23h per tenant inside the
existing insights-scheduler tick. Self-gates on
ANOMALY_ENABLED; idempotent (the anomaly_alerts unique
index handles dedup).
Fixed — dark-mode polish (0.24.7 + 0.24.8)#
- Date-picker indicator in dark mode was a barely-visible
dark glyph on a dark surface. The previous attempt
(
color-scheme: dark+filter: invert(0.85)) was actually fighting itself — Chromium already painted a light glyph in dark mode, and the invert was flipping it back to dark. Replaced the native indicator with an inline SVG that's theme-aware (dark stroke in light mode, light stroke in dark), plus a hover background for a visible click target. - Pill backgrounds that were hardcoded to light-mode hex
codes (the base
.pillwas#f1f5f9,.secret-pilland.warn-pillwere amber, HostWidget pills were rgba()) now use theme vars so they adapt in dark mode.
Fixed — error handler tolerates non-string err.code (0.24.9)#
/api/portability/export returned code.startsWith is not a function — that was the global error handler crashing on
top of the real error. promisify(execFile) rejects with an
Error whose code is the child process exit code (a
number). The handler now coerces to string before calling
startsWith.
Fixed — portability export uses BusyBox-compatible tar (0.24.9)#
The Docker image is Alpine which ships BusyBox tar. The
exporter was passing --force-local (a GNU tar flag) and the
command rejected with unrecognized option: force-local.
Dropped the flag — the exec runs inside the Linux container
regardless of host OS, so Linux paths never have drive-letter
colons and the flag had nothing to defend against.
Fixed — reports page nav loop (0.24.4)#
After running a report, sidebar nav links stopped responding
until you selected (but didn't run) a different report.
FilterableTable recomputed visibleColumns inline on every
render (a fresh array reference each time), which fed a
useMemo for filteredRows, which fired the
onProjectionChange effect, which setState'd on the parent,
which re-rendered FilterableTable, which built a new
visibleColumns array — infinite render loop. Memoizing
visibleColumns on [columns, visible] breaks the cycle.
Added — auto-learn manual transaction renames (0.24.10)#
PATCH /api/transactions/:id setting normalized_merchant
already flipped normalization_status='manual' so future AI
passes couldn't undo the rename — but it didn't TEACH the
system, so future imports of the same raw_description had no
carry-forward. Now an upsert into normalization_rules lands
in the same request: pattern = raw_description verbatim,
source = 'manual', enabled, priority 0. The user can
broaden the pattern from /normalization-rules later.
Added — drift-mode bills track latest matched amount (0.24.10)#
linkBillToTxn in the matcher now updates bills.amount_cents
to the matched transaction's absolute amount when the bill's
amount_mode is drift. Means a car-wash membership going
from $24 to $26 updates the bill row automatically; the next
cycle's budget reflects the new expected. Fixed-mode bills are
left alone (the whole point of fixed is "alert me if this
changes"), and variable-mode bills are too (the cap, not the
expected, is what matters).
Fixed — super-admin sidebar SupportLink undefined#
The super-admin layout referenced <SupportLink> but the
component was never defined — login as super admin crashed
with "SupportLink is not defined". Replaced with the same
inline <a> markup the regular sidebar uses for the same
support-URL link.
Operations#
- Roadmap reorganized: 0.24.x marked ✅ Complete with all sub-slices listed; 0.23.x (Scaling) renumbered to 0.26.x and moved to the end of the document because feature work (0.25.x credit/retirement scenarios next) is taking priority over architectural work we don't yet need.
[0.24.3] — 2026-05-27 — Scenario hub + 13 what-if scenarios#
/scenarios was a one-trick cash-flow projector. This release
refactored it into a hub that registers individual scenario
modules and renders the selected one's Form + Result. Left-rail
picker grouped by category; URL carries ?type=<id> so refresh
or shared link lands on the same scenario. Per-scenario inputs
are preserved when switching between scenarios so users can
compare answers without re-entering numbers.
0.24.0 — Hub refactor#
/scenarios becomes a hub. Existing cash-flow-with-deltas
scenario keeps working as "Cash-flow stress test".
0.24.1 — Wealth-building scenarios (4)#
- Invest $X/mo for Y years at Z% — compounding curve, tax-deferred vs taxable side-by-side.
- Bump 401(k) to N% — take-home delta + tax savings + retirement-balance delta + employer-match warning.
- Windfall split — bonus / refund / inheritance: emergency
- debt + invest split with N-year impact.
- FIRE date — given save rate + spend, years until portfolio covers SWR.
0.24.2 — Debt-payoff scenarios (4)#
- Add $X/mo extra payment — time + interest saved.
- Balance transfer offer — promo APR + transfer fee vs current APR; post-promo balance handling.
- Consolidate at one rate — multi-debt list rolled into a single loan with origination fee.
- Biweekly mortgage — years shaved + interest saved.
0.24.3 — Life-event scenarios (5)#
- Have a kid — childcare + 529 + tax credit, year 1 / 5 / 18.
- Buy a house — PITI breakdown, front/back-end DTI, lender-comfort verdict.
- Job change — total comp delta (salary + benefits + match) adjusted for COL, N-year net.
- Sabbatical / income loss — runway, end-of-break cash, rebuild timeline.
- Recession / income shock — stress test with belt-tightening response, breakpoint month.
Implementation: each scenario lives in
web/src/scenarios/<id>.tsx and exports a ScenarioDef
(id, title, subtitle, category, defaults, Form, Result). Pure
client-side math for every scenario except cash-flow-stress.
[0.22.2] — 2026-05-27 — Credit-card payoff goals#
Goals were always "accumulate up toward target." This release adds the inverse: payoff goals that shrink a tracked balance toward a target ($0 or X% utilization).
- Migration 072:
accounts.credit_limit_cents;savings_goals.kind(savings | payoff);savings_goals.initial_amount_cents;savings_goals.linked_account_ids;savings_goals.target_utilization_pct. Relaxestarget_amount_centscheck from> 0to>= 0so payoff goals targeting $0 can be saved. GET /api/goalsenriches payoff goals withcomputed_balance_cents(live SUM of |balance| across linked accounts) and a shrinking-direction progress formula(initial - current) / (initial - target).- New
PayoffGoalModalreachable from both/goalsand/debt-payoff. Multi-selects credit-card accounts, shows total balance + total limit + current utilization, offers "Pay to $0" or "Pay to N% utilization" (default 30%, the standard credit-score recommendation). - Inline
credit_limitfield on the account detail page +/debt-payofftable. PATCH/api/accounts/:idacceptscredit_limit_cents.
[0.22.1] — 2026-05-27 — Bill matcher UI + date picker fixes#
- Bill row actions on
/recurring— Auto-match (opens a config modal exposingmerchant_pattern,amount_mode,amount_tolerance_cents,match_window_days,overdue_grace_days), Pause (with resume-date picker), Unpause, Skip period. - Recurring page toolbar — Rescan transactions (90-day backfill matcher) and Sweep overdue (manual trigger of the daily sweep).
- Date-picker indicator visible in dark mode —
color-scheme: light/darkon:root, plus::-webkit-calendar-picker-indicatorstyling. (Superseded by 0.24.7's SVG approach.)
[0.22.0] — 2026-05-27 — Bill matching engine + /recurring page#
The headline feature of this arc: bills now auto-match incoming transactions instead of waiting for the user to mark them paid.
Schema (migration 071)#
bills.kind(bill | subscription) — used by the unified/recurringpage's filter tabs.bills.amount_mode(fixed | drift | variable) +bills.amount_tolerance_cents— declares HOW the matcher decides amount fit per row.bills.match_window_days(default 7),bills.merchant_pattern(initialized to the bill name for opportunistic matching on day one),bills.paused_until,bills.overdue_grace_days(default 3).bill_periods— per-cycle ledger keyed by (bill_id, period_anchor_date). Status: pending / paid / overdue / skipped.bill_match_triage— queue for matches the engine wasn't confident enough to make automatically.insight_cards.kindextended withbill_overdue.
Engine (server/src/domain/bill-matcher.ts)#
tryMatchTransaction— vendor (case-insensitive substring)- date (±match_window_days) + amount (per-mode tolerance) scoring. Single confident match → auto-link via bill_periods
- cursor advance + category fill-if-blank. Multi-candidate or edge-of-window or amount-out-of-tolerance → triage rows.
sweepOverdueBills— periodic flip of past-grace pending periods to overdue with aninsight_cardemit.resolveTriage— user accept/reject/reassign from the queue.
Hooks#
- Post-import: matcher runs on each freshly inserted transaction (third pass after rules + anomaly scan).
- Daily:
sweepOverdueBillsruns at the top of every insights-scheduler tick.
UI#
/recurringroute replaces/billsand/subscriptions. Sidebar shows one entry. Both old paths redirect. Tab strip (Bills / Subscriptions) inside the page.BillTriageQueuecomponent renders above the tab strip when there are pending matches; groups by transaction so the user sees "this charge has 2 candidate bills" instead of two unrelated rows. Renders nothing when empty.
Other improvements (same arc)#
- Paycheck budget: collapsible periods. Today's period auto-expands; others collapsed. State persists in localStorage so choices stick across reloads.
- Insights scheduler: a second daily pass besides the
existing insight-card generation —
sweepOverdueBillsruns at the top of every tick.
[0.21.8] — 2026-05-27 — Split Budgets into Monthly + Paycheck pages#
The single /budgets page mixed two distinct workflows: a
month-to-date category-target table and a Paycheck-to-Paycheck
plan with per-period cards. They share categories conceptually
but want very different chrome, so this slice splits them.
Frontend (web/src/pages/):
- New
MonthlyBudgetPage— categories vs. month-to-date table, month-picker, edit-target inline. URL/monthly-budget. - New
PaycheckBudgetPage— plan / period cards, wizard launcher, per-plan delete. URL/paycheck-budget. - Old
/budgetsURL redirects to/monthly-budgetso any existing bookmarks keep working.
Nav: under Planning, Budgets replaced by two siblings — Monthly budget and Paycheck budget.
[0.21.7] — 2026-05-27 — Portable .smrtcash data export + importer#
User-facing extension on the existing portability export changes
from .tar.gz to .smrtcash (still a gzipped tarball). New
importTenantBundle() rehydrates the core tables back into a
fresh tenant.
Server (server/src/domain/portability.ts,
server/src/routes/portability.ts):
- Renamed suggested filename to
.smrtcash. importTenantBundle(targetTenantId, archivePath)— extracts, parsestenant.json, rehydratescategories(parents first with child remapping),accounts, andtransactionswith FK remapping. Returns counts +skippedlist.POST /api/portability/import— admin-only multipart upload accepting the.smrtcashfile. Imports into the CURRENT tenant; caller is expected to use an empty tenant for clean rehydration. Writes an audit log entry.
Frontend (web/src/pages/WorkspacePage.tsx):
- Data portability section gains an Import file picker, a confirmation prompt, and a result summary banner.
Known follow-ups (returned in skipped): budgets, bills,
recurring income, holdings, and attachment file bodies are not
yet rehydrated by the importer.
[0.21.6] — 2026-05-27 — Manual subscription cancellation queue (scoped down)#
The roadmap entry described automated cancellations via Playwright. After review of the ToS / unauthorized-access exposure, scope was reduced to a manual queue.
Server (server/src/db/migrations/068_cancellation_queue.sql,
server/src/routes/cancellation-queue.ts):
- New
cancellation_queuetable with a state machine (queued → in_progress → done | couldnt | abandoned), optionalbill_idbackref,cancel_url,monthly_cents,notes. Partial index on open rows. /api/cancellation-queueCRUD with status filter (open/closed/ specific state).
Frontend (web/src/pages/CancellationsPage.tsx):
- New
/cancellationspage in the Planning nav, per-row state-transition buttons, monthly-savings rollup of completed rows, add/edit modal.
Deliberately NOT in scope: stored vendor credentials, outbound HTTP on the user's behalf, headless-browser flows.
[0.21.5] — 2026-05-27 — Scenario cash-flow forecasting#
What-if overlay on top of the existing /api/cash-flow projection.
Server (server/src/routes/bills.ts):
/api/cash-flowaccepts optionalincomePct,expensePct, andoneTime(comma-separateddate:centspairs) query params. Response gains ascenarioblock alongside the baseline series.
Frontend (web/src/api.ts,
web/src/pages/ScenarioPage.tsx):
api.cashFlow()is now({ days, scenario }); legacy positionalcashFlow(days)callers updated.- New
/scenariospage in the Planning nav. Two sliders (50–200% on income / expense), repeatable one-time event rows, side-by-side baseline-vs-scenario chart.
[0.21.4] — 2026-05-27 — Investment performance: TWRR, IRR, vs benchmark#
Time-weighted and money-weighted returns plus a S&P 500 benchmark comparison.
Server
(server/src/domain/investment-performance.ts,
server/src/routes/investments.ts):
twrr()— sub-period multiplication across cash flows.irr()— Newton-Raphson with bisection fallback.annualize()— period-length → annualized rate.benchmarkCumulativeReturn()— static yearly S&P 500 total-return table; accepts optional override.GET /api/investments/performancereturns per-account + portfolio numbers over the requested window.
Frontend (web/src/pages/InvestmentsPage.tsx):
- New Performance section at the top with start/end pickers, four metric cards (TWRR, IRR, benchmark, vs benchmark), and a per-account table.
Caveats: beginning-of-period account value is approximated from cumulative prior-balance transactions; precise historical TWRR needs daily holdings snapshots (follow-up).
[0.21.3] — 2026-05-27 — Non-traditional household models#
Three new primitives for households that aren't "one couple, one shared account":
Server
(server/src/db/migrations/067_household_participants.sql,
server/src/routes/household.ts):
household_participants— named people (spouse / child / co_parent / roommate / dependent / other), optional email + color.account_splits— per-account percentage allocation across participants. App-layer enforces sum == 100.custody_periods— date-ranged "this account is 100% this participant's" rules; latest start wins for overlaps./api/household/participants/totals— applies splits and custody to produce per-participant year-to-date rollup.
Frontend (web/src/pages/HouseholdPage.tsx):
/householdpage with sections for participants, account splits, custody periods, and the rollup table.- Modal forms for add/edit/delete of each resource.
Out of scope: cross-tenant invites — existing membership/role flow already covers spouse/child access.
[0.21.2] — 2026-05-27 — Receipt → warranty tracking#
Server
(server/src/db/migrations/065_warranties.sql,
server/src/db/migrations/066_insight_cards_warranty_kind.sql,
server/src/routes/warranties.ts):
warrantiestable (item / vendor / purchase + expiry dates, optionalpurchase_cents, optionaltransaction_id+attachment_idbackrefs)./api/warrantiesCRUD with status filter (active/expired/expiring).findExpiringWarranties()used by the daily insights scheduler;insight_cards.kindCHECK extended withwarranty_expiring.insights-generator.tsemits a card for any warranty within 30 days of expiry with severity escalating warn → critical.
Frontend (web/src/pages/WarrantiesPage.tsx):
/warrantiespage in the Tools nav, status filter, add/edit modal, summary cards (active / expiring / expired / covered value).
Bug fix (cff74b2): the original migration used
CURRENT_DATE in an index WHERE clause — Postgres requires
that to be IMMUTABLE. Dropped the partial-predicate; small
warranty row counts make a full index fine.
[0.21.1] — 2026-05-27 — Refund / chargeback tracking#
Server (server/src/db/migrations/064_transactions_refund_status.sql,
server/src/routes/transactions.ts):
- New columns on
transactions:refund_status,refund_note,refund_updated_at. Allowed states:refund_pending,refunded,chargeback_initiated,disputed,closed. Partial index on open (non-resolved) rows. PATCH /api/transactions/:idacceptsrefundStatus+refundNote;refund_updated_atauto-stamps on every change./api/transactionsreturns the three new fields per row.
Frontend (web/src/components/RefundStatusModal.tsx,
web/src/components/TransactionTable.tsx,
web/src/pages/TransactionsPage.tsx):
- TransactionTable shows a coloured
↩ {status}pill in the description meta when set. - A
↩icon in the row's actions column opens the modal for set / edit / clear. - Modal shows the transaction's merchant + amount + date and the last-updated timestamp.
[0.21.0] — 2026-05-27 — Real tax export: Schedule C, mileage, TXF#
Mint-class tax export — Schedule C grouping, IRS-compliant mileage log, TurboTax-importable TXF v042 file.
Server
(server/src/db/migrations/063_mileage_log.sql,
server/src/domain/tax-schedule-c.ts,
server/src/routes/mileage.ts,
server/src/routes/tax-year.ts):
- New
mileage_logtable with date / purpose / miles per trip, optional vehicle + odometer + locations. SCHEDULE_C_LINEStable mapping line number → label → TXF code with keyword fallbacks for the free-texttax_categorycolumn.matchScheduleCLine()does exact-label / "Schedule C- Line N" prefix / keyword matching.
buildTxf()emits TXF v042 records (V042 / ASmrtCash / Dmm/dd/yyyy / per-line TD / N{code} / C1 / L1 / $amount / P{description} / ^).- New endpoints:
/api/mileageCRUD +/api/mileage/summary/:year./api/reports/tax-year/:year/schedule-cJSON view that rolls free-texttax_categorylabels into Schedule C lines and folds business mileage onto line 9./api/reports/tax-year/:year.txf— TurboTax-importable file./api/reports/tax-year/:year/mileage.csv— per-trip substantiation log.
Frontend (web/src/pages/MileagePage.tsx,
web/src/pages/TaxYearPage.tsx):
- New
/mileagepage in Insights nav with summary cards, per-purpose breakdown table, trip table, add/edit modal. /taxpage gains a Summary / Schedule C tab toggle plus Download TXF and Mileage CSV buttons in the header.
[0.18.12] — 2026-05-24 — Settings URL validation guardrails#
The @-instead-of-. typo that consumed hours of diagnosis
during the smrtcash-test deploy (CHANGELOG 0.18.8) would have
been caught instantly by URL-shape validation on the settings
page. This slice adds that validation in both directions.
Server (server/src/routes/settings.ts):
- New
validateUrlSetting(key, value)exported for testing. Runsnew URL(value), rejects 400 on parse failure with an actionable message. - Requires
http:orhttps:scheme — nofile:,ftp:,javascript:etc. - Specifically rejects URLs with a
usernameorpasswordcomponent (RFC-legal userinfo, but in our settings context always a typo of.in a hostname — the exact bug pattern). - New
URL_TYPED_KEYSset:PUBLIC_BASE_URL,SUPPORT_URL,OLLAMA_BASE_URL. PUT to any of these runs through the validator before persistence.
Web (web/src/pages/SettingsPage.tsx):
- Per-key URL hint shown below the input when the typed value
doesn't parse, has a non-http scheme, or contains an
@in the host position. - Save button disabled while the hint is non-null — you can't even submit a typo.
- Input gets
type="url"for URL-typed keys so the browser's native invalid-URL state engages too.
Tests: 6 new unit tests covering happy paths, non-URLs,
non-http schemes, and the specific @-typo case from the
deploy. All 77 server unit + tenant-iso tests pass.
Bonus polish: the .hint.warn CSS class (used here and on
ResetPasswordPage + SignupPage password validation) had no CSS
rule at all — it was rendering as plain unstyled text in every
spot it was used. Now styled as small warn-colored text below
the field.
[0.18.11] — 2026-05-24 — SaaS deploy guide + runbook entry#
Docs-only — captures the smrtcash-test deploy sequence so the next operator doesn't rediscover the gotchas.
- New
docs/SAAS_DEPLOY.md— 11-section walkthrough for putting SmrtCash on a fresh box behind Nginx Proxy Manager: SSH preflight, deploy-key generation,docker-compose. override.ymlfor theproxynetwork (no host-port publish),.envgeneration with strong secrets, NPM proxy-host config with the exact advanced-tab snippet, SMTP relay choice (with the Maileroo URL-mangling lesson called out so future operators don't repeat the wild-goose chase), DNS verification, first super-admin creation, sandbox cleanup, day-2 ops, and a troubleshooting table. docs/OPERATOR_RUNBOOK.md§ "User stuck on verification gate" — appended. Covers the pre-0.17.3 footgun where invitation / OIDC / super-admin-invite users were left withemail_verified_at IS NULLand bounced at login. Ships the SQL one-liner that unsticks them in bulk, plus a pre-0.17.3 vs post-0.17.3 behavior matrix so the operator can tell at a glance whether the playbook even applies.
HTML mirror regenerated.
[0.18.10] — 2026-05-24 — Dropdown lifecycle audit + defensive fix#
User reported that some dropdowns were retracting when the mouse was moved. By the time we looked, the behavior wasn't reproducing (likely a transient Chromium hiccup), but the audit was worth running.
Audit findings (all green):
- Every custom popover uses
mousedownoutside-click detection, nevermousemoveormouseleave. Column chooser (FilterableTable), every modal (Profile, CancelInfo, Goal, Income, Vehicle, etc.), and the mobile sidebar drawer all pass. - No React portals; menu trees stay within their
refcontainer so checkbox/label clicks inside the menu don't trigger outside-click close. - No
onMouseLeave-driven close anywhere except the drag/drop hover state on AttachmentsModal (legitimate use). - No
setTimeout-driven close. - No CSS
:hover { display: ... }menus.
Defensive change:
- Dropped the
transition: border-color 0.12s ease, box-shadow 0.12s easefrom<select>specifically. Chromium has had reported bugs where a focus transition on a<select>can interrupt the native dropdown's render and cause it to retract mid-interaction. The transition is preserved on<input>and<textarea>where it's a useful visual cue (and has no native-dropdown overlay to clash with).
[0.18.9] — 2026-05-24 — Form contrast fix (washed-out inputs)#
Root cause: styles.css had two competing input rules. The newer
design-system rule used theme variables but only matched explicit
input[type="text"] / input[type="number"] / etc. selectors.
Any <input> without a type attribute (HTML defaults to text
but CSS attribute selectors don't match the implicit default), and
<input type="file">, fell through to the older rule which
hardcoded background: #fff — fine in light mode, but in dark
mode it produced a near-white block where the field's text
blended into the background.
Specific places the user flagged:
- new account creation
- transactions filter row
- Categories "tax tag" autocomplete (
<input list="...">) - new goal entry
- Import "choose file" area
- Connections
- new vehicle dialog
- Sharing
All resolved by the same fix.
Changes:
- Old base rule (
input, select) now usesbackground: var(--surface-2)instead of the hardcoded#fff. - Design-system rule consolidated to plain
input, select, textarea— every input type matches, no more fall-through. - New explicit styles for
<input type="file">including a themed::file-selector-button. - Checkboxes + radios get an override that restores their native sizing (don't want a padded box around them).
- Placeholder color bumped from
var(--dim)tovar(--muted)for better visibility in both themes. - Same fix applied to three other surfaces with the same
hardcoded-white-bg bug:
.cell-select(in-row dropdowns),.reports-list-item(Reports left rail),.column-chooser-menu(FilterableTable column menu), and.gauge(Health page).
[0.18.8] — 2026-05-24 — Unify base URL settings#
Pre-0.18.8 there were two operator settings that answered the same question — "what URL should outgoing links use?":
APP_BASE_URL(read by invitations + super-admin admin-invite)STRIPE_PUBLIC_BASE_URL(read by signup verification, password reset, dunning, Stripe Checkout redirects)
with different fallback orders, plus two resolveBaseUrl()
helpers (one each in tenants.ts + system.ts) that also disagreed.
During the smrtcash-test deploy an operator typo'd APP_BASE_URL
with @ instead of .; only the invitation flow broke (the
verification flow read the other key). Hours of misdirected
debugging followed (the symptoms looked like Maileroo URL
mangling).
Changes:
- New
PUBLIC_BASE_URLsetting inKNOWN_SETTINGS; old two keys removed. - Migration 048 backfills
PUBLIC_BASE_URLfromSTRIPE_PUBLIC_BASE_URLfirst, elseAPP_BASE_URL, then deletes the old rows. - New
server/src/domain/base-url.tswith oneresolveBaseUrl()helper. Priority:PUBLIC_BASE_URLsetting → legacySTRIPE_PUBLIC_BASE_URLenv → legacyAPP_BASE_URLenv →Originheader →Host+X-Forwarded-Proto→ dev localhost. Legacy env fallback preserves back-compat for.envfiles that still use the old names. - Both local
resolveBaseUrlhelpers (tenants.ts, system.ts) + the localpublicBaseUrlshims in auth.ts, billing.ts, and webhook-handlers.ts now all import from one place.
Regression bar: 102 tests pass (tenant-isolation, auth, api-keys, email-shell).
[0.18.7] — 2026-05-24 — Branded HTML email shell#
Driven by test-deploy feedback that invitation emails looked
plain. Every outbound email now goes through a shared
renderEmailShell({ title, intro, ctaText, ctaUrl, bodyHtmlSafe, ctaColor }) helper in server/src/domain/mailer.ts — a 560px
table-layout shell with a navy "SmrtCash" header, branded CTA
button, monospace URL fallback, and a "developed by BuildITSmrt,
LLC." footer that links to builditsmrt.com.
Applied to all four existing renderers:
renderVerificationEmail(green CTA, "Confirm email")renderPasswordResetEmail(indigo CTA, "Reset password")renderInvitationEmail(indigo CTA, "Accept invitation")renderDunningEmail(red CTA, "Update payment method")
Inline styles only — the major clients (Gmail, Outlook, iOS
Mail) strip <style> blocks but respect style="..." attrs.
New tests/unit/email-shell.test.ts asserts every renderer's
html includes the shell footer marker ("BuildITSmrt, LLC.") so
a future contributor can't ship a hand-rolled email by accident.
[0.18.6] — 2026-05-24 — Debt payoff plans#
Closes the YNAB gap on debt-payoff workflows.
- Migration 047:
accounts.interest_rate_apr numeric(6,3)+accounts.min_payment_cents bigint, both nullable, both CHECK-constrained. PATCH /api/accounts/:idaccepts the two new fields with validation; GET returns them.POST /api/debt/payoffwith{ extraCents, overrides? }body. Loads loan + credit_card accounts with a non-zero balance, runs both snowball (smallest balance first) + avalanche (highest APR first), returns both plans plus a per-month schedule. Caps iteration at 600 months and surfacesunpayable: truewhen minimums don't cover interest. Reportsmissing_datafor accounts lacking APR or min payment.- Domain helper
computePayoffPlanwith 4 unit tests (zero- interest base case, ordering invariants, min-too-low detection, snowball roll-up). - New
/debt-payoffpage (added to Planning nav group): inline APR + min-payment editors that save immediately, "extra per month" input, tab strip switches between avalanche and snowball, milestone tiles for months / total interest / total paid, per- account result table.
[0.18.5] — 2026-05-24 — Goal tracking polish#
Closes Monarch's visible goal-tracking advantage. Existing
savings_goals data, missing UX.
- Migration 046: new
goal_contributionstable (id, tenant_id, goal_id, transaction_id, amount_cents, note, contributed_at). Signed amount, so withdrawals/corrections live in the same audit trail. POST /api/goals/:id/contribute: writes an audit row and bumps the goal'scurrent_amount_cents. Server clamps the new current at 0; over-target is allowed (some users save past the number).GET /api/goals/:id/contributions: last-50 history (UI not surfaced yet; future slice can show the chart).- Goal cards now show pace-needed when both a target date and remaining amount are set: "$420/month needed". Progress bar flips green at 100%.
- Dashboard "Top savings goals" tile with up to three goals, progress bar + target date each.
Transaction-tagging (linking a specific txn to a contribution) is intentionally deferred — needs a UI on the Transactions page that doesn't exist yet.
[0.18.4] — 2026-05-24 — Public read-only API + per-user keys#
Personal-access-token model (GitHub / Stripe style) layered on top
of the existing session-cookie auth. Same routes the web app uses
become available over Authorization: Bearer smrt_… for read
operations.
Schema (migration 045):
api_keys (id, user_id, tenant_id, key_hash, key_prefix, label, scopes, last_used_at, last_used_ip, revoked_at, created_at). Token stored as SHA-256 hash; prefix kept separately for display.
Auth path (server/src/app.ts):
- preHandler falls back to Bearer when no session cookie is
present.
req.user.viarecords'session' | 'apikey'. - New second hook rejects non-GET (and non-HEAD/OPTIONS) for apikey-authed requests. A leaked token cannot mutate data.
- Tenant binding comes from the
api_keysrow, never the request — a token minted under tenant A can't see tenant B's data even if the caller crafts headers/queries to try.
Routes (server/src/routes/api-keys.ts):
GET /api/me/api-keys— list (prefix + metadata only, never the raw token).POST /api/me/api-keys {label}— mint. Returns the token EXACTLY ONCE. Capped at 10 active keys per user. Audit log recordsapi_key.create.DELETE /api/me/api-keys/:id— soft revoke (setsrevoked_at; row kept for audit + UI history). Logsapi_key.revoke.
Web (ProfileModal → new ApiKeysSection):
- Table of existing keys with prefix, label, last-used relative time, revoke button.
- One-line create row. On success the modal shows the full token inside a yellow warning banner with a Copy button + "I've saved it — dismiss" link.
Docs: new docs/PUBLIC_API.md with curl examples for the
most useful read endpoints (accounts, transactions, cash-flow,
insights, calendar) and a callout that this is NOT a write
scope or OAuth flow.
Tests: 6 new integration tests in
tests/integration/api-keys.test.ts:
- mint → list (prefix only) → use → returns 200
- POST/PATCH/DELETE with a Bearer token → 403
- bogus or missing Bearer → 401
- revoked key → 401
- token minted in tenant A cannot see tenant B's accounts
- 11th key creation → 400 (cap)
All 92 tenant-isolation + auth tests still pass.
Closes roadmap slice 0.18.4 (Public read-only API). Slice renumbering note: this was originally planned as 0.18.2 in the v0.17.0-era roadmap; it became 0.18.4 after 0.18.2 was re-used for the vendor-attribution fix and 0.18.3 for idle auto-logout + timezones.
[0.18.3] — 2026-05-24 — Idle auto-logout + per-user timezone#
Two user-facing controls that the test-deploy use surfaced:
Idle auto-logout (operator-set):
- New super-admin setting
WEB_INACTIVITY_TIMEOUT_MINUTES, surfaced under a new "Web session" section on/settings. 0(default) disables the timer — sessions only end when the user signs out or the cookie expires (prior behavior).- A non-zero value (e.g.
15) starts a per-tab timer that resets on any mouse/key/scroll/touch event. When it fires, the samelogout()call as the manual Sign-out button runs. - New
useIdleTimeout(minutes, onTimeout)hook owns the event subscriptions; disabled mode adds no listeners. - Value is delivered to the client inside
/api/auth/meunderweb_settings.inactivity_timeout_minutesso the enforcement starts on the next auth round-trip without a second fetch.
Per-user timezone:
- New
users.timezonecolumn (nullable IANA string, migration 044). NULL = follow the browser, which is the prior behavior for every existing user. PATCH /api/auth/meaccepts{ name, timezone }. Timezone validated againstIntl.supportedValuesOf('timeZone')so"Mars/Olympus"can't be saved.- New
ProfileModalopened from the sidebar footer ("My profile" link). Lets the user edit display name + pick a zone from a curated "Common" optgroup (13 NA/EU/APAC zones) and a fallback "All zones" optgroup with the full IANA list. Empty selection = "Follow my browser ()". - New
web/src/format.tshelpersformatDateTime(iso)andformatRelative(iso)read the preferred zone from a module-level state set byApp.tsxon auth viasetUserTimezone(). Existing date-only formatter (formatDate) is untouched — YYYY-MM-DD fields like transaction dates and bill due dates remain zone-agnostic, which is the right call for ledger data. - Rollout: the infrastructure is in place. Existing
callers using
new Date(iso).toLocaleString()will be migrated toformatDateTime(iso)opportunistically as pages are touched.
Roadmap slice number renumbered: this takes 0.18.3; the originally-planned 0.18.3 (Public read-only API) becomes 0.18.4, and so on through 0.18.10.
[0.18.2] — 2026-05-24 — Vendor attribution + auto-versioned footer#
Two small but visible fixes that came out of looking at the test deploy:
- Sidebar footer was stuck at "SmrtCash · v0.17.25"
even after 0.18.0 + 0.18.1 shipped, because the version
was a hand-typed string in
App.tsx. Replaced with__APP_VERSION__, injected by Vite fromweb/package.jsonat build time. Pattern documented invite.config.tsso future contributors can't recreate the drift bug. - "Developed by BuildITSmrt, LLC." attribution is
now visible to every user. New
BrandTaglinecomponent rendered in:- the regular sidebar footer (logged-in users)
- the super-admin sidebar footer (operators — version surfaced here too, which it wasn't before)
- the Login page (anonymous visitors)
- the Signup page (anonymous visitors)
Vendor name links to https://builditsmrt.com.
[0.18.1] — 2026-05-24 — Subscription cancellation help#
Each bill now has four nullable fields — cancel_url,
cancel_email_template, cancel_steps, cancel_notes —
surfaced via a "Cancel info" action on the Bills page that
opens a modal with copy-to-clipboard buttons on the URL and
email body, plus a "Open ↗" link straight to the cancel page.
Built-in library (server/src/domain/cancellation-library.ts)
ships ~28 common merchants pre-loaded — Netflix, Spotify,
Hulu, Disney+, Max, Paramount+, Peacock, Apple TV+, YouTube
Premium, Amazon Prime, Audible, Kindle Unlimited, NYT, WSJ,
Washington Post, ChatGPT, GitHub Copilot, Adobe, Microsoft
365, Dropbox, 1Password, LinkedIn Premium, NordVPN,
ExpressVPN, Peloton, Crunchyroll, Planet Fitness, generic
gym. The notes column carries warnings users actually need:
NYT routes you into a chat, WSJ to a phone call, Planet
Fitness is in-person or certified-mail only.
Modal click "Auto-fill from library" → looks up the bill name against the library (case + non-alphanumeric insensitive substring match, longest match wins), fills any BLANK fields, leaves user edits alone. Everything is editable per-bill so a known merchant entry is just a starting point.
A green dot on the "Cancel info" button indicates the bill already has at least one cancel-related field saved, so the user can see at-a-glance which subscriptions are documented.
New surface area:
- Migration 043 — four nullable text columns on
bills GET /api/cancellation/lookup?name=X(tenant-gated)PATCH /api/bills/:idacceptscancelUrl / cancelEmailTemplate / cancelSteps / cancelNotesCancelInfoModalcomponent on the Bills page
Tenant-isolation tests still pass (the cancellation fields
live on bills which is already tenant-scoped; the lookup
route is read-only and returns the same shared library
content for every tenant).
Closes roadmap slice 0.18.1.
[0.18.0] — 2026-05-24 — Cash-flow forecast as dashboard hero#
The 90-day cash-flow projection has been on the dashboard since Phase 6 but was the fourth card down. v0.18.0 promotes it to the headline: it now sits above the chart grid, full-width with a taller plot, and includes the information the older card never had.
- Confidence band.
/api/cash-flownow derives a daily volatility from the last 90 days of non-transfer transactions (stddev of daily NET cents). The projected line is the same deterministic walk through bills + income; the new band widens asstddev × √(day_offset), so day 90 carries roughly ten times the uncertainty of day 1. Rendered as a green-tinted area behind the projection line. - Milestone tiles. Response now includes
milestones.day_30 / day_60 / day_90— the projected balance at each horizon. Shown as three pill-style tiles in the hero header, color-coded green if at-or-above the starting balance, orange if below. - Zero reference line. Dashed orange line at $0 so an upcoming overdraft is visually obvious.
- Daily volatility surfaced in the subtitle so the user understands what's driving the band width.
The old fourth-card placement is gone; everything else on the dashboard (spending pie, income-vs-expense bars, net-worth line, upcoming bills) is unchanged.
Closes roadmap slice 0.18.0 — first slice of the "Competitive parity & depth" series.
[0.17.25] — 2026-05-24 — Plaid + Anomaly toggles in Settings UI#
Both feature switches already existed as super-admin settings
in the backend (PLAID_ENABLED, ANOMALY_ENABLED) but
weren't listed in the SettingsPage's SECTIONS, so the UI
didn't render them. Adds two new sections:
- Plaid — bank sync with
PLAID_ENABLED,PLAID_CLIENT_ID,PLAID_SECRET,PLAID_ENV - Anomaly alerts with
ANOMALY_ENABLED,ANOMALY_LARGE_TXN_THRESHOLD_CENTS,ANOMALY_MULTIPLIER,ANOMALY_EMAIL_TO
Each setting uses the existing save/clear/show-source UI from SettingsPage. No backend or schema change.
[0.17.24] — 2026-05-24 — Duplicate bill action#
Real-world case: two people in a household each have their own Netflix subscription, both billed to the same Chase card. Pre-fix the user had to re-enter all the bill fields (amount, frequency, due date, category, account) by hand.
Change#
POST /api/bills/:id/duplicate— copies every persistable field except id/review fields and appends " (copy)" to the name. Returns the new bill.api.duplicateBill(id)on the web client- "Duplicate" button in each bill row's actions, between "Mark paid" and "Delete"
Renaming the duplicate uses the existing PATCH path (no UI yet for inline rename, but the cadence + account pickers are already on the row). 7 bills tests still pass.
[0.17.23] — 2026-05-24 — Inline frequency editor for bills + income#
The PATCH endpoints already accepted frequency; the UI just
didn't surface it. Adds a small <select> to each row's
actions on /bills so the user can switch a bill from monthly
→ biweekly (or any of the 5 bill cadences) and the change
persists immediately via PATCH /api/bills/:id. Same for
recurring income (4 cadences — no "one-time" for income).
No schema, no test changes.
[0.17.22] — 2026-05-24 — Savings chip redesign + destination account#
Chips#
Pre-rev the wizard had two chips driven by two settings: "% of income" and "% of leftover" with a Max chip that just picked the largest of the three. That mixed income-based and leftover-based bases and only let the user nudge two numbers.
New shape:
- Goal-required — unchanged, from active savings_goals
- Low % — default 25% of post-deduction leftover
- Mid % — default 50%
- High % — default 75%
- Max — 100% of leftover (full available funds)
"Post-deduction leftover" = income − bills − groceries − fuel − tolls − misc. Savings itself isn't subtracted (that's what these chips help you decide).
The three percentages are editable per wizard run and the
defaults can be set globally via three new settings:
SAVINGS_PCT_LOW, SAVINGS_PCT_MID, SAVINGS_PCT_HIGH.
The legacy SAVINGS_INCOME_PCT + SAVINGS_LEFTOVER_PCT
keys stay in the settings registry (marked legacy) but are
no longer read by the wizard.
Destination account#
Migration 042 adds budget_plans.savings_account_id (FK to
accounts(id), ON DELETE SET NULL). The wizard offers a
"Savings goes to" dropdown filtered to accounts.type = 'savings'. Selecting one stamps it on the plan; the chosen
amount on each period card is then known to feed that
account.
Wizard input/preview shape changes#
WizardPreview.savingsLowPct/MidPct/HighPctreplace the legacysavingsIncomePct/savingsLeftoverPctWizardSavingsSuggestionsnow exposespctLowCents/ pctMidCents/pctHighCents(andmaxCents= full leftover)- Wizard request body accepts
savingsLowPctOverride,savingsMidPctOverride,savingsHighPctOverride, andsavingsAccountId
20 budget + wizard + commute-routes tests pass.
[0.17.21] — 2026-05-24 — Account scope for savings_goals#
Final piece of the per-account plan audit. Walking back through each wizard input:
- Groceries (categories of historical transactions) —
already scoped via
weeklyGroceriesMedian(tenantId, accountIds) - Fuel — vehicles tagged with account_id (0.17.20)
- Tolls — commute_routes tagged with account_id (0.17.20)
- Bills — tagged with account_id (0.17.16 + 0.17.17)
- Recurring income — tagged with account_id (0.17.16 + 0.17.17)
- Savings goals — until now, NOT scoped: every plan's "goal-required" suggestion summed every tenant goal, so a $200/week emergency-fund contribution showed up on Chase AND Savings plan cards (would have budgeted $400/week)
Fix#
Migration 041 adds savings_goals.account_id. The wizard's
goalRequiredForPeriod() now takes the same accountIds
filter — when set, only goals tagged to those accounts feed
the per-period suggestion. NULL account_id goals are
excluded from scoped runs (strict semantics, matching every
other axis).
API + UI#
POST/PATCH /api/goalsacceptaccountId(uuid or null)- GoalForm modal has a "Funded from" dropdown
- GoalCard shows "Funded from" + inline picker for in-place edits
20 budget + wizard + 5 goals tests pass. The five tagging
axes — bills, recurring_income, vehicles, commute_routes,
and savings_goals — are now consistent. Combined with
transactions' DB-enforced NOT NULL, every input to the
wizard's per-plan projection is account-aware.
[0.17.20] — 2026-05-24 — Account scope for vehicles + routes#
Pre-fix: the wizard's routeDrivenWeekly() aggregated fuel
and tolls across every vehicle and commute route in the
tenant, regardless of which plan's accounts paid for them.
With per-account plans (0.17.16), that meant the same
$250/week fuel cost landed on every plan's card —
double-counted across budgets.
Schema#
Migration 040 adds account_id uuid REFERENCES accounts(id) ON DELETE SET NULL to both vehicles and commute_routes.
No backfill — these tables have no transaction history to
learn from. Existing rows stay NULL until the user tags them.
Wizard#
routeDrivenWeekly() now takes an accountIds filter.
When set, the vehicle and tolls SQL both gate on account_id = ANY($accountIds). NULL account_id items are excluded
from scoped wizard runs — matching the strict semantics
introduced for bills in 0.17.17.
Routes#
POST /api/vehicles+PATCH /api/vehicles/:idacceptaccountId(uuid or null to clear)POST /api/commute-routes+PATCH /api/commute-routes/:idaccept the same
UI#
- New Account column on the Vehicles fleet table with an
inline
<select>per row - The same picker on each RouteCard in the actions bar
After this slice: the four account-tagging axes (transactions, bills, recurring_income, vehicles, commute_routes) all support strict per-plan scoping. The wizard's groceries/fuel/tolls/ bills/income projections all honor the plan's account scope cleanly.
20 budget + wizard + commute-routes tests pass.
[0.17.19] — 2026-05-24 — Fix migration 038 tenant join#
Migration 038 silently no-op'd: it joined transactions via
t.tenant_id = b.tenant_id, but transactions.tenant_id is
unpopulated on this deploy — every other read path in the
codebase joins through accounts.tenant_id. 038 was the
outlier and matched zero rows.
Migration 039 re-runs the same backfill but joins
transactions through their account_id → accounts.tenant_id
chain, matching the rest of the codebase.
Confirmed on test box: 2 NULL bills (GM Financial, We Energies) are correctly assigned to Chase-5793 after the fix.
[0.17.18] — 2026-05-24 — Bills + income account tagging#
Followup to 0.17.17's strict scope: now that untagged bills disappear from plan cards, the user needs a way to fix them.
Audit#
Walked every INSERT path into transactions. The account_id
column is DB-enforced NOT NULL with an FK to accounts(id),
and every importer (CSV/OFX/QIF via importer.ts, OFX Direct
Connect via stored connection.account_id, Plaid via the
plaid_account_links table) routes through persistBatch()
with a validated account_id. No INSERT path can drop an
account_id today. All 2,039 transactions on the test box
have one.
Backfill#
Migration 038 retries bills + recurring_income with FULL-NAME
matching (vs migration 036's first-word heuristic). For each
NULL-account row, picks the transaction account it matches
most often by ILIKE '%<name>%' against raw_description or
normalized_merchant. Catches the cases 036 missed (e.g. "GM
Financial" and "We Energies" on the test box). Idempotent +
safe to re-run.
Edit support#
PATCH /api/bills/:idacceptsaccountId(uuid or null to clear)- New
PATCH /api/recurring-income/:idsupporting name, amountCents, frequency, nextExpectedDate, active, and accountId api.updateBill+api.updateRecurringIncomeon the web client
UI#
- New Account column on both the Bills and Recurring Income tables on /bills
- Inline
<select>in each row's actions: pick a different account (or "— No account —") and the change persists immediately
Result: no more digging into SQL to fix tagging. Run the migration, see what's still wrong on /bills, pick the right account from the dropdown.
15 budget + 7 bills tests pass.
[0.17.17] — 2026-05-24 — Strict account scope for bills + income#
Pre-0.17.17 a bill or recurring income source with
account_id = NULL was treated as "household-wide" — it
joined every period card regardless of that period's plan
scope. That was fine when a tenant had ONE budget; with
per-account plans (0.17.16) it leaks the untagged item onto
EVERY plan's card.
Concrete case: GM Financial and We Energies on the test box
had NULL account_id (migration 036's first-word heuristic
didn't match any transactions for them). Both showed on the
Chase-5793 plan's period card even though they shouldn't
belong to that scope.
Change#
/api/budgets/period(s) and buildWizardPreview now apply
a strict filter: when a plan/scope is set, only bills and
income tagged to one of the scope's accounts appear. NULL
account_id rows are excluded from every scoped plan.
Unscoped queries (no plan filter) still show everything.
Migration#
No schema change. Existing untagged bills + income will
silently stop appearing on plan cards until their
account_id is set. The next slice will add UI to assign
account_id from /bills.
15 budget + wizard tests pass.
[0.17.16] — 2026-05-24 — Per-account budget plans#
Pre-0.17.16 a tenant effectively had one budget — the wizard
created a flat set of budgets rows sharing a cadence and
optional account scope. That breaks when different accounts
get paid on different paycheck cycles (Chase weekly, Savings
monthly) — the user wants each account to have its own
budget.
Model#
New budget_plans table: one row per paycheck cycle,
carrying name, period_type, anchor_date, and the
account_ids it scopes. Each budgets row hangs off it via
plan_id (ON DELETE CASCADE — removing a plan removes its
per-period lines).
Invariant: each account belongs to at most one plan per tenant (validated at the route layer before insert; 409 on conflict). Plan names must be unique within a tenant.
Migration 037 backfills one plan per existing
(tenant_id, period_type) cluster named
"Imported budget (<cadence>)" and stamps plan_id on the
existing rows. Pre-existing tenants don't need to re-run the
wizard.
Wizard#
- New required
namefield on commit (sent through to the plan row). Preview defaults the name to a placeholder so pre-commit previews still work. - Server validates name + account overlap before insert. Returns 409 on either conflict.
commitWizardreturns the newplanIdso the UI can follow up on the just-created plan.
Paycheck-to-Paycheck section#
Cards are now grouped by plan. Each group renders a header with the plan name, cadence, anchor date, and account list, plus a "Delete plan" button. The PeriodOverview cards stack underneath as before.
/api/budgets/periods groups by (plan_id, window) and
includes plan_id + plan_name on each entry.
Monthly Budget#
The asOf aggregation already collapsed per-category lines across periods; it now also collapses across PLANS — one combined "Groceries" line summing Plan A's $400 (Chase weekly) + Plan B's $200 (Savings monthly) = $600 budgeted.
Actuals are computed against the UNION of all member plans'
account_ids. If any member row carries a NULL scope
(pre-0.17.11 legacy), the combined scope becomes NULL
(include every account) — preserving legacy semantics.
Tests#
3 new wizard tests:
- commit returns planId + stamps plan_id on every row
- duplicate plan name returns 409
- second commit with a new name creates a second plan with its own row set (no skip-as-dup)
15 budget + wizard tests pass. Full suite green at 744/744.
[0.17.15] — 2026-05-24 — Monthly Budget: one row per category#
The Monthly Budget section (bottom of /budgets) was showing every weekly budget row as its own line — e.g. four "Groceries" rows for a month when AutoMagic ran weekly. Plus the per-row actuals query was overlapping: rows from multiple anchors all resolved to the same active period and double/triple-counted the same transactions.
Fix#
The asOf branch of /api/budgets/actual now aggregates:
- Picks budget rows whose anchor (
period_month) lands in the calendar month containingasOf. For monthly cadence that's the one row anchored to the 1st; for weekly cadence it's every row whose week starts that month. - Groups by
(category_id, bill_id, flex). One group per category, one per bill, one for the flex pool. - Sums
budgeted_centsacross each group's member rows — so 4 weekly Groceries × $150 = $600 monthly. - Computes
actual_centsONCE per group against the full calendar-month range — no more overlap counting.
The include_account_ids filter still applies per group
(scope is shared across all member rows by wizard
construction; the first row's scope wins). The legacy
month=YYYY-MM-01 branch is unchanged — it was already
monthly-only.
Bills#
A bill in the wizard creates one budget row per
instance the bill was due in the period (e.g. Netflix
appears in only the weekly period containing its due date).
So bills naturally didn't multiply across weekly rows; the
new code still groups by bill_id for symmetry and to
handle edge cases where two anchors land on the same bill.
Actuals for bills are taken as actual = budgeted
(recurring fixed amount; future slice could match against
actual transactions matching the bill).
Net effect#
Monthly Budget section now shows:
- One row per budgeted category, with budgeted = sum of weekly/biweekly/etc. allotments
- One row per bill, with budgeted = its monthly amount
- One row for the flex pool (catch-all spending), if set
- Actuals match calendar-month transaction totals
API shape unchanged; just fewer + larger rows in the same
rows[] array.
14 budget + wizard tests still pass.
[0.17.14] — 2026-05-24 — Backfill bills + income account_id#
Operator with account-scoped budgets ("Chase 5793 only")
reported "still seeing CAMCO Precision Payroll income" on
the card. Diagnosis: the period filter treats
account_id IS NULL as "household-wide, always include" —
the right rule for genuinely-shared items but wrong for the
many rows the recurring-detection job and manual-entry
created with NULL account_id even though every matching
transaction landed in one specific account.
Migration 036#
For each row with account_id IS NULL:
recurring_income— find the account that has the most income transactions (amount_cents > 0) whosenormalized_merchantorraw_descriptioncontains the first word of the income source name. Setaccount_idto that account.bills— same, but for outgoing transactions (amount_cents < 0).
Heuristic notes:
- Match is case-insensitive
LIKE '%firstword%'against the merchant/description. - Requires the first word to be ≥3 chars so 1–2 char tokens don't false-match ("RJ" → too many false hits).
- If no match: row stays
NULLand continues to behave as household-wide. Operator can edit via UI later. - Only touches rows where
account_id IS NULL. Existing non-null values are preserved.
Net effect (test deploy verification)#
RJW Logistics Payroll→ Chase Checking - 5793 ✓CAMCO Precision Payroll→ Chase Checking - Aadyn (so it no longer appears on a 5793-only card)Zelle from Aadyn Leo Johnson→ Chase Checking - 5793 (most-frequent match)- 37 bills get backfilled similarly.
Follow-up to-dos (not in this slice)#
- Audit the creation paths (recurring-detection job +
manual entry forms) to ensure
account_idis set at insert time for new rows. The migration only fixes existing rows; without these audits, new rows could recreate the bug. - Optional UI to edit
account_idon bills and recurring income from the Bills page / a new recurring-income management page.
Both are reasonable v0.18.x slices.
[0.17.13] — 2026-05-24 — Section labels on /budgets#
The two halves of the budgets page do genuinely different things and the user reframed them in their own language:
- Top — the stacked Period Overview cards from AutoMagic. One per income period, with income / bills / set-aside / net. The user calls this "Paycheck-to-Paycheck Budgeting" because each card maps to one paycheck cycle.
- Bottom — the budget-vs-actual table. Calendar-month actuals against per-category budgets. The user calls this "Monthly Budget".
Change#
- New
<h2>heading + subtitle above the period cards: "Paycheck-to-Paycheck Budgeting · One card per income period — income, bills, set-aside, net." - New
<h2>heading + subtitle above the budget-vs-actual table: "Monthly Budget · Calendar-month actuals against per-category budgets. Use the picker above to change month." - Page-header subtitle rewritten to call out both flows in one sentence so a new operator understands the page at a glance.
- The "AutoMagic setup" button + month picker stay in the page header (they're tied to the period cards + monthly table respectively).
No backend changes; all-UI slice.
[0.17.12] — 2026-05-24 — Period overview bills + income honor scope#
After 0.17.11 added per-budget account scope on actuals,
an operator reported "still showing transactions from
unselected accounts." Investigation: the budget-vs-actual
SQL was correctly filtering, but the Period Overview's
Bills and Income sections read from the master bills
and recurring_income tables and ignored the period's
scope. A bill on Chase-Aadyn still appeared on a card
scoped to Chase-5793.
Fix#
BillRowandIncomeRowtypes gainaccount_id: string | null- The shared
fetchPeriodCtxSELECT pullsaccount_id buildPeriodSummaryfiltersctx.billsandctx.incomeby the period's scope before walking instances:- Rows whose
account_idis in the scope: included - Rows with
account_id IS NULL: always included (household-wide bills/income apply to every account) - All other rows: excluded
- Rows whose
- When the period has no scope (legacy / "all accounts"), filter is a no-op.
Tests#
14 budget + wizard integration tests still pass. The shape of the period summary response is unchanged; only the per-period filter logic in the helper changed.
Net effect#
After this slice, every place a period's totals or events appear on /budgets respects the wizard's account selection:
- Bills in Period Overview: scope-filtered (0.17.12)
- Income in Period Overview: scope-filtered (0.17.12)
- Set aside in Period Overview: scope-tagged on each row already (0.17.11)
- Budget-vs-actual table at the bottom: actuals SQL scope-filtered (0.17.11)
- AutoMagic preview (median + bills + income before commit): scope-filtered (0.17.8)
[0.17.11] — 2026-05-24 — Per-budget account scope on actuals#
After 0.17.8 added an "Include accounts" checklist to the wizard, an operator noticed the deselected accounts were filtered out of the wizard's median calculations BUT NOT out of the budget-vs-actual section that compares actuals to those wizard-set amounts. Net effect: "Groceries budgeted $200/week from my personal checking only" got compared to "$350 spent on groceries across personal + business" — off by an entire account.
Fix#
Each budget row remembers the account scope it was created with:
- Migration 035 adds
budgets.included_account_ids uuid[], nullable. Existing rows keepNULL(legacy "all accounts" behavior); new rows from AutoMagic write the wizard's account selection. commitWizardwrites the scope to each row at INSERT. Null when the wizard ran against every account (no scope needed) so we don't waste DB space on a no-op array./api/budgets/actualSELECTs filter actuals by the row's scope:($N::uuid[] IS NULL OR a.id = ANY($N)). The same guard appears in both the per-category branch and the flex-pool branch./api/budgets/periodscarries the period scope through to the response. Each period summary'sincluded_account_idsechoes the scope so the UI can render it.
Web#
PeriodOverview cards show a small "Includes accounts: …"
line under the period range when the scope is set, with each
account's display name resolved via api.listAccounts().
Removed accounts (the rare case where an account in the
scope was later deleted) render as (removed).
Tests#
14 budget + wizard tests still pass. No new tests in this slice — the change is a column add + a SQL filter add + a field surface; the wizard tests already cover the account-filter path on the wizard side, and the budget-vs-actual tests cover the per-row computation.
Operator notes#
- Existing budget rows from earlier wizard runs (pre-0.17.11)
have
included_account_ids = NULL. They keep the legacy "all accounts count" behavior. To pick up scoping on those rows, re-run AutoMagic with the desired account selection; the new commit will overwrite (where duplicates are detected by the existing constraint) or supplement (where not).
[0.17.10] — 2026-05-24 — Stacked period overviews#
Operator feedback after 0.17.9:
"I want the AutoMagic wizard to add Groceries, Fuel, Tolls, Misc, and Savings entries… presented after the bills and before the leftover. Depending on what the user selected in the wizard there would be a section for every period that was created before presenting the monthly actuals."
The wizard already creates rows for every category + period; the gap was on the read side. The /budgets page only rendered the single period covering today. If the user committed 5 weekly periods, only one card showed; the other four were invisible.
Fix#
New endpoint GET /api/budgets/periods returns all
distinct committed period windows as an array, each with its
own income / bills / set-aside / totals / has_committed_budgets
shape. Sorted ascending by period.start.
- Groups budget rows by distinct
(period_month, period_type, period_end)tuples — the wizard creates rows at distinct anchors per period, so the grouping collapses naturally. - Each group's window is computed via the existing
currentPeriodhelper withasOf = the row's own anchor(returns the canonical window for that row, not whatever cadence-step covers today). - Bills + income filtered into each window via
instancesIn. - When no commits exist anywhere, the response falls back to a single calendar-month placeholder so the empty-state CTA in the set-aside section still has a card to render in.
Shared helpers extracted#
The bill/income/editable/totals computation was duplicated in
the singular /period route. Extracted into two reusable
pieces:
fetchPeriodCtx(tenantId)— parallel SELECT of budgets + bills + income, all tenant-scopedbuildPeriodSummary(window, activeRows, ctx)— given a window + the budget rows that cover it + the master tables, produces aBudgetPeriodSummary
Both /api/budgets/period and /api/budgets/periods use the
same helpers; the difference is just which budget rows feed
into activeRows.
Web#
BudgetsPage now fetches the plural endpoint and maps
each summary to a PeriodOverview card. The month picker
still scopes the budget-vs-actual table below (each card
already says its own period range). Wizard run with 5 weekly
periods → 5 cards stacked. Wizard run with 1 monthly period
→ 1 card. No commits → 1 placeholder card with CTA.
Tests#
14 existing budget + wizard tests still pass. No new tests in
this slice — the helpers are a pure refactor of code already
exercised by the singular /period test path; the plural
route is the same helpers in a loop.
[0.17.9] — 2026-05-24 — Bills show independent of wizard commit#
After 0.17.7's Period Overview, an operator with a cleared budgets table reported "not showing bills or what was configured for set aside — this is the data that should come from the AutoMagic Setup wizard."
Diagnosis: the Bills section sourced its rows from committed budget rows (the wizard creates one budget row per (period, bill)). If no budget row exists for the period, bills were silently dropped — even though the underlying bills are real money that's still due.
Fix#
GET /api/budgets/period now sources bill events from the
bills table directly:
- Compute bill instances in the active period via the same
instancesInhelper used by the wizard (frequency-walked). - If a committed budget row exists for that (period, bill),
use the row's
amount_cents— the wizard may have overridden the master amount. - Otherwise fall back to the bill's own
amount_cents. budget_idis nowstring | nullon each row; null signals "no commitment yet, this is just the master bill."
Net effect: bills always render in the Period Overview when they're due, regardless of whether AutoMagic has committed budget rows for the period.
Empty state for set-aside#
The Set Aside section still requires wizard-committed rows
(those amounts only exist after a wizard run). The empty
state now shows a small CTA card explaining the gap and a
✨ Run AutoMagic Setup button that opens the wizard
modal directly. Response gains a has_committed_budgets
flag — true when at least one budget row exists for the
active period.
Tests#
14 budget + wizard integration tests still pass. No new tests in this slice — the change is a SELECT swap (bills table replaces budget filter) plus a UI empty state.
[0.17.8] — 2026-05-24 — Wizard account include/exclude#
Operator who has multiple accounts (personal checking + business checking + joint household) wants the wizard to only consider a subset when building suggested budgets:
"Allow the AutoMagic budget setup to include or exclude accounts."
What's new#
WizardInput gains an optional accountIds: string[]
filter. When set, the wizard's data-source SELECTs include:
- Bills with
account_id IN (...)ORaccount_id IS NULL - Recurring income same — account-scoped or household-wide
- Grocery transactions (for the median) joined through accounts with the same filter
- Vehicles + commute routes unaffected — they're not account-scoped (tied to the household).
Bills and income with account_id IS NULL are always
included: those represent household-wide commitments by design
(e.g. "rent" is a household bill, not a per-account one).
Filtering them out when the user picks a subset would
silently drop legitimate items.
UX#
The wizard modal now has an "Include accounts" field above the Refresh-preview button:
- One checkbox per tenant account, all checked by default
- "Select all" / "Deselect all" master toggle (indeterminate state when some-but-not-all are selected)
- Counter shows
N of Mselected - Helper text reminds users that household-wide bills and income are always included regardless of selection
Server#
WizardInput.accountIds?: string[]weeklyGroceriesMedian(tenantId, accountIds)— passes the IDs through; SQL uses($2::uuid[] IS NULL OR a.id = ANY($2))so a null array means "no filter"- Bills + income queries gain the same conditional filter
parseInputvalidates each entry as a UUID; rejects bogus IDs with400 accountIds must be UUIDs- An empty array on the wire is normalized to "no filter" in
the service (matches the "deselect all" UI state, which the
client doesn't actually send empty — it sends
undefinedin that case)
Tests#
27 wizard + budget + commute tests all still pass. No new
tests for the filter itself — the SQL guard ($2::uuid[] IS NULL ...)
is the same shape used in the 0.17.6 fixes that ARE tested.
[0.17.7] — 2026-05-24 — Period cash-flow view on /budgets#
Operator feedback after 0.17.6 made the 61 AutoMagic-created budget rows visible:
"Their presentation is not very helpful. A weekly budget should show your income and what your expected expenditures are going to be for that period and what days they are expected to come out — income then days expected to hit your account, expenditures/outgoing bills presented in one line per bill with vendor, amount and date, then modifiable entries presented with what needs to be manually moved to savings, followed by the amount that is left over or that you need to find a way to cover because you are over extended."
That's a "period cash flow" view — completely different from the budget-vs-actual table the page rendered before. Now both ship: cash-flow first (what the user asked for), budget-vs- actual table below (kept for spot-checking actuals).
New endpoint#
GET /api/budgets/period?asOf=YYYY-MM-DD returns:
{
asOf, period: { start, end, type },
income: [{ id, name, amount_cents, date }],
bills: [{ budget_id, bill_id, name, amount_cents, date }],
editable:[{ budget_id, category_id, category_name,
amount_cents, requires_manual_action }],
totals: { income_cents, bills_cents, editable_cents,
net_cents } // negative = overextended
}
- Active period is picked by scanning the tenant's budget
rows and finding the first whose
currentPeriod(asOf)window covers asOf. So a wizard run with weekly cadence shows weekly windows; monthly cadence shows monthly windows; no need to guess. Falls back to the calendar month if no budget rows cover asOf (so the UI still renders income + bills from master tables). - Income events computed from
recurring_incomedirectly (income is a target, not a commitment — no budget rows for it). SameinstancesInhelper the wizard uses. - Bill events come from budget rows where
bill_id IS NOT NULLin the active window; the masterbillstable provides the due date for the visible instance. - Modifiable categories come from budget rows where
category_id IS NOT NULL.requires_manual_actionflips true for Savings (the user has to physically transfer money to a savings account each period); other categories are spending caps where actuals reduce the budget automatically. - All tenant-scoped via
requireTenant. The 0.17.6 fix ensures the underlying rows have propertenant_id.
Web#
BudgetsPage fetches the period summary alongside
budget-vs-actual on every month change. A new
PeriodOverview component renders three tables (Income /
Bills / Set aside) with running totals per section, then a
large net line at the bottom — color-coded green/red.
Overextended runs also show a one-line "you'll need to cover
this gap" note pointing the user at the editable section as
the place to trim.
The budget-vs-actual table stays below for users who like seeing budgeted vs spent on a single screen.
Files changed#
server/src/domain/budget-wizard.ts—BillRowandIncomeRownow exported (was internal);instancesInexported (the period helper that walks frequency forward).server/src/routes/budgets.ts— newGET /api/budgets/periodroute +nextMonthStart/ymdTodayhelpers.web/src/api.ts— newBudgetPeriodSummarytype +api.budgetPeriod(asOf)helper.web/src/pages/BudgetsPage.tsx—periodstate, paired fetch inload(), newPeriodOverviewcomponent rendered above the existing budget-vs-actual card.
Tests#
All existing budget + wizard tests still pass (14/14 in the two test files). No new tests for the new route in this slice — it's a pure read aggregator over data the existing routes already test thoroughly; the cash-flow shape is mostly UI plumbing.
[0.17.6] — 2026-05-24 — Fix: AutoMagic budget wizard tenant scoping#
The AutoMagic budget wizard (/api/budgets/wizard/*) predated
the 0.11.0 multi-tenant phase and was missed in the 0.14.x
isolation hardening. Bug surfaced on the smrtcash-test deploy
when an operator ran the wizard, got "Created 61 budget rows",
and couldn't find a single one on the /budgets page.
Three layered bugs#
commitWizardINSERTed budgets withouttenant_id.budgets.tenant_idwas added as nullable in migration 017 and never made NOT NULL, so the INSERTs succeeded but produced rows invisible to/api/budgets(which filtersWHERE tenant_id = $1). The 61 created rows were orphans.buildWizardPreviewcross-tenant leak. The preview's grocery-median calculation readtransactionswith no tenant join; route-driven fuel + tolls readvehiclesandcommute_routeswithout filtering bytenant_id; bills and recurring_income were read with no filter either. On a multi-tenant deploy tenant A's wizard would include tenant B's data in the budget suggestions.goalRequiredForPeriodcross-tenant leak. Same shape —savings_goalsread without tenant filter.
Fix#
WizardInputgains a requiredtenantIdfield. Threaded throughbuildWizardPreview→ all helpers (weeklyGroceriesMedian,routeDrivenWeekly,goalRequiredForPeriod) and into the resultingWizardPreviewsocommitWizardreads it from the preview object (single source of truth, no re-derive).commitWizarddup-check + INSERTs now both tenant-scoped. Categories lookup uses(tenant_id = $1 OR tenant_id IS NULL)with DISTINCT ON to prefer the tenant's own custom category over a system seed of the same name.budget-wizard.tsroute usesrequireTenanton both preview + commit and threads it into the input.parseInputnow returnsOmit<WizardInput, 'tenantId'>so the parser stays pure and tenant scope lives at the route layer.
Migration 034 — backfill orphans + NOT NULL#
- Single-tenant deploys (like smrtcash-test): the migration
backfills all
tenant_id = NULLbudget rows to the only tenant. The 61 orphans on the test box become visible to the Default tenant on apply. - Multi-tenant deploys: orphans are ambiguous (we'd be
guessing which tenant a wizard run was for), so the
migration
DELETEs them. They were never reachable from any UI; no in-app behavior changes. - Final step:
ALTER COLUMN tenant_id SET NOT NULL— any future regression of the same bug fails at INSERT time instead of silently creating orphans.
Tests#
Two existing tests in tests/integration/budget-wizard.test.ts
and one in tests/integration/commute-routes.test.ts were
exploiting the cross-tenant leak — they seeded vehicles /
routes / savings_goals without tenant_id and relied on the
no-filter SELECT to find them. With the fix those SELECTs
filter correctly, so the seeds had to be updated to include
tenant_id = (SELECT id FROM tenants WHERE slug='default').
Full suite green: 743 server + 6 web.
Operator notes#
- After deploying 0.17.6, your 61 orphan budgets become visible automatically (single-tenant deploy + migration backfill). No re-running of AutoMagic needed; just navigate to /budgets.
- The /budgets page shows one month at a time. If your
AutoMagic run covered multiple weeks (e.g.
weeklycadence × 12), use the month picker to find each period. The budget-vs-actualasOfmode picks the active period for the selected date. - If your run created budgets for non-monthly periods (weekly,
biweekly, semimonthly), those still write to the same
budgetstable; the budget-vs-actual route already handles anyperiod_type. They render on whatever month theperiod_monthanchor falls in.
[0.17.5] — 2026-05-24 — Meter normalize + re-normalize prompt#
Two bugs surfaced during the smrtcash-test deploy after the 0.17.4 progress-indicator ship made it easy to hammer Normalize:
Bug 1 — billing meter stuck at 0#
Symptom. 155 normalize batches in 4 hours, /billing's "AI assistant calls" stuck at 0.
Cause. The normalize route never called
checkAndIncrementQuota. Only /api/assistant/chat did.
AI compute was happening (≈10 LLM calls per batch) but the
meter never moved — operators couldn't see usage and Plus
tenants could over-normalize past their cap.
Fix. POST /api/normalize now calls
checkAndIncrementQuota(tenantId, FEATURES.AI_ASSISTANT, limit)
before doing any work. The same monthly cap that protects
Plus from runaway chat usage now applies to normalize too;
Family stays unlimited but the counter ticks so /billing
shows real usage. Charge is limit (the chunk size) per
batch — close enough for cap enforcement, slightly over-counts
errors. Pre-work check refuses with 402 if a batch would
exceed the cap.
Bug 2 — already-normalized rows silently skipped#
Symptom. Click Normalize after everything's done → button does nothing visible. No feedback, no prompt to redo.
Cause. The route's SELECT only picked status='pending'
rows; already-normalized rows were silently filtered out.
Fix. Three pieces:
POST /api/normalizeaccepts a newmode: 'pending' | 'all'body param.'pending'= legacy behavior (default).'all'includes already-normalized rows in the SELECT and in the UPDATE's WHERE.'manual'rows are never touched in either mode — those are user choices the AI doesn't override.- New
GET /api/normalize/countsreturns{ pending, normalized, manual }in one round-trip. TransactionsPage.runNormalize()fetches counts first and shows the right confirmation prompt:pending > 0, normalized = 0→ run silently (default case)pending = 0, normalized = 0→ "nothing to do" bannerpending = 0, normalized > 0→ "Nothing pending. Re-run AI on the N already-normalized? This counts against your monthly AI quota."pending > 0, normalized > 0→ "N pending. Also re-run AI on the M already-normalized? OK = redo all · Cancel = pending only."
Server changes#
server/src/ai/normalize-service.ts—modeparam onNormalizePendingOptions+fetchPendingTransactions; newcountByNormalizationStatus(tenantId, accountId?)export. UPDATE WHERE changed fromstatus = 'pending'tostatus <> 'manual'so the 'all' mode actually rewrites normalized rows.server/src/routes/normalize.ts— quota check before work; newGET /api/normalize/countsroute;modevalidation on POST.
Web changes#
web/src/api.ts—modeparameter onapi.normalize(); newapi.normalizeCounts().web/src/pages/TransactionsPage.tsx— pre-run counts fetch + 4-way decision tree →window.confirm()prompts for the redo cases;modeplumbed through the chunked loop.
Tests#
All 743 server + 6 web tests still pass. One pre-existing
normalize test (normalizes pending transactions after an import) caught an SQL-precedence regression in my first
draft of the mode-aware SELECT — explicit parens around the
two mode branches fixed it. Worth calling out: AND > OR
binding is the SQL standard, but with two parameterized
type casts ($4::text =) in adjacent branches, explicit
parens are clearer and safer.
[0.17.4] — 2026-05-24 — Normalize progress indicator#
Surfaced during the smrtcash-test deploy when running AI normalization across a hundred-ish imported transactions: the old "Normalizing…" button gave zero feedback for the duration of the run, and on the Claude provider that's ~1 second per transaction. A 500-row import = 8 minutes of silence.
What's new#
- Live progress bar + counter on
/transactionswhile normalization runs. ShowsX of Ywith a percentage and a thin horizontal bar that updates between batches. - "Stop" button lets the user halt mid-run without losing committed progress. The chunked loop checks a cancel ref between batches; the next batch never starts after Stop is clicked.
Approach#
No streaming/SSE plumbing, no jobs table. The existing
POST /api/normalize route already accepts a limit
parameter, so the client gets progress feedback simply by
calling it repeatedly with a small chunk size and
accumulating totals between calls. New denominator endpoint
returns the pending-count once at run start.
Trade-off vs SSE: more HTTP round-trips (one per batch of 10 transactions). For real workloads (≤ a few hundred pending after an import) the overhead is negligible compared to AI-call latency, and the implementation is ~30 lines instead of ~300.
Files changed#
server/src/ai/normalize-service.ts— newcountPendingTransactions(tenantId, accountId?)exported helper.server/src/routes/normalize.ts— newGET /api/normalize/pending-count, tenant-scoped, same optionalaccountIdfilter as the existing POST.web/src/api.ts— newapi.normalizePendingCount().web/src/pages/TransactionsPage.tsx— chunked loop inrunNormalize()(10 per chunk viaNORMALIZE_CHUNK), cumulative totals tracked in newNormalizeProgressstate, Stop button via cancel ref, inline progress banner with bar + counter. A safety-valve iteration cap (2× expected + 4) protects against pathological loops where the server reports nonzeroprocessedbut never reduces the pending count.
Tests#
743 server + 6 web all still pass. No new tests for the
chunked loop itself — it's UI state management; the
underlying /api/normalize is the same well-tested route,
and the new pending-count endpoint is a one-line query
that mirrors the existing fetchPendingTransactions SELECT.
Operator notes#
- Chunk size 10 is hard-coded as
NORMALIZE_CHUNKinTransactionsPage.tsx. Higher numbers reduce HTTP round-trips at the cost of slower progress updates; lower numbers feel more responsive. 10 ≈ 10–20s per chunk against Claude, which is the right cadence. - "Stop" doesn't roll back. Whatever was processed in the
most recent in-flight batch stays processed. The next
Normalize click picks up from where Stop left off
(because the route only selects
status = 'pending').
[0.17.3] — 2026-05-24 — Fix: auto-verify on proven-ownership paths#
Bug. The 0.16.0 login gate refuses any user whose
email_verified_at is NULL. Three user-creation paths leave
that column NULL despite the path itself being equivalent
proof of email ownership:
- Invitation acceptance (
POST /api/invitations/:token/acceptintenants.ts) — clicking the invite link sent to the recipient's email IS the proof, same as the signup verification flow. - OIDC / SAML first-login (
identities.ts:resolveIdentity) — the identity provider verified the email before issuing tokens; we inherit that proof. - Super-admin promotes another super-admin from
/system(POST /api/system/users/super) — same trust model as/api/auth/setup(the bootstrap operator path), which already auto-verifies.
Surfaced during the smrtcash-test deploy when a freshly invited spouse user tried to log in and got the gate ("Please confirm your email address before logging in").
Fix. All three INSERTs now set email_verified_at = now().
The existing-user branch in invitation acceptance also UPDATEs
unverified existing users to verified (an unverified user
created via /signup who never clicked the link can be
unstuck by accepting a tenant invite). The /signup →
/verify-email flow stays unchanged — that's the one path
where the user genuinely needs to prove ownership before
login.
Files changed#
server/src/routes/tenants.ts— new + existing-user branchesserver/src/auth/identities.ts— non-local provider usersserver/src/routes/system.ts— super-admin promotion
Tests#
All 743 server + 6 web tests still pass. (No new tests added in this slice; the bug is in the absence of a column-set operation, and the existing invite + OIDC + super-admin tests exercise the create paths. Dedicated regression tests land in the v0.18.5 email-shell slice that already touches this area.)
Operator notes#
If you already have users stuck in the verification gate from a pre-0.17.3 deploy (i.e. they accepted an invitation and now can't log in), the SQL one-liner to unstick them after upgrading is:
UPDATE users SET email_verified_at = now()
WHERE email_verified_at IS NULL
AND id IN (
SELECT user_id FROM memberships
UNION
SELECT user_id FROM user_identities WHERE provider != 'local'
);
That covers everyone who has either a tenant membership (came
in through an invite) or a non-local identity (came in via
OIDC). It deliberately doesn't touch users with only a 'local'
identity and no memberships — those are the /signup users
who genuinely should verify.
[0.17.2] — 2026-05-24 — Fix: disable SMTP click tracking on transactional email#
Bug. Maileroo (the SMTP relay used on the test deploy) has
click tracking enabled by default. It rewrites every link in
the message body through a tracking redirect. For the
invitation email's URL https://smrtcash-test.builditsmrt.com/ invite/<token>, Maileroo's rewriter mangled the host into
[email protected] — likely because the
<subdomain>.<domain>.<tld> pattern matched the FROM address
[email protected] and the rewriter substituted
the . for an @. Recipients clicking the button landed at
builditsmrt.com (the marketing site, 404).
Fix. tryMail() now passes X-Maileroo-Track: no on
every outbound message. This is the documented Maileroo
header for opting out of both open + click tracking
(docs).
On any other SMTP relay the custom X- header is silently
ignored, so it's safe to set unconditionally.
Why default-off everywhere. Every email SmrtCash sends is transactional (invitation, verification, password reset, dunning, SMTP test). Click tracking is a footgun on this class of email: it corrupts URLs, triggers safe-link warnings in some clients (the wrapped redirect domain doesn't match the visible host), and adds zero analytics value for an account flow. Tracking is appropriate for marketing campaigns, which we don't send.
Operator notes#
- No setting to configure; this is in code. If a future
deployment ever wants tracking on (e.g. they add marketing
emails through the same plumbing), override the header in a
wrapper rather than removing the default in
tryMail(). - Existing in-flight emails sent before this change can't be fixed — clicked links go to the wrong host. Re-send any pending invitations after deploying 0.17.2.
[0.17.1] — 2026-05-24 — Fix: super-admin admin-invite was silent#
Bug. POST /api/system/tenants/:id/admin-invite created
the invitation row + returned a token but never called
tryMail — it was designed in Phase 8 as a copy-link-only
flow and the SaaS-pivot work didn't revisit it. Operators who
deployed against a configured SMTP server expected an email to
go out; nothing did. Tenant-admin-driven invitations
(/api/tenants/:id/invitations) were fine — that path has
been emailing since 0.12.x.
Fix. The super-admin route now mirrors the tenant flow:
when emailHint is set, it renders the standard invitation
email (renderInvitationEmail) and posts it through
tryMail(). The response gains an email field with
{sent, reason} so the UI can switch between "email sent" and
"copy this link" banners. SMTP-unconfigured deployments + calls
with no emailHint keep the copy-link fallback unchanged —
the invitation row still gets created, the route still 201s,
and the operator is told why no mail went out.
Audit-log details now also carry email_sent: boolean so a
super-admin can see at a glance whether the recipient got the
mail or got a copy-paste link.
Behind the scenes#
- New
renderInvitationEmail+tryMailimports inroutes/system.ts. - New local
resolveBaseUrl()helper (mirrors the one inroutes/tenants.ts) for the accept URL. Priority:APP_BASE_URLsetting →STRIPE_PUBLIC_BASE_URLsetting → request headers →http://localhost:4000.
Tests#
Existing system-subscriptions.test.ts (11 cases) still
passes. Dedicated tests for the new email path landed in the
same change.
Roadmap#
Added 0.18.5 — Branded HTML email shell + audit to the
competitive-parity series. Driven by the same test-deploy
finding: every outward email already sends both text and
html, but the HTML is minimal and each renderer hand-codes
styling. Slice will introduce a shared
renderEmailShell() wrapper used by all four renderers
(verification, password reset, dunning, invitations) + a CI
assertion that no tryMail() call ever omits the html field.
[0.17.0] — 2026-05-24 — Documentation refresh + HTML build#
Two-part: bring the top-of-funnel docs in line with the v0.16 SaaS-pivot reality, then add a static-site-friendly HTML mirror of every doc for the marketing site to serve.
Doc refresh#
README.md— status section now mentions both the self-host and SaaS deployment paths; lists the 0.15.x SaaS pivot + 0.16.x launch readiness work explicitly; updates the test count to 743 server + 6 web (was ~630); adds new documentation links (Operator Runbook, SaaS Plan, Stripe Setup, ToS, Privacy); adds a "Static HTML" pointer.docs/FEATURES.md— two new sections at the top: "Accounts, signup, billing (SaaS)" covering pricing tiers, trial, dunning, grace, signup, password reset, the subscriptions console + SaaS health dashboard, runtime settings, and support link; "Security & encryption" covering per-tenant envelope encryption, the rotate button, isolation tests, and account-enumeration prevention.docs/ROADMAP.md— table extended with 0.13.x / 0.14.x / 0.15.x / 0.16.x rows; new "0.14.x" + "0.15.x" + "0.16.x" sections in the body documenting every slice; new "v0.17+" section enumerating what's left after v0.16 (Stripe Tax setup, lawyer review, observability, annual discount UX, native mobile).docs/KNOWN_ISSUES.md— date stamp bumped to 0.16.4; notes that the SaaS pivot introduced no new open issues.docs/INSTALLATION.md— env-vars table split into bootstrap-only (set in.env, requires restart:DATABASE_URL,SESSION_SECRET,ATTACHMENT_ENCRYPTION_KEY, etc.) and runtime-editable (preferred via/settings: Stripe keys, signup gate, support URL, SMTP, AI, etc.). Includes the twonode -eone-liners for generating the secret keys.docs/README.md— replaced the "reflects Phase 1" stamp with a v0.16.4 current-state pointer; added rows for the three SaaS-mode docs; added a "HTML mirror" section.
HTML build pipeline#
scripts/build-docs-html.mjs— converts every.mdin the repo (root +docs/, minus the duplicatedocs/README.md) to a self-contained HTML page viamarked. Output lives atdocs/html/. Features:- Inline CSS — no external requests, no build step for the marketing site (just static files).
prefers-color-scheme: darkmedia query — pages look right on any theme without the host site interfering.- GitHub-style anchor links on every heading (hover to
reveal
#). - Internal
.mdlinks rewritten to.htmlso navigation inside the bundle works without help from the host. - Top header with brand + back-to-index link + outbound Support link.
- Footer with the generation timestamp + git short SHA when run inside a git checkout.
index.htmlwith category-organized list (Getting started / Product / SaaS operator / Development / Legal).
npm run docs:html— script alias.marked@^17added as a root devDependency. No other new dependencies; output is plain static files.- 18 pages committed to
docs/html/. Marketing site can pull straight frommain.
Operator notes#
- The HTML output IS committed to the repo so a marketing
deploy doesn't need a build pipeline. Re-run
npm run docs:htmlafter editing any.mdsource and commit the result. CI could enforce this with agit diff --exit-codecheck if drift becomes a problem. - The legal stubs (
TERMS_OF_SERVICE.md,PRIVACY_POLICY.md) are rendered as-is; they're still marked "PLACEHOLDER" at the top of the body and shouldn't ship to customers until lawyer-reviewed copy replaces them.
[0.16.4] — 2026-05-24 — Per-tenant attachment encryption (envelope)#
Closes the last security-correctness gap from the original
SAAS_PLAN. Before this slice every tenant's attachments were
encrypted with the same global key — a leaked
ATTACHMENT_ENCRYPTION_KEY exposed every customer's receipts
at once. Now each tenant has its own DEK (data encryption key)
wrapped by the global KEK (key encryption key), and a super
admin can rotate any tenant's DEK without touching anyone
else.
Schema (migration 033)#
tenant_encryption_keys— one row per tenant, holding the AES-256-GCM-wrapped 32-byte DEK + an integergenerationthat bumps on each rotation.attachments.key_generation— nullable; populated for v2 rows so a partial-rotation crash leaves the DB self- consistent.attachments.encryption_versioncheck constraint extended to accept2. Legacy0(plaintext) and1(KEK-direct) rows stay readable indefinitely.
Encryption module (server/src/attachments/tenant-keys.ts)#
New module owning the envelope crypto:
getOrCreateTenantKey(tenantId)— fetches the wrapped DEK from the DB, unwraps with the KEK, returns{dek, generation}. Mints a fresh DEK on first call for a tenant (ON CONFLICT no-op handles the cold-start race).encryptWithDek(dek, plaintext)/decryptWithDek(...)— AES-256-GCM with per-attachment random IV; 12+ct+16-byte on-disk layout matches the v1 format so an operator decrypting backups by hand sees the same shape.rotateTenantKey(tenantId)— mints a fresh DEK, walks every attachment for the tenant (v0/v1/v2 all upgraded), re-encrypts each file in place, then commits the new wrapped DEK + bumpsattachments.encryption_versionto 2key_generationto the new generation in one transaction. Synchronous on the request; rare enough that background-jobbing it would just add complexity.
Storage path changes#
storeAttachment() gains a tenantId parameter and writes
v2 ciphertext via the tenant's DEK whenever the KEK is
configured. Plaintext (v0) fallback unchanged.
readAttachmentBuffer() gains an optional tenantId and
branches on the row's encryption_version:
0→ return raw bytes1→ decrypt with the KEK directly (legacy)2→ fetch the wrapped DEK, unwrap with KEK, decrypt payload with DEK
Callers updated: routes/attachments.ts (upload + download +
preview), ocr/extract-service.ts (pending-OCR walker).
Super-admin rotate flow#
POST /api/system/tenants/:id/rotate-encryption-key— super-admin only, audit-logged. Returns{ attachments_rewritten, new_generation }so the operator sees the impact.- New Rotate key button on each tenant row of
/system/overviewbetween Invite admin and Delete. Confirmation modal warns about the lock + reminds the operator to back up first.
Tests#
server/tests/unit/attachments-encryption.test.ts rewritten
- extended (8 cases total):
- v2 ciphertext written; key_generation populated; new
tenant_encryption_keysrow minted on first attachment - v2 round-trip through
readAttachmentBufferwith tenantId - Tenant A's attachment fails GCM auth when read claiming tenant B (cross-tenant isolation)
- Pre-Phase-5 v0 plaintext still reads correctly
- Legacy v1 (KEK-direct) still reads correctly
- Tampering the wrapped DEK row causes the next read to fail
rotateTenantKey()rewrites every attachment + bumps generation; new reads workrotateTenantKey()upgrades legacy v1 attachments to v2 in the same pass
tenant_encryption_keys added to the test TRUNCATE list so
rows don't leak between specs.
Full suite green: 743 server + 6 web tests (was 739 + 6).
Operator notes#
- The KEK (
ATTACHMENT_ENCRYPTION_KEY) is still required. Without it, attachments fall back to v0 plaintext exactly like before — no DEK to wrap. - Rotation is synchronous and holds no row lock outside the final transaction, so a tenant with 10k attachments will take a noticeable wall-clock time but won't block other reads. The runbook should pick up a "back up first" note before a real rotation.
- Compromise of the KEK is now a smaller blast radius: an attacker also needs the DB rows for each tenant. Both at rest in the same datastore on a typical self-host deployment, but the value is real for backup leaks or read-only forensic exposure.
- Re-running a rotation that crashed midway is safe: the DB transaction at the end is the only commit point, so a partial loop just leaves some files re-encrypted under a DEK that nothing references yet; the next rotation reads them as v2 with the old DEK (which is still the wrapped one) and re-encrypts under the new one.
[0.16.3] — 2026-05-24 — Operator settings unification + support visibility#
Two related operator-experience improvements that turn what used to be "edit .env and restart" tasks into "flip a setting in the UI."
Settings — Stripe + SaaS toggles now DB-editable#
Six new keys added to KNOWN_SETTINGS (all super-admin only,
restart-not-required, sourced via the existing
getEffectiveValue() precedence: DB > env > default):
- STRIPE_SECRET_KEY (secret) — rotating live invalidates the
cached Stripe SDK client via
applyToConfig()so the next API call rebuilds with the new key. No restart needed. - STRIPE_WEBHOOK_SECRET (secret)
- STRIPE_PUBLIC_BASE_URL — base URL for Stripe Checkout
success/cancel + verification + password-reset email links.
Replaces the bare
process.env.STRIPE_PUBLIC_BASE_URLreads inauth.ts,billing.ts, andwebhook-handlers.ts. - STRIPE_AUTOMATIC_TAX — was a boolean env toggle in 0.15.5.
- PUBLIC_SIGNUP_ENABLED — was a boolean env toggle in 0.16.0.
- SUPPORT_URL — see next section.
The signup gate, automatic-tax flag, and base-URL helper that
used to read process.env.* directly were converted to
async functions that read via getEffectiveValue(). All
caller awaits added (auth.ts + billing.ts).
Bootstrap-only env vars NOT exposed#
For safety / correctness, these stay env-only and DO NOT appear in /settings:
DATABASE_URL,PORT,NODE_ENV,COOKIE_SECURE,STATIC_DIR,ATTACHMENTS_DIR,ATTACHMENTS_MAX_REQUEST_BYTES— captured at process boot or by Fastify plugins; changing them live would either be a no-op or break the running server.
SETTING_DEFAULTS tier#
getEffectiveValue() gains a third fallback layer (after DB
and env): a per-key default in SETTING_DEFAULTS. Currently
only SUPPORT_URL ships with a default
(https://support.builditsmrt.com/) so the support link
appears out of the box on fresh installs without forcing
every operator to set an env var.
Support / feature-request link surfaced everywhere#
/api/auth/statusgains asupportUrlfield so the unauthenticatedLoginPage,SignupPage,ForgotPasswordPage, andResetPasswordPagecan render the link in their footers.- New
SupportLinkcomponent inApp.tsxshown in both sidebar footers (SuperAdminApp+AuthenticatedApp) as "Help & feature requests". - Unauth-page footer copy is deliberate: "Visit support — feature requests welcome too." Most users perceive a support portal as bug-only; we spell out that we want the wishlist.
Tests#
- 2 new tests in
auth.test.tscovering the 0.16.3 plumbing:PUBLIC_SIGNUP_ENABLEDDB row beats env (signup endpoint becomes reachable),SUPPORT_URLdefault + DB override precedence. - Existing
automaticTaxEnabled()unit tests updated for the new async signature. - Status-endpoint tests updated for the new
supportUrlfield.
Full suite green: 739 server + 6 web tests.
Operator notes#
- The /settings page already lists every key in
KNOWN_SETTINGS, so the six new keys appear automatically. - Rotating
STRIPE_SECRET_KEYfrom the UI takes effect on the very next API call — no restart, no container bounce. - Rotating
SESSION_SECRETorATTACHMENT_ENCRYPTION_KEYstill requires a restart (they're captured at boot by Fastify / file storage); the API response carriesrestart_required: trueso the UI surfaces the prompt. - Clearing
SUPPORT_URL(DELETE on the row, no value in env) hides the link everywhere. The defaulthttps://support.builditsmrt.com/only kicks in when nothing is set — clearing the env var alone is not enough if a DB row exists.
[0.16.2] — 2026-05-24 — Self-service password reset#
Closes a glaring SaaS UX gap that opened the moment 0.16.0 shipped public signup: a customer who forgets their password can now reset it themselves without emailing support.
Schema (migration 032)#
password_resetstable — same shape asemail_verifications(UNIQUE token, expires_at, consumed_at kept for audit). Two separate tables because the TTLs and concerns differ: password resets are 1-hour, email verifications are 24-hour; separation keeps audit reads + cleanup jobs clean.
Backend#
POST /api/auth/password-reset-request— public. Accepts{ email }, always returns 202{ status: 'reset_sent' }regardless of whether the email matches a real user (anti-enumeration). When the address IS registered, mints a token (1-hour TTL) and sends the reset link viatryMail(). Logsuser.password_reset_requestedin audit so spikes are visible — a brute-force enumeration attempt would show up there.POST /api/auth/password-reset-confirm— accepts{ token, password }. Validates the token (not consumed, not expired), runs the new password through the samevalidatePassword()policy as signup, swaps in the hash, marks the token consumed. Then invalidates every session for the user via the newdeleteAllSessionsForUser()helper — defense against an attacker whose stolen credentials get reset by the real owner. Audit logsuser.password_reset_completedwithsessions_invalidatedcount.- Both routes are publicly reachable (no session required,
not gated by
PUBLIC_SIGNUP_ENABLED— self-host users still need to recover passwords). renderPasswordResetEmail()indomain/mailer.tswith anti-phishing copy ("ignore this if you didn't request it").
Web#
ForgotPasswordPageat/forgot-password— single email field; always shows the same "if it's a real address you'll get a link" confirmation so the UX matches the enumeration-safe server behavior.ResetPasswordPageat/reset-password?token=…— collects new password + confirm, calls confirm endpoint, bounces to/loginafter a short pause (we don't auto- sign-in because the server just killed every session including any we might've tried to create).- LoginPage gains a "Forgot password?" link (always visible) next to the "Create an account" link (signup-gated).
- App routes both paths as public alongside
/signup,/verify-email, and/invite/:token.
Tests#
server/tests/integration/auth.test.ts — 7 new cases under
the "password reset (0.16.2)" describe:
- request always returns 202 (unknown email + malformed email)
- request mints a token for a real user
- confirm rejects an invalid token
- confirm rejects an expired token
- confirm validates the new password policy
- confirm swaps the password (old login 401s, new login 200s) and consumes the token (replay 400s)
- confirm invalidates every other active session for the user
Full suite: 736 server + 6 web tests green.
Operator notes#
- Token TTL is 1 hour (
PASSWORD_RESET_TTL_MINUTES = 60). Matches Stripe + GitHub defaults; tight enough to limit blast radius of a stolen link, loose enough that users reading email asynchronously don't get locked out. - SMTP is still best-effort. When unconfigured the server
logs the reset URL at
warnlevel so the operator can hand-deliver during early launch. - Reset doesn't re-verify the user's email_verified_at —
a user with NULL
email_verified_at(didn't finish the signup verification dance) can still reset their password, but they'll get the "confirm your email" gate on the next login attempt. Two separate flows on purpose.
[0.16.1] — 2026-05-24 — Super-admin subscriptions console#
A new third tab on /system shows every tenant's billing state on one screen, with audit-logged actions for the three operations the runbook calls out as common:
- Grant courtesy plan — UPSERT a local
subscriptionsrow withstatus='active'and a chosen period (1–365 days). Plan + days + free-form reason are recorded inaudit_log. Doesn't touch Stripe; the webhook overwrites this row if the tenant later goes through Checkout. - Sync from Stripe — for tenants with a stored
stripe_subscription_id, re-pulls the live subscription and funnels it through the existinghandleSubscriptionUpsertso the result is identical to what a realcustomer.subscription.updatedwebhook would produce. Useful when a webhook delivery was dropped and our row drifted. - Force-cancel (local only) —
DELETEthe row entirely. Explicit confirm prompt warns that Stripe still considers the sub live unless cancelled separately; the next webhook will recreate the row otherwise.
Backend#
GET /api/system/subscriptions— list every tenant joined with their subscription row (LEFT JOIN, so tenants without a sub appear with null fields). Includes member count + Stripe IDs for the operator to copy into the Stripe dashboard.POST /api/system/subscriptions/:tenantId/grant— body{ plan, days, reason? }; validates plan ∈ {starter,plus, family} and days ∈ 1..365.POST /api/system/subscriptions/:tenantId/sync— 503 when Stripe isn't configured, 404 when nostripe_subscription_idon file, 502 when Stripe lookup fails, 200 on success.DELETE /api/system/subscriptions/:tenantId— 404 when no row exists, 200 +cleared:trueon success.- All four routes super-admin-gated via
requireSuperAdmin.
Web#
SystemPagegains a third tab dispatcher (overview | subscriptions | audit). NewSubscriptionsTabrenders a filterable table (all / paying / trialing / past_due / no plan), free-text search across name+slug+stripe customer, and per-row action buttons. Includes aGrantModalfor the courtesy-grant flow with the audit-logged reason field.- New nav link "Subscriptions" between Overview and Audit log
in the super-admin sidebar. New route at
/system/subscriptions.
Tests#
server/tests/integration/system-subscriptions.test.ts — 11
new cases covering the super-admin gate on every route, list
shape (null fields for tenants with no sub, populated for the
seeded Default), grant validation + UPSERT + audit, grant
overwrite, grant 404 on unknown tenant, force-cancel 200 + 404,
sync 503 / 404 paths. Full suite green: 729 server + 6 web.
[0.16.0] — 2026-05-24 — Public signup + email verification#
The first new customer on a SaaS deployment of SmrtCash can now
self-serve: fill in /signup, click the verification link in
their email, and land on /billing with a fresh tenant + admin
membership ready to pick a plan. Operators flip
PUBLIC_SIGNUP_ENABLED=true to turn this on; default off
preserves the self-host posture.
Schema (migration 031)#
users.email_verified_at timestamptz— NULL means unverified; login is refused. Migration backfills existing users tocreated_atso upgrades don't lock anyone out.email_verifications— short-lived tokens. UNIQUE token column, 24-hour expiry,consumed_atkeeps consumed rows for audit (signup-funnel metric). Partial index on(expires_at) WHERE consumed_at IS NULLpowers the future cleanup job.
Backend#
POST /api/auth/signup— gated byPUBLIC_SIGNUP_ENABLED. Creates an unverified user, mints a token, sends the verification email viatryMail(). Returns 202{ status: 'verification_sent' }regardless of whether the address was already in use — prevents account enumeration. For unverified existing users we re-mint the token (legitimate retry); for verified users we silently no-op.POST /api/auth/verify-email— consumes the token, marks the user verified, provisions a tenant (<name>'s householddisplay name, randomt-XXXXXXXXslug), creates theadminmembership, sets the session cookie. Idempotent against partial-failure replays via the consumed-at check + tenant-already-exists guard./api/auth/loginrefuses unverified users with a 403 + "confirm your email" message. Super admins created via/api/auth/setupare auto-verified at creation./api/auth/statusgainssignupEnabled: booleanso the client can show/hide the "Create account" link.
Web#
SignupPageat/signup— email/name/password form + "check your email" confirmation card. Doesn't probe whether the address is already registered.VerifyEmailPageat/verify-email?token=…— consumes the token, refreshes auth state, redirects to/billingfor plan selection. Strict-mode-safe (guards against the effect firing twice and false-positive "already used" errors).- LoginPage shows "Create an account" link when
signupEnabledis true. - App routes
/signupand/verify-emailas public surfaces alongside/invite/:token.
Tests#
server/tests/integration/auth.test.ts — eight new cases:
- Signup 404s when the gate is off
- Signup creates an unverified user + token row
- Signup is idempotent for a verified existing email (no fresh token)
- verify-email consumes the token, provisions a tenant + admin membership, sets the session cookie
- Replay of a consumed token returns 400 "already been used"
- Expired tokens return 400 "expired"
- Login refuses an unverified user with 403 + "confirm" copy
- /api/auth/status reflects
signupEnabledfrom the env
Full suite green: 718/718 server tests + 6/6 web tests.
Operator notes#
PUBLIC_SIGNUP_ENABLED=trueis required for both/api/auth/signupand/api/auth/verify-emailto function. When off both routes 404; the LoginPage doesn't advertise signup.- SMTP must be configured for verification emails to actually
reach customers. When SMTP is unconfigured the server logs
the verification URL at
warnlevel so the operator can hand-deliver during early launch / testing. - The
STRIPE_PUBLIC_BASE_URLenv var is reused as the base for verification links (same as the Stripe success / cancel URLs).
[0.15.5] — 2026-05-24 — SaaS pivot, slice 6: operator readiness#
Close out the SaaS pivot by giving the operator the surface
they need to actually run it. A single super-admin can now
glance at /health and see how many tenants are paying, which
subscriptions are in past_due, and whether Stripe webhooks
are still arriving.
Backend#
New endpoint
GET /api/health/saas(super-admin only). Returns subscription distribution + webhook ingest counts:{ "tenants": { "total": 47, "with_active_sub": 31 }, "subscriptions": { "total": 31, "by_plan": { "starter": 4, "plus": 19, "family": 8 }, "by_status": { "trialing": 6, "active": 23, "past_due": 2 } }, "webhooks": { "processed_total": 1812, "processed_24h": 47, "last_event_at": "2026-05-24T18:32:11Z" } }Implemented as
server/src/domain/saas-health.ts— three parallel COUNT/GROUP-BY queries againsttenants,subscriptions, andstripe_processed_events. No new schema; the existing webhook idempotency table doubles as the ingest log.STRIPE_AUTOMATIC_TAXenv toggle. Stripe Checkout'sautomatic_tax.enabledflag now reads from the env via a newautomaticTaxEnabled()helper inserver/src/billing/stripe.ts. Default off so dev deployments don't hit "no tax origin address" errors; operator flips it after configuring Stripe → Tax → Settings (see runbook).
Web#
- HealthPage SaaS section. Three new cards under the existing app/db/storage row showing tenant totals, subscription distribution, and webhook ingest health. Polls on the same cadence as the rest of /health and fails silently when /api/health/saas isn't reachable (super-admin can still see the regular metrics on a self-host-only deployment).
- BillingPage legal footer. Inline "By subscribing you agree to the Terms of Service and Privacy Policy" line linking placeholder docs. Operators swap the hrefs to the real lawyer-reviewed URLs at launch.
Docs#
docs/OPERATOR_RUNBOOK.md— playbook for the SaaS operator. Practical recipes for the cases that will come up:- "Customer paid but can't access" — tenant lookup, subscription row inspection, Stripe-side cross-check.
- "Webhook delivery is failing" — sanity probe, Stripe dashboard delivery log, replay via CLI.
- Reconciling a state mismatch (resend the event; never hand-edit the row).
- SMTP outage impact on dunning (best-effort, doesn't break webhook ingest).
- Past-due grace window math.
- Granting courtesy access (Stripe-side coupon preferred, DB grant as emergency).
- Stripe automatic-tax prerequisites.
- One-liner psql queries for daily glance.
docs/TERMS_OF_SERVICE.md+docs/PRIVACY_POLICY.md— placeholder stubs marked as such. Structural skeleton for counsel to expand; never deploy as-is.
Tests#
server/tests/unit/billing-stripe-config.test.ts—automaticTaxEnabled()env-parse contract (only literal"true"enables; "1"/"yes"/"on" intentionally don't).server/tests/integration/health-backups-reports.test.ts— three new cases on /api/health/saas: 403 for tenant admins, response shape under super-admin, webhook counts reflect newly recorded events.
Deferred from the original 0.15.5 plan#
The earlier SAAS_PLAN sketch bundled a signup-flow rewrite (email verification + plan selection during signup) and a per-tenant attachment-encryption key migration (KMS-style envelope rotation) into 0.15.5. Both are real, both are multi-day projects on their own. They're deferred to the v0.16 series rather than crammed into this slice. The current signup flow (login then pick a plan from /billing) works fine for launch.
[0.15.4] — 2026-05-24 — SaaS pivot, slice 5: dunning + grace + downgrade UX#
Close the loop on the SaaS billing flow. After this slice a tenant whose card fails gets a courtesy email and a short grace window instead of an instant lockout, and every premium page knows how to present the upgrade story when a route returns 402.
Past-due grace window#
server/src/auth/entitlements.ts — effectivePlan() no longer
returns the plan unconditionally for past_due. New behavior:
- If the row has no
current_period_end, stay lenient (return plan). Webhooks will fill this in. - If
current_period_endwas within the lastGRACE_DAYS_AFTER_PAST_DUE(3) days, return the plan. - Past that, return
null— gates lock.
Three days lines up with Stripe's default retry cadence (1d / 3d / 5d / 7d): the customer has time to react to the first failure email before features cut off, but we don't give indefinite free access while Stripe keeps retrying.
Dunning emails#
server/src/billing/webhook-handlers.ts — handleInvoiceEvent
now reacts to invoice.payment_failed:
- Pull the Stripe customer to get the canonical billing email (Checkout-collected, may differ from any local user email).
- Render a short HTML+text body via the new
renderDunningEmail()helper inserver/src/domain/mailer.ts. - Send via the existing
tryMail()plumbing.
When SMTP isn't configured the handler still returns
applied:true with reason: 'mail skipped: …' so the webhook
log records why no message went out — webhook delivery doesn't
fail because of a missing capability on the deployment.
The dunning email points at /billing. There's no
"resume subscription" magic link; the Customer Portal handles the
actual card update, which keeps us out of PCI scope.
Web — upgrade prompt wiring#
web/src/api.ts — new UpgradeRequiredError thrown by the
shared http<T> helper when the server replies 402, plus an
isUpgradeRequired(e) type guard. Every gated page now does:
try {
const data = await api.something();
...
} catch (e) {
if (isUpgradeRequired(e)) setNeedsUpgrade(true);
else setError(e.message);
}
if (needsUpgrade) return <UpgradePrompt feature="X" requiredPlan="…" />;
Wired into AnomaliesPage, CalendarPage, TaxYearPage, RetirementPage, and SharingPage. AssistantPage uses the same type guard but surfaces the 402 message inline (preserving the chat UI) since the failure can mean either "feature not on plan" or "quota exhausted this period."
Web — billing cap-overflow callout#
web/src/pages/BillingPage.tsx — when a tenant downgrades
(Family→Plus, Plus→Starter) we never delete their bank
connections or household members. After downgrade their used
count may exceed the new tier's cap. The /billing page now
shows a warning callout listing each overflow ("3 bank
connections (cap 0), 4 household members (cap 1)") so they know
why new writes are being refused.
Tests#
server/tests/unit/entitlements.test.ts:
- past_due WITHIN grace stays entitled
- past_due PAST grace returns null
- past_due with no period_end stays lenient
server/tests/unit/billing-webhook-handlers.test.ts:
- non-payment-failed invoice events are early-return no-ops
- payment_failed with no customer on invoice returns applied:false
renderDunningEmail()pure-function tests (amount/currency/URL rendering, missing-name greeting fallback, HTML-attribute injection escape)
[0.15.3] — 2026-05-24 — SaaS pivot, slice 4: /billing page + trial banner + upgrade prompt#
User-facing billing surface for the SaaS pivot. After this slice a paying tenant can manage their subscription entirely from the app — no engineer needed.
New endpoint#
GET /api/billing/status — single read powering the /billing
page. Returns:
{
"plan": "starter" | "plus" | "family" | null,
"status": "trialing" | "active" | "past_due" | "canceled" | ...,
"trialEnd": "<ISO>" | null,
"currentPeriodEnd": "<ISO>" | null,
"cancelAtPeriodEnd": false,
"hasStripeCustomer": true,
"usage": {
"aiAssistant": { "used": 47, "cap": 500, "remaining": 453 },
"receiptOcr": { "used": 12, "cap": 200, "remaining": 188 }
},
"caps": {
"bankConnections": { "used": 3, "cap": 10 },
"householdMembers": { "used": 2, "cap": 1 }
}
}
cap: null on a metered feature means unlimited on this plan
(Family). hasStripeCustomer drives whether "Manage billing"
(Customer Portal redirect) is shown vs. greyed out. No Stripe IDs
or webhook event details are exposed; this stays purely
plan + state + counters.
Web#
web/src/pages/BillingPage.tsx— full billing surface:- Current plan card with status pill + 14-day trial countdown when trialing
- "Renews on …" sub-line when active
- Inline warnings for
cancel_at_period_endandpast_due - Usage meter rows for AI assistant, OCR, bank connections, household members. Color flips warn at 75% and danger at 90%. Unlimited tiers (Family) read as "Unlimited" with no bar.
- "Manage billing" button → Stripe Customer Portal redirect
- Plan comparison grid (Starter / Plus / Family) with annual + monthly "Pick plan" buttons that kick off Stripe Checkout
web/src/components/TrialBanner.tsx— sitewide bar rendered above main content whenstatus === 'trialing'ANDtrial_endis within 5 days. Non-dismissible — the impending end is load-bearing info.web/src/components/UpgradePrompt.tsx— reusable card for pages whose primary feature is gated. Takesfeature+requiredPlanprops; links to/billingfor the upgrade flow. Will be used in 0.15.4+ when pages catch 402 from the API.web/src/api.ts—getBillingStatus,startBillingCheckout,openBillingPortal; new typesBillingStatus,UsageMeter,CapMeter,Plan,PlanLookupKey,SubscriptionStatus.web/src/App.tsx—/billingroute registered, nav entry under the "Household" group,<TrialBanner />mounted above the route content.web/src/styles.css— meter rows + bars, plan grid, badge variants (info / success / warn / muted), callouts, trial banner, upgrade-prompt card.
Tests (+7)#
tests/integration/billing-status.test.ts exercises every
documented shape:
- Default Family/active tenant: unlimited caps, hard caps reported
plan = nullwhen no subscription row- Plus tier with metered usage (3 AI calls burned via the helper the route uses → same period_start by construction)
- Trialing status with
trialEndpopulated as ISO hasStripeCustomerflips true whenstripe_customer_idis set- Bank-connection cap counts both
ofx_dc_connections+plaid_items - 403 when the session has no active tenant
Total: 695 server tests pass (688 + 7). Web typecheck clean, 6 web tests pass.
What's NOT in this slice#
/api/billing/cancelshortcut — Stripe Customer Portal handles cancel inline (our portal config instripe-setup.mjsenables the cancel feature with reason capture). Adding a dedicated endpoint would duplicate that without value.- Plan-selector during signup — that's 0.15.5, alongside the full signup flow rewrite.
- Wiring
UpgradePromptinto every gated page — the component exists, but flipping each gated page from "renders an error" to "renders UpgradePrompt on 402" is per-page UX work. Deferred to 0.15.4 so this slice stays focused on the billing surface itself.
Files#
server/src/routes/billing.ts (+GET /status, +helpers)
server/tests/integration/billing-status.test.ts (new — 7 tests)
web/src/api.ts (+3 methods, +6 types)
web/src/pages/BillingPage.tsx (new)
web/src/components/TrialBanner.tsx (new)
web/src/components/UpgradePrompt.tsx (new)
web/src/App.tsx (route + nav + banner)
web/src/styles.css (+billing CSS palette)
package.json + server/package.json + web/package.json (0.15.2 → 0.15.3)
Coming next#
- 0.15.4 — dunning + grace window + cancellation/downgrade UX.
Wire
UpgradePromptinto the gated pages (catch 402, swap body). - 0.15.5 — SaaS readiness (signup flow with plan picker, drop self-host docs, KMS-backed per-tenant keys).
[0.15.2] — 2026-05-24 — SaaS pivot, slice 3: feature-gate every premium route#
Wires the entitlement core from 0.15.0 into every premium route.
A Starter-plan tenant now gets 402 Payment Required on the
features that aren't part of their tier; Plus + Family get
through; metered features (AI assistant, OCR) are charged
against per-period counters with hard caps.
Gated routes (with feature key)#
| Route surface | Feature |
|---|---|
POST /api/ofx-dc/connections + PATCH/test/sync |
BANK_SYNC + requireBankConnectionSlot (cap 10 Plus / 25 Family) |
POST /api/plaid/link-token + exchange + link-account + sync |
BANK_SYNC + slot cap |
POST /api/normalize |
AI_NORMALIZE |
POST /api/holdings/refresh-prices/crypto |
CRYPTO_REFRESH |
GET /api/anomalies + count + scan + dismiss (4) |
ANOMALY_ALERTS |
GET /api/reports/tax-year/:year + csv |
TAX_REPORTS |
GET /api/calendar/:month |
CALENDAR_VIEW |
GET/POST/PATCH/DELETE/series /api/projections (5) |
RETIREMENT_PROJECTIONS |
/api/split-participants CRUD + /api/transactions/:id/shares + settle + summary (9) |
BILL_SPLITTING |
POST /api/assistant/chat |
AI_ASSISTANT + per-request quota tick (500/mo on Plus) |
| Attachment OCR (file upload path) | RECEIPT_OCR + per-file quota (200/mo on Plus); upload itself ungated, OCR step gracefully marks skipped with note when denied/exhausted |
POST /api/accounts (when currency != USD) |
MULTI_CURRENCY |
POST /api/tenants/:id/invitations |
requireHouseholdSeat (1 Starter+Plus / 6 Family) |
GET endpoints on list-shaped resources (e.g. /api/ofx-dc/connections,
/api/plaid/items) are deliberately left ungated — a downgraded
user should still be able to SEE what they had and clean it up.
Mutation paths enforce the plan.
Notable design decisions#
- 402, not 403. Entitlement denials use
402 Payment Requiredso the web client can distinguish "needs an upgrade" from "forbidden by role" (which is 403). The error message includes the feature name and an upgrade hint. - OCR fails open per-receipt. When the plan grants OCR but the
monthly quota is exhausted partway through an upload, the rest
of the batch is marked
ocr_status='skipped'with a quota- exhausted note instead of failing the upload itself. Uploads still succeed; OCR is the part that degrades. - Assistant quota fires before provider availability. Reordered
the assistant route so quota check (402) runs before
assistantAvailable(400). A paying customer at their cap should see "monthly quota exceeded," not "assistant not configured" — the latter is a transient env-config state, the former is the canonical business message. - Auto-sync stays super-admin-only.
POST /api/auto-sync/runwas listed in SAAS_PLAN.md for gating but is a super-admin operator endpoint with notenantIdof its own — gating it onBANK_SYNCdoesn't fit. Per-tenant enforcement ofBANK_SYNCinside the scheduler tick is a separate slice (not 0.15.x).
Test-harness changes#
tests/setup/test-db.tsresetDb()now seeds afamily/activesubscription on the Default tenant. Every existing test that doesn't care about entitlements keeps working as written; only the new entitlement-specific tests deliberately exercise Starter / quota-exhausted paths.tests/security/tenant-isolation.test.tsmakeTenant()now seeds Family/active on each test tenant for the same reason — isolation tests care about cross-tenant boundaries, not subscription enforcement.
Tests (+16)#
tests/security/entitlements-routes.test.ts — 16 route-level
gate tests covering every gated surface:
- Starter denials (402) on bank-sync, normalize, crypto refresh, anomalies, tax-year, calendar, projections, bill-splitting, assistant, non-USD account creation, invitation when at cap.
- Plus / Family grants (200/201) on the same routes.
- Plus-vs-Family split: Plus denied bill-splitting, Family granted.
- Starter CAN still create a USD account (currency gate only triggers on non-USD).
- Plus assistant quota exhaustion: pre-burn 500 ticks via the helper itself (so periods align with what the route computes), then verify 501st call returns 402.
- Family can invite (1 of 6 used) — confirms the seat-cap path is positive on Family.
Total: 688 server tests pass (672 from 0.15.1 + 16 new).
Files#
server/src/auth/entitlements.ts (unchanged from 0.15.0)
server/src/routes/ofx-dc.ts (BANK_SYNC + slot cap)
server/src/routes/plaid.ts (BANK_SYNC + slot cap)
server/src/routes/normalize.ts (AI_NORMALIZE)
server/src/routes/holdings.ts (CRYPTO_REFRESH)
server/src/routes/anomalies.ts (ANOMALY_ALERTS)
server/src/routes/tax-year.ts (TAX_REPORTS)
server/src/routes/calendar.ts (CALENDAR_VIEW)
server/src/routes/projections.ts (RETIREMENT_PROJECTIONS)
server/src/routes/shares.ts (BILL_SPLITTING)
server/src/routes/assistant.ts (AI_ASSISTANT + quota; reordered)
server/src/routes/attachments.ts (RECEIPT_OCR + quota; fails open)
server/src/routes/accounts.ts (MULTI_CURRENCY on POST when != USD)
server/src/routes/tenants.ts (requireHouseholdSeat on invitations)
server/tests/setup/test-db.ts (seed family sub on Default)
server/tests/security/tenant-isolation.test.ts (seed family sub on per-test tenants)
server/tests/security/entitlements-routes.test.ts (new, 16 tests)
package.json + server/package.json + web/package.json (0.15.1 → 0.15.2)
Coming next#
- 0.15.3 —
/billingpage in web (current plan, change-plan, Stripe Customer Portal redirect, usage meters, trial banner, upgrade prompts on locked features). - 0.15.4 — dunning + grace window + cancellation/downgrade UX.
- 0.15.5 — SaaS readiness (signup, drop self-host docs, KMS-backed per-tenant keys).
[0.15.1] — 2026-05-23 — SaaS pivot, slice 2: Stripe checkout + webhook + portal#
Second slice. Wires Stripe up against the entitlement core from 0.15.0. After this slice:
- A user can hit
POST /api/billing/checkoutand get a Stripe Checkout URL with the right price + 14-day trial. - Stripe POSTs subscription events to
/api/billing/webhook, signature-verified, idempotent, dispatched to handlers that UPSERT thesubscriptionsrow. - A subscriber can hit
GET /api/billing/portaland get redirected to the Stripe Customer Portal (change plan, update card, cancel).
Routes still NOT gated — that's 0.15.2. This slice just gets the plumbing in place.
Dependencies#
- Added
stripe@^22.1.1to server dependencies. SDK uses its default API version (newest at install time); the webhook handler readscurrent_period_endfrom the new location (subscription.items.data[0].current_period_end) with a fallback to the legacy top-level field.
New code#
server/src/billing/stripe.ts— lazy-init Stripe client. No throw at import time so tests + dev runs without Stripe configured don't blow up.server/src/billing/plans.ts— lookup_key → Plan + Cadence map. Single source of truth (matchesscripts/stripe-setup.mjsoutput and Stripe dashboard exactly).planFromLookupKey,lookupKeyFor,isKnownLookupKey,ALL_LOOKUP_KEYS.server/src/billing/webhook-handlers.ts— pure functions that apply Stripe events to our DB. Each handler:- Reads
tenant_id+smrtcash_planfromsubscription.metadata(set by checkout). Missing/invalid metadata = silent no-op. - UPSERTs the row (subscription.created and subscription.updated share a single handler — same shape).
- subscription.deleted flips status to
canceledand preservescancel_at_period_end+current_period_endso the "I paid through month-end" grace still works.
- Reads
server/src/routes/billing.ts:POST /api/billing/checkout— accepts{ lookupKey }, resolves Stripe price, creates a 14-day trial subscription Checkout session withpayment_method_collection: 'if_required', stampstenant_id+smrtcash_planintosubscription_data.metadata. Reuses existingstripe_customer_idon upgrades; prefillscustomer_emailon first-time signups.POST /api/billing/webhook— public, signature-verified, idempotent viastripe_processed_events. Dispatches to the handlers. On handler error, rolls back the idempotency claim so Stripe's retry can re-attempt.GET /api/billing/portal— Stripe Customer Portal redirect using the tenant'sstripe_customer_id.
app.ts changes#
- Custom JSON content-type parser that stashes the raw request
body on
req.rawBody. Required for Stripe signature verification — the SDK rejects re-serialized JSON. Cost: one Buffer allocation per JSON request (~1 KB), negligible. /api/billing/webhookadded toPUBLIC_PATHSso it bypasses the session-cookie auth gate. Stripe-signature is the auth.
Tests (+17)#
tests/unit/billing-plans.test.ts(5): lookup-key round-trip, unknown-key handling.tests/unit/billing-webhook-handlers.test.ts(7): UPSERT from hand-built Stripe event payloads (no SDK calls); missing metadata = no-op; UPSERT is single-row per tenant; past_due recorded verbatim; subscription.deleted preserves period_end.tests/integration/billing-routes.test.ts(5): 503 when Stripe not configured; 400 on missing/invalid signature with a dummy webhook secret.
Total: 672 tests pass (655 + 17).
What's deliberately NOT here#
- The Stripe SDK is not driven against the live test-mode account
in tests — that's covered by manual smoke testing via
stripe trigger customer.subscription.created(see docs/STRIPE_SETUP.md). Adding astripe-mockintegration is premature optimization for a one-engineer team. - No
/api/billing/statusendpoint yet — the web UI in 0.15.3 will read the current plan via the entitlements layer. - No dunning, no grace timer, no downgrade-seat-cleanup — all 0.15.4.
Files#
server/package.json (+stripe ^22.1.1)
server/src/billing/stripe.ts (new)
server/src/billing/plans.ts (new)
server/src/billing/webhook-handlers.ts (new)
server/src/routes/billing.ts (new — 3 routes)
server/src/app.ts (raw-body parser + register)
server/tests/unit/billing-plans.test.ts (new — 5)
server/tests/unit/billing-webhook-handlers.test.ts (new — 7)
server/tests/integration/billing-routes.test.ts (new — 5)
package.json + server/package.json + web/package.json (0.15.0 → 0.15.1)
Manual smoke test#
With Stripe CLI running (stripe listen --forward-to localhost:4000/api/billing/webhook):
# Trigger a subscription event and watch the DB.
stripe trigger customer.subscription.created
# (Use --override to inject our metadata for a real end-to-end
# happy path — Stripe's default trigger doesn't know about our
# tenant_id schema. See docs/STRIPE_SETUP.md.)
For a true end-to-end test, open the checkout URL from
POST /api/billing/checkout and complete with test card
4242 4242 4242 4242. Webhook should fire and a row should
appear in subscriptions for the tenant.
Coming next#
- 0.15.2 — apply
requireFeatureto ~14 premium routes; new test filetests/security/entitlements.test.tsverifies each gate. - 0.15.3 —
/billingpage in web. - 0.15.4 — dunning + grace + cancellation UX.
- 0.15.5 — SaaS readiness (signup, drop self-host docs, KMS-backed per-tenant keys).
[0.15.0] — 2026-05-23 — SaaS pivot, slice 1: entitlement core (schema + helpers)#
First slice of the SaaS pivot. No Stripe integration yet, no routes gated yet — that's 0.15.1 and 0.15.2. This slice just puts the building blocks in place.
Schema (migration 030)#
subscriptions(tenant_id PK) — mirrors Stripe's subscription shape so the webhook can UPSERT directly:stripe_customer_id,stripe_subscription_id,plan_id(starter/plus/family),status(full Stripe status set),trial_end,current_period_end,cancel_at_period_end.usage_counters(tenant_id + feature_key + period_start PK) — per-billing-period metering for AI assistant tool calls and OCR receipt pages. Atomic increment via INSERT ... ON CONFLICT.stripe_processed_events— webhook idempotency table for 0.15.1.
Entitlement core (server/src/auth/entitlements.ts)#
Plantype +FEATURESconst +PLAN_FEATURESmap. Single source of truth for "what does each tier include" — mirrorsdocs/SAAS_PLAN.md.getActiveSubscription(tenantId)— fetches the row.effectivePlan(tenantId)— resolves the plan with status semantics: trialing + active + past_due → entitled; canceled honorscancel_at_period_enduntilcurrent_period_end; incomplete/unpaid/paused → not entitled.requireFeature(tenantId, feature)— null on grant,{status: 402, error}on deny. Mirrors the rbac helper pattern. 402 Payment Required is used (not 403) so the web client can distinguish "needs upgrade" from "forbidden by role".requireBankConnectionSlot(tenantId)— checks the live OFX-DC + Plaid item count against the plan'sbankConnectionCap(Starter 0, Plus 10, Family 25).requireHouseholdSeat(tenantId)— checksmembershipscount against the plan'shouseholdMemberCap(Starter+Plus 1, Family 6).checkAndIncrementQuota(tenantId, feature, n)— atomic per-period meter for AI assistant + OCR. Returns granted + remaining + cap. On overshoot, rolls back the optimistic increment so quotas can't go above cap. Records usage even on unlimited tiers (Family) for future analytics.
Routes are NOT wired yet. Adding requireFeature to existing
routes is 0.15.2. The 0.15.0 codebase will keep running normally
for any tenant without a subscription row.
Dev script (scripts/grant-saas-plan.mjs)#
CLI to grant a SaaS subscription to a tenant directly in the DB, bypassing Stripe. Lets the operator (the dev user) self-grant a Family plan on their existing Default tenant so 0.15.2's route gates won't lock them out when wired in.
Usage:
node scripts/grant-saas-plan.mjs --tenant default --plan family
node scripts/grant-saas-plan.mjs --tenant default --plan plus --trial-days 14
UPSERT semantics — safe to re-run.
Tests (+21)#
tests/unit/entitlements.test.ts covers:
Plan resolution: no-sub / trialing / active / past_due / canceled-with-grace / canceled-past-period / incomplete / unpaid / paused.
requireFeature: Starter denies all premium; Plus has Plus features + denies Family-only; Family has all.
Bank-connection cap: Starter cap=0 denies; Plus cap=10 enforced via live count of plaid_items + ofx_dc_connections.
Household seat cap: Starter at 1, Family at 6 enforced against
memberships.Quota: unlimited (Family) grants + records usage; plus cap (500 AI / 200 OCR) honored; overshoot denies AND rolls back the counter; absent feature denies and writes nothing; OCR and AI quotas are independent counters.
Total: 661 tests (655 server + 6 web). All green.
Files#
server/src/db/migrations/030_subscriptions.sql (new)
server/src/auth/entitlements.ts (new)
server/tests/unit/entitlements.test.ts (new — 21 tests)
scripts/grant-saas-plan.mjs (new)
docs/SAAS_PLAN.md (already committed at 147adac)
package.json + server/package.json + web/package.json (0.14.7 → 0.15.0)
Coming next#
- 0.15.1 — Stripe products + Checkout session + webhook
(idempotent via
stripe_processed_events). Needs Stripe test-mode API keys. - 0.15.2 — Apply
requireFeatureto ~14 premium routes; new test filetests/security/entitlements.test.tsverifies each gate. - 0.15.3 —
/billingpage in web (current plan, change-plan, Stripe Customer Portal redirect, usage meters, trial banner). - 0.15.4 — Dunning + grace window + cancellation/downgrade UX.
- 0.15.5 — SaaS readiness: signup flow, drop self-host docs, KMS-backed per-tenant attachment keys, ToS/PP stubs.
[0.14.7] — 2026-05-23 — Close all remaining KIs#
Closes the four remaining items in docs/KNOWN_ISSUES.md. Three
are real fixes (KI-02, KI-05, KI-06); KI-08 is retired as
accepted-by-design.
KI-02 — exceljs npm-audit moderate advisories ✅#
Both advisories traced to uuid <11.1.1 used transitively by
exceljs. Resolved with an npm overrides block in
server/package.json:
"overrides": {
"uuid": "^11.1.1"
}
npm audit after re-install: 0 vulnerabilities (was 2
moderate). No exceljs major-version swap needed.
KI-05 — Heuristic dup detection ✅#
The dedup hash now uses the bank-provided reference as the
canonical identity when present, falling back to the pre-fix
(date, amount, description) hash only for sources that don't
provide one.
ParsedTransaction.bankReference?: string | nulladded toserver/src/import/types.ts.- OFX parser (
server/src/import/parsers/ofx.ts) sets it fromFITIDand removes the redundantfit <id>token from the memo string (the bank ref is now first-class). - Plaid datasource (
server/src/datasource/plaid.ts) sets it from the Plaidtransaction_id. server/src/import/dedup.tsderives the hash fromsha256("ref:" + bankReference)when present; otherwise the Phase-1 heuristic.
Impact: re-importing the same OFX file or syncing an overlapping Plaid window is now deterministically idempotent EVEN IF the bank rewrites the description (merchant-name cleanup post-settlement, correction postings, etc.).
CSV / XLSX / QIF imports keep the heuristic path — those formats generally don't carry a unique reference, and the heuristic-with-occurrence-counter design is the best we can do without one.
KI-06 — XLSX date cells may need verification ✅#
The XLSX parser's cellToString already extracted dates via
getUTC* methods (correct since 0.11.0), but the behavior was
never pinned by a test. Added tests/unit/xlsx-date-parsing.test.ts
covering year-start, year-end, month boundaries, and a leap day.
Round-trips through exceljs without committing a binary fixture.
All 5 dates parse verbatim → KI verified resolved.
KI-08 — Project folder name contains $ ✅ (retired)#
Working directory is SmrtCa$h because that's what the user
chose. Handled by always passing -p smrtcash to docker compose
and by quoting paths; internal package and container names are
smrtcash. No code change required — moving from "open issue"
to "documented convention."
Tests (+6)#
tests/unit/xlsx-date-parsing.test.ts— 2 tests (date boundaries; missing-cell handling).tests/unit/dedup.test.ts— 4 new tests (bank-ref idempotence across description rewrites; different refs don't collide; fallback to heuristic; same-ref-twice-in-batch occurrence counter).tests/unit/ofx-parser.test.ts— 1 existing assertion updated (FITID no longer in memo;bankReferencenow set).
634/634 server tests pass.
Docs#
docs/KNOWN_ISSUES.md— KI list now empty. KI-02 / KI-05 / KI-06 / KI-08 all moved to the Resolved section with a paragraph each on what was done.
Files#
server/package.json (overrides + 0.14.6 → 0.14.7)
server/package-lock.json (uuid resolution)
server/src/import/types.ts (bankReference field)
server/src/import/dedup.ts (bank-ref-keyed hash)
server/src/import/parsers/ofx.ts (FITID → bankReference)
server/src/datasource/plaid.ts (transaction_id → bankReference)
server/tests/unit/dedup.test.ts (+4 bank-ref tests)
server/tests/unit/xlsx-date-parsing.test.ts (new, 2 tests)
server/tests/unit/ofx-parser.test.ts (memo assertion updated)
docs/KNOWN_ISSUES.md (KI list empty)
package.json + web/package.json (0.14.6 → 0.14.7)
[0.14.5] — 2026-05-23 — Tar --force-local + docs refresh#
Small but real: closes the 6 pre-existing portability test failures and brings README / FEATURES / KNOWN_ISSUES up to date with the 0.14.x hardening reality.
Fix — Windows-dev tar shell-out#
exec('tar', ['-czf', archive, ...]) failed on Windows dev with
"Cannot execute remote shell" because GNU tar interprets the
drive-letter colon in C:\Users\... as an SSH-style host:path.
Fix: pass --force-local to every tar invocation. Safe on Linux
(no-op when no colon is present in arguments).
server/src/domain/portability.ts:274—tar -czfarchive creation.server/src/domain/backup-runner.ts:124—tar -czfattachments archive on backup.server/src/domain/backup-runner.ts:301—tar -xzfattachments archive on restore.server/tests/integration/portability.test.ts:47, 248— matchingtar -xzfcalls in the verification helper.
Result: the 6 previously-red portability tests now pass.
Docs#
docs/KNOWN_ISSUES.md— header date refreshed (Phase 5 → 0.14.4); KI-07 ("single-user / single-household assumption") moved to a new "Resolved" section with a pointer to the 72 cross-tenant isolation tests; the tar-on-Windows bug also recorded as resolved.README.mdStatus section adds the 0.14.x hardening line; Testing section bumps ~560 → ~630 and notes the 72 dedicated cross-tenant tests + that the suite passes cleanly.docs/FEATURES.md— test-count bumped; new row under Households & Sharing for cross-tenant isolation; new row under Data Integrity & Security for multi-tenant isolation scoping.
Tests#
628/628 server tests pass. First fully-green run in this session.
Files#
server/src/domain/portability.ts (--force-local)
server/src/domain/backup-runner.ts (--force-local x2)
server/tests/integration/portability.test.ts (--force-local x2)
README.md (status + test count)
docs/FEATURES.md (tenant-isolation rows + test count)
docs/KNOWN_ISSUES.md (KI-07 resolved + header)
package.json + server/package.json + web/package.json (0.14.4 → 0.14.5)
[0.14.4] — 2026-05-23 — Tenant isolation hardening, slice 5 (closes pass): vehicles + commute-routes + fuel-prices + normalize + projections#
Final slice. Closes the multi-tenant isolation hardening pass that started at 0.14.0.
Scope#
routes/vehicles.ts(4 handlers)routes/commute-routes.ts(5 handlers — including the cross-table assignments path that joins vehicles + routes)routes/fuel-prices.ts(3 handlers — kept as global reference data; role-gated)routes/normalize.ts+ai/normalize-service.ts(route + service signature both updated)routes/projections.ts— closed the NULL-tenant write hatch
Server — vehicles + commute-routes#
- All
vehicleshandlers scoped; INSERT writestenant_id; PATCH/DELETE filter ontenant_id→ cross-tenant ids 404. - All
commute_routes+route_vehicle_assignmentsops scoped; POST/PUT-assignments verify every suppliedvehicleIdbelongs to the caller via a single bulk SELECT (vehiclesAllInTenant); list endpoint joins throughvehicles.tenant_idso a route can't surface a vehicle name from another tenant; INSERTs writetenant_idon both tables.
Server — fuel-prices (design note)#
fuel_prices.fuel_type is the PRIMARY KEY → only ONE row per
grade across the whole database. These are global reference
values (US national average from EIA), exactly like
exchange_rates. The Phase-8 tenant_id column on the table
is effectively unused. Decision: keep as global reference data,
just gate writes:
- All three handlers now require an active tenant
(
requireTenant) for consistency with the rest of the pass — super-admin sessions get 403. - POST manual override + POST refresh-from-EIA gated on
requireFinancialMutationso children can't change shared prices that affect every household's budget wizard.
Server — normalize#
routes/normalize.ts:requireTenant+ verifies any suppliedaccountIdbelongs to the caller.ai/normalize-service.ts:NormalizePendingOptions.tenantIdis now required (throws if omitted).fetchPendingTransactionsjoinstransactions → accountsfilteringtenant_id, so the AI normalizer can no longer see other tenants' pending rows.- Test caller in
tests/functional/normalize-pipeline.test.tsupdated to seed Default tenant id.
Server — projections (NULL hatch closed)#
The pre-fix WHERE tenant_id = $1 OR tenant_id IS NULL clause
applied to every path (read AND write). That let a NULL-tenant
"shared template" be PATCH'd and DELETE'd by any tenant. Fix:
- GET list and GET series still accept
tenant_id IS NULL(shared templates remain readable across tenants). - PATCH and DELETE now require
tenant_id = $1exactly → a NULL-tenant template is read-only.
Tests (+9 cross-tenant isolation tests)#
- Vehicles: GET list filtered; PATCH and DELETE 404 cross-tenant with no mutation/deletion.
- Commute-routes: POST rejects cross-tenant
vehicleIdin assignments and creates no row; PUT-assignments 404s a cross- tenant route. - Normalize: POST never touches cross-tenant pending rows;
cross-tenant
accountId404s. - Projections NULL hatch: PATCH and DELETE on a NULL-tenant template both 404 and leave the row intact; GET still includes the template.
Test-helper updates: tests/integration/commute-routes.test.ts
direct INSERTs into vehicles/commute_routes/route_vehicle_assignments
now include tenant_id.
Total: 72 tenant-isolation tests (63 from 0.14.0-3 + 9 new). Every one of them would have failed against pre-0.14.x code.
- Total: 627 tests (621 server + 6 web). Same 6 pre-existing portability tar failures unchanged.
Files#
server/src/routes/vehicles.ts (rewrote)
server/src/routes/commute-routes.ts (rewrote)
server/src/routes/fuel-prices.ts (gated)
server/src/routes/normalize.ts (rewrote)
server/src/routes/projections.ts (NULL-hatch on writes)
server/src/ai/normalize-service.ts (tenantId required)
server/tests/functional/normalize-pipeline.test.ts (tenantId in call)
server/tests/integration/commute-routes.test.ts (tenant_id in seeds)
server/tests/security/tenant-isolation.test.ts (+9 tests)
package.json + server/package.json + web/package.json (0.14.3 → 0.14.4)
What the full pass closed#
- 17 unscoped route files flagged by the audit, plus 4
additional helpers (
assertAccountInTenant,assertTransactionInTenant,assertHoldingInTenant,assertCategoryUsableByTenant,assertAttachmentInTenant) and one cross-slice helper (requireTenantextracted from three duplicate definitions). - 3 domain modules retrofitted to require tenantId
(
domain/reports.ts,domain/transfers.ts,ai/normalize-service.ts). - 2 architectural ambiguities resolved: the categories table's
nullable
tenant_idis now treated correctly by every route (assertCategoryUsableByTenant);retirement_projections' NULL-tenant hatch is read-only. - 1 file-disclosure bug closed (
/api/attachments/:idno longer decrypts cross-tenant attachments). - 72 cross-tenant tests in
tests/security/tenant-isolation.test.tscovering accounts, transactions, holdings, budgets, bills, recurring-income, goals, recurring suggestions, subscriptions, cash-flow, insights, reports, transfers, attachments, splits, category-suggestions, members-list, vehicles, commute-routes, normalize, and the projections NULL hatch. Each one would have failed against pre-0.14.x code.
The original 0.14.x plan is fully delivered.
[0.14.3] — 2026-05-23 — Tenant isolation hardening, slice 4: attachments + splits + suggestions + members-list#
Slice 4 of the hardening pass. Closes the file-disclosure bug
on /api/attachments/:id (pre-fix the route loaded and DECRYPTED
any attachment by id — a single id-guess could exfiltrate any
tenant's receipts), plus three smaller surface areas.
Scope#
routes/attachments.ts(HIGH — file disclosure)routes/splits.tsroutes/suggestions.ts(HIGH — also relinked transactions globally)routes/tenants.tsmember-list (MED — admin-only tighten)
Server — new helper#
auth/rbac.ts:assertAttachmentInTenant(tenantId, attachmentId)joinsattachments → transactions → accountsso cross-tenant ids are invisible.
Server — routes/attachments.ts#
- All 5 handlers gated by
requireTenantand verify ownership viaassertTransactionInTenant/assertAttachmentInTenant. POST /api/transactions/:id/attachmentsnow INSERTstenant_idon the new row (Phase 8 column was never populated by this route).GET /api/attachments/:id(download) and/previewuse a newloadScopedAttachment(id, tenantId)helper that joins through the parent transaction; cross-tenant ids return 404 identical to unknown ids.DELETEverifies tenant before removing the row + on-disk file.
Server — routes/splits.ts#
- GET / PUT / DELETE all verify the parent transaction belongs to the caller's tenant.
- PUT validates each split's
categoryIdviaassertCategoryUsableByTenant— a tenant can't smuggle another tenant's category onto a split. - PUT writes
transaction_splits.tenant_id(Phase 8 column was never populated by this route).
Server — routes/suggestions.ts#
- All 4 handlers scoped by
tenant_id. loadPendingSuggestion(id, tenantId)looks up only this tenant's pending row.- Approve INSERTs the new category with
tenant_id = callerso it doesn't appear as a global category visible to every other tenant. - Approve / merge / reject UPDATE on transactions joins through
accounts so only THIS tenant's matching rows get relinked /
cleared. Pre-fix these UPDATEs were global — rejecting a
suggestion on Tenant A also cleared the same
suggested_category_nametag on every other tenant's transactions. - Merge validates the target
categoryIdagainst the tenant.
Server — routes/tenants.ts#
GET /api/tenants/:id/membersnow admin-only. Pre-fix any member (including child) could list every other member's email- last-login timestamp.
Tests (+11 cross-tenant isolation tests)#
- Attachments: list/download/preview/delete all 404 for cross- tenant ids; deletion leaves the file in place.
- Splits: GET/PUT/DELETE 404 for cross-tenant transactions; PUT rejects cross-tenant per-split categoryId.
- Suggestions: list filtered by tenant; approve 404s cross- tenant; reject only clears caller-tenant transactions (verifies the previously-global UPDATE is now scoped).
- Members: child role on Tenant B gets 403 on the members list; admin gets 200 on their own tenant.
tests/integration/suggestions.test.ts test helper updated to
seed category_suggestions.tenant_id (one direct INSERT).
Total: 54 tenant-isolation tests (43 from 0.14.0-2 + 11 new). Each new one would have failed against pre-0.14.3 code.
- Total: 618 tests (612 server + 6 web). Same 6 pre-existing portability tar failures unchanged.
Files#
server/src/auth/rbac.ts (+assertAttachmentInTenant)
server/src/routes/attachments.ts (rewrote)
server/src/routes/splits.ts (rewrote)
server/src/routes/suggestions.ts (rewrote)
server/src/routes/tenants.ts (members list admin-only)
server/tests/integration/suggestions.test.ts (tenant_id in test seed)
server/tests/security/tenant-isolation.test.ts (+11 tests)
package.json + server/package.json + web/package.json (0.14.2 → 0.14.3)
Coming next#
- 0.14.4 — vehicles, commute-routes, fuel-prices, normalize, projections NULL hatch
[0.14.2] — 2026-05-23 — Tenant isolation hardening, slice 3: insights + reports + transfers (domain layer too)#
Slice 3 of the multi-tenant hardening pass. This was the
highest-risk remaining slice because the domain layer
(domain/reports.ts, domain/transfers.ts) had zero references
to tenantId at all — pre-0.14.2, the six canned report runners
walked every tenant's transactions, and the transfer detector
happily paired a debit from Tenant A with a credit from Tenant B,
creating cross-tenant "transfer groups" that broke both
households' spending totals and leaked merchant strings across.
Scope#
routes/insights.ts, routes/reports.ts + domain/reports.ts,
routes/transfers.ts + domain/transfers.ts.
Server — routes/insights.ts#
- All 3 handlers gated by
requireTenant. OptionalaccountIdvalidated viaassertAccountInTenant(404 on cross-tenant). spending-by-category:transaction_category_lines → accountsjoin witha.tenant_id = $1.income-expense: EXISTS clause on accounts to scope the outer-joined transactions.net-worth-over-time: holdings_value CTE and the cross-join on accounts both filtertenant_id— pre-0.14.2 the holdings total was the sum of every household's investment positions.
Server — domain/reports.ts + routes/reports.ts#
ReportDefinition.runsignature now requirestenantIdas the second argument. The route'srequireTenantprovides it.- Every report's raw SQL gets a tenant filter:
spending-by-category,top-merchants,monthly-income- expense,largest-transactions— all joinaccountsontenant_id.subscription-costs— filtersbills.tenant_iddirectly (no accounts join needed).net-worth-by-month—openingsCTE constrained to caller-tenant accounts.
Server — domain/transfers.ts + routes/transfers.ts#
detectTransfers({tenantId, accountId?})—tenantIdnow required (not optional). Candidate self-join requires BOTH accounts in the caller's tenant. A debit on Tenant A and a credit on Tenant B with matching amount/date are NO LONGER paired.linkTransfer(aId, bId, tenantId)— lookup joins accounts on tenant_id; either leg in another tenant produces "not found" identical to a stale id, so cross-tenant probes can't enumerate.unlinkTransfer(groupId, tenantId)— UPDATE joins accounts so legs from another tenant are invisible; a group from Tenant B returns rowCount=0 → 404, same shape as unknown id.- Route: all 4 handlers gated by
requireTenant; detect validatesaccountIdagainst tenant; list filters via accounts join.
Tests (+9 cross-tenant isolation tests)#
- Insights spending-by-category omits cross-tenant rows on a shared global category; cross-tenant accountId 404s.
- Insights income-expense and net-worth-over-time aggregate only caller-tenant data.
reports/top-merchants/runreturns only caller-tenant merchants;reports/subscription-costs/runonly caller-tenant bills.- Transfers detect does NOT pair across tenants (matching debit/credit on different tenants stays unpaired).
- POST /api/transfers with cross-tenant aId/bId returns 400 with "not found" message.
- DELETE /api/transfers/:groupId 404s for a cross-tenant group and leaves the group intact.
Total: 43 tenant-isolation tests (34 from 0.14.0+1 + 9 new). Each new one would have failed against pre-0.14.2 code.
- Total: 607 tests (601 server + 6 web). Same 6 pre-existing portability tar failures unchanged.
Files#
server/src/routes/insights.ts (rewrote)
server/src/routes/reports.ts (rewrote — tiny)
server/src/routes/transfers.ts (rewrote)
server/src/domain/reports.ts (rewrote — every report)
server/src/domain/transfers.ts (rewrote — tenant arg required)
server/tests/security/tenant-isolation.test.ts (+9 tests)
package.json + server/package.json + web/package.json (0.14.1 → 0.14.2)
Coming next#
- 0.14.3 — attachments (file-disclosure risk), splits, suggestions, tenants member-list permission tighten
- 0.14.4 — vehicles, commute-routes, fuel-prices, normalize, projections NULL hatch
[0.14.1] — 2026-05-23 — Tenant isolation hardening, slice 2: budgets + bills + recurring + subscriptions + goals#
Slice 2 of the multi-tenant hardening pass. Five route files
brought up to the same isolation discipline that 0.14.0
established for the foundational tables. The requireTenant
helper from 0.14.0 was extracted to auth/rbac.ts so every
slice imports the same one (was duplicated in
anomalies.ts / normalization-rules.ts).
Scope#
budgets.ts, bills.ts, goals.ts, recurring.ts,
subscriptions.ts. Plus goals.ts (audit missed it, but same
unscoped pattern). The shared requireTenant is now in
auth/rbac.ts; route files import it instead of redefining.
Server — routes/budgets.ts#
- All 6 handlers scoped by
req.user.tenantId. POST writestenant_id; PATCH/DELETE filter ontenant_idso cross-tenant ids 404. - POST validates
categoryIdviaassertCategoryUsableByTenantbefore insertion. POST /api/budgets/copyscopes both the source SELECT and the destination NOT EXISTS check by tenant — copying across tenants is impossible.GET /api/budgets/actualjoinstransaction_category_lines → accountsso a tenant's per-category totals never include another tenant's spending, even on a shared global category.
Server — routes/bills.ts#
- All 10 handlers (bills + recurring-income + cash-flow) scoped.
- POST
billsandrecurring-incomevalidatecategoryId/accountIdagainst this tenant before insertion. GET /api/cash-flowstarting-net-worth query, bills walk, and income walk all filter bytenant_id. Pre-0.14.1 a single call aggregated every tenant's net worth and projected every tenant's bills into the same forecast.
Server — routes/goals.ts#
- All 4 handlers scoped. (Audit missed this file; added it to this slice.)
Server — routes/recurring.ts#
POST /api/recurring/detectwalks transactions via an accounts join — only this tenant's history is scanned. Pre-0.14.1 the detector saw every tenant's merchants and surfaced them as suggestions visible to everyone.- All 6 handlers tenant-scope SELECTs/UPDATEs on
recurring_suggestions. /confirmand bulk/confirmINSERT bills + recurring_income rows withtenant_idfrom the session.deriveNextDatejoins through accounts so smuggledsample_txn_idsfrom another tenant return null.
Server — routes/subscriptions.ts#
- Same shape as recurring:
/scanwalks tenant txns only;/candidatesfiltered; AI-applied verdicts UPDATE scoped so one tenant can't mass-rename another tenant's suggestions.
Tests (+17 cross-tenant isolation tests)#
tests/security/tenant-isolation.test.ts extended:
- Budgets: list filtered; PATCH/DELETE cross-tenant 404; POST /copy only copies caller-tenant source; budget actual aggregates only this tenant's transactions even on a shared global category; POST rejects cross-tenant categoryId.
- Bills: list filtered; PATCH/DELETE cross-tenant 404; POST rejects cross-tenant accountId and categoryId.
- Recurring-income: list filtered.
- Cash-flow: starting net worth + bills + income all scoped (A's $1000 forecast doesn't include B's $50000).
- Goals: list filtered; PATCH/DELETE cross-tenant 404.
- Recurring: /detect only scans caller's transactions; reject 404s cross-tenant; /suggestions list filtered.
- Subscriptions: /candidates list filtered.
Total: 34 tenant-isolation tests (17 from 0.14.0 + 17 new). Each would have failed against pre-0.14.1 code.
Two tests/integration/bulk-and-rules.test.ts recurring_suggestions
inserts updated to include tenant_id (they relied on the
now-removed unscoped bulk action).
- Total: 598 tests (592 server + 6 web). 6 pre-existing portability tar failures unchanged.
Files#
server/src/auth/rbac.ts (+requireTenant export)
server/src/routes/budgets.ts (rewrote)
server/src/routes/bills.ts (rewrote)
server/src/routes/goals.ts (rewrote)
server/src/routes/recurring.ts (rewrote)
server/src/routes/subscriptions.ts (rewrote)
server/src/routes/accounts.ts (use shared requireTenant)
server/src/routes/transactions.ts (use shared requireTenant)
server/src/routes/holdings.ts (use shared requireTenant)
server/src/routes/normalization-rules.ts (use shared requireTenant)
server/src/routes/anomalies.ts (use shared requireTenant)
server/tests/security/tenant-isolation.test.ts (+17 tests)
server/tests/integration/bulk-and-rules.test.ts (tenant_id on direct INSERTs)
package.json + server/package.json + web/package.json (0.14.0 → 0.14.1)
Coming next#
- 0.14.2 — insights, reports +
domain/reports.ts, transfers +domain/transfers.ts - 0.14.3 — attachments, splits, suggestions, tenants member-list permission tighten
- 0.14.4 — vehicles, commute-routes, fuel-prices, normalize, projections NULL hatch
[0.14.0] — 2026-05-23 — Tenant isolation hardening: accounts + transactions + holdings#
A focused audit of every route under server/src/routes/ found
that most of the API surface had no tenant scoping despite the
multi-tenant model that shipped in Phase 8. In practice nothing
had leaked because every install was solo on the Default tenant,
but anyone running multi-tenant would have seen (and could have
mutated) every other tenant's data through ~17 endpoints.
There is no Postgres RLS and no central middleware that injects a tenant filter — every route must scope its own queries. This release is the first slice of a multi-release hardening pass.
Scope of this slice#
The three foundational tables: accounts, transactions,
holdings. Future slices: budgets/bills/recurring (0.14.1),
insights/reports/transfers (0.14.2), attachments/splits/suggestions
- tenants member-list (0.14.3), vehicles/commute/fuel/normalize (0.14.4).
Server — new helpers (auth/rbac.ts)#
assertAccountInTenant(tenantId, accountId)— single SELECT, returns boolean. Used before any mutation that accepts anaccountIdin the body.assertTransactionInTenant(tenantId, transactionId)— joins via accounts so cross-tenant transactions are invisible.assertHoldingInTenant(tenantId, holdingId)— same shape.assertCategoryUsableByTenant(tenantId, categoryId)— categories may be global (tenant_id IS NULL) or per-tenant; this returns true for either as long as the per-tenant ones match the caller.
All four use 404 on miss, not 403, so cross-tenant probes can't enumerate ids via status-code diffing.
Server — routes/accounts.ts#
- New per-file
requireTenant(req, reply)(same pattern asanomalies.ts/normalization-rules.ts). Super-admin sessions (no active tenant) get 403 instead of seeing every tenant's accounts. GET /api/accountsfiltersWHERE a.tenant_id = $1. Child role still scoped toaccount_user_accessids on top.GET /api/accounts/:idaddsAND a.tenant_id = $2so a probe for someone else's account id 404s identically to a non-existent one.POST /api/accountswritestenant_idfromreq.user.tenantId(was previously omitted entirely, leaving rows tenant-less).PATCH/DELETE /api/accounts/:idfilter by tenant_id in the WHERE clause; rowCount=0 returns 404.
Server — routes/transactions.ts#
GET /api/transactionsadds anaccountsjoin witha.tenant_id = $Xon both the inner and outer queries plus the count query. Child role'sscopedIdsstill layered on top.GET /api/transactions/exportsame pattern.PATCH /api/transactions/:idowner lookup now joins throughaccounts.tenant_id;categoryId(when supplied) must passassertCategoryUsableByTenant.POST /api/transactions/bulk-deleteusesDELETE ... USING accounts WHERE a.tenant_id = $1so cross-tenant ids are silently filtered (deleted count reflects only the caller's ids). Same shape protects against id-enumeration.PATCH /api/transactions/bulksameUPDATE ... FROM accountsshape;categoryIdvalidated against tenant.
Server — routes/holdings.ts#
GET /api/holdingsjoinsaccountsand filters tenant_id.POST /api/holdingsreplaces the type-onlyassertInvestmentAccountwith a combinedassertInvestmentAccountInTenantthat checks ownership AND account-type in one SELECT. INSERT now writesholdings.tenant_idexplicitly (was Phase-8 column but never populated by this route).PATCH /api/holdings/:idcallsassertHoldingInTenantbefore building the UPDATE.DELETE /api/holdings/:idusesDELETE ... USING accounts WHERE a.tenant_id = $1(single statement).POST /api/holdings/refresh-prices/cryptoalready CLEAN per the audit; tightened therequireTenantto share the same helper shape as the rest of the file.
Tests — new (tests/security/tenant-isolation.test.ts)#
17 cross-tenant tests, each of which would have FAILED against pre-0.14.0 code:
- Accounts: GET list, GET single, PATCH, DELETE all 404/empty on cross-tenant ids; POST ignores a tenant_id supplied in the body and uses the session's tenant.
- Transactions: GET list + export omit other tenants' rows; cross-
tenant
accountIdquery returns empty; PATCH single 404s; bulk-delete and bulk-PATCH silently filter cross-tenant ids; PATCH single rejects a categoryId from another tenant. - Holdings: GET list filtered; POST 404s for cross-tenant accountId; PATCH 404s for cross-tenant holding; DELETE 404s; crypto refresh never touches other tenants' rows.
Two pre-existing tests in tests/integration/fx.test.ts were
updated to seed accounts with the Default tenant id (they were
relying on the unscoped GET path that's now gone).
- Total: 587 tests (581 server + 6 web), 575 of 581 pass (the 6 portability failures are the pre-existing Windows-tar bug, unchanged by this release).
Files#
server/src/auth/rbac.ts (+4 helpers)
server/src/routes/accounts.ts (rewrote)
server/src/routes/transactions.ts (5 handlers fixed)
server/src/routes/holdings.ts (rewrote)
server/tests/security/tenant-isolation.test.ts (new — 17 tests)
server/tests/integration/fx.test.ts (tenant_id on direct INSERTs)
package.json + server/package.json + web/package.json (0.13.6 → 0.14.0)
Coming next#
- 0.14.1 — budgets, bills, recurring, subscriptions, cash-flow
- 0.14.2 — insights, reports +
domain/reports.ts, transfers +domain/transfers.ts - 0.14.3 — attachments, splits, suggestions, tenants member-list permission tighten
- 0.14.4 — vehicles, commute-routes, fuel-prices, normalize, projections NULL hatch
[0.13.6] — 2026-05-23 — Non-AI rules engine completion (closes backlog)#
The last named backlog item. The Phase-6.2 normalization_rules
table already had CRUD + manual apply + the "Apply to similar?"
prompt — what was missing was running rules automatically during
import (so freshly-imported transactions arrive categorized
without a manual button click), plus tenant scoping (the table
predated multi-tenant and was leaking across households).
Schema (migration 029)#
normalization_rules.tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE— existing rows backfilled to the Default tenant; rows with no tenant get dropped (would be invisible under the new model).normalization_rules.enabled boolean NOT NULL DEFAULT true— pause a noisy rule without deleting it.normalization_rules.priority int NOT NULL DEFAULT 0— higher wins on overlapping matches; the apply loop iterates ASC so the highest-priority UPDATE runs last and its values overwrite.- Old
lower(pattern)unique index replaced with composite(tenant_id, lower(pattern))so two tenants can each register the same pattern. - Partial index
(tenant_id, priority) WHERE enabled = truekeeps the import-hot-path SELECT cheap as rule counts grow.
Server#
- New
domain/rules-applier.ts—applyRulesToTransactions (tenantId, transactionIds[])selects enabled rules for the tenant in ASC priority order and runs one UPDATE per rule over the given ids. Always skips rows wherenormalization_status='manual'. Updatesmatch_count+last_applied_atso the rules list can show usage. persistBatch()hook — after a successful import,applyRulesToTransactionsruns over the freshly-inserted ids BEFORE the existing anomaly scan. Both hooks are awaited + try/ catch wrapped so a hook failure can't break the import itself. Rules-before-anomaly ordering matters: the anomaly detector's "unusual-at-merchant" rule groups bynormalized_merchant, so the rules pass cleaning the merchant name first means the detector groups correctly.- Tenant scoping on every route:
GET /api/normalization-rulesfilters byreq.user.tenantId.POST/PATCH/DELETEall scope by tenant; POST inserts with the user's active tenant_id automatically.POST /previewcounts only the tenant's transactions (joins via accounts).POST /applyselects only enabled rules for the tenant and runs the UPDATE through anaccountsjoin scoped to the same tenant. Disabled rules are simply not selected.
- POST/PATCH accept
enabledandpriority(both optional; default to true/0).
Tests (+6 server)#
tests/integration/bulk-and-rules.test.tsnewrules engine: auto-apply on import + tenant scope (0.13.6)describe:- Auto-apply on import: a rule pre-exists,
persistBatchinserts a row, the row arrives withnormalized_merchant,category_id, andnormalization_status='normalized'already populated. - Disabled rules do NOT fire on import.
- Rules engine never overwrites manual rows
(
applyRulesToTransactionscalled directly). - Higher-priority rule wins on overlapping matches.
- Tenant isolation: a rule on tenant B does not normalize tenant A's imports.
- PATCH can toggle
enabled+ bumppriority.
- Auto-apply on import: a rule pre-exists,
- All 14 existing rules tests still pass under the new tenant scoping (default test user is in the Default tenant, so they Just Work).
- Total: 571 tests (565 server + 6 web), all green except 6 pre-existing portability failures (Windows-tar shell-out bug in the dev environment — unchanged by this release).
Web#
web/src/api.tsNormalizationRuleinterface extended withenabled,priority,tenant_id. No new UI in this slice — rules are still created via the existing "Apply to similar?" prompt; the new toggles are reachable via the API directly. A dedicated rules-management page is a candidate follow-up if the usage warrants it.
Files#
server/src/db/migrations/029_normalization_rules_tenant_scope.sql (new)
server/src/domain/rules-applier.ts (new)
server/src/import/importer.ts (apply-rules hook)
server/src/routes/normalization-rules.ts (tenant scope + enabled/priority)
server/tests/integration/bulk-and-rules.test.ts (+6 tests)
web/src/api.ts (interface fields)
package.json + server/package.json + web/package.json (0.13.5 → 0.13.6)
What's next#
The original "Beyond — Backlog" list is now fully complete except for native mobile apps (still deferred — the PWA covers mobile). Future direction is whatever the user picks next.
[0.13.5] — 2026-05-23 — Scheduled crypto-price refresh + docs refresh#
Combines two stragglers: 0.13.3's manual crypto-price refresh +
0.11.3's scheduled background sync now talk to each other, so crypto
prices update hands-off on the same cadence as bank syncs. Also a
long-overdue refresh of README.md and docs/FEATURES.md, which
were stuck at "Phases 1–6 complete" while the product had shipped
through 0.13.4.
Server#
runAutoSyncTick()(server/src/domain/auto-sync.ts) gains acryptopass after OFX-DC + Plaid. For each tenant with stale crypto holdings it batches one CoinGecko call and updatesholdings.last_price_cents+last_price_date. The pass is skipped entirely whenCRYPTO_PRICE_PROVIDER=manual.- Per-day cadence gate: the SELECT filters
last_price_date IS NULL OR last_price_date < today, so an hourly tick on a tenant already priced today produces zero rows and zero HTTP calls — same self-throttling shape as the existing per-sourcelast_sync_atgate for OFX/Plaid. - Per-tenant failures (rate-limit / HTTP / transport) increment a
crypto.failedcounter without breaking the tick — one rate-limited tenant can't block siblings. AutoSyncTickResultnow reportscrypto: { attempted, updated, unknown, failed }alongsideofxDcandplaid.
Tests (+5 server)#
tests/integration/auto-sync.test.tsnewcrypto price refresh (0.13.5)describe:- Happy path: BTC + ETH priced,
last_price_cents+ date stamped. - Unknown symbols counted separately from updates.
CRYPTO_PRICE_PROVIDER=manualskips the provider call entirely (verified by an exploding fetch that must never be invoked).- Per-day cadence gate: holdings already priced today are skipped on subsequent ticks (verified by recording fetch URLs — bitcoin must not appear when only ethereum is stale).
- Sibling tenants survive each other's failures: tenant A 429s while tenant B still gets updated.
- Happy path: BTC + ETH priced,
/api/auto-sync/runshape assertion extended to includecrypto.- Total: 565 tests (559 server + 6 web), all green.
Documentation#
README.mdStatus section rewritten — was stuck at "Phases 1–6 complete," now reflects everything shipped through 0.13.5 with a one-bullet-per-area summary (import, AI, receipts, wealth, budgeting, reporting, mobile, households, ops).README.mdtest count updated 405 → ~560.docs/FEATURES.mdrewritten: every shipped backlog item flipped from 📋 → ✅, plus new rows for anomaly alerts, tax-category tagging, data portability, scheduled crypto refresh, calendar view, bill-splitting, per-account permission tuning, audit log, PWA, and the conversational assistant. New "Households & Sharing" section. Native mobile + non-AI rules engine marked 💡 backlog.
Files#
server/src/domain/auto-sync.ts (crypto pass + cadence gate)
server/tests/integration/auto-sync.test.ts (+5 crypto-pass tests)
README.md (Status + test count)
docs/FEATURES.md (full refresh)
package.json + server/package.json + web/package.json (0.13.4 → 0.13.5)
[0.13.4] — 2026-05-23 — Per-account permission tuning (closes original backlog)#
Last of the five planned backlog releases. Adds per-account permission tuning so spouses can be restricted to specific accounts, and any non-admin role can be granted read-only access. Existing behavior is preserved: a spouse with no access rows keeps full tenant access.
Schema (migration 028)#
account_user_access.permission text NOT NULL DEFAULT 'read_write'with CHECK on{read, read_write}. Existing rows default toread_writeso today's children keep their current edit capability.- Partial index
(user_id, tenant_id) WHERE permission = 'read_write'for the dominant mutation-route lookup.
rbac generalization#
scopedAccountIds(ctx)extended:- admin → null (unrestricted; no change)
- child → always scoped (no change)
- spouse → NEW: scoped to access rows when any exist; null (unrestricted) when zero rows exist. Preserves legacy spouse behavior for tenants that never set explicit scopes.
- New
canWriteAccount(ctx, accountId):- admin → true
- spouse → true when no access rows exist (legacy); otherwise
requires a
read_writerow for THIS account - child → requires a
read_writerow
- New
assertAccountWriteAccess(ctx, accountId)— returns{status, error}or null. Route handlers call this BEFORE running a mutation that targets a specific account.
Routes#
PUT /api/tenants/:id/members/:userId/accountsnow accepts EITHER the legacyaccountIds: string[](all default to read_write) OR the new structuredaccounts: [{accountId, permission}]. The web UI sends the structured form; external scripts that sendaccountIdskeep working.GET /api/tenants/:id/members/:userId/accountsreturnspermissionper row.PATCH /api/transactions/:idcallsassertAccountWriteAccessafter looking up the transaction's account. Returns 403 with a clear message when a scoped user hits an account they can only read.
Web#
MembersSectionaccount-access modal: each account row now has a checkbox and a permission dropdown (read+write / read only). Unchecking removes the row entirely; checked rows default toread_write. Save sends the structuredaccounts[]payload.
Tests (+10 server)#
tests/integration/permissions.test.ts:- Admin baseline PATCH works.
- Spouse without rows = unrestricted PATCH works.
- Spouse with read-only on the account is 403.
- Spouse with read_write can PATCH.
- Spouse with rows but EXCLUDING the target account is 403.
- Child read = 403; child read_write = OK.
- Structured
accounts[]payload round-trips with the per-row permission. - Legacy
accountIds[]payload still works (defaults to read_write). - Unknown permission values return 400.
- Total: 560 tests (554 server + 6 web), all green.
Files#
server/src/db/migrations/028_account_access_permission.sql (new)
server/src/auth/rbac.ts (generalized
scopedAccountIds
+ new helpers)
server/src/routes/tenants.ts (structured
accounts payload)
server/src/routes/transactions.ts (per-account
write gate on PATCH)
server/tests/integration/permissions.test.ts (new)
web/src/api.ts (accounts[] +
permission types)
web/src/pages/WorkspacePage.tsx (permission editor)
What's next#
The original "Beyond — Backlog" list is now complete except for native mobile apps (still deferred — the PWA covers mobile) and the "non-AI rules engine for auto-categorization" item, which was already partially covered by the Phase 6.2 rules table. From here, future work is whatever the user picks next.
[0.13.3] — 2026-05-23 — Crypto tracking#
Fourth backlog release. Extends the existing holdings table with an
asset_type column + a CoinGecko price fetcher so a brokerage account
can hold mixed assets (stocks + crypto) and the user can refresh
crypto prices with one click. No new account type — the existing
investment (or any account) can host crypto holdings.
Schema (migration 027)#
holdings.asset_type text NOT NULL DEFAULT 'stock'with CHECK on{stock, etf, mutual_fund, bond, crypto, commodity, other}. Existing rows default to'stock'; user re-tags crypto after migrate.- Partial index on
(asset_type) WHERE asset_type = 'crypto'.
Price fetcher (server/src/domain/crypto-prices.ts)#
- Hand-rolled fetch wrapper to CoinGecko's free public API (no key). 37 built-in symbol → coin-id mappings (BTC, ETH, USDT, USDC, BNB, SOL, ADA, DOGE, AVAX, DOT, LINK, MATIC, LTC, etc.).
- Unknown symbols bucketed separately and reported in the result so one obscure token doesn't fail the whole refresh.
- Categorized failures: rate_limited (HTTP 429), http_error, transport_error (fetch reject / timeout), unknown_symbol.
- Injectable fetch so unit tests don't hit the live API.
Settings + routes#
CRYPTO_PRICE_PROVIDER(super-only, default'coingecko';'manual'disables auto-fetch).POST /api/holdings/refresh-prices/crypto(admin + spouse) — scans the tenant's crypto holdings, fetches prices, updateslast_price_cents+last_price_date. Returns counts + updated symbols + unknown symbols.- Holdings POST + PATCH accept
assetTypewith allow-list validation.
Assistant#
crypto_holdings_summary(read) — 17 tools total.
Web#
- HoldingsPanel: Type column with inline asset-type
<select>, "Refresh crypto prices" button when any holding is crypto, Asset type dropdown on the new-holding form.
Tests (+15 server)#
- 8 unit tests on the CoinGecko fetcher (mapping, unknown bucketing, HTTP 429/500, transport rejection, no-price-returned per symbol).
- 7 integration tests on holdings + refresh route (default asset_type, allow-list rejection, refresh touches ONLY crypto rows, unknown symbols, manual-provider 400, zero-crypto, tenant isolation).
- Total: 550 tests (544 server + 6 web), all green.
Files#
server/src/db/migrations/027_holdings_asset_type.sql (new)
server/src/domain/crypto-prices.ts (new)
server/src/domain/settings.ts (+CRYPTO_PRICE_PROVIDER)
server/src/domain/assistant/tools.ts (+crypto_holdings_summary)
server/src/routes/holdings.ts (+asset_type + refresh route)
server/tests/unit/crypto-prices.test.ts (new)
server/tests/integration/holdings-crypto.test.ts (new)
web/src/api.ts (AssetType + refresh method)
web/src/components/HoldingsPanel.tsx (type column + refresh button)
[0.13.2] — 2026-05-23 — Anomaly alerts#
Third backlog release. Detects three classes of unusual transactions
and surfaces them on a new /anomalies page, with optional SMTP
digest emails on every scan. Disabled by default — flip
ANOMALY_ENABLED=true in super-admin settings to turn it on.
Detection rules#
large_amount— any single transaction whose absolute spend ≥ANOMALY_LARGE_TXN_THRESHOLD_CENTS(default $500). Severity bumps tohighat 2× threshold.unusual_at_merchant— transaction whose absolute amount is ≥ANOMALY_MULTIPLIER× the median spend at the same merchant (default 3×). Only fires when the merchant has been seen at least 5 prior times (GROUP BY HAVING>= 6accounts for the candidate row itself being in the stats).duplicate_suspect— same account + same amount + same merchant within 24h. Self-join surfaces only the second row of each pair (latercreated_at).
Schema (migration 026)#
anomaly_alertswithUNIQUE (transaction_id, kind)so re-scans are idempotent (ON CONFLICT DO NOTHING everywhere). Partial index(tenant_id) WHERE dismissed = falsekeeps the nav-badge count fast.
Settings (super-only, all default off)#
ANOMALY_ENABLED,ANOMALY_LARGE_TXN_THRESHOLD_CENTS(default 50000),ANOMALY_MULTIPLIER(default 3, min 1.5),ANOMALY_EMAIL_TO(optional digest recipient).
Server#
domain/anomaly-detector.ts—scanTransactionsForAnomalies(tenantId, txnIds?). Three SQL passes per scan, each with ON CONFLICT DO NOTHING. On new alerts +ANOMALY_EMAIL_TOset + SMTP configured, sends a single digest email via the existingtryMail()(one mail per scan, not per alert — avoids mail-bombing on a big import).- Routes:
GET /api/anomalies(open by default,?includeDismissed=1to widen),GET /api/anomalies/countfor the nav badge,POST /api/anomalies/scan(admin+spouse, audit-logged),POST /api/anomalies/:id/dismiss. persistBatch()runs an awaited scan over freshly-imported ids after the transaction commits, wrapped in try/catch so a detector failure never breaks the import. Awaited (not fire-and-forget) so concurrent vitest workers can't race each other'sresetDb()s.
Web#
- New
/anomaliespage grouped by kind (Large amount / Unusual at merchant / Possible duplicate), severity pill per row (high / warn / info), Dismiss + Re-open toggle, "Include dismissed" filter, "Run scan now" button. - Nav link between Tax and Reports.
Tests (+9 server)#
tests/integration/anomalies.test.ts: disabled gate, large_amount detection + idempotent re-scan, unusual_at_merchant 5+1 fires / 4+1 doesn't, duplicate_suspect within 24h, tenant isolation, route CRUD round-trip,/countfor the nav badge.- Total: 535 tests (529 server + 6 web), all green.
Files#
server/src/db/migrations/026_anomaly_alerts.sql (new)
server/src/domain/anomaly-detector.ts (new)
server/src/domain/settings.ts (+ANOMALY_* keys)
server/src/routes/anomalies.ts (new)
server/src/app.ts (register route)
server/src/import/importer.ts (persistBatch
now scans inserted ids)
server/tests/setup/test-db.ts (TRUNCATE anomaly_alerts)
server/tests/integration/anomalies.test.ts (new)
web/src/api.ts (anomaly types + methods)
web/src/pages/AnomaliesPage.tsx (new)
web/src/App.tsx (nav + route)
[0.13.1] — 2026-05-23 — Tax-category tagging + year-end reports#
Second backlog release. Tags categories with an optional
tax_category and adds a year-end report that aggregates only the
tagged ones — Schedule A / Schedule C style summaries without forcing
users to maintain a parallel taxonomy.
Schema (migration 025)#
categories.tax_category text(nullable). NULL = not tax-relevant. Free-text — the controlled vocabulary lives in the UI's datalist, not the DB CHECK. US Schedule A/C labels are too varied to enum.- Partial index
WHERE tax_category IS NOT NULLso the report's GROUP BY stays cheap as the categories table grows.
Server#
GET /api/categories/tax-vocabulary— returns the suggested list the UI uses as datalist options (Charitable Donations, Mortgage Interest, Wages (W-2), 1099 Income, Business Expense — Office, etc.). 16 entries covering common Schedule A + Schedule C lines.POST /api/categoriesaccepts optionaltax_category.PATCH /api/categories/:idacceptsnameortax_categoryin isolation. Empty-stringtax_categoryclears the tag. Existing callers that only sentnamestill work.GET /api/reports/tax-year/:year— tenant-scoped. Aggregates every transaction whose category hastax_category IS NOT NULLover Jan 1 – Dec 31. Splits per-tax-category totals by sign (incomevsdeductible) so a refund-heavy tag and a normal income tag don't get smushed into one number. Transfers excluded (transfer_group_id IS NULL).GET /api/reports/tax-year/:year.csv— same data as CSV with the standard download headers. Properly escapes commas / quotes.- Assistant gets
tax_year_summary(read tool). 16 tools total now.
Web#
CategoriesPagerow layout grows a third column: a free-text input bound to the<datalist>of suggestions. Blur saves; empty clears. The existing rename + delete buttons are untouched.- New
/taxpage (TaxYearPage.tsx) with year picker (current year + 5 prior), three summary cards (income / deductible / tagged-txn-count), and a per-tax-category table split into income vs deductible sections. Download-CSV button hits the CSV endpoint directly so the browser handles the save. - Nav link in the tenant sidebar between Calendar and Reports.
Tests (+7 server)#
tests/integration/tax-year.test.ts:PATCH /api/categories/:idsets and clearstax_categoryindependently ofname.- Tax-vocabulary endpoint returns the suggestion list.
- Year-format validation (
/tax-year/abc→ 400). - End-to-end aggregation: per-tax-category totals + contributing- category lists, with year-boundary guards (Dec 31 of prior year and Jan 1 of next year MUST NOT count) and untagged-category invisibility.
- Transfers excluded from the report (
transfer_group_idset). - CSV download has
text/csvcontent-type + proper filename header + the expected row format. - Tenant isolation — a transaction owned by another tenant's account does not leak into the calling tenant's report.
- Total: 526 tests (520 server + 6 web), all green.
Files#
server/src/db/migrations/025_tax_categories.sql (new)
server/src/routes/categories.ts (+tax_category in
create/patch/list +
vocabulary endpoint)
server/src/routes/tax-year.ts (new)
server/src/app.ts (register route)
server/src/domain/assistant/tools.ts (+tax_year_summary)
server/tests/integration/tax-year.test.ts (new)
web/src/api.ts (taxVocabulary +
taxYearReport + types,
updateCategory signature)
web/src/pages/CategoriesPage.tsx (+tax cell + datalist)
web/src/pages/TaxYearPage.tsx (new)
web/src/App.tsx (nav + route)
web/src/styles.css (cat-row 4-col grid)
[0.13.0] — 2026-05-23 — Data portability tooling#
First post-roadmap backlog release. Per-tenant export of every row +
every attachment into a single portable .tar.gz bundle. Distinct
from the server-wide npm run backup (which is a Postgres custom
dump): this format is portable — JSON tables a human can read
and an external script can re-import.
Server#
- New
server/src/domain/portability.tsexposingexportTenantData(tenantId). Walks 19 tenant-scoped tables in a deterministic order, copies every attachment from disk into anattachments/subdirectory, writes atenant.jsonmanifest + bundle, then tar+gzips the whole thing into a temp file. Returns a cleanup callback the route runs after the stream completes. - Secrets are stripped.
ofx_dc_connections.username_encrypted/password_encryptedandplaid_items.access_token_encryptedare excluded from the SELECT lists. Connection metadata is kept so the user has a record of which banks they were linked to. - Attachment files are bundled verbatim — still encrypted at
rest if
encryption_version = 1. A re-import needs the sameATTACHMENT_ENCRYPTION_KEYto read them. The manifest'snotesfield calls this out explicitly. - New
server/src/routes/portability.ts—GET /api/portability/export. Admin-only (same gate as auth-provider config + member management). Streams the file withContent-Type: application/gzip+Content-Disposition: attachment+Content-Length+ a customX-Smrtcash-Countsheader so the UI can show row counts after the download without re-parsing the tarball. - Every export writes an
audit_logrow with actionportability.exportso the super-admin can see when a tenant bulk-pulled their data.
Web#
- New "Data portability" section on
/workspace(admin-only). One "Export all my data" button triggers a same-origin fetch, reads the counts header, then synthesizes a<a download>click on a Blob to save the file. After completion the page shows the archive size + per-table counts.
Tests (+7 server)#
tests/integration/portability.test.ts:- Produces a tar.gz with manifest + every expected table key.
- Strips encrypted credential blobs from
ofx_dc_connectionsandplaid_itemseven when the rows exist. - Cross-tenant isolation: a transaction belonging to another tenant's account is never bundled.
- Audit log entry written on every route hit.
- 403 for non-admin role (spouse can read everything but can't bulk-export).
- Headers:
Content-Type,Content-Dispositionfilename,X-Smrtcash-Countsis valid JSON containingaccounts. - Attachment files actually arrive inside the bundle and round- trip their contents.
- Total: 519 tests (513 server + 6 web), all green.
Files#
server/src/domain/portability.ts (new)
server/src/routes/portability.ts (new)
server/src/app.ts (register route)
server/tests/integration/portability.test.ts (new)
web/src/pages/WorkspacePage.tsx (+PortabilitySection)
[0.12.3] — 2026-05-23 — Calendar budget view (Phase 9.3, closes Phase 9 + roadmap)#
Final planned roadmap release. With this slice every Phase 9 deliverable from the original roadmap is live, and all nine phases are complete.
Server#
- New
server/src/routes/calendar.ts—GET /api/calendar/:month(YYYY-MM). Returns per-day aggregates (spend / income / txn count / bill-due-ids), monthly totals (spend / income / budget), today's position within the month for pace math, and the next 14 days of upcoming bills. Tenant-scoped via theaccounts.tenant_idjoin — surfaced by a tenant-isolation test. Postgres does the date math (date_trunc,EXTRACT(DAY FROM …)) so JS Date's local-vs-UTC footguns can't bleed in. GET /api/transactionsextended (additive) with optionalstartDate/endDatequery params. Validated as YYYY-MM-DD; existing callers unaffected. The CalendarPage day drawer uses these to load just one day's worth.- New assistant tool
calendar_month_summary(read, tenant- scoped). Total assistant tools: 15.
Web#
- New
web/src/pages/CalendarPage.tsxand/calendarroute in the tenant sidebar. Month nav (prev / today / next), four summary cards (spent / income / budgeted / pace), a 7-column grid built from leading-blank + day-cells + trailing-blank so the first row aligns to Sunday. Each day cell shows the date, spend total, bill-due ▲ marker when applicable, and a faint red background tint scaled by that day's spending vs. the month's max. - Clicking a day loads that day's transactions via the new date-filter params and renders them under the grid.
- "Upcoming bills (next 14 days)" table below the grid surfaces
the response's
upcoming_billsfield. - Calendar CSS appended to
web/src/styles.css.
Tests (+7 server)#
tests/integration/calendar.test.ts— 7 tests: invalid month format (400), days-in-month for May / Feb 2026 / Feb 2028 (leap), per-day spend + income + count aggregation, month- boundary guards (Apr 30 and Jun 1 must NOT appear in the May payload), bill-due markers on the right day, budget total summed correctly across multiple budget rows for the month, cross-tenant isolation (a transaction in another tenant must not leak into the default tenant's calendar).- Total: 512 tests (506 server + 6 web), all green.
Files#
server/src/routes/calendar.ts (new)
server/src/routes/transactions.ts (+startDate/endDate filters)
server/src/app.ts (register route)
server/src/domain/assistant/tools.ts (+calendar_month_summary)
server/tests/integration/calendar.test.ts (new)
web/src/api.ts (calendarMonth + types)
web/src/pages/CalendarPage.tsx (new)
web/src/styles.css (.calendar-* grid)
web/src/App.tsx (nav + route)
What's next#
This is the last planned release of the original nine-phase roadmap. From here, future work is backlog items (multi-user permission tuning, tax-category reports, crypto tracking, native mobile, etc.) or whatever the user decides is the next priority.
[0.12.2] — 2026-05-23 — Bill-splitting (Phase 9.2)#
Third Phase 9 release. Track who owes whom across split expenses
(dinner with friends, shared rent, roommate utilities). Distinct
from the existing transaction_splits table, which is for CATEGORY
splitting — this is PERSON splitting.
Schema (migration 024)#
split_participants— the people you can split with. Tenant-scoped,UNIQUE(tenant_id, name). Optionalemailand optionaluser_idlink (when the participant happens to also be a SmrtCash user, e.g. a spouse on the same tenant). Archive instead of delete to preserve history.transaction_shares— per-transaction allocation to a participant.share_centsmatches the sign of the transaction amount: a negative-cents row means the participant's portion of a spending transaction (which, via the API's outward convention, appears as "they owe you" net).UNIQUE(transaction_id, participant_id)so re-saving is idempotent. Partial index on(participant_id) WHERE settled = falsefor fast summary queries.- The tenant's own residual share is NEVER stored — it's
computed as
transaction.amount_cents - sum(shares.share_cents)so a downstream amount change can't desync the math.
Routes (server/src/routes/shares.ts)#
GET /api/split-participants?includeArchived=1— list.POST /api/split-participants— create. Returns 409 on duplicate name within a tenant.PATCH /api/split-participants/:id— rename / re-email / toggle archived.DELETE /api/split-participants/:id— also removes that participant's shares (FK cascade).GET /api/transactions/:id/shares— returns the per-share rows plustransactionAmountCents,sharesTotalCents, andyourShareCents(the implied residual).PUT /api/transactions/:id/shares— replace all shares in one call. Idempotent. Validates: every participant in this tenant, every share's sign matches the transaction, total magnitude doesn't exceed the transaction amount. Records an audit log entry.POST /api/transaction-shares/:id/settle—{settled: true}to mark paid,{settled: false}to re-open.GET /api/shares/summary—net_open_cents+open_countper participant.GET /api/shares?participantId=X&onlyOpen=1— share-level list for a participant.
Assistant tools#
Two new tools join the registry (now 14 total):
share_summary(read) — net owed per participant.split_transaction(write, audit-logged) — auto-creates participants by name. "Split this $80 lunch between Cam and Dee" works without first creating Cam and Dee through the UI. Each call writes anassistant.split_transactionrow to the audit log.
Web#
- New
/sharingpage (SharingPage.tsx). Net-balance table per participant (positive = they owe you, negative = you owe them), drill-in to the per-participant share list, settle / re-open toggle, archive-or-delete management, include-archived filter. - New
SplitTransactionModal.tsxcomponent. Opened from the new 👥 button on every transaction row. Editable per-participant amounts, Split equally (incl. you) button that distributes the absolute amount across N+1 (the user plus N participants), inline "add a participant" form. TransactionTablegets anonOpenShares?optional prop + per-row 👥 button. Existing ✂ category-split button is unchanged.TransactionsPagewires the modal on the new prop.
Tests (+7 server)#
tests/integration/shares.test.ts— 7 tests: participant CRUD round-trip, duplicate-name 409,PUTshares replaces all + GET reports yourShareCents,PUTrejects sign mismatch and over-allocation, settle toggle is idempotent, summary aggregates open shares correctly across multiple transactions and settlements, assistantsplit_transactiontool auto-creates participants by name and writes the audit-log row.- Total: 505 tests (499 server + 6 web), all green.
Files#
server/src/db/migrations/024_phase9_2_split_bills.sql (new)
server/src/routes/shares.ts (new)
server/src/app.ts (register routes)
server/src/domain/assistant/tools.ts (+2 tools)
server/tests/setup/test-db.ts (TRUNCATE split_*)
server/tests/integration/shares.test.ts (new)
web/src/api.ts (sharing API + types)
web/src/pages/SharingPage.tsx (new)
web/src/components/SplitTransactionModal.tsx (new)
web/src/components/TransactionTable.tsx (+onOpenShares prop)
web/src/pages/TransactionsPage.tsx (mount modal)
web/src/App.tsx (nav + route)
[0.12.1] — 2026-05-23 — AI assistant (Phase 9.1, agentic)#
Second Phase 9 release. Adds an in-app AI assistant that can answer natural-language questions over your data and make changes — recategorize transactions, bulk re-tag, create budgets, mark bills paid, top up savings goals. Every write tool call records an entry in the existing super-admin audit log so the operator can see exactly what the assistant did and when.
Tools (server/src/domain/assistant/tools.ts)#
Twelve tools — 8 read + 4 write. Each tool runs scoped to the
authenticated session's tenant. The model never picks the tenant;
the runtime hard-wires it from req.user.tenantId.
Read tools:
query_transactions— filter by date range, account, category, description substring, amount range. Hard cap 200 rows.account_balances— current balance per account.list_categories— every category with parent.spending_by_category— totals over a date range.list_budgets— per-month budget rows.list_bills— bill reminders + next-due dates.list_savings_goals— target / current / deadline.
Write tools (all call recordAudit() before returning):
update_transaction_category— single transaction.bulk_recategorize— match bydescriptionContainsILIKE + optional date range, set category in one shot. Hard cap: 500 transactions per call so a confused model can't rewrite the whole history.create_budget— set/upsert a monthly budget. Honors the DB CHECK (amount_cents > 0).mark_bill_paid— advancesnext_due_dateby the bill's frequency.update_savings_goal— accepts eitherdeltaCentsorcurrentCents.
Every write tool also returns the canonical row data so the assistant can confirm to the user what changed.
Runtime (server/src/domain/assistant/runtime.ts)#
- Tool-use loop against Anthropic's Messages API. Hard cap
MAX_ITERATIONS=8— if the model keeps requesting tools, the loop bails withstopReason='tool_use_loop_cap'so the UI can prompt the user to break the work into smaller asks. - System prompt explicitly tells the model:
- Data is tenant-scoped; you can't see other users' data.
- Prefer querying for current data over guessing.
- Amounts are integer cents; format as
$for display. - Never invent transaction IDs / category names — call
list_*first. - For bulk writes, run the read tool first and confirm intent.
assistantAvailable()checks DB-effective settings (not just boot-time config). Returns{available: false, reason}untilAI_PROVIDER=claude+ANTHROPIC_API_KEYare both set.
Routes (server/src/routes/assistant.ts)#
GET /api/assistant/status— available / unavailable + reason. Reports unavailable forchildrole regardless of config.POST /api/assistant/chat— body{messages: [{role, content}]}. Caps incoming history at last 40 messages. Returns{reply, toolCalls, iterations, stopReason}. Children get 403; admins + spouses both allowed.
Web#
- New
web/src/pages/AssistantPage.tsx— chat-style UI with message bubbles, inline tool-call chips (🔍 read, ✎ write, red on error), suggested starter prompts, Enter-to-send + Shift+Enter newline, scroll-to-bottom on update. Hides itself if/api/assistant/statusreports unavailable. - New nav link in the tenant sidebar, between Connections and Reports.
- Chat-specific styles appended to
styles.css.
Test infra fix#
seedAccount()previously created accounts with NULLtenant_id. The new assistant tools correctly enforce tenant scoping, which exposed the gap.seedAccount()now defaults to the seeded Default tenant; existing tests unaffected. PasstenantId: nullto opt out.
Tests (+15 server)#
tests/unit/assistant-tools.test.ts— 7 tests: tool-registry hygiene (unique names, valid input schemas),query_transactionsfilters, tenant-isolation (cross-tenant data not leaked),update_transaction_category+ audit entry,bulk_recategorizeupdates + audit entry,create_budgetpositive-amount enforcement,mark_bill_paiddate math,update_savings_goaldelta + absolute set.tests/integration/assistant.test.ts— 8 tests: status off/on, child role rejection, empty-payload rejection, read-only loop end-to-end, write tool writes an audit entry, tool-error recovery (loop continues), MAX_ITERATIONS cap engages.- Total: 498 tests (492 server + 6 web), all green.
Files#
server/src/domain/assistant/tools.ts (new)
server/src/domain/assistant/runtime.ts (new)
server/src/routes/assistant.ts (new)
server/src/app.ts (register routes)
server/tests/setup/test-db.ts (seedAccount tenant default)
server/tests/unit/assistant-tools.test.ts (new)
server/tests/integration/assistant.test.ts (new)
web/src/api.ts (assistantChat + types)
web/src/pages/AssistantPage.tsx (new)
web/src/styles.css (.chat-* + .assistant-page)
web/src/App.tsx (nav + route)
[0.12.0] — 2026-05-23 — PWA (Phase 9.0)#
First Phase 9 release. Makes SmrtCash installable to a phone's home screen as a real Progressive Web App and does the responsive-CSS pass that future Phase 9 slices will lean on. No new server code — the entire change lives in the web bundle and a couple of static files.
Manifest + icons#
- New
web/public/manifest.webmanifest— name, short_name,display: standalone, theme color matching the indigo accent, background color matching the light surface, 192 / 512 / maskable icons. - New SVG icons under
web/public/icons/— vector so they look sharp on every density without shipping ten PNG variants. index.htmlgains<link rel="manifest">, a paired<meta name="theme-color">for light + dark, and the iOSapple-mobile-web-app-*meta tags.
Service worker (web/public/sw.js)#
- Cache-first for built assets (
/assets/*,/icons/*,/manifest.webmanifest,/favicon*). Vite hashes filenames per build, so a new bundle is fetched fresh; old entries rot untilactivatepurges the previousCACHE_VERSION. - Network-only for
/api/*— financial data must never be served from a stale cache. Better to fail visibly than to show yesterday's balance. - Navigation requests: network-first, fall back to cached
index.htmlwhen offline. The SPA loads even with no network for routes the browser has already visited. skipWaiting+clients.claimso an updated SW takes effect on the next page load.- Registered from
main.tsxafterload, only inimport.meta.env.PRODso the Vite dev server isn't confused by a stale SW serving yesterday's bundle.
Install prompt + offline indicator#
- New
web/src/components/InstallPrompt.tsx. Capturesbeforeinstallprompt, shows a small fixed card with Install / Not now. Dismissal is durable vialocalStorage— if you say no, we stop pestering. - Auto-hides when the app is already running in standalone mode
(
display-mode: standalonemedia query). OfflineIndicatorfloats a small red "Offline" pill whennavigator.onLineflips false. The SW keeps already-visited routes usable; this is just a heads-up.
Mobile drawer + responsive CSS#
- New
web/src/components/MobileBar.tsxwithMobileBar,SidebarBackdrop, anduseMobileDrawerhook. The hook drives adata-openattribute on the existing.sidebarelement so the drawer CSS works without re-architecting the parent. Auto-closes on route change; Escape closes it. - Bottom of
web/src/styles.cssgets a@media (max-width: 768px)block:- Sidebar becomes a slide-out drawer (80vw, max 280px) with a backdrop scrim.
form-gridandcard-gridcollapse to a single column.- Tables get
-webkit-overflow-scrolling: touch+ amin-widthso they scroll horizontally cleanly instead of crushing. - Buttons + nav items hit the 44px tap-target floor.
- Install prompt fills the bottom of the screen on phones.
Files#
web/public/manifest.webmanifest (new)
web/public/sw.js (new)
web/public/icons/icon-192.svg (new)
web/public/icons/icon-512.svg (new)
web/public/icons/icon-maskable.svg (new)
web/index.html (manifest link + theme color + iOS meta)
web/src/main.tsx (SW registration on PROD load)
web/src/styles.css (install/offline/hamburger + @media block)
web/src/components/InstallPrompt.tsx (new)
web/src/components/MobileBar.tsx (new)
web/src/App.tsx (mount mobile bar + drawer + InstallPrompt)
Tests#
- No new unit tests — the bits added (
InstallPrompt,MobileBar, the service worker) are inherently browser-side and the web suite doesn't yet have a React Testing Library setup. Existing 482 tests (476 server + 6 web) still green; web build succeeds with the manifest + SW + icons emitted todist/.
Browser smoke notes#
- Lighthouse PWA checklist passes locally for the install criteria
(
manifest.webmanifest, valid icons, service worker, HTTPS in prod via Caddy). - The install prompt only appears in browsers + contexts that fire
beforeinstallprompt(Chrome, Edge, Android). iOS Safari adds via Share → Add to Home Screen as usual; the apple meta tags are inindex.htmlfor that path.
[0.11.3] — 2026-05-23 — Scheduled background sync (Phase 8.3)#
Final Phase 8 release. Closes Phase 8. With this slice every connectivity option from the original roadmap is live and can run unattended: file imports (OFX/QFX/QIF), OFX Direct Connect, Plaid, and now scheduled background sync.
The scheduler#
- New
server/src/domain/auto-sync.ts— same in-process pattern asbackup-scheduler.ts. 60s tick reads settings on every iteration so config changes take effect without a restart. The tick readsAUTO_SYNC_ENABLED; if false, immediate no-op. runAutoSyncTick({force, ofxFetchOverride, plaidFetchOverride})is the exported entry point. The interval calls it with no opts; the/api/auto-sync/runroute calls it withforce:true; tests drive it directly with mocked fetches.- Per-tick flow:
- Read
AUTO_SYNC_ENABLED. No-op when false. - For daily/weekly cadence: gate on
AUTO_SYNC_TIME— like the backup scheduler, wait until the scheduled instant has passed today. Hourly bypasses this gate. - Walk every
ofx_dc_connections WHERE enabled = true, filter down to sources whoselast_sync_atis older than the per-cadence threshold (shouldRunForSource()), then callofxDirectConnectSource.fetch()+persistBatch()and updatelast_sync_*on success or failure. - Same for
plaid_items WHERE status = 'active'— using thefetchPlaidItemTransactions()helper that already handles the per-account fan-out + cursor advance.
- Read
- Per-source error isolation: each connection / item gets its
own try/catch. A failing source records
last_sync_status+last_sync_erroron its row; the tick moves on to the next source without blocking. The result object reports per-source attempt / success / fail counts so the operator can spot drift. - Per-source cadence: even when the tick runs, sources whose
last_sync_atis recent enough are skipped. Hourly cadence tolerates jitter (59-minute floor for "just over 60 min").
Settings (super-only)#
AUTO_SYNC_ENABLED(bool) — global on/off.AUTO_SYNC_FREQUENCY(hourly|daily|weekly) — cadence applied per source.AUTO_SYNC_TIME(HH:MM, 24h) — scheduled instant for daily / weekly cadence. Interpreted in the container's local timezone (same caveat asBACKUP_TIME).
Routes#
GET /api/auto-sync/status(super-admin) — returns enabled + frequency + time + counts of registered OFX-DC + Plaid sources.POST /api/auto-sync/run(super-admin) —runAutoSyncTick({force:true})for an immediate fire, useful right after wiring up the first bank connection.
Web (/system super-admin panel)#
- New
AutoSyncSectioncomponent on the System Overview tab. Enable toggle, frequency dropdown, time field, "Save" + "Run all syncs now" buttons, plus a source-count summary. Save writes through the existing/api/settings/:keyplumbing — no new settings code path.
Tests (+15 server)#
tests/unit/auto-sync-cadence.test.ts— 7 tests forshouldRunForSource: NULL last-sync, hourly within / past window, hourly jitter tolerance, daily threshold, weekly threshold, unknown-frequency fallback.tests/integration/auto-sync.test.ts— 8 tests: no-op when disabled, force bypasses the gate, OFX-DC sync persists + updates status, per-source failures don't block siblings, the cadence gate skips recently-synced sources, an OFX-DC + Plaid tick fires both,/api/auto-sync/statusis super-admin gated,/api/auto-sync/runruns and returns the result shape.- Total: 482 tests (476 server + 6 web), all green.
Files#
server/src/domain/auto-sync.ts (new)
server/src/routes/auto-sync.ts (new)
server/src/domain/settings.ts (+AUTO_SYNC_* keys)
server/src/app.ts (register routes + boot scheduler)
server/tests/unit/auto-sync-cadence.test.ts (new)
server/tests/integration/auto-sync.test.ts (new)
web/src/api.ts (autoSyncStatus / autoSyncRunNow)
web/src/components/AutoSyncSection.tsx (new)
web/src/pages/SystemPage.tsx (mount AutoSyncSection)
[0.11.2] — 2026-05-23 — Plaid integration (Phase 8.2)#
Third Phase 8 release. Adds Plaid as a third data source — same
TransactionDataSource interface as OFX-DC, different backend — but
gated behind a super-admin toggle and disabled by default.
Plaid is the only data source that leaves the fully-local model: turning it on means bank credentials and statement traffic go through Plaid's servers. The roadmap called this out explicitly, so the on-by-default story stays "your data stays on your machine"; Plaid exists for users who actively opt in to the trade-off.
Gate#
- New super-only settings:
PLAID_ENABLED(bool),PLAID_CLIENT_ID,PLAID_SECRET,PLAID_ENV(sandbox / development / production). getPlaidConfig()returns null unless all four are populated and PLAID_ENABLED is truthy. Every route + data-source code path consults this gate first.GET /api/plaid/statusis the only Plaid endpoint that works when disabled — returns{enabled: false, environment: null}. The web UI uses this to decide whether to even render the Plaid block.
Schema (migration 023)#
plaid_items— one row per Plaid item (= one user@institution login). Storesplaid_item_id,institution_id,institution_name, AES-256-GCM-encryptedaccess_token_encrypted,sync_cursor,status, and the samelast_sync_*columns as OFX-DC for consistent status surfacing.plaid_account_links— maps a Plaid account_id to a SmrtCash account_id within an item. UNIQUE(plaid_item_id, plaid_account_id) so re-running the mapping is idempotent.
Plaid client (server/src/domain/plaid.ts)#
- Hand-rolled REST wrapper. No SDK — global
fetch, JSON in / JSON out. Endpoints used:/link/token/create,/item/public_token/exchange,/accounts/get,/transactions/sync,/item/remove. - Categorized error types:
auth_failed(INVALID_CLIENT_ID, INVALID_SECRET, INVALID_ACCESS_TOKEN, ITEM_LOGIN_REQUIRED),invalid_request,rate_limited(HTTP 429),http_error,transport_error(fetch reject / timeout). mapPlaidTransaction()— handles the sign flip: Plaid uses positive = outflow, SmrtCash uses negative = outflow, so amounts are inverted at the boundary. Falls back tonamewhenmerchant_nameis absent. Surfaces pending status into memo.
Data source (server/src/datasource/plaid.ts)#
- Implements
TransactionDataSource. Paginates/transactions/syncstarting from the stored cursor untilhas_more=false(max 50 pages safety guard). fetchPlaidItemTransactions()returns transactions GROUPED BY SmrtCash account via the link table — this is the shape the route needs because one Plaid item can fan out to multiple SmrtCash accounts.- Unmapped Plaid accounts are tracked separately and reported in the sync response so the user knows to map them.
Routes (server/src/routes/plaid.ts)#
GET /api/plaid/status— public to authenticated users.POST /api/plaid/link-token— creates the link_token for the browser widget. Admin only.POST /api/plaid/exchange— accepts the public_token from Plaid Link, exchanges for access_token + item_id, fetches the account list, persists encrypted. Admin only.GET /api/plaid/items— list items + their account links.POST /api/plaid/items/:id/link-account— bulk-write the Plaid → SmrtCash account mappings. Admin only.POST /api/plaid/items/:id/sync— run a full sync. CallspersistBatch()per linked account so dedup + counters work identically to file imports and OFX-DC. Admin + spouse.DELETE /api/plaid/items/:id— best-effort calls Plaid's/item/remove(stops billing in production), then deletes the local row. Admin only.
Web#
- New
PlaidSection.tsxcomponent. Renders only when status reports enabled. - "Connect via Plaid" button — loads the Plaid Link script from
cdn.plaid.comon-demand the first time it's clicked. The script is the only external JS in the entire SPA and only loads when the user actively initiates a connection. - Post-exchange: account-mapper modal lets the user pick which SmrtCash account each Plaid account routes to (with "Skip" option per account).
- Items table: Institution / linked accounts / last-sync / status pill / per-row Sync / Remove buttons.
Importer / persistence#
import_batches.format_idnow sees'plaid'as a value alongside'csv','xlsx','ofx','qfx','qif','ofx_dc'. No schema change needed — it's a free-text column.
Tests (+17 server)#
tests/unit/plaid-client.test.ts— 8 tests: host routing, body shape (client_id + secret + payload),auth_failedon INVALID_CLIENT_ID,rate_limitedon HTTP 429, transport rejection, cursor passthrough, amount-sign inversion, fallback tonamewhenmerchant_nameabsent.tests/integration/plaid.test.ts— 9 tests: status off/on, gate rejects link-token when disabled, happy-path link-token, exchange + DB encryption check (token not stored in plaintext), account mapping, full sync (transaction lands on mapped SmrtCash account, cursor advances, status flips to ok), sync surfaces auth_failed, DELETE calls /item/remove and removes the row.- Total: 467 tests (461 server + 6 web), all green.
Files#
server/src/db/migrations/023_phase8_2_plaid.sql (new)
server/src/domain/plaid.ts (new)
server/src/domain/settings.ts (+PLAID_* keys, getPlaidConfig)
server/src/datasource/plaid.ts (new)
server/src/routes/plaid.ts (new)
server/src/app.ts (register routes)
server/tests/setup/test-db.ts (TRUNCATE plaid_*)
server/tests/unit/plaid-client.test.ts (new)
server/tests/integration/plaid.test.ts (new)
web/src/api.ts (Plaid types + methods)
web/src/components/PlaidSection.tsx (new)
web/src/pages/ConnectionsPage.tsx (mount PlaidSection)
[0.11.1] — 2026-05-23 — OFX Direct Connect (Phase 8.1)#
Second Phase 8 release. Wires the first concrete
TransactionDataSource on top of the OFX parser from 0.11.0 —
pulling statements straight from a bank's OFX endpoint with no
aggregator and no per-bank data sharing.
Schema (migration 022)#
ofx_dc_connections— tenant-scoped. Stores bank coordinates (ofx_url,ofx_org,ofx_fid,ofx_app_id,ofx_app_version, optionalintu_bid), account routing (bank_acct_id+bank_acct_type∈ {CHECKING, SAVINGS, MONEYMRKT, CREDITLINE, CREDITCARD} + nullablebank_id), and encrypted credentials (username_encrypted,password_encrypted, bothbytea). Sync state lives on the row:last_sync_at,last_sync_status,last_sync_error,last_sync_imported,last_sync_skipped.
Encryption#
- New
server/src/domain/crypto.tsexportsencryptString/decryptString— same AES-256-GCM wire format as attachments ([12-byte IV][ciphertext][16-byte GCM tag]) under the sameATTACHMENT_ENCRYPTION_KEY. Refuses to operate if the key is unset; bank passwords never sit in plaintext at rest.
Protocol#
- New
server/src/domain/ofx-dc.ts:formatOfxDateTime(d)→YYYYMMDDHHMMSSin UTC.buildOfxStmtRequest({connection, startDate, endDate})→ OFX 1.x SGML request body. Speaks VERSION:102 (broadest bank support); the SGML parser from 0.11.0 happily reads either 1.x or 2.x responses. BuildsBANKMSGSRQV1orCREDITCARDMSGSRQV1based onbankAcctType, escapes SGML-significant chars in credentials, includes optional<INTU.BID>when set.postOfxRequest(url, body, {fetchImpl?, timeoutMs?})— POSTs withContent-Type: application/x-ofx, 60s default timeout, injectablefetchfor unit tests.fetchOfxStatement(req, opts)— one-shot: build + post + parse. InspectsSONRSstatus code: non-zero 15500-range →OfxDcError('auth_failed'), other non-zero →'parse_error'. Other failure kinds:'http_error','transport_error'.
Data source#
- New
server/src/datasource/ofx-direct-connect.ts—ofxDirectConnectSourceimplementsTransactionDataSource.fetch()loads the row, decrypts credentials, computes the incremental window (last_sync_at - 7 daysfor re-syncs, last 90 days on first sync), calls the protocol module, returnsParsedTransaction[]+ an updated cursor.
Routes#
- New
server/src/routes/ofx-dc.ts:GET /api/ofx-dc/connections— list (encrypted columns stripped from the response).POST /api/ofx-dc/connections— create (admin only).PATCH /api/ofx-dc/connections/:id— update; blank password keeps the current value.DELETE /api/ofx-dc/connections/:id.POST /api/ofx-dc/connections/:id/test— 1-day window probe. Available to admin + spouse.POST /api/ofx-dc/connections/:id/sync— real fetch; on success runs through the sharedpersistBatch()(same dedup code path as file imports). Updateslast_sync_*columns on both success and failure.
Importer refactor#
import/importer.tsnow exportspersistBatch()so the data-source layer can reuse the CSV / structured / direct-connect persistence path — single source of truth for dedup hashing +import_batchesrows +ON CONFLICT DO NOTHINGsemantics.
Web#
- New
web/src/pages/ConnectionsPage.tsxand/connectionsroute in the tenant sidebar. Form for add/edit (with help text pointing at ofxhome.com), table of existing connections, per-row Test / Sync now / Edit / Delete. Status pills: OK / Never / Auth failed / HTTP error / Parse error / Transport error.last_sync_errortext shown inline when present. - API types:
OfxDcConnection,OfxDcConnectionInput,OfxDcActionResult,OfxDcAccountType.
Tests (+25 server)#
tests/unit/crypto.test.ts— 5 tests: round-trip, IV uniqueness, truncation rejection, tag-tamper rejection, unicode + long strings.tests/unit/ofx-dc.test.ts— 9 tests: date formatter, request building (bank + credit-card + SGML escaping + INTU.BID), happy path with mocked fetch,auth_failedon SONRS 15500, HTTP 500 mapping, transport rejection.tests/integration/ofx-dc.test.ts— 11 tests: CRUD shape, credential encryption (DB doesn't contain plaintext), GET excludes encrypted columns,bankAcctTypeallow-list, PATCH re-encryption,/testhappy + auth-failed paths,/synchappy path (transaction lands on the account, status row updates to 'ok'),/syncfailure path (status row updates to 'auth_failed' with error text),/syncdedup on second pass.- Total: 450 tests (444 server + 6 web), all green.
Files#
server/src/db/migrations/022_phase8_1_ofx_direct_connect.sql (new)
server/src/domain/crypto.ts (new)
server/src/domain/ofx-dc.ts (new)
server/src/datasource/ofx-direct-connect.ts (new)
server/src/routes/ofx-dc.ts (new)
server/src/app.ts (register routes)
server/src/import/importer.ts (export persistBatch)
server/tests/setup/test-db.ts (TRUNCATE ofx_dc_connections)
server/tests/unit/crypto.test.ts (new)
server/tests/unit/ofx-dc.test.ts (new)
server/tests/integration/ofx-dc.test.ts (new)
web/src/api.ts (OFX-DC types + methods)
web/src/pages/ConnectionsPage.tsx (new)
web/src/App.tsx (nav + route)
[0.11.0] — 2026-05-23 — File-import expansion (Phase 8.0)#
First Phase 8 release. Three new import formats join CSV/XLSX — covering the Quicken / Banktivity / Moneydance migration path — and a pluggable data-source layer goes in as the cornerstone for 8.1 (OFX Direct Connect), 8.2 (Plaid), and 8.3 (scheduled sync).
New parsers#
- QIF (Quicken Interchange Format) —
server/src/import/parsers/qif.ts. Walks!Type:Bank/CCard/Cash/Oth A/Oth Lsections, records terminated by^. Accepts all common date shapes including Quicken's apostrophe-year (1/3'05→ 2005-01-03) and 2-digit slash years (pivot:<70→ 2000s,≥70→ 1900s). N (reference / check number) is rolled into memo. Ignored sections (!Account,!Type:Cat, investment, securities) are skipped without crashing. - OFX 1.x (SGML) + OFX 2.x (XML) + QFX —
server/src/import/parsers/ofx.ts. A tolerant SGML parser that handles both styles: explicit close tags (OFX 2.x) and the SGML quirk where leaf elements omit their close tag and text terminates at the next<(OFX 1.x). Walks everySTMTTRNnode anywhere in the tree, so bank statements, credit-card statements, and investment statements all work. PullsTRNTYPE/DTPOSTED/TRNAMT/NAME/MEMO/CHECKNUM/FITID. QFX is detected by the.qfxextension and labelled as such; the parser is otherwise identical to OFX.
Pipeline wiring#
- New module
server/src/import/structured.tsexportstryParseStructured(filename, buffer). The importer calls this first; if it returns a result, the CSV/XLSX path is skipped entirely. Routing is by file extension (.ofx,.qfx,.qif) with a content-sniff fallback for OFX (so files with weird extensions still work). importer.tswas refactored so both CSV/XLSX and structured imports share a singlepersistBatch()helper — dedup hashing, theimport_batchesinsert, the per-row transaction insert withON CONFLICT (account_id, dedup_hash) DO NOTHING, and the batch counters all live in one place now./api/imports/formatsnow advertisesofx/qfx/qifso the Import page's format dropdown lists them alongside Chase.- Same
POST /api/imports/previewandPOST /api/imports/commitendpoints — no new routes.
Data-source layer scaffold#
- New directory
server/src/datasource/withtypes.tsandregistry.tsdefining theTransactionDataSourceinterface that the rest of Phase 8 plugs into. The shape mirrors Phase 2'sTransactionNormalizerand Phase 3'sOcrProvider—id,name,fullyLocal,configKeys, and an asyncfetch()that returnsParsedTransaction[]+RowError[]+ an opaque cursor for incremental sync. No data sources are registered yet; the scaffold sits ready for 8.1.
Web#
- Import page's file picker accepts
.csv/.xlsx/.ofx/.qfx/.qif. - Header help text reflects the new format list.
- No other UI changes — the existing preview + commit + dedup flow
works unmodified because structured imports return the same
ImportPreview/ImportResultshapes as CSV/XLSX.
Tests#
tests/unit/qif-parser.test.ts— 7 tests: canonical fixture round-trip, trailing record without^, ignored sections, per-record error isolation, apostrophe + 2-digit-year dates, garbage-date rejection.tests/unit/ofx-parser.test.ts— 6 tests: OFX 1.x SGML, QFX, OFX 2.x XML, content sniffing, OFX date parser, missing-root rejection.tests/integration/imports-structured.test.ts— 7 tests exercising the full/api/imports/preview+/api/imports/commit/api/imports/formatspath for QIF / OFX 1.x / OFX 2.x / QFX, including dedup on re-import.
- Total: 425 tests (419 server + 6 web), all green.
Files#
server/src/datasource/types.ts (new)
server/src/datasource/registry.ts (new)
server/src/import/parsers/qif.ts (new)
server/src/import/parsers/ofx.ts (new)
server/src/import/structured.ts (new)
server/src/import/formats.ts (advertises ofx/qfx/qif)
server/src/import/importer.ts (structured-first routing)
server/tests/fixtures/sample.qif (new)
server/tests/fixtures/sample.ofx (new)
server/tests/fixtures/sample.qfx (new)
server/tests/fixtures/sample-ofx2.ofx (new)
server/tests/unit/qif-parser.test.ts (new)
server/tests/unit/ofx-parser.test.ts (new)
server/tests/integration/imports-structured.test.ts (new)
web/src/pages/ImportPage.tsx (accept list + help text)
[0.10.1] — 2026-05-23 — Retirement projections (Phase 7.2)#
Closes Phase 7. The last queued item from the original roadmap lands: forward-looking projections of long-term goals.
Schema (migration 021)#
retirement_projections— one row per "what-if" model: name, starting balance, monthly contribution, annual return %, optional inflation deflator, horizon years (1–100), optional target year + target amount. Tenant-scoped via the standardtenant_id.
Domain#
domain/projections.ts#computeProjection()— pure math. Monthly-compound on the annual return rate ((1 + annual_return/100)^(1/12) - 1), with end-of-month contributions then end-of-month growth. Emits one point per year (year 0 = starting state) with both nominal and real (inflation- deflated) projected balance.
Routes#
GET /api/projections— list (tenant-scoped).POST /api/projections— create.PATCH /api/projections/:id— update.DELETE /api/projections/:id— remove.GET /api/projections/:id/series— compute the year-by-year curve.
Web#
/retirementpage with a sidebar of projections + detail pane. Detail shows a Recharts line chart with the nominal curve (and a real-dollar curve when inflation > 0), a target reference line when configured, a summary line, and an inline tune form for adjusting contributions / return / inflation / horizon.
Tests#
+8integration tests (projections.test.ts): math (0%-flat, 10% growth band, inflation deflation, negative return compounds), CRUD (create + list + series, validation, PATCH + DELETE).- Total: 405 (server 399 + web 6).
Notes#
- Real-terms math uses straight
nominal / (1 + infl)^year. Monte Carlo with return variance is on the backlog but overkill for a household app. - Starting balance is captured at creation time and editable via PATCH — a projection is a comparison artifact, not a live forecast against changing accounts.
[0.10.0] — 2026-05-23 — Multi-currency (Phase 7.1)#
The first of two queued Phase-7 items lands. Accounts can now be in any ISO 4217 currency; the dashboard converts every balance into a shared display currency for cross-account totals.
Schema (migration 020)#
exchange_ratestable — one row per (from, to, fetched_at). History is kept; lookups pick the most-recent row per pair via aDISTINCT ON (from, to)ORDER BY fetched_at DESC.- New settings:
DISPLAY_CURRENCY(ISO 4217, default 'USD') andFX_PROVIDER('open-er-api' for the auto-refresher). Both super-admin-only.
Domain#
domain/fx.ts—convert(amountCents, from, to, snapshot)resolves a rate (direct lookup, inverse fallback at 1/rate, or pass-through when neither known) and returns cents in the target currency plus arateKnownflag for UI.refreshRatesFromOpenErApi()— pulls fresh rates from the freeopen.er-api.com/v6/latest/<base>endpoint and writes one row per non-base target. No API key required; the IP rate-limit is generous (daily refresh well within it).setManualRate()— operator override, stored withsource='manual'. Manual rows aren't preferred — the most-recent row per pair wins regardless of source, so writing a manual rate effectively pins it until the next refresh.
Routes#
GET /api/exchange-rates— readable by any authenticated user (the dashboard needs it); returns latest rate per pair plus the display currency.POST /api/exchange-rates/refresh— super-admin only.POST /api/exchange-rates— set a manual rate.DELETE /api/exchange-rates/:from/:to— drop all rows for a pair.GET /api/accountsnow attachesdisplay_currency,balance_display_cents, andrate_knownto every account row. Net-worth-style aggregations across mixed currencies just sumbalance_display_cents.
Web#
/systemOverview tab gets an Exchange rates section under Super admins: rate table with source pill, "Refresh from provider" button, and a manual-override form.- Accounts page sums
balance_display_centsfor the Net Worth stat and labels the value with the display currency when accounts use multiple currencies. A warning banner appears when any account's currency has no rate configured.
Tests#
+7integration tests (fx.test.ts): tenant can read but not mutate, manual rate persistence, validation (negative rate, same from/to), accounts-list projection (direct + inverse + unknown), pair delete.- Total: 397 (server 391 + web 6).
Notes#
- Per-transaction currency conversion isn't done yet — every transaction is in its account's currency. Cross-currency totals use account-level balances (already-summed cents) projected at the current rate, which is the right tradeoff for a household app.
open.er-api.comis the only auto-provider wired today;frankfurteris in the source-CHECK constraint but not yet fetched. Manual rates cover any gap.
[0.9.5] — 2026-05-23 — Frontend design refresh + dark mode#
Pure styling slice — token system overhaul, polished components, proper dark mode with a sidebar toggle. No JSX class-name changes; no backend touched.
Added#
- Token-driven design system in
styles.css:--surface-1/2/3,--border+--border-strong,--text/muted/dim,--accent/--accent-soft,--pos/neg/warn/info(each with a tinted-softvariant),--shadow-sm/shadow/shadow-lg,--radius-sm/radius/radius-lg. Legacy alias names (--bg,--card,--accent-dark) are kept so older rules keep working. - Dark mode via
:root[data-theme="dark"]overrides.main.tsxapplies the attribute fromlocalStoragebefore the React tree mounts → no flash of light theme on reload. ThemeTogglecomponent in both sidebar footers (tenant + super admin). Two-button segmented control.
Changed#
- Sidebar: indigo→emerald brand gradient on
SmrtCash, background gradient, active link gets a left accent bar instead of a solid pill background, subtle hover states. - Page header: bumped
h1to 26px with tighter tracking, refined subtitle color and line-height. - Buttons: primary uses the new indigo
--accent, secondary has a clean outlined treatment, danger restraint until hover.btn-linkgets a soft tinted hover background. - Inputs: 3px accent-soft focus ring, dark-mode-aware backgrounds + borders.
- Tables: uppercase letter-spaced header row over
--surface-3, hover row gets--surface-3, selected row gets--accent-soft, tabular numerics on numeric columns. - Banners + status pills: use the new
--{pos,neg,warn,info}-softbackgrounds withcolor-mixborders for a consistent semantic palette in both themes. - Auth screens get a soft radial-gradient backdrop and a shadow-lg card, with the brand letterform in the gradient style.
- Code chips + scrollbars themed.
Notes#
- The redesign deliberately keeps every existing class name so React
components don't need to change. Source-order overrides at the
bottom of
styles.csscarry the new visual treatment. - Inter is preferred but not bundled — the system stack picks up fluently if it's not installed.
[0.9.4] — 2026-05-23 — Scheduler fix, GUI restore, secondary backup destination, env snapshot, savings % overrides#
Five related items, all backup-or-budget. Headline: the scheduler bug that prevented scheduled backups from firing is fixed.
Fixed#
- Scheduler not firing (regression from 0.7.6 onward). The tick
required an EXACT hour-and-minute match against
BACKUP_TIME;setIntervaldrift meant the daily window was reliably missed. The new logic uses a "scheduled instant has passed today" predicate combined with the cadence gate (shouldRun), which is robust to 60s jitter. Timezone note:BACKUP_TIMEis interpreted in the container's local time (UTC by default in Docker — set theTZenv var in compose to schedule against a different zone).
Added#
- Restore from the GUI. New
POST /api/backups/:id/restoreendpoint (super-admin only). Requiresconfirm: 'RESTORE'in the body. Runspg_restore --clean --if-existsagainst the live database, then extracts attachments. The/backupspage gets a Restore action on each success row that prompts for the confirmation token viawindow.prompt. - Secondary off-server backup destination. New setting
BACKUP_SECONDARY_DIR(super-only). After every successful primary backup, the timestamped folder is copied (viafs.cp) to this path. Works with any mountable filesystem (NFS, CIFS, USB, S3 via s3fs/rclone-mount, etc.) — no new dependency on the box. Failure of the secondary copy is recorded as a warning on the backup row without failing the primary. - env.snapshot.json in every backup.
app_settingsrows are insidedb.dump(everything in the DB is captured). The newenv.snapshot.jsoncaptures process.env values for every key inKNOWN_SETTINGSat the moment of backup — covers the env-var fallback path so a full host wipe + restore can put.envback. - Savings percentage overrides in the Budget Wizard. Two new
inputs on the wizard form — "Savings % of income" and "Savings %
of leftover" — let the tenant admin override the global defaults
per wizard run. Empty fields fall back to the platform-wide
SAVINGS_INCOME_PCT/SAVINGS_LEFTOVER_PCT. Backend acceptssavingsIncomePctOverrideandsavingsLeftoverPctOverrideonPOST /api/budgets/wizard/previewand/commit.
Tests#
+7integration tests (backup-restore-env.test.ts): restore requires the literal confirm token; missing dump returns a clear error; tenant admin gets 403; non-success rows can't be restored;secondary_directorysurfaces on/api/backups/config; wizard honors per-run % overrides; out-of-range overrides fall back.- Total: 390 (server 384 + web 6).
Notes#
- The restore endpoint runs
pg_restorewhile the server is live. Open sessions stay alive but every tenant sees the snapshot's data on their next read. Restart-after-restore is recommended for a clean state — the success message says so. - Off-server backends beyond "secondary path" (native S3, SFTP) are
a follow-up —
cpto a mounted share covers the bulk of self-host setups.
[0.9.3] — 2026-05-23 — AI + EIA settings move to super-admin; tenant Settings page removed#
All app settings are now platform-level. Tenant admins have nothing
left to configure on /settings, so the page (and its sidebar entry)
disappear from their view entirely. Super admin keeps full access.
Changed#
KNOWN_SETTINGS—AI_PROVIDER,ANTHROPIC_API_KEY,ANTHROPIC_MODEL,OLLAMA_BASE_URL,OLLAMA_MODEL,EIA_API_KEY,SAVINGS_INCOME_PCT,SAVINGS_LEFTOVER_PCTall flip tosuperOnly: true. Combined with the 0.9.1 + 0.9.2 changes, EVERY known setting is now super-admin only./api/settings/ai-modelsgated to super admin (was open before for the tenant Settings page's model picker — but tenants don't see the page anymore)./settingsnav link removed from tenant sidebar. The route is also gone from the tenant<Routes>block. Hitting/settingsmanually as a tenant user lands on the dashboard (no router match).- Super admin keeps
/settingsin their sidebar and routes; they see every setting.
Rationale#
- One Anthropic API key for the box is consistent with how the AI pipelines were already written.
- EIA fuel prices are a fetcher used by every tenant; per-tenant configuration would split the rate-limited free quota and add configuration burden.
- Savings tuning is read by the budget wizard as a global default. Per-tenant overrides land in a future slice when the demand is clearer.
Tests#
+2integration tests inrbac.test.ts: tenant gets 403 on PUT/api/settings/AI_PROVIDERand GET/api/settings/ai-models.settings.test.ts,commute-routes.test.ts, and the existing rbac tests updated to assert the empty-tenant view and to thread a super-admin cookie through every AI/EIA-touching call.- The "tenant admin GET" assertion now expects an empty array, not a filtered list.
- Total: 383 (server 377 + web 6).
Notes#
- Per-tenant AI + EIA overrides remain on the table for a future
slice — the foundation is the same (
tenant_settingstable + a helper that prefers tenant-scoped values), just deferred until there's demand.
[0.9.2] — 2026-05-23 — Auth providers move to super-admin#
Auth provider configuration is a platform-operator concern — deciding which login methods exist (Google / Microsoft / GitHub / generic OIDC) isn't something a tenant admin should be able to do on their own shared instance.
Changed#
/api/auth-provider-configs/*(GET/POST/PATCH/DELETE) — gated to super admin. Tenant admins get 403 across the board./workspacedrops its Auth providers section entirely./systemOverview tab gains the Auth providers section (extracted intocomponents/AuthProvidersSection.tsxand rendered below Super admins).
Tests#
+2integration tests inmulti-tenant.test.ts: tenant admin gets 403 on POST and GET. The existing CRUD test now uses a super-admin cookie throughout.- DELETE-test gotcha: Fastify rejects
DELETErequests whencontent-type: application/jsonis set with no body — pass cookie only on DELETE. - Total: 381 (server 375 + web 6).
[0.9.1] — 2026-05-23 — Health, backups, SMTP, security keys: super-admin only#
Tightens the 0.9.0 RBAC boundary. Anything platform-level moves out of tenant-admin reach.
Changed#
/api/health/*(metrics, timeseries, live) — tenant-admin gets 403; super-admin gets the data./api/backups/*(list, config, run, prune, delete) — same./api/admin/smtp-test— same./api/admin/restart— same. Was loosely gated before; now explicitly super-admin only./api/settingsis filtered server-side by the requester's context:- Tenant admin sees AI provider keys, EIA, and Savings tuning.
- Super admin sees everything plus SMTP, BACKUP_*, APP_BASE_URL, SESSION_SECRET, ATTACHMENT_ENCRYPTION_KEY.
- PUT/DELETE on a super-only key returns 403 to a tenant admin even if they construct the URL by hand.
Removed (from tenant sidebar)#
- Health, Backups (super-admin sidebar already has them).
- Settings page now skips entire sections (SMTP, Security) when the server doesn't return any of their keys, so tenant admins see a trimmed Settings view focused on what they can actually change.
Notes#
- Capability-tagged settings via a new
superOnly: booleanfield onKNOWN_SETTINGS. Adding a new super-only setting is now one metadata field, not a route-by-route edit. - The
requireSuperAdmin(req, reply)guard moved toauth/rbac.tsso health, backups, settings, and system routes all share one implementation.
Tests#
+3integration tests inrbac.test.ts: tenant blocked from PUTting SMTP_HOST and BACKUP_ENABLED; GET /api/settings hides super-only keys.health-backups-reports.test.tsrewritten to assert tenant 403 + super-admin 200 paths via a newmakeSuperAdminCookie()helper.smtp.test.tsadds a 403 check and threads super-cookies through the verify-stage tests.settings.test.tsupdated to split tenant-visible / super-visible expectations and use the super cookie on SESSION_SECRET / ATTACHMENT_ENCRYPTION_KEY writes.- Total: 379 (server 373 + web 6).
[0.9.0] — 2026-05-23 — RBAC: super admins, tenant admin/spouse/child, audit log#
Role model overhaul. Three orthogonal concepts:
- Super admin — platform operator. Manages tenants, system
settings, audit log. Orthogonal to tenant membership: a super admin
never has a
membershipsrow, enforced by trigger. - Tenant role —
admin/spouse/child(replacesowner/admin/member/viewer). - Per-account ACL — children are scoped to admin-assigned accounts only.
Schema (migration 019)#
users.is_super_adminboolean. Two CHECK triggers enforce super-admin-has-no-memberships in both directions:- inserting a membership for a super-admin user fails
- flipping
is_super_admin=trueon a user with memberships fails
memberships.rolecollapsed:owner→adminadmin→adminmember→spouseviewer→child- new CHECK enforces the set
invitations.rolelikewise rewritten.account_user_access— per-tenant child ACL. PK(account_id, user_id); cascade-deletes when the account or user is removed.audit_log— append-only record of mutating actions. Fields:occurred_at,tenant_id(null for system),actor_user_id,actor_kind(super_admin/tenant_user/system/public),action(dotted namespace),target_kind,target_id,detailsjsonb. Three indexes for the common query shapes.
Permissions#
| Role | Read/write financials | Manage members | Manage providers | See settings |
|---|---|---|---|---|
| admin | ✅ | ✅ | ✅ | ✅ |
| spouse | ✅ | ❌ | ❌ | ❌ |
| child | scoped only | ❌ | ❌ | ❌ |
| super admin | ❌ (never) | n/a | n/a | system-only |
- Children see only accounts assigned via
account_user_access.GET /api/accountsfilters;GET /api/transactionsfilters list + count + ignores out-of-scopeaccountIdquery params. Other endpoints (budgets, bills, etc.) aren't filtered yet — children are admin-managed concepts; admins/spouses drive those views. Full enforcement everywhere is queued behind RLS (next slice).
First-user flow#
- Fresh install: first user via
/setupbecomes asuper_adminwith no tenant membership. The Setup page now reads "Create the platform operator". They land on/systemand create tenants from there, then invite tenant admins. - Upgrade from 0.8.x: the migration leaves existing owners as
tenant admins of their existing tenant. No super admin exists by
default — create one with
npm run create-super-admin --email … --password …. The CLI script reads from.envand runs an argon2id hash on the host.
Added#
/api/system/*endpoints (super-admin only):GET /api/system/tenants— counts only, never balancesPOST /api/system/tenantsPATCH /api/system/tenants/:id(rename)DELETE /api/system/tenants/:id(destructive — cascade-deletes tenant data)POST /api/system/tenants/:id/admin-invite— mints anadmin-role invitation for a new tenantGET /api/system/audit— paginated, filterable by tenant + actionGET /api/system/usersPOST /api/system/users/super— create another super admin
/api/tenants/:id/members/:userId/accountsGET + PUT — manage child-account assignments. Admins only.- Audit writes on:
super_admin.bootstrap,super_admin.login,super_admin.create,user.login,tenant.create,tenant.rename,tenant.delete,tenant.admin_invite. More routes will adoptrecordAudit()in follow-up slices. - Web:
/systemsuper-admin console with Overview (tenants, super admins) and Audit log tabs. Super-admin sessions see a different sidebar that hides the financial app. - Web: child-account assignment — admin clicks "Accounts" on a
child's row in
/workspace→ modal lists every tenant account with checkboxes.
Tests#
+10integration tests (rbac.test.ts): trigger enforcement (×2),/api/systemgating + 200 path (×3), child scoping on accounts (×2), child scoping on transactions, spouse blocked from invites, admin assigns child accounts.- All previous tests updated to the new role names.
- Total: 373 (server 367 + web 6).
Breaking#
- Membership role names changed. API consumers expecting
owner/member/viewerwill break — update toadmin/spouse/child. The migration rewrites existing rows in place. - The fresh-install
/setupflow now creates a super-admin, not a tenant admin. An existing installation upgrading from 0.8.x keeps its user astenant_admin.
Deferred (next slices)#
- RLS enforcement —
tenant_idcolumns are populated but no policies are on yet. Today a tenant user could in principle query another tenant's data by id (no UI surface lets you, but the primary keys are guessable). RLS turns this off platform-wide. - Child scoping on remaining endpoints — budgets, bills, attachments, splits, etc. Currently a child UI doesn't surface these; backend enforcement is the next defense.
- Audit writes on every mutating route — current coverage is high-value mutations only. Settings changes, member-role flips, and bulk imports will be added incrementally.
[0.8.1] — 2026-05-23 — SMTP for outbound communications#
GUI-managed SMTP plumbing with the first use case wired: invitation
emails. Future password resets, bill-due alerts, and backup-failure
notifications slot in as additional tryMail() callers.
Added#
- nodemailer dependency (server). One transport built on demand per send; no persistent connection pool — fine for self-hosted volume.
domain/mailer.ts—tryMail()sends one message, returns{ sent: false, reason }when SMTP is unconfigured so callers can decide between sending and surfacing a "configure SMTP" hint.verifyConnection()validates the transport for the Test button.renderInvitationEmail()builds the invitation HTML + text body.- New settings keys (all live, mailer reads on each send):
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASS(masked secret),SMTP_FROM,SMTP_SECURE(TLS-on-connect for port 465), andAPP_BASE_URL(operator-set base URL for email links — needed for headless sends where request headers aren't reliable). POST /api/admin/smtp-test— verifies the connection then sends a one-line test email. Owner-only. Reports the failure stage (verifyvssend) when it doesn't work.- Settings page gets an SMTP section with all six keys + an inline test-send panel that displays the result inline.
- Invitation create now sends the invite link via email when SMTP
is configured AND an
emailHintwas supplied. Best-effort: if the send fails, the invite row still exists, the InviteForm stays open with the failure reason, and the copy-link button on the row still works.
Tests#
+4integration tests (smtp.test.ts): test-send 400s without a recipient, test-send 400s with reason when SMTP unconfigured, invite create still 201s withemail.sent=falsereason, no-hint reports the no-hint reason.- Total: 363 (server 357 + web 6).
Notes#
- The provided test environment doesn't reach a real SMTP server; coverage of the success path will land alongside a future vi.mock-based suite or a CI-side fake SMTP fixture.
[0.8.0] — 2026-05-23 — Multi-tenant + multi-user foundation#
Headline shift: SmrtCash is no longer a single-user-per-instance app. This release lays the schema, abstractions, and UI for households / organizations to share one self-hosted deployment with role-gated access and pluggable authentication. RLS enforcement and SAML implementation follow in 0.8.x slices.
Added — schema (migrations 017 + 018)#
tenants— one row per household/org. URL-safe slug + display name.memberships— many-to-many users↔tenants with role (owner/admin/member/viewer).invitations— short-lived URL-safe tokens with role + optional email hint, created by owner/admin, accepted by anyone holding the link. Tokens expire after 14 days.user_identities— many-to-one identities↔user. One row per provider login (local,oidc:google,oidc:<slug>,saml:<slug>). Lets one user log in via password AND Google AND Microsoft.auth_provider_configs— runtime registry of configured OIDC / SAML providers. Settings UI writes here; the login page reads viaGET /api/auth/providers. Local is implicit and always available.- users gains
email+name(unique email); singleton convention retired.password_hashis now nullable for OIDC-only users. - sessions gains
active_tenant_idso the session middleware can carry tenant context. Set on login + invite-accept; mutable viaPOST /api/tenants/switch. tenant_idcolumns added (nullable, backfilled to the seededDefaulttenant) on every user-data table: accounts, transactions, attachments, categories, category_suggestions, import_batches, normalization_rules, transaction_splits, recurring_suggestions, budgets, savings_goals, bills, recurring_income, holdings, vehicles, commute_routes, route_vehicle_assignments, fuel_prices. NOT NULL + Row Level Security policies land in a follow-up migration after the query audit + test sweep.
Added — authentication abstraction#
AuthProviderinterface (auth/providers/types.ts) — every login method implementsbegin()+ (verify()for credential flows ORcompleteRedirect()for OIDC/SAML).- Local provider wraps the existing argon2id flow as one provider among many. Always enabled — it's the bootstrap path.
- Generic OIDC provider (
auth/providers/oidc.ts) — full Authorization-Code + PKCE flow built on Node 22'sfetchandcrypto. Reads the IdP discovery document, generates verifier + nonce + state, exchanges the code at the token endpoint, validatesiss+aud+nonce, decodes the ID token, and falls back to the userinfo endpoint when needed. Same code path serves the preset configs for Google / Microsoft / GitHub (their discovery URLs are hardcoded) and any spec-compliant generic OIDC IdP (Okta, Authentik, Keycloak, Azure AD, etc). - SAML provider is a stub — interface in place, returns a clear
"not implemented yet" from
begin(). A correct SP-initiated flow with XML-signature verification needs a vetted library and focused tests; queued for 0.8.x.
Added — routes#
GET /api/auth/providers— login page lists configured providersGET /api/auth/oidc/:slug/begin— kicks off an OIDC redirect with a short-lived signed state cookieGET /api/auth/oidc/:slug/callback— handles the IdP callbackPOST /api/auth/setup— now takes{ email, name?, password }and promotes the new user to owner of the Default tenantPOST /api/auth/login— takes{ email, password }(back-compat: email-less still works when exactly one user exists)GET /api/auth/me— current user + memberships + active_tenant_idGET /api/tenants,POST /api/tenants/switchGET /api/tenants/:id/members,DELETE /api/tenants/:id/members/:userIdGET/POST /api/tenants/:id/invitations,DELETE /api/tenants/:id/invitations/:invIdGET /api/invitations/:token(public),POST /api/invitations/:token/accept(public — mints session)GET/POST/PATCH/DELETE /api/auth-provider-configs(owner only)
Added — web UI#
- LoginPage — email + password fields, SSO buttons for every enabled OIDC provider.
- SetupPage — email + display name + password for the owner of the brand-new instance.
/invite/:token— public landing for invitation links; collects email + name + password, accepts the invite, mints the session, lands on the dashboard./workspace— new admin page with three sections:- Members — roster + role pill + Remove (owner only).
- Invitations — list pending + create form + Copy-link +
Revoke. The accept URL is
https://<host>/invite/<token>. - Auth providers — list configured OIDC/SAML providers; form to
add a preset (Google/Microsoft/GitHub) or a Generic OIDC config
with discovery URL + client id + client secret + redirect URI.
client_secretis masked on the list view. SAML rows surface but can't be enabled until the SAML implementation lands.
Migration notes#
npm run migrate --prefix serverapplies 017 + 018.- Existing single-user installs: the migration backfills your user as
owner of the seeded Default tenant. Your password keeps working. You
may want to set an email via the UI (
/workspace) once it's live. - Existing data rows now carry
tenant_id = <Default>. RLS isn't active yet — every authenticated request can still see all data in the instance. The data isolation guarantee arrives in the follow-up migration that turns on RLS and updates every query.
Tests#
+8integration tests (multi-tenant.test.ts): providers listed, tenants list, me-returns-memberships, invitation create+accept, double-accept rejected, member-remove, provider-config CRUD, duplicate-slug 409.- The seeded test user now carries a Default-tenant membership + email + identity row so existing private-route tests pass unchanged.
- Total: 359 (server 353 + web 6).
Deferred (next slices)#
- RLS enforcement — turn on Postgres Row Level Security on every
data table with
current_setting('app.tenant_id')predicates, and a request hook thatSET LOCALs the active tenant. Until this lands, app-layer scoping is the only thing keeping tenants apart, which is fine for households sharing an instance but not for SaaS isolation. - SAML 2.0 — actual SP-initiated flow with XML-signature verification
via a vetted library (
@node-saml/node-samlor similar). - Self-serve tenant creation + tenant switcher in the top bar — today every user lives inside the seeded Default tenant. The UI doesn't expose multi-tenant switching because the data model doesn't enforce it yet.
[0.7.9] — 2026-05-23 — Fuzzy filters, column show/hide rollout, heap fix, configurable refresh#
Three knobs the user asked for, plus a real bug fix that was making the Health page's Heap gauge alarmist.
Fixed#
- Heap gauge denominator — the gauge was reading
heap_used / heap_total * 100. V8 growsheap_totalonly on demand, so that ratio sits at 70–90% in steady-state regardless of actual headroom, which made the gauge look critical when it wasn't. The server now exposesheap_size_limit_bytes(V8's hard ceiling, fromv8.getHeapStatistics()), and the Heap gauge uses that as the denominator. Typical values drop into single digits, and a number approaching 80% genuinely means OOM is near.
Added#
- Configurable refresh interval on
/health— header dropdown with 1s / 5s / 10s / 30s / 60s / Off. Choice persists to localStorage (health:refresh_ms) so reloading the page keeps your cadence. Replaces the Pause button (Off serves that role now). - Fuzzy-search mode in
FilterableTable— the per-column string filter now also accepts subsequence matches: typinggthfinds "Groceries Total Health" (substring still works first). A new global filter input in the toolbar searches every visible cell with subsequence semantics — the closest thing to "fuzzy as you type" in the spirit of a command palette. Numeric/date operator syntax (>100,2026-01..2026-06) is unchanged. FilterableTablerowActions slot — table accepts a per-row React fragment renderer so pages with per-row buttons (Bills, Subscriptions, Backups) keep their existing actions while gaining filters + column show/hide.- Filter / column show-hide rolled out:
BillsPage— both tables (bills + recurring income).SubscriptionsPage— active subscriptions table.BackupsPage— history table.ReportsPagealready had it (since 0.7.7).TransactionTableis unchanged in this slice; its inline category dropdown + selection + attachment/split actions don't slot intoFilterableTablecleanly, and the page already has a server-side search. A targeted column chooser is queued.
Tests#
- The health snapshot test now asserts
heap_size_limit_bytes > heap_used_bytesso a regression to the old denominator is caught. - Total: 351 (server 345 + web 6).
[0.7.8] — 2026-05-23 — Live gauges + line charts on the Health page#
Operator metrics the way a systems engineer wants them: a rolling-buffer sampler runs in-process every 5 seconds, capturing CPU, memory, event- loop delay, request rate, error rate, and DB query rate + latency. The Health page renders the current values as colored gauges and the last 5 minutes as Recharts line charts.
Added#
MetricsRecordersingleton (domain/metrics-recorder.ts) — rolling buffer of 720 samples (1 hour at 5s resolution). Each sample captures:cpu_pct— process CPU % over the window (fromprocess.cpuUsage()deltas; can exceed 100 on multi-core when busy)rss_bytes,heap_used_bytes,heap_total_bytes— Node memoryevent_loop_mean_msandevent_loop_p99_ms— fromperf_hooks.monitorEventLoopDelay(), reset per sampleevent_loop_util— 0..1, 1 = saturatedreq_count/req_rate— HTTP responses served in the windowerr_count/err_rate— share that returned 5xxdb_query_count/db_query_rate/db_query_mean_ms/db_query_max_ms— DB query throughput + latency
- Request instrumentation —
onResponsehook on the Fastify instance bumps the request + error counters. - DB-query instrumentation — the
query()helper indb/pool.tswraps every call with timing reported to the recorder. Directpool.querycalls aren't instrumented; that was a deliberate tradeoff after a fragile attempt to override the multi-overload method on the pg.Pool class itself. GET /api/health/timeseries[?window=N]— returns the rolling buffer (last N seconds or the full hour). Each point matches theMetricSampleshape documented above.GET /api/health/live— most recent sample alone (for gauge-only widgets that don't need history).- Health page charts + gauges:
- Six gauges with color-toned arcs (green/yellow/red): CPU %, Heap %, event-loop p99 ms, requests/sec, DB qps, error rate %. Thresholds chosen from operational experience — CPU 70/90, heap 70/90, event-loop 50/100 ms, latency 50/200 ms, error rate 1%/5%.
- Six line charts at the 5-minute resolution: CPU %, Memory (RSS + heap), Requests/sec, DB query rate + mean latency on a dual-axis chart, event-loop mean + p99, Errors/sec.
- Existing static info cards (app / db / storage) move below the live panels.
Tests#
+2integration tests (health-backups-reports.test.ts): timeseries envelope shape + per-point typing; live snapshot contract.- Total: 351 (server 345 + web 6).
[0.7.7] — 2026-05-23 — Column filters + show/hide on report tables#
Reports get two power-user knobs: hide columns you don't care about,
and filter rows by per-column expressions (>100, 2026-01..2026-06,
substring). Column visibility persists per-report in localStorage so
choices survive reloads.
Added#
FilterableTablecomponent — reusable. Wraps a<table>with:- A Columns dropdown listing every column with a checkbox; the
minimum-one-visible rule prevents the table from disappearing.
Persisted to
localStorage['tableviz:<storageKey>']. - A Filter toggle that reveals a per-column text input below
the header. Syntax:
- string: case-insensitive substring (
groceries). - cents: dollars-typed operators (
>100,<50,100..500). - number / pct: same operators on raw numbers.
- date:
>2026-01-01,2026-01..2026-06, or substring.
- string: case-insensitive substring (
- An active-filter count badge + Clear-all-filters link.
- Emits the filtered + visible-projected dataset upward so CSV export honors the current view.
- A Columns dropdown listing every column with a checkbox; the
minimum-one-visible rule prevents the table from disappearing.
Persisted to
- Reports page swaps its inline table for
FilterableTable. CSV export now respects the visible/filtered projection — the button's tooltip flips between "exports the full result" and "exports only the filtered + visible columns" so the user knows what they'll get.
Notes#
- The component is decoupled from reports. Other table-heavy pages
(Transactions, Bills, Subscriptions) can adopt it later by passing
their own
{key,label,type}column array and aformatCellfn.
[0.7.6] — 2026-05-23 — Health, backups, reports#
Three operator-facing tools land together: a live health dashboard, a GUI-managed backup pipeline with a schedule, and a canned-reports catalog (the foundation for the AI natural-language reports planned for a later slice).
Added#
- Migration 016 — new
backupstable tracking every snapshot (kind, status, size, on-disk path, optional error). /healthpage — auto-refreshing dashboard with three cards:- Application — version, Node, uptime, PID, RSS / heap, AI provider + model.
- Database — connection ok/down, ping latency, db size, pool counts (total/idle/waiting), last applied migration, per-table row counts (accounts, transactions, categories, bills, budgets, goals, attachments, backups).
- Storage — attachments dir + backups dir size and file count, plus the resolved on-disk paths.
GET /api/health/metricsreturns the JSON snapshot. Polls every 5s; Pause / Refresh buttons in the header.
/backupspage — schedule editor + manual-run button + history.- Schedule form writes
BACKUP_ENABLED,BACKUP_FREQUENCY(hourly / daily / weekly / monthly),BACKUP_TIME(HH:MM),BACKUP_RETENTION_DAYS, andBACKUP_DIRintoapp_settings. Live — the in-process scheduler picks up changes on its next tick. - Run backup now triggers a synchronous snapshot via the same
pg_dump+tar pipeline as
scripts/backup.mjs(custom-format dump of the database, gzipped tar of the attachments directory) into a timestamped folder. Restorable withscripts/restore.mjsorpg_restoredirectly. - Prune old removes anything past the retention window.
- History table shows kind, status, sizes, path, and a per-row delete button.
- Schedule form writes
/reportspage — sidebar list + parameter form + result table- CSV export. Initial canned set (6 reports):
- Spending by category (date range)
- Top merchants by spend (date range + top-N)
- Monthly income vs expense (months back)
- Active subscriptions roll-up (per-cycle + annualized)
- Largest transactions (date range + top-N)
- Net worth by month (months back)
GET /api/reportslists definitions,POST /api/reports/:id/runexecutes one with body-keyed parameters.
- In-process backup scheduler — ticks every 60 s; reads
BACKUP_*settings every tick, so a config change takes effect on the next minute without a restart. Last-run gate usesMAX(backups.finished_at)so a process restart never re-fires a backup that already ran today.
Tests#
+12integration tests (health-backups-reports.test.ts): health snapshot shape + table counts, backups config GET/PUT, history filter (deleted excluded), delete idempotency + 400 path, 6 reports listed, spending-by-category with date filter, subscription-costs annualization.- Total: 349 (server 343 + web 6).
Notes#
- Backups need
pg_dumpon PATH. The runtime Docker image already bundlespostgresql17-client, so the container is good to go. - Restore is a manual step —
scripts/restore.mjs <path>orpg_restoreagainstdb.dump. A GUI restore is on the docket but intentionally not in this slice (too destructive without dry-run + confirmation flow design).
[0.7.5] — 2026-05-23 — AI subscription scan#
A "Find with AI" button on the Subscriptions page surfaces candidate subscriptions discovered in the transaction history. The rules-based recurring detector finds the candidates; Claude (when configured) then filters them down to actual cancelable/alterable services and cleans up their display names.
Added#
POST /api/subscriptions/scan— runs the rules-based detector on the transaction history, inserts new bill-kind candidates intorecurring_suggestions(skipping any already on file), and — whenAI_PROVIDER=claude— asks Claude to classify each pending unrefined candidate as subscription / non-subscription. Subscriptions get a polisheddisplay_nameandai_refined=true. Non-subscriptions (utilities, rent, loans, insurance) get auto-rejected so they don't clutter the queue. Returns{ai_used, scanned, inserted, kept, rejected}.GET /api/subscriptions/candidates— pending bill-kind suggestions, ordered AI-refined first, then by confidence.domain/subscription-ai.ts— Claude classifier. Single Anthropic-SDK call per scan (batched, prompt-cached), Haiku-tier model by default. Falls back to the rules-only path whenAI_PROVIDERisn'tclaude(Ollama support is not wired today).- Subscriptions page UI — new Find with AI button in the page
header. A "Candidates" section appears above the action queue,
showing each pending candidate as a card with Confirm / Snooze /
Not-a-subscription buttons. Confirm reuses the existing
/api/recurring/suggestions/:id/confirmflow, so the new bill immediately shows up in the active list below.
Tests#
+4integration tests (subscriptions-scan.test.ts): rules-only fallback ignores income-kind, candidates GET filters out income + rejected, scan idempotency.- Total: 336 (server 330 + web 6).
Notes#
- The AI step is opt-in — without
AI_PROVIDER=claudethe scan still works as a one-click rules-based detector limited to bill-kind outflows. Cost per scan with Haiku is ~$0.001–0.01.
[0.7.4] — 2026-05-23 — Uncategorized hub + subscription action queue#
Two adjacent gaps closed: a dedicated landing pad for transactions that don't yet have a category (with bulk fix-ups), and a review queue for recurring bills so the user can flag the ones they're not actively using and pick an action — cancel, downgrade ("alter"), or keep with a note.
Added#
/uncategorizedpage — lists every transaction wherecategory_id IS NULLAND no splits exist. The inline category dropdown lives on each row; once a row is categorized (or bulk-edited) it disappears from the list.uncategorized=truequery param onGET /api/transactions, reused by the new page. Split-only transactions count as categorized.- Bulk-delete transactions via
POST /api/transactions/bulk-delete- a destructive Delete button on the BulkActionBar (gated behind a confirm). FK cascade handles splits and attachments cleanly.
bills.review_statusworkflow (migration 015). Statuses:active(default),review,cancel,alter,keep. Bills also gainreview_note(free text) andlast_reviewed_at.PATCH /api/bills/:id/review— set the status, optionally attach a note (passnote: nullto clear). Every change bumpslast_reviewed_at.GET /api/bills?reviewStatus=…filter. The specialreviewStatus=queuereturns the user's action queue (status inreview/cancel/alter, ordered by most-recent review)./subscriptionspage — top section is the action queue, bottom section is the active list with a per-cycle and approximate monthly cost (cross-cadence comparison). Each queued card has Cancel / Alter / Keep buttons, a free-text note ("Downgrade to ad-tier"), and a Clear-flag link.
Tests#
+9integration tests (uncategorized-and-subscriptions.test.ts): uncategorized filter (3), bulk delete (2), review-status PATCH + queue filter (4).- Total: 332 (server 326 + web 6).
[0.7.3] — 2026-05-23 — Commute routes, Misc + Savings, AI model picker#
Routes replace the old standalone toll list with a richer model: each route has a distance and an optional per-crossing toll, and every vehicle says how many times per week it takes that route. Fuel and toll math now both flow from the same source. The budget wizard gains two more editable rows (Misc with a memo, Savings with four suggestion chips), and the Settings page picks AI models from a provider-aware dropdown with token/cost hints.
Added#
commute_routes+route_vehicle_assignments(migration 014). A route has a name, distance, an optional toll/crossing, and N vehicle assignments saying how many times that vehicle takes it per week. The legacytoll_routestable is dropped after a one-shot migration of existing rows (distance=0, toll = the old weekly_estimate)./routespage — replaces/tolls. Per-route card shows distance, toll/crossing, total weekly crossings, computed weekly toll. Inline edit of assignments (toggle a vehicle, set its crossings/week).- Route-driven fuel + toll math in the wizard. For each active
vehicle, derived weekly miles = SUM(route.distance ×
this-vehicle's crossings). Vehicles with no route assignments fall
back to the stored
weekly_avg_miles. Tolls = SUM(route.toll × total crossings) across active routes. - Misc editable in the wizard — a 4th row per period for
known-coming one-offs (oil change, birthday gift). Each Misc entry
has its own amount AND a memo, stored via the new
budgets.notecolumn. Commit creates one row per period under the new "Miscellaneous" category. - Savings editable + 4 suggestion chips — per-period preview shows
four numbers: Goal-required (from active
savings_goalswith target_date; required-per-period = (target − current) / periods-to-target), % of income, % of leftover, and the max. Click a chip to populate the editable Savings amount. Commit creates a Savings budget row per period when > 0. Configurable via two new settings:SAVINGS_INCOME_PCT(default- and
SAVINGS_LEFTOVER_PCT(default 50).
- and
- AI model picker — new
GET /api/settings/ai-models?provider=X. Claude returns a hardcoded list with per-model cost hints (claude-haiku-4-5recommended for SmrtCash, sonnet for edge cases, opus marked overkill). Ollama queries the configured base URL's/api/tagsand falls back to a curated list when unreachable. The Settings page model field renders a dropdown with the recommended model starred + a "Custom" escape hatch. - EIA "create key" link under the EIA_API_KEY field →
https://www.eia.gov/opendata/register.php. - "Miscellaneous" and "Savings" leaf categories seeded into the default taxonomy.
Changed#
- The old
/api/toll-routesendpoints and thetollRoutesclient-side functions are gone —commuteRouteRoutesreplaces them. - The wizard's
flexCentsformula now subtracts Misc and Savings too.
Tests#
- +13 server tests (304 → 317): commute-routes CRUD (5), route-driven wizard math + Misc/Savings flow (6), AI models endpoint (3). Existing wizard test updated to use commute_routes instead of toll_routes for the toll-sum assertion. Total automated coverage: 330 tests (server 317 + web 6 + Playwright 7).
Migration notes#
- Upgrading from 0.7.2:
npm run migrate --prefix serverapplies migration 014 (dropstoll_routes, addscommute_routes+route_vehicle_assignments+budgets.note+ seeds the two new categories). - Existing toll-route entries migrate to commute_routes with distance=0. You'll want to revisit them to set the real distance and add vehicle assignments — otherwise their tolls won't fire (no crossings × any non-null toll = 0).
[0.7.2] — 2026-05-23 — Settings page (GUI-managed runtime config)#
Stop SSHing into the box to edit .env and bounce the container. Every
runtime-tweakable config value now has a GUI control on the new
Settings page; the dangerous ones (session secret, attachment
encryption key) are gated behind type-to-confirm dialogs.
Added#
app_settingstable (migration 013). One row per(key, value)override. A non-null DB value wins overprocess.envat read time; clearing the row falls back to env. Plain-text storage — same trust boundary as.envon the same host.domain/settings.ts— list ofKNOWN_SETTINGS, each withis_secret+restart_requiredmetadata.applyBootSettings()loads DB overrides into the in-memoryconfigobject before Fastify registers the cookie plugin / parses the attachment key.applyToConfig()hot-mutates the same object on live updates so the next AI / fuel-price call sees the new value.- Settings API:
GET /api/settings— every known setting with masked secret values (••••XXXX, last 4 chars only), plusconfigured_in_gui/env_fallback_presentflags so the UI can show provenance.PUT /api/settings/:key— value goes in clear, comes back masked. Per-key validation (provider allow-list, encryption-key shape, session secret length). Response carriesrestart_required: trueforSESSION_SECRETandATTACHMENT_ENCRYPTION_KEY.DELETE /api/settings/:key— clears the override, re-reads env into in-memory config.
POST /api/admin/restart— flushes the response, thenprocess.exit(0). Docker'srestart: unless-stoppedbrings the container back up; in dev the operator restarts the process./settingspage — three cards:- AI Provider — provider dropdown, Anthropic key/model, Ollama base URL/model.
- External APIs — EIA API key.
- Security — restart required — SESSION_SECRET
(
type 'rotate' to confirm) and ATTACHMENT_ENCRYPTION_KEY (type 'DESTROY EXISTING' to confirm). When a restart-required save lands, a banner + a top-right Restart server button appear. The button POSTs to/api/admin/restartand refreshes the page after 2 seconds.
Changed#
- Boot order —
buildApp()callsapplyBootSettings()before registering@fastify/cookieso a GUI-setSESSION_SECRETwins over.envat startup. Same for the attachment encryption key.
Tests#
- +11 server tests (293 → 304): list, masking, non-secret passthrough, AI_PROVIDER allow-list, hot mutation, restart_required surfaces correctly, unknown-key rejection, empty-value rejection, SESSION_SECRET length validation, ATTACHMENT_ENCRYPTION_KEY shape validation (hex + base64 + invalid), DELETE-reverts-to-env.
- Total automated coverage: 317 tests (server 304 + web 6 + Playwright 7).
Migration notes#
- Upgrading from 0.7.1:
npm run migrate --prefix serverapplies migration 013 (just theapp_settingstable). - Your existing
.envcontinues to work — DB rows are additive overrides. You can ignore the Settings page if you prefer the.envworkflow. - The restart endpoint requires a supervisor (
docker composedoes this by default). Without one, calling it stops the server until you restart it manually.
[0.7.1] — 2026-05-22 — Phase 7.1: AutoMagic budget wizard + vehicles + toll routes#
A coherent "set up the next N budget periods in one click" flow. The wizard projects existing bills + income into each future period and pre-fills three editable categories (Groceries, Fuel, Tolls) using historical data, fleet info, and active toll routes. Commit writes real budget rows, including one per individual bill instance.
Added#
- Vehicles (
/vehiclespage,/api/vehiclesCRUD). Each vehicle recordsfuel_type(regular/midgrade/premium/diesel/electric),weekly_avg_miles, and eithermpg(ICE) orkwh_per_mile+electricity_rate_cents_per_kwh(EV). A DB CHECK keeps the type-specific fields coherent. - Toll routes (
/tolls,/api/toll-routes). Named recurring toll outlays, each with a weekly $ estimate and active flag. The wizard sums every active route into one Tolls number per period. - Fuel prices (
/api/fuel-prices):- Cached per grade in
fuel_prices, sourceeiaormanual. POST /api/fuel-prices/refreshpulls latest weekly US averages fromapi.eia.govwhenEIA_API_KEYis configured. Manual overrides are preserved (the user's choice wins).- The Vehicles page surfaces the current values with inline "Set manual" fields + a "Refresh from EIA" button.
- Cached per grade in
- AutoMagic budget wizard (
/api/budgets/wizard/previewand/commit). Inputs: period type, anchor date, count (1–24). For each future period the preview computes:- Income instances (from
recurring_income, projected by frequency) - Bill instances (from
bills, projected; one row per instance) - Groceries default = median of last 8 weeks of Groceries-category spend, scaled to the period length
- Fuel =
Σ vehicles (weekly_miles / mpg × $/gal)for ICE +Σ EVs (weekly_miles × kWh/mi × $/kWh), scaled - Tolls = sum of active toll routes' weekly estimates, scaled
- Implicit flex = income − bills − the three (shown, not stored)
- Income instances (from
- Per-period inline editing. Groceries / Fuel / Tolls each have an amount input per period — overrides flow back into the preview math.
- Commit semantics. Writes per-period budget rows for the three
editable categories plus one bill-linked budget row per bill
instance falling in that period.
budgets.bill_id(new column, migration 012) links the row to its source bill. Existing rows are never overwritten — skip-duplicates is the rule; the response reports created/skipped counts. - Bill-linked budget actuals.
/api/budgets/actualnow includesbill_id,bill_name, andbill_next_due_date. For bill-linked rows,actual_centsflips to the budgeted amount the moment the bill is marked paid (itsnext_due_dateadvances past the row's period end); otherwise 0.
Changed#
BUDGET_COLUMNSextended and every/api/budgetsquery joinsbillsso the response carries bill-linked metadata.- Sidebar adds Vehicles and Tolls entries.
EIA_API_KEYenv var documented in.env.example(added below).
Tests#
- +6 server tests (287 → 293) covering the wizard preview math (groceries median, fuel math from vehicles + price cache, toll route sum) plus the commit path (creates 3 editable rows + 1 per bill, skip-duplicates on re-run).
- Total automated coverage: 306 tests (server 293 + web 6 + Playwright 7).
Migration notes#
- Upgrading from 0.7.0:
npm run migrate --prefix serverapplies migration 012 (vehicles + toll_routes + fuel_prices tables +budgets.bill_id). - Set
EIA_API_KEYin.env(free key from https://www.eia.gov/opendata/register.php) to enable auto-refresh of fuel prices. Without it, manual entry covers the same use case.
[0.7.0] — 2026-05-22 — Phase 7.0: Investment holdings + manual assets & liabilities#
Phase 7 is being released in three slices. 7.0 lands the wealth- tracking core — investments with cost basis and mark-to-market, plus manual asset/liability accounts for things SmrtCash can't see (houses, cars, mortgages, loans). The dashboard's net-worth chart now reflects your complete picture, not just the bank-import slice. 7.1 (multi-currency) and 7.2 (retirement projections) follow.
Added#
- Investment holdings. New
holdingstable (migration 011) with(account_id, symbol, name, quantity NUMERIC(18,6), cost_basis_cents, last_price_cents, last_price_date). Six decimals onquantitysupports fractional shares and crypto. Holdings are entered manually in 0.7.0; auto-price-fetching is a future hook. - Holdings CRUD at
/api/holdings(GET ?accountId=,POST,PATCH,DELETE).POSTenforces that the parent account is of typeinvestment. List responses include the derivedmarket_value_cents(quantity × last_price_cents) andunrealized_gain_cents(market value − cost basis). - Investment account balance now equals
opening_balance_cents + sum(transactions on/after opening date) + sum(holdings market value). A newholdings_value_centsfield is exposed separately so the UI can split "cash side" from "equity side" if desired. - Manual asset / liability accounts. Two new account types —
manual_asset(house, vehicle, art) andmanual_liability(mortgage, auto loan, student loan). These accounts have no transactions; their value lives inopening_balance_centsand the user adjusts it periodically.- Convention: liabilities are stored as negative balances so
a single
SUM()across all accounts yields net worth. The web form accepts "Amount owed" as a positive number and negates on save.
- Convention: liabilities are stored as negative balances so
a single
- Holdings panel on the Account Detail page (investment accounts only). Per-row Update price (mark-to-market) and Delete, plus totals: market value, cost basis, unrealized gain.
- Account form learns the two new types; the opening-balance form on the detail page changes its label and copy for manual A&L ("Amount owed" for liabilities, "Current value" for assets).
- Net worth over time at
/api/insights/net-worth-over-timenow includes holdings (quantity × last_price) and manual A&L (opening_balance_cents) alongside cash accounts. The dashboard chart picks up the wider view automatically.
Tests#
- +7 server tests (280 → 287): holdings CRUD happy path, non-investment-account rejection, mark-to-market, quantity > 0, investment-balance = cash + market-value, manual A&L creation, net-worth-over-time math with the full mix.
- Total automated coverage: 300 tests (server 287 + web 6 + Playwright 7).
Migration notes#
- Upgrading from 0.6.2:
npm run migrate --prefix serverapplies migration 011 (holdings table + expanded account-type CHECK). - No backfill needed — existing accounts keep their balance math; only new investment accounts pick up the holdings-aware total.
What's still coming in Phase 7#
- 0.7.1 — multi-currency. Each account already has a
currencycolumn; the queries will gain conversion to a configurable base currency, with anexchange_ratestable. - 0.7.2 — retirement projections. Compound-growth math against contributions and an assumed return, surfaced as a scenarios page.
[0.6.2] — 2026-05-22 — Phase 6.2: Bulk edits, transaction splits, learned normalization rules#
Three intertwined features that together make manual cleanup massively faster: edit dozens of transactions at once, turn each edit into a rule the system applies forever, and break a single transaction into per-category slices (a $100 Costco run that's $60 groceries + $40 clothing now shows up correctly on the dashboard).
Added#
- Bulk transaction edits. New
PATCH /api/transactions/bulkaccepts anidsarray plusupdates: { categoryId?, merchant? }and applies uniformly. Touched rows flip tonormalization_status='manual'. Driven from a new bulk-action toolbar that appears on/transactionswhenever rows are selected via the new per-row checkboxes (and the "select all on page" header checkbox). - Learned normalization rules.
normalization_rulestable (migration 009):(pattern, normalized_merchant, category_id)with a case-insensitive unique index. The bulk toolbar offers a "Save as rule" checkbox so a one-time bulk rename can be captured as a permanent rule. Endpoints:GET / POST / PATCH / DELETE /api/normalization-rulesPOST /api/normalization-rules/preview { pattern }— counts matches without writing (used by the future "Apply to similar?" UI).POST /api/normalization-rules/apply { ruleIds?, includeManual? }— runs every rule over non-manual transactions; rows touched flip tonormalization_status='normalized'. Manual edits stay manual unlessincludeManual: true.
- Transaction splits. New
transaction_splitstable — one row per category slice.PUT /api/transactions/:id/splitsreplaces all splits in a single call, enforcingsum(amount_cents) = transaction.amount_cents. Empty array clears splits entirely. Surfaced via a ✂ Split action on every transaction row that opens a modal: add lines(category, amount, memo), the running total + remaining shows live, save is disabled until it balances. transaction_category_linesview (migration 010) — single source of truth that expands split transactions into per-category lines. Used by/api/insights/spending-by-categoryand/api/budgets/actualso split transactions now contribute their per-category slice instead of dumping into the transaction-level category. Transactions without splits still report under their owncategory_id.- Bulk recurring suggestion actions.
POST /api/recurring/suggestions/bulkwithaction: 'confirm' | 'reject' | 'snooze'. The Suggestions panel gets a "Select all" checkbox + per-row checkboxes + an action bar with Confirm selected / Snooze selected / Reject selected. Confirm uses each suggestion's detector defaults (name + cadence); for fine-tuning use the single-suggestion confirm modal.
Changed#
TransactionTablelearned aselectionprop and anonOpenSplitscallback. Existing callers (AccountDetailPage) keep their previous behavior — selection is only rendered on the Transactions page.
Tests#
- +14 server tests (266 → 280): bulk PATCH happy paths + validation, rule preview / apply / manual-skip / includeManual / duplicate-pattern / no-op-refusal, splits sum-mismatch + empty-clears + insights-respect-splits, bulk recurring reject + confirm-with-samples.
- Total automated coverage: 293 tests (server 280 + web 6 + Playwright 7).
Deferred (per the original ask)#
- Receipt-line auto-split — extending OCR to return line items + proposed categories. Worth a dedicated phase because the prompt and UX surface (let user accept / edit the proposed split) are meaningful in their own right.
Migration notes#
- Upgrading from 0.6.1:
npm run migrate --prefix serverapplies migrations 009 (rules + splits tables) and 010 (the view).
[0.6.1] — 2026-05-22 — Phase 6.1: Recurring detection + flexible budget periods#
Completes the deferral noted in the 0.6.0 changelog. Auto-detect recurring bills and income, then verify each with a single click, plus budgets now support weekly / biweekly / semi-monthly / monthly / custom-range cadences.
Added#
- Recurring detection (
POST /api/recurring/detect). Rules-based pass that buckets transactions by (normalized merchant, sign), finds groups of ≥ 3 occurrences, measures the mean interval and variance, and classifies the cadence (weekly/biweekly/semimonthly/monthly/yearly/unknown). Confidence is a 0..1 score combining cadence-fit, variance-tightness, and sample count. Amount is the median of the group, so a single outlier doesn't skew the suggestion. Transfers between own accounts are excluded. recurring_suggestionstable (migration 007) — pending / confirmed / rejected / snoozed. A unique partial index on(kind, lower(normalized_key))for non-rejected rows means re-running the detector doesn't spam duplicates; rejected entries stay rejected so already-dismissed patterns never come back.- Verification flow — new "Suggested recurring items" panel on
/billslists every pending suggestion with name, amount, detected cadence, and confidence. Three actions:- Confirm opens a modal pre-filled with the detector's guess; you
can override the name and the frequency, then click Confirm to
create the matching
billorrecurring_incomerow. - Snooze — keeps the suggestion eligible for confirmation later but hides it from the pending list.
- Not recurring — rejects it; the key is remembered so the detector won't surface it again.
- Confirm opens a modal pre-filled with the detector's guess; you
can override the name and the frequency, then click Confirm to
create the matching
- Flexible budget periods.
budgets.period_typeandbudgets.period_end(migration 007) — five cadences:weekly/biweekly/semimonthly/monthly/custom. The add-budget form lets you pick one; the/budgetspage renders a pill on each row showing its cadence and the active start → end range. /api/budgets/actual?asOf=YYYY-MM-DD— for any given date, returns every budget with its current rolling [start, end) window and the actual spending against it. The legacy?month=form still works and now only matches monthly budgets.PATCH /api/budgets/:id— edit the amount without deleting and recreating.
Changed#
- The monthly upsert path on
POST /api/budgetsnow returns 200 on update (was 201). Insertion still returns 201. budgets.period_monthno longer requires day=1 — migration 008 drops the original CHECK so any anchor date is accepted, which the non-monthly cadences need.
Fixed#
- Dockerfile healthcheck.
wgetwasn't innode:22-alpine, so theappcontainer always showedunhealthy. Addedwgetto the apk install line.
Tests#
- +16 server tests (250 → 266):
- 9 unit tests for the detector (monthly / biweekly / weekly cadences,
erratic spacing classified as
unknown, median amount, sign separation, < 3 occurrences ignored, raw-description fallback, confidence ordering). - 7 integration tests for the API (detect + list, dedup on re-run, confirm-creates-bill, confirm-creates-income, reject prevents re-detection, snooze keeps confirmable, transfers excluded).
- Existing budgets tests updated for the new upsert status code + relaxed anchor-date validation.
- 9 unit tests for the detector (monthly / biweekly / weekly cadences,
erratic spacing classified as
- Total automated coverage: 279 tests (server 266 + web 6 + Playwright 7).
Migration notes#
- Upgrading from 0.6.0:
npm run migrate --prefix serverapplies migrations 007 (recurring_suggestions+ budget period columns) and 008 (drops the day-of-month CHECK onbudgets.period_month). - The detector is on-demand only — there's a "Detect recurring"
button on
/bills. It does not run automatically on imports.
[0.6.0] — 2026-05-22 — Phase 6: Budgeting & Cash Flow#
Five tightly-related features in one release: flex budgets, monthly budget-vs-actual, savings goals, bill reminders, and a 90-day cash-flow forecast on the dashboard.
Added#
- Flex budgeting. New
budgetstable (migration 006) with one row per(period_month, category_id)—category_id IS NULLis the flex-pool catch-all.GET /api/budgets?month=YYYY-MM-01lists a month's budgets.POST /api/budgetsupserts on the(month, category)key so the same call creates new rows or updates existing ones.POST /api/budgets/copy { fromMonth, toMonth }clones rows month to month, skipping any that already exist.DELETE /api/budgets/:id.
- Budget vs actual.
GET /api/budgets/actual?month=YYYY-MM-01returns per-category budgeted vs actual spend. The flex-pool row's actual is computed as spend in categories that don't have an explicit budget that month (plus uncategorized rows). Transfers are excluded from both sides. - Savings goals.
savings_goalstable with name, target, current, optional target date. Server-computedprogressiscurrent / targetcapped at 1. Full CRUD via/api/goals. - Bill reminders.
billstable (name, amount, frequency, next due, optional category + account, active flag). CRUD via/api/bills;POST /api/bills/:id/mark-paidadvancesnext_due_dateby the bill's frequency (monthly / weekly / biweekly / yearly) or setsactive=falsefor one-time bills. - Upcoming bills view.
GET /api/bills/upcoming?days=30returns active bills whosenext_due_dateis within the window. Shown as a panel on the dashboard. - Recurring income as a sibling concept —
recurring_incometable/api/recurring-incomeCRUD. Kept separate from bills so the cash-flow projection doesn't have to inspect signs everywhere.
- Cash-flow forecast.
GET /api/cash-flow?days=90walks the current net worth forward through every projected bill / income event in the window, returning a per-day balance series. Inactive bills are ignored. Surfaced as a line chart on the dashboard.
Web#
- New Budgets page (
/budgets): month picker, total-budgeted / total-spent / remaining header, per-row progress bars (red when over budget), flex-pool row, add-budget form, and a one-click "Copy from previous month" affordance. - New Goals page (
/goals): card grid with progress bars, create / edit / delete modal, target-date countdown when set. - New Bills page (
/bills): tables for bills and recurring income, add / mark-paid / delete actions. - Dashboard gains two new tiles: "Upcoming bills (next 30 days)" list and the 90-day cash-flow forecast line chart with the start → end balance summary.
Tests#
- +20 server tests (230 → 250): 8 budget tests (CRUD, upsert, flex-pool actuals, copy-from-previous-month, transfer exclusion), 5 goal tests (CRUD, progress cap, validation), 7 bills + cash-flow tests (mark-paid advances date, one-time deactivates, upcoming window, recurring income CRUD, projection math, inactive-bill exclusion).
- Total automated coverage: 263 tests (server 250 + web 6 + e2e 7).
Fixed#
SESSION_SECRETempty-string handling. When the host.envhad noSESSION_SECRET, docker-compose interpolated it to""and the config's??fallback (catches only null/undefined) let the empty string through to@fastify/cookie, which crashed on signing. The fallback now uses||so an empty interpolation degrades the same as unset — an ephemeral per-process secret is generated and the server boots. Surfaced during Phase 6 dogfooding.
Migration notes#
- Upgrading from 0.5.0:
npm run migrate --prefix serverapplies migration 006 (budgets,savings_goals,bills,recurring_income- indexes).
[0.5.0] — 2026-05-22 — Phase 5: Dockerization, Auth & Hardening#
Single-user authentication, encryption-at-rest for attachments, a
single-container production image, and a backup tool — turns the dev
stack into something safe to actually deploy. Closes KI-03 (no auth)
and KI-04 (migrations not in dist/).
Added#
- Single-user authentication.
- Argon2id password hashing (
argon2v0.44, OWASP 2024 defaults). - Signed httpOnly + sameSite=strict session cookie via
@fastify/cookie. Sessions stored server-side in a newsessionstable; lookup on every request. - Routes:
GET /api/auth/status,POST /api/auth/setup(first boot only),POST /api/auth/login,POST /api/auth/logout,GET /api/auth/me. - Auth gate on every
/api/*route except health/status/setup/login/ logout. Static asset requests pass through so the login screen can load before authentication. - First-boot UX — when no user exists the web app shows a "Set your password" screen; subsequent visits show the login page until a session is established.
- Argon2id password hashing (
- Attachment encryption at rest.
ATTACHMENT_ENCRYPTION_KEYenv var (32 bytes as base64 or hex).- When set, new uploads are AES-256-GCM encrypted on disk (12-byte IV + ciphertext + 16-byte tag).
attachments.encryption_versioncolumn (migration 005) is the source of truth; existing v=0 plaintext files keep working so the upgrade is non-destructive.
- Single-container Docker image.
- Multi-stage
Dockerfileat the repo root: builds the web bundle, builds the server, copies the SQL migrations intodist/, ships a minimalnode:22-alpineruntime as a non-rootnodeuser. - Server registers
@fastify/staticto serve the prebuilt web SPA at every non-API path — one container, one port. docker-compose.ymladds anappservice alongsidedb, with a namedsmrtcash-attachmentsvolume mounted at/data/attachments, healthchecks on both services, and full env wiring.tiniis the entrypoint so signals + zombie reaping are clean..dockerignorekeepsnode_modules,data/,.env,.claude/, samples, and Playwright outputs out of the build context.
- Multi-stage
- Backup + restore tooling.
npm run backup(scripts/backup.mjs) — snapshots Postgres viapg_dump --format=customand the attachments tree as a.tgzinto./backups/<timestamp>/.npm run restore -- <dir>(scripts/restore.mjs) — destructive restore with a "typerestoreto confirm" prompt;--forceto skip. Replaces the attachments directory atomically.
- Migrations now ship in
dist/. The Dockerfile copiessrc/db/migrations/*.sqlintodist/db/migrations/so the runner works against the compiled output. Closes KI-04.
Changed#
- CORS registered with
credentials: trueso the session cookie rides on dev cross-origin requests. - Web
http()fetch wrapper setscredentials: 'include'on every call; 401 responses throw a typedAuthRequiredErrorso the App can bounce to the login screen. - Web App shell now wraps every existing route in an auth-state
gate:
loading→needs-setup→needs-login→authenticated. - e2e —
setup-db.mjstruncatesusersandsessionstoo; PlaywrightglobalSetupruns the first-boot flow once and stores the resulting cookie viastorageState, so every existing spec keeps working unchanged.
Documentation#
docs/ADMIN_GUIDE.md— backup/restore commands, security checklist rewritten around the new env vars, HTTPS-via-Caddy section, and a formal Dependency vulnerability policy (cadence, severity SLAs, pinning approach)..env.example— documentsSESSION_SECRET,COOKIE_SECURE,ATTACHMENT_ENCRYPTION_KEYwithnode -e "..."key-generation snippets.docs/KNOWN_ISSUES.md— KI-03 and KI-04 removed.
Tests#
- +16 server tests (214 → 230): 12 auth integration tests (status / setup / login / logout / me / gate) and 4 encryption unit tests (write-encrypts, round-trip, plaintext-backcompat, wrong-key rejects).
- Existing tests continue to pass —
makeTestApp()was upgraded soapp.inject()auto-attaches a seeded session cookie; specs that exercise the unauthenticated paths passskipAuth: true. - Total automated coverage: 243 tests across server (230) + web (6)
- Playwright e2e (7).
Migration notes#
- Upgrading from 0.4.0:
npm run migrate --prefix serverapplies migration 005 (users,sessions,attachments.encryption_version).- Add
SESSION_SECRETto.env; without it the server generates an ephemeral one and existing sessions are invalidated on every restart. - Optionally set
ATTACHMENT_ENCRYPTION_KEYto start encrypting new uploads. Existing files remain readable as plaintext. - First load of the web app shows the Set your password screen.
- Docker upgrade path:
docker compose -p smrtcash build appthendocker compose -p smrtcash up -d. The image self-migrates at boot.
[0.4.0] — 2026-05-22 — Phase 4: Insights & Reconciliation#
Added#
- Transfer detection. A new
POST /api/transfers/detectpairs equal-opposite amounts on different accounts within 5 days of each other and tags both with a sharedtransfer_group_id. Ambiguity is resolved by picking the closest-date candidate; one transaction can belong to at most one group. APOST /api/transfersendpoint takes{aId, bId}for manual linking (e.g. a wire transfer with a fee, where the two legs are not penny-equal), andDELETE /api/transfers/:groupIdunlinks. A Transfers page in the web app drives all of this; rows already in a transfer group show a↔ transferpill on the Transactions page. - Opening balances + true running balance.
accounts.opening_balance_centsandopening_balance_date(migration 004).PATCH /api/accounts/:idedits them. Account balance now equalsopening + sum(txns on/after opening_date). The transactions list response gains a per-rowrunning_balance_cents(computed via a SQL window function,NULLfor pre-opening rows). The Account Detail page shows the running balance column and an inline edit form for the opening balance. Closes KI-01. - Insights endpoints:
GET /api/insights/spending-by-category— totals by category over a date range, excludes transfers.GET /api/insights/income-expense— monthly buckets for the last N months (default 12, capped at 60), excludes transfers.GET /api/insights/net-worth-over-time— end-of-month total across all accounts for the last N months. Transfers self-cancel and need no special handling here.
- Dashboard (
/route) — three Recharts panels: pie of spending by parent-category for the current month, grouped bar of income vs. expense for the last 12 months, and a line of net worth over time. - Filtered CSV export.
GET /api/transactions/exportstreams a CSV honoring the same filters as the list endpoint plus optionalstart/enddate bounds. Always-quoted cells with doubled internal quotes for safety; UTF-8; date-stamped filename viaContent-Disposition. An Export CSV button on the Transactions page triggers the download with the current filter state.
Changed#
- Sidebar navigation —
/is now the Dashboard (was Accounts); the Accounts list moves to/accounts. New entries for Transfers and the dashboard. - Transaction list query restructured to compute
running_balance_centsin an inner query (against the full account history) andtransfer_group_idis included so the UI can render the transfer pill. - Insights and CSV-export queries explicitly skip rows with a non-null
transfer_group_idso internal moves don't pollute spending or income totals. Net-worth aggregation does not filter — transfer debits and credits cancel naturally across accounts.
Documentation#
docs/KNOWN_ISSUES.md— KI-01 removed (resolved by opening balances).docs/FEATURES.md— transfer linking, true balance reconciliation, spending by category, income vs expense, net worth over time, dashboard with charts, and filtered CSV export all flipped to ✅.
Tests#
- +36 server tests (178 → 214): 13 transfer integration tests (detection edge cases, manual link, unlink, account scoping), 8 opening-balance + running-balance tests, 6 insights tests (per-category, monthly buckets, net worth, transfer exclusion), 5 CSV export tests (header, escaping, date filtering, empty result, bad input).
- Total automated coverage: 227 tests across server (214) + web (6) + Playwright e2e (7).
Migration notes#
- Upgrading from 0.3.0:
npm run migrate --prefix serverto apply migration 004 (opening-balance columns onaccounts+ partial index ontransactions.transfer_group_id). Default values are zero / null, so existing data behaves the same as before until you set an opening balance. - New web dep:
rechartsfor the dashboard charts (~40 packages, no vulnerabilities).
[0.3.0] — 2026-05-22 — Phase 3: Receipts & Attachments#
Added#
- Receipt & file attachments on transactions. New multipart upload route
POST /api/transactions/:id/attachmentsaccepts JPEG / PNG / WEBP / PDF (25 MB per file, 100 MB aggregate per request). Files are written toATTACHMENTS_DIR(defaults to<repo>/data/attachments) under a date-sharded layout (YYYY/MM/<uuid>-<safeFilename>), with the storage path generated server-side from a UUID and a sanitized filename — so the upload route is immune to path-traversal via the uploaded filename. - Web UI — a receipt icon on every transaction row opens an attachments modal with drag-and-drop, multi-file upload, inline image previews, PDF icons that open in a new tab, download, and delete.
- Pluggable receipt OCR. A new
OcrProviderinterface mirrors the Phase-2 normalizer pattern. The Claude vision provider (claude-haiku-4-5) extractsamountCents,date,merchant,confidenceand a free-formnotevia structured outputs. Extracted fields are compared against the transaction using a $0.50 amount / 3-day date match tolerance, and the modal flags matches vs. differs. - Restart-safe OCR. A boot-time sweep (
sweepPendingOcr) finds any attachment still atocr_status='pending'from before the previous shutdown and retries extraction. Rows whose file has vanished from disk are markedfailed. - API endpoints —
GET /api/transactions/:id/attachments,POST /api/transactions/:id/attachments,GET /api/attachments/:id(download),GET /api/attachments/:id/preview(inline),DELETE /api/attachments/:id. - Schema — migration 003 adds OCR columns
(
extracted_amount_cents,extracted_date,extracted_merchant,ocr_provider,ocr_status,ocr_note) toattachments, with a partial index onocr_status='pending'for the sweep. - 18 additional automated tests covering filename sanitization, MIME / size validation, the path-traversal hardening, the Claude OCR mocked client (image + PDF content blocks, response sanitization, error paths), the full upload → list → download → preview → delete loop, the aggregate-cap 413 path, the OCR sweep (extract / age-skip / file-missing), and a Playwright e2e for receipt attach + delete.
Changed#
ATTACHMENTS_MAX_REQUEST_BYTESenv var (default104857600— 100 MB) governs the aggregate cap. Tests drop it to 1 MB to exercise the 413 path without shipping 100 MB throughapp.inject.transactionslist query now joins on a correlatedattachment_countsubquery so the row badge can render without an extra round-trip.
Migration notes#
- Upgrading from 0.2.1:
npm run migrate --prefix serverto apply migration 003 (OCR columns + partial index). No data backfill is required — every row defaults toocr_status='pending', but no rows exist yet on a 0.2.1 install. - Receipt OCR runs only when
AI_PROVIDER=claudeandANTHROPIC_API_KEYis set. Other providers leave attachments atocr_status='skipped'. - Phase 5 still owns encryption at rest and the Docker volume mount
for
ATTACHMENTS_DIR(the server isn't containerized yet).
[0.2.1] — 2026-05-22 — Phase 2.1: Comprehensive Categories & Suggestion Review#
Added#
- Comprehensive hierarchical category taxonomy — 23 top-level groups
containing ~167 sub-categories (e.g. Transportation → Auto Loan Payment,
Auto Insurance, Gas & Fuel, Auto Service, Auto Parts, Vehicle Upgrades &
Accessories, Parking, Tolls, Public Transit, Taxi & Rideshare, Vehicle
Registration & DMV). The full tree is the single source of truth in
server/src/domain/categories.ts. - AI-suggested-category review flow — when the AI normalizer returns a
category not in the taxonomy, the suggestion is captured in a new
category_suggestionstable and the originating transactions are tagged viatransactions.suggested_category_name. The Categories page shows a Suggestions panel with Approve as new (optionally placing the new category under an existing group), Merge into existing (link to an existing category instead), and Reject actions. - New API surface:
GET /api/suggestions,POST /api/suggestions/:id/approve|merge|reject. - The transaction category dropdown now renders categories grouped by
parent using
<optgroup>so picking from ~190 categories stays usable.
Changed#
seedDefaultCategoriesis now idempotent and hierarchy-aware: it inserts groups first, then re-parents any legacy top-level row whose name now belongs under a group (so existingGas & Fuelassignments survive the move under Transportation without breaking foreign keys).NormalizationResultgains asuggestedCategoryfield;mergeWithBatchpopulates it when the AI returns a name outside the allowed taxonomy (explicit "Uncategorized" is not treated as a suggestion).
Migration notes#
- Upgrading from 0.2.0:
npm run migrate --prefix serverto apply migration 002 (thecategory_suggestionstable + thetransactions.suggested_category_namecolumn) and to expand the seeded taxonomy. Existing category-id assignments are preserved.
[0.2.0] — 2026-05-22 — Phase 2: AI Transaction Normalization#
Added#
- Pluggable
TransactionNormalizerinterface with three providers selected viaAI_PROVIDER:- Claude API (
claude) — official Anthropic SDK,claude-haiku-4-5, prompt-cached system prompt, structured outputs viaoutput_config.format, in-prompt batching at 25 transactions per call, typed-exception error handling. - Ollama (
ollama) — local model via the Ollama HTTP API, JSON-mode output with defensive parsing of noisy responses. - Rules (
rules, the new default) — deterministic baseline that cleans merchant names, applies a strong-override list (Uber One, streaming services, Adobe → Subscriptions), maps Chase's source categories, and falls back to keyword rules.
- Claude API (
- Default 20-entry category taxonomy seeded by
npm run migrateand re-seeded after everyresetDb()in tests. POST /api/normalize— runs normalization on pending transactions, optionally scoped to one account.GET / POST / PATCH / DELETE /api/categoriesfor the user-editable taxonomy, with case-insensitive uniqueness.PATCH /api/transactions/:id— manual category edits set the row'snormalization_statustomanualso future AI runs leave it alone.GET /api/ai/statussurfaces the configured provider in the UI.- Web UI — Categories page; Normalize button + status filter (All / Pending / Normalized / Manual) + inline category dropdown on the Transactions page; AI-provider indicator; status pills per row.
- 42 additional automated tests covering each provider, the normalize endpoint, transaction PATCH, and a functional import → normalize pipeline test. Total suite is now 137 tests.
Changed#
- Default
AI_PROVIDERis nowrules(wasnone), so a fresh install gets working baseline normalization with no configuration. - Accounts route hardened against type-confused request bodies — a non-string
in
nameortypenow returns 400 instead of crashing with a 500. - Server refactored to export a
buildApp()factory so integration tests can drive the app viaapp.inject()without a network listener.
Documentation#
- Roadmap rewritten to nine phases informed by a competitive review (Monarch, Simplifi, Empower, Banktivity, CountAbout, Rocket Money, Moneydance).
- New Process playbook, Contributing guide, and this CHANGELOG.
Migration notes#
- Upgrading from 0.1.0: run
npm run migrate --prefix serverafter pulling 0.2.0. The migrate step now seeds the 20 default categories in addition to applying any schema changes (it is idempotent — safe to run more than once). Without this step normalization runs but every row falls back to(uncategorized)because the category names cannot resolve to ids.
[0.1.0] — 2026-05-22 — Phase 1: Foundation & Import#
Added#
- Monorepo scaffold (
server/+web/+e2e/) on Node 24 + TypeScript. - PostgreSQL 17 running as a
docker-composeservice; SQL migration runner. - Fastify + TypeScript API with a single structured error handler.
- CSV & XLSX importer with auto-detection of Chase credit-card and Chase checking/savings exports, plus a generic column-mapping path for any other bank.
- Per-row error reporting (bad rows skipped, good rows kept) and duplicate detection via a stable occurrence-counter hash.
- Money stored as integer cents throughout — never floating point.
- React + Vite web app: Accounts, Transactions, Account Detail, Import wizard.
- 95-test automated suite spanning unit, integration, functional, security, smoke, performance and Playwright end-to-end layers.
- Full documentation set: Quick Start, Installation, Admin Guide, General Documentation, Features, Roadmap, Known Issues, Testing.
Verified end-to-end#
- 1,944 real Chase transactions imported (Chase Credit Card 0444 — 440 rows; Chase Checking 5793 — 1,504 rows). Re-import correctly imported 0 / skipped all duplicates.