Prorating Charges for Move-In and Move-Out Dates

A new account rarely starts on the first of a cycle and a closing account rarely ends on the last. When a customer moves in on the eleventh and out on the twenty-third, the fixed monthly service charge — the flat connection or availability fee that does not depend on usage — must be billed only for the days they actually held the account. The whole disagreement lives in one decision: which days count. Charge the move-out day or not? Include the move-in day? An off-by-one here, multiplied across every start and stop in a cycle, is a real revenue variance and a stack of “my final bill is a day too long” calls. This page sits under proration and billing-period calculation, part of the broader Automated Rate Calculation & Rule Engines subsystem, and it settles the day-counting rule for an occupancy window and turns it into a small, decimal-safe function.

Minimal Prerequisites

  • Python 3.11+ — for date arithmetic and the X | None union used for an open-ended occupancy.
  • decimal.Decimal for the monthly charge and the prorated result — never float. The proration is a fee times a ratio of day counts, and sub-cent float drift across thousands of move-ins is exactly what reconciliation rejects.
  • An explicit inclusive/exclusive policy — a single documented rule for whether the move-out day is billed. This is a tariff decision; the code takes it as a parameter rather than assuming it.

Data assumptions: the billing cycle is given as inclusive civil dates (cycle_start through cycle_end, both billed days), the monthly service charge is a Decimal, and move_out is None while the account is still open at the cycle’s end. Timestamps are civil dates in the utility’s zone — the tz-aware instant handling belongs to the parent proration guide; here the unit is the calendar day.

Annotated Implementation

The occupancy window is intersected with the cycle, the billed span is counted under an explicit rule, and the fee is day-weighted against the length of the cycle. The move_out_billed flag encodes the one policy decision that everything else follows from: when it is False, the customer vacates on the move-out day and is not charged for it (the common convention, since the next occupant or the disconnect owns that day).

from __future__ import annotations

from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal, ROUND_HALF_UP

CENTS = Decimal("0.01")


@dataclass(frozen=True, slots=True)
class ProrationResult:
    billed_days: int
    cycle_days: int
    amount: Decimal


def prorate_service_charge(
    cycle_start: date,            # first billed day of the cycle (inclusive)
    cycle_end: date,             # last billed day of the cycle (inclusive)
    move_in: date,               # first day the customer holds the account
    move_out: date | None,       # None => still occupied at cycle end
    monthly_charge: Decimal,     # the flat service/connection fee for a full cycle
    *,
    move_out_billed: bool = False,   # tariff policy: is the move-out day charged?
) -> ProrationResult:
    if cycle_end < cycle_start:
        raise ValueError("cycle_end must not precede cycle_start")

    # Denominator: the full cycle length, counted inclusively of both endpoints.
    cycle_days = (cycle_end - cycle_start).days + 1

    # Clamp the occupancy window to the cycle it is being billed within.
    first_billed = max(move_in, cycle_start)
    if move_out is None:
        last_present = cycle_end                       # still occupied at cycle end
    elif move_out_billed:
        last_present = min(move_out, cycle_end)        # bill through the move-out day
    else:
        # Default policy: the vacated move-out day is not billed.
        last_present = min(move_out - timedelta(days=1), cycle_end)

    # Inclusive day count, floored at zero for a window that never overlaps the cycle.
    billed_days = max(0, (last_present - first_billed).days + 1)

    fraction = Decimal(billed_days) / Decimal(cycle_days)
    amount = (monthly_charge * fraction).quantize(CENTS, rounding=ROUND_HALF_UP)
    return ProrationResult(billed_days=billed_days, cycle_days=cycle_days, amount=amount)

Three lines carry the correctness. cycle_days counts inclusively — a June cycle of June 1 through June 30 is thirty days, not twenty-nine — because both the first and last dates are billed days, so the + 1 is not a fencepost error but the definition of an inclusive span. The max(move_in, cycle_start) and the min(..., cycle_end) clamp an occupancy that started before or ends after the cycle down to the portion this invoice is responsible for, so a mid-cycle move-in that opened weeks earlier still bills only its days in this cycle. And the move_out_billed branch is the single place the inclusive/exclusive policy lives: with the default False, moving out on the twenty-third means the last billed day is the twenty-second, matching the near-universal convention that a vacated day belongs to whoever holds the account next.

Billing only the occupied days of a cycle for a move-in and move-out A billing cycle runs inclusively from day one to day thirty. A customer moves in on day eleven and moves out on day twenty-three. The days before the move-in and on and after the move-out are not billed. Under the default exclusive move-out convention the billed span runs from day eleven through day twenty-two, giving twelve billed days. The prorated fixed charge is the monthly charge multiplied by twelve over thirty and quantized to whole cents. Day 1 Day 30 Billing cycle — 30 days (inclusive) not billed Billed occupancy Day 11 → Day 22 · 12 days not billed move-in · Day 11 move-out · Day 23 (exclusive) Prorated fixed charge monthly_charge × 12 / 30 → Decimal quantized ROUND_HALF_UP

Edge Cases and Billing Gotchas

Move-in and move-out proration breaks in a handful of specific, testable ways.

  • Move-in equals move-out (same day). With the default exclusive convention, an account opened and closed on the same day bills zero days — the customer never held it for a full billed day. If local policy instead charges a minimum of one day for a same-day connect, set move_out_billed=True for that case so the single day is counted; the point is that the outcome is a deliberate policy toggle, not an accident of the arithmetic.
  • Move-out before the cycle ends (final bill). A closing account moves out mid-cycle; min(..., cycle_end) is never reached, and the billed span simply ends at the move-out. The denominator stays the full cycle length, so the customer pays their fraction of a normal month rather than being re-based onto a shorter one — which is what keeps a final bill consistent with the prior full-month bills.
  • A month that spans a DST change. The day count is a date difference, so a cycle containing a 23-hour or 25-hour day still counts that day as one. The proration is unaffected; the tz-aware instant handling for period edges is the parent guide’s concern, and here the calendar day is the only unit that matters.
  • February and leap years. Because cycle_days is derived from the actual cycle dates, a 28-day February cycle uses a denominator of 28 and a leap February uses 29 with no special case. A day of occupancy in February is therefore worth slightly more of the monthly charge than a day in a 31-day month — correct under the actual/actual-period convention this function uses.
  • Occupancy entirely outside the cycle. A window that ends before the cycle starts, or begins after it ends, yields a negative raw span; the max(0, ...) floors billed_days to zero so a stale or mis-dated occupancy record bills nothing rather than a nonsensical negative amount.

Verification Snippet

Pin the day-counting behavior against the exact cases that generate disputes, including the inclusive/exclusive toggle and the leap-February denominator.

def test_move_in_move_out_proration() -> None:
    charge = Decimal("30.00")

    # Move in day 11, out day 23, exclusive move-out => days 11..22 = 12 days.
    r = prorate_service_charge(
        date(2026, 6, 1), date(2026, 6, 30),
        move_in=date(2026, 6, 11), move_out=date(2026, 6, 23),
        monthly_charge=charge,
    )
    assert r.cycle_days == 30
    assert r.billed_days == 12
    assert r.amount == Decimal("12.00")          # 30.00 * 12/30

    # Still occupied at cycle end: billed from move-in through the last day.
    r_open = prorate_service_charge(
        date(2026, 6, 1), date(2026, 6, 30),
        move_in=date(2026, 6, 11), move_out=None,
        monthly_charge=charge,
    )
    assert r_open.billed_days == 20              # days 11..30

    # Same-day connect/disconnect bills zero under the exclusive default...
    r_same = prorate_service_charge(
        date(2026, 6, 1), date(2026, 6, 30),
        move_in=date(2026, 6, 15), move_out=date(2026, 6, 15),
        monthly_charge=charge,
    )
    assert r_same.billed_days == 0
    # ...but one day when policy charges the move-out day.
    r_same_incl = prorate_service_charge(
        date(2026, 6, 1), date(2026, 6, 30),
        move_in=date(2026, 6, 15), move_out=date(2026, 6, 15),
        monthly_charge=charge, move_out_billed=True,
    )
    assert r_same_incl.billed_days == 1

    # Leap February: denominator is 29, not 28.
    r_feb = prorate_service_charge(
        date(2028, 2, 1), date(2028, 2, 29),
        move_in=date(2028, 2, 20), move_out=None,
        monthly_charge=charge,
    )
    assert r_feb.cycle_days == 29                # 2028 is a leap year
    assert r_feb.billed_days == 10              # days 20..29

Running these against real move-in and move-out records — not synthetic ones — surfaces the local policy edges, especially same-day accounts and mid-cycle closes, before a final bill goes out wrong.

FAQ

Should the move-out day be billed?
Most municipalities do not bill the move-out day: the customer vacates that day and the next occupant, or the disconnect, owns it. That is why move_out_billed defaults to False and the last billed day is the day before the move-out. It is a tariff decision, though, so the function exposes the flag rather than hard-coding it — set it True where local policy charges through the move-out day or guarantees a one-day minimum for a same-day account.
Why is the denominator the full cycle length and not the occupied days?
Because a partial-month charge should be a fraction of a normal month, not a re-based full charge over a shorter window. Dividing the billed days by the full cycle length means a customer present for twelve days of a thirty-day cycle pays twelve-thirtieths of the monthly charge, consistent with the full-month bills around it. Using occupied days as the denominator would charge every partial customer a full month, which is exactly the complaint proration exists to prevent.
How does this handle a customer who moved in during an earlier cycle?
The occupancy window is clamped to the cycle being billed. A move-in date weeks before this cycle is pulled forward to the cycle start by max(move_in, cycle_start), so the account is billed for its days in this cycle only. Each cycle prorates independently against its own boundaries, which keeps a long-running account's monthly bills whole and only the opening and closing cycles prorated.
Does this prorate usage charges too?
No — it prorates the fixed monthly service charge only, the flat fee that does not depend on consumption. Metered usage should be billed from the actual interval reads during the occupancy, not day-weighted, so that tiered and seasonal rates apply to real consumption. Day-weighting a usage charge is only a fallback when interval data is genuinely missing; the fixed connection or availability fee is the charge that legitimately prorates by day.
What happens on a leap day or a short February?
Nothing special is needed. Because cycle_days is computed from the actual cycle dates, a leap February uses 29 as the denominator and a common February uses 28, automatically. A day of occupancy is therefore weighted against the real length of that specific cycle, which is the correct behavior under the actual/actual-period convention this function applies.

Up: Proration & Billing-Period Calculation · Automated Rate Calculation & Rule Engines