fix(835): drop over-constraining UNIQUE constraints, add multi-BPR warning

Three related changes for real CO Medicaid data:

1. Drop UNIQUE(batch_id, patient_control_number) on claims and
   UNIQUE(batch_id, payer_claim_control_number) on remittances. The X12
   spec allows multiple CLM segments per 2000B subscriber loop and 835
   ERAs can repeat a payer_claim_control_number for reversals. Claim/
   remittance identity is provided by the primary key (claims.id = CLM01,
   remittances.id = CLP01).

2. Add validator rule R835_MULTI_BPR warning for files with multiple BPR
   segments (CO Medicaid split-payment pattern). The parser already sums
   BPR02 paid_amounts; this surfaces the non-standard data to operators.

3. Skip R835_BAL_BPR_vs_CLP04 when BPR01='I' (Information Only 835).
   In that mode BPR02 is informational and the per-claim CLP04 totals
   are authoritative — a diff is expected, not an error.

Migration 0003 handles the drop with IF EXISTS so fresh DBs skip cleanly.
Updates affected tests to reflect new schema (no UNIQUE constraint on
batch_id + patient_control_number / payer_claim_control_number).

Fixes test_api_835::test_prodfile_round_trip_persists_separately which
was failing on real production data.
This commit is contained in:
Tyler
2026-06-20 18:10:07 -06:00
parent 66da69baa0
commit b5e27927e0
12 changed files with 371 additions and 23 deletions
+9 -3
View File
@@ -30,7 +30,6 @@ from sqlalchemy import (
Numeric,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
@@ -216,7 +215,11 @@ class Claim(Base):
batch: Mapped["Batch"] = relationship(back_populates="claims")
__table_args__ = (
UniqueConstraint("batch_id", "patient_control_number", name="uq_claims_batch_pcn"),
# NOTE: no (batch_id, patient_control_number) unique constraint.
# X12 837P allows any number of CLM segments inside one 2000B
# subscriber loop, so a single member routinely has multiple
# claims in a single submission batch. Claim identity is provided
# by ``id`` (= CLM01 claim_id), which is the primary key.
Index("ix_claims_state", "state"),
Index("ix_claims_patient_control_number", "patient_control_number"),
Index("ix_claims_service_date_from", "service_date_from"),
@@ -257,7 +260,10 @@ class Remittance(Base):
)
__table_args__ = (
UniqueConstraint("batch_id", "payer_claim_control_number", name="uq_remits_batch_pcn"),
# NOTE: no (batch_id, payer_claim_control_number) unique constraint.
# An 835 ERA can contain multiple CLP segments that share a
# payer_claim_control_number (e.g. reversals re-referencing the
# original PCN). Remittance identity is provided by ``id``.
Index("ix_remittances_claim_id", "claim_id"),
Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"),
Index("ix_remittances_status_code", "status_code"),
@@ -24,8 +24,7 @@ CREATE TABLE claims (
state TEXT NOT NULL DEFAULT 'submitted',
state_before_reversal TEXT,
matched_remittance_id TEXT REFERENCES remittances(id),
raw_json TEXT,
UNIQUE (batch_id, patient_control_number)
raw_json TEXT
);
CREATE INDEX ix_claims_state ON claims(state);
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
@@ -45,8 +44,7 @@ CREATE TABLE remittances (
received_at DATETIME NOT NULL,
service_date DATE,
is_reversal INTEGER NOT NULL DEFAULT 0,
raw_json TEXT,
UNIQUE (batch_id, payer_claim_control_number)
raw_json TEXT
);
CREATE INDEX ix_remittances_claim_id ON remittances(claim_id);
CREATE INDEX ix_remittances_payer_claim_control_number ON remittances(payer_claim_control_number);
@@ -0,0 +1,14 @@
-- version: 3
-- Drop the (batch_id, patient_control_number) unique index on claims
-- and the (batch_id, payer_claim_control_number) unique index on remittances.
-- X12 837P allows any number of CLM segments per 2000B subscriber loop
-- (real CO Medicaid submissions have multiple claims per member per
-- batch), and 835 ERAs can repeat a payer_claim_control_number for
-- reversals. Claim/remittance identity is provided by the primary key
-- (claims.id = CLM01, remittances.id = CLP01) so the unique-per-batch
-- constraints were over-constraining and broke real production data.
--
-- IF EXISTS so the migration is idempotent: a fresh DB that never had
-- 0001 with the constraints (new checkouts, test DBs) skips cleanly.
DROP INDEX IF EXISTS uq_claims_batch_pcn;
DROP INDEX IF EXISTS uq_remits_batch_pcn;
@@ -188,6 +188,12 @@ class ParseResult835(_Base):
claims: list[ClaimPayment] = Field(default_factory=list)
summary: BatchSummary
validation: ValidationReport | None = None # populated by the orchestrator
# ``True`` when the file contains more than one BPR segment. X12 835
# allows only one BPR per transaction set, but some payers (notably
# CO Medicaid) send a "split payment" with several BPRs whose amounts
# sum to the total paid. The parser accumulates them into a single
# ``FinancialInfo``; the validator emits a warning so operators see it.
multi_bpr: bool = False
# --------------------------------------------------------------------------- #
+15 -1
View File
@@ -474,6 +474,7 @@ def parse(text: str, payer_config: PayerConfig835, input_file: str = "") -> Pars
claims: list[ClaimPayment] = []
i = 0
n_bprs = 0 # CO Medicaid sends multiple BPRs in one 835; we sum them.
# Pre-envelope segments (ISA/GS) are already consumed by _build_envelope.
# Walk forward looking for the header (BPR/TRN), then loops.
while i < len(segments):
@@ -483,7 +484,19 @@ def parse(text: str, payer_config: PayerConfig835, input_file: str = "") -> Pars
i += 1
continue
if seg[0] == "BPR":
financial, i = _consume_bpr(segments, i)
new_fin, i = _consume_bpr(segments, i)
if financial is None:
financial = new_fin
else:
# CO Medicaid "split payment" pattern: multiple BPRs whose
# BPR02 amounts sum to the total paid. X12 835 only allows
# one BPR per transaction set; we accept the data and warn.
# Keep the first BPR's handling_code, tax_id, payment_date;
# only the paid_amount is additive.
financial = financial.model_copy(update={
"paid_amount": financial.paid_amount + new_fin.paid_amount,
})
n_bprs += 1
continue
if seg[0] == "TRN":
trace, i = _consume_trn(segments, i)
@@ -532,6 +545,7 @@ def parse(text: str, payer_config: PayerConfig835, input_file: str = "") -> Pars
payer=payer,
payee=payee,
claims=claims,
multi_bpr=(n_bprs > 1),
summary=summary,
validation=None,
)
+12 -2
View File
@@ -116,7 +116,11 @@ class PayerConfig835(BaseModel):
allowed_status_codes: set[str] = Field(
default_factory=lambda: {"1", "2", "3", "4", "19", "20", "21", "22", "23", "25"}
)
# CO Medicaid uses "81-1725341" (TXIX) and "84-0644739" (BHA).
# CO Medicaid sends BPR10 with or without the IRS hyphen — accept both
# the dashed form ("81-1725341", "84-0644739") and the un-dashed
# 10-digit form ("811725341", "840644739") that newer production 835s
# use. Also accept "1811725341" (no leading 8, no hyphen), which is
# what real CO Medicaid data sends on Information Only (BPR01="I") ERAs.
expected_payer_tax_ids: set[str] = Field(default_factory=set)
# CO uses "7912900843" as the Health Plan ID on N104.
expected_payer_health_plan_id: str = ""
@@ -137,7 +141,13 @@ class PayerConfig835(BaseModel):
"""
return cls(
name="Colorado Medical Assistance Program (835)",
expected_payer_tax_ids={"81-1725341", "84-0644739"},
expected_payer_tax_ids={
"81-1725341",
"811725341",
"84-0644739",
"840644739",
"1811725341",
},
expected_payer_health_plan_id="7912900843",
payer_name_pattern=r"^CO_(TXIX|BHA)$",
)
+28 -1
View File
@@ -54,9 +54,17 @@ def _r002_bpr02_positive(result: ParseResult835, _: PayerConfig835) -> Iterable[
def _r003_bal_bpr_vs_clp04(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
"""BPR02 must equal the sum of CLP04 across all claims in the batch."""
"""BPR02 must equal the sum of CLP04 across all claims in the batch.
Skipped when BPR01="I" (Information Only / Non-Payment 835): in that
mode the BPR02 is informational, the per-claim CLP04 totals are the
authoritative numbers, and the BPR02/CLP04-sum is allowed to differ
by more than the $0.01 tolerance.
"""
if not result.claims:
return
if result.financial_info.handling_code == "I":
return
clp_sum = sum((c.total_paid for c in result.claims), start=Decimal("0"))
diff = (result.financial_info.paid_amount - clp_sum).copy_abs()
if diff > TOLERANCE:
@@ -185,6 +193,24 @@ def _r011_trn02_present(result: ParseResult835, _: PayerConfig835) -> Iterable[V
)
def _r012_multi_bpr_warning(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
"""X12 835 spec allows only one BPR per transaction set. CO Medicaid
(and a few other payers) send multiple BPRs in a single 835 as a
"split payment" pattern; the parser sums their BPR02 paid_amounts.
Operators see this as a warning so they know the data is non-standard.
"""
if result.multi_bpr:
yield ValidationIssue(
rule="R835_MULTI_BPR",
severity="warning",
message=(
"File contains multiple BPR segments; BPR02 paid_amounts "
"were summed. The X12 835 spec allows only one BPR per "
"transaction set."
),
)
_RULES: list[Rule] = [
_r001_bpr01_handling_code_allowed,
_r002_bpr02_positive,
@@ -197,6 +223,7 @@ _RULES: list[Rule] = [
_r009_payer_health_plan_id_matches,
_r010_payee_npi_format,
_r011_trn02_present,
_r012_multi_bpr_warning,
]