64 lines
2.1 KiB
Python
64 lines
2.1 KiB
Python
"""Read-only reporting over transactions and budgets (R11).
|
|
|
|
Queries only: nothing in this module mutates state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from ..money import Money
|
|
from ..models import EXPENSE, BudgetLine, CategoryTotal, Month, MonthSummary
|
|
from ..repository import Repository
|
|
|
|
__all__ = ["ReportingService"]
|
|
|
|
|
|
class ReportingService:
|
|
"""Monthly summaries, category breakdowns, and budget status."""
|
|
|
|
def __init__(self, repo: Repository) -> None:
|
|
self._repo = repo
|
|
|
|
def monthly_summary(self, month: Month) -> MonthSummary:
|
|
start, end = month.range
|
|
income_cents, expense_cents = self._repo.income_expense_totals(start, end)
|
|
return MonthSummary(
|
|
month=month,
|
|
income=Money(cents=income_cents),
|
|
expenses=Money(cents=expense_cents),
|
|
)
|
|
|
|
def category_totals(self, month: Month) -> list[CategoryTotal]:
|
|
start, end = month.range
|
|
return [
|
|
CategoryTotal(
|
|
category=row["category"],
|
|
kind=row["kind"],
|
|
total=Money(cents=row["total_cents"]),
|
|
)
|
|
for row in self._repo.category_totals(start, end)
|
|
]
|
|
|
|
def budget_status(self, month: Month) -> list[BudgetLine]:
|
|
"""Budgeted lines first, then unbudgeted spending (R11)."""
|
|
start, end = month.range
|
|
spent = {
|
|
row["category"]: Money(cents=row["total_cents"])
|
|
for row in self._repo.category_totals(start, end)
|
|
if row["kind"] == EXPENSE
|
|
}
|
|
lines: list[BudgetLine] = []
|
|
for row in self._repo.list_budgets():
|
|
name = row["category"]
|
|
lines.append(
|
|
BudgetLine(
|
|
category=name,
|
|
budget=Money(cents=row["amount_cents"]),
|
|
spent=spent.get(name, Money.zero()),
|
|
)
|
|
)
|
|
budgeted = {line.category for line in lines}
|
|
for name, cost in sorted(spent.items()):
|
|
if name not in budgeted and cost.is_positive:
|
|
lines.append(BudgetLine(category=name, budget=Money.zero(), spent=cost))
|
|
return lines
|