FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day
frontend / frontend (push) Successful in 2m5s
pytest / test (push) Successful in 7m34s

- Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py).
- summarize: fixed fee / tax credit accrue only for elapsed whole local days,
  integrated across contract versions — no future-day inflation, no version-switch collapse.
- MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline
  apportionment), anchored at the active contract's earliest version effective_from so the
  total sensors stay monotonic and never jump backward.
- effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC.
- frontend ContractForm sends local-midnight naive datetime (was UTC midnight).
- get_costs_summary default window uses server-local "today".
This commit is contained in:
2026-06-25 11:13:30 +02:00
parent 682e06d256
commit 8b91146c29
10 changed files with 941 additions and 158 deletions
+8 -4
View File
@@ -75,6 +75,7 @@ from app.schemas.energy import (
from app.services.auth import AuthenticatedSession from app.services.auth import AuthenticatedSession
from app.services.contracts import active_contract_version_at from app.services.contracts import active_contract_version_at
from app.services.energy_cost import recompute_range, summarize from app.services.energy_cost import recompute_range, summarize
from app.services.timezone import local_midnight_utc, local_now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -358,12 +359,15 @@ def get_costs_summary(
Both ``fixed_costs`` and ``credits`` are derived from the **currently active Both ``fixed_costs`` and ``credits`` are derived from the **currently active
contract version at ``end``**. When no active contract exists they are 0. contract version at ``end``**. When no active contract exists they are 0.
""" """
now = datetime.now(UTC) if start is None or end is None:
# Default to the server's local today: [local_midnight, next_local_midnight).
# This ensures "today" aligns with the local calendar day (NL time) rather
# than UTC midnight.
local_today = local_now().date()
if start is None: if start is None:
start = now.replace(hour=0, minute=0, second=0, microsecond=0) start = local_midnight_utc(local_today)
if end is None: if end is None:
end = start + timedelta(days=1) end = local_midnight_utc(local_today + timedelta(days=1))
result = summarize(db, _as_utc(start), _as_utc(end)) result = summarize(db, _as_utc(start), _as_utc(end))
return SummaryResponse(**result) return SummaryResponse(**result)
+28 -2
View File
@@ -44,6 +44,7 @@ from app.schemas.energy_contract import (
VersionCreate, VersionCreate,
) )
from app.services.auth import AuthenticatedSession from app.services.auth import AuthenticatedSession
from app.services import timezone as _tz_mod
from app.services.contracts import ( from app.services.contracts import (
ContractVersionError, ContractVersionError,
activate_contract, activate_contract,
@@ -113,6 +114,30 @@ def _raise_422_for_profile_error(exc: Exception) -> Any:
) )
def _localize_effective_from(dt: datetime | None) -> datetime:
"""Resolve *dt* to an aware UTC datetime for storage.
Rules (Principle A):
- If *dt* is None → use ``datetime.now(UTC)`` (unchanged from before).
- If *dt* is timezone-aware → convert to UTC as-is.
- If *dt* is timezone-naive → interpret as server local wall-clock time,
localize with ``local_tz()``, then convert to UTC.
This means a front-end that sends ``"2026-06-25T00:00:00"`` (no Z) has it
interpreted as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
not as UTC midnight.
"""
if dt is None:
return datetime.now(UTC)
if dt.tzinfo is not None:
# Already aware: convert to UTC.
return dt.astimezone(UTC)
# Naive: assume server local wall-clock.
tz = _tz_mod.local_tz()
local_dt = dt.replace(tzinfo=tz)
return local_dt.astimezone(UTC)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /api/energy/profiles # GET /api/energy/profiles
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -175,7 +200,7 @@ def create_energy_contract(
The new contract is created with ``active=False``; use The new contract is created with ``active=False``; use
PATCH /api/energy/contracts/{id} with ``active=true`` to activate it. PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.
""" """
effective_from = body.effective_from or datetime.now(UTC) effective_from = _localize_effective_from(body.effective_from)
try: try:
contract = create_contract( contract = create_contract(
@@ -284,12 +309,13 @@ def add_contract_version(
Historical versions are never modified; this endpoint is append-only. Historical versions are never modified; this endpoint is append-only.
""" """
contract = _get_contract_or_404(db, contract_id) contract = _get_contract_or_404(db, contract_id)
effective_from = _localize_effective_from(body.effective_from)
try: try:
add_version( add_version(
db, db,
contract, contract,
effective_from=body.effective_from, effective_from=effective_from,
values=body.values, values=body.values,
) )
except ContractVersionError as exc: except ContractVersionError as exc:
+42 -47
View File
@@ -549,43 +549,43 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
def _make_import_cost_getter() -> Callable[["Session"], Any]: def _make_import_cost_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative import cost plus prorated standing charges. """Return a getter for the cumulative import cost plus prorated standing charges.
Value = SUM(import_cost, non-degraded) + standing_charges_cumulative Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``,
where ``anchor_utc`` is the effective_from of the earliest version of the active
contract (i.e. the contract billing start). This makes the cumulative total
monotonically increasing and cross-version consistent:
where standing_charges_cumulative = elapsed_days × (network_fee + management_fee) / 30. value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
Elapsed days are counted from the active contract version's effective_from date
(inclusive of today). Returns None when no non-degraded periods exist. Returns None when no non-degraded period exists in [anchor, now].
No arithmetic is done here — all fixed-cost/credit accounting lives in summarize().
""" """
def _getter(sess: "Session") -> Any: def _getter(sess: "Session") -> Any:
from decimal import Decimal
from datetime import UTC, datetime as _dt from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions
from app.services.contracts import _as_utc as _cu
from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP from app.models.energy import EnergyCostPeriod as _ECP
from app.services.contracts import active_contract_version_at, _as_utc
from sqlalchemy import func as _func from sqlalchemy import func as _func
total = sess.query(_func.sum(_ECP.import_cost)).filter( # Quick check: any non-degraded period at all? (avoids full summarize overhead
# when there are zero periods, which should return None)
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
_ECP.degraded.is_(False) _ECP.degraded.is_(False)
).scalar() ).scalar()
if total is None: if has_data is None:
return None return None
# Add prorated standing charges from the active contract version. versions = active_contract_versions(sess)
standing_cumulative = Decimal("0") if not versions:
# No active contract — return pure SUM(import_cost) without standing charges.
return float(has_data)
# anchor = earliest version's effective_from (contract billing start).
anchor_utc = _cu(versions[0].effective_from)
now_utc = _dt.now(UTC) now_utc = _dt.now(UTC)
version = active_contract_version_at(sess, now_utc)
if version is not None:
vals = version.values or {}
standing = vals.get("standing", {})
network_fee = Decimal(str(standing.get("network_fee") or 0))
management_fee = Decimal(str(standing.get("management_fee") or 0))
daily_standing = (network_fee + management_fee) / Decimal("30")
effective_from = _as_utc(version.effective_from) result = _summarize(sess, anchor_utc, now_utc)
days = (now_utc.date() - effective_from.date()).days + 1 return result["metered_import"] + result["fixed_costs"]
days = max(days, 0)
standing_cumulative = daily_standing * days
return float(Decimal(str(total)) + standing_cumulative)
return _getter return _getter
@@ -594,42 +594,37 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
def _make_export_revenue_getter() -> Callable[["Session"], Any]: def _make_export_revenue_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative export revenue plus prorated tax credit. """Return a getter for the cumulative export revenue plus prorated tax credit.
Value = SUM(export_revenue, non-degraded) + heffingskorting_cumulative Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``.
where heffingskorting_cumulative = elapsed_days × heffingskorting / 365. value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
Elapsed days are counted from the active contract version's effective_from date
(inclusive of today). Returns None when no non-degraded periods exist. Returns None when no non-degraded period exists in [anchor, now].
""" """
def _getter(sess: "Session") -> Any: def _getter(sess: "Session") -> Any:
from decimal import Decimal
from datetime import UTC, datetime as _dt from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions
from app.services.contracts import _as_utc as _cu
from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP from app.models.energy import EnergyCostPeriod as _ECP
from app.services.contracts import active_contract_version_at, _as_utc
from sqlalchemy import func as _func from sqlalchemy import func as _func
total = sess.query(_func.sum(_ECP.export_revenue)).filter( # Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
_ECP.degraded.is_(False) _ECP.degraded.is_(False)
).scalar() ).scalar()
if total is None: if has_data is None:
return None return None
# Add prorated heffingskorting credit from the active contract version. versions = active_contract_versions(sess)
credit_cumulative = Decimal("0") if not versions:
# No active contract — return pure SUM(export_revenue) without credits.
return float(has_data)
anchor_utc = _cu(versions[0].effective_from)
now_utc = _dt.now(UTC) now_utc = _dt.now(UTC)
version = active_contract_version_at(sess, now_utc)
if version is not None:
vals = version.values or {}
credits = vals.get("credits", {})
heffingskorting = Decimal(str(credits.get("heffingskorting") or 0))
daily_credit = heffingskorting / Decimal("365")
effective_from = _as_utc(version.effective_from) result = _summarize(sess, anchor_utc, now_utc)
days = (now_utc.date() - effective_from.date()).days + 1 return result["metered_export"] + result["credits"]
days = max(days, 0)
credit_cumulative = daily_credit * days
return float(Decimal(str(total)) + credit_cumulative)
return _getter return _getter
+26
View File
@@ -286,6 +286,32 @@ def deactivate_contract(session: Session, contract: EnergyContract) -> None:
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id) logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
def active_contract_versions(session: Session) -> 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)).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( def active_contract_version_at(
session: Session, ts: datetime session: Session, ts: datetime
) -> EnergyContractVersion | None: ) -> EnergyContractVersion | None:
+100 -24
View File
@@ -60,7 +60,8 @@ from app.integrations.pricing.strategies import (
get_strategy, get_strategy,
) )
from app.models.energy import DsmrReading, EnergyCostPeriod from app.models.energy import DsmrReading, EnergyCostPeriod
from app.services.contracts import active_contract_version_at from app.services.contracts import active_contract_version_at, active_contract_versions
from app.services.timezone import local_date, local_now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -496,13 +497,23 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
Computes the total payable as: Computes the total payable as:
total_payable = Σ(net_cost) -- metered electricity total_payable = Σ(net_cost) -- metered electricity
+ fixed_costs -- (network_fee + management_fee) EUR/month ÷ 30 × days + fixed_costs -- per-day standing charges, cross-version
- credits -- heffingskorting EUR/year ÷ 365 × days - credits -- per-day heffingskorting, cross-version
The fixed costs and credits are derived from the *currently active contract **Fixed-cost / credit counting — Principle C**:
version* at *end* (i.e. the version in effect at the end of the requested Only *already-elapsed* whole local calendar days are counted. For each
interval). When no active contract version exists, fixed costs and credits local calendar date D in the window ``[start_local_date, min(end_local_date,
are both 0; only the metered sum is returned. tomorrow_local))``, the contract version whose rate covers D (the one whose
effective_from local-date ≤ D < next version's effective_from local-date) is
used. Days that have not yet started in local time (D > today_local) are
never counted. This is cross-version: if the active contract has V1 from
June 1 and V2 from June 25, querying June 130 uses V1 for days 1-24 and V2
for day 25. Switching versions never resets the counter.
**Timezone note**: the ``days`` field in the returned dict still represents
the window length in calendar days (total_seconds / 86400), for backward
compatibility with existing API consumers. The fixed/credit calculation
independently counts whole local days as described above.
All arithmetic uses Decimal; the returned dict contains Python floats for All arithmetic uses Decimal; the returned dict contains Python floats for
JSON-serialisation convenience. JSON-serialisation convenience.
@@ -512,9 +523,9 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
session: session:
Active read-only SQLAlchemy session. Active read-only SQLAlchemy session.
start: start:
Inclusive start of the summary interval. Inclusive start of the summary interval (UTC or naive-UTC).
end: end:
Exclusive end of the summary interval. Exclusive end of the summary interval (UTC or naive-UTC).
Returns Returns
------- -------
@@ -524,13 +535,15 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
metered_import float Σ import_cost from non-degraded periods metered_import float Σ import_cost from non-degraded periods
metered_export float Σ export_revenue from non-degraded periods metered_export float Σ export_revenue from non-degraded periods
metered_net float Σ net_cost from non-degraded periods metered_net float Σ net_cost from non-degraded periods
fixed_costs float standing charges apportioned over the interval fixed_costs float standing charges for elapsed whole local days
credits float energy-tax credit apportioned over the interval credits float energy-tax credit for elapsed whole local days
total_payable float metered_net + fixed_costs credits total_payable float metered_net + fixed_costs credits
period_count int number of non-degraded periods in range period_count int number of non-degraded periods in range
degraded_count int number of degraded periods in range degraded_count int number of degraded periods in range
days float interval length in days (total_seconds / 86400) days float interval length in days (total_seconds / 86400)
""" """
from datetime import timedelta as _td, date as _date
start_utc = _as_utc(start) start_utc = _as_utc(start)
end_utc = _as_utc(end) end_utc = _as_utc(end)
@@ -550,32 +563,95 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0")) sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0")) sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
# --- Interval length in days --- # --- Interval length in days (window, not elapsed — kept for API compat) ---
total_seconds = (end_utc - start_utc).total_seconds() total_seconds = (end_utc - start_utc).total_seconds()
days = _to_decimal(str(total_seconds)) / _to_decimal("86400") days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
# --- Active contract version at *end* for standing charges --- # --- Fixed costs and credits: Principle C cross-version whole-day counting ---
version = active_contract_version_at(session, end_utc) today_local: _date = local_now().date()
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
#
# We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc)
# AND D ≤ today_local (only elapsed / today days count as "whole days").
#
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today)
# AND the local midnight is within the billing window."
#
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that
# contains CEST midnight (= 22:00 UTC) contributes 1 day.
#
# Implementation: compute the first and last local date whose midnight is in range.
from app.services.timezone import local_midnight_utc as _lmu
local_start_date: _date = local_date(start_utc)
# Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day.
if _lmu(local_start_date) >= start_utc:
first_counted: _date = local_start_date
else:
first_counted = local_start_date + _td(days=1)
local_end_date: _date = local_date(end_utc)
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
if _lmu(local_end_date) < end_utc:
last_counted: _date = local_end_date
else:
last_counted = local_end_date - _td(days=1)
# Cap: only elapsed or today's days.
last_counted = min(last_counted, today_local)
# If the range is empty (first_counted > last_counted), no days are counted.
# Fetch all versions of the active contract once (single DB call).
versions = active_contract_versions(session)
fixed_dec = Decimal("0") fixed_dec = Decimal("0")
credits_dec = Decimal("0") credits_dec = Decimal("0")
currency = "EUR" currency = "EUR"
if version is not None: if versions:
currency = version.contract.currency # Currency comes from the contract regardless of day count.
vals: dict = version.values or {} currency = versions[0].contract.currency
standing: dict = vals.get("standing", {})
creds: dict = vals.get("credits", {}) if versions and first_counted <= last_counted:
# For each version segment, find its overlap with [first_counted, last_counted]
# and accumulate whole days.
version_segments: list[tuple[_date, _date | None, dict]] = []
for v in versions:
v_start_local = local_date(_as_utc(v.effective_from))
v_end_local = local_date(_as_utc(v.effective_to)) if v.effective_to is not None else None
version_segments.append((v_start_local, v_end_local, v.values or {}))
for v_start, v_end_excl, v_values in version_segments:
# The version covers [v_start, v_end_excl) in local dates,
# where v_end_excl=None means open-ended (no upper bound).
# Intersection with [first_counted, last_counted]:
seg_start = max(first_counted, v_start)
if v_end_excl is not None:
seg_end_excl = min(last_counted + _td(days=1), v_end_excl)
else:
seg_end_excl = last_counted + _td(days=1)
if seg_start >= seg_end_excl:
continue # no overlap
n_days = (seg_end_excl - seg_start).days
if n_days <= 0:
continue
standing: dict = v_values.get("standing", {})
creds: dict = v_values.get("credits", {})
network_fee = _to_decimal(standing.get("network_fee", 0)) network_fee = _to_decimal(standing.get("network_fee", 0))
management_fee = _to_decimal(standing.get("management_fee", 0)) management_fee = _to_decimal(standing.get("management_fee", 0))
heffingskorting = _to_decimal(creds.get("heffingskorting", 0)) heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
# Standing charges: EUR/month → EUR/day (÷ 30) × days. # Standing charges: EUR/month → EUR/day (÷ 30) × n_days.
fixed_dec = (network_fee + management_fee) / Decimal("30") * days fixed_dec += (network_fee + management_fee) / Decimal("30") * Decimal(str(n_days))
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × n_days.
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × days. credits_dec += heffingskorting / Decimal("365") * Decimal(str(n_days))
credits_dec = heffingskorting / Decimal("365") * days
total_payable = sum_net + fixed_dec - credits_dec total_payable = sum_net + fixed_dec - credits_dec
+100
View File
@@ -0,0 +1,100 @@
"""Server local-timezone helpers.
All time is stored as UTC (no change). This module provides a single,
monkeypatch-friendly entry point for converting UTC moments to the server's
local timezone — used for business-day boundary calculations (standing charges,
effective-from date interpretation, etc.).
Design goals
------------
- Zero third-party dependencies (no tzlocal, no pytz).
- Testable without depending on CI host timezone:
monkeypatch ``local_tz`` to a fixed ``ZoneInfo`` in unit tests.
- DST-correct: conversions use ``astimezone(local_tz())`` per instant, so each
moment is independently correct even during DST transitions.
- Consistent with the existing ``.astimezone()`` pattern already used in
``homeassistant_inbound.py`` and ``poo.py``.
Priority for resolving the local timezone
-----------------------------------------
1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``.
Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling.
2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``.
This matches the behaviour callers already relied on implicitly.
"""
from __future__ import annotations
import os
from datetime import date, datetime, timezone
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from datetime import tzinfo
def local_tz() -> "tzinfo":
"""Return the server's local timezone.
Resolution order:
1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set
``TZ=Europe/Amsterdam`` in production for correct NL/DST handling.
2. System local timezone via ``datetime.now().astimezone().tzinfo``.
**Monkeypatch this function in tests** to get deterministic timezone
behaviour regardless of CI host configuration::
monkeypatch.setattr("app.services.timezone.local_tz",
lambda: ZoneInfo("Europe/Amsterdam"))
"""
tz_env = os.environ.get("TZ", "").strip()
if tz_env:
return ZoneInfo(tz_env)
# System fallback — identical to the .astimezone() pattern already used
# in homeassistant_inbound.py and poo.py.
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
def to_local(dt: datetime) -> datetime:
"""Convert *dt* to the server local timezone.
Handles three input cases:
- timezone-aware (any zone): converted via ``astimezone``.
- timezone-naive: assumed UTC (SQLite read-back convention), then converted.
Returns a timezone-aware datetime in ``local_tz()``.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(local_tz())
def local_now() -> datetime:
"""Return the current moment as a timezone-aware local datetime."""
return datetime.now(local_tz())
def local_date(dt: datetime) -> date:
"""Return the local calendar date for *dt*.
Equivalent to ``to_local(dt).date()``. A distinct helper to make
call sites readable.
"""
return to_local(dt).date()
def local_midnight_utc(d: date) -> datetime:
"""Return the UTC instant that corresponds to local midnight on *d*.
Example (Europe/Amsterdam, CEST=UTC+2):
local_midnight_utc(date(2026, 6, 25))
→ datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC)
This is used to convert a local calendar date back to a UTC boundary, e.g.
to compute the start/end of a local day for DB queries.
"""
tz = local_tz()
# Build a naive local datetime at midnight, then localize and convert to UTC.
local_midnight = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
return local_midnight.astimezone(timezone.utc)
+6 -2
View File
@@ -218,9 +218,13 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
const values = buildNestedValues(sectionFields, fieldValues) const values = buildNestedValues(sectionFields, fieldValues)
try { try {
// Convert local date string to ISO datetime if provided // Convert local date string to a naive local-midnight datetime string (no Z).
// The backend interprets naive datetimes as server local wall-clock time,
// so "2026-06-25T00:00:00" → CEST local midnight → stored as UTC 22:00Z.
// When effectiveFromStr is empty, fall back to the current instant (aware,
// sent as ISO with Z) so the backend stores it unchanged.
const effectiveFromISO = effectiveFromStr const effectiveFromISO = effectiveFromStr
? new Date(effectiveFromStr).toISOString() ? `${effectiveFromStr}T00:00:00`
: undefined : undefined
if (isAddVersion) { if (isAddVersion) {
+337 -18
View File
@@ -1,4 +1,4 @@
"""Tests for M6-T07: billing engine (app/services/energy_cost.py). """Tests for M6-T07 + FU10: billing engine (app/services/energy_cost.py).
Acceptance criteria covered Acceptance criteria covered
---------------------------- ----------------------------
@@ -13,8 +13,9 @@ Acceptance criteria covered
5. Missing readings (no DsmrReading covering a boundary) → degraded row. 5. Missing readings (no DsmrReading covering a boundary) → degraded row.
6. Cross-version selection: two contract versions with different effective 6. Cross-version selection: two contract versions with different effective
dates; each t0 picks the correct version (asserts contract_version_id). dates; each t0 picks the correct version (asserts contract_version_id).
7. ``summarize``: Σnet + standing charges (month/30 × days) heffingskorting 7. ``summarize`` (Principle C): fixed costs & credits count only elapsed whole
(year/365 × days) — hand-calculated against known values. *local* calendar days, cross-version. Sub-day windows → 0 fixed/credit.
One whole local day → full rate. Future dates → 0. Cross-version → sum.
8. ``compute_closed_periods``: only processes closed and uncalculated periods; 8. ``compute_closed_periods``: only processes closed and uncalculated periods;
does not touch already-computed (non-degraded) rows. does not touch already-computed (non-degraded) rows.
9. ``recompute_range``: explicitly overwrites all periods in [start, end) 9. ``recompute_range``: explicitly overwrites all periods in [start, end)
@@ -50,6 +51,7 @@ from app.services.energy_cost import (
recompute_range, recompute_range,
summarize, summarize,
) )
from app.services import timezone as tz_module
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -892,22 +894,36 @@ class TestSummarize:
assert result["period_count"] == 2 assert result["period_count"] == 2
assert result["degraded_count"] == 0 assert result["degraded_count"] == 0
def test_fixed_costs_formula(self, energy_db: Session) -> None: def test_fixed_costs_zero_for_sub_day_window(self, energy_db: Session) -> None:
"""Fixed costs = (network_fee + management_fee) / 30 × days.""" """Principle C: a 30-minute window within the same local day counts 0 whole days.
self._setup_two_periods(energy_db)
# Interval is 30 minutes = 0.5/48 day = 0.020833... days
days = Decimal("1800") / Decimal("86400")
expected_fixed = (Decimal("9.87") + Decimal("9.87")) / Decimal("30") * days
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert abs(Decimal(str(result["fixed_costs"])) - expected_fixed) < Decimal("1e-9")
def test_credits_formula(self, energy_db: Session) -> None: Fixed costs are only added for elapsed whole local calendar days.
"""Credits = heffingskorting / 365 × days.""" A window [10:00, 10:30) UTC stays within the same local calendar date
regardless of timezone offset (any UTC-11..UTC+14 range), so no whole
day is covered → fixed_costs = 0.
"""
from zoneinfo import ZoneInfo
self._setup_two_periods(energy_db) self._setup_two_periods(energy_db)
days = Decimal("1800") / Decimal("86400") # Pin the local timezone so the test is deterministic on any CI host.
expected_credits = Decimal("600.0") / Decimal("365") * days with __import__("unittest.mock", fromlist=["patch"]).patch.object(
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
):
result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert abs(Decimal(str(result["credits"])) - expected_credits) < Decimal("1e-9") assert result["fixed_costs"] == 0.0, (
"Sub-day window should contribute 0 whole-day fixed costs under Principle C"
)
def test_credits_zero_for_sub_day_window(self, energy_db: Session) -> None:
"""Principle C: a 30-minute window within the same local day counts 0 whole days for credits."""
from zoneinfo import ZoneInfo
self._setup_two_periods(energy_db)
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
):
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert result["credits"] == 0.0, (
"Sub-day window should contribute 0 whole-day credits under Principle C"
)
def test_total_payable_formula(self, energy_db: Session) -> None: def test_total_payable_formula(self, energy_db: Session) -> None:
"""total_payable = metered_net + fixed_costs credits.""" """total_payable = metered_net + fixed_costs credits."""
@@ -949,14 +965,26 @@ class TestSummarize:
assert result["period_count"] == 0 assert result["period_count"] == 0
def test_one_day_summarize_hand_calc(self, energy_db: Session) -> None: def test_one_day_summarize_hand_calc(self, energy_db: Session) -> None:
"""Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day interval.""" """Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day interval.
Principle C: the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC) spans
exactly one local calendar day in Europe/Amsterdam (CEST=UTC+2: 02:00→02:00).
effective_from = June 23 00:00 UTC = June 23 02:00 CEST = local date June 23.
Today (2026-06-25) ≥ June 23, so the day is elapsed → 1 day counted.
"""
from zoneinfo import ZoneInfo
from unittest.mock import patch
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
energy_db.commit() energy_db.commit()
# Summarize over exactly 1 day (no periods in DB — only standing/credits). # Summarize over exactly 1 UTC day (no periods in DB — only standing/credits).
# Pin timezone to Europe/Amsterdam for deterministic local-date mapping.
start = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC) start = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC)
end = datetime(2026, 6, 24, 0, 0, 0, tzinfo=_UTC) end = datetime(2026, 6, 24, 0, 0, 0, tzinfo=_UTC)
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
result = summarize(energy_db, start, end) result = summarize(energy_db, start, end)
# fixed_costs = (9.87 + 9.87) / 30 × 1.0 = 0.658 # fixed_costs = (9.87 + 9.87) / 30 × 1.0 = 0.658
@@ -968,6 +996,297 @@ class TestSummarize:
assert abs(result["total_payable"] - expected_total) < 1e-9 assert abs(result["total_payable"] - expected_total) < 1e-9
# ---------------------------------------------------------------------------
# 7b. summarize — Principle C (whole-local-day counting, cross-version)
# ---------------------------------------------------------------------------
#
# All tests in this section pin the local timezone to Europe/Amsterdam via
# monkeypatch so assertions are deterministic regardless of CI host timezone.
# "Today" in these tests is 2026-06-25 (CEST = UTC+2).
#
# Reference rates:
# network_fee = 9.87 EUR/month, management_fee = 9.87 EUR/month
# daily_fixed = (9.87 + 9.87) / 30 = 0.658 EUR/day
# heffingskorting = 600.0 EUR/year
# daily_credit = 600.0 / 365 ≈ 1.6438... EUR/day
#
# Window semantics:
# start_local_date = local_date(start_utc) (inclusive)
# end_local_date = local_date(end_utc) (exclusive upper bound)
# cap = min(end_local_date, today_local + 1 day)
# counted_days = max(0, (cap - start_local_date).days)
# restricted to the version's [v_start, v_end) range
#
# CEST = UTC+2; local midnight June D = UTC June (D-1) 22:00.
# So:
# start "This Month" (June 1 local 00:00) = May 31 22:00 UTC
# end "Next Month" (July 1 local 00:00) = June 30 22:00 UTC
# In UTC we feed actual UTC boundaries.
#
# Table (today = June 25 local, effective_from = June 1 local 00:00 = May 31 22:00 UTC):
#
# | Window (UTC) | Local dates | Elapsed | Expected |
# |----------------------------------|-------------------------|---------|----------|
# | June 25 UTC+2 today (1 local day)| [June 25, June 26) | 1 day | 1 day |
# | June 25 UTC+2 → June 28 UTC+2 | [June 25, June 28) | 1 day* | 1 day |
# | June 1 CEST → July 1 CEST (mon) | [June 1, July 1) | 25 days*| 25 days |
# | May 1 → June 1 (all past) | [May 1, June 1) | 31 days | 31 days |
# | July 1 → Aug 1 (all future) | [July 1, Aug 1) | 0 days | 0 days |
#
# *today local = June 25: "today" counts but June 26+ does not.
_AMS = None # ZoneInfo resolved lazily
def _ams():
global _AMS
if _AMS is None:
from zoneinfo import ZoneInfo
_AMS = ZoneInfo("Europe/Amsterdam")
return _AMS
def _local_midnight_utc_ams(year: int, month: int, day: int) -> datetime:
"""Return UTC instant for Europe/Amsterdam local midnight on the given date."""
from zoneinfo import ZoneInfo
ams = ZoneInfo("Europe/Amsterdam")
return datetime(year, month, day, 0, 0, 0, tzinfo=ams).replace(tzinfo=None).replace(
tzinfo=ams
).astimezone(_UTC)
# Simpler helper using ZoneInfo directly:
def _ams_midnight(year: int, month: int, day: int) -> datetime:
"""UTC datetime corresponding to Europe/Amsterdam local midnight on year/month/day."""
from zoneinfo import ZoneInfo
ams = ZoneInfo("Europe/Amsterdam")
return datetime(year, month, day, 0, 0, 0, tzinfo=ams).astimezone(_UTC)
class TestSummarizePrincipleC:
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone."""
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
# Today local = June 25 CEST (since today is 2026-06-25).
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
"""Create an active manual contract with a single version."""
contract = _make_contract(session, kind="manual", active=True)
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
session.commit()
def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict:
"""Run summarize with Europe/Amsterdam local_tz monkeypatched."""
from unittest.mock import patch
with patch.object(tz_module, "local_tz", return_value=_ams()):
return summarize(session, start_utc, end_utc)
def test_today_window_counts_1_day(self, energy_db: Session) -> None:
"""Today's window (local today 00:00 → local tomorrow 00:00) counts 1 day.
Matches table row: Today 6/25→6/26 → 1 day.
"""
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 6, 26)
result = self._run_summarize_ams(energy_db, start, end)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
f"Expected 1 day of fixed costs, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
f"Expected 1 day of credits, got {result['credits']}"
)
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
"""A fully future window (all local dates > today) counts 0 days.
Matches table row: 7/1→8/1 (all future) → 0 days.
"""
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 7, 1)
end = _ams_midnight(2026, 8, 1)
result = self._run_summarize_ams(energy_db, start, end)
assert result["fixed_costs"] == 0.0, (
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
)
assert result["credits"] == 0.0, (
f"All-future window must count 0 days; got credits={result['credits']}"
)
def test_this_month_window_counts_25_days(self, energy_db: Session) -> None:
"""This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25).
Today = June 25 local, so June 2630 are not yet elapsed.
Matches table row: 6/1→7/1 (This Month) → 25 days.
"""
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1)
result = self._run_summarize_ams(energy_db, start, end)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 25) < 1e-9, (
f"Expected 25 days of fixed costs (June 1-25 elapsed), got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 25) < 1e-9, (
f"Expected 25 days of credits, got {result['credits']}"
)
def test_past_month_window_counts_all_31_days(self, energy_db: Session) -> None:
"""Last month (May 1 → June 1 local) counts all 31 days (fully past).
Matches table row: 5/1→6/1 (all past) → 31 days.
effective_from is June 1 so May is before the contract — 0 days from contract.
Use an earlier effective_from to cover May.
"""
eff_utc = _ams_midnight(2026, 1, 1) # contract starts Jan 1 → covers May
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 5, 1)
end = _ams_midnight(2026, 6, 1)
result = self._run_summarize_ams(energy_db, start, end)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 31) < 1e-9, (
f"Expected 31 days of fixed costs (May 1-31 all past), got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 31) < 1e-9, (
f"Expected 31 days of credits, got {result['credits']}"
)
def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None:
"""A window partially in the future caps at today (only elapsed days counted).
Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
Today = June 25 → only 1 day elapsed (June 25).
Matches table row: 6/25→6/28 → 1 day.
"""
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 6, 28)
result = self._run_summarize_ams(energy_db, start, end)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
f"Expected 1 day of fixed costs (only June 25 elapsed), got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
f"Expected 1 day of credits, got {result['credits']}"
)
def test_cross_version_integration(self, energy_db: Session) -> None:
"""Cross-version integration: V1 June 1-24, V2 June 25+ (today's boundary).
Window: This Month (June 1 → July 1 local) = 25 days total.
- V1: June 1-24 (24 days) at rate1
- V2: June 25-25 (1 day, today) at rate2
V1 rates: network_fee=6.0, management_fee=6.0 → daily = 12/30 = 0.4
heffingskorting=300 → daily = 300/365
V2 rates: network_fee=12.0, management_fee=12.0 → daily = 24/30 = 0.8
heffingskorting=600 → daily = 600/365
Expected:
fixed_costs = 24 × 0.4 + 1 × 0.8 = 9.6 + 0.8 = 10.4
credits = 24 × (300/365) + 1 × (600/365) = (7200+600)/365 = 7800/365
"""
_VALUES_V1 = {
"energy": {"buy": {"normal": 0.10, "dal": 0.10}, "sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 6.0, "management_fee": 6.0},
"credits": {"heffingskorting": 300.0},
}
_VALUES_V2 = {
"energy": {"buy": {"normal": 0.20, "dal": 0.20}, "sell": {"normal": 0.08, "dal": 0.08},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 12.0, "management_fee": 12.0},
"credits": {"heffingskorting": 600.0},
}
# V1: effective June 1 CEST local. V2: effective June 25 CEST local.
v1_from = _ams_midnight(2026, 6, 1)
v2_from = _ams_midnight(2026, 6, 25)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from,
effective_to=v2_from)
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
energy_db.commit()
start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = summarize(energy_db, start, end)
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
expected_fixed = 24 * 12 / 30 + 1 * 24 / 30
expected_credits = 24 * 300 / 365 + 1 * 600 / 365
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
f"Cross-version fixed_costs wrong: expected {expected_fixed}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - expected_credits) < 1e-9, (
f"Cross-version credits wrong: expected {expected_credits}, got {result['credits']}"
)
def test_version_switch_does_not_reset_counter(self, energy_db: Session) -> None:
"""Adding a new version does not reset the cumulative counter.
With V1 from June 1 and V2 from June 25, querying June 25 → July 1
still returns V2's 1-day rate without losing the historical V1 days
(the MQTT getter anchors at V1.effective_from to accumulate everything).
This test just checks that V2-only window returns V2 rate × 1 day.
"""
_VALUES_V1 = {
"energy": {"buy": {"normal": 0.10, "dal": 0.10}, "sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 6.0, "management_fee": 6.0},
"credits": {"heffingskorting": 300.0},
}
_VALUES_V2 = {
"energy": {"buy": {"normal": 0.20, "dal": 0.20}, "sell": {"normal": 0.08, "dal": 0.08},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 12.0, "management_fee": 12.0},
"credits": {"heffingskorting": 600.0},
}
v1_from = _ams_midnight(2026, 6, 1)
v2_from = _ams_midnight(2026, 6, 25)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from,
effective_to=v2_from)
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
energy_db.commit()
# Window = June 25 only (today, 1 day elapsed at V2 rate)
start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = summarize(energy_db, start, end)
# Only 1 day at V2 rate
expected_fixed = 1 * 24 / 30
expected_credits = 1 * 600 / 365
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9
assert abs(result["credits"] - expected_credits) < 1e-9
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 8. compute_closed_periods # 8. compute_closed_periods
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+81 -35
View File
@@ -871,27 +871,39 @@ _STANDING_VALUES = {
def test_import_cost_total_includes_standing_charges(energy_db) -> None: def test_import_cost_total_includes_standing_charges(energy_db) -> None:
"""import_cost_total getter must add prorated standing charges from active contract. """import_cost_total getter must add prorated standing charges from active contract.
Principle B/D: the getter delegates to summarize(anchor now).
anchor = earliest version's effective_from.
With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day. With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day.
Elapsed days = (now.date() - effective_from.date()).days + 1. The getter uses summarize, which counts whole *local* elapsed days under Principle C.
We pin the timezone to UTC for simplicity: local days = UTC days.
Setup:
- effective_from = 10 full UTC days ago (exact UTC midnight), so today is day 11
but Principle C counts only days D today, and today is included as 1 day.
With anchor = 10 days ago UTC midnight and now near end of day 11:
local (UTC) dates: [10-days-ago, today_inclusive] = 11 days.
We place period rows within [anchor, now] so summarize sees them.
""" """
from decimal import Decimal from decimal import Decimal
from app.integrations.expose import build_catalog
# Set effective_from to 10 days ago (UTC) to get a known elapsed day count.
now_utc = datetime.now(timezone.utc)
effective_from = datetime(
now_utc.year, now_utc.month, now_utc.day, 0, 0, 0, tzinfo=timezone.utc
).replace(day=1)
# Use a date we know: 10 days before today (simulate by computing days ourselves)
from datetime import timedelta from datetime import timedelta
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=9) from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
# Expected elapsed days = 9 + 1 = 10 (from effective_from date to today, inclusive) now_utc = datetime.now(timezone.utc)
expected_days = (now_utc.date() - effective_from.date()).days + 1 # anchor = exactly 10 UTC days ago at midnight
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=10)
# Under UTC timezone (pinned), Principle C counts whole days from effective_from.date()
# to today (inclusive) = 11 days total (10 + today).
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11
import_cost_sum = 5.00 # EUR import_cost_sum = 5.00 # EUR
t0 = datetime(2025, 10, 1, 6, 0, tzinfo=timezone.utc) # Place period rows AFTER effective_from so summarize(anchor, now) picks them up.
t1 = datetime(2025, 10, 1, 6, 15, tzinfo=timezone.utc) t0 = effective_from + timedelta(hours=1)
t1 = t0 + timedelta(minutes=15)
with Session(energy_db) as session: with Session(energy_db) as session:
_make_contract_with_version( _make_contract_with_version(
@@ -905,6 +917,8 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
session.commit() session.commit()
with Session(energy_db) as session: with Session(energy_db) as session:
# Pin to UTC so local days = UTC days (deterministic on any CI host).
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session) catalog = build_catalog(session)
import_entry = next( import_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_total" e for e in catalog if e.entity.key == "energy.import_cost_total"
@@ -915,7 +929,7 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
daily_standing = Decimal("90") / Decimal("30") daily_standing = Decimal("90") / Decimal("30")
expected = float(Decimal(str(import_cost_sum)) + daily_standing * expected_days) expected = float(Decimal(str(import_cost_sum)) + daily_standing * expected_days)
assert value == pytest.approx(expected, rel=1e-9), ( assert value == pytest.approx(expected, rel=1e-6), (
f"Expected import_cost_total={expected} (sum={import_cost_sum} + " f"Expected import_cost_total={expected} (sum={import_cost_sum} + "
f"standing={float(daily_standing * expected_days)} over {expected_days} days), " f"standing={float(daily_standing * expected_days)} over {expected_days} days), "
f"got {value!r}" f"got {value!r}"
@@ -930,20 +944,26 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
def test_export_revenue_total_includes_tax_credit(energy_db) -> None: def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
"""export_revenue_total getter must add prorated heffingskorting from active contract. """export_revenue_total getter must add prorated heffingskorting from active contract.
With heffingskorting=365 EUR/year, daily_credit = 1.0 EUR/day. Principle B/D: delegates to summarize(anchor now).
With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day.
Timezone pinned to UTC for deterministic local-day counting.
""" """
from decimal import Decimal from decimal import Decimal
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4) effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4)
# Expected elapsed days = 4 + 1 = 5 # Under UTC pinned: expected_days = days from effective_from.date() to today inclusive.
expected_days = (now_utc.date() - effective_from.date()).days + 1 expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5
t0 = datetime(2025, 11, 1, 6, 0, tzinfo=timezone.utc) # Place period rows AFTER effective_from.
t1 = datetime(2025, 11, 1, 6, 15, tzinfo=timezone.utc) t0 = effective_from + timedelta(hours=1)
t1 = t0 + timedelta(minutes=15)
export_sum = 2.50 # EUR export_sum = 2.50 # EUR
with Session(energy_db) as session: with Session(energy_db) as session:
@@ -958,6 +978,7 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
session.commit() session.commit()
with Session(energy_db) as session: with Session(energy_db) as session:
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session) catalog = build_catalog(session)
export_entry = next( export_entry = next(
e for e in catalog if e.entity.key == "energy.export_revenue_total" e for e in catalog if e.entity.key == "energy.export_revenue_total"
@@ -968,7 +989,7 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
daily_credit = Decimal("365") / Decimal("365") daily_credit = Decimal("365") / Decimal("365")
expected = float(Decimal(str(export_sum)) + daily_credit * expected_days) expected = float(Decimal(str(export_sum)) + daily_credit * expected_days)
assert value == pytest.approx(expected, rel=1e-9), ( assert value == pytest.approx(expected, rel=1e-6), (
f"Expected export_revenue_total={expected} (sum={export_sum} + " f"Expected export_revenue_total={expected} (sum={export_sum} + "
f"credit={float(daily_credit * expected_days)} over {expected_days} days), " f"credit={float(daily_credit * expected_days)} over {expected_days} days), "
f"got {value!r}" f"got {value!r}"
@@ -981,14 +1002,32 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> None: def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> None:
"""When contract version effective_from is in the future, standing cumulative must be 0.""" """When contract version effective_from is in the future, standing cumulative must be 0.
Principle D: anchor = effective_from (future).
summarize(anchor now) has a negative/empty window 0 days, 0 fixed_costs.
The global `has_data` check passes (there are non-degraded rows from the past),
but no periods fall within [anchor, now] window metered = 0, fixed = 0.
The getter returns 0 + 0 = 0.
Note: under the new design, if a contract has a future effective_from, the
getter returns the pure metered sum from the summarize window (which is 0
since no periods exist after the future anchor). The old test expected to
return the raw global SUM, but with Principle D the anchor is the future
date window is empty metered = 0.
"""
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
future_effective = now_utc + timedelta(days=30) future_effective = now_utc + timedelta(days=30)
t0 = datetime(2025, 12, 1, 6, 0, tzinfo=timezone.utc) # Place a period BEFORE the future effective_from (global sum = 4.00)
# but it won't be in [anchor, now] window.
t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
import_cost_sum = 4.00 import_cost_sum = 4.00
with Session(energy_db) as session: with Session(energy_db) as session:
@@ -1002,30 +1041,37 @@ def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> N
session.commit() session.commit()
with Session(energy_db) as session: with Session(energy_db) as session:
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session) catalog = build_catalog(session)
import_entry = next( import_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_total" e for e in catalog if e.entity.key == "energy.import_cost_total"
) )
# Note: active_contract_version_at uses ts=now, but effective_from > now,
# so the version does not cover now → version lookup returns None → standing = 0.
value = import_entry.entity.value_getter(session) value = import_entry.entity.value_getter(session)
# Standing cumulative must be 0; only the raw SUM is returned. # anchor is in future → summarize([future, now]) is empty → metered=0, fixed_costs=0
assert value == pytest.approx(import_cost_sum, rel=1e-9), ( # Principle C: no future local days counted → 0 fixed costs.
f"When effective_from is in future, standing must be 0; " # Result = 0 (no periods in window) + 0 (0 standing days) = 0.
f"expected {import_cost_sum}, got {value!r}" assert value == pytest.approx(0.0, abs=1e-9), (
f"When effective_from is in future, summarize window is empty → value must be 0.0; "
f"got {value!r}"
) )
def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> None: def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> None:
"""When contract version effective_from is in the future, credit cumulative must be 0.""" """When contract version effective_from is in the future, credit cumulative must be 0.
Same logic as import: anchor is future summarize window empty 0 credits.
"""
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
future_effective = now_utc + timedelta(days=60) future_effective = now_utc + timedelta(days=60)
t0 = datetime(2025, 12, 2, 6, 0, tzinfo=timezone.utc) t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
export_sum = 3.00 export_sum = 3.00
with Session(energy_db) as session: with Session(energy_db) as session:
@@ -1039,16 +1085,16 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) ->
session.commit() session.commit()
with Session(energy_db) as session: with Session(energy_db) as session:
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session) catalog = build_catalog(session)
export_entry = next( export_entry = next(
e for e in catalog if e.entity.key == "energy.export_revenue_total" e for e in catalog if e.entity.key == "energy.export_revenue_total"
) )
value = export_entry.entity.value_getter(session) value = export_entry.entity.value_getter(session)
# Credit cumulative must be 0; only the raw SUM is returned. # anchor is in future → empty window → 0 credits
assert value == pytest.approx(export_sum, rel=1e-9), ( assert value == pytest.approx(0.0, abs=1e-9), (
f"When effective_from is in future, credit must be 0; " f"When effective_from is in future, credit must be 0; got {value!r}"
f"expected {export_sum}, got {value!r}"
) )
+187
View File
@@ -0,0 +1,187 @@
"""Tests for app/services/timezone.py (FU10-energy-tz-accrual).
All tests monkeypatch ``local_tz`` to a fixed zone so they are deterministic
on any CI host timezone.
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo
import pytest
from app.services import timezone as tz_mod
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture()
def ams(monkeypatch):
"""Pin local_tz to Europe/Amsterdam for all tests in this module."""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
@pytest.fixture()
def utc_tz(monkeypatch):
"""Pin local_tz to UTC for simple arithmetic tests."""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("UTC"))
# ---------------------------------------------------------------------------
# local_tz() — resolution and monkeypatch contract
# ---------------------------------------------------------------------------
def test_local_tz_returns_tzinfo(ams):
"""local_tz() must return a tzinfo object."""
tz = tz_mod.local_tz()
assert tz is not None
# Verify it behaves like a valid tzinfo.
d = datetime(2026, 6, 25, 12, 0, tzinfo=tz)
assert d.utcoffset() is not None
def test_local_tz_env_var_overrides(monkeypatch):
"""When TZ env var is set, local_tz() returns that zone."""
monkeypatch.setenv("TZ", "America/New_York")
tz = tz_mod.local_tz()
# ZoneInfo("America/New_York") should have UTC-5 or UTC-4 offset.
d = datetime(2026, 1, 1, 12, 0, tzinfo=tz) # January → UTC-5
offset_hours = d.utcoffset().total_seconds() / 3600
assert offset_hours == pytest.approx(-5.0)
monkeypatch.delenv("TZ")
# ---------------------------------------------------------------------------
# to_local() — conversion correctness
# ---------------------------------------------------------------------------
def test_to_local_aware_utc(ams):
"""Aware UTC datetime must convert to CEST (UTC+2) in summer."""
dt_utc = datetime(2026, 6, 25, 12, 0, tzinfo=UTC)
local = tz_mod.to_local(dt_utc)
assert local.tzinfo is not None
assert local.hour == 14 # CEST = UTC+2
def test_to_local_naive_treated_as_utc(ams):
"""Naive datetime must be treated as UTC and converted to local."""
naive = datetime(2026, 6, 25, 12, 0) # naive = UTC convention
local = tz_mod.to_local(naive)
assert local.hour == 14 # CEST = UTC+2
def test_to_local_winter_offset(monkeypatch):
"""CET (UTC+1) applies in January."""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
dt_utc = datetime(2026, 1, 15, 12, 0, tzinfo=UTC)
local = tz_mod.to_local(dt_utc)
assert local.hour == 13 # CET = UTC+1
# ---------------------------------------------------------------------------
# local_date() — date extraction
# ---------------------------------------------------------------------------
def test_local_date_around_cest_midnight(ams):
"""UTC 22:00 on June 24 is local midnight June 25 in CEST."""
# CEST = UTC+2; June 24 22:00 UTC = June 25 00:00 CEST
dt = datetime(2026, 6, 24, 22, 0, tzinfo=UTC)
d = tz_mod.local_date(dt)
assert d == date(2026, 6, 25)
def test_local_date_just_before_cest_midnight(ams):
"""UTC 21:59 on June 24 is still June 24 in CEST (23:59 local)."""
dt = datetime(2026, 6, 24, 21, 59, tzinfo=UTC)
d = tz_mod.local_date(dt)
assert d == date(2026, 6, 24)
# ---------------------------------------------------------------------------
# local_midnight_utc() — round-trip correctness
# ---------------------------------------------------------------------------
def test_local_midnight_utc_cest_summer(ams):
"""Europe/Amsterdam local midnight June 25 → June 24 22:00 UTC (CEST=UTC+2)."""
utc_midnight = tz_mod.local_midnight_utc(date(2026, 6, 25))
expected = datetime(2026, 6, 24, 22, 0, tzinfo=UTC)
assert utc_midnight == expected
def test_local_midnight_utc_cet_winter(monkeypatch):
"""Europe/Amsterdam local midnight Jan 15 → Jan 14 23:00 UTC (CET=UTC+1)."""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
utc_midnight = tz_mod.local_midnight_utc(date(2026, 1, 15))
expected = datetime(2026, 1, 14, 23, 0, tzinfo=UTC)
assert utc_midnight == expected
def test_local_midnight_utc_roundtrip(ams):
"""local_date(local_midnight_utc(D)) must return D for any date D."""
for day in range(1, 32):
d = date(2026, 6, day) if day <= 30 else date(2026, 7, day - 30)
if day > 30:
continue
midnight_utc = tz_mod.local_midnight_utc(d)
recovered = tz_mod.local_date(midnight_utc)
assert recovered == d, f"Round-trip failed for {d}"
# ---------------------------------------------------------------------------
# local_now() — sanity check
# ---------------------------------------------------------------------------
def test_local_now_is_aware(ams):
"""local_now() must return a timezone-aware datetime."""
now = tz_mod.local_now()
assert now.tzinfo is not None
assert now.utcoffset() is not None
# ---------------------------------------------------------------------------
# effective_from naive→local→UTC (Principle A — contract routes)
# ---------------------------------------------------------------------------
def test_localize_naive_effective_from(monkeypatch):
"""A naive '2026-06-25T00:00:00' must be interpreted as CEST local midnight.
Expected: CEST midnight June 25 = UTC June 24 22:00.
"""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
# Simulate what the route helper does:
from app.api.routes.api.energy_contracts import _localize_effective_from
naive = datetime(2026, 6, 25, 0, 0, 0) # naive, no tzinfo
utc_result = _localize_effective_from(naive)
assert utc_result.tzinfo is not None
expected = datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC)
assert utc_result == expected, (
f"Naive local midnight should localize to {expected}, got {utc_result}"
)
def test_localize_aware_effective_from():
"""An aware datetime must be stored as UTC unchanged (no double conversion)."""
from app.api.routes.api.energy_contracts import _localize_effective_from
aware = datetime(2026, 6, 25, 0, 0, 0, tzinfo=UTC)
utc_result = _localize_effective_from(aware)
assert utc_result == aware
def test_localize_none_effective_from():
"""None effective_from must return current UTC time (as before)."""
from app.api.routes.api.energy_contracts import _localize_effective_from
before = datetime.now(UTC)
result = _localize_effective_from(None)
after = datetime.now(UTC)
assert result.tzinfo is not None
assert before <= result <= after