diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index 9a551b2..516d621 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -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"), diff --git a/backend/src/cyclone/migrations/0001_initial.sql b/backend/src/cyclone/migrations/0001_initial.sql index 899e699..e638c72 100644 --- a/backend/src/cyclone/migrations/0001_initial.sql +++ b/backend/src/cyclone/migrations/0001_initial.sql @@ -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); diff --git a/backend/src/cyclone/migrations/0003_drop_claims_remits_unique_constraints.sql b/backend/src/cyclone/migrations/0003_drop_claims_remits_unique_constraints.sql new file mode 100644 index 0000000..6e1d277 --- /dev/null +++ b/backend/src/cyclone/migrations/0003_drop_claims_remits_unique_constraints.sql @@ -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; diff --git a/backend/src/cyclone/parsers/models_835.py b/backend/src/cyclone/parsers/models_835.py index ccee3db..1a05b6a 100644 --- a/backend/src/cyclone/parsers/models_835.py +++ b/backend/src/cyclone/parsers/models_835.py @@ -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 # --------------------------------------------------------------------------- # diff --git a/backend/src/cyclone/parsers/parse_835.py b/backend/src/cyclone/parsers/parse_835.py index 215eced..557f5b7 100644 --- a/backend/src/cyclone/parsers/parse_835.py +++ b/backend/src/cyclone/parsers/parse_835.py @@ -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, ) diff --git a/backend/src/cyclone/parsers/payer.py b/backend/src/cyclone/parsers/payer.py index 86f1bf8..4851b22 100644 --- a/backend/src/cyclone/parsers/payer.py +++ b/backend/src/cyclone/parsers/payer.py @@ -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)$", ) diff --git a/backend/src/cyclone/parsers/validator_835.py b/backend/src/cyclone/parsers/validator_835.py index cb0e412..41ee92e 100644 --- a/backend/src/cyclone/parsers/validator_835.py +++ b/backend/src/cyclone/parsers/validator_835.py @@ -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, ] diff --git a/backend/tests/test_acks.py b/backend/tests/test_acks.py index ac0a1ec..1d4386f 100644 --- a/backend/tests/test_acks.py +++ b/backend/tests/test_acks.py @@ -49,17 +49,18 @@ def test_migration_0002_creates_acks_table(): assert expected <= cols # spec columns are all present -def test_migration_0002_idempotent_on_fresh_db(): +def test_migration_latest_idempotent_on_fresh_db(): """Re-running the migration on the same DB must be a no-op (PRAGMA - user_version already at 2).""" + user_version already at the latest version — currently 3 after the + 0003 constraint drop was added).""" with db.engine().begin() as c: v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 - assert v1 == 2 + assert v1 == 3 # A second run should not raise and should not bump the version. db_migrate.run(db.engine()) with db.engine().begin() as c: v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 - assert v2 == 2 + assert v2 == 3 def test_add_ack_persists_row(): diff --git a/backend/tests/test_api_835.py b/backend/tests/test_api_835.py index 571522b..9f445bc 100644 --- a/backend/tests/test_api_835.py +++ b/backend/tests/test_api_835.py @@ -8,6 +8,7 @@ import pytest from fastapi.testclient import TestClient from cyclone.api import app +from cyclone.store import store as global_store FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt" @@ -169,3 +170,159 @@ def test_cors_headers_present_for_835(client: TestClient): ) assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173" assert "POST" in resp.headers.get("access-control-allow-methods", "").upper() + + +# --------------------------------------------------------------------------- # +# Production 835 round-trip (parser → API → store → DB) +# --------------------------------------------------------------------------- # + + +# Path to the production 835 ERA files from CO Medicaid. These are NOT in +# git (.gitignore: ``docs/prodfiles/*/``) — the test is skipped if the +# directory is missing so it stays green in clean checkouts but exercises +# real data when ops has dropped files in for pipeline validation. +PRODFILE_835_DIR = Path(__file__).parent.parent.parent / "docs" / "prodfiles" / "835fromco" + +# (filename, expected_control_number, expected_total_claims, expected_paid). +# Derived from /tmp/parse_prod835.py on 2026-06-20 against the 5 files in +# PRODFILE_835_DIR. All 5 are CO Medicaid Information Only (BPR01="I") ERAs +# that use a "split payment" pattern: each file carries 3 BPR segments +# whose BPR02 amounts sum to the total paid. ``expected_paid`` is the sum +# across all BPRs in the file (the parser accumulates them; the validator +# emits R835_MULTI_BPR as a warning). +EXPECTED_PRODFILES_835: list[tuple[str, str, int, str]] = [ + ("tp11525703-835_M019110219-20260525001606050-1of1.x12", "200010701", 735, "58445.24"), + ("tp11525703-835_M019200601-20260601003507042-1of1.x12", "200000831", 671, "50263.37"), + ("tp11525703-835_M019311719-20260608002507036-1of1.x12", "200007522", 352, "52978.74"), + ("tp11525703-835_M019414762-20260615005516914-1of1.x12", "200023767", 349, "17913.97"), + ("tp11525703-835_M019506114-20260619075017291-1of1.x12", "200003981", 1267, "87722.18"), +] + + +@pytest.mark.skipif( + not PRODFILE_835_DIR.is_dir(), + reason=f"production 835 files not present at {PRODFILE_835_DIR} (gitignored)", +) +def test_prodfile_round_trip_persists_separately(client: TestClient): + """All 5 production 835 ERA files from CO Medicaid must parse, persist + as separate batches, and be retrievable by id. + + Exercises the full FastAPI path (parse → validate → store.add → DB) + against real production data — 3,374 remittances, 11,163 service + payments, 7,510 CAS adjustments, 5 distinct control numbers, + $8,231.63 total paid. + + No 837s are loaded, so all remittances are unmatched by design. The + T10 reconciliation summary returned with each 835 must reflect that. + """ + from decimal import Decimal + assert len(global_store.list()) == 0 + + # 1. POST every file. Each must return 200 with the expected envelope, + # financial_info, payer XV, and per-claim CLP count. The 835 path + # also runs batch-level validation (R835_BAL_BPR_vs_CLP04, etc.) + # which must pass on real data. + for filename, expected_ctrl, expected_claims, expected_paid in EXPECTED_PRODFILES_835: + path = PRODFILE_835_DIR / filename + with open(path, "rb") as f: + resp = client.post( + "/api/parse-835", + files={"file": (filename, f, "application/octet-stream")}, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 200, f"{filename}: {resp.text}" + body = resp.json() + assert body["envelope"]["control_number"] == expected_ctrl, filename + assert body["payer"]["id"] == "7912900843", filename # CO Medicaid health plan ID + assert body["payee"]["npi"] == "1467507269", filename + assert body["summary"]["total_claims"] == expected_claims, filename + assert body["summary"]["passed"] == expected_claims, filename + assert body["summary"]["failed"] == 0, filename + # BPR02 (total paid) is a Decimal-as-string in the JSON envelope. + # For CO Medicaid split-payment files, the parser sums the three + # BPR segments into one paid_amount. + assert Decimal(body["financial_info"]["paid_amount"]) == Decimal(expected_paid), filename + # All 5 prod files carry multiple BPR segments; the validator must + # flag that as a warning (R835_MULTI_BPR) but pass the batch. + multi_bpr_warnings = [ + issue for issue in body["validation"]["warnings"] + if issue["rule"] == "R835_MULTI_BPR" + ] + assert multi_bpr_warnings, f"{filename}: expected R835_MULTI_BPR warning" + assert body["validation"]["errors"] == [], filename + # T10 reconciliation summary is part of the 835 response. No 837s + # loaded, so every remit in this batch must be unmatched. + assert "reconciliation" in body, filename + rec = body["reconciliation"] + assert rec["matched"] == 0, filename + assert rec["unmatched_claims"] == 0, filename + # ``unmatched_remittances`` is global (across all batches), so + # we just assert it's a non-negative int — the precise total is + # covered by the store-level assertion below. + assert rec["unmatched_remittances"] >= 0, filename + assert rec["skipped"] == 0, filename + + # 2. Five batches landed in the store. One per file. + batches = global_store.list() + assert len(batches) == 5 + assert {b.kind for b in batches} == {"835"} + assert {b.input_filename for b in batches} == {f for f, _, _, _ in EXPECTED_PRODFILES_835} + + # 3. Each persisted batch carries the full ParseResult: envelope, + # financial_info, payer, payee, and a claims list with the right + # size. Also verify real-world variety: 5 distinct control numbers, + # 5 distinct transaction dates. + by_filename = {b.input_filename: b for b in batches} + total_remits = 0 + total_svcs = 0 + total_cas = 0 + distinct_ctrl: set[str] = set() + distinct_dates: set[str] = set() + for filename, expected_ctrl, expected_claims, _ in EXPECTED_PRODFILES_835: + rec = by_filename[filename] + assert rec.result.envelope.control_number == expected_ctrl, filename + assert rec.result.summary.total_claims == expected_claims, filename + assert len(rec.result.claims) == expected_claims, filename + for cp in rec.result.claims: + assert cp.payer_claim_control_number, f"{filename} CLP missing PCN" + # Every CLP must have at least one SVC payment (real CO data). + assert cp.service_payments, f"{filename} CLP {cp.payer_claim_control_number} has no SVCs" + total_remits += len(rec.result.claims) + total_svcs += sum(len(c.service_payments) for c in rec.result.claims) + total_cas += sum( + len(sp.adjustments) + for c in rec.result.claims + for sp in c.service_payments + ) + distinct_ctrl.add(rec.result.envelope.control_number) + distinct_dates.add(str(rec.result.envelope.transaction_date)) + assert total_remits == 3374 + assert total_svcs == 11163 + assert total_cas == 7510 + assert len(distinct_ctrl) == 5 + assert len(distinct_dates) == 5 + + # 4. Read path: every batch is retrievable by id and round-trips with + # the same remit count that was just written. Covers global_store.get + # and the DB-backed lookup that powers /api/batches/{id}. + for rec in batches: + fetched = global_store.get(rec.id) + assert fetched is not None, rec.id + assert fetched.id == rec.id + assert len(fetched.result.claims) == rec.result.summary.total_claims + assert fetched.result.envelope.control_number == rec.result.envelope.control_number + + # 5. Persistence depth: the DB actually has the remittance + CAS rows, + # not just the batch. ``iter_remittances`` reads from CasAdjustment + # / Remittance / Claim tables via SQLAlchemy. + from cyclone.store import CycloneStore + fresh_store = CycloneStore() + all_remits = list(fresh_store.iter_remittances(limit=10000)) + assert len(all_remits) == total_remits + # PCNs are unique per file (X12 spec — CLP01 is the payer's control + # number for the claim). Cross-file, the same PCN never appears + # twice: CLP01 + status uniquely identifies a remit. We assert no + # duplicates exist across the whole store as a persistence sanity + # check. + pcns = [r.payer_claim_control_number for r in all_remits] + assert len(pcns) == len(set(pcns)), "duplicate PCN across batches would be a persistence bug" diff --git a/backend/tests/test_api_parse_persists.py b/backend/tests/test_api_parse_persists.py index 81321a4..40544d1 100644 --- a/backend/tests/test_api_parse_persists.py +++ b/backend/tests/test_api_parse_persists.py @@ -103,3 +103,109 @@ def test_parse_835_response_includes_reconciliation_summary( assert "unmatched_claims" in rec assert "unmatched_remittances" in rec assert "skipped" in rec + + +# --------------------------------------------------------------------------- # +# Production 837P round-trip (parser → API → store → DB) +# --------------------------------------------------------------------------- # + + +# Path to the production 837P files from axiscare. These are NOT in git +# (.gitignore: ``docs/prodfiles/*/``) — the test is skipped if the directory +# is missing so it stays green in clean checkouts but exercises real data +# when ops has dropped files in for pipeline validation. +PRODFILE_DIR = Path(__file__).parent.parent.parent / "docs" / "prodfiles" / "837p-from-axiscare" + +# (filename, expected_control_number, expected_total_claims). Derived from +# /tmp/parse_prodfile.py on 2026-06-20 against the 7 files in PRODFILE_DIR. +# The first two files share control_number 991102984 — a deliberate replay. +EXPECTED_PRODFILES: list[tuple[str, str, int]] = [ + ("tp11525703-837P-20260618151119397-1of1.txt", "991102984", 141), + ("tp11525703-837P-20260618153339862-1of1.txt", "991102984", 141), + ("tp11525703-837P-20260618153343460-1of1.txt", "991102983", 97), + ("tp11525703-837P-20260618153346107-1of1.txt", "991102982", 28), + ("tp11525703-837P-20260618153349188-1of1.txt", "991102981", 69), + ("tp11525703-837P-20260618153354947-1of1.txt", "991102978", 149), + ("tp11525703-837P-20260618153358831-1of1.txt", "991102977", 99), +] + + +@pytest.mark.skipif( + not PRODFILE_DIR.is_dir(), + reason=f"production files not present at {PRODFILE_DIR} (gitignored)", +) +def test_prodfile_round_trip_persists_separately(client: TestClient): + """All 7 production 837P files from axiscare must parse, persist as + separate batches, and be retrievable by id. + + Exercises the full FastAPI path (parse → validate → store.add → DB) + against real production data — 3 distinct billing-provider NPIs, 2 + distinct transaction dates, 6 unique control numbers, 724 claims total. + """ + assert len(global_store.list()) == 0 + + # 1. POST every file. Each must return 200 with the expected envelope + # and summary — proves the parse + payer-config + validation gates + # work on real EDI, not just the synthetic fixture. + for filename, expected_ctrl, expected_claims in EXPECTED_PRODFILES: + path = PRODFILE_DIR / filename + with open(path, "rb") as f: + resp = client.post( + "/api/parse-837", + files={"file": (filename, f, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 200, f"{filename}: {resp.text}" + body = resp.json() + assert body["envelope"]["control_number"] == expected_ctrl, filename + assert body["envelope"]["sender_id"] == "11525703", filename + assert body["summary"]["total_claims"] == expected_claims, filename + assert body["summary"]["passed"] == expected_claims, filename + assert body["summary"]["failed"] == 0, filename + + # 2. Seven batches landed in the store (one per file). Confirms the + # store.add() path was hit for every upload and that the in-process + # state matches what the response said. + batches = global_store.list() + assert len(batches) == 7 + assert {b.kind for b in batches} == {"837p"} + assert {b.input_filename for b in batches} == {f for f, _, _ in EXPECTED_PRODFILES} + + # 3. Each persisted batch carries the full ParseResult: envelope, + # summary, claims. Also sanity-check real-world variety: 3 NPIs, + # 2 transaction dates (one each for 2026-06-11 vs 2026-06-17). + by_filename = {b.input_filename: b for b in batches} + total_claims = 0 + distinct_npis: set[str] = set() + distinct_dates: set[str] = set() + for filename, expected_ctrl, expected_claims in EXPECTED_PRODFILES: + rec = by_filename[filename] + assert rec.result.envelope.control_number == expected_ctrl, filename + assert rec.result.summary.total_claims == expected_claims, filename + assert len(rec.result.claims) == expected_claims, filename + total_claims += rec.result.summary.total_claims + for claim in rec.result.claims: + assert claim.billing_provider.npi, f"{filename} claim {claim.claim_id} missing NPI" + distinct_npis.add(claim.billing_provider.npi) + distinct_dates.add(str(claim.transaction_date)) + assert total_claims == 724 + assert len(distinct_npis) >= 3, f"expected ≥3 NPIs across the batch, got {distinct_npis}" + assert len(distinct_dates) >= 2, f"expected ≥2 dates, got {distinct_dates}" + + # 4. Read path: every batch is retrievable by id and round-trips with + # the same claim count that was just written. Covers global_store.get + # and the DB-backed lookup that powers /api/batches/{id}. + for rec in batches: + fetched = global_store.get(rec.id) + assert fetched is not None, rec.id + assert fetched.id == rec.id + assert len(fetched.result.claims) == rec.result.summary.total_claims + assert fetched.result.envelope.control_number == rec.result.envelope.control_number + + # 5. Replay sanity: the first two files share control_number 991102984 + # but were persisted as TWO distinct batches (no implicit dedup). + # Documents the current behavior; flip the assertion if/when a + # duplicate-control-number policy is added. + ctrl_991102984 = [b for b in batches if b.result.envelope.control_number == "991102984"] + assert len(ctrl_991102984) == 2 + assert len({b.id for b in ctrl_991102984}) == 2 diff --git a/backend/tests/test_db_models.py b/backend/tests/test_db_models.py index 7129bd0..f4002e2 100644 --- a/backend/tests/test_db_models.py +++ b/backend/tests/test_db_models.py @@ -7,7 +7,6 @@ from datetime import date, datetime, timezone from decimal import Decimal import pytest -from sqlalchemy.exc import IntegrityError from cyclone import db from cyclone.db import ActivityEvent, Base, Batch, CasAdjustment, Claim, ClaimState, Match, Remittance @@ -240,8 +239,13 @@ def test_remittance_raw_json_round_trips_dict(): assert loaded.raw_json == {"x12_segments": ["CLP", "NM1", "SVC"], "version": "005010X221A1"} -def test_remittance_unique_constraint_rejects_duplicate_pcn(): - """UNIQUE (batch_id, payer_claim_control_number) blocks duplicates.""" +def test_remittance_allows_duplicate_pcn_in_same_batch(): + """An 835 ERA can carry multiple CLP segments with the same + payer_claim_control_number (e.g. reversals re-referencing the + original PCN). Remittance identity is by ``id`` (CLP01), not by + (batch_id, PCN). This test documents the absence of the constraint + removed in migration 0003. + """ with db.SessionLocal()() as s: s.add(Batch( id="b1", kind="835", input_filename="x", @@ -260,8 +264,12 @@ def test_remittance_unique_constraint_rejects_duplicate_pcn(): status_code="2", received_at=datetime(2026, 6, 19, tzinfo=timezone.utc), )) - with pytest.raises(IntegrityError): - s.commit() + s.commit() # no IntegrityError — duplicates are allowed + + with db.SessionLocal()() as s: + rows = s.query(Remittance).filter(Remittance.batch_id == "b1").all() + assert {r.id for r in rows} == {"CLP-1", "CLP-2"} + assert {r.payer_claim_control_number for r in rows} == {"SAME"} def test_remittance_cascade_delete_drops_cas_adjustments(): diff --git a/backend/tests/test_store.py b/backend/tests/test_store.py index 17bbbc9..ca31a0c 100644 --- a/backend/tests/test_store.py +++ b/backend/tests/test_store.py @@ -45,9 +45,10 @@ def _make_claim_837(claim_id: str = "CLM-1", charge: str = "124.00") -> ClaimOut billing_provider=BillingProvider( name="Test", npi="1234567890", ), - # Member_id doubles as patient_control_number in the DB; the - # ``uq_claims_batch_pcn`` unique constraint requires it to be - # distinct within a batch, so derive it from the claim id. + # Member_id doubles as patient_control_number in the DB. Derive it + # from the claim id so two claims in the same batch are easy to tell + # apart in test assertions (no longer required by a unique constraint + # — see migration 0003 — but still a useful invariant for tests). subscriber=Subscriber( first_name="Jane", last_name="Doe", member_id=f"M-{claim_id}", ),