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).
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
PLBsign inversion. A positivePLB04is money the payer recovers, so it reducesBPR02; 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 asDecimal. A0.01residual from binary rounding will tripassert_claim_balanceson legitimate claims and mask real drift on others. - Duplicate ERA posting. Clearinghouses redeliver files; without a UNIQUE constraint on the
TRN02+CLP01key a replay doubles cash. Make the database reject it — do not rely on an in-memory check that resets on restart. - Dropping non-
CO/PRadjustments.OAandPIgroups 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.
Related
- Parent guide: X12 835 Remittance Structure Breakdown — the BPR/TRN/CLP/SVC/CAS/PLB hierarchy and the Pydantic models this posting engine consumes.
- Matching 835 CLP Segments to 837 Claims — resolving
CLP01to the open AR claim before you can post against it. - Payer-Specific Rule Boundary Configuration — per-payer adjustment tolerances and CARC semantics that gate auto-posting.