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

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