Posting Billing Batches to an ERP General Ledger
You have a balanced batch of journal entries from a completed billing cycle and one job left: get it into the finance department’s ERP general ledger exactly once, and know for certain that it landed correctly. The danger is concentrated in a single HTTP round trip — if the commit’s acknowledgement is lost, a naive retry posts the whole cycle twice; if the ERP accepts only part of the batch, the ledger is left unbalanced; if the totals it reports back differ from what you sent, nobody notices until the audit. This page shows one focused async function that closes all of those gaps. It sits under ERP general-ledger posting integration, part of the broader Billing Reconciliation & Audit Logging subsystem, and where that page lays out the full protocol, this one is the single post-with-confirm-and-reversal call you can drop into a worker.
Minimal Prerequisites
- Python 3.11+ — for
X | Noneunions and timezone-aware datetimes; the ledger period a post lands in depends on correct time handling. decimal.Decimalfor every amount — neverfloat. A general ledger balances to the cent, and the ERP will reject a journal that is off by a rounding artifact.- Pydantic v2 (
pydantic>=2.6) for the batch contract — the batch is assumed already balanced (debits equal credits) and already carrying its expected control totals before this function runs. - An async HTTP client with
.post()returning an object exposing.status_codeand.json()(the examples use aclientyou inject, so a mock stands in for tests). - An ERP that exposes a stage endpoint and a confirm endpoint and honours an
X-Idempotency-Keyheader as a uniqueness guarantee.
Data assumptions: entries arrive as (account, debit, credit) triples with cent-quantized Decimal amounts, the batch is internally balanced, and expected_debits / expected_credits were computed when the batch was generated. Nothing downstream should ever see a float.
Annotated Implementation
The whole flow is one function. It stamps a stable idempotency key, stages the batch so the ERP can validate the period and preview its own totals, confirms under the same key, verifies the confirmed totals against what you expected, and — only if confirmation fails or disagrees — posts a contra reversal so the ledger is never left half-changed.
from __future__ import annotations
import hashlib
import json
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, model_validator
CENTS = Decimal("0.01")
class GLEntry(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
account: str
debit: Decimal = Field(ge=Decimal("0"))
credit: Decimal = Field(ge=Decimal("0"))
class BillingBatch(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
batch_id: str
gl_period: str = Field(pattern=r"^\d{4}-\d{2}$") # e.g. 2026-07
entries: list[GLEntry]
expected_debits: Decimal
expected_credits: Decimal
@model_validator(mode="after")
def must_balance(self) -> "BillingBatch":
d = sum((e.debit for e in self.entries), Decimal("0"))
c = sum((e.credit for e in self.entries), Decimal("0"))
if d != self.expected_debits or c != self.expected_credits:
raise ValueError("entries disagree with expected control totals")
if d != c:
raise ValueError(f"unbalanced batch: {d} != {c}")
return self
class PostResult(BaseModel):
confirmation_id: str
reversed: bool = False
reversal_id: str | None = None
class PeriodClosed(Exception):
"""Permanent: the GL period is closed. Do not retry; escalate."""
def _idem_key(batch: BillingBatch) -> str:
"""Content-addressed key so a retry of the same batch never posts twice."""
basis = json.dumps(
{"id": batch.batch_id, "period": batch.gl_period,
"e": [[e.account, str(e.debit), str(e.credit)] for e in batch.entries]},
sort_keys=True, separators=(",", ":"),
)
return "batch-" + hashlib.sha3_256(basis.encode()).hexdigest()[:32]
async def post_batch_to_gl(client, batch: BillingBatch) -> PostResult:
"""Stage, confirm, verify, and reverse-on-failure — the full safe post in one call."""
key = _idem_key(batch)
headers = {"X-Idempotency-Key": key} # SAME key on every call below
lines = [{"account": e.account, "debit": str(e.debit), "credit": str(e.credit)}
for e in batch.entries]
# --- Phase 1: stage. The ERP validates the period and previews its own totals. ---
staged = await client.post(
"/gl/batches:stage",
json={"reference": batch.batch_id, "period": batch.gl_period, "lines": lines},
headers=headers,
)
if staged.status_code == 409 and staged.json().get("reason") == "period_closed":
raise PeriodClosed(batch.gl_period) # permanent — nothing posted, nothing to undo
staged.raise_for_status()
token = staged.json()["stage_token"]
preview = staged.json()["preview_totals"]
if Decimal(preview["debits"]) != batch.expected_debits:
raise ValueError("ERP preview disagrees with batch before commit") # abort pre-commit
# --- Phase 2: confirm under the same key (safe to re-send if the ack is lost). ---
confirmed = await client.post(f"/gl/batches/{token}:confirm", headers=headers)
confirmed.raise_for_status()
body = confirmed.json()
posted = body["posted_totals"]
# --- Verify the ERP posted exactly what we intended, to the cent. ---
ok = (
body.get("status") == "committed"
and Decimal(posted["debits"]) == batch.expected_debits
and Decimal(posted["credits"]) == batch.expected_credits
)
if ok:
return PostResult(confirmation_id=body["confirmation_id"])
# --- Failure path: partial commit or totals mismatch → post a contra reversal. ---
reversal_lines = [{"account": e.account, "debit": str(e.credit), "credit": str(e.debit)}
for e in batch.entries] # swap columns to net the original to zero
rev = await client.post(
"/gl/batches:stage",
json={"reference": f"{batch.batch_id}-REV", "period": batch.gl_period,
"lines": reversal_lines},
headers={"X-Idempotency-Key": key + "-rev"}, # its own stable key
)
rev.raise_for_status()
rev_conf = await client.post(f"/gl/batches/{rev.json()['stage_token']}:confirm",
headers={"X-Idempotency-Key": key + "-rev"})
rev_conf.raise_for_status()
return PostResult(
confirmation_id=body.get("confirmation_id", "none"),
reversed=True,
reversal_id=rev_conf.json()["confirmation_id"],
)
Two properties do all the heavy lifting. The idempotency key is derived from the batch’s contents, so re-running the function after a dropped acknowledgement re-sends the identical key and the ERP returns the original confirmation instead of posting a second journal. And the reversal is built by swapping each entry’s debit and credit columns, which produces a balanced contra journal that nets the original posting to zero — posted through the same stage-then-confirm handshake under its own stable key, so even the correction is safe to retry.
Edge Cases and Billing Gotchas
Posting breaks in a handful of specific, expensive ways. Each of these is handled by the function above.
- Lost acknowledgement, then a retry. The ERP committed the journal but the network dropped the confirmation, so a worker retries. Because the confirm carries the content-addressed
X-Idempotency-Key, the ERP recognises the key and returns the original confirmation instead of posting the cycle’s revenue a second time. - Accounting period closed. A post aimed at a period the finance office already closed comes back as a
409 period_closed. This is permanent, so the function raisesPeriodClosedat stage time — before any ledger row exists — rather than retrying it. The caller escalates the batch to the next open period for reviewer sign-off. - Partial commit. The ERP reports a status other than
committed, meaning some lines landed and some did not, leaving the journal unbalanced. The function does not try to patch the missing lines; it posts a full contra reversal so the ledger returns to a balanced state, then the whole batch can be re-staged cleanly. - Totals mismatch. The confirmed debits or credits differ from the expected control totals — a rounding drift or a silently altered line. Comparing
DecimaltoDecimalcatches a one-cent disagreement, and any mismatch triggers the same reversal path so a wrong journal is never left standing in the ledger.
Verification Snippet
Drive the function with a mock ERP client to prove both the happy path and the reversal path, and to prove the idempotency key is stable across a retry.
import asyncio
from decimal import Decimal
class MockERP:
"""Minimal stand-in: `mode` controls whether confirm succeeds or under-posts."""
def __init__(self, mode: str = "ok") -> None:
self.mode = mode
self.confirm_calls: list[str] = []
async def post(self, url, json=None, headers=None):
key = headers["X-Idempotency-Key"]
if url.endswith(":stage"):
debits = "100.00" if "REV" not in json["reference"] else "40.00"
return _Resp(200, {"stage_token": f"tok-{key[-6:]}",
"preview_totals": {"debits": debits, "credits": debits}})
# confirm
self.confirm_calls.append(key)
if self.mode == "short" and "-rev" not in key:
return _Resp(200, {"status": "partial", "confirmation_id": "C-1",
"posted_totals": {"debits": "60.00", "credits": "60.00"}})
return _Resp(200, {"status": "committed", "confirmation_id": f"C-{key[-4:]}",
"posted_totals": {"debits": "100.00", "credits": "100.00"}})
class _Resp:
def __init__(self, code, payload): self.status_code, self._p = code, payload
def json(self): return self._p
def raise_for_status(self): assert self.status_code < 400
def make_batch() -> BillingBatch:
return BillingBatch(
batch_id="RUN-2026-07",
gl_period="2026-07",
entries=[GLEntry(account="101-1000-000", debit=Decimal("100.00"), credit=Decimal("0.00")),
GLEntry(account="401-4000-000", debit=Decimal("0.00"), credit=Decimal("100.00"))],
expected_debits=Decimal("100.00"),
expected_credits=Decimal("100.00"),
)
def test_happy_path_posts_once() -> None:
erp = MockERP("ok")
result = asyncio.run(post_batch_to_gl(erp, make_batch()))
assert result.reversed is False
assert result.confirmation_id.startswith("C-")
# Idempotency: a retry re-sends the SAME key, so the ERP can dedupe.
asyncio.run(post_batch_to_gl(erp, make_batch()))
assert erp.confirm_calls[0] == erp.confirm_calls[1]
def test_partial_commit_triggers_reversal() -> None:
result = asyncio.run(post_batch_to_gl(MockERP("short"), make_batch()))
assert result.reversed is True
assert result.reversal_id is not None # contra journal was posted to net it to zero
Running these two tests before shipping is what proves the two properties that matter: a clean post happens exactly once even on retry, and a bad post is always followed by a balancing reversal.
FAQ
Related Topics
- ERP General-Ledger Posting Integration — the full protocol this single function implements, including mapping and reconciliation.
- Double-Entry Journal Manifest Generation — where the balanced batch and its expected totals come from.
- Hash-Total Validation Controls — the control-total discipline the confirmation check relies on.
- Error Handling & Retry Workflows — the backoff and circuit-breaker layer to wrap this posting call in.
- GASB Audit-Trail Compliance Patterns — recording each posting and reversal in an append-only log.
Up: ERP General-Ledger Posting Integration · Billing Reconciliation & Audit Logging