From ee6e640e1dbc7f4ff8d3e5da22216f1b79abe613 Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Thu, 9 Jul 2026 08:14:31 +0530 Subject: [PATCH 1/4] feat(backend): date-aware stock price lookup, price_at_vest on RSU vestings GET /api/stock-price/{symbol} accepts optional on_date and returns the close on that date (nearest prior trading day within a week for weekends/holidays) plus an as_of field. RsuVesting schema gains optional price_at_vest so vested income can be locked at the vest-date price. --- backend/src/ledger_sync/api/stock_price.py | 80 ++++++++++++-- backend/src/ledger_sync/schemas/salary.py | 5 + backend/tests/unit/test_salary_schemas.py | 12 +++ backend/tests/unit/test_stock_price.py | 120 +++++++++++++++++++++ 4 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 backend/tests/unit/test_stock_price.py diff --git a/backend/src/ledger_sync/api/stock_price.py b/backend/src/ledger_sync/api/stock_price.py index e5812af1..616e7615 100644 --- a/backend/src/ledger_sync/api/stock_price.py +++ b/backend/src/ledger_sync/api/stock_price.py @@ -1,10 +1,15 @@ """Stock price lookup endpoint. Proxies Yahoo Finance chart API to avoid CORS restrictions on the frontend. -Returns the latest regular-market price for a given ticker symbol. +Returns the latest regular-market price for a given ticker symbol, or the +closing price on a specific date when ``on_date`` is provided (used to lock +in vest-date prices for RSU vestings). """ -from fastapi import APIRouter, HTTPException, Request +from datetime import UTC, date, datetime, timedelta +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel from ledger_sync.api.deps import CurrentUser @@ -14,11 +19,38 @@ _YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" +# Look back a few days from the requested date so weekends/market holidays +# still resolve to the most recent prior trading day's close. +_HISTORICAL_LOOKBACK_DAYS = 7 + class StockPriceResponse(BaseModel): symbol: str price: float currency: str + as_of: date | None = None + + +def _historical_close(data: dict[str, Any], on_date: date) -> tuple[float, date]: + """Pick the last close on or before ``on_date`` from a Yahoo chart payload. + + Raises KeyError/IndexError/TypeError on malformed payloads, which the + caller maps to a 502. + """ + result = data["chart"]["result"][0] + timestamps: list[int] = result["timestamp"] + closes: list[float | None] = result["indicators"]["quote"][0]["close"] + + best: tuple[float, date] | None = None + for ts, close in zip(timestamps, closes, strict=False): + if close is None: + continue + trading_day = datetime.fromtimestamp(ts, tz=UTC).date() + if trading_day <= on_date: + best = (float(close), trading_day) + if best is None: + raise KeyError(f"no close on or before {on_date}") + return best @router.get( @@ -32,38 +64,72 @@ async def get_stock_price( symbol: str, request: Request, _current_user: CurrentUser, + on_date: date | None = Query( + default=None, + description=( + "Return the closing price on this date " + "(or the nearest prior trading day) instead of the latest price." + ), + ), ) -> StockPriceResponse: - """Fetch latest stock price for a ticker symbol via Yahoo Finance. + """Fetch a stock price for a ticker symbol via Yahoo Finance. Args: symbol: Stock ticker (e.g. AMZN, AAPL, GOOGL). request: FastAPI request (for shared httpx client). + on_date: Optional historical date; when set, returns that day's close + (falling back to the nearest prior trading day within a week). Returns: - Stock price response with symbol, price, and currency. + Stock price response with symbol, price, currency, and the date the + price is as of (None for latest-price lookups). """ symbol = symbol.upper().strip() if not symbol or len(symbol) > 10: raise HTTPException(status_code=400, detail="Invalid symbol") + if on_date is not None and on_date > datetime.now(tz=UTC).date(): + raise HTTPException(status_code=400, detail="on_date cannot be in the future") url = _YAHOO_CHART_URL.format(symbol=symbol) + if on_date is None: + params: dict[str, str | int] = {"interval": "1d", "range": "1d"} + else: + window_start = on_date - timedelta(days=_HISTORICAL_LOOKBACK_DAYS) + params = { + "interval": "1d", + "period1": int( + datetime.combine(window_start, datetime.min.time(), tzinfo=UTC).timestamp() + ), + "period2": int( + datetime.combine( + on_date + timedelta(days=1), datetime.min.time(), tzinfo=UTC + ).timestamp() + ), + } + try: client = request.app.state.http_client resp = await client.get( url, - params={"interval": "1d", "range": "1d"}, + params=params, headers={"User-Agent": "Mozilla/5.0"}, ) resp.raise_for_status() data = resp.json() meta = data["chart"]["result"][0]["meta"] - price = meta["regularMarketPrice"] currency = meta.get("currency", "USD") - return StockPriceResponse(symbol=symbol, price=price, currency=currency) + if on_date is None: + price = meta["regularMarketPrice"] + return StockPriceResponse(symbol=symbol, price=price, currency=currency) + + price, as_of = _historical_close(data, on_date) + return StockPriceResponse(symbol=symbol, price=price, currency=currency, as_of=as_of) + except HTTPException: + raise except Exception as e: logger.warning("Failed to fetch stock price for %s: %s", symbol, e) raise HTTPException( diff --git a/backend/src/ledger_sync/schemas/salary.py b/backend/src/ledger_sync/schemas/salary.py index b0517eda..44ffcd0c 100644 --- a/backend/src/ledger_sync/schemas/salary.py +++ b/backend/src/ledger_sync/schemas/salary.py @@ -31,6 +31,11 @@ class RsuVesting(BaseModel): date: date quantity: int = Field(gt=0) + price_at_vest: Decimal | None = Field( + default=None, + gt=0, + description="Stock price on the vest date, locked in once the vesting has passed.", + ) class RsuGrant(BaseModel): diff --git a/backend/tests/unit/test_salary_schemas.py b/backend/tests/unit/test_salary_schemas.py index a15ab12b..5e704f37 100644 --- a/backend/tests/unit/test_salary_schemas.py +++ b/backend/tests/unit/test_salary_schemas.py @@ -72,6 +72,18 @@ def test_vesting_quantity_must_be_positive(self): with pytest.raises(ValidationError): RsuVesting(date=date(2026, 3, 15), quantity=0) + def test_vesting_price_at_vest_defaults_to_none(self): + vesting = RsuVesting(date=date(2026, 3, 15), quantity=25) + assert vesting.price_at_vest is None + + def test_vesting_price_at_vest_accepts_positive(self): + vesting = RsuVesting(date=date(2025, 8, 15), quantity=6, price_at_vest=Decimal("21500.75")) + assert vesting.price_at_vest == Decimal("21500.75") + + def test_vesting_price_at_vest_rejects_zero(self): + with pytest.raises(ValidationError): + RsuVesting(date=date(2025, 8, 15), quantity=6, price_at_vest=Decimal("0")) + def test_stock_price_must_be_positive(self): with pytest.raises(ValidationError): RsuGrant( diff --git a/backend/tests/unit/test_stock_price.py b/backend/tests/unit/test_stock_price.py new file mode 100644 index 00000000..a73b0c53 --- /dev/null +++ b/backend/tests/unit/test_stock_price.py @@ -0,0 +1,120 @@ +"""Unit tests for the stock price lookup endpoint. + +The Yahoo Finance upstream is mocked via the app's shared httpx client, so +these verify the HTTP contract: latest-price lookups, historical vest-date +lookups (including weekend fallback to the prior trading day), and input +validation. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from ledger_sync.api.deps import get_current_user +from ledger_sync.api.stock_price import router as stock_price_router + + +def _ts(day: str) -> int: + """UTC midnight timestamp for a YYYY-MM-DD string.""" + return int(datetime.fromisoformat(f"{day}T00:00:00+00:00").astimezone(UTC).timestamp()) + + +def _make_app(yahoo_payload: dict[str, Any]) -> FastAPI: + app = FastAPI() + app.include_router(stock_price_router) + app.dependency_overrides[get_current_user] = lambda: MagicMock(id=1) + + response = MagicMock() + response.raise_for_status = MagicMock() + response.json = MagicMock(return_value=yahoo_payload) + app.state.http_client = MagicMock(get=AsyncMock(return_value=response)) + return app + + +def _latest_payload(price: float, currency: str = "USD") -> dict[str, Any]: + return {"chart": {"result": [{"meta": {"regularMarketPrice": price, "currency": currency}}]}} + + +def _historical_payload( + days: list[str], closes: list[float | None], currency: str = "USD" +) -> dict[str, Any]: + return { + "chart": { + "result": [ + { + "meta": {"currency": currency}, + "timestamp": [_ts(d) for d in days], + "indicators": {"quote": [{"close": closes}]}, + } + ] + } + } + + +def test_latest_price() -> None: + app = _make_app(_latest_payload(231.44)) + resp = TestClient(app).get("/api/stock-price/AMZN") + + assert resp.status_code == 200 + body = resp.json() + assert body["symbol"] == "AMZN" + assert body["price"] == 231.44 + assert body["as_of"] is None + + +def test_historical_price_exact_day() -> None: + app = _make_app( + _historical_payload(["2025-08-13", "2025-08-14", "2025-08-15"], [100.0, 101.0, 102.5]) + ) + resp = TestClient(app).get("/api/stock-price/AMZN", params={"on_date": "2025-08-15"}) + + assert resp.status_code == 200 + body = resp.json() + assert body["price"] == 102.5 + assert body["as_of"] == "2025-08-15" + + +def test_historical_price_weekend_falls_back_to_prior_close() -> None: + # 2025-08-16 is a Saturday; the last trading day is Friday the 15th. + app = _make_app(_historical_payload(["2025-08-14", "2025-08-15"], [101.0, 102.5])) + resp = TestClient(app).get("/api/stock-price/AMZN", params={"on_date": "2025-08-16"}) + + assert resp.status_code == 200 + body = resp.json() + assert body["price"] == 102.5 + assert body["as_of"] == "2025-08-15" + + +def test_historical_price_skips_null_closes() -> None: + app = _make_app(_historical_payload(["2025-08-14", "2025-08-15"], [101.0, None])) + resp = TestClient(app).get("/api/stock-price/AMZN", params={"on_date": "2025-08-15"}) + + assert resp.status_code == 200 + assert resp.json()["price"] == 101.0 + assert resp.json()["as_of"] == "2025-08-14" + + +def test_historical_price_no_data_returns_502() -> None: + app = _make_app(_historical_payload([], [])) + resp = TestClient(app).get("/api/stock-price/AMZN", params={"on_date": "2025-08-15"}) + + assert resp.status_code == 502 + + +def test_future_on_date_returns_400() -> None: + app = _make_app(_latest_payload(1.0)) + resp = TestClient(app).get("/api/stock-price/AMZN", params={"on_date": "2999-01-01"}) + + assert resp.status_code == 400 + + +def test_invalid_symbol_returns_400() -> None: + app = _make_app(_latest_payload(1.0)) + resp = TestClient(app).get("/api/stock-price/THISISWAYTOOLONG") + + assert resp.status_code == 400 From 92ed346094491bee8bbe08706036be0e2cd612fb Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Thu, 9 Jul 2026 08:15:01 +0530 Subject: [PATCH 2/4] feat(frontend): vested/upcoming RSU grouping with vest-date valuation Vesting tables split into Vested (date <= today, dimmed) and Upcoming sections, sorted chronologically; rows re-sort on date-input blur and pre-existing grants are normalized on load. Summary line splits into vested vs upcoming shares/value. New lib/rsuVesting.ts is the single source of truth for isVested / sortVestings / vestingPrice / splitRsuTotals, shared by the settings UI, projectionCalculator (vested rows use locked vest-date price, never appreciated) and tdsScheduleCalculator. useRsuGrants hook extracts grant state handling from SalaryStructureSection and auto-fetches the historical close once when a vesting passes, storing price_at_vest; editing a vested date clears the lock so it re-fetches. --- .../__tests__/projectionCalculator.test.ts | 29 ++- frontend/src/lib/__tests__/rsuVesting.test.ts | 86 ++++++++ frontend/src/lib/projectionCalculator.ts | 15 +- frontend/src/lib/rsuVesting.ts | 64 ++++++ frontend/src/lib/tdsScheduleCalculator.ts | 4 +- .../sections/SalaryStructureSection.tsx | 116 ++--------- .../settings/sections/salary/RsuGrants.tsx | 119 ++++-------- .../settings/sections/salary/VestingTable.tsx | 150 ++++++++++++++ .../settings/sections/salary/useRsuGrants.ts | 183 ++++++++++++++++++ .../src/pages/settings/useSettingsState.ts | 9 +- frontend/src/services/api/preferences.ts | 16 +- frontend/src/types/salary.ts | 2 + 12 files changed, 600 insertions(+), 193 deletions(-) create mode 100644 frontend/src/lib/__tests__/rsuVesting.test.ts create mode 100644 frontend/src/lib/rsuVesting.ts create mode 100644 frontend/src/pages/settings/sections/salary/VestingTable.tsx create mode 100644 frontend/src/pages/settings/sections/salary/useRsuGrants.ts diff --git a/frontend/src/lib/__tests__/projectionCalculator.test.ts b/frontend/src/lib/__tests__/projectionCalculator.test.ts index a6946c19..bd0a1e94 100644 --- a/frontend/src/lib/__tests__/projectionCalculator.test.ts +++ b/frontend/src/lib/__tests__/projectionCalculator.test.ts @@ -41,8 +41,10 @@ describe('getRsuVestingsByFY', () => { expect(result['2027-28'].value).toBe(3000) }) - it('applies stock appreciation', () => { - const result = getRsuVestingsByFY([testGrant], 4, 10, 2025) + it('applies stock appreciation to upcoming vestings', () => { + // Pin "today" before every vesting so all rows stay projections -- + // vested rows intentionally never get appreciation applied. + const result = getRsuVestingsByFY([testGrant], 4, 10, 2025, '2025-04-01') expect(result['2025-26'].value).toBe(2500) expect(result['2026-27'].value).toBeCloseTo(25 * 110, 0) expect(result['2027-28'].value).toBeCloseTo(30 * 121, 0) @@ -53,6 +55,29 @@ describe('getRsuVestingsByFY', () => { expect(Object.keys(result)).toHaveLength(0) }) + it('values vested rows at the locked vest-date price, without appreciation', () => { + const vestedGrant: RsuGrant = { + id: 'g-vested', + stock_name: 'AMZN', + stock_price: 200, + grant_date: null, + notes: null, + vestings: [ + { date: '2025-08-15', quantity: 6, price_at_vest: 150 }, // vested, locked + { date: '2026-02-15', quantity: 23 }, // vested, no locked price + { date: '2026-08-15', quantity: 18 }, // upcoming + ], + } + // Fixed "today" between the second and third vesting; appreciation 10%/yr + // from base FY 2025. + const result = getRsuVestingsByFY([vestedGrant], 4, 10, 2025, '2026-07-09') + // FY 2025-26 holds both vested rows: locked 150 and fallback current 200, + // neither appreciated. + expect(result['2025-26'].value).toBe(6 * 150 + 23 * 200) + // FY 2026-27 upcoming row: current price appreciated one year. + expect(result['2026-27'].value).toBeCloseTo(18 * 220, 5) + }) + it('formats FY strings correctly across the year-2100 boundary (regression test)', () => { // Old code used `(year + 1) % 100` which formatted FY 2099-2100 as // "2099-00" (invalid) and would then collide with any other FY whose diff --git a/frontend/src/lib/__tests__/rsuVesting.test.ts b/frontend/src/lib/__tests__/rsuVesting.test.ts new file mode 100644 index 00000000..9146678b --- /dev/null +++ b/frontend/src/lib/__tests__/rsuVesting.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' + +import { isVested, sortVestings, splitRsuTotals, vestingPrice } from '../rsuVesting' +import type { RsuGrant } from '@/types/salary' + +const TODAY = '2026-07-09' + +const grant: RsuGrant = { + id: 'g1', + stock_name: 'AMZN', + stock_price: 200, + grant_date: null, + notes: null, + vestings: [ + { date: '2026-08-15', quantity: 18 }, + { date: '2025-08-15', quantity: 6, price_at_vest: 150 }, + { date: '2026-02-15', quantity: 23 }, + ], +} + +describe('isVested', () => { + it('treats past and today as vested, future as not', () => { + expect(isVested({ date: '2025-08-15', quantity: 1 }, TODAY)).toBe(true) + expect(isVested({ date: TODAY, quantity: 1 }, TODAY)).toBe(true) + expect(isVested({ date: '2026-08-15', quantity: 1 }, TODAY)).toBe(false) + }) + + it('treats a blank date (row being typed) as not vested', () => { + expect(isVested({ date: '', quantity: 1 }, TODAY)).toBe(false) + }) +}) + +describe('sortVestings', () => { + it('sorts chronologically', () => { + const sorted = sortVestings(grant.vestings) + expect(sorted.map((v) => v.date)).toEqual(['2025-08-15', '2026-02-15', '2026-08-15']) + }) + + it('keeps blank-date rows at the end in stable order', () => { + const sorted = sortVestings([ + { date: '', quantity: 1 }, + { date: '2026-01-01', quantity: 2 }, + { date: '', quantity: 3 }, + ]) + expect(sorted.map((v) => v.quantity)).toEqual([2, 1, 3]) + }) + + it('does not mutate the input array', () => { + const input = [...grant.vestings] + sortVestings(input) + expect(input.map((v) => v.date)).toEqual(['2026-08-15', '2025-08-15', '2026-02-15']) + }) +}) + +describe('vestingPrice', () => { + it('uses the locked vest-date price for vested rows', () => { + expect(vestingPrice(grant, grant.vestings[1], TODAY)).toBe(150) + }) + + it('falls back to current price for vested rows without a locked price', () => { + expect(vestingPrice(grant, grant.vestings[2], TODAY)).toBe(200) + }) + + it('uses current price for upcoming rows even if price_at_vest is set', () => { + const future = { date: '2027-01-01', quantity: 5, price_at_vest: 999 } + expect(vestingPrice(grant, future, TODAY)).toBe(200) + }) +}) + +describe('splitRsuTotals', () => { + it('splits shares and value into vested vs upcoming buckets', () => { + const totals = splitRsuTotals([grant], TODAY) + // Vested: 6 @ locked 150 + 23 @ current 200 + expect(totals.vested.shares).toBe(29) + expect(totals.vested.value).toBe(6 * 150 + 23 * 200) + // Upcoming: 18 @ current 200 + expect(totals.upcoming.shares).toBe(18) + expect(totals.upcoming.value).toBe(18 * 200) + }) + + it('returns zeros for no grants', () => { + const totals = splitRsuTotals([], TODAY) + expect(totals.vested.shares).toBe(0) + expect(totals.upcoming.value).toBe(0) + }) +}) diff --git a/frontend/src/lib/projectionCalculator.ts b/frontend/src/lib/projectionCalculator.ts index 293d71f0..81839576 100644 --- a/frontend/src/lib/projectionCalculator.ts +++ b/frontend/src/lib/projectionCalculator.ts @@ -11,6 +11,7 @@ import { getTaxSlabs, } from '@/lib/taxCalculator' import { MONTHS_PER_YEAR } from '@/lib/dateUtils' +import { isVested, todayKey, vestingPrice } from '@/lib/rsuVesting' import type { GrowthAssumptions, ProjectedFYBreakdown, @@ -71,12 +72,19 @@ interface RsuFYData { details: Array<{ stock_name: string; shares: number; value: number }> } -/** Group RSU vestings by fiscal year with optional stock appreciation. */ +/** Group RSU vestings by fiscal year with optional stock appreciation. + * + * Vested rows (date <= today) are realized income: they use the locked + * vest-date price when available and never get appreciation applied. + * Upcoming rows are projections: current price grown by the appreciation + * assumption for the years between the base FY and the vesting FY. + */ export function getRsuVestingsByFY( grants: RsuGrant[], fyStartMonth: number, stockAppreciationPct: number, baseStartYear?: number, + today: string = todayKey(), ): Record { const result: Record = {} @@ -84,13 +92,14 @@ export function getRsuVestingsByFY( for (const vesting of grant.vestings) { const fy = dateToFY(vesting.date, fyStartMonth) const fyStart = parseFYStart(fy) + const vested = isVested(vesting, today) const yearsFromBase = - baseStartYear === undefined ? 0 : fyStart - baseStartYear + baseStartYear === undefined || vested ? 0 : fyStart - baseStartYear const appreciationFactor = Math.pow( 1 + stockAppreciationPct / 100, Math.max(0, yearsFromBase), ) - const adjustedPrice = N(grant.stock_price) * appreciationFactor + const adjustedPrice = vestingPrice(grant, vesting, today) * appreciationFactor const vestingValue = vesting.quantity * adjustedPrice if (!result[fy]) { diff --git a/frontend/src/lib/rsuVesting.ts b/frontend/src/lib/rsuVesting.ts new file mode 100644 index 00000000..c2684b30 --- /dev/null +++ b/frontend/src/lib/rsuVesting.ts @@ -0,0 +1,64 @@ +/** + * Shared RSU vesting helpers -- single source of truth for "is this vesting + * vested?" and "what price does it get valued at?" used by the settings UI, + * the tax projection calculator, and the TDS schedule calculator. + */ + +import { toLocalDateKey } from '@/lib/dateUtils' +import type { RsuGrant, RsuVesting } from '@/types/salary' + +/** Today's date as a local YYYY-MM-DD key. */ +export function todayKey(): string { + return toLocalDateKey(new Date()) +} + +/** A vesting is vested once its date is today or earlier. */ +export function isVested(v: RsuVesting, today: string = todayKey()): boolean { + return Boolean(v.date) && v.date <= today +} + +/** + * Sort vestings chronologically. Rows without a date (still being typed) + * keep their relative order at the end so a new blank row doesn't jump. + */ +export function sortVestings(vestings: RsuVesting[]): RsuVesting[] { + return [...vestings].sort((a, b) => { + if (!a.date && !b.date) return 0 + if (!a.date) return 1 + if (!b.date) return -1 + return a.date.localeCompare(b.date) + }) +} + +/** + * Effective per-share price for a vesting: vested rows use the locked + * vest-date price when available, everything else falls back to the + * grant's current price. + */ +export function vestingPrice(grant: RsuGrant, v: RsuVesting, today: string = todayKey()): number { + if (isVested(v, today) && v.price_at_vest != null && v.price_at_vest > 0) { + return Number(v.price_at_vest) + } + return Number(grant.stock_price) || 0 +} + +export interface RsuSplitTotals { + vested: { shares: number; value: number } + upcoming: { shares: number; value: number } +} + +/** Split share/value totals across all grants into vested vs upcoming. */ +export function splitRsuTotals(grants: RsuGrant[], today: string = todayKey()): RsuSplitTotals { + const totals: RsuSplitTotals = { + vested: { shares: 0, value: 0 }, + upcoming: { shares: 0, value: 0 }, + } + for (const g of grants) { + for (const v of g.vestings) { + const bucket = isVested(v, today) ? totals.vested : totals.upcoming + bucket.shares += v.quantity + bucket.value += v.quantity * vestingPrice(g, v, today) + } + } + return totals +} diff --git a/frontend/src/lib/tdsScheduleCalculator.ts b/frontend/src/lib/tdsScheduleCalculator.ts index e1e0119e..57cd2fbd 100644 --- a/frontend/src/lib/tdsScheduleCalculator.ts +++ b/frontend/src/lib/tdsScheduleCalculator.ts @@ -26,6 +26,7 @@ import { calculateTax, type TaxSlab } from '@/lib/taxCalculator' import { MONTHS_PER_YEAR } from '@/lib/dateUtils' +import { vestingPrice } from '@/lib/rsuVesting' import type { RsuGrant } from '@/types/salary' /** Month labels in fiscal-year order, starting from the FY start month. */ @@ -98,7 +99,8 @@ export function rsuExtrasByFyMonth( const vestingFyStart = m >= fyStartMonth ? y : y - 1 if (vestingFyStart !== fyStartYear) continue const idx = (m - fyStartMonth + MONTHS_PER_YEAR) % MONTHS_PER_YEAR - out[idx] = (out[idx] ?? 0) + v.quantity * (Number(grant.stock_price) || 0) + // Vested rows use the locked vest-date price; upcoming use current. + out[idx] = (out[idx] ?? 0) + v.quantity * vestingPrice(grant, v) } } return out diff --git a/frontend/src/pages/settings/sections/SalaryStructureSection.tsx b/frontend/src/pages/settings/sections/SalaryStructureSection.tsx index ca651126..8d1dd2bd 100644 --- a/frontend/src/pages/settings/sections/SalaryStructureSection.tsx +++ b/frontend/src/pages/settings/sections/SalaryStructureSection.tsx @@ -6,14 +6,8 @@ import { useCallback, useMemo, useState } from 'react' import { Banknote } from 'lucide-react' -import { preferencesService } from '@/services/api/preferences' import { selectDisplayCurrency, usePreferencesStore } from '@/store/preferencesStore' -import type { - GrowthAssumptions, - RsuGrant, - RsuVesting, - SalaryComponents, -} from '@/types/salary' +import type { GrowthAssumptions, RsuGrant, SalaryComponents } from '@/types/salary' import { DEFAULT_SALARY_COMPONENTS } from '@/types/salary' import { Section } from '../sectionPrimitives' @@ -21,6 +15,7 @@ import { GrowthAssumptionsForm } from './salary/GrowthAssumptionsForm' import { RsuGrants } from './salary/RsuGrants' import { SalaryFieldsGrid } from './salary/SalaryFieldsGrid' import { currentFYLabel, nextFY, parseBareStartYear } from './salary/fyHelpers' +import { useRsuGrants } from './salary/useRsuGrants' export interface SalaryStructureSectionProps { index: number @@ -115,101 +110,18 @@ export default function SalaryStructureSection({ ) }, [currentSalary]) - const addGrant = useCallback(() => { - const grant: RsuGrant = { - id: crypto.randomUUID(), - stock_name: '', - stock_price: 0, - grant_date: null, - notes: null, - vestings: [], - } - updateRsuGrants([...localRsuGrants, grant]) - }, [localRsuGrants, updateRsuGrants]) - - const removeGrant = useCallback( - (id: string) => updateRsuGrants(localRsuGrants.filter((g) => g.id !== id)), - [localRsuGrants, updateRsuGrants], - ) - - const updateGrant = useCallback( - (id: string, patch: Partial) => { - updateRsuGrants(localRsuGrants.map((g) => (g.id === id ? { ...g, ...patch } : g))) - }, - [localRsuGrants, updateRsuGrants], - ) - - const addVesting = useCallback( - (grantId: string) => { - updateGrant(grantId, { - vestings: [ - ...(localRsuGrants.find((g) => g.id === grantId)?.vestings ?? []), - { date: '', quantity: 0 }, - ], - }) - }, - [localRsuGrants, updateGrant], - ) - - const updateVesting = useCallback( - (grantId: string, vestIdx: number, patch: Partial) => { - const grant = localRsuGrants.find((g) => g.id === grantId) - if (!grant) return - const vestings = grant.vestings.map((v, i) => (i === vestIdx ? { ...v, ...patch } : v)) - updateGrant(grantId, { vestings }) - }, - [localRsuGrants, updateGrant], - ) - - const removeVesting = useCallback( - (grantId: string, vestIdx: number) => { - const grant = localRsuGrants.find((g) => g.id === grantId) - if (!grant) return - updateGrant(grantId, { vestings: grant.vestings.filter((_, i) => i !== vestIdx) }) - }, - [localRsuGrants, updateGrant], - ) - - const [fetchingPriceFor, setFetchingPriceFor] = useState(null) const displayCurrency = usePreferencesStore(selectDisplayCurrency) - - const fetchStockPrice = useCallback( - async (grant: RsuGrant) => { - if (!grant.stock_name.trim()) return - setFetchingPriceFor(grant.id) - try { - const result = await preferencesService.getStockPrice(grant.stock_name.trim()) - let price = result.price - - if (result.currency && result.currency !== displayCurrency) { - const rates = await preferencesService.getExchangeRates(result.currency) - const rate = rates.rates[displayCurrency] - if (rate) { - price = Math.round(price * rate * 100) / 100 - } - } - - updateGrant(grant.id, { stock_price: price }) - } catch { - /* user can still enter manually */ - } finally { - setFetchingPriceFor(null) - } - }, - [updateGrant, displayCurrency], - ) - - const rsuTotals = useMemo(() => { - let shares = 0 - let value = 0 - for (const g of localRsuGrants) { - for (const v of g.vestings) { - shares += v.quantity - value += v.quantity * g.stock_price - } - } - return { shares, value } - }, [localRsuGrants]) + const { + fetchingPriceFor, + addGrant, + removeGrant, + updateGrant, + addVesting, + updateVesting, + removeVesting, + sortGrantVestings, + fetchStockPrice, + } = useRsuGrants(localRsuGrants, updateRsuGrants, displayCurrency) const updateGrowth = useCallback( (field: keyof GrowthAssumptions, raw: string | boolean) => { @@ -252,13 +164,13 @@ export default function SalaryStructureSection({ diff --git a/frontend/src/pages/settings/sections/salary/RsuGrants.tsx b/frontend/src/pages/settings/sections/salary/RsuGrants.tsx index fbbdea7b..121d1f3f 100644 --- a/frontend/src/pages/settings/sections/salary/RsuGrants.tsx +++ b/frontend/src/pages/settings/sections/salary/RsuGrants.tsx @@ -1,22 +1,23 @@ -import { Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react' +import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react' import { formatCurrency } from '@/lib/formatters' import type { RsuGrant, RsuVesting } from '@/types/salary' import { FieldLabel } from '../../sectionPrimitives' import { inputClass } from '../../styles' -import { dateToFY } from './fyHelpers' +import { splitRsuTotals, todayKey } from '@/lib/rsuVesting' +import { VestingTable } from './VestingTable' interface RsuGrantsProps { grants: RsuGrant[] fetchingPriceFor: string | null - rsuTotals: { shares: number; value: number } onAddGrant: () => void onRemoveGrant: (id: string) => void onUpdateGrant: (id: string, patch: Partial) => void onAddVesting: (grantId: string) => void onUpdateVesting: (grantId: string, vestIdx: number, patch: Partial) => void onRemoveVesting: (grantId: string, vestIdx: number) => void + onSortVestings: (grantId: string) => void onFetchStockPrice: (grant: RsuGrant) => void } @@ -24,16 +25,20 @@ export function RsuGrants(props: Readonly) { const { grants, fetchingPriceFor, - rsuTotals, onAddGrant, onRemoveGrant, onUpdateGrant, onAddVesting, onUpdateVesting, onRemoveVesting, + onSortVestings, onFetchStockPrice, } = props + const today = todayKey() + const totals = splitRsuTotals(grants, today) + const hasAnyShares = totals.vested.shares + totals.upcoming.shares > 0 + return (
@@ -115,9 +120,7 @@ export function RsuGrants(props: Readonly) { id={`grant-notes-${grant.id}`} type="text" value={grant.notes ?? ''} - onChange={(e) => - onUpdateGrant(grant.id, { notes: e.target.value || null }) - } + onChange={(e) => onUpdateGrant(grant.id, { notes: e.target.value || null })} placeholder="Optional" className={inputClass} /> @@ -134,73 +137,13 @@ export function RsuGrants(props: Readonly) {
{grant.vestings.length > 0 && ( -
- - - - - - - - - - - {grant.vestings.map((v, vi) => { - const estValue = v.quantity * grant.stock_price - const fy = v.date ? dateToFY(v.date) : '' - return ( - - - - - - - - ) - })} - -
DateQtyEst. ValueFY -
- - onUpdateVesting(grant.id, vi, { date: e.target.value }) - } - className={`${inputClass} max-w-[160px]`} - /> - - - onUpdateVesting(grant.id, vi, { - quantity: e.target.value === '' ? 0 : Number(e.target.value), - }) - } - placeholder="0" - className={`${inputClass} max-w-[100px]`} - /> - - {estValue > 0 ? formatCurrency(estValue) : '--'} - - {fy ? `FY ${fy}` : '--'} - - -
-
+ )}
))} - {grants.length > 0 && rsuTotals.shares > 0 && ( -
+ {grants.length > 0 && hasAnyShares && ( +
- Total shares:{' '} - {rsuTotals.shares.toLocaleString()} + Vested:{' '} + + {totals.vested.shares.toLocaleString()} shares + + {totals.vested.value > 0 && ( + + {' '} + ({formatCurrency(totals.vested.value)}) + + )} - Total value:{' '} - {formatCurrency(rsuTotals.value)} + Upcoming:{' '} + + {totals.upcoming.shares.toLocaleString()} shares + + {totals.upcoming.value > 0 && ( + + {' '} + ({formatCurrency(totals.upcoming.value)}) + + )}
)} diff --git a/frontend/src/pages/settings/sections/salary/VestingTable.tsx b/frontend/src/pages/settings/sections/salary/VestingTable.tsx new file mode 100644 index 00000000..a35b6f40 --- /dev/null +++ b/frontend/src/pages/settings/sections/salary/VestingTable.tsx @@ -0,0 +1,150 @@ +import { X } from 'lucide-react' + +import { formatCurrency } from '@/lib/formatters' +import type { RsuGrant, RsuVesting } from '@/types/salary' + +import { inputClass } from '../../styles' +import { dateToFY } from './fyHelpers' +import { isVested, vestingPrice } from '@/lib/rsuVesting' + +export interface VestingTableProps { + grant: RsuGrant + today: string + onUpdateVesting: (grantId: string, vestIdx: number, patch: Partial) => void + onRemoveVesting: (grantId: string, vestIdx: number) => void + onSortVestings: (grantId: string) => void +} + +interface IndexedVesting { + vesting: RsuVesting + /** Index into the grant's state array -- edit handlers are index-based. */ + stateIdx: number +} + +function GroupDividerRow({ label, count }: Readonly<{ label: string; count: number }>) { + return ( + + + + {label} ({count}) + + + + ) +} + +function VestingRow({ + grant, + entry, + today, + vested, + onUpdateVesting, + onRemoveVesting, + onSortVestings, +}: Readonly< + Pick & { + entry: IndexedVesting + vested: boolean + } +>) { + const { vesting: v, stateIdx } = entry + const price = vestingPrice(grant, v, today) + const estValue = v.quantity * price + const fy = v.date ? dateToFY(v.date) : '' + const usesVestPrice = vested && v.price_at_vest != null && v.price_at_vest > 0 + + return ( + + + onUpdateVesting(grant.id, stateIdx, { date: e.target.value })} + onBlur={() => onSortVestings(grant.id)} + className={`${inputClass} max-w-[160px]`} + /> + + + + onUpdateVesting(grant.id, stateIdx, { + quantity: e.target.value === '' ? 0 : Number(e.target.value), + }) + } + placeholder="0" + className={`${inputClass} max-w-[100px]`} + /> + + + {estValue > 0 ? formatCurrency(estValue) : '--'} + + {fy ? `FY ${fy}` : '--'} + + + + + ) +} + +/** Vesting schedule table for one grant, grouped into Vested / Upcoming. */ +export function VestingTable(props: Readonly) { + const { grant, today } = props + + const indexed: IndexedVesting[] = grant.vestings.map((vesting, stateIdx) => ({ + vesting, + stateIdx, + })) + const vestedRows = indexed.filter((e) => isVested(e.vesting, today)) + const upcomingRows = indexed.filter((e) => !isVested(e.vesting, today)) + + return ( +
+ + + + + + + + + + + {vestedRows.length > 0 && } + {vestedRows.map((entry) => ( + + ))} + {upcomingRows.length > 0 && ( + + )} + {upcomingRows.map((entry) => ( + + ))} + +
DateQtyEst. ValueFY +
+
+ ) +} diff --git a/frontend/src/pages/settings/sections/salary/useRsuGrants.ts b/frontend/src/pages/settings/sections/salary/useRsuGrants.ts new file mode 100644 index 00000000..c434cc64 --- /dev/null +++ b/frontend/src/pages/settings/sections/salary/useRsuGrants.ts @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { preferencesService } from '@/services/api/preferences' +import type { RsuGrant, RsuVesting } from '@/types/salary' + +import { isVested, sortVestings, todayKey } from '@/lib/rsuVesting' + +/** + * RSU grant/vesting state handlers for the salary structure section. + * + * Also locks in vest-date prices: once a vesting date passes, the historical + * close for that date is fetched once and stored as `price_at_vest` so the + * vested value stops drifting with the current price. + */ +export function useRsuGrants( + localRsuGrants: RsuGrant[], + updateRsuGrants: (grants: RsuGrant[]) => void, + displayCurrency: string, +) { + const [fetchingPriceFor, setFetchingPriceFor] = useState(null) + + const addGrant = useCallback(() => { + const grant: RsuGrant = { + id: crypto.randomUUID(), + stock_name: '', + stock_price: 0, + grant_date: null, + notes: null, + vestings: [], + } + updateRsuGrants([...localRsuGrants, grant]) + }, [localRsuGrants, updateRsuGrants]) + + const removeGrant = useCallback( + (id: string) => updateRsuGrants(localRsuGrants.filter((g) => g.id !== id)), + [localRsuGrants, updateRsuGrants], + ) + + const updateGrant = useCallback( + (id: string, patch: Partial) => { + updateRsuGrants(localRsuGrants.map((g) => (g.id === id ? { ...g, ...patch } : g))) + }, + [localRsuGrants, updateRsuGrants], + ) + + const addVesting = useCallback( + (grantId: string) => { + updateGrant(grantId, { + vestings: [ + ...(localRsuGrants.find((g) => g.id === grantId)?.vestings ?? []), + { date: '', quantity: 0 }, + ], + }) + }, + [localRsuGrants, updateGrant], + ) + + const updateVesting = useCallback( + (grantId: string, vestIdx: number, patch: Partial) => { + const grant = localRsuGrants.find((g) => g.id === grantId) + if (!grant) return + const vestings = grant.vestings.map((v, i) => { + if (i !== vestIdx) return v + // A changed date invalidates any locked vest-date price. + const clearPrice = patch.date !== undefined && patch.date !== v.date + return { ...v, ...patch, ...(clearPrice ? { price_at_vest: null } : {}) } + }) + updateGrant(grantId, { vestings }) + }, + [localRsuGrants, updateGrant], + ) + + const removeVesting = useCallback( + (grantId: string, vestIdx: number) => { + const grant = localRsuGrants.find((g) => g.id === grantId) + if (!grant) return + updateGrant(grantId, { vestings: grant.vestings.filter((_, i) => i !== vestIdx) }) + }, + [localRsuGrants, updateGrant], + ) + + const sortGrantVestings = useCallback( + (grantId: string) => { + const grant = localRsuGrants.find((g) => g.id === grantId) + if (!grant) return + const sorted = sortVestings(grant.vestings) + // Skip the no-op case so a mere focus/blur doesn't mark settings dirty. + const changed = sorted.some((v, i) => v !== grant.vestings[i]) + if (changed) updateGrant(grantId, { vestings: sorted }) + }, + [localRsuGrants, updateGrant], + ) + + const convertPrice = useCallback( + async (price: number, currency: string): Promise => { + if (!currency || currency === displayCurrency) return price + const rates = await preferencesService.getExchangeRates(currency) + const rate = rates.rates[displayCurrency] + return rate ? Math.round(price * rate * 100) / 100 : price + }, + [displayCurrency], + ) + + const fetchStockPrice = useCallback( + async (grant: RsuGrant) => { + if (!grant.stock_name.trim()) return + setFetchingPriceFor(grant.id) + try { + const result = await preferencesService.getStockPrice(grant.stock_name.trim()) + const price = await convertPrice(result.price, result.currency) + updateGrant(grant.id, { stock_price: price }) + } catch { + /* user can still enter manually */ + } finally { + setFetchingPriceFor(null) + } + }, + [updateGrant, convertPrice], + ) + + // Lock vest-date prices for vested rows that don't have one yet. + // Attempted keys are remembered per session so upstream failures + // (delisted ticker, Yahoo hiccup) don't retrigger on every render. + const attemptedVestFetches = useRef(new Set()) + useEffect(() => { + const today = todayKey() + const jobs: Array<{ grantId: string; symbol: string; date: string }> = [] + for (const g of localRsuGrants) { + const symbol = g.stock_name.trim() + if (!symbol) continue + for (const v of g.vestings) { + if (!isVested(v, today) || (v.price_at_vest != null && v.price_at_vest > 0)) continue + const key = `${g.id}|${symbol}|${v.date}` + if (attemptedVestFetches.current.has(key)) continue + attemptedVestFetches.current.add(key) + jobs.push({ grantId: g.id, symbol, date: v.date }) + } + } + if (jobs.length === 0) return + + let cancelled = false + const run = async () => { + const fetched = new Map() + for (const job of jobs) { + try { + const result = await preferencesService.getStockPrice(job.symbol, job.date) + const price = await convertPrice(result.price, result.currency) + if (price > 0) fetched.set(`${job.grantId}|${job.date}`, price) + } catch { + /* falls back to current price in the UI */ + } + } + if (cancelled || fetched.size === 0) return + updateRsuGrants( + localRsuGrants.map((g) => ({ + ...g, + vestings: g.vestings.map((v) => { + const price = fetched.get(`${g.id}|${v.date}`) + return price !== undefined && (v.price_at_vest == null || v.price_at_vest <= 0) + ? { ...v, price_at_vest: price } + : v + }), + })), + ) + } + run() + return () => { + cancelled = true + } + }, [localRsuGrants, updateRsuGrants, convertPrice]) + + return { + fetchingPriceFor, + addGrant, + removeGrant, + updateGrant, + addVesting, + updateVesting, + removeVesting, + sortGrantVestings, + fetchStockPrice, + } +} diff --git a/frontend/src/pages/settings/useSettingsState.ts b/frontend/src/pages/settings/useSettingsState.ts index 2863bc11..13534a80 100644 --- a/frontend/src/pages/settings/useSettingsState.ts +++ b/frontend/src/pages/settings/useSettingsState.ts @@ -13,6 +13,7 @@ import { toast } from 'sonner' import { useDemoGuard } from '@/hooks/useDemoGuard' import type { SalaryComponents, RsuGrant, GrowthAssumptions } from '@/types/salary' import { DEFAULT_GROWTH_ASSUMPTIONS } from '@/types/salary' +import { sortVestings } from '@/lib/rsuVesting' import type { LocalPrefs, LocalPrefKey, LocalRule } from './types' import { ACCOUNT_TYPES } from './types' import { @@ -134,7 +135,13 @@ export function useSettingsState() { // eslint-disable-next-line react-hooks/set-state-in-effect -- seeding editable local copy from server preferences once setLocalPrefs(buildInitialLocalPrefs(preferences as unknown as Record) as unknown as LocalPrefs) if (preferences.salary_structure) setLocalSalaryStructure(preferences.salary_structure) - if (preferences.rsu_grants) setLocalRsuGrants(preferences.rsu_grants) + if (preferences.rsu_grants) { + // Grants saved before vesting sort-on-blur existed may hold rows in + // insertion order; normalize chronologically on load. + setLocalRsuGrants( + preferences.rsu_grants.map((g) => ({ ...g, vestings: sortVestings(g.vestings) })), + ) + } if (preferences.growth_assumptions) { setLocalGrowthAssumptions({ ...DEFAULT_GROWTH_ASSUMPTIONS, ...preferences.growth_assumptions }) } diff --git a/frontend/src/services/api/preferences.ts b/frontend/src/services/api/preferences.ts index d0756b29..9bc1857b 100644 --- a/frontend/src/services/api/preferences.ts +++ b/frontend/src/services/api/preferences.ts @@ -239,10 +239,18 @@ export const preferencesService = { updateRsuGrants: createSectionUpdater('rsu-grants'), updateGrowthAssumptions: createSectionUpdater('growth-assumptions'), - async getStockPrice(symbol: string): Promise<{ symbol: string; price: number; currency: string }> { - const response = await apiClient.get<{ symbol: string; price: number; currency: string }>( - `/api/stock-price/${encodeURIComponent(symbol)}`, - ) + async getStockPrice( + symbol: string, + onDate?: string, + ): Promise<{ symbol: string; price: number; currency: string; as_of: string | null }> { + const response = await apiClient.get<{ + symbol: string + price: number + currency: string + as_of: string | null + }>(`/api/stock-price/${encodeURIComponent(symbol)}`, { + params: onDate ? { on_date: onDate } : undefined, + }) return response.data }, diff --git a/frontend/src/types/salary.ts b/frontend/src/types/salary.ts index 870bdb61..d710636d 100644 --- a/frontend/src/types/salary.ts +++ b/frontend/src/types/salary.ts @@ -13,6 +13,8 @@ export interface SalaryComponents { export interface RsuVesting { date: string // YYYY-MM-DD quantity: number + /** Stock price on the vest date (display currency). Set once a vesting is in the past. */ + price_at_vest?: number | null } /** An RSU grant with its vesting schedule. */ From 1f32ab1330f75f5850140779097c94835055cf51 Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Thu, 9 Jul 2026 08:15:36 +0530 Subject: [PATCH 3/4] chore: release 2.22.0, backfill changelog, align versions everywhere CHANGELOG gains 2.22.0 (this work), 2.21.0 (PR #203, was missing) and renames the stale Unreleased heading to 2.20.0 (PR #202, merged 2026-07-04). Versions aligned across root/frontend package.json, backend pyproject + uv.lock, and APP_VERSION (health endpoint reported a hardcoded 1.0.0). HANDBOOK RSU section updated for vested/upcoming grouping. --- CHANGELOG.md | 21 ++++++++++++++++++++- backend/pyproject.toml | 2 +- backend/src/ledger_sync/api/main.py | 2 +- backend/uv.lock | 2 +- docs/HANDBOOK.md | 2 +- frontend/package.json | 2 +- package.json | 2 +- 7 files changed, 26 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f53efdbb..ae434e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,26 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- -## Unreleased -- `feat/comprehensive-hardening` (PR #202) +## 2.22.0 - 2026-07-09 + +RSU vesting schedule overhaul: vested vs upcoming grouping with vest-date valuation. + +### Added + +- **Vested / Upcoming grouping in RSU Grants** (Settings > Income & Salary Structure). Each grant's vesting table is split into a dimmed "Vested" section (date <= today) and an "Upcoming" section, both sorted chronologically. Rows re-sort on date-input blur (not per keystroke, so rows don't jump under the cursor), and grants saved before this change are normalized on load. The summary line now splits into "Vested: N shares (value)" and "Upcoming: N shares (value)" instead of one combined total. +- **Vest-date price locking (`price_at_vest`).** Once a vesting date passes, the historical close for that date is fetched once and stored on the vesting, so realized RSU income stops drifting with the current stock price. Falls back to the grant's current price when the lookup fails (delisted ticker, upstream hiccup); editing a vested row's date clears the locked price so it re-fetches. Backend: `GET /api/stock-price/{symbol}?on_date=YYYY-MM-DD` returns the close on that date (or the nearest prior trading day within a week, covering weekends/market holidays) plus an `as_of` field; `RsuVesting` schema gains optional `price_at_vest`. +- **`lib/rsuVesting.ts`** -- single source of truth for `isVested` / `sortVestings` / `vestingPrice` / `splitRsuTotals`, shared by the settings UI, the multi-year tax projection (`projectionCalculator.ts`), and the TDS schedule (`tdsScheduleCalculator.ts`). + +### Changed + +- **Projection math treats vested rows as realized income**: `getRsuVestingsByFY` values them at the locked vest-date price and never applies the stock-appreciation assumption to them; upcoming vestings keep the projection behavior (current price grown by appreciation %/yr). +- **RSU grant state handlers extracted** from `SalaryStructureSection.tsx` into a `useRsuGrants` hook; per-grant vesting table extracted into `VestingTable.tsx`. + +## 2.21.0 - 2026-07-07 + +Rule-based auto-categorization, transaction tags, and saved filter views (PR #203). Backend: categorization rules engine with retroactive apply, transaction tags, saved views CRUD. Frontend: tag chips + filtering on Transactions, saved-views menu, rules settings UI. + +## 2.20.0 - 2026-07-04 -- was `Unreleased` (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. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7c15d7fb..8929239e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ledger-sync" -version = "2.17.0" +version = "2.22.0" description = "Personal finance dashboard backend -- Excel import, financial analytics, multi-currency exchange rates" authors = [{ name = "Sagar Gupta" }] readme = "README.md" diff --git a/backend/src/ledger_sync/api/main.py b/backend/src/ledger_sync/api/main.py index 917d427b..b5227a9b 100644 --- a/backend/src/ledger_sync/api/main.py +++ b/backend/src/ledger_sync/api/main.py @@ -44,7 +44,7 @@ _MiddlewareCallNext = Callable[[Request], Awaitable[Response]] -APP_VERSION = "1.0.0" +APP_VERSION = "2.22.0" # Initialize logging at the configured level (LEDGER_SYNC_LOG_LEVEL, default INFO) setup_logging(settings.log_level) diff --git a/backend/uv.lock b/backend/uv.lock index bfe0f3fb..680c44f2 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -728,7 +728,7 @@ wheels = [ [[package]] name = "ledger-sync" -version = "2.17.0" +version = "2.22.0" source = { editable = "." } dependencies = [ { name = "alembic" }, diff --git a/docs/HANDBOOK.md b/docs/HANDBOOK.md index 5070d762..c7df1ed1 100644 --- a/docs/HANDBOOK.md +++ b/docs/HANDBOOK.md @@ -1671,7 +1671,7 @@ Centralized configuration page for financial preferences, account classification - *Salary Fields Grid:* Fiscal year navigation (previous/next buttons, "Add FY" button to create a new FY). For the selected FY, editable fields: Base Salary (annual), HRA (annual, optional), Bonus (annual), EPF (monthly), NPS (monthly, optional), Special Allowance (annual), Other Taxable (annual). All numeric inputs. Displays a highlighted box showing Annual CTC (sum of base + HRA + bonus + EPF*12 + NPS*12 + special allowance + other) and monthly pre-tax equivalent. *computed:* `localSalaryStructure` is a `Record` where FYLabel is a bare year string (e.g., "2023", "2024"). Navigating the FY list sorts by year. Adding a FY clones the previous year's values if any, otherwise uses `DEFAULT_SALARY_COMPONENTS` defaults. - - *RSU Grants:* Add/remove RSU grant records. Each grant has: Stock Name (text), Price/Share (numeric with live-fetch button), Notes (text), and a table of Vesting events. Vesting table has columns: Date, Quantity, Est. Value (computed as Qty × Stock Price), FY (auto-computed from date). Bottom shows total shares and total value across all grants. *computed:* `localRsuGrants` is an array of `RsuGrant` objects (id, stock_name, stock_price, grant_date, notes, vestings[]). Price fetch button calls `preferencesService.getStockPrice()` and converts to display currency using exchange rates if needed. + - *RSU Grants:* Add/remove RSU grant records. Each grant has: Stock Name (text), Price/Share (numeric with live-fetch button), Notes (text), and a table of Vesting events grouped into **Vested** (date <= today, dimmed) and **Upcoming** sections, each sorted chronologically (rows re-sort on date-input blur; grants saved pre-sort are normalized on load). Vesting table has columns: Date, Quantity, Est. Value, FY (auto-computed from date). Est. Value for vested rows uses the locked vest-date price (`price_at_vest`, auto-fetched once via `getStockPrice(symbol, on_date)` when a vesting passes; falls back to current price if unavailable); upcoming rows use Qty × current Stock Price. Editing a vested row's date clears the locked price so it re-fetches. Bottom shows split totals: Vested shares/value (green) and Upcoming shares/value. *computed:* `localRsuGrants` is an array of `RsuGrant` objects (id, stock_name, stock_price, grant_date, notes, vestings[] with optional price_at_vest). Vested/upcoming logic lives in `lib/rsuVesting.ts` (shared with projection + TDS calculators). Price fetch converts to display currency using exchange rates if needed. - *Growth Assumptions:* Inputs for financial projections. Fields: Base Salary Growth (%/yr), Stock Appreciation (%/yr), Projection Horizon (years, 1–30), Bonus Growth (%/yr), NPS Growth (%/yr), and a toggle "EPF Scales With Base". *computed:* Stored in `localGrowthAssumptions` (GrowthAssumptions type). diff --git a/frontend/package.json b/frontend/package.json index b94506fb..ce70df6d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.19.0", + "version": "2.22.0", "type": "module", "packageManager": "pnpm@10.32.0", "scripts": { diff --git a/package.json b/package.json index 229d4282..09a773bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ledger-sync", - "version": "2.19.0", + "version": "2.22.0", "description": "Personal finance dashboard -- import bank statements, track spending, and view analytics in any currency", "scripts": { "setup": "concurrently -n \"backend,frontend\" -c \"bgBlue.bold,bgMagenta.bold\" \"pnpm run setup:backend\" \"pnpm run setup:frontend\"", From 425c53cb5402c4d51832a3c65ab79a837aab428a Mon Sep 17 00:00:00 2001 From: Sagar Gupta Date: Thu, 9 Jul 2026 08:20:37 +0530 Subject: [PATCH 4/4] refactor: fix SonarCloud findings on PR #204 pytest.approx for float assertions, single throwing call in the exception test, Annotated Query param, and extract applyVestPrices to cut function nesting below 4 levels. --- backend/src/ledger_sync/api/stock_price.py | 16 +++++++------ backend/tests/unit/test_salary_schemas.py | 4 +++- backend/tests/unit/test_stock_price.py | 9 ++++---- .../settings/sections/salary/useRsuGrants.ts | 23 ++++++++++--------- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/backend/src/ledger_sync/api/stock_price.py b/backend/src/ledger_sync/api/stock_price.py index 616e7615..2f4ac041 100644 --- a/backend/src/ledger_sync/api/stock_price.py +++ b/backend/src/ledger_sync/api/stock_price.py @@ -7,7 +7,7 @@ """ from datetime import UTC, date, datetime, timedelta -from typing import Any +from typing import Annotated, Any from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel @@ -64,13 +64,15 @@ async def get_stock_price( symbol: str, request: Request, _current_user: CurrentUser, - on_date: date | None = Query( - default=None, - description=( - "Return the closing price on this date " - "(or the nearest prior trading day) instead of the latest price." + on_date: Annotated[ + date | None, + Query( + description=( + "Return the closing price on this date " + "(or the nearest prior trading day) instead of the latest price." + ), ), - ), + ] = None, ) -> StockPriceResponse: """Fetch a stock price for a ticker symbol via Yahoo Finance. diff --git a/backend/tests/unit/test_salary_schemas.py b/backend/tests/unit/test_salary_schemas.py index 5e704f37..a24b01ff 100644 --- a/backend/tests/unit/test_salary_schemas.py +++ b/backend/tests/unit/test_salary_schemas.py @@ -81,8 +81,10 @@ def test_vesting_price_at_vest_accepts_positive(self): assert vesting.price_at_vest == Decimal("21500.75") def test_vesting_price_at_vest_rejects_zero(self): + vest_date = date(2025, 8, 15) + zero = Decimal("0") with pytest.raises(ValidationError): - RsuVesting(date=date(2025, 8, 15), quantity=6, price_at_vest=Decimal("0")) + RsuVesting(date=vest_date, quantity=6, price_at_vest=zero) def test_stock_price_must_be_positive(self): with pytest.raises(ValidationError): diff --git a/backend/tests/unit/test_stock_price.py b/backend/tests/unit/test_stock_price.py index a73b0c53..a2279390 100644 --- a/backend/tests/unit/test_stock_price.py +++ b/backend/tests/unit/test_stock_price.py @@ -12,6 +12,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -63,7 +64,7 @@ def test_latest_price() -> None: assert resp.status_code == 200 body = resp.json() assert body["symbol"] == "AMZN" - assert body["price"] == 231.44 + assert body["price"] == pytest.approx(231.44) assert body["as_of"] is None @@ -75,7 +76,7 @@ def test_historical_price_exact_day() -> None: assert resp.status_code == 200 body = resp.json() - assert body["price"] == 102.5 + assert body["price"] == pytest.approx(102.5) assert body["as_of"] == "2025-08-15" @@ -86,7 +87,7 @@ def test_historical_price_weekend_falls_back_to_prior_close() -> None: assert resp.status_code == 200 body = resp.json() - assert body["price"] == 102.5 + assert body["price"] == pytest.approx(102.5) assert body["as_of"] == "2025-08-15" @@ -95,7 +96,7 @@ def test_historical_price_skips_null_closes() -> None: resp = TestClient(app).get("/api/stock-price/AMZN", params={"on_date": "2025-08-15"}) assert resp.status_code == 200 - assert resp.json()["price"] == 101.0 + assert resp.json()["price"] == pytest.approx(101.0) assert resp.json()["as_of"] == "2025-08-14" diff --git a/frontend/src/pages/settings/sections/salary/useRsuGrants.ts b/frontend/src/pages/settings/sections/salary/useRsuGrants.ts index c434cc64..b7bda563 100644 --- a/frontend/src/pages/settings/sections/salary/useRsuGrants.ts +++ b/frontend/src/pages/settings/sections/salary/useRsuGrants.ts @@ -5,6 +5,17 @@ import type { RsuGrant, RsuVesting } from '@/types/salary' import { isVested, sortVestings, todayKey } from '@/lib/rsuVesting' +/** Merge fetched vest-date prices (keyed `grantId|date`) into the grants. */ +function applyVestPrices(grants: RsuGrant[], fetched: Map): RsuGrant[] { + const withPrice = (grantId: string, v: RsuVesting): RsuVesting => { + const price = fetched.get(`${grantId}|${v.date}`) + return price !== undefined && (v.price_at_vest == null || v.price_at_vest <= 0) + ? { ...v, price_at_vest: price } + : v + } + return grants.map((g) => ({ ...g, vestings: g.vestings.map((v) => withPrice(g.id, v)) })) +} + /** * RSU grant/vesting state handlers for the salary structure section. * @@ -151,17 +162,7 @@ export function useRsuGrants( } } if (cancelled || fetched.size === 0) return - updateRsuGrants( - localRsuGrants.map((g) => ({ - ...g, - vestings: g.vestings.map((v) => { - const price = fetched.get(`${g.id}|${v.date}`) - return price !== undefined && (v.price_at_vest == null || v.price_at_vest <= 0) - ? { ...v, price_at_vest: price } - : v - }), - })), - ) + updateRsuGrants(applyVestPrices(localRsuGrants, fetched)) } run() return () => {