"""Service layer for EnergyContract CRUD, versioning, and activation. All functions accept an explicit SQLAlchemy Session; callers are responsible for committing or rolling back the transaction. Design decisions ---------------- - ``create_contract``: validates values against the pricing profile *before* writing any rows; raises ``ProfileValidationError`` on non-compliance. - ``add_version``: append-only; closes the previous open version by setting its ``effective_to`` to the new version's ``effective_from``; raises ``ContractVersionError`` if the new date is strictly earlier than the previous version's ``effective_from``. - ``activate_contract``: scope-local mutual exclusion; sets other contracts in the target scope inactive, then sets the given contract active. - ``active_contract_version_at``: returns the single version of the currently active contract that covers *ts* (``effective_from ≤ ts < effective_to``, or open-ended when ``effective_to`` is None). SQLite timezone note -------------------- SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on read-back they come out as **timezone-naive** datetimes. Wherever this code compares timestamps from the DB against timezone-aware values (e.g. from Pydantic or ``datetime.now(UTC)``), it calls ``_as_utc()`` to make both sides comparable without tripping on "offset-naive vs offset-aware" TypeErrors. """ from __future__ import annotations import logging from datetime import UTC, datetime from typing import Any from sqlalchemy import select, update from sqlalchemy.orm import Session from app.integrations.pricing.profiles import validate_values from app.models.energy import EnergyContract, EnergyContractVersion logger = logging.getLogger(__name__) # This is deliberately separate from the pricing-profile loader. T12 needs to # reserve the thermal domain before T13 supplies its actual profile. CONTRACT_KIND_SCOPES: dict[str, str] = { "manual": "electricity", "tibber": "electricity", "district_heating": "thermal", } class ContractScopeError(ValueError): """Raised when a contract kind is unknown or its supplied scope disagrees.""" def contract_scope_for_kind(kind: str, requested_scope: str | None = None) -> str: """Return the registry-owned scope for *kind*, rejecting client mismatches.""" try: scope = CONTRACT_KIND_SCOPES[kind] except KeyError as exc: raise ContractScopeError(f"Unknown energy contract kind: {kind!r}") from exc if requested_scope is not None and requested_scope != scope: raise ContractScopeError( f"Contract kind {kind!r} belongs to scope {scope!r}, not {requested_scope!r}." ) return scope # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _as_utc(dt: datetime) -> datetime: """Return *dt* as a timezone-aware UTC datetime. SQLite's DateTime(timezone=True) column type stores datetimes as naive UTC strings and gives them back as naive datetimes on read. This helper re-attaches the UTC timezone info when it is missing, making cross-origin comparisons safe. """ if dt.tzinfo is None: return dt.replace(tzinfo=UTC) return dt # --------------------------------------------------------------------------- # Custom exception # --------------------------------------------------------------------------- class ContractVersionError(ValueError): """Raised when a new contract version has an invalid effective date. Specifically: the new version's ``effective_from`` must be greater than or equal to the previous open version's ``effective_from``. Allowing equal timestamps would cause ambiguous overlap; the service therefore also rejects strictly-equal values (same second) to avoid silent data loss. """ # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def get_contract_or_none(session: Session, contract_id: int) -> EnergyContract | None: """Return the contract with the given id, or None if not found.""" return session.execute( select(EnergyContract).where(EnergyContract.id == contract_id) ).scalar_one_or_none() def list_contracts(session: Session, *, scope: str = "electricity") -> list[EnergyContract]: """Return contracts in one scope, ordered by id (ascending).""" return list( session.execute( select(EnergyContract) .where(EnergyContract.scope == scope) .order_by(EnergyContract.id) ).scalars().all() ) def _open_version(session: Session, contract: EnergyContract) -> EnergyContractVersion | None: """Return the current open version (effective_to IS NULL) for *contract*, or None.""" return session.execute( select(EnergyContractVersion) .where( EnergyContractVersion.contract_id == contract.id, EnergyContractVersion.effective_to.is_(None), ) .order_by(EnergyContractVersion.effective_from.desc()) .limit(1) ).scalar_one_or_none() # --------------------------------------------------------------------------- # Core service functions # --------------------------------------------------------------------------- def create_contract( session: Session, *, name: str, kind: str, currency: str = "EUR", scope: str | None = None, values: dict[str, Any], effective_from: datetime, ) -> EnergyContract: """Create a new energy contract with its first pricing version. Parameters ---------- session: Active SQLAlchemy session. Caller must commit after this returns. name: Human-readable label for the contract. kind: Pricing strategy identifier (``"manual"`` or ``"tibber"``). currency: ISO 4217 currency code (default ``"EUR"``). values: Pricing values dict conforming to the named profile's structure. Validated via ``validate_values(kind, values)`` before any writes. effective_from: UTC datetime at which the first pricing version takes effect. Returns ------- EnergyContract The newly created contract (not yet committed). Raises ------ ProfileNotFoundError If no YAML profile exists for *kind*. ProfileValidationError If *values* does not conform to the profile structure. """ resolved_scope = contract_scope_for_kind(kind, scope) # Validate (and fill defaults) before any DB write. filled_values = validate_values(kind, values) now = datetime.now(UTC) contract = EnergyContract( name=name, kind=kind, scope=resolved_scope, currency=currency, active=False, # New contracts are inactive; caller must explicitly activate. created_at=now, updated_at=now, ) session.add(contract) session.flush() # Assign contract.id so we can reference it in the version FK. version = EnergyContractVersion( contract_id=contract.id, effective_from=effective_from, effective_to=None, values=filled_values, created_at=now, ) session.add(version) logger.info("Created energy contract %r (kind=%s, id=%d)", name, kind, contract.id) return contract def add_version( session: Session, contract: EnergyContract, *, effective_from: datetime, values: dict[str, Any], ) -> EnergyContractVersion: """Add a new pricing version to an existing contract (append-only). The previous open version's ``effective_to`` is automatically set to the new version's ``effective_from`` (version closure), ensuring there is never a gap or overlap between consecutive versions. The new ``effective_from`` **must be strictly greater than** the previous open version's ``effective_from``. Equal timestamps are rejected because they would produce two versions starting at the same instant, making it impossible to determine which is current. If this constraint is not met, ``ContractVersionError`` is raised and **no rows are written**. Parameters ---------- session: Active SQLAlchemy session. Caller must commit after this returns. contract: The parent ``EnergyContract`` to append a version to. effective_from: UTC datetime at which this version's pricing takes effect. values: New pricing values dict conforming to the contract's profile. Returns ------- EnergyContractVersion The newly created version (not yet committed). Raises ------ ContractVersionError If *effective_from* is not strictly after the previous open version's ``effective_from``. ProfileNotFoundError If no YAML profile exists for the contract's kind. ProfileValidationError If *values* does not conform to the profile structure. """ # Validate (and fill defaults) before any DB write. filled_values = validate_values(contract.kind, values) prev = _open_version(session, contract) if prev is not None: # Enforce strictly-after constraint to prevent ambiguous overlap. # Use _as_utc() on both sides: the incoming value may be tz-aware # while the DB-read value is tz-naive (SQLite limitation). if _as_utc(effective_from) <= _as_utc(prev.effective_from): raise ContractVersionError( f"New version effective_from ({effective_from.isoformat()}) must be strictly " f"after the previous open version's effective_from " f"({prev.effective_from.isoformat()})." ) # Close the previous open version. prev.effective_to = effective_from now = datetime.now(UTC) new_version = EnergyContractVersion( contract_id=contract.id, effective_from=effective_from, effective_to=None, values=filled_values, created_at=now, ) session.add(new_version) logger.info( "Added version to contract id=%d (kind=%s, effective_from=%s)", contract.id, contract.kind, effective_from.isoformat(), ) return new_version def activate_contract(session: Session, contract: EnergyContract) -> None: """Activate a contract with mutual exclusion. Sets every other contract in the same scope inactive, then sets the given contract active. This guarantees at most one active contract per scope. Caller must commit after this returns. """ # This bulk update is a single write statement inside the caller's # transaction. SQLite serializes writers, and another scope is never touched. session.execute( update(EnergyContract) .where(EnergyContract.scope == contract.scope, EnergyContract.id != contract.id) .values(active=False) ) contract.active = True contract.updated_at = datetime.now(UTC) logger.info("Activated contract %r (id=%d)", contract.name, contract.id) def deactivate_contract(session: Session, contract: EnergyContract) -> None: """Deactivate a contract without touching other contracts. Caller must commit after this returns. """ contract.active = False contract.updated_at = datetime.now(UTC) logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id) def active_contract_versions( session: Session, *, scope: str = "electricity" ) -> list[EnergyContractVersion]: """Return all versions of the currently active contract, ordered by effective_from ascending. Returns an empty list when there is no active contract. The list spans the full history of the active contract (all closed + the current open version) and is used to iterate over pricing-rate segments for cross-version fixed- cost / credit accumulation (Principle C). """ active = session.execute( select(EnergyContract) .where(EnergyContract.active.is_(True), EnergyContract.scope == scope) .limit(1) ).scalar_one_or_none() if active is None: return [] return list( session.execute( select(EnergyContractVersion) .where(EnergyContractVersion.contract_id == active.id) .order_by(EnergyContractVersion.effective_from) ) .scalars() .all() ) def active_contract_version_at( session: Session, ts: datetime, *, scope: str = "electricity" ) -> EnergyContractVersion | None: """Return the active contract's version that covers *ts*. A version covers *ts* when: ``effective_from ≤ ts`` AND (``effective_to IS NULL`` OR ``ts < effective_to``) Returns None when: - There is no active contract. - The active contract has no version covering *ts*. Parameters ---------- session: Active SQLAlchemy session (read-only usage). ts: UTC datetime to look up. Returns ------- EnergyContractVersion | None """ active = session.execute( select(EnergyContract) .where(EnergyContract.active.is_(True), EnergyContract.scope == scope) .limit(1) ).scalar_one_or_none() if active is None: return None # Build a query for all versions of the active contract covering ts. stmt = ( select(EnergyContractVersion) .where( EnergyContractVersion.contract_id == active.id, EnergyContractVersion.effective_from <= ts, ) .order_by(EnergyContractVersion.effective_from.desc()) .limit(1) ) version = session.execute(stmt).scalar_one_or_none() if version is None: return None # Exclude versions whose effective window has already closed before ts. if version.effective_to is not None and _as_utc(ts) >= _as_utc(version.effective_to): return None return version