feat(backend): 835 ERA parser with BPR/CLP/SVC balancing, CO Medicaid config, CLI, and FastAPI route
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TPID001 *260617*1937*^*00501*991102986*1*P*:~ST*835*991102986*005010X221A1~
|
||||
BPR*C*241.16*C*ACH*CCP*01*021000021*DA*123456789*81-1725341******20260617~
|
||||
TRN*1*TPID001*81-1725341~
|
||||
DTM*405*20260617~
|
||||
N1*PR*2*CO_TXIX*****XV*7912900843~
|
||||
N3*PO BOX 11000~
|
||||
N4*DENVER*CO*802110000~
|
||||
PER*BL*Customer Service*TE*8001234567*UR*hcpf.colorado.gov~
|
||||
N1*PE*2*TOC, Inc.*****XX*1881068062~
|
||||
N3*1100 East Main St*Suite A~
|
||||
N4*Montrose*CO*814014063~
|
||||
REF*TJ*123456789~
|
||||
LX*1~
|
||||
CLP*t991102986o1c1d*1*85.40*85.40*0.00*MC*t991102986o1c1d*12*1~
|
||||
CAS*CO*45*0.00~
|
||||
REF*CE*PLAN_A~
|
||||
NM1*QC*1*Balliache*Marianela~
|
||||
SVC*HC:T1019:U2*42.70*42.70*UN*6.00*20260602~
|
||||
DTM*472*20260602~
|
||||
SVC*HC:T1019:U2*42.70*42.70*UN*6.00*20260603~
|
||||
DTM*472*20260603~
|
||||
LX*2~
|
||||
CLP*t991102986o1c2d*1*155.76*155.76*0.00*MC*t991102986o1c2d*12*1~
|
||||
CAS*CO*45*0.00~
|
||||
REF*CE*PLAN_A~
|
||||
QTY*CA*4~
|
||||
NM1*QC*1*Barella*Victoria~
|
||||
SVC*HC:S5130:U2*38.94*38.94*UN*6.00*20260602~
|
||||
DTM*472*20260602~
|
||||
REF*0K*PLAN_A~
|
||||
SVC*HC:S5130:U2*38.94*38.94*UN*6.00*20260603~
|
||||
DTM*472*20260603~
|
||||
REF*0K*PLAN_A~
|
||||
SVC*HC:S5130:U2*38.94*38.94*UN*6.00*20260604~
|
||||
DTM*472*20260604~
|
||||
REF*0K*PLAN_A~
|
||||
SVC*HC:S5130:U2*38.94*38.94*UN*6.00*20260605~
|
||||
DTM*472*20260605~
|
||||
REF*0K*PLAN_A~
|
||||
SE*45*991102986~
|
||||
GE*1*991102986~
|
||||
IEA*1*991102986~
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TPID001 *260617*1937*^*00501*991102984*1*P*:~ST*835*991102984*005010X221A1~
|
||||
BPR*C*85.00*C*ACH*CCP*01*021000021*DA*123456789*81-1725341******20260617~
|
||||
TRN*1*TPID001*81-1725341~
|
||||
DTM*405*20260617~
|
||||
N1*PR*2*CO_TXIX*****XV*7912900843~
|
||||
N3*PO BOX 11000~
|
||||
N4*DENVER*CO*802110000~
|
||||
PER*BL*Customer Service*TE*8001234567*UR*hcpf.colorado.gov~
|
||||
N1*PE*2*TOC, Inc.*****XX*1881068062~
|
||||
N3*1100 East Main St*Suite A~
|
||||
N4*Montrose*CO*814014063~
|
||||
LX*1~
|
||||
CLP*CLM001*1*100.00*85.00*15.00*MC*CLM001*12*1~
|
||||
CAS*CO*45*15.00~
|
||||
SVC*HC:S5130:U2*60.00*50.00*7.73*UN*20260602~
|
||||
DTM*472*20260602~
|
||||
SVC*HC:S5130:U2*40.00*35.00*7.73*UN*20260603~
|
||||
DTM*472*20260603~
|
||||
SE*22*991102984~
|
||||
GE*1*991102984~
|
||||
IEA*1*991102984~
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TPID001 *260617*1937*^*00501*991102985*1*P*:~ST*835*991102985*005010X221A1~
|
||||
BPR*C*85.50*C*ACH*CCP*01*021000021*DA*123456789*81-1725341******20260617~
|
||||
TRN*1*TPID001*81-1725341~
|
||||
DTM*405*20260617~
|
||||
N1*PR*2*CO_TXIX*****XV*7912900843~
|
||||
N3*PO BOX 11000~
|
||||
N4*DENVER*CO*802110000~
|
||||
PER*BL*Customer Service*TE*8001234567*UR*hcpf.colorado.gov~
|
||||
N1*PE*2*TOC, Inc.*****XX*1881068062~
|
||||
N3*1100 East Main St*Suite A~
|
||||
N4*Montrose*CO*814014063~
|
||||
LX*1~
|
||||
CLP*CLM001*1*100.00*85.00*15.00*MC*CLM001*12*1~
|
||||
CAS*CO*45*15.00~
|
||||
SVC*HC:S5130:U2*60.00*50.00*7.73*UN*20260602~
|
||||
DTM*472*20260602~
|
||||
SVC*HC:S5130:U2*40.00*35.00*7.73*UN*20260603~
|
||||
DTM*472*20260603~
|
||||
SE*22*991102985~
|
||||
GE*1*991102985~
|
||||
IEA*1*991102985~
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for the FastAPI surface in ``cyclone.api`` for the 835 ERA endpoint."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.api import app
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
|
||||
UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# JSON response path
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_835_endpoint_returns_json(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "envelope" in body
|
||||
assert "financial_info" in body
|
||||
assert "trace" in body
|
||||
assert "payer" in body
|
||||
assert "payee" in body
|
||||
assert "claims" in body and len(body["claims"]) == 2
|
||||
assert "summary" in body
|
||||
assert body["summary"]["total_claims"] == 2
|
||||
assert body["summary"]["passed"] == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# NDJSON streaming path
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_835_endpoint_streams_ndjson(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
||||
|
||||
lines = list(resp.iter_lines())
|
||||
# 1 envelope + 1 financial_info + 1 trace + 1 payer + 1 payee + 2 claims + 1 summary = 8
|
||||
assert len(lines) == 8
|
||||
|
||||
parsed = [json.loads(line) for line in lines]
|
||||
assert parsed[0]["type"] == "envelope"
|
||||
assert parsed[1]["type"] == "financial_info"
|
||||
assert parsed[2]["type"] == "trace"
|
||||
assert parsed[3]["type"] == "payer"
|
||||
assert parsed[4]["type"] == "payee"
|
||||
assert parsed[5]["type"] == "claim_payment"
|
||||
assert parsed[6]["type"] == "claim_payment"
|
||||
assert parsed[7]["type"] == "summary"
|
||||
|
||||
# When include_raw_segments defaults to True, each claim carries raw segments.
|
||||
for obj in parsed[5:7]:
|
||||
assert "raw_segments" in obj["data"]
|
||||
assert isinstance(obj["data"]["raw_segments"], list)
|
||||
|
||||
# Summary numbers match the JSON path.
|
||||
assert parsed[7]["data"]["total_claims"] == 2
|
||||
assert parsed[7]["data"]["passed"] == 1
|
||||
|
||||
|
||||
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835?include_raw_segments=false",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
||||
|
||||
claims = [
|
||||
json.loads(line)
|
||||
for line in resp.iter_lines()
|
||||
if json.loads(line)["type"] == "claim_payment"
|
||||
]
|
||||
assert len(claims) == 2
|
||||
for c in claims:
|
||||
assert c["data"]["raw_segments"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation / error paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_835_endpoint_rejects_missing_file(client: TestClient):
|
||||
# FastAPI's `File(...)` (no default) → 422 Unprocessable Entity.
|
||||
resp = client.post("/api/parse-835")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_parse_835_endpoint_handles_payer_query_param(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
for payer in ("co_medicaid_835", "generic_835"):
|
||||
resp = client.post(
|
||||
f"/api/parse-835?payer={payer}",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, (payer, resp.text)
|
||||
body = resp.json()
|
||||
assert body["summary"]["total_claims"] == 2
|
||||
assert body["summary"]["passed"] == 1
|
||||
|
||||
|
||||
def test_parse_835_endpoint_rejects_unknown_payer(client: TestClient):
|
||||
text = FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835?payer=does_not_exist",
|
||||
files={"file": ("co_medicaid_835.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_parse_835_endpoint_unbalanced_returns_422(client: TestClient):
|
||||
text = UNBALANCED.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-835",
|
||||
files={"file": ("unbalanced_835.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
body = resp.json()
|
||||
assert body["summary"]["failed"] == 1
|
||||
# The R835_BAL_BPR_vs_CLP04 rule should have fired.
|
||||
assert any(
|
||||
issue["rule"] == "R835_BAL_BPR_vs_CLP04"
|
||||
for issue in body["validation"]["errors"]
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CORS
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_cors_headers_present_for_835(client: TestClient):
|
||||
resp = client.options(
|
||||
"/api/parse-835",
|
||||
headers={
|
||||
"Origin": "http://localhost:5173",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||
assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests for the `parse-835` Click subcommand."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from cyclone.cli import main
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "minimal_835.txt"
|
||||
UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt"
|
||||
|
||||
|
||||
def test_cli_parse_835_writes_outputs(tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path)])
|
||||
assert result.exit_code == 0, result.output
|
||||
written = list(tmp_path.glob("payout-*.json"))
|
||||
assert len(written) == 1
|
||||
summary = json.loads((tmp_path / "summary.json").read_text())
|
||||
assert summary["total_claims"] == 1
|
||||
assert summary["passed"] == 1
|
||||
assert summary["failed"] == 0
|
||||
|
||||
|
||||
def test_cli_parse_835_strict_promotes_warnings(tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
# Strict mode: even warnings cause failed=1. Minimal fixture is fully
|
||||
# passing so this is a smoke test that the flag is wired.
|
||||
result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path), "--strict"])
|
||||
assert result.exit_code == 0
|
||||
summary = json.loads((tmp_path / "summary.json").read_text())
|
||||
assert summary["passed"] == 1
|
||||
|
||||
|
||||
def test_cli_parse_835_no_raw_segments(tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path), "--no-raw-segments"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(next(tmp_path.glob("payout-*.json")).read_text())
|
||||
# The claim lives under the "claim" key in each payout file.
|
||||
assert data["claim"]["raw_segments"] == []
|
||||
|
||||
|
||||
def test_cli_parse_835_unbalanced_strict_fails(tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["parse-835", str(UNBALANCED), "--output-dir", str(tmp_path), "--strict"])
|
||||
assert result.exit_code == 0, result.output
|
||||
summary = json.loads((tmp_path / "summary.json").read_text())
|
||||
assert summary["passed"] == 0
|
||||
assert summary["failed"] == 1
|
||||
# R835_BAL_BPR_vs_CLP04 is the rule that fired.
|
||||
assert "R835_BAL_BPR_vs_CLP04" in summary["issues_by_rule"]
|
||||
|
||||
|
||||
def test_cli_parse_835_unknown_payer_errors(tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path), "--payer", "no_such_payer"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_cli_parse_835_missing_file_exits_2(tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["parse-835", "/no/such/file.txt", "--output-dir", str(tmp_path)])
|
||||
assert result.exit_code == 2
|
||||
|
||||
|
||||
def test_cli_parse_835_generic_payer(tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path), "--payer", "generic_835"])
|
||||
assert result.exit_code == 0, result.output
|
||||
summary = json.loads((tmp_path / "summary.json").read_text())
|
||||
assert summary["passed"] == 1
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Integration test: load the realistic CO Medicaid 835 fixture and validate it end-to-end."""
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from cyclone.parsers.parse_835 import parse
|
||||
from cyclone.parsers.payer import PayerConfig835
|
||||
from cyclone.parsers.validator_835 import validate
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
|
||||
|
||||
|
||||
def test_co_fixture_two_claims_pass():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
report = validate(result, cfg)
|
||||
assert result.summary.total_claims == 2
|
||||
assert report.passed is True
|
||||
assert report.errors == []
|
||||
|
||||
|
||||
def test_first_claim_has_two_service_lines():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
c1 = result.claims[0]
|
||||
assert c1.payer_claim_control_number == "t991102986o1c1d"
|
||||
assert len(c1.service_payments) == 2
|
||||
assert c1.service_payments[0].procedure_code == "T1019"
|
||||
assert c1.service_payments[0].modifiers == ["U2"]
|
||||
assert c1.service_payments[1].service_date is not None
|
||||
|
||||
|
||||
def test_second_claim_has_four_service_lines():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
c2 = result.claims[1]
|
||||
assert c2.payer_claim_control_number == "t991102986o1c2d"
|
||||
assert len(c2.service_payments) == 4
|
||||
assert c2.per_diem_covered_days == Decimal("4")
|
||||
|
||||
|
||||
def test_payer_and_payee_match_co_config():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
assert result.payer.name == "CO_TXIX"
|
||||
assert result.payer.id == "7912900843"
|
||||
assert result.financial_info.payer_tax_id in {"81-1725341", "84-0644739"}
|
||||
assert result.payee.npi == "1881068062"
|
||||
assert result.payee.name == "TOC, Inc."
|
||||
|
||||
|
||||
def test_bpr_vs_clp_balance_holds():
|
||||
"""BPR02 (241.16) = CLP04 sum (85.40 + 155.76)."""
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
clp_sum = sum((c.total_paid for c in result.claims), start=Decimal("0"))
|
||||
assert result.financial_info.paid_amount == clp_sum
|
||||
assert result.financial_info.paid_amount == Decimal("241.16")
|
||||
|
||||
|
||||
def test_per_claim_clp_vs_svc_balance_holds():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
for c in result.claims:
|
||||
svc_sum = sum((sp.payment for sp in c.service_payments), start=Decimal("0"))
|
||||
assert c.total_paid == svc_sum, f"claim {c.payer_claim_control_number}: CLP04={c.total_paid} != sum(SVC03)={svc_sum}"
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for the 835 ERA Pydantic models."""
|
||||
import json
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from cyclone.parsers.models import Address, BatchSummary, Envelope
|
||||
from cyclone.parsers.models_835 import (
|
||||
ClaimAdjustment,
|
||||
ClaimPayment,
|
||||
FinancialInfo,
|
||||
ParseResult835,
|
||||
Payer835,
|
||||
Payee835,
|
||||
ReassociationTrace,
|
||||
ServicePayment,
|
||||
claim_status_label,
|
||||
)
|
||||
|
||||
|
||||
def _build_result() -> ParseResult835:
|
||||
return ParseResult835(
|
||||
envelope=Envelope(
|
||||
sender_id="COMEDASSISTPROG",
|
||||
receiver_id="TPID001",
|
||||
control_number="991102984",
|
||||
transaction_date=date(2026, 6, 17),
|
||||
implementation_guide="005010X221A1",
|
||||
),
|
||||
financial_info=FinancialInfo(
|
||||
handling_code="C",
|
||||
paid_amount=Decimal("85.00"),
|
||||
credit_debit_flag="C",
|
||||
payment_method="ACH",
|
||||
payer_tax_id="81-1725341",
|
||||
payment_date=date(2026, 6, 17),
|
||||
),
|
||||
trace=ReassociationTrace(
|
||||
trace_type_code="1",
|
||||
trace_number="991102984",
|
||||
originating_company_id="TPID001",
|
||||
payer_id="81-1725341",
|
||||
),
|
||||
payer=Payer835(
|
||||
name="CO_TXIX",
|
||||
id="7912900843",
|
||||
contact_url="hcpf.colorado.gov",
|
||||
address=Address(line1="PO BOX 11000", city="DENVER", state="CO", zip="802110000"),
|
||||
),
|
||||
payee=Payee835(
|
||||
name="TOC, Inc.",
|
||||
npi="1881068062",
|
||||
address=Address(line1="1100 East Main St", line2="Suite A", city="Montrose", state="CO", zip="814014063"),
|
||||
),
|
||||
claims=[
|
||||
ClaimPayment(
|
||||
payer_claim_control_number="CLM001",
|
||||
status_code="1",
|
||||
status_label="Processed as Primary",
|
||||
total_charge=Decimal("100.00"),
|
||||
total_paid=Decimal("85.00"),
|
||||
patient_responsibility=Decimal("15.00"),
|
||||
claim_filing_indicator="MC",
|
||||
original_claim_id="CLM001",
|
||||
facility_type="12",
|
||||
frequency_code="1",
|
||||
per_diem_covered_days=None,
|
||||
ref_benefit_plan=None,
|
||||
service_payments=[
|
||||
ServicePayment(
|
||||
line_number=1,
|
||||
procedure_qualifier="HC",
|
||||
procedure_code="S5130",
|
||||
modifiers=["U2"],
|
||||
charge=Decimal("60.00"),
|
||||
payment=Decimal("50.00"),
|
||||
units=Decimal("7.73"),
|
||||
unit_type="UN",
|
||||
service_date=date(2026, 6, 2),
|
||||
adjustments=[
|
||||
ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("10.00"), quantity=None),
|
||||
],
|
||||
ref_benefit_plan=None,
|
||||
),
|
||||
],
|
||||
raw_segments=[["CLP", "CLM001", "1", "100.00", "85.00"]],
|
||||
)
|
||||
],
|
||||
summary=BatchSummary(
|
||||
input_file="f",
|
||||
control_number="991102984",
|
||||
transaction_date=date(2026, 6, 17),
|
||||
total_claims=1,
|
||||
passed=1,
|
||||
failed=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_financial_info_decimal_paid_amount():
|
||||
f = FinancialInfo(handling_code="C", paid_amount=Decimal("85.00"), credit_debit_flag="C")
|
||||
assert f.paid_amount == Decimal("85.00")
|
||||
assert f.payer_tax_id is None
|
||||
assert f.payment_date is None
|
||||
|
||||
|
||||
def test_reassociation_trace_required():
|
||||
t = ReassociationTrace(trace_type_code="1", trace_number="X", originating_company_id="Y")
|
||||
assert t.payer_id is None
|
||||
|
||||
|
||||
def test_payer835_optional_fields():
|
||||
p = Payer835(name="X")
|
||||
assert p.id is None
|
||||
assert p.address is None
|
||||
assert p.contact_url is None
|
||||
|
||||
|
||||
def test_payee835_required_npi():
|
||||
p = Payee835(name="TOC", npi="1881068062")
|
||||
assert p.npi == "1881068062"
|
||||
|
||||
|
||||
def test_claim_adjustment_quantity_optional():
|
||||
a = ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("10.00"))
|
||||
assert a.quantity is None
|
||||
|
||||
|
||||
def test_service_payment_modifiers_default_empty():
|
||||
sp = ServicePayment(
|
||||
line_number=1,
|
||||
procedure_qualifier="HC",
|
||||
procedure_code="99213",
|
||||
charge=Decimal("100.00"),
|
||||
payment=Decimal("85.00"),
|
||||
)
|
||||
assert sp.modifiers == []
|
||||
assert sp.adjustments == []
|
||||
|
||||
|
||||
def test_claim_payment_status_label_decoded():
|
||||
c = ClaimPayment(
|
||||
payer_claim_control_number="X",
|
||||
status_code="1",
|
||||
total_charge=Decimal("100"),
|
||||
total_paid=Decimal("85"),
|
||||
)
|
||||
assert c.status_label == "Processed as Primary"
|
||||
assert claim_status_label("4") == "Denied"
|
||||
assert claim_status_label("25") == "Predetermination Pricing Only"
|
||||
assert claim_status_label("99") is None # unknown → None
|
||||
|
||||
|
||||
def test_parse_result835_date_serializes_to_iso():
|
||||
r = _build_result()
|
||||
blob = r.model_dump()
|
||||
assert blob["envelope"]["transaction_date"] == "2026-06-17"
|
||||
assert blob["financial_info"]["payment_date"] == "2026-06-17"
|
||||
assert blob["claims"][0]["service_payments"][0]["service_date"] == "2026-06-02"
|
||||
|
||||
|
||||
def test_parse_result835_full_round_trip():
|
||||
r = _build_result()
|
||||
blob = json.loads(r.model_dump_json())
|
||||
assert blob["envelope"]["control_number"] == "991102984"
|
||||
assert blob["payer"]["name"] == "CO_TXIX"
|
||||
assert blob["payee"]["npi"] == "1881068062"
|
||||
assert blob["claims"][0]["payer_claim_control_number"] == "CLM001"
|
||||
# Round-trip back to a model
|
||||
r2 = ParseResult835.model_validate(blob)
|
||||
assert r2.claims[0].total_paid == Decimal("85.00")
|
||||
assert r2.payer.contact_url == "hcpf.colorado.gov"
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for the 835 ERA parser orchestrator."""
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
from cyclone.parsers.parse_835 import parse
|
||||
from cyclone.parsers.payer import PayerConfig835
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "minimal_835.txt"
|
||||
|
||||
|
||||
def test_parse_minimal_fixture_returns_one_claim():
|
||||
text = FIXTURE.read_text()
|
||||
result = parse(text, PayerConfig835.co_medicaid_835())
|
||||
assert len(result.claims) == 1
|
||||
c = result.claims[0]
|
||||
assert c.payer_claim_control_number == "CLM001"
|
||||
assert c.status_code == "1"
|
||||
assert c.status_label == "Processed as Primary"
|
||||
assert c.total_charge == 100.00
|
||||
assert c.total_paid == 85.00
|
||||
assert c.patient_responsibility == 15.00
|
||||
assert c.claim_filing_indicator == "MC"
|
||||
assert c.facility_type == "12"
|
||||
assert c.frequency_code == "1"
|
||||
|
||||
|
||||
def test_parse_envelope_captured():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
assert result.envelope is not None
|
||||
assert result.envelope.control_number == "991102984"
|
||||
assert result.envelope.transaction_date == date(2026, 6, 17)
|
||||
assert result.envelope.sender_id == "COMEDASSISTPROG"
|
||||
assert result.envelope.implementation_guide == "005010X221A1"
|
||||
|
||||
|
||||
def test_parse_financial_info():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
f = result.financial_info
|
||||
assert f.handling_code == "C"
|
||||
assert f.paid_amount == 85.00
|
||||
assert f.credit_debit_flag == "C"
|
||||
assert f.payment_method == "ACH"
|
||||
assert f.payer_tax_id == "81-1725341"
|
||||
assert f.payment_date == date(2026, 6, 17)
|
||||
|
||||
|
||||
def test_parse_trace():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
t = result.trace
|
||||
assert t.trace_type_code == "1"
|
||||
# X12 standard: TRN02 = Reference Identification (trace number),
|
||||
# TRN03 = Originating Company Identifier. The CO guide labels
|
||||
# TRN03 as the Payer Tax ID, so for CO Medicaid 835s the
|
||||
# originating_company_id is "81-1725341" and the trace number is
|
||||
# the provider's TPID.
|
||||
assert t.trace_number == "TPID001"
|
||||
assert t.originating_company_id == "81-1725341"
|
||||
assert t.payer_id is None # TRN04 is not present in this fixture
|
||||
|
||||
|
||||
def test_parse_payer_and_payee():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
p = result.payer
|
||||
assert p.name == "CO_TXIX"
|
||||
assert p.id == "7912900843"
|
||||
assert p.contact_url == "hcpf.colorado.gov"
|
||||
assert p.address is not None
|
||||
assert p.address.city == "DENVER"
|
||||
pe = result.payee
|
||||
assert pe.name == "TOC, Inc."
|
||||
assert pe.npi == "1881068062"
|
||||
assert pe.address is not None
|
||||
assert pe.address.zip == "814014063"
|
||||
|
||||
|
||||
def test_parse_claim_has_two_service_lines():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
c = result.claims[0]
|
||||
assert len(c.service_payments) == 2
|
||||
s1, s2 = c.service_payments
|
||||
assert s1.line_number == 1
|
||||
assert s1.procedure_code == "S5130"
|
||||
assert s1.procedure_qualifier == "HC"
|
||||
assert s1.modifiers == ["U2"]
|
||||
assert s1.payment == 50.00
|
||||
assert s1.service_date == date(2026, 6, 2)
|
||||
assert s2.line_number == 2
|
||||
assert s2.payment == 35.00
|
||||
assert s2.service_date == date(2026, 6, 3)
|
||||
|
||||
|
||||
def test_parse_summary_counts():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
assert result.summary.total_claims == 1
|
||||
assert result.summary.input_file == ""
|
||||
# The orchestrator sets passed/failed from the validation report.
|
||||
# Without running validation it remains 0 — the CLI / API does that.
|
||||
assert result.summary.passed == 0
|
||||
|
||||
|
||||
def test_parse_preserves_raw_segments():
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835())
|
||||
c = result.claims[0]
|
||||
assert any(s[0] == "CLP" for s in c.raw_segments)
|
||||
assert any(s[0] == "SVC" for s in c.raw_segments)
|
||||
|
||||
|
||||
def test_parse_uses_generic_config():
|
||||
"""Generic config shouldn't suppress parser errors but accepts any payer."""
|
||||
result = parse(FIXTURE.read_text(), PayerConfig835.generic_835())
|
||||
assert len(result.claims) == 1
|
||||
|
||||
|
||||
def test_parse_no_bpr_raises():
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TPID001 *260617*1937*^*00501*991102990*1*P*:~"
|
||||
"ST*835*991102990*005010X221A1~"
|
||||
"SE*1*991102990~"
|
||||
"GE*1*991102990~"
|
||||
"IEA*1*991102990~"
|
||||
)
|
||||
with pytest.raises(CycloneParseError, match="No BPR"):
|
||||
parse(text, PayerConfig835.co_medicaid_835())
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Tests for the 835 ERA validator rules."""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.parsers.models import Address, BatchSummary, Envelope
|
||||
from cyclone.parsers.models_835 import (
|
||||
ClaimAdjustment,
|
||||
ClaimPayment,
|
||||
FinancialInfo,
|
||||
ParseResult835,
|
||||
Payer835,
|
||||
Payee835,
|
||||
ReassociationTrace,
|
||||
ServicePayment,
|
||||
)
|
||||
from cyclone.parsers.payer import PayerConfig835
|
||||
from cyclone.parsers.validator_835 import validate
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Builders
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _build_result(**overrides) -> ParseResult835:
|
||||
"""Build a passing CO Medicaid 835 result; override fields to break rules."""
|
||||
financial = FinancialInfo(
|
||||
handling_code="C",
|
||||
paid_amount=Decimal("85.00"),
|
||||
credit_debit_flag="C",
|
||||
payment_method="ACH",
|
||||
payer_tax_id="81-1725341",
|
||||
payment_date=date(2026, 6, 17),
|
||||
)
|
||||
trace = ReassociationTrace(
|
||||
trace_type_code="1",
|
||||
trace_number="TPID001",
|
||||
originating_company_id="TPID001",
|
||||
payer_id="81-1725341",
|
||||
)
|
||||
payer = Payer835(
|
||||
name="CO_TXIX",
|
||||
id="7912900843",
|
||||
address=Address(line1="PO BOX 11000", city="DENVER", state="CO", zip="802110000"),
|
||||
)
|
||||
payee = Payee835(
|
||||
name="TOC, Inc.",
|
||||
npi="1881068062",
|
||||
address=Address(line1="1100 East Main St", city="Montrose", state="CO", zip="814014063"),
|
||||
)
|
||||
claim = ClaimPayment(
|
||||
payer_claim_control_number="CLM001",
|
||||
status_code="1",
|
||||
status_label="Processed as Primary",
|
||||
total_charge=Decimal("100.00"),
|
||||
total_paid=Decimal("85.00"),
|
||||
patient_responsibility=Decimal("15.00"),
|
||||
claim_filing_indicator="MC",
|
||||
original_claim_id="CLM001",
|
||||
facility_type="12",
|
||||
frequency_code="1",
|
||||
per_diem_covered_days=None,
|
||||
ref_benefit_plan=None,
|
||||
service_payments=[
|
||||
ServicePayment(
|
||||
line_number=1,
|
||||
procedure_qualifier="HC",
|
||||
procedure_code="S5130",
|
||||
modifiers=["U2"],
|
||||
charge=Decimal("60.00"),
|
||||
payment=Decimal("50.00"),
|
||||
units=Decimal("7.73"),
|
||||
unit_type="UN",
|
||||
service_date=date(2026, 6, 2),
|
||||
adjustments=[],
|
||||
ref_benefit_plan=None,
|
||||
),
|
||||
ServicePayment(
|
||||
line_number=2,
|
||||
procedure_qualifier="HC",
|
||||
procedure_code="S5130",
|
||||
modifiers=["U2"],
|
||||
charge=Decimal("40.00"),
|
||||
payment=Decimal("35.00"),
|
||||
units=Decimal("7.73"),
|
||||
unit_type="UN",
|
||||
service_date=date(2026, 6, 3),
|
||||
adjustments=[],
|
||||
ref_benefit_plan=None,
|
||||
),
|
||||
],
|
||||
raw_segments=[],
|
||||
)
|
||||
summary = BatchSummary(input_file="f", total_claims=1, passed=1, failed=0)
|
||||
result = ParseResult835(
|
||||
envelope=Envelope(
|
||||
sender_id="COMEDASSISTPROG",
|
||||
receiver_id="TPID001",
|
||||
control_number="991102984",
|
||||
transaction_date=date(2026, 6, 17),
|
||||
),
|
||||
financial_info=financial,
|
||||
trace=trace,
|
||||
payer=payer,
|
||||
payee=payee,
|
||||
claims=[claim],
|
||||
summary=summary,
|
||||
)
|
||||
if overrides:
|
||||
result = result.model_copy(update=overrides)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sanity: balanced claim passes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_balanced_claim_passes_all_rules():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
report = validate(_build_result(), cfg)
|
||||
assert report.passed is True
|
||||
assert report.errors == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BPR rules
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_r835_bpr01_handling_code_disallowed():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"handling_code": "Z"}))
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_BPR01_handling_code_allowed" for i in report.errors)
|
||||
assert report.passed is False
|
||||
|
||||
|
||||
def test_r835_bpr02_positive():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"paid_amount": Decimal("0.00")}))
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_BPR02_positive" for i in report.errors)
|
||||
|
||||
|
||||
def test_r835_bal_bpr_vs_clp04_unbalanced():
|
||||
"""BPR02 (85.00) must equal sum of CLP04 (85.00). Off by 0.50 → error."""
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"paid_amount": Decimal("85.50")}))
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_BAL_BPR_vs_CLP04" for i in report.errors)
|
||||
assert report.passed is False
|
||||
|
||||
|
||||
def test_r835_bal_bpr_vs_clp04_within_tolerance_passes():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"paid_amount": Decimal("85.005")}))
|
||||
report = validate(result, cfg)
|
||||
# 0.005 difference is below tolerance (0.01) — no error.
|
||||
assert not any(i.rule == "R835_BAL_BPR_vs_CLP04" for i in report.errors)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLP rules
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_r835_bal_clp04_vs_svc03_unbalanced():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
# The default fixture has CLP04=85.00 and SVC03 sum = 50+35 = 85.00.
|
||||
# Change one SVC payment to 40 → sum = 40+35 = 75, off by 10.
|
||||
base = _build_result()
|
||||
svc = base.claims[0].service_payments[0].model_copy(update={"payment": Decimal("40.00")})
|
||||
result = base.model_copy(update={"claims": [base.claims[0].model_copy(update={"service_payments": [svc, base.claims[0].service_payments[1]]})]})
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_BAL_CLP04_vs_SVC03" for i in report.errors)
|
||||
assert "CLM001" in [i.message for i in report.errors if i.rule == "R835_BAL_CLP04_vs_SVC03"][0]
|
||||
|
||||
|
||||
def test_r835_clp01_present():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(claims=[_build_result().claims[0].model_copy(update={"payer_claim_control_number": ""})])
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_CLP01_present" for i in report.errors)
|
||||
|
||||
|
||||
def test_r835_clp02_status_disallowed():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(claims=[_build_result().claims[0].model_copy(update={"status_code": "9", "status_label": None})])
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_CLP02_status_allowed" for i in report.errors)
|
||||
|
||||
|
||||
def test_r835_clp03_present():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(claims=[_build_result().claims[0].model_copy(update={"total_charge": Decimal("0.00")})])
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_CLP03_present" for i in report.errors)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Payer / payee rules
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_r835_payer_tax_id_mismatch_warning():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"payer_tax_id": "99-9999999"}))
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_PAYER_TAX_ID_matches" and i.severity == "warning" for i in report.warnings)
|
||||
# Warning only — report still passes.
|
||||
assert report.passed is True
|
||||
|
||||
|
||||
def test_r835_payer_tax_id_skipped_when_set_empty():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"payer_tax_id": None}))
|
||||
report = validate(result, cfg)
|
||||
# When BPR10 is empty, the rule is skipped silently (per spec wording).
|
||||
assert not any(i.rule == "R835_PAYER_TAX_ID_matches" for i in report.errors + report.warnings)
|
||||
|
||||
|
||||
def test_r835_payer_health_plan_id_mismatch_warning():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(payer=_build_result().payer.model_copy(update={"id": "12345"}))
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_PAYER_HEALTH_PLAN_ID_matches" and i.severity == "warning" for i in report.warnings)
|
||||
assert report.passed is True
|
||||
|
||||
|
||||
def test_r835_payee_npi_format():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(payee=_build_result().payee.model_copy(update={"npi": "12345"}))
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_PAYEE_NPI_format" for i in report.errors)
|
||||
assert report.passed is False
|
||||
|
||||
|
||||
def test_r835_trn02_originating_company_id_present():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
result = _build_result(trace=_build_result().trace.model_copy(update={"originating_company_id": ""}))
|
||||
report = validate(result, cfg)
|
||||
assert any(i.rule == "R835_REGN01_trn02_present" for i in report.errors)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Optional: warnings don't fail the report
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_warnings_do_not_set_passed_false():
|
||||
cfg = PayerConfig835.co_medicaid_835()
|
||||
# A wrong payer tax id is a warning; report should still pass.
|
||||
result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"payer_tax_id": "BAD"}))
|
||||
report = validate(result, cfg)
|
||||
assert report.passed is True
|
||||
assert any(i.severity == "warning" for i in report.warnings)
|
||||
Reference in New Issue
Block a user