Ledgerly/ledgerly/services/ledger.py
2026-10-27 11:04:50 -05:00

103 lines
3.6 KiB
Python

"""Commands and queries for categories and transactions.
CQRS: the ``add_*``/``delete_*`` methods are commands (state changes);
``list_*`` methods are queries (read-only). Neither side leaks the other.
"""
from __future__ import annotations
from datetime import date
from ..errors import ConflictError, NotFoundError, ValidationError
from ..money import Money
from ..models import Category, Transaction, clean_name, clean_note, validate_kind
from ..repository import Repository
__all__ = ["LedgerService"]
class LedgerService:
"""Owns category and transaction rules (R5-R10, R12)."""
def __init__(self, repo: Repository) -> None:
self._repo = repo
# -- commands ----------------------------------------------------------
def add_category(self, name: str, kind: str) -> Category:
clean = clean_name(name, "category")
validate_kind(kind)
if self._repo.find_category(clean) is not None:
raise ConflictError(f"category {clean!r} already exists")
self._repo.create_category(clean, kind)
return Category(name=clean, kind=kind)
def delete_category(self, name: str) -> None:
clean = clean_name(name, "category")
ref = self._repo.find_category(clean)
if ref is None:
raise NotFoundError(f"unknown category {clean!r}")
category_id, stored_name, _kind = ref
if self._repo.category_in_use(category_id):
raise ConflictError(
f"category {stored_name!r} has transactions or a budget"
)
self._repo.delete_category(category_id)
def add_transaction(
self, amount: Money, category: str, day: date, note: str | None = None
) -> Transaction:
if not amount.is_positive:
raise ValidationError("amount must be greater than zero")
ref = self._repo.find_category(category)
if ref is None:
raise NotFoundError(f"unknown category {category!r}")
category_id, stored_name, kind = ref
cleaned_note = clean_note(note)
tx_id = self._repo.insert_transaction(
amount.cents, category_id, day.isoformat(), cleaned_note
)
return Transaction(
id=tx_id,
amount=amount,
category=stored_name,
kind=kind,
date=day,
note=cleaned_note,
)
def delete_transaction(self, tx_id: int) -> None:
if not self._repo.transaction_exists(tx_id):
raise NotFoundError(f"unknown transaction #{tx_id}")
self._repo.delete_transaction(tx_id)
# -- queries -----------------------------------------------------------
def list_categories(self) -> list[Category]:
return [Category(name=name, kind=kind) for name, kind in self._repo.list_categories()]
def list_transactions(
self,
start: str | None,
end: str | None,
category: str | None = None,
) -> list[Transaction]:
category_id = None
if category is not None:
ref = self._repo.find_category(category)
if ref is None:
raise NotFoundError(f"unknown category {category!r}")
category_id = ref[0]
rows = self._repo.list_transactions(start, end, category_id)
return [
Transaction(
id=row["id"],
amount=Money(cents=row["amount_cents"]),
category=row["category"],
kind=row["kind"],
date=date.fromisoformat(row["date"]),
note=row["note"],
)
for row in rows
]