"""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``: mutual-exclusion; sets all other contracts' ``active`` to False, then sets the given contract's ``active`` to True. - ``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 from sqlalchemy.orm import Session from app.integrations.pricing.profiles import validate_values from app.models.energy import EnergyContract, EnergyContractVersion logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # 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) -> list[EnergyContract]: """Return all contracts ordered by id (ascending).""" return list( session.execute(select(EnergyContract).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", 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. """ # Validate (and fill defaults) before any DB write. filled_values = validate_values(kind, values) now = datetime.now(UTC) contract = EnergyContract( name=name, kind=kind, 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's ``active`` flag to False, then sets the given contract's ``active`` to True. This guarantees at most one active contract at any time. Caller must commit after this returns. """ # Deactivate all contracts (including the target; we re-activate below). for other in session.execute(select(EnergyContract)).scalars().all(): other.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_version_at( session: Session, ts: datetime ) -> 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)).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