Files
aurum/backend/app/services/dashboard_service.py
ZProger fe9b079c12 Initial public release
Aurum — a self-hosted personal finance operating system: cash flow,
net worth tracking, budgets, goals, recurring payments, reports,
proactive alerts, and a compound-interest returns calculator, all
bilingual (RU/EN) and mobile-first. Ships as a three-container Docker
Compose stack (Postgres + FastAPI + nginx-served React frontend).

See README.md for setup and CONTRIBUTING.md for development.
2026-08-16 18:39:52 +03:00

90 lines
3.3 KiB
Python

"""Aggregation logic behind the Overview dashboard."""
import calendar
from datetime import date
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.category import Category
from app.models.enums import TransactionType
from app.models.transaction import Transaction
from app.schemas.dashboard import CategoryBreakdownItem, DashboardSummary
# Categorical slots are capped at 8 (dataviz skill: a 9th series folds into "Other",
# never a generated hue) — this is also the exact size of the default category set.
MAX_CHART_SLICES = 8
OTHER_SLICE_COLOR = "#898781" # muted ink, reserved for the non-categorical rollup
def _month_bounds(year: int, month: int) -> tuple[date, date]:
last_day = calendar.monthrange(year, month)[1]
return date(year, month, 1), date(year, month, last_day)
async def get_dashboard_summary(session: AsyncSession, year: int, month: int) -> DashboardSummary:
start, end = _month_bounds(year, month)
totals_stmt = (
select(Transaction.type, func.coalesce(func.sum(Transaction.amount), 0))
.where(Transaction.date >= start, Transaction.date <= end)
.group_by(Transaction.type)
)
totals_result = await session.execute(totals_stmt)
totals: dict[TransactionType, Decimal] = {row[0]: row[1] for row in totals_result.all()}
real_income = totals.get(TransactionType.INCOME, Decimal("0"))
spent = totals.get(TransactionType.EXPENSE, Decimal("0"))
transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0"))
breakdown_stmt = (
select(
Category.id,
Category.name,
Category.color,
Category.icon,
func.coalesce(func.sum(Transaction.amount), 0).label("amount"),
)
.join(Category, Category.id == Transaction.category_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)
)
breakdown_result = await session.execute(breakdown_stmt)
rows = breakdown_result.all()
top_rows, rest_rows = rows[:MAX_CHART_SLICES], rows[MAX_CHART_SLICES:]
def _percent(amount: Decimal) -> float:
return float(amount / spent * 100) if spent else 0.0
spending_by_category = [
CategoryBreakdownItem(
category_id=cat_id, name=name, color=color, icon=icon, amount=amount, percent=_percent(amount)
)
for cat_id, name, color, icon, amount in top_rows
]
if rest_rows:
other_amount = sum((row.amount for row in rest_rows), Decimal("0"))
spending_by_category.append(
CategoryBreakdownItem(
category_id=None, name="Other", color=OTHER_SLICE_COLOR, icon="more-horizontal",
amount=other_amount, percent=_percent(other_amount),
)
)
return DashboardSummary(
year=year,
month=month,
real_income=real_income,
spent=spent,
net=real_income - spent,
transferred_out=transferred_out,
spending_by_category=spending_by_category,
)