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 | None unions and timezone-aware datetimes; the ledger period a post lands in depends on correct time handling.
  • decimal.Decimal for every amount — never float. 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_code and .json() (the examples use a client you inject, so a mock stands in for tests).
  • An ERP that exposes a stage endpoint and a confirm endpoint and honours an X-Idempotency-Key header 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.

Single-function flow for posting a billing batch to an ERP general ledger A left-to-right flow. A balanced billing batch is staged with an idempotency key, at which point the ERP previews the totals it would post. The function then confirms the commit using the same key. If the confirmed totals equal the expected control totals and the status is committed, the run is marked posted. If instead the commit was partial or the totals disagree, the function posts a contra reversal through the same keyed protocol to net the original journal back to zero. Balanced batch debits = credits 1 Stage · send key ERP previews totals 2 Confirm · same key commit journal 3 Confirmed totals == expected control? status committed? Run POSTED confirmation id returned Post reversal (contra) same keyed protocol totals match mismatch / partial

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 raises PeriodClosed at 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 Decimal to Decimal catches 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

Why send the idempotency key on both the stage and confirm calls?
The key derived from the batch contents makes the whole exchange replay-safe. On stage it lets the ERP recognise a re-staged batch, and on confirm it is the guarantee that a re-sent commit — after a dropped acknowledgement — returns the original confirmation rather than posting the journal a second time. Using the same key on both keeps the entire round trip idempotent, not just one half of it.
What happens if the ERP is unreachable between stage and confirm?
A staged-but-unconfirmed batch holds no ledger rows, so nothing is committed and nothing needs undoing. Once the ERP recovers you re-run the function: it re-stages and confirms under the same content-addressed key, and the ERP either completes the commit or returns the already-posted result. Wrap the call in the backoff and circuit-breaker patterns from the error-handling workflows so a sustained outage does not become a retry storm.
Why post a reversal instead of deleting the bad journal?
A general ledger is append-only from the finance office's perspective, so posted journals are never edited or deleted. When a commit is partial or the totals disagree, the function posts a contra journal that swaps every debit and credit, netting the original to zero. The reversal is itself balanced and posts through the same keyed stage-then-confirm handshake, so the correction is as safe and auditable as the original post.
How do I detect that only part of the batch committed?
The function treats any confirmation whose status is not committed, or whose posted debit and credit totals do not equal the expected control totals to the cent, as a failure. Comparing Decimal to Decimal catches a partial commit and a one-cent rounding drift alike, and both route to the reversal path so an unbalanced journal is never left standing.

Up: ERP General-Ledger Posting Integration · Billing Reconciliation & Audit Logging