Proration & Billing-Period Calculation for Partial Service Periods

Most invoices in a municipal cycle cover a whole, clean period; the ones that generate disputes cover a partial one. A customer opens service on the eleventh, a council-approved service charge takes effect mid-cycle, a meter is set on a leap day, or a period straddles the spring-forward transition — and suddenly the bill must answer a precise question: exactly how much of a monthly charge does this account owe for the fraction of the period it was actually served? This page sits inside Automated Rate Calculation & Rule Engines, and it treats proration as a deterministic calculation rather than a spreadsheet fudge. The stakes are concrete: an off-by-one day count applied across tens of thousands of move-ins is a measurable revenue variance, and a proration that a customer service representative cannot reproduce on demand is an audit finding waiting to happen. The four invariants enforced throughout are that every period boundary is a time-zone-aware instant, every day is counted under one declared convention, every currency amount is a Decimal, and the sum of prorated parts reconciles exactly to the whole it was split from.

Prerequisites

Proration is arithmetic that touches money, so the toolchain choices below are semantic, not stylistic. A change to any of them changes a billed amount.

  • Python 3.11+. Required for zoneinfo in the standard library, StrEnum, and datetime fold semantics used to resolve daylight-saving boundaries deterministically. Never pytz; never a naive datetime for a period edge.
  • decimal.Decimal for every monetary and rate quantity. Proration multiplies a fee by a ratio of day counts; binary float cannot represent tenths of a cent exactly, and the drift surfaces as sub-cent variances that reconciliation rejects.
  • A single, declared day-count convention per rate. Actual/actual, actual/month, or 30/360 each produce a different fraction for the same dates. The convention must be stored with the rate, not assumed at call sites.
  • zoneinfo for the utility’s civil time zone. Billing periods begin and end at local midnight; whether a given instant belongs to the last day of one period or the first day of the next is a time-zone question, and DST transitions make it a subtle one.
  • Rate schedules versioned by effective date. A mid-cycle rate change must be modeled as two versions with effective dates, never as an in-place edit, so a period that spans the change can be split and each part priced against the version that was legally in force. This is the same effective-date discipline used across automated rate calculation.
  • Data assumption: a period is expressed as a half-open interval of civil dates [start, end)start is the first service day, end is the first day after the period. Half-open intervals compose without double-counting the shared boundary day, which is what makes adjacent sub-periods sum cleanly.

Architecture Overview

Proration is a split-price-sum pipeline. A billing period is first resolved to two time-zone-aware instants; it is then cut at every rate-change effective date that falls inside it, producing contiguous sub-periods; each sub-period is priced against the rate version in force for it; and the priced parts are summed back into one period charge whose components reconcile to the total. The diagram below traces a single period that a mid-cycle service-charge change splits into two sub-periods.

Splitting a billing period at an effective date, pricing each sub-period, and summing A monthly billing period runs from June 1 at local midnight to July 1 at local midnight, a half-open interval of thirty days. A rate-change effective date on June 19 cuts the period into two contiguous sub-periods: sub-period A covers eighteen days at the old service charge, and sub-period B covers twelve days at the new service charge. Each sub-period is day-weighted and priced to a Decimal amount. The two priced amounts are then summed into a single period total, and a conservation check confirms the sub-period day counts sum to the full thirty and the amounts sum to the invoiced charge. Jun 1 · 00:00 local Jul 1 · 00:00 local Billing period — half-open [Jun 1, Jul 1) · 30 days effective date · Jun 19 Sub-period A 18 days · old service charge Sub-period B 12 days · new service charge Price A fee×(18/30) → Decimal day-weighted, quantized Price B fee×(12/30) → Decimal day-weighted, quantized Period total = A + B · one invoice line days & amounts reconcile

Figure: A period is cut at the effective date into contiguous sub-periods, each priced by its day weight, then summed into one reconciled period charge.

The pipeline is intentionally boring, because boring is auditable. Nothing in it depends on wall-clock arithmetic, floating-point ratios, or an implicit assumption about how many days a month has. Each stage is a pure function of civil dates, a declared convention, and versioned rates, which is what lets a representative reproduce any prorated line exactly and lets a nightly job replay a full cycle to the cent.

Step-by-Step Implementation

Step 1 — Model the billing period with time-zone-aware boundaries

A billing period is not a pair of dates; it is a pair of instants. The period begins at local midnight on its first service day and ends at local midnight on the day after its last, and both endpoints must be resolved in the utility’s civil zone so that the day a late-evening event belongs to is never ambiguous. Crucially, the count of days in the period is a calendar-day difference, which is invariant to daylight saving: a 23-hour spring-forward day and a 25-hour fall-back day each still count as exactly one billing day. Anchoring boundaries with zoneinfo gives correct instants for logging and equality while keeping the day count integer and stable.

from __future__ import annotations

from dataclasses import dataclass
from datetime import date, datetime
from zoneinfo import ZoneInfo

UTILITY_TZ = ZoneInfo("America/Denver")  # the utility's civil time zone


@dataclass(frozen=True, slots=True)
class BillingPeriod:
    """A service period as a half-open interval of civil dates [start, end)."""

    start: date          # first service day (inclusive)
    end: date            # first day AFTER the period (exclusive)
    tz: ZoneInfo = UTILITY_TZ

    def __post_init__(self) -> None:
        if self.end <= self.start:
            raise ValueError(f"period end {self.end} must be after start {self.start}")

    def _midnight(self, d: date) -> datetime:
        # Local midnight for a civil date; zoneinfo attaches the correct offset,
        # including across a DST transition, so the instant is unambiguous.
        return datetime(d.year, d.month, d.day, tzinfo=self.tz)

    @property
    def start_instant(self) -> datetime:
        return self._midnight(self.start)

    @property
    def end_instant(self) -> datetime:
        return self._midnight(self.end)

    @property
    def days(self) -> int:
        # Calendar-day count is DST-invariant: a 23h or 25h day is still one day.
        return (self.end - self.start).days

Storing the boundaries as civil dates and deriving instants on demand keeps the model honest: day counting stays in the calendar domain where it belongs, and the tz-aware instants exist for audit records and for any downstream logic (such as seasonal rate mapping and calendar logic) that must reason about the exact moment a period opened.

Step 2 — Split the period at rate-change effective dates

When a charge changes partway through a period, the period must be cut at the change so each portion is priced against the version legally in force for it. The rate schedule is a list of (effective_date, monthly_charge) versions in ascending order; a mid-period change is two versions whose effective dates bracket the cut. Splitting produces contiguous half-open sub-periods, and because the intervals are half-open, adjacent sub-periods share a boundary date without either one counting it twice.

from decimal import Decimal


@dataclass(frozen=True, slots=True)
class SubPeriod:
    start: date
    end: date
    monthly_charge: Decimal   # the fixed charge version in force for this slice

    @property
    def days(self) -> int:
        return (self.end - self.start).days


def _charge_in_force(schedule: list[tuple[date, Decimal]], when: date) -> Decimal:
    """The most recent version whose effective date is on or before `when`."""
    applicable = [amount for eff, amount in schedule if eff <= when]
    if not applicable:
        raise ValueError(f"no rate version in force on {when.isoformat()}")
    return applicable[-1]          # schedule is sorted ascending; last wins


def split_on_effective_dates(
    period: BillingPeriod,
    schedule: list[tuple[date, Decimal]],
) -> list[SubPeriod]:
    """Cut the period at every effective date strictly interior to it."""
    schedule = sorted(schedule, key=lambda row: row[0])
    interior = [eff for eff, _ in schedule if period.start < eff < period.end]
    edges = [period.start, *interior, period.end]
    return [
        SubPeriod(lo, hi, _charge_in_force(schedule, lo))
        for lo, hi in zip(edges, edges[1:])
    ]

Only effective dates strictly inside the open interval (start, end) create a cut. A version that takes effect exactly on the period start simply becomes the charge for the whole first slice, and one that takes effect on the exclusive end belongs to the next period, not this one — the boundary conditions that most naive implementations get wrong. When a period has no interior effective date, split_on_effective_dates returns a single sub-period covering the whole span, so the rest of the pipeline is identical whether or not a rate changed.

Step 3 — Day-weight each sub-period with Decimal, under a declared convention

Pricing a sub-period is a multiplication of its monthly charge by a ratio of day counts. The ratio’s denominator is the day-count convention, and it must be explicit. Under actual/actual-period the denominator is the number of days in the billing period; under actual/actual-month it is the number of days in the calendar month (so a February period and a July period weight a day differently); under 30/360 every month is treated as 30 days. The convention is a property of the rate, so it travels with the schedule rather than being hard-coded at the call site.

from calendar import monthrange
from decimal import ROUND_HALF_UP
from enum import StrEnum

CENTS = Decimal("0.01")


class DayCount(StrEnum):
    ACTUAL_PERIOD = "actual/actual-period"
    ACTUAL_MONTH = "actual/actual-month"
    THIRTY_360 = "30/360"


def _denominator(convention: DayCount, period: BillingPeriod) -> int:
    if convention is DayCount.ACTUAL_PERIOD:
        return period.days
    if convention is DayCount.ACTUAL_MONTH:
        return monthrange(period.start.year, period.start.month)[1]
    if convention is DayCount.THIRTY_360:
        return 30
    raise ValueError(convention)


def prorate_charge(
    monthly_charge: Decimal,
    numerator_days: int,
    denominator_days: int,
) -> Decimal:
    """Day-weight one charge and quantize to whole cents deterministically."""
    if denominator_days <= 0:
        raise ValueError("denominator_days must be positive")
    fraction = Decimal(numerator_days) / Decimal(denominator_days)
    return (monthly_charge * fraction).quantize(CENTS, rounding=ROUND_HALF_UP)

Two properties make this audit-safe. First, the fraction is built from integer day counts converted to Decimal, so there is no float ratio anywhere in the money path. Second, quantize with an explicit ROUND_HALF_UP fixes the rounding rule at the point of computation rather than leaving it to the display layer, so the stored amount and the printed amount are the same value. Metered usage proration follows the same day-weighting only as a fallback: when actual interval reads exist, usage should be split by the reads themselves, priced through step-rate versus block-rate structure design, and day-weighting reserved for the fixed components and for periods where interval data is genuinely absent.

Step 4 — Sum sub-period charges into a reconciled period invoice

The final stage prices every sub-period and sums the parts. It also emits a line item per sub-period so the invoice explains itself: each line carries its span, its day count, the charge version applied, and the resulting amount. A representative asked “why is this bill $9.20 and not $12.00?” can read the answer straight off the line items.

@dataclass(frozen=True, slots=True)
class LineItem:
    start: date
    end: date
    days: int
    monthly_charge: Decimal
    amount: Decimal


def price_fixed_charge(
    period: BillingPeriod,
    schedule: list[tuple[date, Decimal]],
    convention: DayCount = DayCount.ACTUAL_PERIOD,
) -> tuple[list[LineItem], Decimal]:
    """Split, day-weight, and sum a fixed charge that may change mid-period."""
    subs = split_on_effective_dates(period, schedule)
    denom = _denominator(convention, period)
    items = [
        LineItem(
            start=s.start,
            end=s.end,
            days=s.days,
            monthly_charge=s.monthly_charge,
            amount=prorate_charge(s.monthly_charge, s.days, denom),
        )
        for s in subs
    ]
    total = sum((item.amount for item in items), Decimal("0.00"))
    return items, total

Because each sub-period is priced independently against its own charge version, this correctly handles the rate-change-mid-period case: sub-period A is billed at the old charge for its days and sub-period B at the new charge for its days. When the fixed charge does not change and a single amount must instead be split across arbitrary slices — for example distributing one monthly fee across occupancy segments — independent rounding of each slice can drift a cent from the intended whole. That conservation problem is solved by a largest-remainder allocation, covered in Verification and Audit Trail below, and the day-counting choices it depends on are worked through in detail for the common case of prorating charges for move-in and move-out dates.

Edge-Case Handling

The clean, whole-month period never causes trouble. Each case below has produced a wrong municipal bill, and each is handled by the model above rather than patched around it.

  • Spring-forward and fall-back days (23h / 25h). A period spanning a DST transition still contains an integer number of calendar days. Because BillingPeriod.days is a date difference, not an elapsed-hours calculation, the 23-hour or 25-hour day counts as one day and the proration fraction is unaffected. Elapsed-hour arithmetic would silently over- or under-bill by roughly 1/720 of a monthly charge; day counting avoids it entirely.
  • Leap-year day counts. Under actual/actual-month, a day in February 2028 weighs 1/29, while the same day in a common year weighs 1/28. monthrange returns 29 for a leap February, so the denominator is correct without special-casing. A period that spans February 28 into March under actual/actual-period simply counts the real number of days between its boundaries.
  • Same-day start and stop. A period whose end equals its start covers zero days and is rejected by BillingPeriod.__post_init__, because a half-open interval of length zero is not a billable period. A genuine same-day service event — connected and disconnected on the same date — is a one-day period [d, d+1), which the model bills as a single day. Distinguishing “zero days” from “one day” is a policy decision that must be made once and applied everywhere.
  • Rate change effective on a period boundary. A version effective exactly on start prices the whole period at that version (no cut); a version effective exactly on the exclusive end belongs to the next period. Only strictly interior effective dates split the period, so a change dated to the first of the next cycle never leaks a phantom sub-period into this one.
  • Effective date outside the period. A schedule may contain versions from before and after the period. _charge_in_force selects the latest version on or before each slice’s start, and versions dated after the period are ignored for it, so a schedule spanning years still prices a single month correctly. When no version is in force at all, the code raises rather than guessing — the missing-schedule case is routed through fallback routing for missing rate data instead of defaulting to zero.
  • Mixed conventions in one invoice. A base service charge on actual/actual-period and a franchise fee on 30/360 can legitimately coexist. Because the convention travels with each charge, price_fixed_charge is called once per charge with its own convention, and the invoice sums heterogeneous conventions without conflating them.

Verification and Audit Trail

A proration is only defensible if it reconciles. Two independent checks establish that. First, day conservation: the day counts of the sub-periods a split produced must sum to the period’s own day count, with no gap and no overlap. Second, amount conservation for a split single charge: when one fixed fee is distributed across slices, the allocated parts must sum exactly to the fee — independent ROUND_HALF_UP on each slice can leave a residual cent, so a largest-remainder allocation assigns the leftover cents to the slices that lost the most to flooring. Both checks belong in the code path and in the audit record, not only in a test.

from decimal import ROUND_DOWN


def assert_days_reconcile(period: BillingPeriod, subs: list[SubPeriod]) -> None:
    """A split must partition the period exactly — no gap, no overlap."""
    if sum(s.days for s in subs) != period.days:
        raise AssertionError("sub-period days do not sum to the period length")
    for earlier, later in zip(subs, subs[1:]):
        if earlier.end != later.start:
            raise AssertionError(f"gap or overlap at {earlier.end} / {later.start}")


def allocate_single_fee(fee: Decimal, subs: list[SubPeriod], total_days: int) -> list[Decimal]:
    """Distribute ONE fee across slices so the parts sum EXACTLY to the fee."""
    exact = [fee * Decimal(s.days) / Decimal(total_days) for s in subs]
    floored = [amt.quantize(CENTS, rounding=ROUND_DOWN) for amt in exact]
    leftover = int(((fee - sum(floored)) / CENTS).to_integral_value(rounding=ROUND_HALF_UP))
    # Rank slices by the fractional cent each lost to flooring; award leftovers there.
    order = sorted(range(len(subs)), key=lambda i: exact[i] - floored[i], reverse=True)
    result = list(floored)
    for i in order[:leftover]:
        result[i] += CENTS
    return result

The audit trail records, per invoice line, the period boundaries as tz-aware ISO instants, the convention name, the numerator and denominator day counts, the charge version and its effective date, and the quantized amount — enough for any reviewer to recompute the line by hand. This is the same append-only, reconcilable discipline that governs the broader system, and prorated adjustments flow into it exactly like any other charge; the shape of that ledger is covered under surcharge and fee application logic, where prorated fees and flat surcharges must sum consistently. A retroactive council order that changes a charge for a period already billed is handled by adding a new schedule version and re-running price_fixed_charge, producing an additive adjustment rather than a destructive overwrite.

def verify_reconciliation() -> None:
    period = BillingPeriod(date(2026, 6, 1), date(2026, 7, 1))
    schedule = [
        (date(2026, 1, 1), Decimal("12.00")),   # old service charge
        (date(2026, 6, 19), Decimal("15.00")),  # new charge, effective mid-period
    ]
    items, total = price_fixed_charge(period, schedule)

    assert [i.days for i in items] == [18, 12]           # split at Jun 19
    assert items[0].amount == Decimal("7.20")            # 12.00 * 18/30
    assert items[1].amount == Decimal("6.00")            # 15.00 * 12/30
    assert total == Decimal("13.20")

    subs = split_on_effective_dates(period, schedule)
    assert_days_reconcile(period, subs)                  # 18 + 12 == 30

    # A single unchanged fee splits penny-exact across the same two slices.
    parts = allocate_single_fee(Decimal("10.00"), subs, period.days)
    assert sum(parts) == Decimal("10.00")                # no cent lost to rounding

Running this against historical cycle fixtures — real periods that crossed DST, leap days, and mid-cycle orders — is what catches a convention or boundary regression before it reaches a customer’s bill.

Troubleshooting

Symptom Likely cause Fix
Prorated parts sum to one cent less than the fee Independent ROUND_HALF_UP on each slice dropped a residual cent Split single fees with allocate_single_fee (largest-remainder), not by rounding each slice in isolation
A day is billed on two adjacent periods Inclusive end boundary double-counts the shared day Use half-open [start, end) intervals so the boundary day belongs to exactly one period
February bills look proportionally high Actual/actual-period used where actual/actual-month was intended Store the day-count convention with the rate and pass it explicitly to price_fixed_charge
Bill shifts by a day around March or November Period boundary computed from elapsed hours across a DST change Count calendar days via date difference; resolve instants with zoneinfo, never by adding 24-hour deltas
Mid-cycle rate change priced entirely at the new rate Effective date treated as applying to the whole period Split at strictly interior effective dates and price each slice against _charge_in_force for its start
no rate version in force raised mid-run Schedule has a gap before the period start Ensure a version is effective on or before the period start; route true gaps through fallback routing rather than defaulting to zero
Same-day connect/disconnect bills zero Period built as [d, d) (zero-length) instead of [d, d+1) Decide the one-day-minimum policy once and construct the period accordingly

Frequently Asked Questions

Should I count days inclusively or exclusively?

Use half-open intervals internally: the period runs from the first service day up to, but not including, the day after the last. This makes adjacent periods and sub-periods compose without either double-counting or dropping the shared boundary day. The presentation of a period to a customer can still read as inclusive dates (“June 1 through June 30”), but the arithmetic should be half-open so that thirty days is [Jun 1, Jul 1). The move-in and move-out case has its own inclusive/exclusive nuance and is worked through separately.

Which day-count convention should a municipality use?

Whichever the governing rate ordinance or tariff specifies — the code must follow policy, not the reverse. Actual/actual-period is the most transparent for customers because every day in the cycle weighs the same. Actual/actual-month is common where a monthly charge is defined as “per calendar month” and partial months are prorated against that month’s real length. 30/360 appears mostly in charges inherited from financial instruments. The essential discipline is that exactly one convention is declared per charge and stored with it, so the same dates always produce the same fraction.

How do I prorate when a rate changes in the middle of a period?

Model the change as two versioned entries in the rate schedule with distinct effective dates, then split the period at the effective date and price each sub-period against the version in force for it. Never edit a rate in place. Splitting at the effective date means the old charge applies to the days before it and the new charge to the days on and after it, and because the amounts are computed independently and then summed, the result is exact and fully explainable on the invoice.

Does daylight saving time change a prorated amount?

No, provided you count calendar days rather than elapsed hours. A billing period that spans the spring-forward transition contains a 23-hour day, and one that spans fall-back contains a 25-hour day, but each is still one billing day. Because the day count is a date difference resolved with zoneinfo-anchored boundaries, the proration fraction is identical to a period with no transition. Only implementations that compute duration in hours and divide by 24 are affected — and that approach should be avoided.

How do prorated charges reconcile in the audit trail?

Each prorated line records its boundaries as time-zone-aware instants, the convention used, the numerator and denominator day counts, the charge version with its effective date, and the quantized amount. Two invariants are asserted: sub-period days sum to the period length, and when a single fee is split, the parts sum exactly to the fee. A reviewer can recompute any line from these fields, and a retroactive rate order produces an additive adjustment by re-running the calculation with a new schedule version rather than overwriting the original.

Up one level: Automated Rate Calculation & Rule Engines · Return to the utilitybilling.org home.