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

212 lines
5.2 KiB
Python

"""Domain entities, value objects, and input sanitizers (SPECS.md §4)."""
from __future__ import annotations
import calendar
from dataclasses import dataclass
from datetime import date
from .errors import ValidationError
from .money import Money
__all__ = [
"EXPENSE",
"INCOME",
"KINDS",
"DEFAULT_CATEGORIES",
"Budget",
"BudgetLine",
"Category",
"CategoryTotal",
"Month",
"MonthSummary",
"Transaction",
"clean_name",
"clean_note",
"parse_date",
"validate_kind",
]
INCOME = "income"
EXPENSE = "expense"
KINDS = (INCOME, EXPENSE)
MAX_NAME_LEN = 50
MAX_NOTE_LEN = 200
DATE_MIN = date(2000, 1, 1)
DATE_MAX = date(2100, 12, 31)
YEAR_MIN = 2000
YEAR_MAX = 2100
#: Categories seeded on first run (SPECS.md §6).
DEFAULT_CATEGORIES: tuple[tuple[str, str], ...] = (
("Salary", INCOME),
("Other Income", INCOME),
("Rent", EXPENSE),
("Groceries", EXPENSE),
("Utilities", EXPENSE),
("Dining Out", EXPENSE),
("Transport", EXPENSE),
("Entertainment", EXPENSE),
("Health", EXPENSE),
("Savings", EXPENSE),
("Other", EXPENSE),
)
def clean_name(text: str | None, field: str = "name") -> str:
"""Validate a category name (R5)."""
if text is None:
raise ValidationError(f"{field} must not be empty")
name = str(text).strip()
if not name:
raise ValidationError(f"{field} must not be empty")
if len(name) > MAX_NAME_LEN:
raise ValidationError(f"{field} must be at most {MAX_NAME_LEN} characters")
return name
def clean_note(text: str | None) -> str | None:
"""Validate a transaction note (R10); blank notes become None."""
if text is None:
return None
note = str(text).strip()
if not note:
return None
if len(note) > MAX_NOTE_LEN:
raise ValidationError(f"note must be at most {MAX_NOTE_LEN} characters")
return note
def parse_date(text: str) -> date:
"""Validate an ISO date string (R4)."""
try:
parsed = date.fromisoformat(str(text).strip())
except (TypeError, ValueError) as exc:
raise ValidationError(f"invalid date {text!r}, expected YYYY-MM-DD") from exc
if not DATE_MIN <= parsed <= DATE_MAX:
raise ValidationError(
f"date must be between {DATE_MIN.isoformat()} and {DATE_MAX.isoformat()}"
)
return parsed
def validate_kind(kind: str) -> str:
"""Validate a category kind (R6)."""
if kind not in KINDS:
raise ValidationError(f"kind must be one of {', '.join(KINDS)}")
return kind
@dataclass(frozen=True)
class Category:
"""A named bucket that classifies transactions (R5, R6)."""
name: str
kind: str
@dataclass(frozen=True)
class Transaction:
"""A single amount of money that came in or went out (R1, R4, R8, R10)."""
id: int
amount: Money
category: str
kind: str
date: date
note: str | None = None
@dataclass(frozen=True)
class Budget:
"""A monthly limit for one expense category (R2, R7)."""
category: str
amount: Money
@dataclass(frozen=True)
class Month:
"""A calendar month; the reporting time axis (SPECS.md §4.2)."""
year: int
month: int
def __post_init__(self) -> None:
if not YEAR_MIN <= self.year <= YEAR_MAX:
raise ValidationError(f"year must be between {YEAR_MIN} and {YEAR_MAX}")
if not 1 <= self.month <= 12:
raise ValidationError("month must be between 1 and 12")
@classmethod
def from_str(cls, text: str) -> Month:
"""Parse ``YYYY-MM``; raises ValidationError on bad input."""
try:
year_text, month_text = str(text).strip().split("-")
return cls(year=int(year_text), month=int(month_text))
except (TypeError, ValueError) as exc:
raise ValidationError(f"invalid month {text!r}, expected YYYY-MM") from exc
@classmethod
def now(cls) -> Month:
today = date.today()
return cls(year=today.year, month=today.month)
@property
def label(self) -> str:
return f"{self.year:04d}-{self.month:02d}"
@property
def first_day(self) -> date:
return date(self.year, self.month, 1)
@property
def last_day(self) -> date:
last = calendar.monthrange(self.year, self.month)[1]
return date(self.year, self.month, last)
@property
def range(self) -> tuple[str, str]:
"""Inclusive ISO date bounds (start, end) used by repository queries."""
return (self.first_day.isoformat(), self.last_day.isoformat())
@dataclass(frozen=True)
class MonthSummary:
"""Income, expenses, and net for one month (report read model)."""
month: Month
income: Money
expenses: Money
@property
def net(self) -> Money:
return self.income - self.expenses
@dataclass(frozen=True)
class CategoryTotal:
"""Total movement in one category over a period (report read model)."""
category: str
kind: str
total: Money
@dataclass(frozen=True)
class BudgetLine:
"""Budget vs. actual spending for one category in one month (R11)."""
category: str
budget: Money
spent: Money
@property
def remaining(self) -> Money:
return self.budget - self.spent
@property
def is_over(self) -> bool:
return self.spent > self.budget