feat: idle cash alert and category hierarchy (subcategories)

Two of the four ideas picked from the Maybe/Sure feature audit:

- Idle cash: a 5th proactive alert flags a depository account (checking/
  savings/cash) sitting at or above a configurable balance with no
  transaction touching it for a configurable number of days.
- Category hierarchy: categories can now have one level of subcategories
  (Groceries -> Alcohol). Dashboard/report aggregations roll a
  subcategory's spending into its parent's slice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-25 02:27:46 +03:00
parent 79e4ebb06b
commit 09a0e383e0
23 changed files with 628 additions and 81 deletions

View File

@@ -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')

View File

@@ -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')

View File

@@ -10,6 +10,21 @@ from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
router = APIRouter(prefix="/categories", tags=["categories"]) 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]) @router.get("", response_model=list[CategoryRead])
async def list_categories( async def list_categories(
kind: CategoryKind | None = None, session: AsyncSession = Depends(get_session) 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) @router.post("", response_model=CategoryRead, status_code=201)
async def create_category(payload: CategoryCreate, session: AsyncSession = Depends(get_session)) -> Category: 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) category = Category(**payload.model_dump(), is_default=False)
session.add(category) session.add(category)
await session.commit() await session.commit()
@@ -37,7 +54,15 @@ async def update_category(
category = await session.get(Category, category_id) category = await session.get(Category, category_id)
if category is None: if category is None:
raise HTTPException(status_code=404, detail="Category not found") 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) setattr(category, field, value)
await session.commit() await session.commit()
await session.refresh(category) await session.refresh(category)

View File

@@ -1,5 +1,5 @@
"""A spending/income category, colored so it maps 1:1 to a dashboard chart slot.""" """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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base 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. # Fixed slot ordering keeps the donut chart's category order stable across renders.
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 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") 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")

View File

@@ -2,7 +2,9 @@
single-row table (id is always 1). Primary display currency (see UPDATES.md 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 for why this doesn't do currency conversion) and the proactive-alert
thresholds consumed by services/insights_service.py.""" 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 sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base from app.db.base import Base
@@ -21,3 +23,9 @@ class AppSettings(Base):
# tiers before insights_service.py raises risky_allocation_exceeded — # tiers before insights_service.py raises risky_allocation_exceeded —
# the classic "80% at zero risk, 20% at most exposed" rule. # the classic "80% at zero risk, 20% at most exposed" rule.
risky_allocation_threshold_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=20) 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)

View File

@@ -37,6 +37,9 @@ class CategoryBackup(BaseModel):
color: str color: str
sort_order: int sort_order: int
is_default: bool 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): class TransactionBackup(BaseModel):
@@ -136,6 +139,10 @@ class AppSettingsBackup(BaseModel):
# Defaulted so a backup exported before this threshold existed still # Defaulted so a backup exported before this threshold existed still
# imports cleanly under the same format version. # imports cleanly under the same format version.
risky_allocation_threshold_percent: int = 20 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): class BackupPayload(BaseModel):

View File

@@ -9,6 +9,9 @@ class CategoryBase(BaseModel):
icon: str | None = None icon: str | None = None
color: str = Field(pattern=r"^#[0-9a-fA-F]{6}$") color: str = Field(pattern=r"^#[0-9a-fA-F]{6}$")
sort_order: int = 0 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): class CategoryCreate(CategoryBase):
@@ -20,6 +23,10 @@ class CategoryUpdate(BaseModel):
icon: str | None = None icon: str | None = None
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$") color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
sort_order: int | None = None 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): class CategoryRead(CategoryBase):

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -6,6 +8,8 @@ class AppSettingsRead(BaseModel):
negative_cash_flow_threshold_months: int negative_cash_flow_threshold_months: int
net_worth_decline_threshold_months: int net_worth_decline_threshold_months: int
risky_allocation_threshold_percent: int risky_allocation_threshold_percent: int
idle_cash_threshold_amount: Decimal
idle_cash_threshold_days: int
class AppSettingsUpdate(BaseModel): class AppSettingsUpdate(BaseModel):
@@ -24,3 +28,7 @@ class AppSettingsUpdate(BaseModel):
# Max % of capital allowed in medium/high risk tiers (the 80/20 rule's # Max % of capital allowed in medium/high risk tiers (the 80/20 rule's
# "20% at most exposed" half) before risky_allocation_exceeded fires. # "20% at most exposed" half) before risky_allocation_exceeded fires.
risky_allocation_threshold_percent: int | None = Field(default=None, ge=1, le=100) 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)

View File

@@ -75,6 +75,10 @@ def _validate_references(payload: BackupPayload) -> None:
category_ids = {row.id for row in payload.categories} category_ids = {row.id for row in payload.categories}
asset_ids = {row.id for row in payload.assets} 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: for t in payload.transactions:
if t.account_id not in account_ids: if t.account_id not in account_ids:
raise HTTPException(400, f"Transaction {t.id} references unknown account_id {t.account_id}") 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(Category))
await session.execute(delete(Account)) 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(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(Asset(**row.model_dump()) for row in payload.assets)
session.add_all(Transaction(**row.model_dump()) for row in payload.transactions) 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) session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)

View File

@@ -5,6 +5,7 @@ from decimal import Decimal
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.category import Category from app.models.category import Category
from app.models.enums import TransactionType 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")) spent = totals.get(TransactionType.EXPENSE, Decimal("0"))
transferred_out = totals.get(TransactionType.TRANSFER, 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 = ( breakdown_stmt = (
select( select(
Category.id, effective_id.label("category_id"),
Category.name, effective_name.label("name"),
Category.color, effective_color.label("color"),
Category.icon, effective_icon.label("icon"),
func.coalesce(func.sum(Transaction.amount), 0).label("amount"), func.coalesce(func.sum(Transaction.amount), 0).label("amount"),
) )
.join(Category, Category.id == Transaction.category_id) .join(Category, Category.id == Transaction.category_id)
.outerjoin(Parent, Parent.id == Category.parent_id)
.where( .where(
Transaction.date >= start, Transaction.date >= start,
Transaction.date <= end, Transaction.date <= end,
Transaction.type == TransactionType.EXPENSE, Transaction.type == TransactionType.EXPENSE,
) )
.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order) .group_by(effective_id, effective_name, effective_color, effective_icon, effective_sort)
.order_by(func.sum(Transaction.amount).desc(), Category.sort_order) .order_by(func.sum(Transaction.amount).desc(), effective_sort)
) )
breakdown_result = await session.execute(breakdown_stmt) breakdown_result = await session.execute(breakdown_stmt)
rows = breakdown_result.all() rows = breakdown_result.all()

View File

@@ -1,20 +1,26 @@
"""Proactive early-warning checks, computed from data that already exists. """Proactive early-warning checks, computed from data that already exists.
Four signals: sustained negative monthly cash flow, a sustained net-worth Five signals: sustained negative monthly cash flow, a sustained net-worth
decline, one or more over-budget categories, and too much capital in decline, one or more over-budget categories, too much capital in medium/high
medium/high risk tiers (the "80% at zero risk, 20% at most exposed" rule). risk tiers (the "80% at zero risk, 20% at most exposed" rule), and cash
The first two only look at fully-elapsed calendar months, so a same-month sitting idle in a depository account. The first two only look at
false alarm (rent already paid, salary not landed yet) never fires — their fully-elapsed calendar months, so a same-month false alarm (rent already
"sustained for how long" thresholds, and the risk-allocation percentage, are 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 all user-configurable (Settings page), stored on AppSettings, see
get_or_create_app_settings. The budget check is the opposite: it deliberately 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 looks at the CURRENT, still-in-progress month, since the whole point is to
catch overspending while there's still time to react. 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 decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.insights import AlertsResponse, FinancialAlert
from app.schemas.net_worth import NetWorthSummary from app.schemas.net_worth import NetWorthSummary
from app.services.budget_service import get_budget_status 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 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]: def _previous_month(year: int, month: int) -> tuple[int, int]:
return (year - 1, 12) if month == 1 else (year, month - 1) 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 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: async def get_financial_alerts(session: AsyncSession) -> AlertsResponse:
settings = await get_or_create_app_settings(session) settings = await get_or_create_app_settings(session)
alerts: list[FinancialAlert] = [] 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) return AlertsResponse(alerts=alerts)

View File

@@ -10,6 +10,7 @@ from decimal import Decimal
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import extract, func, select from sqlalchemy import extract, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.category import Category from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType from app.models.enums import CategoryKind, TransactionType
@@ -33,8 +34,18 @@ async def get_category_spending_report(
if category is None: if category is None:
raise HTTPException(status_code=404, detail="Category not found") 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( 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: if start_date:
bounds_stmt = bounds_stmt.where(Transaction.date >= 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"), func.count(Transaction.id).label("cnt"),
) )
.where( .where(
Transaction.category_id == category_id, Transaction.category_id.in_(category_ids),
Transaction.date >= effective_start, Transaction.date >= effective_start,
Transaction.date <= effective_end, Transaction.date <= effective_end,
) )
@@ -115,24 +126,34 @@ async def get_category_ranking_report(
arbitrary period — "which category costs the most" across the whole arbitrary period — "which category costs the most" across the whole
range, unlike the month-scoped Dashboard breakdown or the range, unlike the month-scoped Dashboard breakdown or the
single-category detail above.""" 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 = ( stmt = (
select( select(
Category.id, effective_id.label("category_id"),
Category.name, effective_name.label("name"),
Category.color, effective_color.label("color"),
Category.icon, effective_icon.label("icon"),
func.sum(Transaction.amount).label("amount"), func.sum(Transaction.amount).label("amount"),
func.count(Transaction.id).label("cnt"), func.count(Transaction.id).label("cnt"),
) )
.join(Category, Category.id == Transaction.category_id) .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]) .where(Category.kind == kind, Transaction.type == _KIND_TO_TRANSACTION_TYPE[kind])
) )
if start_date: if start_date:
stmt = stmt.where(Transaction.date >= start_date) stmt = stmt.where(Transaction.date >= start_date)
if end_date: if end_date:
stmt = stmt.where(Transaction.date <= 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.group_by(effective_id, effective_name, effective_color, effective_icon, effective_sort)
stmt = stmt.order_by(func.sum(Transaction.amount).desc(), Category.sort_order) stmt = stmt.order_by(func.sum(Transaction.amount).desc(), effective_sort)
rows = (await session.execute(stmt)).all() rows = (await session.execute(stmt)).all()
total_amount = sum((row.amount for row in rows), Decimal("0")) total_amount = sum((row.amount for row in rows), Decimal("0"))

View File

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

View File

@@ -81,6 +81,23 @@ async def test_spending_by_category_amounts_and_percent(client: AsyncClient, acc
assert breakdown["Dining Out"]["percent"] == 25.0 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): 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"] 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) — # The default seed only ships 8 expense categories (see app/db/seed.py) —

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "aurum-frontend", "name": "aurum-frontend",
"private": true, "private": true,
"version": "0.23.2", "version": "0.24.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -4,7 +4,8 @@ import { Button } from "@/components/ui/Button";
import { Input, Label, Select } from "@/components/ui/Input"; import { Input, Label, Select } from "@/components/ui/Input";
import { CategoryColorPicker } from "@/components/categories/CategoryColorPicker"; import { CategoryColorPicker } from "@/components/categories/CategoryColorPicker";
import { CategoryIconPicker } from "@/components/categories/CategoryIconPicker"; 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 { useTranslation, type TranslationKey } from "@/lib/i18n";
import type { Category, CategoryKind } from "@/types"; import type { Category, CategoryKind } from "@/types";
@@ -20,11 +21,12 @@ interface CategoryFormModalProps {
const KINDS: CategoryKind[] = ["expense", "income"]; const KINDS: CategoryKind[] = ["expense", "income"];
function emptyForm(kind: CategoryKind) { 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) { export function CategoryFormModal({ open, onClose, category, defaultKind }: CategoryFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: allCategories } = useCategories();
const createCategory = useCreateCategory(); const createCategory = useCreateCategory();
const updateCategory = useUpdateCategory(); const updateCategory = useUpdateCategory();
@@ -35,26 +37,41 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate
if (!open) return; if (!open) return;
setForm( setForm(
category 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) : emptyForm(defaultKind)
); );
setError(null); setError(null);
}, [open, category, defaultKind]); }, [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; const isSaving = createCategory.isPending || updateCategory.isPending;
async function handleSubmit(event: React.FormEvent) { async function handleSubmit(event: React.FormEvent) {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
const parent_id = form.parent_id ? Number(form.parent_id) : null;
try { try {
if (category) { if (category) {
await updateCategory.mutateAsync({ await updateCategory.mutateAsync({
id: category.id, 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 { } else {
await createCategory.mutateAsync(form); await createCategory.mutateAsync({ name: form.name, kind: form.kind, icon: form.icon, color: form.color, parent_id });
} }
onClose(); onClose();
} catch { } catch {
@@ -82,7 +99,9 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate
id="category-kind" id="category-kind"
value={form.kind} value={form.kind}
disabled={Boolean(category)} 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) => ( {KINDS.map((kind) => (
<option key={kind} value={kind}> <option key={kind} value={kind}>
@@ -92,6 +111,24 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate
</Select> </Select>
</div> </div>
<div>
<Label htmlFor="category-parent">{t("category.form.parentLabel")}</Label>
<Select
id="category-parent"
value={hasChildren ? "" : form.parent_id}
disabled={hasChildren}
onChange={(event) => setForm((prev) => ({ ...prev, parent_id: event.target.value }))}
>
<option value="">{t("category.form.noParent")}</option>
{parentCandidates.map((parent) => (
<option key={parent.id} value={parent.id}>
{translateCategoryName(parent.name)}
</option>
))}
</Select>
{hasChildren && <p className="mt-1 text-xs text-text-muted">{t("category.form.hasChildrenHint")}</p>}
</div>
<div> <div>
<Label>{t("category.form.iconLabel")}</Label> <Label>{t("category.form.iconLabel")}</Label>
<CategoryIconPicker value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} /> <CategoryIconPicker value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />

View File

@@ -10,6 +10,57 @@ interface CategoryListProps {
onDelete: (category: Category) => void; onDelete: (category: Category) => void;
} }
interface RowProps {
category: Category;
indented: boolean;
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
}
function CategoryRow({ category, indented, onEdit, onDelete }: RowProps) {
const { t } = useTranslation();
const Icon = getCategoryIcon(category.icon);
return (
<li className={`flex items-center gap-3 py-2.5 ${indented ? "pl-8" : ""}`}>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: `${category.color}1a`, color: category.color }}
>
<Icon size={15} />
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm font-medium text-text-primary">
<span className="truncate">{translateCategoryName(category.name)}</span>
{category.is_default && (
<span className="shrink-0 rounded bg-surface-2 px-1 py-0.5 text-[10px] leading-none text-text-muted">
{t("category.defaultBadge")}
</span>
)}
</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
aria-label={t("common.edit")}
onClick={() => onEdit(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Pencil size={15} />
</button>
<button
type="button"
aria-label={t("common.delete")}
title={category.is_default ? t("category.defaultCannotDelete") : undefined}
disabled={category.is_default}
onClick={() => onDelete(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted"
>
<Trash2 size={15} />
</button>
</span>
</li>
);
}
export function CategoryList({ items, onEdit, onDelete }: CategoryListProps) { export function CategoryList({ items, onEdit, onDelete }: CategoryListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -17,50 +68,31 @@ export function CategoryList({ items, onEdit, onDelete }: CategoryListProps) {
return <p className="py-6 text-center text-sm text-text-muted">{t("category.empty")}</p>; return <p className="py-6 text-center text-sm text-text-muted">{t("category.empty")}</p>;
} }
// `items` arrives pre-sorted (alphabetically, by CategoriesPage) — walk
// top-level categories in that order and splice each one's children in
// directly beneath it, so a subcategory renders indented under its parent
// while everything stays a single flat, evenly-divided list.
const topLevel = items.filter((category) => category.parent_id === null);
const topLevelIds = new Set(topLevel.map((category) => category.id));
// Defensive only — a child whose parent got filtered out (e.g. a kind
// mismatch) should never happen given the create/update validation, but
// falls back to a top-level row instead of silently disappearing.
const orphaned = items.filter((category) => category.parent_id !== null && !topLevelIds.has(category.parent_id));
const rows: { category: Category; indented: boolean }[] = [];
for (const parent of topLevel) {
rows.push({ category: parent, indented: false });
for (const child of items) {
if (child.parent_id === parent.id) rows.push({ category: child, indented: true });
}
}
for (const category of orphaned) rows.push({ category, indented: false });
return ( return (
<ul className="divide-y divide-gridline"> <ul className="divide-y divide-gridline">
{items.map((category) => { {rows.map(({ category, indented }) => (
const Icon = getCategoryIcon(category.icon); <CategoryRow key={category.id} category={category} indented={indented} onEdit={onEdit} onDelete={onDelete} />
))}
return (
<li key={category.id} className="flex items-center gap-3 py-2.5">
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: `${category.color}1a`, color: category.color }}
>
<Icon size={15} />
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm font-medium text-text-primary">
<span className="truncate">{translateCategoryName(category.name)}</span>
{category.is_default && (
<span className="shrink-0 rounded bg-surface-2 px-1 py-0.5 text-[10px] leading-none text-text-muted">
{t("category.defaultBadge")}
</span>
)}
</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
aria-label={t("common.edit")}
onClick={() => onEdit(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Pencil size={15} />
</button>
<button
type="button"
aria-label={t("common.delete")}
title={category.is_default ? t("category.defaultCannotDelete") : undefined}
disabled={category.is_default}
onClick={() => onDelete(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted"
>
<Trash2 size={15} />
</button>
</span>
</li>
);
})}
</ul> </ul>
); );
} }

View File

@@ -9,6 +9,7 @@ const ALERT_MESSAGE_KEYS: Record<string, TranslationKey> = {
net_worth_decline_streak: "insights.netWorthDecline", net_worth_decline_streak: "insights.netWorthDecline",
budget_exceeded: "insights.budgetExceeded", budget_exceeded: "insights.budgetExceeded",
risky_allocation_exceeded: "insights.riskyAllocationExceeded", risky_allocation_exceeded: "insights.riskyAllocationExceeded",
idle_cash: "insights.idleCash",
}; };
function alertMessage(alert: FinancialAlert, t: ReturnType<typeof useTranslation>["t"]): string { function alertMessage(alert: FinancialAlert, t: ReturnType<typeof useTranslation>["t"]): string {
@@ -18,6 +19,9 @@ function alertMessage(alert: FinancialAlert, t: ReturnType<typeof useTranslation
if (alert.key === "budget_exceeded" && alert.params.count === 1) { if (alert.key === "budget_exceeded" && alert.params.count === 1) {
return t("insights.budgetExceededOne"); return t("insights.budgetExceededOne");
} }
if (alert.key === "idle_cash" && alert.params.count === 1) {
return t("insights.idleCashOne", alert.params);
}
const messageKey = ALERT_MESSAGE_KEYS[alert.key]; const messageKey = ALERT_MESSAGE_KEYS[alert.key];
if (!messageKey) return alert.key; if (!messageKey) return alert.key;
return t(messageKey, alert.params); return t(messageKey, alert.params);

View File

@@ -8,6 +8,9 @@ const MIN_MONTHS = 1;
const MAX_MONTHS = 24; const MAX_MONTHS = 24;
const MIN_PERCENT = 1; const MIN_PERCENT = 1;
const MAX_PERCENT = 100; const MAX_PERCENT = 100;
const MIN_AMOUNT = 0;
const MIN_DAYS = 1;
const MAX_DAYS = 365;
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
@@ -21,12 +24,16 @@ export function AlertThresholdsCard() {
const [cashFlowMonths, setCashFlowMonths] = useState(""); const [cashFlowMonths, setCashFlowMonths] = useState("");
const [netWorthMonths, setNetWorthMonths] = useState(""); const [netWorthMonths, setNetWorthMonths] = useState("");
const [riskyPercent, setRiskyPercent] = useState(""); const [riskyPercent, setRiskyPercent] = useState("");
const [idleCashAmount, setIdleCashAmount] = useState("");
const [idleCashDays, setIdleCashDays] = useState("");
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings) return;
setCashFlowMonths(String(settings.negative_cash_flow_threshold_months)); setCashFlowMonths(String(settings.negative_cash_flow_threshold_months));
setNetWorthMonths(String(settings.net_worth_decline_threshold_months)); setNetWorthMonths(String(settings.net_worth_decline_threshold_months));
setRiskyPercent(String(settings.risky_allocation_threshold_percent)); setRiskyPercent(String(settings.risky_allocation_threshold_percent));
setIdleCashAmount(settings.idle_cash_threshold_amount);
setIdleCashDays(String(settings.idle_cash_threshold_days));
}, [settings]); }, [settings]);
function commitCashFlowMonths() { function commitCashFlowMonths() {
@@ -53,13 +60,29 @@ export function AlertThresholdsCard() {
} }
} }
function commitIdleCashAmount() {
const value = Math.max(MIN_AMOUNT, Number(idleCashAmount) || MIN_AMOUNT).toFixed(2);
setIdleCashAmount(value);
if (settings && value !== settings.idle_cash_threshold_amount) {
updateSettings.mutate({ idle_cash_threshold_amount: value });
}
}
function commitIdleCashDays() {
const value = clamp(Number(idleCashDays) || MIN_DAYS, MIN_DAYS, MAX_DAYS);
setIdleCashDays(String(value));
if (settings && value !== settings.idle_cash_threshold_days) {
updateSettings.mutate({ idle_cash_threshold_days: value });
}
}
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t("settings.alertsTitle")}</CardTitle> <CardTitle>{t("settings.alertsTitle")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div> <div>
<Label htmlFor="cash-flow-threshold">{t("settings.negativeCashFlowThresholdLabel")}</Label> <Label htmlFor="cash-flow-threshold">{t("settings.negativeCashFlowThresholdLabel")}</Label>
<Input <Input
@@ -99,6 +122,31 @@ export function AlertThresholdsCard() {
onBlur={commitRiskyPercent} onBlur={commitRiskyPercent}
/> />
</div> </div>
<div>
<Label htmlFor="idle-cash-amount-threshold">{t("settings.idleCashThresholdAmountLabel")}</Label>
<Input
id="idle-cash-amount-threshold"
type="number"
min={MIN_AMOUNT}
step={0.01}
value={idleCashAmount}
onChange={(event) => setIdleCashAmount(event.target.value)}
onBlur={commitIdleCashAmount}
/>
</div>
<div>
<Label htmlFor="idle-cash-days-threshold">{t("settings.idleCashThresholdDaysLabel")}</Label>
<Input
id="idle-cash-days-threshold"
type="number"
min={MIN_DAYS}
max={MAX_DAYS}
step={1}
value={idleCashDays}
onChange={(event) => setIdleCashDays(event.target.value)}
onBlur={commitIdleCashDays}
/>
</div>
</div> </div>
<p className="text-xs text-text-muted">{t("settings.alertsHint")}</p> <p className="text-xs text-text-muted">{t("settings.alertsHint")}</p>
</CardContent> </CardContent>

View File

@@ -7,7 +7,7 @@ import { useCategories } from "@/hooks/useCategories";
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions"; import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels"; import { translateCategoryName } from "@/lib/categoryLabels";
import type { Transaction, TransactionInput, TransactionType } from "@/types"; import type { Category, Transaction, TransactionInput, TransactionType } from "@/types";
interface TransactionFormModalProps { interface TransactionFormModalProps {
open: boolean; open: boolean;
@@ -32,7 +32,7 @@ const EMPTY_FORM = {
}; };
export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) { export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) {
const { t } = useTranslation(); const { t, language } = useTranslation();
const { data: accounts } = useAccounts(); const { data: accounts } = useAccounts();
const { data: categories } = useCategories(); const { data: categories } = useCategories();
const createTransaction = useCreateTransaction(); const createTransaction = useCreateTransaction();
@@ -61,9 +61,19 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
setError(null); setError(null);
}, [open, transaction, accounts]); }, [open, transaction, accounts]);
const relevantCategories = (categories ?? []).filter((category) => // Subcategories are listed right under their parent (not scattered by
form.type === "income" ? category.kind === "income" : category.kind === "expense" // sort_order) so the hierarchy set up on the Categories page reads the
); // same way here.
const kindCategories = (categories ?? [])
.filter((category) => (form.type === "income" ? category.kind === "income" : category.kind === "expense"))
.sort((a, b) => translateCategoryName(a.name).localeCompare(translateCategoryName(b.name), language));
const relevantCategories: (Category & { indented?: boolean })[] = [];
for (const parent of kindCategories.filter((category) => category.parent_id === null)) {
relevantCategories.push(parent);
for (const child of kindCategories.filter((category) => category.parent_id === parent.id)) {
relevantCategories.push({ ...child, indented: true });
}
}
const isSaving = createTransaction.isPending || updateTransaction.isPending; const isSaving = createTransaction.isPending || updateTransaction.isPending;
@@ -217,6 +227,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
<option value="">{t("transactions.form.noCategory")}</option> <option value="">{t("transactions.form.noCategory")}</option>
{relevantCategories.map((category) => ( {relevantCategories.map((category) => (
<option key={category.id} value={category.id}> <option key={category.id} value={category.id}>
{category.indented ? `    ↳ ` : ""}
{translateCategoryName(category.name)} {translateCategoryName(category.name)}
</option> </option>
))} ))}

View File

@@ -203,6 +203,8 @@ const ru = {
"settings.negativeCashFlowThresholdLabel": "Расходы превышают доходы, мес. подряд", "settings.negativeCashFlowThresholdLabel": "Расходы превышают доходы, мес. подряд",
"settings.netWorthDeclineThresholdLabel": "Капитал снижается, мес. подряд", "settings.netWorthDeclineThresholdLabel": "Капитал снижается, мес. подряд",
"settings.riskyAllocationThresholdLabel": "Допустимая доля рискового капитала, %", "settings.riskyAllocationThresholdLabel": "Допустимая доля рискового капитала, %",
"settings.idleCashThresholdAmountLabel": "Простаивающий кэш — сумма от",
"settings.idleCashThresholdDaysLabel": "Простаивающий кэш — без движения, дней",
"backup.title": "Резервное копирование", "backup.title": "Резервное копирование",
"backup.description": "backup.description":
@@ -226,6 +228,8 @@ const ru = {
"insights.budgetExceededOne": "Превышен бюджет в 1 категории в этом месяце", "insights.budgetExceededOne": "Превышен бюджет в 1 категории в этом месяце",
"insights.budgetExceeded": "Превышен бюджет в {{count}} категориях в этом месяце", "insights.budgetExceeded": "Превышен бюджет в {{count}} категориях в этом месяце",
"insights.riskyAllocationExceeded": "Рискованная часть капитала — {{percent}}%, превышает установленный лимит {{threshold}}%", "insights.riskyAllocationExceeded": "Рискованная часть капитала — {{percent}}%, превышает установленный лимит {{threshold}}%",
"insights.idleCashOne": "На 1 счёте лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
"insights.idleCash": "На {{count}} счетах лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
"budget.empty": "Бюджетов пока нет. Задайте лимит для категории расходов, чтобы отслеживать её здесь.", "budget.empty": "Бюджетов пока нет. Задайте лимит для категории расходов, чтобы отслеживать её здесь.",
"budget.remaining": "{{amount}} осталось", "budget.remaining": "{{amount}} осталось",
@@ -314,6 +318,9 @@ const ru = {
"category.form.nameLabel": "Название", "category.form.nameLabel": "Название",
"category.form.namePlaceholder": "Продукты, транспорт, подписки…", "category.form.namePlaceholder": "Продукты, транспорт, подписки…",
"category.form.kindLabel": "Тип", "category.form.kindLabel": "Тип",
"category.form.parentLabel": "Родительская категория",
"category.form.noParent": "— без родителя (верхний уровень) —",
"category.form.hasChildrenHint": "У категории уже есть подкатегории — она не может сама стать подкатегорией.",
"category.form.iconLabel": "Иконка", "category.form.iconLabel": "Иконка",
"category.form.colorLabel": "Цвет", "category.form.colorLabel": "Цвет",
"category.form.customColorLabel": "Свой цвет", "category.form.customColorLabel": "Свой цвет",
@@ -559,6 +566,8 @@ const en: Record<keyof typeof ru, string> = {
"settings.negativeCashFlowThresholdLabel": "Expenses exceed income, consecutive months", "settings.negativeCashFlowThresholdLabel": "Expenses exceed income, consecutive months",
"settings.netWorthDeclineThresholdLabel": "Net worth declining, consecutive months", "settings.netWorthDeclineThresholdLabel": "Net worth declining, consecutive months",
"settings.riskyAllocationThresholdLabel": "Max % of capital allowed at risk", "settings.riskyAllocationThresholdLabel": "Max % of capital allowed at risk",
"settings.idleCashThresholdAmountLabel": "Idle cash — balance at or above",
"settings.idleCashThresholdDaysLabel": "Idle cash — no activity for, days",
"backup.title": "Backup & Restore", "backup.title": "Backup & Restore",
"backup.description": "backup.description":
@@ -582,6 +591,8 @@ const en: Record<keyof typeof ru, string> = {
"insights.budgetExceededOne": "Budget exceeded in 1 category this month", "insights.budgetExceededOne": "Budget exceeded in 1 category this month",
"insights.budgetExceeded": "Budget exceeded in {{count}} categories this month", "insights.budgetExceeded": "Budget exceeded in {{count}} categories this month",
"insights.riskyAllocationExceeded": "Risky share of capital is {{percent}}%, above your {{threshold}}% limit", "insights.riskyAllocationExceeded": "Risky share of capital is {{percent}}%, above your {{threshold}}% limit",
"insights.idleCashOne": "1 account is holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
"insights.idleCash": "{{count}} accounts are holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
"budget.empty": "No budgets yet. Set a limit for an expense category to start tracking it here.", "budget.empty": "No budgets yet. Set a limit for an expense category to start tracking it here.",
"budget.remaining": "{{amount}} left", "budget.remaining": "{{amount}} left",
@@ -670,6 +681,9 @@ const en: Record<keyof typeof ru, string> = {
"category.form.nameLabel": "Name", "category.form.nameLabel": "Name",
"category.form.namePlaceholder": "Groceries, transport, subscriptions…", "category.form.namePlaceholder": "Groceries, transport, subscriptions…",
"category.form.kindLabel": "Type", "category.form.kindLabel": "Type",
"category.form.parentLabel": "Parent category",
"category.form.noParent": "— no parent (top level) —",
"category.form.hasChildrenHint": "This category already has subcategories — it can't become a subcategory itself.",
"category.form.iconLabel": "Icon", "category.form.iconLabel": "Icon",
"category.form.colorLabel": "Color", "category.form.colorLabel": "Color",
"category.form.customColorLabel": "Custom color", "category.form.customColorLabel": "Custom color",

View File

@@ -34,6 +34,9 @@ export interface Category {
color: string; color: string;
sort_order: number; sort_order: number;
is_default: boolean; is_default: boolean;
// Subcategory parent, one level deep only (a category whose own parent_id
// is set can't itself have children — enforced backend-side).
parent_id: number | null;
} }
export interface CategoryInput { export interface CategoryInput {
@@ -42,6 +45,7 @@ export interface CategoryInput {
icon?: string | null; icon?: string | null;
color: string; color: string;
sort_order?: number; sort_order?: number;
parent_id?: number | null;
} }
// kind is fixed at creation on the backend (CategoryUpdate has no kind field). // kind is fixed at creation on the backend (CategoryUpdate has no kind field).
@@ -50,6 +54,7 @@ export interface CategoryUpdateInput {
icon?: string | null; icon?: string | null;
color?: string; color?: string;
sort_order?: number; sort_order?: number;
parent_id?: number | null;
} }
export interface Transaction { export interface Transaction {
@@ -365,5 +370,7 @@ export interface AppSettings {
negative_cash_flow_threshold_months: number; negative_cash_flow_threshold_months: number;
net_worth_decline_threshold_months: number; net_worth_decline_threshold_months: number;
risky_allocation_threshold_percent: number; risky_allocation_threshold_percent: number;
idle_cash_threshold_amount: string;
idle_cash_threshold_days: number;
} }