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 the X | None union syntax used below.
  • decimal.Decimal for every kWh volume and dollar amount — never float. 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.

A billing cycle split at a mid-cycle customer class change A thirty-day billing cycle running from June 1 to June 30 carries a customer class change effective June 16, when the account changes from residential to commercial. A vertical boundary at the effective date splits the cycle timeline into two contiguous segments: a residential segment covering June 1 through June 15 (fifteen days) and a commercial segment covering June 16 through June 30 (fifteen days). The cycle's 900 kilowatt-hours are prorated by day count, giving 450 kilowatt-hours to each segment. Each segment is then rated under the class in force during it: the residential 450 kilowatt-hours at 0.1180 per unit yields 53.10, and the commercial 450 kilowatt-hours at 0.1425 per unit yields 64.13. The two segment charges are line items that sum to a reconciled cycle total of 117.23. Billing cycle · Jun 1 – Jun 30 · total 900 kWh class change effective Jun 16 Residential Jun 1 – Jun 15 · 15 days rate 0.1180 / kWh Commercial Jun 16 – Jun 30 · 15 days rate 0.1425 / kWh Segment 1 charge 450 × 0.1180 = 53.10 Segment 2 charge 450 × 0.1425 = 64.13 Cycle total (Σ line items) 53.10 + 64.13 = 117.23

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_start means the account was the new class for the entire period — there is no split, and the opening class is simply the new one. split_cycle folds any change with effective_on <= cycle_start into current, producing a single segment rather than an empty zero-day segment that would divide-by-zero in allocation. A change effective on the day after cycle_end belongs 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_cycle for 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

Why split the cycle instead of picking the class in force on the read date?
Because the read date is arbitrary relative to the change. Billing the whole cycle at the class in force on the last day over-charges the ratepayer for every day they were still the previous class, and billing at the first day's class forgives revenue the municipality is owed. Splitting at the effective date and rating each sub-period under its own class is the only outcome that is correct for both parties and defensible in a dispute.
How is the cycle's consumption divided between the two classes?
By day count, using a largest-remainder allocation so the prorated segment volumes sum back to the metered total exactly. This assumes usage is uniform across the cycle, which is the honest default when only one cumulative read exists. If interval or AMI data is available, attribute the actual metered volume in each segment's date range instead of prorating by days — the rating step is unchanged, only the volume source differs.
Should each segment be rounded to cents, or only the cycle total?
Each segment, because each segment appears as its own line item on the bill and a printed line must be a whole number of cents. The cycle total is then the sum of those rounded lines, which keeps the bill internally consistent. This is deliberately different from pricing one continuous block schedule, where you accumulate unrounded and quantize a single time at the very end — there, no intermediate figure is ever shown to the ratepayer.
What happens when the class changes more than once in a cycle?
The cycle is cut into as many contiguous segments as there are changes plus one, each rated under the class in force during it. Sorting the changes by effective date and walking them in order produces the segments, and the day counts still sum to the full cycle length. Nothing special is needed for two, three, or more changes — the same loop handles them, and each segment's volume is prorated from the same total.
How do I handle a reclassification that is back-dated into a closed cycle?
Re-run the rating for that historical window with the corrected change record, then post the difference against the originally billed amount as an explicit adjustment rather than overwriting the prior charge. The original and the correction both remain on the record so an auditor can reconstruct what changed and why. Who is permitted to back-date an effective date is itself a governed action controlled by role-based access.

Up: Customer Class & Service Tier Mapping · Municipal Utility Billing Architecture & Rate Taxonomy