Customer Class & Service Tier Mapping in Municipal Utility Billing

The single most consequential decision a billing pipeline makes about an account happens before a rate is ever applied: what class is this, and which service tier governs it. This guide sits under Municipal Utility Billing Architecture & Rate Taxonomy and addresses one specific production failure — the silent misclassification that routes a metered account to the wrong tariff. When a mixed-use parcel is billed as residential, when an industrial account lands on a standard tier, or when a deprecated schedule reference slips through, the error is invisible at ingestion and expensive at reconciliation. Misaligned classifications cascade into rate miscalculations, compliance violations, and systematic revenue leakage across an entire billing run.

Class and tier mapping is the translation matrix between physical service delivery and the financial ledger. It converts raw service attributes — meter type, parcel zoning, demand profile, jurisdiction — into a deterministic billing path so that every account resolves to exactly one tariff, surcharge set, and assistance eligibility state. The remedy for misclassification is engineering discipline: strict schema validation at ingestion, a documented precedence hierarchy for conflicting attributes, decimal-safe tier-boundary math, deliberate fallback for missing data, and an immutable audit trail that lets a finance team reconstruct why any account was mapped the way it was.

Classification routing from service attributes to the calculation engine Raw service attributes — meter type, parcel zoning, and demand profile — reach a decision on the customer segment. The account branches to one of four class paths: residential tiers, commercial tiers, industrial or demand tiers, or special-class municipal and agricultural tariffs. All four paths converge on a single step that resolves the point-in-time rate schedule and its effective date, which then feeds the calculation engine. Every account resolves to exactly one billing path. Service attributes meter · zoning · demand Customer segment? Residential tiers Commercial tiers Industrial / demand Special-class tariffs municipal · agricultural Resolve schedule + effective date Calculation engine

Figure: Classification routing — service attributes resolve to a deterministic billing path before the calculation engine runs.

Prerequisites

This workflow targets a modern, reproducible Python stack. Pin the following before implementing anything below:

  • Python 3.11+ — required for zoneinfo in the standard library, StrEnum, and X | None union syntax used throughout.
  • Pydantic v2 (pydantic>=2.6) — every class-tier contract uses v2 model_validator / field_validator semantics; v1 models will not validate these structures.
  • decimal.Decimal for all monetary and threshold arithmetic — never float. Tier boundaries and demand thresholds are compared and quantized as Decimal to keep classification reproducible.
  • zoneinfo (not pytz) — effective dates and mid-cycle reclassifications are resolved in the utility’s local civil time, so every date comparison must be timezone-aware.

Data assumptions: each incoming account record carries an account_id, a raw customer_class and service_tier (from the customer information system, or CIS), a service_address used for jurisdiction resolution, and a rate_schedule reference. Authoritative parcel and zoning attributes arrive from the municipal GIS layer and take precedence over heuristic consumption thresholds. Upstream, records should already have passed schema validation & data quality checks so that this stage handles classification logic, not malformed payloads.

Access permissions matter here: the class-to-tier mapping table and the tier thresholds it references are rate configuration, not billing execution. Write access to those tables must be isolated from the code that runs a billing cycle. See Security Boundaries & Role-Based Access for the separation-of-duties pattern that gates who may edit tier thresholds or approve schedule overrides.

Architecture Overview

Class and tier mapping is a staged pipeline, not a single lookup. Each stage has an explicit exception path so that an ambiguous parcel or a missing schedule is diverted rather than allowed to corrupt the run. A raw account record enters from the CIS, its class is resolved against a documented precedence hierarchy, the class-tier pair is validated for compatibility, the pair is resolved to a point-in-time rate schedule, and every decision emits an audit record before the mapped account is handed to the calculation engine.

Staged class and tier mapping pipeline with explicit exception paths A raw CIS account record enters a strict Pydantic v2 class-tier contract; structurally invalid records divert to quarantine. Valid records pass to class resolution by documented precedence — GIS parcel over ordinance code over consumption heuristic — then to a class-tier compatibility check, where incompatible pairings are flagged for review rather than rejected. The account then resolves to a point-in-time rate schedule for its service date and jurisdiction; a missing schedule routes to fallback on the default tariff. Every decision emits an immutable audit record chained by SHA-256 before the mapped account reaches the calculation engine. Raw CIS account account_id · class · tier · address Class-tier contract strict Pydantic v2 — structural gate Class resolution by precedence GIS parcel › ordinance › heuristic Class-tier compatibility check suspect pairings reviewed, not billed Resolve rate schedule point-in-time: service_date + jurisdiction Emit immutable audit record SHA-256 hash chain Mapped account → calculation engine Quarantine structural reject · no silent drop Flag for review copy queued · cycle proceeds Fallback routing default tariff · tagged for true-up valid path invalid incompatible missing

The sections below implement each stage in order.

Step-by-Step Implementation

Step 1 — Enforce a strict class-tier contract at ingestion

Municipal account data arrives from disparate systems: the CIS, GIS parcel layers, assessor metadata, and legacy zoning codes. Without strict typing, a malformed class-tier pairing cascades into incorrect statements and audit failures. Pydantic v2 enforces structural integrity before any routing occurs. The contract captures the account identifier, customer class, service tier, and a rate schedule reference — and it rejects a schedule whose identifier does not match its class prefix, which is the most common data-entry error in a mapping table.

from datetime import date
from decimal import Decimal
from enum import StrEnum

from pydantic import BaseModel, ConfigDict, model_validator


class CustomerClass(StrEnum):
    RESIDENTIAL = "RES"
    COMMERCIAL = "COM"
    INDUSTRIAL = "IND"
    MUNICIPAL = "MUN"
    AGRICULTURAL = "AGR"


class ServiceTier(StrEnum):
    STANDARD = "STD"
    HIGH_DEMAND = "HIGH"
    CRITICAL = "CRIT"


class RateScheduleRef(BaseModel):
    model_config = ConfigDict(frozen=True)  # schedule references are immutable snapshots
    schedule_id: str
    effective_date: date
    jurisdiction_code: str


class AccountMapping(BaseModel):
    account_id: str
    customer_class: CustomerClass
    service_tier: ServiceTier
    rate_schedule: RateScheduleRef

    @model_validator(mode="after")
    def schedule_prefix_matches_class(self) -> "AccountMapping":
        # A schedule ID must begin with its class code (e.g. "RES-TIER2-2026").
        # A mismatch means the mapping table paired an account with the wrong tariff.
        if not self.rate_schedule.schedule_id.startswith(self.customer_class.value):
            raise ValueError(
                f"schedule {self.rate_schedule.schedule_id!r} is incompatible "
                f"with class {self.customer_class.value!r}"
            )
        return self

Note the model is now purely structural: validation raises on hard errors and does not mutate a mapping_status field. Compatibility policy (an industrial account on a standard tier is suspicious but not fatal) belongs in a separate resolution step, covered next, so the contract stays a pure gate.

Step 2 — Resolve the class by documented precedence

Hardcoded if/elif chains degrade the moment a zoning ordinance changes. Production class resolution uses an explicit precedence hierarchy: authoritative GIS parcel classification wins, a municipal ordinance code is the next fallback, and only when both are absent does the engine fall back to a consumption-baseline heuristic. This ordering is itself auditable data, not buried logic, so a finance team can point to the exact rule that classified a disputed account. The full mixed-use and legacy-zoning treatment lives in How to Map Residential vs Commercial Classes in Python; the resolver below shows the precedence spine.

from dataclasses import dataclass


@dataclass(frozen=True)
class ClassSignals:
    gis_parcel_class: CustomerClass | None
    ordinance_class: CustomerClass | None
    annual_kwh: Decimal | None  # consumption baseline, used only as last resort


# Ordered precedence: the first non-null source wins. Documented, not implicit.
def resolve_class(signals: ClassSignals) -> tuple[CustomerClass, str]:
    if signals.gis_parcel_class is not None:
        return signals.gis_parcel_class, "gis_parcel"
    if signals.ordinance_class is not None:
        return signals.ordinance_class, "ordinance_code"
    if signals.annual_kwh is not None:
        # Heuristic threshold only when authoritative sources are silent.
        cls = (
            CustomerClass.COMMERCIAL
            if signals.annual_kwh > Decimal("40000")
            else CustomerClass.RESIDENTIAL
        )
        return cls, "consumption_heuristic"
    raise LookupError("no class signal available; route to fallback")


# The returned reason string ("gis_parcel", "ordinance_code", …) is written to the
# audit record so every classification is traceable to its authoritative source.

Step 3 — Validate class-tier compatibility as reviewable policy

Some class-tier pairings are structurally valid yet operationally wrong. An industrial account on the standard tier, or a residential account flagged critical-infrastructure, signals a misconfiguration that should be flagged for human review rather than silently billed or hard-rejected. Encoding this as a compatibility policy keeps the decision explicit and reversible.

from enum import StrEnum


class MappingStatus(StrEnum):
    VALIDATED = "VALIDATED"
    FLAGGED_REVIEW = "FLAGGED_REVIEW"


# Pairings that are legal but suspicious. Each entry is a policy decision a
# finance lead can review, not a rule hidden in a validator.
SUSPECT_PAIRS: frozenset[tuple[CustomerClass, ServiceTier]] = frozenset(
    {
        (CustomerClass.INDUSTRIAL, ServiceTier.STANDARD),
        (CustomerClass.RESIDENTIAL, ServiceTier.CRITICAL),
    }
)


def classify_status(mapping: AccountMapping) -> MappingStatus:
    pair = (mapping.customer_class, mapping.service_tier)
    return MappingStatus.FLAGGED_REVIEW if pair in SUSPECT_PAIRS else MappingStatus.VALIDATED

A FLAGGED_REVIEW account still flows through the cycle on its mapped schedule — the flag routes a copy to a review queue so revenue is not held up while a human confirms the pairing.

Step 4 — Resolve the class-tier pair to a point-in-time rate schedule

Once class and tier are settled, the pair must resolve to a specific schedule for the service date. This is the junction where mapping feeds the calculation engine, and where the difference between marginal and cliff pricing matters. Whether a municipality uses progressive consumption pricing or flat-tiered demand charges, the class-tier pair selects the parameters described in Step-Rate vs Block-Rate Structure Design. A residential account mapped to a conservation block resolves to different tier boundaries than a commercial account routed to a step-rate industrial tariff. Resolution is point-in-time: the schedule in force on the account’s service_date, in its jurisdiction, is selected — never simply “the latest.”

from zoneinfo import ZoneInfo

UTILITY_TZ = ZoneInfo("America/Denver")


def resolve_schedule(
    customer_class: CustomerClass,
    service_tier: ServiceTier,
    service_date: date,
    jurisdiction_code: str,
    catalog: list[RateScheduleRef],
) -> RateScheduleRef:
    # Candidate schedules for this class prefix and jurisdiction, effective on or
    # before the service date; pick the most recent one still in force.
    prefix = customer_class.value
    candidates = [
        s
        for s in catalog
        if s.schedule_id.startswith(prefix)
        and s.jurisdiction_code == jurisdiction_code
        and s.effective_date <= service_date
    ]
    if not candidates:
        # No schedule in force: defer to deterministic fallback (Step 5).
        raise LookupError(f"no {prefix} schedule for {jurisdiction_code} on {service_date}")
    return max(candidates, key=lambda s: s.effective_date)

Step 5 — Route missing data through deterministic fallback

Real-world datasets contain gaps. When an account lacks a class signal or references a deprecated schedule, the pipeline must apply deliberate fallback logic rather than rejecting the transaction. Implementing Fallback Routing for Missing Rate Data applies the jurisdiction’s default tariff, tags the account for true-up, and keeps the cycle on schedule during migrations, legacy imports, or temporary CIS outages — while preserving a clear trail for later reconciliation. The classification engine must also evaluate each account against the Assistance Program Eligibility Taxonomy at this stage so low-income discounts or medical life-support exemptions are applied without exposing protected financial or health data.

def map_account(record: dict, catalog: list[RateScheduleRef], signals: ClassSignals) -> dict:
    try:
        customer_class, class_source = resolve_class(signals)
    except LookupError:
        # No authoritative or heuristic signal: default class for the jurisdiction.
        customer_class, class_source = CustomerClass.RESIDENTIAL, "fallback_default"

    tier = ServiceTier(record["service_tier"])
    try:
        schedule = resolve_schedule(
            customer_class, tier, record["service_date"], record["jurisdiction_code"], catalog
        )
        outcome = "MAPPED"
    except LookupError:
        schedule = default_schedule(customer_class, record["jurisdiction_code"])
        outcome = "FALLBACK"  # provisional; flagged for true-up

    mapping = AccountMapping(
        account_id=record["account_id"],
        customer_class=customer_class,
        service_tier=tier,
        rate_schedule=schedule,
    )
    status = classify_status(mapping)
    return {
        "mapping": mapping,
        "class_source": class_source,
        "outcome": outcome,
        "status": status,
    }

Edge-Case Handling

Municipal utility data produces classification edge cases that generic ETL never anticipates. Handle each one deliberately:

  • Mixed-use parcels. A storefront with an apartment above it carries both residential and commercial signals. Precedence alone is not enough; the parcel must be split into sub-accounts or routed to a dedicated mixed-use tariff, and the decision recorded. Never let the first non-null signal silently win a genuinely mixed parcel.
  • Mid-cycle reclassification. When a property converts (a residence becomes a rental office on the 14th), consumption before and after the conversion date belongs to different classes. Split the cycle at the conversion boundary in UTILITY_TZ and map each segment independently, exactly as a mid-month rate change is handled — never re-map the whole cycle to the new class.
  • Deprecated schedule references. A CIS record may point at a schedule that was retired. Because resolve_schedule filters on effective_date <= service_date, a retired schedule with a future retirement is still valid for past service dates; a truly missing schedule falls through to Step 5’s default rather than raising into the batch.
  • Retroactive PUC orders. A commission order effective the first of a prior month re-prices already-mapped accounts. Point-in-time resolution makes this tractable: re-run mapping with the new schedule’s effective_date and produce an adjustment, rather than editing historical records in place.
  • Leap-year and DST boundaries. Because all date logic uses zoneinfo and civil dates rather than naive offsets, February 29 and the spring-forward hour resolve correctly; a class effective on a DST-transition day still selects the right schedule.
  • Null or zero consumption on heuristic paths. When class resolution reaches the consumption heuristic and annual_kwh is None or zero, do not default to residential by omission — treat it as a missing signal and route to fallback so the choice is explicit and auditable.

Verification and Audit Trail

A mapping decision is only defensible if a finance team can reconstruct why it was made. Every mapped account emits an immutable audit record, and records are chained by hash so any later alteration is detectable. The chain records the resolved class, its source (gis_parcel, ordinance_code, consumption_heuristic, or fallback_default), the schedule chosen, and the compatibility status.

import hashlib
import json


def audit_record(prev_hash: str, result: dict) -> dict:
    mapping = result["mapping"]
    payload = {
        "account_id": mapping.account_id,
        "customer_class": mapping.customer_class.value,
        "service_tier": mapping.service_tier.value,
        "schedule_id": mapping.rate_schedule.schedule_id,
        "class_source": result["class_source"],
        "outcome": result["outcome"],
        "status": result["status"].value,
        "prev_hash": prev_hash,
    }
    # Canonical JSON (sorted keys) so the digest is reproducible across runs.
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
    payload["record_hash"] = hashlib.sha256(encoded).hexdigest()
    return payload


def verify_chain(records: list[dict]) -> bool:
    prev = "GENESIS"
    for rec in records:
        recomputed = audit_record(prev, {
            "mapping": _rehydrate(rec),  # reconstruct AccountMapping from stored fields
            "class_source": rec["class_source"],
            "outcome": rec["outcome"],
            "status": MappingStatus(rec["status"]),
        })
        if recomputed["record_hash"] != rec["record_hash"]:
            return False
        prev = rec["record_hash"]
    return True

To confirm correctness before a production run, replay the mapper against a fixture of historical accounts with known-good classifications and assert the class distribution matches the prior cycle within tolerance:

def test_class_distribution_stable(historical: list[dict], mapped: list[dict]) -> None:
    from collections import Counter
    expected = Counter(r["customer_class"] for r in historical)
    actual = Counter(m["mapping"].customer_class.value for m in mapped)
    for cls, count in expected.items():
        # A >5% swing in any class between cycles signals a mapping regression.
        assert abs(actual[cls] - count) <= count * 0.05, f"class {cls} drifted"

At month-end, mapped accounts feed batch reconciliation and ledger synchronization. Automated scripts compare mapped class distributions against general-ledger postings, flagging variances before final invoice generation so multi-jurisdictional taxes, franchise fees, and special-assessment charges land on the correct service class. All mapping decisions must remain auditable under Data Governance & Privacy Compliance, which mandates cryptographic hashing of the snapshots that satisfy municipal audit and state retention requirements.

Troubleshooting

Symptom Likely cause Fix
ValueError: schedule incompatible with class at ingestion Mapping table paired an account with a schedule whose ID prefix does not match its class Correct the schedule reference in the CIS export, or fix the mapping table entry; the Step 1 contract is working as intended
Whole batch aborts on one bad record Class resolution or schedule lookup raised into the batch loop instead of being caught Wrap each account in map_account’s try/except so failures divert to fallback, never halt the run
Industrial accounts billed at standard rates Compatibility policy not consulted, or SUSPECT_PAIRS missing the pairing Run classify_status on every mapping and route FLAGGED_REVIEW to the review queue
Class distribution swings between cycles An ordinance or GIS refresh changed authoritative signals mid-run Compare class_source counts across cycles; a jump in consumption_heuristic means authoritative sources went silent
Retired schedule still applied Resolution picked “latest” instead of point-in-time Confirm resolve_schedule filters on effective_date <= service_date and selects the max by effective date
Audit chain fails verify_chain A historical record was edited in place Records are append-only; reverse the edit and post a compensating adjustment instead
Mixed-use parcel billed as single class Precedence let the first non-null signal win a genuinely mixed parcel Detect dual signals and split into sub-accounts or route to the mixed-use tariff

FAQ

Why should class-tier compatibility be a flag rather than a hard validation error?

Because the pairing is legal but merely suspicious. An industrial account on the standard tier may be a genuine small-load site or a data-entry mistake. Hard-rejecting it stalls a bill for a possibly-correct account; silently billing it hides a possibly-wrong one. Flagging routes a copy to human review while the cycle proceeds, which is the only option that neither delays revenue nor buries an error.

What determines which class wins when GIS, ordinance, and consumption disagree?

A documented precedence hierarchy, applied in order: authoritative GIS parcel classification first, municipal ordinance code second, consumption baseline heuristic only as a last resort. The winning source is recorded on the audit record, so a disputed classification can always be traced to the exact rule and data source that produced it.

How do I reclassify an account that converts partway through a billing cycle?

Split the cycle at the conversion date in the utility’s local timezone and map each segment independently — pre-conversion usage under the old class, post-conversion under the new one. This mirrors mid-cycle rate handling and avoids re-pricing usage that was correctly classified before the conversion.

What happens when an account references a rate schedule that no longer exists?

Point-in-time resolution first tries to find the schedule in force on the service date. If none exists, the account is routed through fallback to the jurisdiction’s default tariff, tagged as a provisional FALLBACK outcome for true-up, and flagged for review — the cycle completes on schedule and no wrong figure is posted.

How do I prove to an auditor that a past classification was not altered?

Each audit record chains the previous record’s hash into its own SHA-256 digest over canonical JSON. Changing any past record changes its hash and therefore every hash after it, so recomputing the chain with verify_chain and comparing to the stored digests immediately reveals tampering. Combined with the retention rules in the governance layer, this satisfies typical PUC audit requirements.

Up: Municipal Utility Billing Architecture & Rate Taxonomy