Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0b9d950
fix: align FE/BE paths for AI-config reveal and rate endpoints
Sagargupta16 Jul 4, 2026
09368b4
docs: comprehensive hardening plan from Wave 1 research
Sagargupta16 Jul 4, 2026
2ed0c19
chore(ci): align on Python 3.13 everywhere; add job timeouts
Sagargupta16 Jul 4, 2026
f484c0e
feat(security): split BYOK encryption key from JWT secret; use HKDF f…
Sagargupta16 Jul 4, 2026
8ab75f5
feat(security): refresh token rotation via User.token_version
Sagargupta16 Jul 4, 2026
e91a3f2
feat(security): per-authenticated-user rate limits on upload + ai chat
Sagargupta16 Jul 4, 2026
7a205cc
feat(db): DB-level ondelete=CASCADE on every user_id FK
Sagargupta16 Jul 4, 2026
1b491ac
feat(analytics): robust anomaly detection with median+MAD and rolling…
Sagargupta16 Jul 4, 2026
f3137d2
chore: broaden tax detection regex; delete stale Makefile + dead reno…
Sagargupta16 Jul 4, 2026
029291e
feat(analytics): 50/30/20 budget-rule aggregation endpoint
Sagargupta16 Jul 4, 2026
3ef1990
feat(budget): replace /budgets with 50/30/20 Budget Rule page
Sagargupta16 Jul 4, 2026
ff6b07c
fix: cascade migration duplicated SQLite FKs; harden /budgets against…
Sagargupta16 Jul 4, 2026
d970431
fix(budget): remove dead capForBar ternary (SonarCloud typescript:S3923)
Sagargupta16 Jul 4, 2026
30ed499
feat(budget): three-column layout, top-10 rollup, expanded period opt…
Sagargupta16 Jul 4, 2026
f5606ae
feat(budget): relabel generic 'Transfer' rows on Savings column
Sagargupta16 Jul 4, 2026
07e5642
feat(budget): collapse rows by category with top-3 subs inline
Sagargupta16 Jul 4, 2026
ad8d0dd
fix(budget): relabel 'Transfer: X → Y' rows + kill horizontal scroll
Sagargupta16 Jul 4, 2026
48ba415
fix(budget): design-system consistency + Education classifier
Sagargupta16 Jul 4, 2026
8ee8793
fix(budget): rewrite CategoryTable with hand-rolled rows (no more tru…
Sagargupta16 Jul 4, 2026
a6c9d24
refactor(frontend): extract Money primitive + migrate 5 pages to Page…
Sagargupta16 Jul 4, 2026
9f34b28
fix(frontend): cap historical chart data at today
Sagargupta16 Jul 4, 2026
6062ac1
docs: changelog Unreleased + CLAUDE.md guidance for Money / chart cap
Sagargupta16 Jul 4, 2026
d761cf3
refactor: fix all 18 SonarCloud issues on PR #202
Sagargupta16 Jul 4, 2026
fc6702a
chore: retrigger CI on latest sonar-fix HEAD
Sagargupta16 Jul 4, 2026
3f86652
Merge remote-tracking branch 'origin/main' into feat/comprehensive-ha…
Sagargupta16 Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions .github/renovate.json

This file was deleted.

1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
# fails the build on lint/type/test errors.
backend:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: backend
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
defaults:
Expand Down Expand Up @@ -66,6 +67,7 @@ jobs:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
timeout-minutes: 10
needs: build
steps:
- name: Deploy to GitHub Pages
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/keepalive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ permissions:
jobs:
ping:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Wake backend
run: |
# The /api/health endpoint is unauthenticated and returns instantly.
# We don't fail the workflow on non-200 — the goal is only to keep
# The /health endpoint is unauthenticated and returns instantly.
# (App-level route in main.py -- no /api/ prefix, unlike domain routers.)
# We don't fail the workflow on non-200 -- the goal is only to keep
# the branch warm; a transient 5xx during deploy is fine.
curl --silent --max-time 30 \
"https://ledger-sync-api.vercel.app/health" \
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/migrate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ on:
- 'backend/alembic/**'
- 'backend/src/ledger_sync/db/migrations/**'
- 'backend/src/ledger_sync/db/models.py'
- 'backend/src/ledger_sync/db/_models/**'

workflow_dispatch: # Allow manual trigger

jobs:
migrate:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.14'
python-version: '3.13'

- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8

Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## Unreleased -- `feat/comprehensive-hardening` (PR #202)

Comprehensive hardening wave landing on the branch: security tightening (BYOK key split + HKDF, refresh-token rotation, per-user rate limits, DB-level cascade), the anomaly-detection rewrite, the 50/30/20 Budget Rule page, and a design-system consistency pass that trims data past today across every historical chart and codifies the money-cell pattern.

### Added

- **50/30/20 Budget Rule page (`/budget`).** Replaces the old category-CRUD `/budgets`. Three side-by-side bucket cards (Needs / Wants / Savings) show target vs actual with a delta score; the breakdown table caps each bucket at top-10 categories with an "Other (N more)" rollup that expands inline. Period picker covers 1yr, 2yr, 5yr, All-time, Custom. Backend endpoint `POST /api/analytics/v2/spending-rule` classifies categories via word-boundary regex (fixes Education-not-in-Needs bug), unions user overrides with defaults (fixes first-override-drops-defaults bug), and relabels compound `Transfer: Bank: X -> Y: Z` rows to instrument names (Stocks, Mutual Funds, PPF, etc.).
- **`<Money>` primitive** (`frontend/src/components/ui/Money.tsx`). Codifies the canonical amount-cell rule set: `shrink-0 text-right tabular-nums whitespace-nowrap font-medium` plus `sm|md|lg|xl` width presets so flex parents can't compress the amount. Prevents the `₹12,91` truncation bug that occurred inside 33%-wide columns. Barrel-exported from `@/components/ui`; migrated `CategoryTable`, `CategoryBreakdown` (both top-level and subcategory rows).
- **`capEndDateAtToday()` and `capSeriesToToday<T>()`** in `frontend/src/lib/dateUtils.ts`. Historical time-series charts across the app no longer render a flat-zero tail past today. `capEndDateAtToday()` is applied inside `getAnalyticsDateRange()` so it cascades through `useAnalyticsTimeFilter`, `useDashboardMetrics`, and every analytics query hook without per-caller edits. `capSeriesToToday<T>(rows, key)` is generic on row shape with ISO-string comparison for use with in-memory month or day series. Projection pages (FIRE, tax-planning multi-year, retirement forecasting) intentionally build their own future ranges and are untouched.
- **Anomaly detection rewrite** (`api/analytics/anomalies.py`). Median + MAD (Median Absolute Deviation) with a rolling per-category baseline; robust to skew and single-transaction outliers.
- **DB-level `ON DELETE CASCADE`** on every `user_id` foreign key. Migration `20260704_1100_add_ondelete_cascade_user_fks.py` handles both PostgreSQL (`ALTER TABLE ... DROP CONSTRAINT / ADD CONSTRAINT`) and SQLite (`batch_alter_table(recreate="always", naming_convention=...)` with FK-name reflection to avoid the duplicate-FK trap that happens when `create_foreign_key` runs without an explicit `drop_constraint` in the same batch).

### Changed

- **`PageContainer` migration** for 5 pages (Anomaly, FIRE, Transactions, More, SubscriptionTracker). Hand-rolled `min-h-dvh p-4 md:p-6 lg:p-8` + `max-w-7xl mx-auto space-y-6` scaffolds replaced with the shared primitive.
- **Palette drift fixes.** `year-in-review/types.ts` heatmap band colors moved from slate-tinted rgba to `${rawColors.app.red}<alpha>` template literals (theme-aware). New `rawColors.onAccent` token replaces raw `'#fff'` in `PeriodSelectors`. `CommandPalette` inline shadow moved to the shared `var(--glass-shadow-ultra)` token + app-blue ambient glow.
- **Year-in-review Monthly Breakdown** now slices `MONTHS_SHORT` at today's month for the current calendar/fiscal year (no more empty bars for future months). FY wrap handled via `((nowMonth - (fyStart - 1) + 12) % 12) + 1`.
- **CI on Python 3.13 everywhere**, added job timeouts.

### Fixed

- Cascade migration created duplicate FKs on SQLite (drop + create in same batch = REPLACE, but pure `create_foreign_key` alone APPENDS).
- Frontend crash `Cannot read properties of undefined (reading 'start')` on the budget page in demo mode -- demo axios adapter's catch-all `/analytics/v2/*` returned the wrong shape for spending-rule.
- SonarCloud MAJOR bug: identical ternary branches in a `capForBar` variable that always resolved to `target`.
- Substring false positives in investment-account classifier (`'rd'` matched inside `"weird broker xyz"`); replaced exact-match with word-boundary regex.
- Failed main deploy on 2026-07-03 (transient GitHub Pages API error); rerun succeeded.

### Security

- **Split BYOK encryption key from JWT secret.** Uses HKDF for v2 keys with per-ciphertext salt; JWT rotation no longer invalidates stored API keys.
- **Refresh-token rotation via `User.token_version`.** Logout / password reset increments the version claim; old refresh tokens fail verification server-side.
- **Per-authenticated-user rate limits** on `/api/upload` and `/api/ai/bedrock/chat` (was IP-keyed).

---

## 2.19.0 - 2026-06-28

A premium **light theme**, a world-class UI/UX elevation pass, and an information-architecture restructure. Adds a Light/Dark/System toggle and makes the entire app render flawlessly in both themes, driven by live design-system research (Radix Colors, Material 3, Apple HIG), a 14-lens UI audit, and a 5-lens IA audit. Frontend-only -- no API, schema, or backend changes. Dark theme is preserved byte-for-byte.
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Layered architecture:
### Frontend (`frontend/src/`)

- **`pages/`** - 25 page components (24 feature pages + `MorePage` for phone grid nav), all lazy-loaded via `React.lazy` for code splitting. Pages import directly (no barrel re-export). **Structure convention**: multi-file pages use kebab-case directories (`bill-calendar/`, `year-in-review/`, `tax-planning/`, `trends-forecasts/`, `comparison/`, `goals/`, `income-expense-flow/`, `settings/`, `subscription-tracker/`) -- each containing `PageName.tsx` + `use<Page>.ts` + `types.ts` + `*utils.ts` + `components/` subfolder. Single-file pages use PascalCase (`DashboardPage.tsx`, `BudgetPage.tsx`, etc.). Settings uses `sections/` (instead of `components/`) because "section" is the domain term. `settings/sectionPrimitives.tsx` provides shared Section/FieldLabel/FieldHint primitives. See [`docs/HANDBOOK.md`](docs/HANDBOOK.md) for the full card-by-card walkthrough of every page + setting (formula + data source per metric), or [`docs/PAGES.md`](docs/PAGES.md) for the shorter data-focused catalog.
- **`components/`** - Organized by domain: `analytics/` (chart components including `CategoryBreakdown` for shared category treemaps), `chat/` (ChatWidget, ChatPanel, ChatMessage, useChat hook for AI chatbot), `layout/` (AppLayout, Sidebar with ProfileModal), `shared/` (reusable components like MetricCard, ProgressBar, Sparkline, AnalyticsTimeFilter, ProtectedRoute, ProfileModal, ChunkErrorBoundary, EmptyState, LoadingSkeleton, QuickInsights), `transactions/`, `upload/`, `ui/` (base primitives including ChartContainer, PageContainer, PageHeader, Spinner, DataTable, ConfirmDialog, CollapsibleSection, plus chartDefaults helpers `referenceLine`/`currencyTooltipFormatter`).
- **`components/`** - Organized by domain: `analytics/` (chart components including `CategoryBreakdown` for shared category treemaps), `chat/` (ChatWidget, ChatPanel, ChatMessage, useChat hook for AI chatbot), `layout/` (AppLayout, Sidebar with ProfileModal), `shared/` (reusable components like MetricCard, ProgressBar, Sparkline, AnalyticsTimeFilter, ProtectedRoute, ProfileModal, ChunkErrorBoundary, EmptyState, LoadingSkeleton, QuickInsights), `transactions/`, `upload/`, `ui/` (base primitives including ChartContainer, PageContainer, PageHeader, Spinner, DataTable, ConfirmDialog, CollapsibleSection, Money, plus chartDefaults helpers `referenceLine`/`currencyTooltipFormatter`).
- **`hooks/`** - Custom React hooks. `useAnalyticsTimeFilter` encapsulates time-filter state (view mode, date range, FY) shared across all analytics pages. `useChartDimensions` provides responsive chart sizing. `hooks/api/` contains TanStack Query hooks for API calls, configured with `staleTime: Infinity` and `gcTime: 1 hour`.
- **`services/api/`** - Axios-based API client. Axios interceptor auto-attaches JWT `Authorization` header. `aiConfig.ts` handles AI provider configuration CRUD.
- **`store/`** - Zustand stores: `authStore` (JWT tokens with persist middleware), `accountStore`, `budgetStore`, `investmentAccountStore`, `preferencesStore`.
Expand Down Expand Up @@ -211,7 +211,9 @@ Repo-specific skills live in [`.claude/skills/<name>/SKILL.md`](.claude/skills/)
- **Page scaffold**: Wrap page bodies in `PageContainer` (`@/components/ui`) -- one centered `max-w-7xl` root with consistent padding + section spacing. Don't hand-roll `min-h-dvh p-4 ... max-w-7xl mx-auto` per page.
- **Charts**: Wrap in `ChartContainer` (pass `ariaLabel` -- every chart needs an accessible name). Prefer the `Standard{Bar,Area,Pie}Chart` wrappers. Use `referenceLine()` (peak/avg/target/goal/zero) and `currencyTooltipFormatter()` from `chartDefaults` instead of hand-rolling reference lines / tooltip formatters. Colors from `constants/`. Respect the data-viz-fit rules: pie/donut only <=7 slices, radar only for fixed 0-100 multivariate profiles, bars for rankings, time-series default to per-period not cumulative.
- **Layout**: `PageHeader` for titles, `MetricCard` for KPIs (use its `change`/`subtitle`/`trend`/`hero` props to make a number informative -- don't hand-roll delta badges or sparkline slots), `EmptyState` for no-data, `Spinner` (`@/components/ui`) for transient loads and the `LoadingSkeleton` family for page/chart/table skeletons, `ProgressBar` (`@/components/shared`) for any actual-vs-target metric (fill + optional target tick + bullet `bands`) -- don't hand-roll `h-2 rounded-full` bars.
- **Tables**: Use `DataTable` from `@/components/ui` for flat + sortable tables. Column-driven API (`DataTableColumn<T>`), internal sort state, keyboard-accessible sort headers, optional row animation, `stickyHeader`, and `mobileCards` (below `sm`, rows render as stacked label/value cards via `mobilePrimary`/`mobileLabel` -- use for wide tables so they don't horizontal-scroll on phones). Don't hand-roll `<table>` unless the shape is genuinely different (expandable groups, pivoted rows-as-columns); for those, drop low-priority columns on mobile (`hidden sm:table-cell`) or pin the label column.
- **Tables**: Use `DataTable` from `@/components/ui` for flat + sortable tables. Column-driven API (`DataTableColumn<T>`), internal sort state, keyboard-accessible sort headers, optional row animation, `stickyHeader`, and `mobileCards` (below `sm`, rows render as stacked label/value cards via `mobilePrimary`/`mobileLabel` -- use for wide tables so they don't horizontal-scroll on phones). Don't hand-roll `<table>` unless the shape is genuinely different (expandable groups, pivoted rows-as-columns); for those, drop low-priority columns on mobile (`hidden sm:table-cell`) or pin the label column. **At narrow widths (3-column grids, expandable rows), DataTable's `<td>` truncates money -- render amount cells with `<Money>` instead, or hand-roll a flex row following the CategoryBreakdown pattern.**
- **Money**: Any amount rendered in a flex row or narrow table cell uses `<Money>` from `@/components/ui`. Signature: `<Money value={n} width="sm|md|lg|xl" bold muted />`. Codifies `shrink-0 text-right tabular-nums whitespace-nowrap font-medium` so the digits never truncate (was the `₹12,91` bug in the 3-col /budget layout). Omit `width` for free-flow contexts (hero KPIs, tooltips).
- **Historical charts**: Time-series data must not extend past today. Rely on `getAnalyticsDateRange()` -- it now caps `end_date` at today for FY/yearly/monthly modes (via `capEndDateAtToday()` in `lib/dateUtils.ts`), so every analytics endpoint call inherits the fix. For pre-computed in-memory series, wrap with `capSeriesToToday<T>(rows, key)`. Projection pages (FIRE, tax multi-year, retirement) build their own future ranges and are exempt.
- **Dark-only (for now)**: Light theme planned (#79). Until then, don't add `dark:` prefixes or theme toggles.
- **No inline styles** for layout -- use Tailwind classes.
- **Mobile-first**: most users are on phones. KPI/stat grids should be `grid-cols-2` on phone (not single-column), gate changes behind `sm:`/`lg:`/`useIsMobile` so desktop never regresses, keep interactive controls >=44px, and verify at real mobile width (Playwright) -- never claim mobile-friendly on static reasoning.
Expand Down
63 changes: 0 additions & 63 deletions backend/Makefile

This file was deleted.

4 changes: 2 additions & 2 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "2.17.0"
description = "Personal finance dashboard backend -- Excel import, financial analytics, multi-currency exchange rates"
authors = [{ name = "Sagar Gupta" }]
readme = "README.md"
requires-python = ">=3.11"
requires-python = ">=3.13"
dependencies = [
"sqlalchemy>=2.0.51",
"alembic>=1.18.4",
Expand Down Expand Up @@ -94,7 +94,7 @@ select = ["E", "F", "I", "N", "W", "UP"]
"src/ledger_sync/core/analytics_engine.py" = ["PERF401"]

[tool.mypy]
python_version = "3.12"
python_version = "3.13"
strict = true
warn_return_any = true
warn_unused_configs = true
Expand Down
8 changes: 6 additions & 2 deletions backend/src/ledger_sync/api/ai_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
record_usage,
)
from ledger_sync.api.deps import CurrentUser, DatabaseSession
from ledger_sync.api.rate_limit import limiter
from ledger_sync.api.rate_limit import limiter, user_limiter
from ledger_sync.config.settings import settings
from ledger_sync.db.models import UserPreferences

Expand Down Expand Up @@ -188,7 +188,11 @@ def _from_bedrock_blocks(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
503: {"description": "Bedrock is not configured on the server"},
},
)
@limiter.limit("30/minute")
# Two-layer rate limit. Per-user (30/min) is the primary throttle; IP-keyed
# (60/min) protects the auth path against unauthenticated floods and gives a
# safety net when a token is missing/malformed.
@user_limiter.limit("30/minute")
@limiter.limit("60/minute")
def bedrock_chat_proxy(
request: Request, # slowapi requires a `request: Request` parameter # noqa: ARG001
current_user: CurrentUser,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/ledger_sync/api/analytics_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from ledger_sync.api.analytics_v2_impl import (
networth_misc_router,
recurring_router,
spending_rule_router,
summaries_router,
)
from ledger_sync.api.deps import CurrentUser
Expand All @@ -27,6 +28,7 @@
# Mount the split sub-routers under the same prefix.
router.include_router(summaries_router)
router.include_router(recurring_router)
router.include_router(spending_rule_router)
router.include_router(networth_misc_router)


Expand Down
8 changes: 7 additions & 1 deletion backend/src/ledger_sync/api/analytics_v2_impl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

from .networth_misc import router as networth_misc_router
from .recurring import router as recurring_router
from .spending_rule import router as spending_rule_router
from .summaries import router as summaries_router

__all__ = ["networth_misc_router", "recurring_router", "summaries_router"]
__all__ = [
"networth_misc_router",
"recurring_router",
"spending_rule_router",
"summaries_router",
]
Loading
Loading