"""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