"""Tests for ``CycloneStore.get_claim_detail`` (SP4 Task 1). The method is the read path behind the ``GET /api/claims/{id}`` endpoint that drives the per-claim detail drawer. The response shape is locked in ``docs/superpowers/specs/2026-06-20-cyclone-claim-drawer-design.md`` §3.2. What this exercises: * ``test_get_claim_detail_returns_none_for_missing`` — a claim id that isn't in the DB must return ``None`` (the API maps that to 404). * ``test_get_claim_detail_happy_path_returns_full_shape`` — a fully populated claim (diagnoses, service lines, parties, validation, raw segments) round-trips into the spec dict. * ``test_get_claim_detail_includes_state_history`` — the ``stateHistory`` array surfaces ``ActivityEvent`` rows for this claim, newest first, with both batch and remittance associations preserved. * ``test_get_claim_detail_includes_matched_remittance_summary`` — a claim that has been paired with a remittance via ``manual_match`` surfaces a ``matchedRemittance`` summary with id / totalPaid / status / receivedAt. * ``test_get_claim_detail_no_matched_remittance_returns_null_field`` — an unmatched claim returns ``matchedRemittance: null`` (not missing, not an empty dict). """ from __future__ import annotations from datetime import date, datetime, timezone from decimal import Decimal import pytest from cyclone import db from cyclone.parsers.models import ( Address, BatchSummary, BillingProvider, ClaimHeader, ClaimOutput, Diagnosis, Envelope, Payer, ParseResult, Procedure, ServiceLine, Subscriber, ValidationIssue, ValidationReport, ) from cyclone.parsers.models_835 import ( ClaimAdjustment, ClaimPayment, Envelope as Envelope835, FinancialInfo, ParseResult835, Payer835, Payee835, ReassociationTrace, ServicePayment, BatchSummary as BatchSummary835, ) from cyclone.store import BatchRecord837, BatchRecord835, CycloneStore # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _setup(tmp_path, monkeypatch): """Per-test DB init — matches the pattern in test_store_reconcile.py. Defensive: conftest.py already provides an autouse fixture, but we declare it here too so this file is self-contained and matches the convention used by the other store-level test modules. """ monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db") db._reset_for_tests() db.init_db() yield db._reset_for_tests() # --------------------------------------------------------------------------- # Builders # --------------------------------------------------------------------------- def _make_rich_claim_837(claim_id: str = "CLM-1") -> ClaimOutput: """Build a ClaimOutput with every section populated. Drives the happy-path test: the detail mapper should round-trip diagnoses, service lines (with procedure+modifiers+charge+units), parties (billing_provider with address, subscriber with dob+gender, payer with id), validation issues, and raw_segments. """ bp_addr = Address( line1="123 Main St", line2="Suite 400", city="Denver", state="CO", zip="80202", ) sub_addr = Address( line1="456 Oak Ave", line2=None, city="Boulder", state="CO", zip="80301", ) return ClaimOutput( claim_id=claim_id, control_number="0001", transaction_date=date(2026, 6, 11), billing_provider=BillingProvider( name="Test Provider", npi="1234567890", tax_id="99-1234567", address=bp_addr, ), subscriber=Subscriber( first_name="John", last_name="Doe", member_id="M-001", dob=date(1980, 1, 1), gender="M", address=sub_addr, ), payer=Payer(name="COHCPF", id="SKCO0"), claim=ClaimHeader( claim_id=claim_id, total_charge=Decimal("100.00"), frequency_code="1", place_of_service="11", ), diagnoses=[Diagnosis(code="Z00", qualifier="ABK")], service_lines=[ ServiceLine( line_number=1, procedure=Procedure( qualifier="HC", code="99213", modifiers=["25"], ), charge=Decimal("80.00"), unit_type="UN", units=Decimal("1.0"), service_date=date(2026, 6, 11), ), ServiceLine( line_number=2, procedure=Procedure(qualifier="HC", code="85025"), charge=Decimal("20.00"), unit_type="UN", units=Decimal("1.0"), service_date=date(2026, 6, 11), ), ], validation=ValidationReport( passed=False, errors=[ ValidationIssue( rule="R050_diagnosis_present", severity="error", message="At least one diagnosis code is required", ), ], warnings=[], ), raw_segments=[["ISA", "*00*"], ["CLM", claim_id, "*100***"]], ) def _add_837_with_claim(s: CycloneStore, claim_id: str = "CLM-1") -> str: """Add a single 837 claim via the store. Returns the batch id.""" co = _make_rich_claim_837(claim_id) parsed_at = datetime(2026, 6, 11, 12, 0, tzinfo=timezone.utc) pr = ParseResult( envelope=Envelope( sender_id="S", receiver_id="R", control_number="0001", transaction_date=date(2026, 6, 11), ), claims=[co], summary=BatchSummary( input_file="c.txt", control_number="0001", transaction_date=date(2026, 6, 11), total_claims=1, passed=0, failed=1, ), ) batch_id = f"b-837-{claim_id}" s.add(BatchRecord837( id=batch_id, kind="837p", input_filename="c.txt", parsed_at=parsed_at, result=pr, )) return batch_id def _add_835_with_remit( s: CycloneStore, pcn: str, charge: str = "100", paid: str = "100", status: str = "1", ) -> str: """Add a single 835 remittance. Returns the batch id.""" cp = ClaimPayment( payer_claim_control_number=pcn, status_code=status, status_label="Primary", total_charge=charge, total_paid=paid, service_payments=[ ServicePayment( line_number=1, procedure_qualifier="HC", procedure_code="99213", charge=charge, payment=paid, adjustments=[], ), ], ) parsed_at = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc) batch_id = f"b-835-{pcn}" s.add(BatchRecord835( id=batch_id, kind="835", input_filename="era.txt", parsed_at=parsed_at, result=ParseResult835( envelope=Envelope835( sender_id="P", receiver_id="R", control_number="0002", transaction_date=date(2026, 6, 12), ), financial_info=FinancialInfo( handling_code="C", paid_amount=Decimal("0"), credit_debit_flag="C", payment_method=None, ), trace=ReassociationTrace( trace_type_code="1", trace_number="0001", originating_company_id="P", ), payer=Payer835(name="COHCPF", id="SKCO0"), payee=Payee835(name="Test Provider", npi="1234567890"), claims=[cp], summary=BatchSummary835( input_file="era.txt", control_number="0002", transaction_date=date(2026, 6, 12), total_claims=1, passed=1, failed=0, ), ), )) return batch_id # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_get_claim_detail_returns_none_for_missing(): """An unknown claim id must return ``None`` (not raise, not empty dict).""" s = CycloneStore() assert s.get_claim_detail("CLM-DOES-NOT-EXIST") is None def test_get_claim_detail_happy_path_returns_full_shape(): """A fully populated claim round-trips into the spec-shaped dict.""" s = CycloneStore() batch_id = _add_837_with_claim(s, "CLM-1") parsed_at = datetime(2026, 6, 11, 12, 0, tzinfo=timezone.utc) out = s.get_claim_detail("CLM-1") assert out is not None # -- top-level identity ----------------------------------------- assert out["id"] == "CLM-1" assert out["batchId"] == batch_id # SP1 ingest stamps every claim as submitted; detail shape preserves it. assert out["state"] == "submitted" # stateLabel is a human-readable capitalization of state. assert out["stateLabel"] == "Submitted" # -- money + dates ---------------------------------------------- assert out["billedAmount"] == 100.0 assert isinstance(out["billedAmount"], float) assert out["submissionDate"] == "2026-06-11T12:00:00Z" assert out["parsedAt"] == "2026-06-11T12:00:00Z" assert out["serviceDateFrom"] == "2026-06-11" assert out["serviceDateTo"] == "2026-06-11" # -- patient + provider + payer --------------------------------- assert out["patientName"] == "John Doe" assert out["providerNpi"] == "1234567890" assert out["providerName"] == "Test Provider" assert out["payerName"] == "COHCPF" assert out["payerId"] == "SKCO0" # -- diagnoses -------------------------------------------------- assert out["diagnoses"] == [{"code": "Z00", "qualifier": "ABK"}] # -- service lines (procedure.flattened shape) ------------------ assert len(out["serviceLines"]) == 2 sl1 = out["serviceLines"][0] assert sl1["lineNumber"] == 1 assert sl1["procedureQualifier"] == "HC" assert sl1["procedureCode"] == "99213" assert sl1["modifiers"] == ["25"] assert sl1["charge"] == 80.0 assert sl1["units"] == 1.0 assert sl1["unitType"] == "UN" assert sl1["serviceDate"] == "2026-06-11" # Second line has no modifiers — must be an empty list, not None. sl2 = out["serviceLines"][1] assert sl2["modifiers"] == [] assert sl2["procedureCode"] == "85025" assert sl2["charge"] == 20.0 # -- parties ---------------------------------------------------- bp = out["parties"]["billingProvider"] assert bp["name"] == "Test Provider" assert bp["npi"] == "1234567890" assert bp["taxId"] == "99-1234567" assert bp["address"] == { "line1": "123 Main St", "line2": "Suite 400", "city": "Denver", "state": "CO", "zip": "80202", } sub = out["parties"]["subscriber"] assert sub["firstName"] == "John" assert sub["lastName"] == "Doe" assert sub["memberId"] == "M-001" assert sub["dob"] == "1980-01-01" assert sub["gender"] == "M" payer = out["parties"]["payer"] assert payer == {"name": "COHCPF", "id": "SKCO0"} # -- validation ------------------------------------------------- v = out["validation"] assert v["passed"] is False assert len(v["errors"]) == 1 err = v["errors"][0] assert err["rule"] == "R050_diagnosis_present" assert err["severity"] == "error" assert "diagnosis" in err["message"].lower() assert v["warnings"] == [] # -- raw segments ---------------------------------------------- assert out["rawSegments"] == [ ["ISA", "*00*"], ["CLM", "CLM-1", "*100***"], ] # -- unmatched (this test doesn't pair anything) --------------- assert out["matchedRemittance"] is None # -- state history: at minimum the claim_submitted event ------- history = out["stateHistory"] assert isinstance(history, list) assert any(ev["kind"] == "claim_submitted" for ev in history), history # The submitted event must carry the batch id, not a remittance id. submitted = next(ev for ev in history if ev["kind"] == "claim_submitted") assert submitted["batchId"] == batch_id assert submitted["remittanceId"] is None # ts is ISO Z. assert submitted["ts"].endswith("Z") def test_get_claim_detail_includes_state_history(): """``stateHistory`` must include the manual_match event after pairing.""" s = CycloneStore() _add_837_with_claim(s, "CLM-1") # 835 PCN deliberately differs from claim_id so the auto-matcher # (which now joins on claim_id == pcn after the SP27 Task 17 PCN # fix) doesn't pair them — manual_match needs work to do. _add_835_with_remit(s, pcn="CLM-1-ORPHAN", paid="100", status="1") s.manual_match("CLM-1", "CLM-1-ORPHAN") out = s.get_claim_detail("CLM-1") assert out is not None history = out["stateHistory"] kinds = [ev["kind"] for ev in history] # manual_match must appear; claim_submitted must also appear. assert "manual_match" in kinds, history assert "claim_submitted" in kinds, history # Ordering: ts DESC — manual_match was inserted after the submitted # event, so it must come first. assert kinds.index("manual_match") < kinds.index("claim_submitted"), ( f"stateHistory not ordered DESC by ts: {history}" ) # The manual_match event must carry the remittanceId. It also # carries the remittance's batch_id (per the manual_match # implementation) — the mapper passes through whatever is in the # ActivityEvent row, which is correct per the spec's # ``{kind, ts, batchId|null, remittanceId|null}`` contract. mm = next(ev for ev in history if ev["kind"] == "manual_match") assert mm["remittanceId"] == "CLM-1-ORPHAN" assert mm["batchId"] is not None assert mm["ts"].endswith("Z") # The claim_submitted event has no remittance_id (only a batch id), # which is the opposite of manual_match. submitted = next(ev for ev in history if ev["kind"] == "claim_submitted") assert submitted["remittanceId"] is None assert submitted["batchId"] is not None def test_get_claim_detail_includes_matched_remittance_summary(): """A paired claim surfaces a ``matchedRemittance`` summary block.""" s = CycloneStore() _add_837_with_claim(s, "CLM-1") # 835 PCN deliberately differs from claim_id so manual_match has work. _add_835_with_remit(s, pcn="CLM-1-ORPHAN", paid="100", status="1") s.manual_match("CLM-1", "CLM-1-ORPHAN") out = s.get_claim_detail("CLM-1") assert out is not None mr = out["matchedRemittance"] assert mr is not None assert mr["id"] == "CLM-1-ORPHAN" assert mr["totalPaid"] == 100.0 assert isinstance(mr["totalPaid"], float) # status_code "1" → "received" (per to_ui_remittance_from_orm mapping). assert mr["status"] == "received" # receivedAt is ISO Z (the remit's received_at is tz-aware UTC). assert mr["receivedAt"].endswith("Z") # Sanity: the receivedAt parses back to a real timestamp. parsed = datetime.fromisoformat(mr["receivedAt"].replace("Z", "+00:00")) assert parsed.tzinfo is not None def test_get_claim_detail_no_matched_remittance_returns_null_field(): """An unmatched claim has ``matchedRemittance: null`` (not missing, not {}).""" s = CycloneStore() _add_837_with_claim(s, "CLM-1") # No 835 added — claim is unmatched. out = s.get_claim_detail("CLM-1") assert out is not None # Key must exist; value must be None. A 404 endpoint would have # removed the field, but the spec keeps the key with a null value so # the UI can branch on `matchedRemittance == null` cleanly. assert "matchedRemittance" in out assert out["matchedRemittance"] is None