Ledgerly/ledgerly/services/reports.py

64 lines
2.1 KiB
Python
Raw Permalink Normal View History

2026-10-27 16:04:50 +00:00
"""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