"""Domain models for expenses and merge suggestions.""" from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from decimal import Decimal from typing import List, Mapping, Sequence from .config import settings def _extract_expense_time(doc: Mapping[str, object]) -> datetime: for field in settings.time_fields: value = doc.get(field) if isinstance(value, datetime): if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value raise ValueError("Expense document missing a valid timestamp field") def _extract_merchant(doc: Mapping[str, object]) -> str: for field in settings.merchant_fields: value = doc.get(field) if value: text = str(value).strip() if text: return text return "" @dataclass(frozen=True) class Expense: expense_id: str amount: Decimal currency: str merchant: str expense_time: datetime user_id: str | None = None source: str | None = None metadata: Mapping[str, object] | None = None @staticmethod def from_document(doc: Mapping[str, object]) -> "Expense": try: amount_value = Decimal(str(doc["amount"])) except KeyError as exc: raise ValueError("Expense document missing 'amount'") from exc expense_time = _extract_expense_time(doc) merchant_value = _extract_merchant(doc) return Expense( expense_id=str(doc.get("_id")), amount=amount_value, currency=str(doc.get("currency", "INR")), merchant=merchant_value, expense_time=expense_time, user_id=str(doc.get("user")) if doc.get("user") else None, source=doc.get("source"), metadata=doc.get("metadata") or {}, ) @dataclass class MergeSuggestion: candidate_ids: Sequence[str] message: str details: Mapping[str, object] audit: Mapping[str, object] status: str = "pending" _id: str | None = None def to_document(self) -> Mapping[str, object]: return { "candidate_ids": list(self.candidate_ids), "message": self.message, "details": dict(self.details), "audit": dict(self.audit), "status": self.status, } @dataclass class DuplicateCluster: expenses: List[Expense] = field(default_factory=list) amount_delta_pct: float = 0.0 time_delta_minutes: float = 0.0 merchant_rule: str = "exact" def to_details(self) -> Mapping[str, object]: return { "amount_delta_pct": self.amount_delta_pct, "time_delta_minutes": self.time_delta_minutes, "merchant_match_rule": self.merchant_rule, }