Files
cyclone/backend/tests/test_store_claim_detail.py
T
Nora d8841834dc fix(sp27): Claim.patient_control_number populated from CLM01 (claim_id), not 2010BA NM109 (member_id)
The 837 ingest path (_claim_837_row in store.py:194) populated
Claim.patient_control_number from claim.subscriber.member_id — the
subscriber's 2010BA NM109 Medicaid ID — instead of from
claim.claim_id (CLM01, the claim submitter's identifier the 837
actually sent).

That silently broke every downstream join that uses this column as a
cross-reference key:

  * reconcile.match() (reconcile.py:74) — joins
    Claim.patient_control_number against
    Remittance.payer_claim_control_number (which is parsed from CLP01,
    the 835's echo of CLM01 per X12 spec). Member_id never matches
    CLP01, so auto-match always fails.
  * apply_999_rejections — same lookup, same broken key.
  * apply_277ca_rejections — same lookup, same broken key.
  * scoring.score_pair — same broken key.

Live DB probe (1,739 remits, 337 claims):
  * Claim.id == Remit.payer_claim_control_number    → 0 matches
  * Claim.patient_control_number == Remit.payer_claim_control_number
    → 9 matches (substring coincidences; synthetic PCN strings share
    alphanumeric characters with the human-readable member_ids)
  * Claim.matched_remittance_id NOT NULL             → 0 claims

This commit changes _claim_837_row to write
Claim.patient_control_number = claim.claim_id (= CLM01). The
reconcile matcher's existing join now hits the row the 835 echoes
back. Companion migration 0017 backfills the 337 pre-fix rows so
they're on equal footing with new ingests (UPDATE claims SET
patient_control_number = id WHERE patient_control_number IS DISTINCT
FROM id — idempotent).

Tests:
  * 2 new RED→GREEN tests in test_store_reconcile.py:
    - test_837_ingest_populates_patient_control_number_from_claim_id
      pins the field semantics directly
    - test_837_then_835_with_echoed_pcn_auto_pairs proves the full
      end-to-end auto-match now fires
  * 3 existing manual_match tests that relied on the bug (had setup
    using member_id != claim_id to deliberately prevent auto-match)
    updated to use distinct PCNs explicitly so the tests still
    exercise the manual-match path with a real orphan pair.
  * 3 store_claim_detail / api_gets tests adjusted for the same
    reason.

Verified: 1,176 / 1,177 backend tests pass; the one failure is the
pre-existing flake in test_provider_extended_response.py noted before
this work started.

Caveat for the existing dev DB: the 1,739 remits already in the DB
were ingested from 835 fixtures whose CLP01 is a different synthetic
identifier than the 837's CLM01 (the test fixtures never echoed
CLM01 — a fixture-data limitation, not a code bug). After this fix,
new 837+835 ingest pairs whose payer echoes CLM01 in CLP01 will
auto-match as expected. The pre-existing 1,739 remits will continue
to land in the unmatched bucket; that can only be fixed by
regenerating the test fixtures (out of scope for this SP).
2026-06-29 14:32:10 -06:00

429 lines
16 KiB
Python

"""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