Files
aurum/backend/app/services/cash_flow_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

88 lines
3.2 KiB
Python

"""Month-by-month income vs. expense — the multi-month view neither the
Dashboard (locked to one month) nor Reports (one category at a time, or a
category ranking) answers on its own. Transfers between the user's own
accounts are excluded from both totals, same as the Dashboard breakdown.
"""
from collections import defaultdict
from datetime import date as date_
from decimal import Decimal
from sqlalchemy import extract, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import TransactionType
from app.models.transaction import Transaction
from app.schemas.cash_flow import CashFlowPoint, CashFlowResponse
def _next_month(year: int, month: int) -> tuple[int, int]:
return (year + 1, 1) if month == 12 else (year, month + 1)
async def get_cash_flow(
session: AsyncSession, start_date: date_ | None, end_date: date_ | None
) -> CashFlowResponse:
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
Transaction.type != TransactionType.TRANSFER
)
if start_date:
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
if end_date:
bounds_stmt = bounds_stmt.where(Transaction.date <= end_date)
min_date, max_date = (await session.execute(bounds_stmt)).one()
effective_start = start_date or min_date
effective_end = end_date or max_date
empty = CashFlowResponse(
start_date=effective_start,
end_date=effective_end,
points=[],
total_income=Decimal("0"),
total_expense=Decimal("0"),
total_net=Decimal("0"),
)
if effective_start is None or effective_end is None:
return empty
rows_stmt = (
select(
extract("year", Transaction.date).label("year"),
extract("month", Transaction.date).label("month"),
Transaction.type,
func.sum(Transaction.amount).label("amount"),
)
.where(
Transaction.type != TransactionType.TRANSFER,
Transaction.date >= effective_start,
Transaction.date <= effective_end,
)
.group_by("year", "month", Transaction.type)
)
rows = (await session.execute(rows_stmt)).all()
by_month: dict[tuple[int, int], dict[TransactionType, Decimal]] = defaultdict(dict)
for year, month, tx_type, amount in rows:
by_month[(int(year), int(month))][tx_type] = amount
points: list[CashFlowPoint] = []
year, month = effective_start.year, effective_start.month
while (year, month) <= (effective_end.year, effective_end.month):
totals = by_month.get((year, month), {})
income = totals.get(TransactionType.INCOME, Decimal("0"))
expense = totals.get(TransactionType.EXPENSE, Decimal("0"))
points.append(CashFlowPoint(year=year, month=month, income=income, expense=expense, net=income - expense))
year, month = _next_month(year, month)
total_income = sum((p.income for p in points), Decimal("0"))
total_expense = sum((p.expense for p in points), Decimal("0"))
return CashFlowResponse(
start_date=effective_start,
end_date=effective_end,
points=points,
total_income=total_income,
total_expense=total_expense,
total_net=total_income - total_expense,
)