FUE-T01: count billing window start day in full for fixed-fee/credit accrual (symmetric with end day)
This commit is contained in:
+57
-23
@@ -678,15 +678,33 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
||||
+ fixed_costs -- per-day standing charges, cross-version
|
||||
- credits -- per-day heffingskorting, cross-version
|
||||
|
||||
**Fixed-cost / credit counting — Principle C**:
|
||||
Only *already-elapsed* whole local calendar days are counted. For each
|
||||
local calendar date D in the window ``[start_local_date, min(end_local_date,
|
||||
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 1–30 uses V1 for days 1-24 and V2
|
||||
for day 25. Switching versions never resets the counter.
|
||||
**Fixed-cost / credit counting — Principle C (symmetric begin/end)**:
|
||||
Both the local calendar day in which *start* falls and the local calendar
|
||||
day in which *end* falls are counted as full days. Fixed charges
|
||||
(network_fee, management_fee) and the energy-tax credit (heffingskorting)
|
||||
are assessed on a "service-is-active" basis — if the meter was online on a
|
||||
given calendar day, the full day's charge/credit applies, regardless of
|
||||
whether the window starts at midnight or mid-morning.
|
||||
|
||||
For each local calendar date D in the range
|
||||
``[local_date(start), min(local_date(end), today_local)]``:
|
||||
- D ≤ today_local (only elapsed / today days count as "whole days").
|
||||
- The contract version whose effective_from local-date ≤ D is used.
|
||||
- This is cross-version: if V1 is from June 1 and V2 from June 25,
|
||||
querying June 1–30 uses V1 for days 1-24 and V2 for day 25.
|
||||
|
||||
The end-day (last_counted) is included when the end's local midnight falls
|
||||
strictly before end_utc; combined with the always-counted start day this
|
||||
makes the begin/end handling symmetric. A short same-day window therefore
|
||||
counts its single local day. A window contributes 0 days only when the
|
||||
counted range is empty (first_counted > last_counted) — e.g. a window lying
|
||||
entirely in the future, since last_counted is capped at today_local.
|
||||
|
||||
Daily getters (``*_today``) use windows exactly aligned to local midnight,
|
||||
so their ``first_counted`` is always today — unaffected by this fix.
|
||||
|
||||
Days that have not yet started in local time (D > today_local) are
|
||||
never counted. 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
|
||||
@@ -750,25 +768,41 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
||||
|
||||
# --- 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").
|
||||
# Both the window-start day and the window-end day are counted as complete
|
||||
# local calendar days, regardless of whether the window starts/ends at midnight.
|
||||
#
|
||||
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today)
|
||||
# AND the local midnight is within the billing window."
|
||||
# Principle C (symmetric begin/end):
|
||||
# • first_counted = local calendar date of start_utc (start day always counted)
|
||||
# • last_counted = local calendar date of end_utc (end day counted if its
|
||||
# local midnight is strictly before end_utc)
|
||||
# • Both are then capped at today_local (only elapsed / today days count).
|
||||
#
|
||||
# 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.
|
||||
# Why symmetric? Fixed charges (network_fee, management_fee) and energy-tax credits
|
||||
# (heffingskorting) are assessed on a "service-is-active" basis, not on how many
|
||||
# hours the service was actually running within that calendar day. If the meter
|
||||
# anchor (started_at) falls at 09:18 on June 24, the full June 24 standing charge
|
||||
# and credit still apply because the service was online for that day.
|
||||
#
|
||||
# Implementation: compute the first and last local date whose midnight is in range.
|
||||
# Previous asymmetric behaviour: a start_utc that was *later* than the local
|
||||
# midnight of local_start_date caused first_counted to be bumped to the *next*
|
||||
# day, silently dropping the anchor day's charges/credits. This was incorrect
|
||||
# for cumulative entities (import_cost_total / export_revenue_total) whose anchor
|
||||
# is often an above-midnight started_at. The end-side had always been symmetric
|
||||
# (counted if local midnight < end_utc), creating an inconsistency.
|
||||
#
|
||||
# Daily getters (window = [local today 00:00, local tomorrow 00:00)) are
|
||||
# unaffected: local_date(local_midnight_utc(today)) == today, so first_counted
|
||||
# is still today regardless of the fix.
|
||||
#
|
||||
# A short same-day window now counts its single local day (the start day is
|
||||
# always counted, symmetric with the end side). A window contributes 0 days
|
||||
# only when the counted range is empty (first_counted > last_counted) — e.g. a
|
||||
# window lying entirely in the future, where last_counted is capped at
|
||||
# today_local while first_counted is later.
|
||||
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)
|
||||
# Start side: always count the local calendar day in which start_utc falls.
|
||||
first_counted: _date = local_date(start_utc)
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user