diff --git a/backend/alembic/versions/3b7c941bbadf_add_idle_cash_thresholds.py b/backend/alembic/versions/3b7c941bbadf_add_idle_cash_thresholds.py new file mode 100644 index 0000000..9af7838 --- /dev/null +++ b/backend/alembic/versions/3b7c941bbadf_add_idle_cash_thresholds.py @@ -0,0 +1,36 @@ +"""add idle cash thresholds to app_settings + +Revision ID: 3b7c941bbadf +Revises: a71d9f0c3e58 +Create Date: 2026-08-25 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3b7c941bbadf' +down_revision: Union[str, None] = 'a71d9f0c3e58' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'app_settings', + sa.Column('idle_cash_threshold_amount', sa.Numeric(14, 2), nullable=False, server_default='1000'), + ) + op.add_column( + 'app_settings', + sa.Column('idle_cash_threshold_days', sa.Integer(), nullable=False, server_default='60'), + ) + op.alter_column('app_settings', 'idle_cash_threshold_amount', server_default=None) + op.alter_column('app_settings', 'idle_cash_threshold_days', server_default=None) + + +def downgrade() -> None: + op.drop_column('app_settings', 'idle_cash_threshold_days') + op.drop_column('app_settings', 'idle_cash_threshold_amount') diff --git a/backend/alembic/versions/8e2f5a13c9d4_add_category_parent_id.py b/backend/alembic/versions/8e2f5a13c9d4_add_category_parent_id.py new file mode 100644 index 0000000..3e45f36 --- /dev/null +++ b/backend/alembic/versions/8e2f5a13c9d4_add_category_parent_id.py @@ -0,0 +1,32 @@ +"""add category parent_id (subcategories) + +Revision ID: 8e2f5a13c9d4 +Revises: 3b7c941bbadf +Create Date: 2026-08-25 10:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8e2f5a13c9d4' +down_revision: Union[str, None] = '3b7c941bbadf' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('categories', sa.Column('parent_id', sa.Integer(), nullable=True)) + op.create_foreign_key( + 'fk_categories_parent_id_categories', 'categories', 'categories', ['parent_id'], ['id'], ondelete='SET NULL' + ) + op.create_index('ix_categories_parent_id', 'categories', ['parent_id']) + + +def downgrade() -> None: + op.drop_index('ix_categories_parent_id', table_name='categories') + op.drop_constraint('fk_categories_parent_id_categories', 'categories', type_='foreignkey') + op.drop_column('categories', 'parent_id') diff --git a/backend/app/api/routes/categories.py b/backend/app/api/routes/categories.py index ee42adc..60ee66d 100644 --- a/backend/app/api/routes/categories.py +++ b/backend/app/api/routes/categories.py @@ -10,6 +10,21 @@ from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate router = APIRouter(prefix="/categories", tags=["categories"]) +async def _validate_parent(session: AsyncSession, parent_id: int, kind: CategoryKind, category_id: int | None) -> None: + """Subcategories are one level deep only: a parent must itself be + top-level, and must share the child's kind (an expense category can't + nest under an income one, or vice versa).""" + if parent_id == category_id: + raise HTTPException(status_code=400, detail="A category cannot be its own parent") + parent = await session.get(Category, parent_id) + if parent is None: + raise HTTPException(status_code=400, detail="Parent category not found") + if parent.parent_id is not None: + raise HTTPException(status_code=400, detail="Subcategories can only be one level deep") + if parent.kind != kind: + raise HTTPException(status_code=400, detail="A subcategory must have the same kind as its parent") + + @router.get("", response_model=list[CategoryRead]) async def list_categories( kind: CategoryKind | None = None, session: AsyncSession = Depends(get_session) @@ -23,6 +38,8 @@ async def list_categories( @router.post("", response_model=CategoryRead, status_code=201) async def create_category(payload: CategoryCreate, session: AsyncSession = Depends(get_session)) -> Category: + if payload.parent_id is not None: + await _validate_parent(session, payload.parent_id, payload.kind, category_id=None) category = Category(**payload.model_dump(), is_default=False) session.add(category) await session.commit() @@ -37,7 +54,15 @@ async def update_category( category = await session.get(Category, category_id) if category is None: raise HTTPException(status_code=404, detail="Category not found") - for field, value in payload.model_dump(exclude_unset=True).items(): + updates = payload.model_dump(exclude_unset=True) + if "parent_id" in updates and updates["parent_id"] is not None: + await _validate_parent(session, updates["parent_id"], category.kind, category_id=category_id) + has_children = ( + await session.execute(select(Category.id).where(Category.parent_id == category_id).limit(1)) + ).first() + if has_children is not None: + raise HTTPException(status_code=400, detail="A category with subcategories cannot become a subcategory itself") + for field, value in updates.items(): setattr(category, field, value) await session.commit() await session.refresh(category) diff --git a/backend/app/models/category.py b/backend/app/models/category.py index 7b1a5f5..3609b99 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -1,5 +1,5 @@ """A spending/income category, colored so it maps 1:1 to a dashboard chart slot.""" -from sqlalchemy import Boolean, Enum, Integer, String +from sqlalchemy import Boolean, Enum, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -22,5 +22,12 @@ class Category(Base): # Fixed slot ordering keeps the donut chart's category order stable across renders. sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # Self-referential, one level deep only (routes/categories.py rejects a + # parent that itself has a parent) — a subcategory ("Alcohol" under + # "Groceries"). SET NULL so deleting a parent turns its children back + # into top-level categories instead of cascading the delete. + parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id", ondelete="SET NULL"), nullable=True) transactions: Mapped[list["Transaction"]] = relationship(back_populates="category") + parent: Mapped["Category | None"] = relationship(remote_side=[id], back_populates="children") + children: Mapped[list["Category"]] = relationship(back_populates="parent") diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 329b925..2122d63 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -2,7 +2,9 @@ single-row table (id is always 1). Primary display currency (see UPDATES.md for why this doesn't do currency conversion) and the proactive-alert thresholds consumed by services/insights_service.py.""" -from sqlalchemy import Integer, String +from decimal import Decimal + +from sqlalchemy import Integer, Numeric, String from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -21,3 +23,9 @@ class AppSettings(Base): # tiers before insights_service.py raises risky_allocation_exceeded — # the classic "80% at zero risk, 20% at most exposed" rule. risky_allocation_threshold_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=20) + # A depository account (checking/savings/cash) sitting at or above this + # balance with no transaction touching it in idle_cash_threshold_days + # raises insights_service.py's idle_cash alert — money that isn't + # working. In the app's display currency (see `currency` above). + idle_cash_threshold_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("1000")) + idle_cash_threshold_days: Mapped[int] = mapped_column(Integer, nullable=False, default=60) diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py index 0b2603e..d923cc5 100644 --- a/backend/app/schemas/backup.py +++ b/backend/app/schemas/backup.py @@ -37,6 +37,9 @@ class CategoryBackup(BaseModel): color: str sort_order: int is_default: bool + # Defaulted so a backup exported before subcategories existed still + # imports cleanly under the same format version. + parent_id: int | None = None class TransactionBackup(BaseModel): @@ -136,6 +139,10 @@ class AppSettingsBackup(BaseModel): # Defaulted so a backup exported before this threshold existed still # imports cleanly under the same format version. risky_allocation_threshold_percent: int = 20 + # Defaulted so a backup exported before idle-cash thresholds existed + # still imports cleanly under the same format version. + idle_cash_threshold_amount: Decimal = Decimal("1000") + idle_cash_threshold_days: int = 60 class BackupPayload(BaseModel): diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py index c50098a..4c61efd 100644 --- a/backend/app/schemas/category.py +++ b/backend/app/schemas/category.py @@ -9,6 +9,9 @@ class CategoryBase(BaseModel): icon: str | None = None color: str = Field(pattern=r"^#[0-9a-fA-F]{6}$") sort_order: int = 0 + # Subcategory parent, one level deep only — routes/categories.py rejects + # a parent that itself already has a parent. + parent_id: int | None = None class CategoryCreate(CategoryBase): @@ -20,6 +23,10 @@ class CategoryUpdate(BaseModel): icon: str | None = None color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$") sort_order: int | None = None + # The route uses exclude_unset=True, so sending parent_id explicitly as + # null (as opposed to omitting the field) is what turns a subcategory + # back into a top-level category. + parent_id: int | None = None class CategoryRead(CategoryBase): diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index a4b12d7..0bdde0b 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from pydantic import BaseModel, Field @@ -6,6 +8,8 @@ class AppSettingsRead(BaseModel): negative_cash_flow_threshold_months: int net_worth_decline_threshold_months: int risky_allocation_threshold_percent: int + idle_cash_threshold_amount: Decimal + idle_cash_threshold_days: int class AppSettingsUpdate(BaseModel): @@ -24,3 +28,7 @@ class AppSettingsUpdate(BaseModel): # Max % of capital allowed in medium/high risk tiers (the 80/20 rule's # "20% at most exposed" half) before risky_allocation_exceeded fires. risky_allocation_threshold_percent: int | None = Field(default=None, ge=1, le=100) + # Balance (in the app's display currency) and days of no activity a + # depository account needs to hit before idle_cash fires. + idle_cash_threshold_amount: Decimal | None = Field(default=None, ge=0, max_digits=14, decimal_places=2) + idle_cash_threshold_days: int | None = Field(default=None, ge=1, le=365) diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 24adec5..9c98e80 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -75,6 +75,10 @@ def _validate_references(payload: BackupPayload) -> None: category_ids = {row.id for row in payload.categories} asset_ids = {row.id for row in payload.assets} + for c in payload.categories: + if c.parent_id is not None and c.parent_id not in category_ids: + raise HTTPException(400, f"Category {c.id} references unknown parent_id {c.parent_id}") + for t in payload.transactions: if t.account_id not in account_ids: raise HTTPException(400, f"Transaction {t.id} references unknown account_id {t.account_id}") @@ -145,9 +149,14 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None: await session.execute(delete(Category)) await session.execute(delete(Account)) - # Parents before children. + # Parents before children. Categories are additionally self-referential + # (parent_id points at another row in the same table) — sort + # top-level categories first so a subcategory's FK is never inserted + # ahead of the row it points to. + categories_in_order = sorted(payload.categories, key=lambda row: row.parent_id is not None) + session.add_all(Account(**row.model_dump()) for row in payload.accounts) - session.add_all(Category(**row.model_dump()) for row in payload.categories) + session.add_all(Category(**row.model_dump()) for row in categories_in_order) session.add_all(Asset(**row.model_dump()) for row in payload.assets) session.add_all(Transaction(**row.model_dump()) for row in payload.transactions) session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index ae85758..daf4b51 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -5,6 +5,7 @@ from decimal import Decimal from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from app.models.category import Category from app.models.enums import TransactionType @@ -37,22 +38,33 @@ async def get_dashboard_summary(session: AsyncSession, year: int, month: int) -> spent = totals.get(TransactionType.EXPENSE, Decimal("0")) transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0")) + # A subcategory's spending rolls up into its parent's slice — Parent is a + # self-join to resolve each transaction's *top-level* category, falling + # back to the transaction's own category when it has no parent. + Parent = aliased(Category) + effective_id = func.coalesce(Category.parent_id, Category.id) + effective_name = func.coalesce(Parent.name, Category.name) + effective_color = func.coalesce(Parent.color, Category.color) + effective_icon = func.coalesce(Parent.icon, Category.icon) + effective_sort = func.coalesce(Parent.sort_order, Category.sort_order) + breakdown_stmt = ( select( - Category.id, - Category.name, - Category.color, - Category.icon, + effective_id.label("category_id"), + effective_name.label("name"), + effective_color.label("color"), + effective_icon.label("icon"), func.coalesce(func.sum(Transaction.amount), 0).label("amount"), ) .join(Category, Category.id == Transaction.category_id) + .outerjoin(Parent, Parent.id == Category.parent_id) .where( Transaction.date >= start, Transaction.date <= end, Transaction.type == TransactionType.EXPENSE, ) - .group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order) - .order_by(func.sum(Transaction.amount).desc(), Category.sort_order) + .group_by(effective_id, effective_name, effective_color, effective_icon, effective_sort) + .order_by(func.sum(Transaction.amount).desc(), effective_sort) ) breakdown_result = await session.execute(breakdown_stmt) rows = breakdown_result.all() diff --git a/backend/app/services/insights_service.py b/backend/app/services/insights_service.py index ccfb7ac..8473e19 100644 --- a/backend/app/services/insights_service.py +++ b/backend/app/services/insights_service.py @@ -1,20 +1,26 @@ """Proactive early-warning checks, computed from data that already exists. -Four signals: sustained negative monthly cash flow, a sustained net-worth -decline, one or more over-budget categories, and too much capital in -medium/high risk tiers (the "80% at zero risk, 20% at most exposed" rule). -The first two only look at fully-elapsed calendar months, so a same-month -false alarm (rent already paid, salary not landed yet) never fires — their -"sustained for how long" thresholds, and the risk-allocation percentage, are +Five signals: sustained negative monthly cash flow, a sustained net-worth +decline, one or more over-budget categories, too much capital in medium/high +risk tiers (the "80% at zero risk, 20% at most exposed" rule), and cash +sitting idle in a depository account. The first two only look at +fully-elapsed calendar months, so a same-month false alarm (rent already +paid, salary not landed yet) never fires — their "sustained for how long" +thresholds, the risk-allocation percentage, and the idle-cash amount/days are all user-configurable (Settings page), stored on AppSettings, see get_or_create_app_settings. The budget check is the opposite: it deliberately looks at the CURRENT, still-in-progress month, since the whole point is to catch overspending while there's still time to react. """ -from datetime import date +from collections import defaultdict +from datetime import date, timedelta from decimal import Decimal +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.models.account import Account +from app.models.enums import AccountType, TransactionType +from app.models.transaction import Transaction from app.schemas.insights import AlertsResponse, FinancialAlert from app.schemas.net_worth import NetWorthSummary from app.services.budget_service import get_budget_status @@ -24,6 +30,11 @@ from app.services.settings_service import get_or_create_app_settings MAX_LOOKBACK_MONTHS = 24 +# Accounts where a big, untouched balance means money isn't working — a +# credit card balance isn't "your cash", an investment account is already +# invested, and OTHER is too ambiguous to guess at. +_IDLE_CASH_ACCOUNT_TYPES = (AccountType.CHECKING, AccountType.SAVINGS, AccountType.CASH) + def _previous_month(year: int, month: int) -> tuple[int, int]: return (year - 1, 12) if month == 1 else (year, month - 1) @@ -68,6 +79,55 @@ def _net_worth_decline_streak(summary: NetWorthSummary) -> int: return streak +async def _idle_cash_account_count(session: AsyncSession, threshold_amount: Decimal, threshold_days: int) -> int: + eligible_ids = set( + ( + await session.execute( + select(Account.id).where( + Account.is_archived.is_(False), Account.type.in_(_IDLE_CASH_ACCOUNT_TYPES) + ) + ) + ) + .scalars() + .all() + ) + if not eligible_ids: + return 0 + + # Same derivation account_service._account_balances uses — balance is + # never stored, only ever summed from the full transaction history — plus + # tracking the most recent date that touched each account along the way. + rows = await session.execute( + select(Transaction.type, Transaction.amount, Transaction.account_id, Transaction.transfer_account_id, Transaction.date) + ) + balances: dict[int, Decimal] = defaultdict(Decimal) + last_activity: dict[int, date] = {} + + def touch(account_id: int | None, tx_date: date) -> None: + if account_id in eligible_ids and (account_id not in last_activity or tx_date > last_activity[account_id]): + last_activity[account_id] = tx_date + + for tx_type, amount, account_id, transfer_account_id, tx_date in rows.all(): + if tx_type == TransactionType.INCOME: + balances[account_id] += amount + elif tx_type == TransactionType.EXPENSE: + balances[account_id] -= amount + elif tx_type == TransactionType.TRANSFER: + balances[account_id] -= amount + if transfer_account_id is not None: + balances[transfer_account_id] += amount + touch(account_id, tx_date) + touch(transfer_account_id, tx_date) + + cutoff = date.today() - timedelta(days=threshold_days) + return sum( + 1 + for account_id in eligible_ids + if balances.get(account_id, Decimal("0")) >= threshold_amount + and last_activity.get(account_id, cutoff) <= cutoff + ) + + async def get_financial_alerts(session: AsyncSession) -> AlertsResponse: settings = await get_or_create_app_settings(session) alerts: list[FinancialAlert] = [] @@ -116,4 +176,16 @@ async def get_financial_alerts(session: AsyncSession) -> AlertsResponse: ) ) + idle_cash_count = await _idle_cash_account_count( + session, settings.idle_cash_threshold_amount, settings.idle_cash_threshold_days + ) + if idle_cash_count > 0: + alerts.append( + FinancialAlert( + key="idle_cash", + severity="warning", + params={"count": idle_cash_count, "days": settings.idle_cash_threshold_days}, + ) + ) + return AlertsResponse(alerts=alerts) diff --git a/backend/app/services/reports_service.py b/backend/app/services/reports_service.py index 9576de8..0be806c 100644 --- a/backend/app/services/reports_service.py +++ b/backend/app/services/reports_service.py @@ -10,6 +10,7 @@ from decimal import Decimal from fastapi import HTTPException from sqlalchemy import extract, func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from app.models.category import Category from app.models.enums import CategoryKind, TransactionType @@ -33,8 +34,18 @@ async def get_category_spending_report( if category is None: raise HTTPException(status_code=404, detail="Category not found") + # A top-level category's own report folds in its subcategories' spending + # too (same rollup as the Dashboard breakdown); a subcategory picked + # directly shows just its own transactions — there's nothing beneath it. + category_ids: list[int] = [category_id] + if category.parent_id is None: + child_ids = ( + await session.execute(select(Category.id).where(Category.parent_id == category_id)) + ).scalars().all() + category_ids.extend(child_ids) + bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where( - Transaction.category_id == category_id + Transaction.category_id.in_(category_ids) ) if start_date: bounds_stmt = bounds_stmt.where(Transaction.date >= start_date) @@ -68,7 +79,7 @@ async def get_category_spending_report( func.count(Transaction.id).label("cnt"), ) .where( - Transaction.category_id == category_id, + Transaction.category_id.in_(category_ids), Transaction.date >= effective_start, Transaction.date <= effective_end, ) @@ -115,24 +126,34 @@ async def get_category_ranking_report( arbitrary period — "which category costs the most" across the whole range, unlike the month-scoped Dashboard breakdown or the single-category detail above.""" + # Same subcategory-into-parent rollup as the Dashboard breakdown — see + # dashboard_service.get_dashboard_summary. + Parent = aliased(Category) + effective_id = func.coalesce(Category.parent_id, Category.id) + effective_name = func.coalesce(Parent.name, Category.name) + effective_color = func.coalesce(Parent.color, Category.color) + effective_icon = func.coalesce(Parent.icon, Category.icon) + effective_sort = func.coalesce(Parent.sort_order, Category.sort_order) + stmt = ( select( - Category.id, - Category.name, - Category.color, - Category.icon, + effective_id.label("category_id"), + effective_name.label("name"), + effective_color.label("color"), + effective_icon.label("icon"), func.sum(Transaction.amount).label("amount"), func.count(Transaction.id).label("cnt"), ) .join(Category, Category.id == Transaction.category_id) + .outerjoin(Parent, Parent.id == Category.parent_id) .where(Category.kind == kind, Transaction.type == _KIND_TO_TRANSACTION_TYPE[kind]) ) if start_date: stmt = stmt.where(Transaction.date >= start_date) if end_date: stmt = stmt.where(Transaction.date <= end_date) - stmt = stmt.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order) - stmt = stmt.order_by(func.sum(Transaction.amount).desc(), Category.sort_order) + stmt = stmt.group_by(effective_id, effective_name, effective_color, effective_icon, effective_sort) + stmt = stmt.order_by(func.sum(Transaction.amount).desc(), effective_sort) rows = (await session.execute(stmt)).all() total_amount = sum((row.amount for row in rows), Decimal("0")) diff --git a/backend/tests/test_categories.py b/backend/tests/test_categories.py new file mode 100644 index 0000000..56a5022 --- /dev/null +++ b/backend/tests/test_categories.py @@ -0,0 +1,69 @@ +"""Category subcategories (parent_id): creation rules and the one-level-deep +invariant enforced in api/routes/categories.py. +""" +from httpx import AsyncClient + + +async def test_create_subcategory_under_top_level_category(client: AsyncClient, categories): + groceries = categories["Groceries"]["id"] + resp = await client.post( + "/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries} + ) + assert resp.status_code == 201 + assert resp.json()["parent_id"] == groceries + + +async def test_subcategory_cannot_have_a_different_kind_than_its_parent(client: AsyncClient, categories): + groceries = categories["Groceries"]["id"] + resp = await client.post( + "/categories", json={"name": "Bad", "kind": "income", "color": "#e34948", "parent_id": groceries} + ) + assert resp.status_code == 400 + + +async def test_subcategories_are_only_one_level_deep(client: AsyncClient, categories): + groceries = categories["Groceries"]["id"] + child = await client.post( + "/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries} + ) + child_id = child.json()["id"] + + resp = await client.post( + "/categories", json={"name": "Wine", "kind": "expense", "color": "#e34948", "parent_id": child_id} + ) + assert resp.status_code == 400 + + +async def test_category_cannot_be_its_own_parent(client: AsyncClient, categories): + groceries = categories["Groceries"]["id"] + resp = await client.patch(f"/categories/{groceries}", json={"parent_id": groceries}) + assert resp.status_code == 400 + + +async def test_a_category_with_subcategories_cannot_itself_become_a_subcategory(client: AsyncClient, categories): + groceries = categories["Groceries"]["id"] + dining = categories["Dining Out"]["id"] + await client.post( + "/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries} + ) + + resp = await client.patch(f"/categories/{groceries}", json={"parent_id": dining}) + assert resp.status_code == 400 + + +async def test_deleting_a_parent_leaves_children_as_top_level(client: AsyncClient, categories): + # A default category can't be deleted at all (separate rule) — use a + # fresh, non-default parent so this test isolates the parent/child rule. + parent = await client.post("/categories", json={"name": "Custom Parent", "kind": "expense", "color": "#e34948"}) + parent_id = parent.json()["id"] + created = await client.post( + "/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": parent_id} + ) + child_id = created.json()["id"] + + resp = await client.delete(f"/categories/{parent_id}") + assert resp.status_code == 204 + + refetched = await client.get("/categories") + child = next(c for c in refetched.json() if c["id"] == child_id) + assert child["parent_id"] is None diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 304af7e..0276a9a 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -81,6 +81,23 @@ async def test_spending_by_category_amounts_and_percent(client: AsyncClient, acc assert breakdown["Dining Out"]["percent"] == 25.0 +async def test_subcategory_spending_rolls_up_into_its_parent(client: AsyncClient, account_id, categories): + groceries = categories["Groceries"]["id"] + alcohol = await client.post( + "/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries} + ) + alcohol_id = alcohol.json()["id"] + + await client.post("/transactions", json=_txn(account_id, amount="60.00", category_id=groceries, date="2026-08-01")) + await client.post("/transactions", json=_txn(account_id, amount="15.00", category_id=alcohol_id, date="2026-08-02")) + + resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8}) + breakdown = {row["name"]: row for row in resp.json()["spending_by_category"]} + + assert "Alcohol" not in breakdown + assert money(breakdown["Groceries"]["amount"]) == Decimal("75.00") + + async def test_more_than_eight_expense_categories_roll_up_into_other(client: AsyncClient, account_id, categories): expense_categories = [c for c in categories.values() if c["kind"] == "expense"] # The default seed only ships 8 expense categories (see app/db/seed.py) — diff --git a/backend/tests/test_insights.py b/backend/tests/test_insights.py new file mode 100644 index 0000000..2423aa4 --- /dev/null +++ b/backend/tests/test_insights.py @@ -0,0 +1,64 @@ +"""Proactive alerts (services/insights_service.py): only the idle-cash +signal is covered here — the other three (negative cash flow, net worth +decline, over-budget) predate this test file and aren't touched by this +change. +""" +from datetime import date, timedelta + +from httpx import AsyncClient + +from tests.helpers import txn_payload as _txn + +STALE_DATE = (date.today() - timedelta(days=400)).isoformat() +RECENT_DATE = date.today().isoformat() + + +async def _alert_keys(client: AsyncClient) -> set[str]: + resp = await client.get("/insights/alerts") + return {alert["key"] for alert in resp.json()["alerts"]} + + +async def test_idle_cash_flags_a_stale_large_balance(client: AsyncClient, account_id, categories): + await client.post( + "/transactions", + json=_txn(account_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE), + ) + + assert "idle_cash" in await _alert_keys(client) + + +async def test_idle_cash_ignores_a_recently_touched_account(client: AsyncClient, account_id, categories): + await client.post( + "/transactions", + json=_txn(account_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE), + ) + await client.post( + "/transactions", + json=_txn(account_id, type="expense", amount="1.00", category_id=categories["Groceries"]["id"], date=RECENT_DATE), + ) + + assert "idle_cash" not in await _alert_keys(client) + + +async def test_idle_cash_ignores_investment_accounts(client: AsyncClient, categories): + investment = await client.post("/accounts", json={"name": "Brokerage", "type": "investment", "currency": "USD"}) + investment_id = investment.json()["id"] + await client.post( + "/transactions", + json=_txn(investment_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE), + ) + + assert "idle_cash" not in await _alert_keys(client) + + +async def test_idle_cash_respects_custom_threshold(client: AsyncClient, account_id, categories): + await client.post( + "/transactions", + json=_txn(account_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE), + ) + assert "idle_cash" in await _alert_keys(client) + + resp = await client.patch("/settings", json={"idle_cash_threshold_amount": "10000.00"}) + assert resp.status_code == 200 + + assert "idle_cash" not in await _alert_keys(client) diff --git a/frontend/package.json b/frontend/package.json index 3134725..72e96cb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.23.2", + "version": "0.24.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/categories/CategoryFormModal.tsx b/frontend/src/components/categories/CategoryFormModal.tsx index 06e9c56..2c79f1e 100644 --- a/frontend/src/components/categories/CategoryFormModal.tsx +++ b/frontend/src/components/categories/CategoryFormModal.tsx @@ -4,7 +4,8 @@ import { Button } from "@/components/ui/Button"; import { Input, Label, Select } from "@/components/ui/Input"; import { CategoryColorPicker } from "@/components/categories/CategoryColorPicker"; import { CategoryIconPicker } from "@/components/categories/CategoryIconPicker"; -import { useCreateCategory, useUpdateCategory } from "@/hooks/useCategories"; +import { useCategories, useCreateCategory, useUpdateCategory } from "@/hooks/useCategories"; +import { translateCategoryName } from "@/lib/categoryLabels"; import { useTranslation, type TranslationKey } from "@/lib/i18n"; import type { Category, CategoryKind } from "@/types"; @@ -20,11 +21,12 @@ interface CategoryFormModalProps { const KINDS: CategoryKind[] = ["expense", "income"]; function emptyForm(kind: CategoryKind) { - return { name: "", kind, icon: "wallet", color: "#2a78d6" }; + return { name: "", kind, icon: "wallet", color: "#2a78d6", parent_id: "" }; } export function CategoryFormModal({ open, onClose, category, defaultKind }: CategoryFormModalProps) { const { t } = useTranslation(); + const { data: allCategories } = useCategories(); const createCategory = useCreateCategory(); const updateCategory = useUpdateCategory(); @@ -35,26 +37,41 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate if (!open) return; setForm( category - ? { name: category.name, kind: category.kind, icon: category.icon ?? "wallet", color: category.color } + ? { + name: category.name, + kind: category.kind, + icon: category.icon ?? "wallet", + color: category.color, + parent_id: category.parent_id ? String(category.parent_id) : "", + } : emptyForm(defaultKind) ); setError(null); }, [open, category, defaultKind]); + // A category that already has subcategories can't become one itself + // (backend rejects it) — the parent picker is disabled in that case. + const hasChildren = category ? (allCategories ?? []).some((c) => c.parent_id === category.id) : false; + const parentCandidates = (allCategories ?? []).filter( + (c) => c.kind === form.kind && c.parent_id === null && c.id !== category?.id + ); + const isSaving = createCategory.isPending || updateCategory.isPending; async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setError(null); + const parent_id = form.parent_id ? Number(form.parent_id) : null; + try { if (category) { await updateCategory.mutateAsync({ id: category.id, - input: { name: form.name, icon: form.icon, color: form.color }, + input: { name: form.name, icon: form.icon, color: form.color, parent_id }, }); } else { - await createCategory.mutateAsync(form); + await createCategory.mutateAsync({ name: form.name, kind: form.kind, icon: form.icon, color: form.color, parent_id }); } onClose(); } catch { @@ -82,7 +99,9 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate id="category-kind" value={form.kind} disabled={Boolean(category)} - onChange={(event) => setForm((prev) => ({ ...prev, kind: event.target.value as CategoryKind }))} + onChange={(event) => + setForm((prev) => ({ ...prev, kind: event.target.value as CategoryKind, parent_id: "" })) + } > {KINDS.map((kind) => (