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
datearithmetic and theX | Noneunion used for an open-ended occupancy. decimal.Decimalfor the monthly charge and the prorated result — neverfloat. 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.
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=Truefor 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
datedifference, 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_daysis 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, ...)floorsbilled_daysto 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
Related Topics
- Proration & Billing-Period Calculation — the parent guide covering tz-aware boundaries, effective-date splitting, and day-count conventions.
- Surcharge & Fee Application Logic — how the prorated service charge combines with flat surcharges on the same invoice.
- Seasonal Rate Mapping & Calendar Logic — resolving the calendar boundaries a partial cycle is measured against.
- Step-Rate vs Block-Rate Structure Design — pricing the usage portion of a partial cycle from real interval reads.
- Automated Rate Calculation & Rule Engines — the end-to-end rate engine this proration feeds.
Up: Proration & Billing-Period Calculation · Automated Rate Calculation & Rule Engines