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

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

View File

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

View File

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

View File

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