Generating Balanced Journal Manifests in Python
The hard part of emitting a double-entry journal is not the double entry — it is making the pennies land exactly. When a batch total is fixed in advance (the control total the rate engine already reported, or the amount printed on the customer’s invoice) and that total has to be split across several revenue accounts, quantizing each share independently almost always misses the target by a cent or two, and a journal that is a cent off is an out-of-balance ledger. This page sits under double-entry journal manifest generation, part of the broader billing reconciliation and audit logging subsystem, and it works through one focused, production-quality module: given rated line items and a control total, it produces a manifest whose revenue credits sum to the control total exactly, whose receivable debit equals that same total, and whose penny allocation is deterministic and reproducible.
Minimal Prerequisites
- Python 3.11+ — for
zoneinfoin the standard library and modern typing. - Pydantic v2 (
pydantic>=2.6) — the frozen manifest model uses v2 semantics. decimal.Decimalfor every amount — neverfloat. The whole point is exact base-10 arithmetic; a singlefloatin the path reintroduces the drift this module exists to eliminate.- A cent-exact control total — a
Decimalquantized to0.01that the manifest must hit. It comes from the rate engine’s own summation, not from re-adding the parts here.
Data assumption: revenue shares arrive as full-precision Decimal amounts (they may carry sub-cent tails, for example when a blended charge or a percentage split was computed upstream). The control total is authoritative; the shares must be reconciled to it, not the other way around.
Annotated Implementation
The module does one thing: allocate the control total across revenue accounts so the quantized parts sum to it exactly, then build the balanced entries. The allocation uses the largest-remainder method — floor every share to the cent, then hand out the leftover cents (or claw back the overshoot) one at a time, in a deterministic order, to the shares whose fractional part was largest. Because the residue is an integer number of cents distributed one by one, the parts provably sum to the target.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, ROUND_DOWN
from pydantic import BaseModel, ConfigDict
CENT = Decimal("0.01")
@dataclass(frozen=True, slots=True)
class RevenueShare:
account_code: str # revenue GL account, e.g. "4310"
fund: str # enterprise fund, e.g. "WATER"
raw_amount: Decimal # full-precision share; may carry sub-cent tails
class Posting(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
account_code: str
fund: str
debit: Decimal = Decimal("0.00")
credit: Decimal = Decimal("0.00")
class BalancedManifest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
batch_id: str
posted_at: datetime
control_total: Decimal
postings: list[Posting]
def allocate_to_control(control_total: Decimal, shares: list[RevenueShare]) -> list[Decimal]:
"""Quantize each share so the cent amounts sum EXACTLY to control_total.
Largest-remainder rule: floor every share, then distribute the leftover
cents (positive) or reclaim the overshoot (negative) one cent at a time,
ranked by the fractional cent each share gained or lost to flooring.
"""
if not shares:
raise ValueError("cannot build a manifest with no revenue shares")
floored = [s.raw_amount.quantize(CENT, rounding=ROUND_DOWN) for s in shares]
# Residue is a signed whole number of cents: target minus what flooring produced.
residue_cents = int(
((control_total - sum(floored, Decimal("0"))) / CENT).to_integral_value()
)
# Fractional cent lost to flooring, per share. Larger fraction = stronger claim
# to the next cent up; smaller fraction = first to give a cent back.
remainders = [s.raw_amount - f for s, f in zip(shares, floored)]
# Deterministic tie-break: fall back to account_code so equal remainders
# always resolve the same way across runs.
order = sorted(
range(len(shares)),
key=lambda i: (remainders[i], shares[i].account_code),
reverse=True,
)
step = CENT if residue_cents >= 0 else -CENT
for k in range(abs(residue_cents)):
# For a positive residue walk largest-remainder first; for a negative
# residue walk smallest-remainder first (the reversed order from the end).
idx = order[k] if residue_cents >= 0 else order[-1 - k]
floored[idx] += step
return floored
def build_balanced_manifest(
batch_id: str, control_total: Decimal, shares: list[RevenueShare]
) -> BalancedManifest:
"""Emit a manifest whose credits sum to control_total and whose debit matches."""
amounts = allocate_to_control(control_total, shares)
postings = [
Posting(account_code=s.account_code, fund=s.fund, credit=amt)
for s, amt in zip(shares, amounts)
if amt != Decimal("0.00") # drop zero-value credit rows
]
# One offsetting receivable debit for the whole control total.
postings.append(Posting(account_code="1200", fund=shares[0].fund, debit=control_total))
total_credit = sum((p.credit for p in postings), Decimal("0.00"))
total_debit = sum((p.debit for p in postings), Decimal("0.00"))
if not (total_credit == total_debit == control_total):
raise ValueError(
f"allocation failed to balance: credit {total_credit}, "
f"debit {total_debit}, target {control_total}"
)
return BalancedManifest(
batch_id=batch_id,
posted_at=datetime.now(timezone.utc),
control_total=control_total,
postings=postings,
)
Two design choices make the balance exact rather than approximate. First, the residue is computed against the given control total, never against a re-summation of the shares — so the manifest hits the number finance already committed to, closing the gap between “what we rated” and “what we posted.” Second, the tie-break on account_code makes the allocation deterministic: two shares with identical fractional remainders always resolve in the same order, so the same batch produces byte-identical postings every run, which is what lets the manifest be hashed and reconciled. The final triple-equality guard is a real assertion, not a comment — if the arithmetic ever fails to balance, the function raises before a manifest object can exist.
Edge Cases and Billing Gotchas
- Negative residue (overshoot). If the control total is a hair below the summed shares, flooring can still leave the parts above the target once tails are accounted for, giving a negative residue. The module handles the sign explicitly — a negative residue walks the smallest remainders first and subtracts a cent — so it never leaves a share it should not or drives one negative silently.
- A share that floors to zero. A sub-cent share (say
Decimal("0.004")) floors to0.00and would post an empty credit row. The builder drops zero-value credits, but the residue step can still hand that account its cent if its remainder ranks high — so a genuinely tiny share is not always lost, it competes for the cent like any other. - All-equal remainders. When several shares share the same fractional cent, distributing “to the largest” is ambiguous unless the tie is broken deterministically. Ranking by
account_codeafter the remainder guarantees the same shares receive the residue every run, which is what keeps the manifest hash stable for audit trail verification. - A control total that is not cent-quantized. If the caller passes a total with more than two decimal places, the residue arithmetic will not converge to a whole number of cents and the balance guard will fire. Quantize the control total to the cent at its source — the rate engine — before it ever reaches this module.
Verification Snippet
The test asserts the three properties that matter: the credits sum to the control total, the debit matches, and the allocation is reproducible across runs.
def test_manifest_balances_and_is_deterministic() -> None:
# Three shares whose sub-cent tails cannot all round in the same direction.
shares = [
RevenueShare("4310", "WATER", Decimal("100.334")),
RevenueShare("4320", "WATER", Decimal("50.333")),
RevenueShare("4330", "WATER", Decimal("25.333")),
]
control_total = Decimal("176.00") # authoritative, cent-exact target
manifest = build_balanced_manifest("BATCH-2026-07", control_total, shares)
credits = sum((p.credit for p in manifest.postings), Decimal("0.00"))
debits = sum((p.debit for p in manifest.postings), Decimal("0.00"))
assert credits == control_total # parts sum EXACTLY to the target
assert debits == control_total # receivable debit matches
assert credits == debits # the double-entry invariant holds
# Determinism: same input -> byte-identical allocation every run.
again = build_balanced_manifest("BATCH-2026-07", control_total, shares)
assert [p.credit for p in manifest.postings] == [p.credit for p in again.postings]
The floored shares sum to 175.99, leaving a one-cent residue that the largest-remainder rule assigns to the share with the biggest truncated fraction — so the credits total 176.00 exactly rather than the 175.99 that naive independent rounding would have produced.
FAQ
Related Topics
- Double-Entry Journal Manifest Generation — the full workflow this rounding module plugs into, from GL aggregation to serialization.
- Hash Total Validation Controls — control totals that verify a batch survived processing intact.
- GASB Audit Trail Compliance Patterns — why a reproducible, hashable manifest matters for chain of custody.
- Automated Rate Calculation & Rule Engines — the source of both the rated shares and the authoritative control total.
Up: Double-Entry Journal Manifest Generation · Billing Reconciliation & Audit Logging