Posting X12 835 Payments to Internal AR Ledgers Without Balance Drift

Problem: an X12 835 remittance parses cleanly, but when its CLP and SVC amounts hit the accounts-receivable ledger the claim balance does not zero out — a CAS contractual write-off gets booked as a patient balance, a PLB provider-level takeback flips the wrong sign, or the same ERA posts twice after a clearinghouse redelivery and inflates cash by a full deposit. This guide gives the exact Python to turn parsed 835 claim-payment blocks into balanced, idempotent ledger entries where, for every claim, CLP03 charged reconciles to the penny against CLP04 paid plus every CAS03 adjustment plus CLP05 patient responsibility. It sits under the X12 835 Remittance Structure Breakdown and targets the revenue-cycle engineers and healthcare IT teams who own the posting engine.

Prerequisites

Spec Reference: Segments That Move Money

Posting reads five financial segments. The CLP balancing identity is the load-bearing invariant: the payer asserts that the submitted charge is fully accounted for by what it paid, what it adjusted, and what it assigned to the patient.

Element Name Role in posting Notes
BPR02 Total actual payment Batch cash total Must equal the deposit and the sum of CLP04 across the ERA
BPR04 Payment method Deposit reconciliation ACH, CHK, NON (zero-pay / info-only)
TRN02 Reassociation trace Idempotency root Payer EFT/check trace; the natural posting key
CLP01 Claim submitter id Ledger target Patient control number → open AR claim
CLP02 Claim status Posting disposition 1 paid, 2 denied, 3 partial, 4 denied-by-review, 22 reversal
CLP03 Total charge Left side of balance The amount the claim was submitted at
CLP04 Total paid Cash posting Booked to cash / contractual receipts
CLP05 Patient responsibility Statement balance Sum of PR-group CAS03 at claim level
SVC02/SVC03 Line billed / paid Line-level posting Service-line detail beneath the CLP
CAS01 Adjustment group Sign + bucket CO contractual, PR patient, OA other, PI payer-initiated
CAS02/CAS03 CARC + amount Adjustment posting Triads repeat up to six per CAS; positive reduces payer liability
PLB03/PLB04 Provider-level adj reason + amount Provider-level ledger Not tied to one claim; a positive PLB amount reduces the check

The claim identity every posting must satisfy is:

CLP03 (charged) == CLP04 (paid) + sum(CAS03 at claim + line) + CLP05 (patient responsibility)

PLB amounts live outside this identity. They adjust the check total (BPR02) at the provider level — an overpayment recovery, an interest payment, or a withhold — and they carry a sign convention that trips up almost every first implementation: a positive PLB04 is money the payer is taking back, so it decreases the deposit. Reconciliation therefore closes at the batch level as BPR02 == sum(CLP04) - sum(PLB04).

Balancing and idempotent posting flow for an 835 claim paymentA parsed CLP claim block feeds a balancing gate that checks whether CLP03 charged equals CLP04 paid plus the sum of CAS03 adjustments plus CLP05 patient responsibility. When the residual is zero the claim posts three ledger entries: paid to cash, contractual CO adjustments to write-off, and patient responsibility PR to the patient statement balance. When the residual is non-zero the claim is quarantined as unbalanced and never posted. Every posting is keyed on the TRN02 EFT trace joined with CLP01, and a unique constraint on that key rejects a duplicate redelivered ERA. Provider-level PLB amounts post separately against the batch, where a positive PLB reduces the deposit total BPR02. PARSED CLAIM CLP block CLP03 · CLP04 CLP05 · CAS03 BALANCING GATE residual = charge − paid − adj − PR residual == 0 Post ledger entries CLP04 → cash receipts CO CAS03 → contractual write-off PR CAS03 → patient statement key = TRN02 + CLP01 (UNIQUE) residual != 0 Quarantine — unbalanced never posted; operator review DUPLICATE ERA UNIQUE key rejects redelivery idempotent — no double posting BATCH RECONCILIATION Batch: BPR02 == sum(CLP04) − sum(PLB04) positive PLB04 is a payer takeback — it reduces the deposit
The balancing gate is the posting boundary: a claim posts three entries only when its residual is exactly zero, every posting is keyed on the TRN02+CLP01 pair, and the batch closes only when BPR02 equals paid minus provider-level takebacks.

Step-by-Step Implementation

Step 1 — Model ledger entries and the posting key

A ledger entry is an immutable double-entry line: it names the claim, the account bucket, a signed Decimal amount, and the idempotency key it was posted under. Keeping the model frozen means a downstream routine cannot mutate an amount after balancing has validated it.

from __future__ import annotations

import hashlib
import logging
from decimal import Decimal
from enum import Enum

from pydantic import BaseModel, ConfigDict

logging.basicConfig(format="%(asctime)s | %(levelname)s | %(message)s", level=logging.INFO)
logger = logging.getLogger("x12_835.post")

ZERO = Decimal("0.00")


def posting_key(trn02: str, clp01: str) -> str:
    """Idempotency key: the EFT trace bound to the claim control number."""
    return hashlib.sha256(f"{trn02}|{clp01}".encode()).hexdigest()


class Account(str, Enum):
    CASH = "CASH"                 # CLP04 payer payment
    CONTRACTUAL = "CONTRACTUAL"   # CO-group CAS03 write-off
    PATIENT_AR = "PATIENT_AR"     # PR-group CAS03 / CLP05
    OTHER_ADJ = "OTHER_ADJ"       # OA / PI group CAS03
    PROVIDER_LEVEL = "PROVIDER_LEVEL"  # PLB04


class LedgerEntry(BaseModel):
    model_config = ConfigDict(frozen=True)
    idempotency_key: str
    clp01: str            # claim control number — a token, not PHI
    account: Account
    amount: Decimal       # signed; debits positive, credits negative by convention

Step 2 — Assert the claim balances before any entry is written

Compute the residual from the typed segments. CAS03 amounts as delivered on the 835 are positive when they reduce the payer’s liability, so the balancing identity subtracts their sum. Refuse to post anything unless the residual is exactly zero — a non-zero residual means the parse dropped a CAS triad or a line, and posting it would silently corrupt AR.

class BalanceError(Exception):
    """Claim does not satisfy the CLP03 == CLP04 + adjustments + CLP05 identity."""


def assert_claim_balances(
    clp03_charge: Decimal,
    clp04_paid: Decimal,
    clp05_patient_resp: Decimal,
    cas_amounts: list[Decimal],   # every CAS03 at claim AND line level
) -> None:
    """Raise unless the payer's balancing identity holds to the penny."""
    adjustments = sum(cas_amounts, ZERO)
    residual = clp03_charge - (clp04_paid + adjustments + clp05_patient_resp)
    if residual != ZERO:
        raise BalanceError(
            f"unbalanced: charge={clp03_charge} paid={clp04_paid} "
            f"adj={adjustments} pr={clp05_patient_resp} residual={residual}"
        )

Step 3 — Build the posting from a balanced claim

Once balanced, fan the claim out to buckets by CAS01 group code. Contractual (CO) adjustments become write-offs; patient (PR) amounts land in patient AR; everything else (OA, PI) is booked to an other-adjustment account so nothing disappears. Every entry carries the same idempotency key.

GROUP_TO_ACCOUNT = {
    "CO": Account.CONTRACTUAL,
    "PR": Account.PATIENT_AR,
    "OA": Account.OTHER_ADJ,
    "PI": Account.OTHER_ADJ,
    "CR": Account.OTHER_ADJ,
}


def build_claim_postings(
    trn02: str,
    clp01: str,
    clp04_paid: Decimal,
    cas_triads: list[tuple[str, str, Decimal]],  # (CAS01 group, CAS02 CARC, CAS03 amt)
) -> list[LedgerEntry]:
    key = posting_key(trn02, clp01)
    entries: list[LedgerEntry] = [
        LedgerEntry(idempotency_key=key, clp01=clp01, account=Account.CASH, amount=clp04_paid)
    ]
    for group, _carc, amount in cas_triads:
        entries.append(
            LedgerEntry(
                idempotency_key=key,
                clp01=clp01,
                account=GROUP_TO_ACCOUNT.get(group, Account.OTHER_ADJ),
                amount=amount,
            )
        )
    logger.info("built postings clp01=%s entries=%d key=%s", clp01, len(entries), key[:12])
    return entries

Step 4 — Post idempotently and reconcile the batch

Persist under a UNIQUE constraint on idempotency_key + account. When a redelivered ERA replays the same trace and claim, the insert conflicts and is skipped rather than doubling cash. After all claims post, close the batch: BPR02 must equal the sum of CLP04 less the sum of PLB04, because a positive PLB04 is a payer takeback that never appears on the deposit.

def post_entries(entries: list[LedgerEntry], seen_keys: set[tuple[str, str]]) -> int:
    """Insert entries idempotently. `seen_keys` stands in for a UNIQUE index."""
    written = 0
    for e in entries:
        pk = (e.idempotency_key, e.account.value)
        if pk in seen_keys:  # in prod: ON CONFLICT DO NOTHING on the UNIQUE index
            logger.warning("duplicate posting skipped clp01=%s account=%s", e.clp01, e.account.value)
            continue
        seen_keys.add(pk)
        written += 1
    return written


def reconcile_batch(
    bpr02_total: Decimal,
    clp04_sum: Decimal,
    plb04_sum: Decimal,   # positive PLB = takeback = reduces the check
) -> bool:
    expected = clp04_sum - plb04_sum
    matched = bpr02_total == expected
    logger.info(
        "batch reconcile bpr02=%s expected=%s matched=%s", bpr02_total, expected, matched
    )
    return matched

Verification

Post a two-claim ERA where one claim carries a CO-45 contractual write-off and a PR-1 deductible, then assert the claim balances and the batch reconciles. A redelivery of the same trace must post zero new rows.

seen: set[tuple[str, str]] = set()

# Claim: charged 200.00, paid 120.00, CO-45 write-off 60.00, PR-1 deductible 20.00
assert_claim_balances(Decimal("200.00"), Decimal("120.00"), Decimal("20.00"),
                      [Decimal("60.00"), Decimal("20.00")])
postings = build_claim_postings(
    "EFT884412", "CLM-000123", Decimal("120.00"),
    [("CO", "45", Decimal("60.00")), ("PR", "1", Decimal("20.00"))],
)
assert post_entries(postings, seen) == 3            # cash + contractual + patient AR
assert post_entries(postings, seen) == 0            # redelivery: nothing new

# Batch: BPR02 480.00 = sum(CLP04) 500.00 - PLB takeback 20.00
assert reconcile_batch(Decimal("480.00"), Decimal("500.00"), Decimal("20.00"))

Expected log output (trace and claim references are control numbers, no PHI):

2026-07-16 09:04:11 | INFO    | built postings clp01=CLM-000123 entries=3 key=6f1c2a9b4e0d
2026-07-16 09:04:11 | WARNING | duplicate posting skipped clp01=CLM-000123 account=CASH
2026-07-16 09:04:11 | INFO    | batch reconcile bpr02=480.00 expected=480.00 matched=True

A BalanceError at post time is the correct, loud failure: it means the parser handed you an incomplete claim, and quarantining it protects the ledger.

Common Gotchas

  • PLB sign inversion. A positive PLB04 is money the payer recovers, so it reduces BPR02; treating it as an addition inflates the deposit and breaks batch reconciliation. Book provider-level amounts against the batch, never against a single claim.
  • Balancing with float. Parse and sum every amount as Decimal. A 0.01 residual from binary rounding will trip assert_claim_balances on legitimate claims and mask real drift on others.
  • Duplicate ERA posting. Clearinghouses redeliver files; without a UNIQUE constraint on the TRN02+CLP01 key a replay doubles cash. Make the database reject it — do not rely on an in-memory check that resets on restart.
  • Dropping non-CO/PR adjustments. OA and PI groups still carry money. Route them to an other-adjustment account so the residual closes; silently ignoring them turns a balanced claim into a phantom overpayment.

Up: X12 835 Remittance Structure Breakdown.