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 zoneinfo in the standard library and modern typing.
  • Pydantic v2 (pydantic>=2.6) — the frozen manifest model uses v2 semantics.
  • decimal.Decimal for every amount — never float. The whole point is exact base-10 arithmetic; a single float in the path reintroduces the drift this module exists to eliminate.
  • A cent-exact control total — a Decimal quantized to 0.01 that 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.

Deterministic penny allocation of a control total across revenue accounts A cent-exact control total is the target the manifest must hit. The raw revenue shares carry full-precision sub-cent tails. Each share is floored to the cent with ROUND_DOWN, which makes the parts sum a whole number of cents below the target. That difference is the residue, an integer number of cents that is never dropped. The residue is distributed by the largest-remainder rule, adding one cent at a time to the shares whose truncated fraction was largest. The result is a set of credits that sum exactly to the control total and an accounts-receivable debit equal to the same total, balanced to the cent and reproducible. Control total (given) cent-exact target the manifest must hit Raw revenue shares full-precision Decimal, sub-cent tails per revenue account Floor each to the cent ROUND_DOWN · parts now sum LOW quantize(0.01, ROUND_DOWN) Residue = target − sum(floored) a whole number of cents, never dropped signed integer of cents Distribute by largest remainder one cent to each biggest truncated fraction tie-break on account_code Credits == A/R debit == target balanced to the cent · reproducible sum(credit) == sum(debit)

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 to 0.00 and 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_code after 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

Why not just round each share to the nearest cent independently?
Because independently rounded parts do not reliably sum to the whole. Three shares that each round down leave the total a few cents short of the control total, and a journal that is even a cent short is out of balance. Allocating against a fixed target and distributing the residue guarantees the parts sum to exactly the number finance already committed to.
Why the largest-remainder method specifically?
It is the allocation that minimizes how far any single share moves from its true value while still summing exactly to the target: every share is floored, and only the shares that lost the most to flooring get a cent back. It is also simple to make deterministic with a tie-break, which matters more here than statistical elegance because the same batch must always hash the same way.
What makes the allocation reproducible across runs?
The ranking sorts by fractional remainder and then by account_code, so shares with identical remainders always resolve in the same order. Without that secondary key, Python's sort would still be stable but the input order could vary between runs, producing a different penny distribution and a different manifest hash. The tie-break pins the outcome to the data, not the call order.
Should the receivable debit be one row or one per revenue account?
One row per fund is enough for the journal to balance, and this module posts a single receivable debit for the whole control total because the example is within one fund. When a batch spans several enterprise funds, split the debit so each fund's receivable equals that fund's credits — the per-fund balance rule is covered in the parent workflow on journal manifest generation.
Where should the control total come from?
From the upstream rate engine's own reported batch total, quantized to the cent, not from re-summing the shares inside this module. Treating it as an external, authoritative input is what turns the manifest into a reconciliation: if the allocated credits cannot be made to hit it, the balance guard raises, surfacing a discrepancy between rating and posting instead of hiding it.

Up: Double-Entry Journal Manifest Generation · Billing Reconciliation & Audit Logging