d8841834dc
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).
885 lines
32 KiB
Python
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
|