Prorating Fixed Monthly Service Fees
A fixed monthly service fee — the flat availability or customer charge a utility levies regardless of how much water, gas, or power a household actually uses — is the one line item practitioners assume needs no math. It is a constant, after all. The complication appears the moment a customer is on the system for only part of a month: a new connection on the 12th, a final bill closed on the 20th, a rate schedule that changes the fixed charge mid-cycle. Now the flat fee must be split by day, and the two hard parts are choosing the denominator (how many days is “a month”?) and rounding the result to a clean cent without leaking a fraction. This page sits under surcharge and fee application logic, part of the broader automated rate calculation and rule engines subsystem, and covers the day-weighted proration of a flat fixed fee specifically — the mechanics and the rounding — not the wider account-lifecycle question of when service starts and stops.
A note on scope: computing the service window itself — deriving billable days from a move-in date, a move-out date, or a transfer — is a distinct problem handled in prorating charges for move-in and move-out dates. This page assumes those dates are already resolved and focuses purely on turning a billable-day count into a correctly rounded fixed-fee amount.
Minimal Prerequisites
- Python 3.11+ — for modern typing and the standard-library
datetime/calendarmodules used below; no third-party runtime dependency is needed for the core calculation. decimal.Decimalfor the fee and the day fraction — the fee, the day counts, and the prorated result all stay inDecimal. A fraction like 19/31 of a fee is a non-terminating decimal, so the division must be controlled, not left tofloat.datetime.datefor the period boundaries — the billable window is expressed as inclusivedateendpoints; day counting is done on dates, never on wall-clock datetimes, to sidestep DST entirely for a whole-day fee.calendar.monthrange— the authoritative source for how many days a given month has, which is what makes leap-February handling automatic.
Data assumptions: the fee itself is a validated flat Decimal (for example Decimal("18.50")) drawn from an authoritative, version-controlled rule record with a governing PUC reference; the billable start and end dates for the period are already resolved and both fall within a single calendar month; and the proration basis — actual days in the month versus a fixed 30-day convention — is a stated policy of the utility, not an ad-hoc choice made in code. The single decision to fix before implementing is the denominator: most municipal fixed-fee tariffs prorate on the actual number of days in the billing month, so that is the default here, with the 30-day banker’s convention noted as an explicit alternative.
Annotated Implementation
The calculation is one pure function: given the fee, the inclusive service window, and the month it falls in, return the prorated amount rounded to the cent. Keeping it free of I/O and clocks makes it deterministically replayable when an auditor reruns a historical bill.
from __future__ import annotations
import calendar
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
CENTS = Decimal("0.01")
def prorate_fixed_fee(
monthly_fee: Decimal, # the flat fixed service fee for a full month
service_start: date, # first day of service, inclusive
service_end: date, # last day of service, inclusive
use_actual_days: bool = True, # False -> 30-day banker's convention
) -> Decimal:
"""Prorate a flat fixed monthly fee by billable days within one month."""
if service_end < service_start:
raise ValueError("service_end must be on or after service_start")
if service_start.year != service_end.year or service_start.month != service_end.month:
raise ValueError("window must fall within a single calendar month")
if monthly_fee < 0:
raise ValueError("monthly_fee must be non-negative")
# Inclusive day count: a window of the 12th through the 20th is 9 days,
# so add one to the difference. Both endpoints are counted as served.
billable_days = Decimal((service_end - service_start).days + 1)
# Denominator: actual days in THIS month (28/29/30/31) or a flat 30.
if use_actual_days:
days_in_month = Decimal(calendar.monthrange(service_start.year, service_start.month)[1])
else:
days_in_month = Decimal("30")
# A full month bills the full fee — never prorate a complete period,
# which would introduce a rounding cent for no reason.
if billable_days >= days_in_month:
return monthly_fee.quantize(CENTS, rounding=ROUND_HALF_UP)
# Multiply before dividing to keep precision; quantize ONCE at the end.
prorated = monthly_fee * billable_days / days_in_month
return prorated.quantize(CENTS, rounding=ROUND_HALF_UP)
Three decisions carry the correctness. First, the day count is inclusive — service from the 12th through the 20th is nine served days, so the code adds one to the date difference; billing eight days there is the most common off-by-one in fixed-fee proration and it silently short-changes the utility on every partial bill. Second, calendar.monthrange supplies the denominator, so February prorates over 28 or 29 days automatically and a 31-day month is never treated as 30 unless the tariff explicitly says so. Third, the multiplication happens before the division and the result is quantized exactly once with ROUND_HALF_UP; computing a per-day rate first and rounding it, then multiplying, compounds a rounding error across every day and drifts the total. This single-quantization discipline is the same one applied throughout surcharge and fee application logic for every money boundary.
Edge Cases and Billing Gotchas
Flat-fee proration breaks at the boundaries of the calendar, not the meter. These are the cases a naive implementation charges wrong.
- Single-day service. A connection and disconnection on the same date is one billable day, not zero — the inclusive count (
(end - start).days + 1) returns1, and the fee is one day’s slice of the month. Billing zero for a same-day service or, worse, raising on it, is a frequent regression; the inclusive-count guard covers it and the verification snippet pins it down. - Month-length variance. Nine days of a
Decimal("18.50")fee is not a fixed amount — it is18.50 × 9 / 30in a 30-day month but18.50 × 9 / 31in a 31-day month, a difference of real cents. Deriving the denominator fromcalendar.monthrangerather than assuming 30 keeps each month’s proration faithful to that month, unless the tariff specifically mandates the 30-day convention viause_actual_days=False. - Leap February. A partial February must prorate over 29 days in a leap year and 28 otherwise. Because
calendar.monthrange(year, 2)returns the correct length for the specific year, February 29 is handled with no hardcoded calendar and no special case — the same value flows straight into the denominator. - Rounding to the cent. The fraction is almost always non-terminating, so quantize exactly once, at the end, with
ROUND_HALF_UP; never round a per-day rate and then multiply. If a utility requires the summed proration across a mid-month rate change to reconcile to the full monthly fee to the cent, allocate the final cent to the longer segment rather than re-rounding both — the same largest-remainder discipline used when splitting any charge across proration and billing period calculation segments. - A full month sneaking through the proration path. When the billable window covers the whole month, bill the full fee directly instead of computing
fee × days / days, which can round to a figure a cent off the intended flat charge. The early return guards this so a complete period always posts the exact tariff amount.
Verification Snippet
Test the calendar boundaries that produce real cent differences: a single day, a leap February, a 31-day month, and a full month that must bypass proration entirely.
from datetime import date
from decimal import Decimal
def test_prorate_fixed_fee_calendar_boundaries() -> None:
fee = Decimal("18.50")
# Partial month: 12th through 30th inclusive = 19 days in a 30-day month.
part = prorate_fixed_fee(fee, date(2026, 4, 12), date(2026, 4, 30))
assert part == Decimal("11.72") # 18.50 * 19 / 30 = 11.7166..., half-up
# Single-day service is one billable day, not zero.
one = prorate_fixed_fee(fee, date(2026, 4, 15), date(2026, 4, 15))
assert one == Decimal("0.62") # 18.50 * 1 / 30
# Leap February prorates over 29 days; non-leap over 28.
leap = prorate_fixed_fee(fee, date(2028, 2, 1), date(2028, 2, 14))
assert leap == Decimal("8.93") # 18.50 * 14 / 29 = 8.931..., half-up
common = prorate_fixed_fee(fee, date(2027, 2, 1), date(2027, 2, 14))
assert common == Decimal("9.25") # 18.50 * 14 / 28
# A 31-day month uses 31 as the denominator, not 30.
july = prorate_fixed_fee(fee, date(2026, 7, 1), date(2026, 7, 15))
assert july == Decimal("8.95") # 18.50 * 15 / 31 = 8.951..., half-up
# A full month bills the exact flat fee, never a prorated approximation.
full = prorate_fixed_fee(fee, date(2026, 6, 1), date(2026, 6, 30))
assert full == Decimal("18.50")
Running these against fixtures pulled from reconciled historical bills — especially February and same-day connections — is what catches the off-by-one day and the wrong-denominator cases before a cycle posts. A stored proration that no longer matches a fresh recomputation is the signal that a fee or the proration basis changed without being versioned.
FAQ
Related Topics
- Surcharge & Fee Application Logic — the parent guide covering fee rule schemas, decimal-safe math, and audit trails this proration feeds.
- Automated Rate Calculation & Rule Engines — the end-to-end rating subsystem the fixed-fee layer belongs to.
- Proration & Billing Period Calculation — the broader guide to splitting charges across partial and mid-cycle periods.
- Prorating Charges for Move-In and Move-Out Dates — deriving the billable service window this page prorates over.
- Seasonal Rate Mapping & Calendar Logic — timezone-aware calendar resolution for effective-date changes to a fixed charge.
Up: Surcharge & Fee Application Logic · Automated Rate Calculation & Rule Engines