SmrtCash docs

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:

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)#

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).

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#


[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)#

0.24.2 — Debt-payoff scenarios (4)#

0.24.3 — Life-event scenarios (5)#

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).


[0.22.1] — 2026-05-27 — Bill matcher UI + date picker fixes#


[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)#

Engine (server/src/domain/bill-matcher.ts)#

Hooks#

UI#

Other improvements (same arc)#


[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/):

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):

Frontend (web/src/pages/WorkspacePage.tsx):

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):

Frontend (web/src/pages/CancellationsPage.tsx):

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):

Frontend (web/src/api.ts, web/src/pages/ScenarioPage.tsx):


[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):

Frontend (web/src/pages/InvestmentsPage.tsx):

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):

Frontend (web/src/pages/HouseholdPage.tsx):

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):

Frontend (web/src/pages/WarrantiesPage.tsx):

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):

Frontend (web/src/components/RefundStatusModal.tsx, web/src/components/TransactionTable.tsx, web/src/pages/TransactionsPage.tsx):


[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):

Frontend (web/src/pages/MileagePage.tsx, web/src/pages/TaxYearPage.tsx):


[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):

Web (web/src/pages/SettingsPage.tsx):

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.

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):

Defensive change:


[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:

All resolved by the same fix.

Changes:


[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?":

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:

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:

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.


[0.18.5] — 2026-05-24 — Goal tracking polish#

Closes Monarch's visible goal-tracking advantage. Existing savings_goals data, missing UX.

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):

Auth path (server/src/app.ts):

Routes (server/src/routes/api-keys.ts):

Web (ProfileModal → new ApiKeysSection):

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:

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):

Per-user timezone:

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.


Two small but visible fixes that came out of looking at the test deploy:

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:

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.

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:

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#

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:

  1. Goal-required — unchanged, from active savings_goals
  2. Low % — default 25% of post-deduction leftover
  3. Mid % — default 50%
  4. High % — default 75%
  5. 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#

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:

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#

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#

UI#

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#

UI#

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#

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:

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:

  1. Picks budget rows whose anchor (period_month) lands in the calendar month containing asOf. For monthly cadence that's the one row anchored to the 1st; for weekly cadence it's every row whose week starts that month.
  2. Groups by (category_id, bill_id, flex). One group per category, one per bill, one for the flex pool.
  3. Sums budgeted_cents across each group's member rows — so 4 weekly Groceries × $150 = $600 monthly.
  4. Computes actual_cents ONCE 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:

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:

Heuristic notes:

Net effect (test deploy verification)#

Follow-up to-dos (not in this slice)#

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:

Change#

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#

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:


[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:

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#


[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.

Shared helpers extracted#

The bill/income/editable/totals computation was duplicated in the singular /period route. Extracted into two reusable pieces:

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:

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 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:

Server#

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
}

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#

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#

  1. commitWizard INSERTed budgets without tenant_id. budgets.tenant_id was added as nullable in migration 017 and never made NOT NULL, so the INSERTs succeeded but produced rows invisible to /api/budgets (which filters WHERE tenant_id = $1). The 61 created rows were orphans.

  2. buildWizardPreview cross-tenant leak. The preview's grocery-median calculation read transactions with no tenant join; route-driven fuel + tolls read vehicles and commute_routes without filtering by tenant_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.

  3. goalRequiredForPeriod cross-tenant leak. Same shape — savings_goals read without tenant filter.

Fix#

Migration 034 — backfill orphans + NOT NULL#

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#


[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:

Server changes#

Web changes#

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#

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#

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#


[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:

  1. Invitation acceptance (POST /api/invitations/:token/accept in tenants.ts) — clicking the invite link sent to the recipient's email IS the proof, same as the signup verification flow.
  2. OIDC / SAML first-login (identities.ts:resolveIdentity) — the identity provider verified the email before issuing tokens; we inherit that proof.
  3. 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#

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#


[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#

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#

HTML build pipeline#

Operator notes#


[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)#

Encryption module (server/src/attachments/tenant-keys.ts)#

New module owning the envelope crypto:

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:

Callers updated: routes/attachments.ts (upload + download + preview), ocr/extract-service.ts (pending-OCR walker).

Super-admin rotate flow#

Tests#

server/tests/unit/attachments-encryption.test.ts rewritten

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#


[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):

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:

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.

Tests#

Full suite green: 739 server + 6 web tests.

Operator notes#


[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)#

Backend#

Web#

Tests#

server/tests/integration/auth.test.ts — 7 new cases under the "password reset (0.16.2)" describe:

Full suite: 736 server + 6 web tests green.

Operator notes#


[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:

Backend#

Web#

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)#

Backend#

Web#

Tests#

server/tests/integration/auth.test.ts — eight new cases:

Full suite green: 718/718 server tests + 6/6 web tests.

Operator notes#


[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#

Web#

Docs#

Tests#

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.tseffectivePlan() no longer returns the plan unconditionally for past_due. New behavior:

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.tshandleInvoiceEvent now reacts to invoice.payment_failed:

  1. Pull the Stripe customer to get the canonical billing email (Checkout-collected, may differ from any local user email).
  2. Render a short HTML+text body via the new renderDunningEmail() helper in server/src/domain/mailer.ts.
  3. 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:

server/tests/unit/billing-webhook-handlers.test.ts:


[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#

Tests (+7)#

tests/integration/billing-status.test.ts exercises every documented shape:

Total: 695 server tests pass (688 + 7). Web typecheck clean, 6 web tests pass.

What's NOT in this slice#

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.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#

Test-harness changes#

Tests (+16)#

tests/security/entitlements-routes.test.ts — 16 route-level gate tests covering every gated surface:

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.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:

Routes still NOT gated — that's 0.15.2. This slice just gets the plumbing in place.

Dependencies#

New code#

app.ts changes#

Tests (+17)#

Total: 672 tests pass (655 + 17).

What's deliberately NOT here#

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.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)#

Entitlement core (server/src/auth/entitlements.ts)#

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:

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.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.

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)#

634/634 server tests pass.

Docs#

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).

Result: the 6 previously-red portability tests now pass.

Docs#

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#

Server — vehicles + commute-routes#

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:

Server — normalize#

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:

Tests (+9 cross-tenant isolation tests)#

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.

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#

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#

Server — new helper#

Server — routes/attachments.ts#

Server — routes/splits.ts#

Server — routes/suggestions.ts#

Server — routes/tenants.ts#

Tests (+11 cross-tenant isolation tests)#

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.

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.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#

Server — domain/reports.ts + routes/reports.ts#

Server — domain/transfers.ts + routes/transfers.ts#

Tests (+9 cross-tenant isolation tests)#

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.

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.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#

Server — routes/bills.ts#

Server — routes/goals.ts#

Server — routes/recurring.ts#

Server — routes/subscriptions.ts#

Tests (+17 cross-tenant isolation tests)#

tests/security/tenant-isolation.test.ts extended:

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).

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.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

Server — new helpers (auth/rbac.ts)#

All four use 404 on miss, not 403, so cross-tenant probes can't enumerate ids via status-code diffing.

Server — routes/accounts.ts#

Server — routes/transactions.ts#

Server — routes/holdings.ts#

Tests — new (tests/security/tenant-isolation.test.ts)#

17 cross-tenant tests, each of which would have FAILED against pre-0.14.0 code:

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).

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.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)#

Server#

Tests (+6 server)#

Web#

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#

Tests (+5 server)#

Documentation#

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)#

rbac generalization#

Routes#

Web#

Tests (+10 server)#

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)#

Price fetcher (server/src/domain/crypto-prices.ts)#

Settings + routes#

Assistant#

Web#

Tests (+15 server)#

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#

Schema (migration 026)#

Settings (super-only, all default off)#

Server#

Web#

Tests (+9 server)#

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)#

Server#

Web#

Tests (+7 server)#

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#

Web#

Tests (+7 server)#

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#

Web#

Tests (+7 server)#

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)#

Routes (server/src/routes/shares.ts)#

Assistant tools#

Two new tools join the registry (now 14 total):

Web#

Tests (+7 server)#

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:

Write tools (all call recordAudit() before returning):

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)#

Routes (server/src/routes/assistant.ts)#

Web#

Test infra fix#

Tests (+15 server)#

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#

Service worker (web/public/sw.js)#

Install prompt + offline indicator#

Mobile drawer + responsive CSS#

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#

Browser smoke notes#


[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#

Settings (super-only)#

Routes#

Web (/system super-admin panel)#

Tests (+15 server)#

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#

Schema (migration 023)#

Plaid client (server/src/domain/plaid.ts)#

Data source (server/src/datasource/plaid.ts)#

Routes (server/src/routes/plaid.ts)#

Web#

Importer / persistence#

Tests (+17 server)#

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)#

Encryption#

Protocol#

Data source#

Routes#

Importer refactor#

Web#

Tests (+25 server)#

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#

Pipeline wiring#

Data-source layer scaffold#

Web#

Tests#

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)#

Domain#

Routes#

Web#

Tests#

Notes#


[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)#

Domain#

Routes#

Web#

Tests#

Notes#


[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#

Changed#

Notes#


[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#

Added#

Tests#

Notes#


[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#

Rationale#

Tests#

Notes#


[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#

Tests#


[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#

Removed (from tenant sidebar)#

Notes#

Tests#


[0.9.0] — 2026-05-23 — RBAC: super admins, tenant admin/spouse/child, audit log#

Role model overhaul. Three orthogonal concepts:

  1. Super admin — platform operator. Manages tenants, system settings, audit log. Orthogonal to tenant membership: a super admin never has a memberships row, enforced by trigger.
  2. Tenant roleadmin / spouse / child (replaces owner / admin / member / viewer).
  3. Per-account ACL — children are scoped to admin-assigned accounts only.

Schema (migration 019)#

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

First-user flow#

Added#

Tests#

Breaking#

Deferred (next slices)#


[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#

Tests#

Notes#


[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)#

Added — authentication abstraction#

Added — routes#

Added — web UI#

Migration notes#

Tests#

Deferred (next slices)#


[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#

Added#

Tests#


[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#

Tests#


[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#

Notes#


[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#

Tests#

Notes#


[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#

Tests#

Notes#


[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#

Tests#


[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#

Changed#

Tests#

Migration notes#


[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#

Changed#

Tests#

Migration notes#


[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#

Changed#

Tests#

Migration notes#


[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#

Tests#

Migration notes#

What's still coming in Phase 7#


[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#

Changed#

Tests#

Deferred (per the original ask)#

Migration notes#


[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#

Changed#

Fixed#

Tests#

Migration notes#


[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#

Web#

Tests#

Fixed#

Migration notes#


[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#

Changed#

Documentation#

Tests#

Migration notes#


[0.4.0] — 2026-05-22 — Phase 4: Insights & Reconciliation#

Added#

Changed#

Documentation#

Tests#

Migration notes#


[0.3.0] — 2026-05-22 — Phase 3: Receipts & Attachments#

Added#

Changed#

Migration notes#


[0.2.1] — 2026-05-22 — Phase 2.1: Comprehensive Categories & Suggestion Review#

Added#

Changed#

Migration notes#


[0.2.0] — 2026-05-22 — Phase 2: AI Transaction Normalization#

Added#

Changed#

Documentation#

Migration notes#


[0.1.0] — 2026-05-22 — Phase 1: Foundation & Import#

Added#

Verified end-to-end#