Files
cyclone/backend/tests/test_api_gets.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

885 lines
32 KiB
Python

"""Tests for the 6 new GET endpoints."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.store import store as global_store
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
@pytest.fixture(autouse=True)
def clear_store():
with global_store._lock:
global_store._batches.clear()
yield
with global_store._lock:
global_store._batches.clear()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def seeded_store():
"""One parsed 837P fixture batch."""
text = FIXTURE_837.read_text()
client = TestClient(app)
client.post(
"/api/parse-837",
files={"file": ("x.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
return client
JSON = {"Accept": "application/json"}
# --------------------------------------------------------------------------- #
# /api/batches
# --------------------------------------------------------------------------- #
def test_batches_returns_empty_list_when_no_parses(client: TestClient):
resp = client.get("/api/batches", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body == {"items": [], "total": 0, "returned": 0, "has_more": False}
def test_batches_returns_summary_after_parse(seeded_store):
resp = seeded_store.get("/api/batches", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["returned"] == 1
assert body["items"][0]["kind"] == "837p"
assert body["items"][0]["inputFilename"] == "x.txt"
def test_batches_includes_claim_ids_for_837p(seeded_store):
"""The Upload page's History tab renders a Re-export ZIP button per
row that fires ``POST /api/batches/{id}/export-837`` with the row's
claim ids. Carry those ids in the list response so the UI doesn't
need an extra round-trip per row to fetch them."""
batches = seeded_store.get("/api/batches", headers=JSON).json()["items"]
assert len(batches) == 1
item = batches[0]
assert item["kind"] == "837p"
# claimIds is a non-empty list of the same claim ids that live
# inside the parsed result — see `seeded_store` in conftest.py.
assert isinstance(item["claimIds"], list)
assert len(item["claimIds"]) == 2
full = seeded_store.get(f"/api/batches/{item['id']}").json()
assert sorted(item["claimIds"]) == sorted(c["claim_id"] for c in full["claims"])
def test_batches_claim_ids_empty_for_835(client: TestClient, tmp_path):
"""835 has no re-export endpoint — the field is always ``[]`` so the
History tab can use it as the signal to hide the Re-export button."""
src = Path(__file__).parent / "fixtures" / "minimal_835.txt"
files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")}
r = client.post(
"/api/parse-835",
params={"payer": "co_medicaid_835"},
files=files,
headers=JSON,
)
assert r.status_code == 200, r.text
body = client.get("/api/batches", headers=JSON).json()
assert body["total"] == 1
item = body["items"][0]
assert item["kind"] == "835"
assert item["claimIds"] == []
# --------------------------------------------------------------------------- #
# /api/batches/{id}
# --------------------------------------------------------------------------- #
def test_get_batch_by_id_returns_full_result(seeded_store):
batches = seeded_store.get("/api/batches", headers=JSON).json()["items"]
bid = batches[0]["id"]
resp = seeded_store.get(f"/api/batches/{bid}")
assert resp.status_code == 200
body = resp.json()
assert "envelope" in body
assert "claims" in body
assert len(body["claims"]) == 2
def test_get_batch_404_when_missing(client: TestClient):
resp = client.get("/api/batches/does-not-exist")
assert resp.status_code == 404
# --------------------------------------------------------------------------- #
# /api/claims
# --------------------------------------------------------------------------- #
def test_claims_returns_empty_list_when_no_parses(client: TestClient):
resp = client.get("/api/claims", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["total"] == 0
def test_claims_filters_by_status_submitted(seeded_store):
resp = seeded_store.get("/api/claims?status=submitted", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert all(c["status"] == "submitted" for c in body["items"])
assert body["total"] >= 1
def test_claims_filters_by_batch_id(seeded_store):
bid = seeded_store.get("/api/batches", headers=JSON).json()["items"][0]["id"]
resp = seeded_store.get(f"/api/claims?batch_id={bid}", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert all(c["batchId"] == bid for c in body["items"])
def test_claims_sorts_by_billed_amount_desc(seeded_store):
resp = seeded_store.get("/api/claims?sort=billedAmount&order=desc", headers=JSON)
assert resp.status_code == 200
body = resp.json()
amounts = [c["billedAmount"] for c in body["items"]]
assert amounts == sorted(amounts, reverse=True)
def test_claims_pagination_limit_offset(seeded_store):
resp1 = seeded_store.get("/api/claims?limit=1&offset=0", headers=JSON)
body1 = resp1.json()
assert body1["returned"] == 1
assert body1["has_more"] is True
# --------------------------------------------------------------------------- #
# /api/remittances
# --------------------------------------------------------------------------- #
def test_remittances_returns_empty_list_when_no_parses(client: TestClient):
resp = client.get("/api/remittances", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
def test_remittances_filters_by_batch_id(seeded_store):
"""No 835 fixture parsed yet — but the endpoint should return 200 with empty."""
resp = seeded_store.get("/api/remittances?batch_id=nope", headers=JSON)
assert resp.status_code == 200
assert resp.json()["items"] == []
# --------------------------------------------------------------------------- #
# /api/remittances list payload includes adjustments array (SP3 P2 follow-up)
# --------------------------------------------------------------------------- #
def test_list_remittances_includes_adjustments_array(client: TestClient) -> None:
"""SP3 P2 follow-up: the list endpoint must include `adjustments` so the
UI's expandable row has data when a remittance is opened from the list
(not just from the detail endpoint)."""
_seed_835_remittance(
pcns=["PCN-LIST-1"],
cas_rows={"PCN-LIST-1": [("CO", "45", "50.00", ""), ("PR", "1", "10.00", "")]},
)
resp = client.get("/api/remittances", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert len(body["items"]) == 1
item = body["items"][0]
assert "adjustments" in item, item
assert len(item["adjustments"]) == 2
# Verify labels come through (resolved by cas_codes.reason_label).
labels = {a["reason"]: a["label"] for a in item["adjustments"]}
assert "45" in labels and "1" in labels
assert labels["45"] # non-empty
assert labels["1"]
def test_list_remittances_empty_adjustments_is_empty_array(client: TestClient) -> None:
"""A remittance with no CAS rows must still expose `adjustments: []`."""
_seed_835_remittance(pcns=["PCN-LIST-EMPTY"], cas_rows=None)
resp = client.get("/api/remittances", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert len(body["items"]) == 1
assert body["items"][0]["adjustments"] == []
# --------------------------------------------------------------------------- #
# /api/providers
# --------------------------------------------------------------------------- #
def test_providers_empty_when_no_parses(client: TestClient):
resp = client.get("/api/providers", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
def test_providers_returns_one_per_distinct_npi(seeded_store):
resp = seeded_store.get("/api/providers", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert len(body["items"]) == 1
assert body["items"][0]["npi"] == "1881068062"
# --------------------------------------------------------------------------- #
# /api/activity
# --------------------------------------------------------------------------- #
def test_activity_empty_when_no_parses(client: TestClient):
resp = client.get("/api/activity", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
def test_activity_returns_one_event_per_claim(seeded_store):
resp = seeded_store.get("/api/activity", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert len(body["items"]) == 2 # co_medicaid_837p.txt has 2 claims
assert all(e["kind"] == "claim_submitted" for e in body["items"])
# --------------------------------------------------------------------------- #
# NDJSON streaming on the list endpoints
# --------------------------------------------------------------------------- #
def _assert_ndjson_envelope(resp, expected_total: int) -> list[dict]:
"""Common helper: parse NDJSON body, return the item payloads."""
import json as _json
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/x-ndjson"), resp.headers
lines = [l for l in resp.text.split("\n") if l.strip()]
events = [_json.loads(l) for l in lines]
items = [e["data"] for e in events if e["type"] == "item"]
summaries = [e for e in events if e["type"] == "summary"]
assert len(summaries) == 1, events
s = summaries[0]["data"]
assert s["total"] == expected_total
assert s["returned"] == len(items)
return items
def test_batches_ndjson_returns_parseable_lines(seeded_store):
resp = seeded_store.get(
"/api/batches",
headers={"Accept": "application/x-ndjson"},
)
items = _assert_ndjson_envelope(resp, expected_total=1)
assert len(items) == 1
assert items[0]["kind"] == "837p"
def test_claims_ndjson_returns_parseable_lines(seeded_store):
resp = seeded_store.get(
"/api/claims",
headers={"Accept": "application/x-ndjson"},
)
items = _assert_ndjson_envelope(resp, expected_total=2)
assert len(items) == 2
def test_remittances_ndjson_returns_parseable_lines(seeded_store):
resp = seeded_store.get(
"/api/remittances",
headers={"Accept": "application/x-ndjson"},
)
# 0 batches parsed, 0 remits
_assert_ndjson_envelope(resp, expected_total=0)
def test_providers_ndjson_returns_parseable_lines(seeded_store):
resp = seeded_store.get(
"/api/providers",
headers={"Accept": "application/x-ndjson"},
)
items = _assert_ndjson_envelope(resp, expected_total=1)
assert items[0]["npi"] == "1881068062"
def test_activity_ndjson_returns_parseable_lines(seeded_store):
resp = seeded_store.get(
"/api/activity",
headers={"Accept": "application/x-ndjson"},
)
items = _assert_ndjson_envelope(resp, expected_total=2)
assert all(it["kind"] == "claim_submitted" for it in items)
def test_claims_json_default_wraps_in_envelope(seeded_store):
"""Accept: application/json → wrapped JSON response (items/total/returned/has_more)."""
resp = seeded_store.get(
"/api/claims",
headers={"Accept": "application/json"},
)
assert resp.status_code == 200
body = resp.json()
assert set(body.keys()) == {"items", "total", "returned", "has_more"}
assert body["total"] == 2
assert body["returned"] == 2
def test_claims_ndjson_wins_over_json_when_both_accepted(seeded_store):
"""When both NDJSON and JSON are in Accept, the explicit NDJSON hint wins."""
resp = seeded_store.get(
"/api/claims",
headers={"Accept": "application/x-ndjson, application/json"},
)
assert resp.headers["content-type"].startswith("application/x-ndjson")
def test_claims_default_accept_returns_json(seeded_store):
"""No Accept header → JSON envelope (per spec 6.2: JSON is the default for
list endpoints; NDJSON is the opt-in)."""
resp = seeded_store.get("/api/claims")
body = resp.json()
assert "items" in body
assert "total" in body
assert "returned" in body
assert "has_more" in body
# --------------------------------------------------------------------------- #
# /api/reconciliation/unmatched (T14)
# --------------------------------------------------------------------------- #
def test_get_reconciliation_unmatched_empty(client: TestClient):
"""With no parsed batches, both sides of the unmatched list are empty."""
resp = client.get("/api/reconciliation/unmatched")
assert resp.status_code == 200
assert resp.json() == {"claims": [], "remittances": []}
def test_get_reconciliation_unmatched_returns_orphans(client: TestClient):
"""An unmatched Claim (left) and an unmatched Remittance (right) both surface.
The 837 subscriber's ``member_id="M1"`` intentionally differs from the
835 remit's ``payer_claim_control_number="PCN-OTHER"`` so the auto-matcher
in ``reconcile.run`` does not pair them on ingest — we want them to land
in the unmatched list so the endpoint has something to return.
"""
from datetime import date, datetime, timezone
from decimal import Decimal
from cyclone.parsers.models import (
BatchSummary, BillingProvider, ClaimHeader, ClaimOutput,
Envelope, Payer, ParseResult, Subscriber, ValidationReport,
)
from cyclone.parsers.models_835 import (
BatchSummary as BatchSummary835,
ClaimPayment, Envelope as Envelope835,
FinancialInfo, Payer835, ParseResult835, Payee835,
ReassociationTrace,
)
from cyclone.store import BatchRecord837, BatchRecord835
# Add a single 837 Claim. member_id="M1" so PCN-based auto-match won't pair it.
co = ClaimOutput(
claim_id="CLM-1",
control_number="0001",
transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(name="Test", npi="1234567890"),
subscriber=Subscriber(
first_name="Jane", last_name="Doe", member_id="M1",
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id="CLM-1", total_charge=Decimal("100"),
frequency_code="1", place_of_service="11",
),
diagnoses=[],
service_lines=[],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
global_store.add(BatchRecord837(
id="b-837", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=ParseResult(
claims=[co],
summary=BatchSummary(
input_file="c.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
),
))
# Add a single 835 Remittance with PCN="PCN-OTHER" (won't auto-match "M1").
cp = ClaimPayment(
payer_claim_control_number="PCN-OTHER",
status_code="1",
total_charge=Decimal("50"), total_paid=Decimal("50"),
service_payments=[],
)
global_store.add(BatchRecord835(
id="b-835", kind="835", input_filename="e.txt",
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
result=ParseResult835(
envelope=Envelope835(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
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="S",
),
payer=Payer835(name="X", id="SKCO0"),
payee=Payee835(name="Y", npi="1234567890"),
claims=[cp],
summary=BatchSummary835(
input_file="e.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
),
))
resp = client.get("/api/reconciliation/unmatched")
assert resp.status_code == 200
body = resp.json()
assert len(body["claims"]) == 1
assert body["claims"][0]["id"] == "CLM-1"
assert len(body["remittances"]) == 1
assert body["remittances"][0]["payerClaimControlNumber"] == "PCN-OTHER"
# --------------------------------------------------------------------------- #
# /api/reconciliation/match + /unmatch (T15)
# --------------------------------------------------------------------------- #
def test_post_match_happy_path(client: TestClient):
"""Match an unmatched claim + remit; both removed from unmatched bucket.
Setup is fully self-contained: we add a Claim (837 ingest) AND a
Remittance (835 ingest) with mismatched member_id/PCN so the auto-
matcher in ``reconcile.run`` does not pair them on ingest, then
POST to /api/reconciliation/match. Asserts the 200 response shape:
``body["claim"]["state"] == "paid"`` (charge == paid triggers
PAID in ``reconcile.apply_payment``) and the match row records
``strategy="manual"``.
The plan's snippet used stale Pydantic v1 dict fields for
``ClaimOutput`` / ``ParseResult``; the current models are typed
BaseModel instances, so we mirror the pattern from
``test_get_reconciliation_unmatched_returns_orphans`` and
``tests/test_store_reconcile.py::test_manual_match_pairs_claim_with_orphan_remit``.
"""
from datetime import date, datetime, timezone
from decimal import Decimal
from cyclone.parsers.models import (
BatchSummary, BillingProvider, ClaimHeader, ClaimOutput,
Envelope, Payer, ParseResult, Subscriber, ValidationReport,
)
from cyclone.parsers.models_835 import (
BatchSummary as BatchSummary835,
ClaimPayment, Envelope as Envelope835,
FinancialInfo, Payer835, ParseResult835, Payee835,
ReassociationTrace,
)
from cyclone.store import BatchRecord837, BatchRecord835
# 837 Claim. pcn deliberately differs from the 835's PCN so the
# auto-matcher (which now joins on claim_id == pcn after the SP27
# Task 17 PCN fix) doesn't pair them — we want an orphan so the
# manual_match call has work to do.
co = ClaimOutput(
claim_id="CLM-1",
control_number="0001",
transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(name="Test", npi="1234567890"),
subscriber=Subscriber(
first_name="Jane", last_name="Doe", member_id="ORPHAN-MEMBER",
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id="CLM-1", total_charge=Decimal("100"),
frequency_code="1", place_of_service="11",
),
diagnoses=[],
service_lines=[],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
global_store.add(BatchRecord837(
id="b-837", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=ParseResult(
claims=[co],
summary=BatchSummary(
input_file="c.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
),
))
# 835 Remittance. PCN="CLM-1-ORPHAN" deliberately differs from the
# claim's claim_id="CLM-1" so the auto-matcher in reconcile.run does
# NOT pair them; we want an orphan so manual_match has work to do.
# charge == paid == 100 so apply_payment picks ClaimState.PAID.
cp = ClaimPayment(
payer_claim_control_number="CLM-1-ORPHAN",
status_code="1",
total_charge=Decimal("100"),
total_paid=Decimal("100"),
service_payments=[],
)
global_store.add(BatchRecord835(
id="b-835", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
result=ParseResult835(
envelope=Envelope835(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
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="S",
),
payer=Payer835(name="X", id="SKCO0"),
payee=Payee835(name="Y", npi="1234567890"),
claims=[cp],
summary=BatchSummary835(
input_file="era.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
),
))
out = client.get("/api/reconciliation/unmatched").json()
assert len(out["claims"]) == 1
assert len(out["remittances"]) == 1
claim_id = out["claims"][0]["id"]
remit_id = out["remittances"][0]["id"]
r = client.post(
"/api/reconciliation/match",
json={"claim_id": claim_id, "remit_id": remit_id},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["claim"]["state"] == "paid"
assert body["match"]["strategy"] == "manual"
assert body["claim"]["matchedRemittanceId"] == remit_id
assert body["match"]["claimId"] == claim_id
assert body["match"]["remittanceId"] == remit_id
# Both sides now filtered out of the unmatched list.
out2 = client.get("/api/reconciliation/unmatched").json()
assert out2["claims"] == []
assert out2["remittances"] == []
def test_post_match_conflict_returns_409(client: TestClient):
"""Matching an already-matched claim returns 409 with ``already_matched``.
Self-contained: builds its own Claim + Remittance via direct ORM
inserts (skips the 837/835 ingest path so the test doesn't depend
on the previous test's data — pytest's default file ordering runs
``test_post_match_happy_path`` first and that test drains the
unmatched bucket, so reading from
``/api/reconciliation/unmatched`` would yield nothing here and
the plan's ``pytest.skip(...)`` would silently no-op the test).
"""
from datetime import datetime, timezone
from decimal import Decimal
from cyclone import db
from cyclone.db import Batch, Claim, Remittance
with db.SessionLocal()() as s:
s.add(Batch(
id="b-conflict", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Claim(
id="CLM-CONFLICT", batch_id="b-conflict",
patient_control_number="CLM-CONFLICT",
charge_amount=Decimal("100"),
state="submitted",
matched_remittance_id=None,
))
s.add(Remittance(
id="CLP-CONFLICT", batch_id="b-conflict",
payer_claim_control_number="PCN-CONFLICT",
status_code="1", total_charge=Decimal("100"),
total_paid=Decimal("100"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.commit()
# First match succeeds.
r1 = client.post(
"/api/reconciliation/match",
json={"claim_id": "CLM-CONFLICT", "remit_id": "CLP-CONFLICT"},
)
assert r1.status_code == 200, r1.text
# Second match for the same claim → 409.
r2 = client.post(
"/api/reconciliation/match",
json={"claim_id": "CLM-CONFLICT", "remit_id": "CLP-CONFLICT"},
)
assert r2.status_code == 409, r2.text
detail = r2.json()["detail"]
assert detail["error"] == "already_matched"
def test_post_unmatch_removes_match(client: TestClient):
"""Unmatch reverses a manual pair and returns the claim to ``submitted``.
The plan snippet creates a Claim with ``state="paid"`` and a
``matched_remittance_id`` set, but **no** corresponding ``Match``
row. ``manual_unmatch`` is built to handle this defensive case —
``matched_remittance_id IS NOT NULL`` lets it through the first
guard, then with no ``Match`` rows it falls back to restoring
``ClaimState.SUBMITTED`` (the default prior state).
We also need an 835 batch row for the remittance's ``batch_id`` FK;
the remittance here is detached from auto-match (no Match row) so
we just need the batch row to exist.
"""
from datetime import datetime, timezone
from decimal import Decimal
from cyclone import db
from cyclone.db import Batch, Claim, Remittance
with db.SessionLocal()() as s:
s.add(Batch(
id="b-unmatch", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Claim(
id="CLM-X", batch_id="b-unmatch",
patient_control_number="CLM-X",
charge_amount=Decimal("100"),
state="paid",
matched_remittance_id="CLP-Y:b-unmatch",
))
s.add(Remittance(
id="CLP-Y:b-unmatch", batch_id="b-unmatch",
payer_claim_control_number="CLM-X",
status_code="1",
total_charge=Decimal("100"),
total_paid=Decimal("100"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.commit()
r = client.post(
"/api/reconciliation/unmatch",
json={"claim_id": "CLM-X"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["claim"]["state"] == "submitted"
assert body["claim"]["matchedRemittanceId"] is None
# No Match rows existed before the call → 0 deleted.
assert body["deletedMatches"] == 0
# --------------------------------------------------------------------------- #
# /api/remittances/{remittance_id} (T7 / SP3 P2)
# --------------------------------------------------------------------------- #
def test_get_remittance_404_when_missing(client: TestClient) -> None:
"""A missing remittance returns 404 (not 500)."""
resp = client.get("/api/remittances/does-not-exist")
assert resp.status_code == 404
def _seed_835_remittance(
*,
pcns: list[str],
cas_rows: dict[str, list[tuple[str, str, str, str]]] | None = None,
status_code: str = "1",
total_charge: str = "100",
total_paid: str = "100",
) -> None:
"""Helper: insert one 835 batch with one ClaimPayment per PCN.
``cas_rows`` maps ``pcn -> [(group, reason, amount, qty_or_None), ...]``
so a single CAS row maps cleanly to ``CasAdjustment``.
"""
from datetime import date, datetime, timezone
from decimal import Decimal
from cyclone.parsers.models_835 import (
BatchSummary as BatchSummary835,
ClaimAdjustment as ClaimAdjustment835,
ClaimPayment,
Envelope as Envelope835,
FinancialInfo,
Payer835,
ParseResult835,
Payee835,
ReassociationTrace,
ServicePayment,
)
from cyclone.store import BatchRecord835
claims = []
for pcn in pcns:
adjustments: list[ClaimAdjustment835] = []
rows = (cas_rows or {}).get(pcn, [])
for group, reason, amount, qty in rows:
adjustments.append(ClaimAdjustment835(
group_code=group, reason_code=reason,
amount=Decimal(amount),
quantity=Decimal(qty) if qty else None,
))
sp = ServicePayment(
line_number=1,
procedure_qualifier="HC",
procedure_code="T1019",
modifiers=[],
charge=Decimal(total_charge),
payment=Decimal(total_paid),
units=Decimal("1"),
unit_type="UN",
adjustments=adjustments,
)
claims.append(ClaimPayment(
payer_claim_control_number=pcn,
status_code=status_code,
total_charge=Decimal(total_charge),
total_paid=Decimal(total_paid),
service_payments=[sp] if rows else [],
))
global_store.add(BatchRecord835(
id=f"b-835-{pcns[0]}", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
result=ParseResult835(
envelope=Envelope835(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
financial_info=FinancialInfo(
handling_code="C", paid_amount=Decimal(total_paid),
credit_debit_flag="C", payment_method=None,
),
trace=ReassociationTrace(
trace_type_code="1", trace_number="0001",
originating_company_id="S",
),
payer=Payer835(name="Colorado Medicaid", id="SKCO0"),
payee=Payee835(name="TOC, Inc.", npi="1881068062"),
claims=claims,
summary=BatchSummary835(
input_file="era.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=len(claims), passed=len(claims), failed=0,
),
),
))
def test_get_remittance_with_empty_adjustments(client: TestClient) -> None:
"""A remittance with no CAS rows returns ``adjustments == []``."""
_seed_835_remittance(pcns=["PCN-EMPTY"])
resp = client.get("/api/remittances/PCN-EMPTY")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["id"] == "PCN-EMPTY"
assert body["adjustments"] == []
def test_get_remittance_with_adjustments_returns_labeled(client: TestClient) -> None:
"""A remittance with two CAS rows surfaces both with the correct labels.
Seeds CO-45 (the fee-schedule code) and PR-1 (deductible) so we
exercise both a CO group code and a PR group code, plus both a
quantified and non-quantified row.
"""
_seed_835_remittance(
pcns=["PCN-LABELED"],
cas_rows={
"PCN-LABELED": [
("CO", "45", "50.00", ""), # no quantity
("PR", "1", "10.00", "1"), # quantified
],
},
)
resp = client.get("/api/remittances/PCN-LABELED")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["id"] == "PCN-LABELED"
adjs = body["adjustments"]
assert len(adjs) == 2
by_key = {(a["group"], a["reason"]): a for a in adjs}
assert ("CO", "45") in by_key
assert ("PR", "1") in by_key
co45 = by_key[("CO", "45")]
assert co45["amount"] == 50.00
assert co45["quantity"] is None
# Label is non-empty and isn't the "Unknown" fallback (both are in the dict).
assert co45["label"] and "Unknown" not in co45["label"]
pr1 = by_key[("PR", "1")]
assert pr1["amount"] == 10.00
assert pr1["quantity"] == 1
assert pr1["label"] and "Unknown" not in pr1["label"]
# The CO and PR rows must have distinct labels (different codes → different meanings).
assert co45["label"] != pr1["label"]
def test_get_remittance_basic_fields_preserved(client: TestClient) -> None:
"""claimId, payerName, paidAmount, status all surface unchanged."""
_seed_835_remittance(
pcns=["PCN-FIELDS"],
cas_rows={"PCN-FIELDS": [("CO", "45", "12.34", "")]},
total_paid="75.50",
)
resp = client.get("/api/remittances/PCN-FIELDS")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["id"] == "PCN-FIELDS"
assert body["payerName"] == "Colorado Medicaid"
assert body["paidAmount"] == 75.50
assert body["status"] == "received"
assert body["batchId"] # populated; not pinned to a specific value
assert body["receivedDate"] # populated
assert body["parsedAt"] # populated