Handling Mid-Cycle Customer Class Changes
An account rarely changes its customer class on a tidy cycle boundary. A landlord converts a duplex to a storefront on the 14th, a home business is reclassified commercial after a zoning appeal, a farm loses its agricultural exemption mid-month — and the billing engine still has one meter read spanning the whole period. The wrong fix is to pick a single class for the entire cycle: bill the whole month residential and the municipality forgives revenue it is owed; bill it all commercial and the ratepayer is over-charged for days they were still residential. The correct fix is to split the cycle at the change’s effective date and rate each sub-period under the class that was actually in force during it. This page is the focused counterpart to the broader customer class and service tier mapping workflow inside the municipal utility billing architecture and rate taxonomy subsystem. Where mapping residential versus commercial classes decides which class an account holds, this page decides what to do when that answer changes on, say, the 14th.
Minimal Prerequisites
- Python 3.11+ — for
StrEnum,datetime.date, and theX | Noneunion syntax used below. decimal.Decimalfor every kWh volume and dollar amount — neverfloat. Proration divides a total by a day count, and float division seeds a fractional-cent drift that reconciliation cannot later close.- The class-change effective date is a governed, authoritative fact — sourced from the reclassification workflow, not inferred here. This page assumes the effective date and the opening class have already been resolved and validated upstream.
Data assumptions: a billing cycle is a closed [cycle_start, cycle_end] interval of civil calendar dates, both inclusive. One cumulative total_kwh is known for the whole cycle. Zero or more ClassChange records may carry an effective date; each names the class that comes into force on that day. Unless interval or AMI data is available to attribute usage to specific days, consumption is assumed uniform across the cycle and prorated by day count.
Annotated Implementation
The logic separates cleanly into three steps: cut the cycle into contiguous single-class segments, allocate the cycle’s consumption across those segments by days, then rate each segment under its own class. Keeping allocation and rating apart is what lets the day-proration stay exact — the parts must sum back to the whole total_kwh before any rate touches them.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP
from enum import StrEnum
CENTS = Decimal("0.01")
KWH = Decimal("0.001") # allocation granularity for prorated volume
class CustomerClass(StrEnum):
RESIDENTIAL = "RES"
COMMERCIAL = "COM"
# Per-class volumetric rate ($/kWh). Authoritative config, shown inline for clarity.
CLASS_RATES = {
CustomerClass.RESIDENTIAL: Decimal("0.1180"),
CustomerClass.COMMERCIAL: Decimal("0.1425"),
}
@dataclass(frozen=True, order=True)
class ClassChange:
effective_on: date # the FIRST day the new class is in force
new_class: CustomerClass
@dataclass(frozen=True)
class Segment:
customer_class: CustomerClass
start: date # inclusive
end: date # inclusive
@property
def days(self) -> int:
return (self.end - self.start).days + 1
def split_cycle(
cycle_start: date,
cycle_end: date,
opening_class: CustomerClass,
changes: list[ClassChange],
) -> list[Segment]:
"""Cut [cycle_start, cycle_end] into contiguous single-class segments."""
current = opening_class
interior: list[ClassChange] = []
for change in sorted(changes):
if change.effective_on <= cycle_start:
current = change.new_class # in force for the whole cycle
elif change.effective_on <= cycle_end:
interior.append(change) # a real mid-cycle boundary
# a change effective after cycle_end belongs to a later cycle
segments: list[Segment] = []
seg_start, seg_class = cycle_start, current
for change in interior:
# The day BEFORE the effective date closes the outgoing segment.
segments.append(Segment(seg_class, seg_start, change.effective_on - timedelta(days=1)))
seg_start, seg_class = change.effective_on, change.new_class
segments.append(Segment(seg_class, seg_start, cycle_end))
return segments
def allocate_by_days(total_kwh: Decimal, segments: list[Segment]) -> list[Decimal]:
"""Split total_kwh across segments by day count; parts sum to the whole exactly."""
total_days = sum(s.days for s in segments)
exact = [total_kwh * s.days / total_days for s in segments]
floored = [e.quantize(KWH, rounding=ROUND_DOWN) for e in exact]
# Largest-remainder (Hamilton) distribution of the leftover quanta.
leftover = int(((total_kwh - sum(floored)) / KWH).to_integral_value())
by_remainder = sorted(range(len(segments)), key=lambda i: exact[i] - floored[i], reverse=True)
for i in by_remainder[:leftover]:
floored[i] += KWH
return floored
def rate_cycle(
cycle_start: date,
cycle_end: date,
opening_class: CustomerClass,
total_kwh: Decimal,
changes: list[ClassChange],
) -> tuple[list[tuple[Segment, Decimal, Decimal]], Decimal]:
"""Return (segment, volume, charge) line items plus the reconciled cycle total."""
segments = split_cycle(cycle_start, cycle_end, opening_class, changes)
volumes = allocate_by_days(total_kwh, segments)
lines: list[tuple[Segment, Decimal, Decimal]] = []
total = Decimal("0")
for seg, volume in zip(segments, volumes):
# Each segment is a visible bill line, so its charge is rounded to cents here.
charge = (volume * CLASS_RATES[seg.customer_class]).quantize(CENTS, rounding=ROUND_HALF_UP)
lines.append((seg, volume, charge))
total += charge # cycle total == sum of the line items
return lines, total
Two decisions carry the billing weight. First, split_cycle treats the effective date as the first day the new class applies, so the outgoing segment ends on the day before — an off-by-one here silently misprices a day at the wrong rate every time a class changes. Second, allocate_by_days uses a largest-remainder distribution so the prorated segment volumes sum back to total_kwh to the last thousandth of a kilowatt-hour; a naive round-each-share would leak or invent fractions of a kWh that then reconcile short against the metered total. Because each segment becomes its own line on the bill, its charge is rounded to cents per segment and the cycle total is the sum of those rounded lines — the opposite discipline from a single continuous marginal block-rate charge, which rounds only once at the very end.
Edge Cases and Billing Gotchas
Mid-cycle class changes fail in a handful of specific, revenue-affecting ways. These are the ones worth encoding as fixtures before a batch runs.
- Change on a cycle boundary. A change effective on
cycle_startmeans the account was the new class for the entire period — there is no split, and the opening class is simply the new one.split_cyclefolds any change witheffective_on <= cycle_startintocurrent, producing a single segment rather than an empty zero-day segment that would divide-by-zero in allocation. A change effective on the day aftercycle_endbelongs to the next cycle and must not affect this one. - Multiple changes in one cycle. A parcel can flip residential to commercial and back — or through mixed-use — inside a single period. Sorting the changes and walking them in order yields three or more contiguous segments; each still rates under the class in force during it, and the day counts still sum to the full cycle length. Never collapse multiple changes to “the last one wins.”
- Retroactive reclassification. A zoning appeal resolved after a cycle already posted carries an effective date in a closed period. Re-running
rate_cyclefor that historical window produces the corrected line items; the difference against the originally posted charge becomes an explicit adjustment, not a silent overwrite. The reclassification itself and who may back-date it are governed decisions — see security boundaries and role-based access. - Interaction with move-in/move-out proration. Day-based class proration composes with, but is distinct from, prorating a partial occupancy period. When both apply in one cycle, resolve the occupancy window first, then split the occupied interval by class so a vacant stretch is never rated under either class. The occupancy proration mechanics live in proration and billing period calculation; this page assumes the interval it splits is already the billable one.
Verification Snippet
Lock the split, the allocation, and the rating in place with assertions. The decisive cases are the conflict ones: a change on the boundary must collapse to a single segment, and multiple changes must each price under the right class while the volumes still tie back to the metered total.
from datetime import date
from decimal import Decimal
def test_single_mid_cycle_change_splits_and_rates() -> None:
lines, total = rate_cycle(
cycle_start=date(2026, 6, 1),
cycle_end=date(2026, 6, 30),
opening_class=CustomerClass.RESIDENTIAL,
total_kwh=Decimal("900"),
changes=[ClassChange(date(2026, 6, 16), CustomerClass.COMMERCIAL)],
)
assert [s.customer_class for s, _, _ in lines] == [
CustomerClass.RESIDENTIAL, CustomerClass.COMMERCIAL,
]
assert [s.days for s, _, _ in lines] == [15, 15]
assert sum(v for _, v, _ in lines) == Decimal("900.000") # allocation ties out
assert [c for _, _, c in lines] == [Decimal("53.10"), Decimal("64.13")]
assert total == Decimal("117.23") # total is Σ line items
def test_change_on_cycle_start_is_one_segment() -> None:
lines, total = rate_cycle(
cycle_start=date(2026, 6, 1),
cycle_end=date(2026, 6, 30),
opening_class=CustomerClass.RESIDENTIAL,
total_kwh=Decimal("900"),
changes=[ClassChange(date(2026, 6, 1), CustomerClass.COMMERCIAL)],
)
assert len(lines) == 1
assert lines[0][0].customer_class is CustomerClass.COMMERCIAL
assert total == Decimal("128.25") # 900 × 0.1425
def test_multiple_changes_each_price_under_class_in_force() -> None:
lines, _ = rate_cycle(
cycle_start=date(2026, 6, 1),
cycle_end=date(2026, 6, 30),
opening_class=CustomerClass.RESIDENTIAL,
total_kwh=Decimal("900"),
changes=[
ClassChange(date(2026, 6, 11), CustomerClass.COMMERCIAL),
ClassChange(date(2026, 6, 21), CustomerClass.RESIDENTIAL),
],
)
assert [s.customer_class for s, _, _ in lines] == [
CustomerClass.RESIDENTIAL, CustomerClass.COMMERCIAL, CustomerClass.RESIDENTIAL,
]
assert [s.days for s, _, _ in lines] == [10, 10, 10]
assert sum(v for _, v, _ in lines) == Decimal("900.000")
Run this against a replay of historical reclassifications and confirm the recomputed segment totals match what the billing team originally posted for the un-changed cycles, so a refactor of the split logic can never quietly move a day across a class boundary.
FAQ
Related Topics
- Customer Class & Service Tier Mapping — the parent workflow this mid-cycle handling plugs into.
- Municipal Utility Billing Architecture & Rate Taxonomy — the wider rate and account model the split feeds.
- How to Map Residential vs Commercial Classes in Python — how the class each segment carries is decided in the first place.
- Proration and Billing Period Calculation — day-based proration for partial occupancy that composes with a class split.
- Security Boundaries & Role-Based Access — who may set or back-date a class-change effective date.
Up: Customer Class & Service Tier Mapping · Municipal Utility Billing Architecture & Rate Taxonomy