b5e27927e0
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.
375 lines
13 KiB
Python
375 lines
13 KiB
Python
"""Tests for ORM model definitions and CRUD."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
from datetime import date, datetime, timezone
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from cyclone import db
|
|
from cyclone.db import ActivityEvent, Base, Batch, CasAdjustment, Claim, ClaimState, Match, Remittance
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
db._reset_for_tests()
|
|
db.init_db()
|
|
|
|
|
|
def test_claim_state_enum_values():
|
|
assert ClaimState.SUBMITTED.value == "submitted"
|
|
assert ClaimState.RECEIVED.value == "received"
|
|
assert ClaimState.PAID.value == "paid"
|
|
assert ClaimState.PARTIAL.value == "partial"
|
|
assert ClaimState.DENIED.value == "denied"
|
|
assert ClaimState.RECONCILED.value == "reconciled"
|
|
assert ClaimState.REVERSED.value == "reversed"
|
|
# 7 values total.
|
|
assert len(list(ClaimState)) == 7
|
|
|
|
|
|
def test_create_and_query_batch():
|
|
with db.SessionLocal()() as s:
|
|
b = Batch(
|
|
id="b1",
|
|
kind="837p",
|
|
input_filename="test.txt",
|
|
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
|
|
totals_json={"total_claims": 1},
|
|
validation_json={"passed": True, "warnings": [], "errors": []},
|
|
raw_result_json={"_": "stub"},
|
|
)
|
|
s.add(b)
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
loaded = s.get(Batch, "b1")
|
|
assert loaded is not None
|
|
assert loaded.kind == "837p"
|
|
assert loaded.totals_json == {"total_claims": 1}
|
|
|
|
|
|
def test_create_and_query_claim_with_default_state():
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="837p", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
c = Claim(
|
|
id="CLM-1",
|
|
batch_id="b1",
|
|
patient_control_number="CLM-1",
|
|
service_date_from=date(2026, 6, 1),
|
|
service_date_to=date(2026, 6, 1),
|
|
charge_amount=Decimal("124.00"),
|
|
provider_npi="1234567890",
|
|
payer_id="SKCO0",
|
|
state=ClaimState.SUBMITTED,
|
|
)
|
|
s.add(c)
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
loaded = s.get(Claim, "CLM-1")
|
|
assert loaded is not None
|
|
assert loaded.state == ClaimState.SUBMITTED
|
|
assert loaded.charge_amount == Decimal("124.00")
|
|
assert loaded.service_date_from == date(2026, 6, 1)
|
|
|
|
|
|
def test_claim_uses_db_default_when_state_omitted():
|
|
"""The `state` column has a DB-side DEFAULT of 'submitted' (per migration
|
|
and ORM `default=`). When the caller omits `state=`, the loaded value
|
|
must be ClaimState.SUBMITTED, not None."""
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="837p", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
c = Claim(
|
|
id="CLM-1",
|
|
batch_id="b1",
|
|
patient_control_number="CLM-1",
|
|
charge_amount=Decimal("0"),
|
|
)
|
|
s.add(c)
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
loaded = s.get(Claim, "CLM-1")
|
|
assert loaded is not None
|
|
assert loaded.state == ClaimState.SUBMITTED
|
|
|
|
|
|
def test_state_before_reversal_round_trips():
|
|
"""`state_before_reversal` is nullable but stores a ClaimState enum.
|
|
Setting it to PAID must round-trip as ClaimState.PAID after commit+reload."""
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="837p", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
c = Claim(
|
|
id="CLM-1",
|
|
batch_id="b1",
|
|
patient_control_number="CLM-1",
|
|
charge_amount=Decimal("50.00"),
|
|
state_before_reversal=ClaimState.PAID,
|
|
)
|
|
s.add(c)
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
loaded = s.get(Claim, "CLM-1")
|
|
assert loaded is not None
|
|
assert loaded.state_before_reversal == ClaimState.PAID
|
|
|
|
|
|
def test_batch_cascade_delete_drops_claims():
|
|
"""Deleting a Batch must cascade-delete its claims (FK is ON DELETE CASCADE
|
|
in migration; ORM relationship uses cascade='all, delete-orphan' which makes
|
|
SQLAlchemy emit the child DELETE at flush time even without
|
|
PRAGMA foreign_keys=ON)."""
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="837p", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
s.add(Claim(
|
|
id="CLM-1",
|
|
batch_id="b1",
|
|
patient_control_number="CLM-1",
|
|
charge_amount=Decimal("0"),
|
|
))
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
batch = s.get(Batch, "b1")
|
|
assert batch is not None
|
|
s.delete(batch)
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
assert s.get(Batch, "b1") is None
|
|
assert s.get(Claim, "CLM-1") is None
|
|
|
|
|
|
def test_create_and_query_remittance():
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="835", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
r = Remittance(
|
|
id="CLP-1",
|
|
batch_id="b1",
|
|
payer_claim_control_number="CLP-1",
|
|
status_code="1",
|
|
total_charge=Decimal("124.00"),
|
|
total_paid=Decimal("124.00"),
|
|
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
service_date=date(2026, 6, 1),
|
|
is_reversal=False,
|
|
)
|
|
s.add(r)
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
loaded = s.get(Remittance, "CLP-1")
|
|
assert loaded is not None
|
|
assert loaded.status_code == "1"
|
|
assert loaded.total_paid == Decimal("124.00")
|
|
assert loaded.is_reversal is False
|
|
assert loaded.adjustment_amount == Decimal("0") # default
|
|
|
|
|
|
def test_cas_adjustment_aggregation():
|
|
"""Sum of CAS rows for a Remittance equals expected total."""
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="835", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
r = Remittance(
|
|
id="CLP-1", batch_id="b1",
|
|
payer_claim_control_number="CLP-1", status_code="1",
|
|
total_charge=Decimal("124.00"), total_paid=Decimal("62.00"),
|
|
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
)
|
|
s.add(r)
|
|
s.flush()
|
|
s.add_all([
|
|
CasAdjustment(remittance_id="CLP-1", group_code="CO", reason_code="45",
|
|
amount=Decimal("30.00")),
|
|
CasAdjustment(remittance_id="CLP-1", group_code="PR", reason_code="1",
|
|
amount=Decimal("32.00")),
|
|
])
|
|
s.commit()
|
|
|
|
from sqlalchemy import select, func
|
|
with db.SessionLocal()() as s:
|
|
total = s.execute(
|
|
select(func.sum(CasAdjustment.amount))
|
|
.where(CasAdjustment.remittance_id == "CLP-1")
|
|
).scalar_one()
|
|
assert total == Decimal("62.00")
|
|
|
|
|
|
def test_remittance_raw_json_round_trips_dict():
|
|
"""JSONText on Remittance.raw_json round-trips a dict, not a string."""
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="835", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
r = Remittance(
|
|
id="CLP-1", batch_id="b1", payer_claim_control_number="CLP-1",
|
|
status_code="1",
|
|
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
raw_json={"x12_segments": ["CLP", "NM1", "SVC"], "version": "005010X221A1"},
|
|
)
|
|
s.add(r)
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
loaded = s.get(Remittance, "CLP-1")
|
|
assert loaded.raw_json == {"x12_segments": ["CLP", "NM1", "SVC"], "version": "005010X221A1"}
|
|
|
|
|
|
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",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
s.add(Remittance(
|
|
id="CLP-1", batch_id="b1", payer_claim_control_number="SAME",
|
|
status_code="1",
|
|
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
s.add(Remittance(
|
|
id="CLP-2", batch_id="b1", payer_claim_control_number="SAME",
|
|
status_code="2",
|
|
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
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():
|
|
"""Deleting a Remittance drops its CAS rows via ORM cascade."""
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="835", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
r = Remittance(
|
|
id="CLP-1", batch_id="b1", payer_claim_control_number="CLP-1",
|
|
status_code="1", total_paid=Decimal("62.00"),
|
|
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
)
|
|
s.add(r)
|
|
s.flush()
|
|
s.add(CasAdjustment(remittance_id="CLP-1", group_code="CO",
|
|
reason_code="45", amount=Decimal("30.00")))
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
r = s.get(Remittance, "CLP-1")
|
|
s.delete(r)
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
from sqlalchemy import select
|
|
cas = s.execute(
|
|
select(CasAdjustment).where(CasAdjustment.remittance_id == "CLP-1")
|
|
).all()
|
|
assert len(cas) == 0
|
|
|
|
|
|
def test_create_match_and_activity_event():
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="835", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
s.add(Claim(
|
|
id="CLM-1", batch_id="b1", patient_control_number="CLM-1",
|
|
charge_amount=Decimal("124.00"), state=ClaimState.SUBMITTED,
|
|
))
|
|
s.add(Remittance(
|
|
id="CLP-1", batch_id="b1", payer_claim_control_number="CLM-1",
|
|
status_code="1", total_charge=Decimal("124.00"), total_paid=Decimal("124.00"),
|
|
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
m = Match(
|
|
claim_id="CLM-1", remittance_id="CLP-1",
|
|
strategy="auto", is_reversal=False,
|
|
matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
|
|
)
|
|
s.add(m)
|
|
s.flush()
|
|
s.add(ActivityEvent(
|
|
ts=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
|
|
kind="reconcile", batch_id="b1", claim_id="CLM-1",
|
|
payload_json={"matched": 1},
|
|
))
|
|
s.commit()
|
|
|
|
with db.SessionLocal()() as s:
|
|
loaded = s.get(Match, 1) # autoincrement id
|
|
assert loaded is not None
|
|
assert loaded.strategy == "auto"
|
|
assert loaded.is_reversal is False
|
|
|
|
events = s.query(ActivityEvent).all()
|
|
assert len(events) == 1
|
|
assert events[0].kind == "reconcile"
|
|
assert events[0].payload_json == {"matched": 1}
|
|
|
|
|
|
def test_match_multiple_rows_per_claim():
|
|
"""Spec-bug fix (T10): a Claim can have multiple Match rows
|
|
(original payment + reversal = audit trail). UNIQUE on claim_id
|
|
was removed; the constraint test that previously lived here
|
|
asserted the old, incorrect design.
|
|
"""
|
|
with db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="b1", kind="835", input_filename="x",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
))
|
|
s.add(Claim(id="CLM-1", batch_id="b1", patient_control_number="CLM-1",
|
|
charge_amount=Decimal("0"), state=ClaimState.SUBMITTED))
|
|
s.add(Remittance(id="CLP-1", batch_id="b1", payer_claim_control_number="x",
|
|
status_code="1", received_at=datetime.now(timezone.utc)))
|
|
s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y",
|
|
status_code="1", received_at=datetime.now(timezone.utc)))
|
|
s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto",
|
|
matched_at=datetime.now(timezone.utc)))
|
|
s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual",
|
|
matched_at=datetime.now(timezone.utc),
|
|
is_reversal=True, prior_claim_state=ClaimState.PAID))
|
|
# No IntegrityError — two Match rows per claim are now allowed.
|
|
s.commit()
|
|
|
|
rows = s.query(Match).filter(Match.claim_id == "CLM-1").all()
|
|
assert len(rows) == 2
|
|
assert {r.remittance_id for r in rows} == {"CLP-1", "CLP-2"}
|