212 lines
5.2 KiB
Python
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
|