ERP General-Ledger Posting Integration for Municipal Utility Billing
The moment a billing run leaves your system and lands in the finance department’s general ledger is where revenue assurance is either enforced or quietly lost. This page sits inside Billing Reconciliation & Audit Logging, and it addresses one specific, high-stakes handoff: taking a balanced journal manifest produced by a billing cycle and posting it into a municipal ERP general ledger without ever double-posting, dropping a fund’s worth of revenue, or leaving the two systems disagreeing by a penny. The failure modes here are not abstract. A retried post after a lost acknowledgement can duplicate an entire cycle’s receivables. A batch that commits half its lines before the ERP times out leaves the ledger unbalanced and un-auditable. A posting into a period the finance office already closed is rejected outright, and if your code treats that rejection as a transient error it will retry it into oblivion. The protocol on this page enforces four invariants: every post is idempotent, the manifest is proven balanced before it is confirmed, posted totals are reconciled against the manifest’s control totals before the run is marked complete, and any partial or rejected post is corrected with an explicit reversal rather than a silent overwrite.
Prerequisites
This integration assumes a pinned, deterministic toolchain. The ERP is treated as a black-box REST service you do not control, so every guarantee has to come from your side of the boundary.
- Python 3.11+ for
zoneinfo,StrEnum, and structured concurrency. All datetimes are timezone-aware; the accounting period a post lands in depends on it. decimal.Decimalfor every debit and credit. General ledgers balance to the cent; a single binaryfloatsum surfaces as an out-of-balance journal the ERP will reject.- Pydantic v2 (
pydantic>=2.6) for the manifest and payload contracts. The ERP’s rejection messages are far more useful when your payload was validated before it left. - A balanced double-entry journal manifest as input — total debits already equal total credits, each line already carries its fund and account, and the manifest already carries hash-total control figures computed at generation time.
- An ERP that exposes a stage (or “validate/prepare”) endpoint distinct from a confirm (or “commit”) endpoint. If yours only offers a single atomic post, the two-phase discipline collapses into a stricter idempotency-key contract; most municipal ERPs (Tyler, Oracle EBS, SAP, Workday) expose both.
- An idempotency-key contract on the ERP side — an
X-Idempotency-Keyheader or a client-suppliedjournal_referencethe ERP enforces as unique. This is what makes a retry structurally safe rather than hopeful. - Append-only audit storage for the posting chain of custody every GASB and PUC reviewer expects.
Architecture Overview
Posting is not a single HTTP call; it is a conversation with a system that can accept, partially accept, reject, or silently drop your message. The safe shape of that conversation is a two-phase handshake: you stage the batch so the ERP validates the period and computes what it would post, you verify the ERP’s arithmetic matches your manifest’s control totals, and only then do you confirm the commit. Reconciliation happens against the ERP’s own returned figures, and any disagreement or partial failure triggers an explicit reversal rather than a retry that might double-post.
Figure: The stage-then-confirm posting protocol. The ERP validates the open period and computes its own totals at stage time; the billing system commits only after those totals match the manifest, and corrects any failure with an explicit reversal rather than a blind retry.
The single most important property of this diagram is that the commit in step 3 is safe to send more than once. If the acknowledgement in step 4 is lost to a network timeout, the billing system can re-send the identical confirm with the identical idempotency key and the ERP returns the already-posted result instead of posting a second time. Everything else — the stage validation, the reconciliation, the reversal path — exists to make that one guarantee complete.
Step-by-Step Implementation
Step 1 — Map the journal manifest to an ERP journal-batch payload
The input is a balanced journal manifest: a header plus a list of double-entry lines, each with an account, a fund/department dimension, and either a debit or a credit as a Decimal. The ERP wants a journal-batch payload in its own shape. Mapping is where two vocabularies meet, so it is also where mistakes are cheapest to catch. Validate that the manifest is internally balanced before you build the payload — never ask the ERP to be your balance checker.
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, model_validator
CENTS = Decimal("0.01")
class Side(StrEnum):
DEBIT = "debit"
CREDIT = "credit"
class JournalLine(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
account: str = Field(pattern=r"^\d{3}-\d{4}-\d{3}$") # fund-account-dept
fund: str
side: Side
amount: Decimal = Field(gt=Decimal("0")) # magnitude only; side carries sign
class JournalManifest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
manifest_id: str # stable id for the billing run
period: str = Field(pattern=r"^\d{4}-\d{2}$") # accounting period, e.g. 2026-07
posted_as_of: date
lines: list[JournalLine]
control_debits: Decimal # hash-total figures from generation
control_credits: Decimal
control_line_count: int
@model_validator(mode="after")
def enforce_balance(self) -> "JournalManifest":
debits = sum((ln.amount for ln in self.lines if ln.side is Side.DEBIT), Decimal("0"))
credits = sum((ln.amount for ln in self.lines if ln.side is Side.CREDIT), Decimal("0"))
if debits != self.control_debits or credits != self.control_credits:
raise ValueError("manifest lines disagree with declared control totals")
if debits != credits:
raise ValueError(f"unbalanced manifest: debits {debits} != credits {credits}")
if len(self.lines) != self.control_line_count:
raise ValueError("line count disagrees with control figure")
return self
def to_erp_payload(manifest: JournalManifest) -> dict:
"""Translate the internal manifest into the ERP's journal-batch vocabulary."""
return {
"journal_reference": manifest.manifest_id, # the ERP's client-supplied uniqueness key
"accounting_period": manifest.period,
"posting_date": manifest.posted_as_of.isoformat(),
"source": "UTILITY_BILLING",
"lines": [
{
"gl_account": ln.account,
"fund_code": ln.fund,
# ERP expects signed amounts in separate columns, quantized to cents.
"debit": str(ln.amount.quantize(CENTS)) if ln.side is Side.DEBIT else "0.00",
"credit": str(ln.amount.quantize(CENTS)) if ln.side is Side.CREDIT else "0.00",
}
for ln in manifest.lines
],
}
The enforce_balance validator is the gate: a manifest that does not tie to its own control totals never becomes a payload. Amounts cross the boundary as quantized strings, not floats, so the ERP parses the exact cent figure you intended rather than a binary approximation of it.
Step 2 — Derive an idempotency key that is stable across retries
The precondition for a safe retry is that two attempts to post the same run produce the same key, and two different runs never collide. Derive the key from the content of the manifest — its identity and the exact set of lines — never from a wall-clock time, a UUID minted per attempt, or a transport sequence number. A content hash also gives you a free integrity check: if a single line changed between attempts, the key changes, and the ERP treats it as a genuinely new post instead of silently merging it.
import hashlib
import json
def posting_key(manifest: JournalManifest) -> str:
"""Content-addressed key: the same run always hashes to the same posting key."""
basis = {
"manifest_id": manifest.manifest_id,
"period": manifest.period,
"lines": [
[ln.account, ln.fund, ln.side.value, str(ln.amount.quantize(CENTS))]
for ln in manifest.lines
],
}
canonical = json.dumps(basis, sort_keys=True, separators=(",", ":"))
return "glpost-" + hashlib.sha3_256(canonical.encode("utf-8")).hexdigest()
This key travels in the X-Idempotency-Key header on both the stage and the confirm call. The ERP binds the confirmed result to the key, so a re-sent confirm after a lost acknowledgement returns the original posting rather than creating a second journal.
Step 3 — Stage the batch, then confirm in two phases
Staging asks the ERP to validate the period and compute the totals it would post without committing anything. The response carries a staged token and the ERP’s own arithmetic. You verify that arithmetic against your control totals (Step 4) and only then confirm. Splitting validation from commit means a period-closed or malformed-account error surfaces at stage time, before any ledger row exists — nothing to reverse, because nothing was written.
class PostingError(Exception):
"""Base for posting failures."""
class PeriodClosedError(PostingError):
"""The accounting period is closed; this is permanent, never retry it."""
class TransientPostingError(PostingError):
"""Timeout, 503, or connection reset — safe to retry with the same key."""
async def stage_batch(client, payload: dict, key: str) -> dict:
headers = {"X-Idempotency-Key": key}
resp = await client.post("/gl/journal-batches:stage", json=payload, headers=headers)
if resp.status_code == 409 and resp.json().get("reason") == "period_closed":
raise PeriodClosedError(payload["accounting_period"])
if resp.status_code in (429, 503, 504):
raise TransientPostingError(f"stage returned {resp.status_code}")
resp.raise_for_status()
return resp.json() # {"stage_token": "...", "would_post": {"debits": "...", "credits": "..."}}
async def confirm_batch(client, stage_token: str, key: str) -> dict:
headers = {"X-Idempotency-Key": key} # SAME key as stage — this is what makes confirm replay-safe
resp = await client.post(f"/gl/journal-batches/{stage_token}:confirm", headers=headers)
if resp.status_code in (429, 503, 504):
raise TransientPostingError(f"confirm returned {resp.status_code}")
resp.raise_for_status()
return resp.json() # {"confirmation_id": "...", "posted": {"debits": "...", "credits": "..."}}
Both calls should run inside the resilient retry policy documented under error handling and retry workflows: only TransientPostingError re-enters the backoff loop, while PeriodClosedError is re-raised straight through so it is handled as a permanent condition rather than retried into the ground.
Step 4 — Verify posted totals equal the manifest control totals
The ERP is not trusted to be correct; it is trusted to report what it did, and your job is to check that report against what you sent. Compare the ERP’s staged and posted totals to the manifest’s control figures as Decimal values. A mismatch — even of one cent — means the two systems disagree about reality, and the run must not be marked complete.
def totals_match(erp_totals: dict, manifest: JournalManifest) -> bool:
"""Both sides must agree to the cent on debits and credits."""
return (
Decimal(erp_totals["debits"]) == manifest.control_debits
and Decimal(erp_totals["credits"]) == manifest.control_credits
)
class ReconciliationMismatch(PostingError):
def __init__(self, expected: JournalManifest, got: dict) -> None:
self.expected = expected
self.got = got
super().__init__(
f"posted debits {got['debits']}/credits {got['credits']} != "
f"control {expected.control_debits}/{expected.control_credits}"
)
Run totals_match twice: against the would_post figures returned by staging (a cheap pre-commit sanity check) and against the posted figures returned by confirming (the authoritative post-commit reconciliation). The second check is what closes the loop with the broader hash-total validation controls that guard every stage of the run.
Step 5 — Emit reversal entries on partial failure or rejection
A general ledger is append-only from the finance office’s point of view: you do not edit or delete a posted journal, you post a reversal — a contra entry that swaps every debit and credit — and let the two net to zero. When a confirm partially commits, when reconciliation fails, or when a period is closed after a batch was already posted, the correct response is a reversal that references the original, never a destructive fix.
def build_reversal(manifest: JournalManifest, original_confirmation_id: str) -> JournalManifest:
"""Swap every debit/credit to net the original posting to zero."""
reversed_lines = [
JournalLine(
account=ln.account,
fund=ln.fund,
side=Side.CREDIT if ln.side is Side.DEBIT else Side.DEBIT,
amount=ln.amount,
)
for ln in manifest.lines
]
# Control totals swap alongside the sides, so the reversal is itself a balanced manifest.
return JournalManifest(
manifest_id=f"{manifest.manifest_id}-REV-{original_confirmation_id}",
period=manifest.period,
posted_as_of=manifest.posted_as_of,
lines=reversed_lines,
control_debits=manifest.control_credits,
control_credits=manifest.control_debits,
control_line_count=manifest.control_line_count,
)
Because the reversal is produced as a fully-formed JournalManifest, it flows back through the exact same stage-confirm-reconcile path as the original — it gets its own content-addressed posting_key, so it too is idempotent, and the -REV- suffix plus the original confirmation id make the correction traceable in the audit trail. If the reversal must post into a period that has since closed, it is queued for the next open period rather than forced, and the discrepancy is flagged for manual finance review.
Edge-Case Handling
The happy path is a single clean post. The cases that decide whether the ledger stays trustworthy are the awkward ones, and each has broken a real municipal close.
- Partial posting. The ERP commits some lines and then fails mid-batch, leaving an unbalanced journal. A journal batch must be atomic; if the ERP cannot guarantee all-or-nothing at confirm, treat any non-complete confirmation as a failure and post a reversal of whatever committed, then re-stage the whole manifest fresh. Never “top up” the missing lines — that path cannot be made idempotent.
- Accounting period closed. Finance closes a period on a schedule you do not control. A post or reversal aimed at a closed period returns a permanent rejection, not a transient error. Detect it explicitly (
PeriodClosedError), stop retrying immediately, and route the manifest to the next open period with a flag for reviewer sign-off. - Duplicate posting after a lost acknowledgement. The ERP committed the journal but the network dropped the confirmation. The naive retry double-posts an entire cycle’s revenue. The content-addressed idempotency key on the confirm call makes the retry return the original confirmation id instead of posting again — this is the invariant the whole protocol rests on.
- Rounding differences between systems. The ERP may round or represent amounts differently than your engine. Because every amount crosses the boundary as a cent-quantized
Decimalstring and reconciliation comparesDecimaltoDecimal, a rounding drift shows up as a reconciliation mismatch (which halts the run) rather than a silent cent that only surfaces in an audit months later. - ERP down mid-batch. The service becomes unreachable between stage and confirm, or during confirm. A staged-but-unconfirmed batch holds no ledger rows, so it is safe to re-stage or re-confirm under the same key once the ERP recovers; a confirm that timed out is re-sent under the same key and either completes or returns the already-posted result. The circuit-breaker and dead-letter patterns from error handling and retry workflows keep a sustained ERP outage from turning into a retry storm.
Verification and Audit Trail
A posting integration that cannot prove what it posted, under which key, into which period, and whether it reconciled is a liability no matter how clean the invoices look. Every phase — staged, confirmed, reconciled, reversed — appends an entry to append-only storage, and each entry is chained to its predecessor so tampering is detectable. This is the discipline detailed under GASB audit-trail compliance patterns, and it is what lets an external auditor trace any general-ledger line back to the exact billing run and posting key that produced it.
import hashlib
import json
from datetime import datetime, timezone
def posting_audit_entry(event: dict, prev_hash: str) -> dict:
"""Hash-chain each posting disposition to its predecessor (tamper-evident)."""
body = {
"event": event, # e.g. {"key": key, "phase": "confirmed", "confirmation_id": cid}
"prev": prev_hash,
"logged_utc": datetime.now(timezone.utc).isoformat(),
}
canonical = json.dumps(body, sort_keys=True, default=str)
body["hash"] = hashlib.sha3_256(canonical.encode("utf-8")).hexdigest()
return body
def assert_run_reconciled(entries: list[dict]) -> None:
"""A run is only complete if a 'confirmed' entry has a matching 'reconciled' entry."""
confirmed = {e["event"]["key"] for e in entries if e["event"].get("phase") == "confirmed"}
reconciled = {e["event"]["key"] for e in entries if e["event"].get("phase") == "reconciled"}
unreconciled = confirmed - reconciled
if unreconciled:
raise AssertionError(f"confirmed but never reconciled: {sorted(unreconciled)}")
Correctness is confirmed two ways. First, replay idempotence: re-running a posting against a fixture ERP — including a re-sent confirm — must produce zero additional ledger rows and the same confirmation id, proving the key is stable. Second, totals tie-out: for every confirmed run, the ERP’s posted debits and credits must equal the manifest’s control debits and credits, and the audit log must carry a reconciled entry for every confirmed entry. Run both against historical billing fixtures before any change to the mapping or posting policy ships.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| An entire cycle’s revenue posted twice | Confirm retried after a lost ack without a stable key | Send X-Idempotency-Key on the confirm call, derive it from manifest content, and have the ERP enforce uniqueness on it |
| Posts keep retrying and never succeed | A period-closed rejection treated as transient | Detect period_closed explicitly, raise PeriodClosedError, and stop retrying — route to the next open period |
| Ledger journal is out of balance after a post | Partial commit accepted as success | Require an all-or-nothing confirmation; reverse any partial commit and re-stage the whole manifest |
| Reconciliation fails by a cent or two | Float arithmetic or rounding drift between systems | Use Decimal end to end, quantize to cents at the boundary, and compare Decimal to Decimal |
| Stage succeeds but confirm times out repeatedly | ERP degraded or unreachable mid-batch | Re-confirm under the same key once recovered; let the circuit breaker halt sustained outages instead of retrying |
| Auditor cannot trace a GL line to a billing run | Posting key and confirmation id not logged | Append a hash-chained audit entry per phase carrying the posting key, period, and confirmation id |
| Reversal itself double-posts | Reversal path bypasses the idempotent stage-confirm flow | Build the reversal as a full JournalManifest and post it through the same keyed protocol |
Frequently Asked Questions
Why stage and confirm separately instead of posting in one call?
Splitting the post lets the ERP validate the accounting period and compute its own totals before anything is committed. That means a closed period, a bad account, or an arithmetic disagreement surfaces at stage time, when there are no ledger rows to reverse. A single atomic call collapses validation and commit, so every failure becomes a post-commit cleanup problem instead of a pre-commit rejection.
How does the idempotency key prevent a double post?
The key is a hash of the manifest’s identity and its exact set of lines, sent on both the stage and confirm calls. The ERP binds the confirmed journal to that key. If a confirm is re-sent — because its acknowledgement was lost — the ERP recognises the key and returns the original confirmation instead of posting a second journal. Two different runs hash to different keys, so they can never be merged by accident.
What should happen when the accounting period is already closed?
Treat it as a permanent condition, not a transient one. The code raises PeriodClosedError and stops retrying immediately, because no amount of retrying will reopen a period the finance office has closed. The manifest is queued for the next open period and flagged for a reviewer, so the revenue still posts — just into a period that will accept it — with a clear audit note explaining why.
When do I post a reversal instead of just fixing the entry?
Always, once anything has committed. A general ledger is append-only from the finance office’s perspective: posted journals are never edited or deleted. If a post partially commits, fails reconciliation, or has to be undone, you post a contra entry that swaps every debit and credit so the pair nets to zero, referencing the original confirmation id. The reversal is itself a balanced manifest and posts through the same idempotent protocol.
How do I know the ERP actually posted what my billing run intended?
Reconcile the ERP’s returned totals against the manifest’s control totals as Decimal values, both at stage time and after confirm. A run is only marked complete when the posted debits and credits equal the control figures to the cent and the audit log carries a reconciled entry matching the confirmed entry. This ties the integration into the same hash-total controls that guard the rest of the reconciliation subsystem.
Related Topics
- Double-Entry Journal Manifest Generation — produces the balanced manifest and control totals this integration consumes.
- Hash-Total Validation Controls — the control-figure discipline the posted-versus-expected reconciliation extends into the ledger.
- GASB Audit-Trail Compliance Patterns — the append-only, hash-chained log that records every posting disposition.
- Error Handling & Retry Workflows — the backoff, circuit-breaker, and dead-letter patterns the stage and confirm calls run inside.
- Posting Billing Batches to an ERP General Ledger — a focused, single-function implementation of the post-with-confirm-and-reversal flow.
Up one level: Billing Reconciliation & Audit Logging · Return to the utilitybilling.org home.