Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion backend/src/ledger_sync/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
82 changes: 75 additions & 7 deletions backend/src/ledger_sync/api/stock_price.py
Original file line number Diff line number Diff line change
@@ -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 Annotated, Any

from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel

from ledger_sync.api.deps import CurrentUser
Expand All @@ -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(
Expand All @@ -32,38 +64,74 @@ async def get_stock_price(
symbol: str,
request: Request,
_current_user: CurrentUser,
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 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(
Expand Down
5 changes: 5 additions & 0 deletions backend/src/ledger_sync/schemas/salary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
14 changes: 14 additions & 0 deletions backend/tests/unit/test_salary_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ 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):
vest_date = date(2025, 8, 15)
zero = Decimal("0")
with pytest.raises(ValidationError):
RsuVesting(date=vest_date, quantity=6, price_at_vest=zero)

def test_stock_price_must_be_positive(self):
with pytest.raises(ValidationError):
RsuGrant(
Expand Down
121 changes: 121 additions & 0 deletions backend/tests/unit/test_stock_price.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""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

import pytest
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"] == pytest.approx(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"] == pytest.approx(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"] == pytest.approx(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"] == pytest.approx(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
2 changes: 1 addition & 1 deletion backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/HANDBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<FYLabel, SalaryComponents>` 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).

Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.19.0",
"version": "2.22.0",
"type": "module",
"packageManager": "pnpm@10.32.0",
"scripts": {
Expand Down
Loading
Loading