Double-Entry Journal Manifest Generation for Municipal Utility Billing

A rated billing batch is a list of priced line items; the general ledger is a set of accounts that must always balance. The bridge between them is a journal manifest — a structured, double-entry record that debits accounts receivable for everything the utility is now owed and credits the revenue accounts that earned it, with the strict invariant that sum(debits) == sum(credits) to the cent. This page sits inside Billing Reconciliation & Audit Logging, and it addresses the specific failure that this invariant guards against: a billing run that posts to finance without proving it balances. When a batch of a hundred thousand accounts is rated, the sum of the charges is a number no human will re-derive by hand, so the manifest must carry its own proof — control totals a reviewer can tie out, a source hash that binds the manifest to the exact rated input it was built from, and a balance assertion that fails loudly rather than posting a lopsided entry. An unbalanced journal is not a rounding curiosity; it is an out-of-balance general ledger, a GASB audit finding, and a reconciliation that finance cannot close for the period. The manifest is the deterministic, auditable handoff from the rate engine to the books.

Prerequisites

This workflow assumes a pinned toolchain and a specific relationship to the accounting system. Getting these wrong changes posted dollar amounts, not just formatting.

  • Python 3.11+. Required for zoneinfo, StrEnum, and modern typing used throughout. Never naive datetime; the manifest’s posted_at must be timezone-aware.
  • decimal.Decimal for every monetary quantity. A journal that balances under Decimal will silently drift out of balance under binary float. All line amounts, subtotals, and totals are Decimal quantized to the cent.
  • Pydantic v2 (pydantic>=2.6) for the manifest and entry contracts. The frozen models below reject unknown fields so a schema drift in the rated input surfaces as a validation error rather than a mispost.
  • A chart of accounts mapping each billable charge type to a revenue GL account, plus the receivable (asset) account for each fund. This mapping is data, not code — it changes by council ordinance, not by deploy.
  • Fund accounting context. Municipal utilities keep enterprise funds (water, sewer, refuse) separate from the general fund. A single batch spans several funds, and each fund must balance on its own — an inter-fund entry can never net a water shortfall against a sewer surplus.
  • An upstream rate engine that has already priced every line item. Manifest generation never re-rates; it consumes the output of automated rate calculation as a fixed input and is responsible only for the accounting transform.
  • Append-only audit storage for the manifest and its hash, so the posted journal can be tied back to the rated batch during a GASB review.

Architecture Overview

Manifest generation is a pure transform: rated line items in, one balanced journal out, with no side effects until the manifest is proven balanced. The batch is grouped by GL account so that thousands of individual charges collapse into a handful of summary entries; each revenue account becomes a credit, each fund’s receivable becomes an offsetting debit, and the whole thing is checked against a control total before it is allowed to leave the function.

Transforming a rated billing batch into a balanced journal manifest A rated billing batch of priced line items enters an aggregation stage that groups every charge by its general-ledger account using Decimal arithmetic. The grouped totals fan out into two lanes: accounts-receivable debit rows, one per fund, and revenue credit rows, one per revenue account, kept separate for each enterprise fund. Both lanes feed a balance assertion that requires the sum of debits to equal the sum of credits to the cent and to equal the control total. Only a batch that passes the assertion is serialized into a journal manifest carrying control totals and a source hash of the rated input. Rated billing batch priced line items (one per charge) Group by GL account aggregate amounts with Decimal charge_type → account_code DEBIT Accounts receivable one debit per fund 1200 A/R · per enterprise fund CREDIT Revenue accounts one credit per revenue account 4310 water · 4320 sewer · 4330 refuse Balance assertion sum(debits) == sum(credits) to the cent · per fund · == control total Journal manifest control totals + source hash · serializable what is owed what was earned balanced only

Figure: A rated batch is grouped by GL account, split into receivable debits and revenue credits, and released as a manifest only after the balance assertion holds to the cent for every fund.

The direction of each entry follows directly from what the batch represents. Billing a customer creates something the utility is owed — an asset — so accounts receivable is debited; the charge simultaneously recognizes revenue the utility has earned, so the matching revenue account is credited. Every dollar therefore appears twice, once on each side, which is exactly why the sums must agree. The manifest never touches cash: payment is a later, separate journal that debits cash and credits receivable when the money actually arrives. Keeping billing recognition and cash receipt as distinct journals is what lets finance age receivables and reconcile collections independently.

Step-by-Step Implementation

Step 1 — Aggregate rated line items by GL account

The rated batch arrives as many small line items — a water base charge here, a sewer volumetric charge there, a stormwater fee, a late penalty. Posting each one individually would produce an unreadable journal and a slow ledger. Instead, group by the GL account each charge maps to and sum with Decimal, so a hundred thousand line items collapse into a handful of revenue totals. The account each charge maps to also carries its fund, because a downstream credit must land in the correct enterprise fund.

from __future__ import annotations

from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal

CENTS = Decimal("0.01")

# Chart-of-accounts mapping: charge type -> (revenue account, fund).
# This is configuration, set by ordinance, never hard-coded logic.
CHARGE_ACCOUNTS: dict[str, tuple[str, str]] = {
    "water_base":        ("4310", "WATER"),
    "water_volumetric":  ("4310", "WATER"),
    "sewer_volumetric":  ("4320", "SEWER"),
    "refuse_flat":       ("4330", "REFUSE"),
    "late_penalty":      ("4810", "WATER"),   # penalty revenue rides the water fund
}
# One receivable (asset) account per fund.
AR_ACCOUNTS: dict[str, str] = {"WATER": "1200", "SEWER": "1201", "REFUSE": "1202"}


@dataclass(frozen=True, slots=True)
class RatedLineItem:
    account_ref: str        # customer/service account being billed
    charge_type: str        # key into CHARGE_ACCOUNTS
    amount: Decimal         # already priced by the rate engine, cent-quantized


def aggregate_by_account(items: list[RatedLineItem]) -> dict[tuple[str, str], Decimal]:
    """Collapse line items into (revenue_account, fund) -> summed Decimal amount."""
    totals: dict[tuple[str, str], Decimal] = defaultdict(lambda: Decimal("0"))
    for item in items:
        account, fund = CHARGE_ACCOUNTS[item.charge_type]   # KeyError = unmapped charge
        totals[(account, fund)] += item.amount              # exact Decimal addition
    return totals

An unmapped charge_type raises KeyError here rather than silently dropping revenue — an unmapped charge is a chart-of-accounts gap that must be fixed before the batch can post, not a row to skip. Summing in Decimal guarantees the aggregate is exact; the individual amounts were already cent-quantized upstream, so no rounding happens in this step.

Step 2 — Build debit and credit rows per fund

With totals grouped by account and fund, the journal writes itself. Each revenue account becomes a credit row for its summed amount. For each fund, the receivable account is debited for the total of that fund’s credits — that single offsetting debit is what makes the fund self-balancing. Modeling the entry rows as frozen Pydantic records gives every row a validated shape and forbids a row that carries both a debit and a credit.

from pydantic import BaseModel, ConfigDict, model_validator


class JournalEntry(BaseModel):
    """One side of one posting. Exactly one of debit/credit is non-zero."""
    model_config = ConfigDict(frozen=True, extra="forbid")

    account_code: str
    fund: str
    debit: Decimal = Decimal("0.00")
    credit: Decimal = Decimal("0.00")
    memo: str = ""

    @model_validator(mode="after")
    def one_sided(self) -> "JournalEntry":
        if (self.debit > 0) == (self.credit > 0):
            raise ValueError("a journal entry must have exactly one of debit or credit")
        return self


def build_entries(totals: dict[tuple[str, str], Decimal]) -> list[JournalEntry]:
    """Revenue credits per account; one offsetting A/R debit per fund."""
    entries: list[JournalEntry] = []
    fund_debit: dict[str, Decimal] = defaultdict(lambda: Decimal("0.00"))

    for (account, fund), amount in sorted(totals.items()):
        entries.append(JournalEntry(
            account_code=account, fund=fund,
            credit=amount, memo=f"revenue recognized: {account}",
        ))
        fund_debit[fund] += amount            # accumulate the fund's receivable

    for fund, debit_total in sorted(fund_debit.items()):
        entries.append(JournalEntry(
            account_code=AR_ACCOUNTS[fund], fund=fund,
            debit=debit_total, memo=f"accounts receivable: {fund} fund",
        ))
    return entries

Sorting the accounts and funds before building rows makes the entry order deterministic, which matters when the manifest is hashed in Step 5: the same rated batch must always produce byte-identical output. Each fund’s debit is the sum of that fund’s credits, so at this point the batch balances within each fund by construction — the assertion in Step 3 is what proves the construction held.

Step 3 — Enforce the balance invariant per fund

Construction that “should” balance is not proof; posting demands proof. Before anything is serialized, assert that debits equal credits both overall and for each fund independently. Per-fund checking is not optional for a municipal utility: netting a water overage against a sewer shortfall would produce a manifest that balances in total while quietly moving money between funds, which is exactly the inter-fund error auditors look for. This balance discipline is the same control that underpins hash total validation controls elsewhere in the reconciliation stack.

class UnbalancedJournalError(Exception):
    """Raised when debits and credits disagree overall or within any fund."""


def assert_balanced(entries: list[JournalEntry]) -> None:
    total_debit = sum((e.debit for e in entries), Decimal("0.00"))
    total_credit = sum((e.credit for e in entries), Decimal("0.00"))
    if total_debit != total_credit:
        raise UnbalancedJournalError(
            f"journal out of balance: debit {total_debit} != credit {total_credit}"
        )

    per_fund: dict[str, Decimal] = defaultdict(lambda: Decimal("0.00"))
    for e in entries:
        per_fund[e.fund] += e.debit - e.credit          # must net to zero per fund
    unbalanced = {f: net for f, net in per_fund.items() if net != Decimal("0.00")}
    if unbalanced:
        raise UnbalancedJournalError(f"funds do not net to zero: {unbalanced}")

The comparison uses exact Decimal equality, not a tolerance. A tolerance-based check (“within a penny”) is a code smell in accounting code: it hides the very rounding defect this workflow exists to eliminate. If the totals are off by a cent, that cent has to be assigned to a real account, which is the subject of the next step.

Step 4 — Allocate penny-rounding residue deterministically

Rounding enters whenever a total is derived by a calculation rather than a sum of already-rounded parts — for example, when a single blended charge is split across two funds by percentage, or when tax is computed on an aggregate. The split parts, each quantized to the cent, may sum to a value one or two cents away from the quantized whole. That residue is real money and must be assigned to specific accounts deterministically, never dropped and never floated. The workflow uses a largest-remainder rule: quantize every part down, then hand the leftover cents, one at a time, to the parts with the largest truncated fractions.

from decimal import ROUND_DOWN


def allocate_residue(target_total: Decimal, weights: list[Decimal]) -> list[Decimal]:
    """Split target_total across weights so the parts sum EXACTLY to target_total."""
    weight_sum = sum(weights, Decimal("0"))
    raw = [target_total * w / weight_sum for w in weights]
    floored = [r.quantize(CENTS, rounding=ROUND_DOWN) for r in raw]
    residue_cents = int(((target_total - sum(floored, Decimal("0"))) / CENTS).to_integral_value())

    # Rank parts by the fraction of a cent they lost to flooring (largest first).
    order = sorted(range(len(raw)), key=lambda i: raw[i] - floored[i], reverse=True)
    for k in range(residue_cents):
        floored[order[k]] += CENTS       # hand each stray cent to the next-largest remainder
    return floored

This step is deliberately kept lean here because the balancing arithmetic is subtle enough to deserve its own focused treatment. The full production module — one that ties the allocation directly to the manifest’s control total so debits, credits, and the target agree exactly — is worked through in generating balanced journal manifests in Python.

Step 5 — Attach control totals and a source hash

A manifest that finance can trust carries its own tie-out figures and a fingerprint of the exact input it came from. The control totals — entry count, total debit, total credit — are what a reviewer reconciles against the batch summary. The source hash is a digest of the canonical rated input; it binds this manifest to that batch, so a manifest can never be quietly re-associated with a different or edited run. This is the same chain-of-custody expectation documented under GASB audit trail compliance patterns.

import hashlib
import json


def source_hash(items: list[RatedLineItem]) -> str:
    """Deterministic fingerprint of the rated input the manifest was built from."""
    canonical = json.dumps(
        [[i.account_ref, i.charge_type, str(i.amount)] for i in items],
        sort_keys=True, separators=(",", ":"),
    )
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def control_totals(entries: list[JournalEntry]) -> dict[str, str]:
    return {
        "entry_count": str(len(entries)),
        "total_debit": str(sum((e.debit for e in entries), Decimal("0.00"))),
        "total_credit": str(sum((e.credit for e in entries), Decimal("0.00"))),
    }

Amounts are serialized as strings, not floats, so the JSON round-trip cannot reintroduce the binary drift that Decimal was chosen to avoid. Because the entry order and the canonical input are both deterministic, re-running the same batch reproduces the same hash — which is what makes the manifest verifiable rather than merely plausible.

Step 6 — Serialize the balanced manifest

The final step assembles the pieces into an immutable manifest object and serializes it. Assertion happens inside the builder, before serialization, so an unbalanced batch can never produce a manifest file at all — the failure surfaces at generation time, not at posting time in the ERP.

from datetime import datetime, timezone


class JournalManifest(BaseModel):
    model_config = ConfigDict(frozen=True, extra="forbid")

    batch_id: str
    posted_at: datetime
    source_hash: str
    controls: dict[str, str]
    entries: list[JournalEntry]


def build_manifest(batch_id: str, items: list[RatedLineItem]) -> JournalManifest:
    totals = aggregate_by_account(items)
    entries = build_entries(totals)
    assert_balanced(entries)                 # raises before a manifest can exist
    return JournalManifest(
        batch_id=batch_id,
        posted_at=datetime.now(timezone.utc),
        source_hash=source_hash(items),
        controls=control_totals(entries),
        entries=entries,
    )

The resulting JournalManifest is what hands off to the accounting system, covered under ERP general ledger posting integration. Because the model is frozen, the manifest that was proven balanced is the exact object that posts — nothing can mutate it between assertion and handoff.

Edge-Case Handling

The balanced happy path is short; the accounting reality of a municipal billing run is where the work is. Each of these has produced an out-of-balance ledger in a real utility.

  • Penny-rounding residue on derived splits. When one amount is split across funds or a tax is computed on an aggregate, quantized parts can miss the quantized whole by a cent or two. Assign the residue deterministically (Step 4) to specific accounts; never truncate it away or the fund will not net to zero.
  • Refunds and reversals as contra entries. A refund or a corrected charge is never a destructive edit of a prior manifest. It posts as a new entry with the debit and credit reversed — receivable credited, revenue debited — so the audit trail shows both the original and the correction. Netting a reversal into the original figure erases the very history a GASB review needs to see.
  • Retroactive rate adjustments. A council order that re-prices a closed period does not rewrite the posted journal. The rate engine re-rates the affected accounts and the difference posts as an additive adjustment manifest against the current period, tagged to the original batch by its source hash so the two can be tied together.
  • Multi-fund line items. A single combined utility charge that funds both water and sewer must be split into per-fund amounts before aggregation, each carrying its own fund tag. If the split is by percentage, its cents are reconciled with the residue rule so each fund receives whole cents that sum to the combined charge.
  • Zero-net batches. A batch of pure corrections can net to zero total — every debit offset by an equal credit. This is valid, not an error: the balance assertion passes trivially, and the manifest still posts so the individual account movements are recorded. Guard only against an empty entry list, which usually means the aggregation dropped every row.
  • A charge that maps to no account. An unmapped charge_type must halt the batch, not skip the row. Skipping silently understates revenue and unbalances the fund; the KeyError in Step 1 is intentional and should route to review, not a bare except.

Verification and Audit Trail

A manifest is only trustworthy if its balance and its provenance can both be re-checked independently of the code that produced it. Verification is two assertions a reviewer — or a nightly job — can run against any manifest: that it still balances, and that it still corresponds to its stated input. The second check is what makes the source hash more than decoration; recomputing it from the rated batch and comparing proves the manifest was not edited after generation. This is the same append-only discipline used across billing reconciliation and audit logging, and it satisfies the tie-out a GASB reviewer expects for revenue postings.

def verify_manifest(manifest: JournalManifest, items: list[RatedLineItem]) -> bool:
    """Re-prove balance and provenance without trusting the builder."""
    total_debit = sum((e.debit for e in manifest.entries), Decimal("0.00"))
    total_credit = sum((e.credit for e in manifest.entries), Decimal("0.00"))

    balanced = total_debit == total_credit
    controls_match = (
        manifest.controls["total_debit"] == str(total_debit)
        and manifest.controls["total_credit"] == str(total_credit)
        and manifest.controls["entry_count"] == str(len(manifest.entries))
    )
    provenance_ok = manifest.source_hash == source_hash(items)
    return balanced and controls_match and provenance_ok

Correctness is confirmed two ways in practice. First, round-trip determinism: regenerating the manifest from the same rated batch must produce the identical source hash and identical control totals — a mismatch means either the input changed or the entry ordering is not stable. Second, tie-out to the batch summary: the manifest’s total credit must equal the rate engine’s reported revenue total for the batch, the check that catches a line item lost between rating and aggregation. Run both against historical batches before any change to the account mapping or rounding rule ships.

Troubleshooting

Symptom Likely cause Fix
Journal off by a few cents Derived split quantized each part independently and dropped the residue Apply the largest-remainder allocation (Step 4) so the parts sum exactly to the target total
Total balances but a fund does not net to zero Inter-fund netting hid a per-fund imbalance Assert balance per fund, not just overall; keep each fund’s receivable debit equal to that fund’s credits
Manifest silently understates revenue An unmapped charge_type was skipped instead of raising Let the KeyError halt the batch and route the unmapped charge to chart-of-accounts review
Source hash changes on identical input Entry order or JSON serialization is non-deterministic Sort accounts and funds before building entries; serialize amounts as strings with fixed separators
Ledger drifts by fractions of a cent over time Amounts stored or summed as float somewhere in the path Use Decimal end to end; serialize as strings so JSON never reintroduces binary drift
Refund appears to erase the original charge Correction was netted into the prior figure Post the reversal as a separate contra entry so both the original and the correction remain visible
ERP rejects the posting as unbalanced Manifest was assembled without the internal assertion Call assert_balanced inside the builder so an unbalanced batch can never produce a manifest

Frequently Asked Questions

Why must the journal balance to the exact cent rather than within a tolerance?

Because a general ledger is defined by the identity that debits equal credits. A tolerance-based check does not make a lopsided entry acceptable; it only hides where a real cent went. If a derived split leaves a residual cent, that cent belongs to a specific account and must be assigned there deterministically. Exact Decimal equality forces the rounding to be resolved at generation time instead of surfacing as an unexplained variance during period close.

Why does each fund have to balance on its own?

Municipal utilities use fund accounting, keeping enterprise funds like water and sewer legally separate. A manifest that balances in total but not per fund has quietly moved money between funds — a water overage offsetting a sewer shortfall — which is an inter-fund transfer that never happened and a classic audit finding. Checking that every fund’s debits equal its credits keeps each fund self-contained, so the receivable recorded for the water fund exactly matches the water revenue recognized.

How are refunds and corrections represented without breaking the audit trail?

They post as contra entries: a new journal that reverses the direction of the original, crediting receivable and debiting revenue for the adjusted amount. The prior manifest is never edited. This leaves both the original charge and its correction visible in the ledger, which is what a reviewer needs to reconstruct what happened. Retroactive rate changes work the same way — the difference posts as an additive adjustment tied back to the original batch by its source hash.

What does the source hash actually protect against?

It binds a manifest to the exact rated input it was generated from. Recomputing the hash from the batch and comparing it to the stored value proves the manifest was not edited, re-associated with a different run, or built from a silently changed input after the fact. Combined with deterministic entry ordering, it makes the manifest reproducible: the same batch always yields the same hash, so any drift is detectable rather than deniable.

Where does manifest generation stop and ledger posting begin?

Generation is a pure transform that ends the moment a balanced, hashed manifest object exists; it has no knowledge of the accounting system’s API. Posting that manifest — mapping its entries to the ERP’s journal format, handling idempotent submission, and confirming acceptance — is a separate concern covered under ERP general ledger posting integration. Keeping the two apart means the balance proof is independent of any downstream system’s quirks.

Up one level: Billing Reconciliation & Audit Logging · Return to the utilitybilling.org home.