diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 244cca2..b3a7706 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -499,6 +499,18 @@ def list_claims( } +@app.get("/api/reconciliation/unmatched") +def get_reconciliation_unmatched() -> dict: + """Return unmatched Claims (left) and unmatched Remittances (right). + + Powers the reconciliation review surface: every Claim with no + paired Remittance appears on the left, every Remittance with no + paired Claim appears on the right. The two lists are always present + (empty list, never absent) so the UI can index unconditionally. + """ + return store.list_unmatched(kind="both") + + @app.get("/api/remittances") def list_remittances( request: Request, diff --git a/backend/tests/test_api_gets.py b/backend/tests/test_api_gets.py index 5f53df9..db1c17e 100644 --- a/backend/tests/test_api_gets.py +++ b/backend/tests/test_api_gets.py @@ -287,3 +287,113 @@ def test_claims_default_accept_returns_json(seeded_store): 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"