mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user