diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index b3a7706..ad0a40f 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -33,7 +33,13 @@ from cyclone.parsers.payer import PayerConfig, PayerConfig835 from cyclone.parsers.parse_837 import parse from cyclone.parsers.parse_835 import parse as parse_835 from cyclone.parsers.validator_835 import validate as validate_835 -from cyclone.store import BatchRecord, store, utcnow +from cyclone.store import ( + AlreadyMatchedError, + BatchRecord, + InvalidStateError, + store, + utcnow, +) log = logging.getLogger(__name__) @@ -511,6 +517,83 @@ def get_reconciliation_unmatched() -> dict: return store.list_unmatched(kind="both") +@app.post("/api/reconciliation/match") +def post_reconciliation_match(body: dict) -> dict: + """Manually pair a Claim with a Remittance (operator override). + + Body: ``{"claim_id": ..., "remit_id": ...}``. Returns + ``{"claim": , "match": }`` on success. Errors: + - 400: missing ``claim_id`` or ``remit_id`` + - 404: claim or remittance not found + - 409: claim already matched, or apply_* returned a noop + (claim in terminal state) — detail echoes ``current_state`` + and ``activity_kind`` so the UI can render a precise message. + """ + claim_id = body.get("claim_id") + remit_id = body.get("remit_id") + if not claim_id or not remit_id: + raise HTTPException( + status_code=400, + detail="claim_id and remit_id required", + ) + try: + return store.manual_match(claim_id, remit_id) + except AlreadyMatchedError as e: + raise HTTPException( + status_code=409, + detail={"error": "already_matched", "message": str(e)}, + ) + except InvalidStateError as e: + raise HTTPException( + status_code=409, + detail={ + "error": "invalid_state", + "current_state": e.current_state, + "activity_kind": e.activity_kind, + }, + ) + except LookupError: + # manual_match raises LookupError when the claim or remittance + # row is missing (we catch the parent class so any future + # KeyError subclasses in the store get the same treatment). + raise HTTPException( + status_code=404, + detail="claim_or_remit_not_found", + ) + + +@app.post("/api/reconciliation/unmatch") +def post_reconciliation_unmatch(body: dict) -> dict: + """Remove the current match for a Claim; reset Claim to submitted. + + Body: ``{"claim_id": ...}``. Returns + ``{"claim": , "deletedMatches": }``. Errors: + - 400: missing ``claim_id`` + - 404: claim not found + - 409: claim has no current match (NotMatchedError is mapped + by the store; we surface 409 to match the manual_match contract) + """ + from cyclone.store import NotMatchedError + claim_id = body.get("claim_id") + if not claim_id: + raise HTTPException( + status_code=400, + detail="claim_id required", + ) + try: + return store.manual_unmatch(claim_id) + except NotMatchedError as e: + raise HTTPException( + status_code=409, + detail={"error": "not_matched", "message": str(e)}, + ) + except LookupError: + raise HTTPException( + status_code=404, + detail="claim_not_found", + ) + + @app.get("/api/remittances") def list_remittances( request: Request, diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index f649f49..0bb70bf 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -16,7 +16,7 @@ New API (T12): - list_unmatched(kind="both") - manual_match(claim_id, remit_id) - manual_unmatch(claim_id) - - AlreadyMatchedError, NotMatchedError exception classes + - AlreadyMatchedError, NotMatchedError, InvalidStateError exception classes Backward-compat shims for tests that relied on the in-memory internals: - ``_lock`` — a no-op ``threading.RLock``. SQLAlchemy handles @@ -70,6 +70,25 @@ class NotMatchedError(Exception): """ +class InvalidStateError(Exception): + """Raised when an apply_* pure fn returns a skipped ApplyIntent. + + ``reconcile.apply_payment`` / ``apply_reversal`` may return + ``skipped=True`` (e.g. claim already in a terminal state, or reversal + on a non-paid claim). The store surfaces that as ``InvalidStateError`` + rather than silently pairing. The T15 API endpoint maps this to a + 409 Conflict and echoes ``current_state`` and ``activity_kind`` so + the UI can render a precise message. + """ + + def __init__(self, current_state: str, activity_kind: str = "invalid_state"): + self.current_state = current_state + self.activity_kind = activity_kind + super().__init__( + f"invalid state {current_state} for apply (kind={activity_kind})" + ) + + BatchKind = Literal["837p", "835"] @@ -1049,8 +1068,9 @@ class CycloneStore: 7. Commit; return ``{"claim": , "match": }``. ``reconcile.apply_payment`` may return a noop (claim in terminal - state); we surface that as ``ValueError`` rather than silently - pairing, because the operator clearly intended a state change. + state); we surface that as ``InvalidStateError`` rather than + silently pairing, because the operator clearly intended a state + change. The T15 API endpoint maps this to a 409 Conflict. """ from cyclone import reconcile as _reconcile @@ -1080,8 +1100,14 @@ class CycloneStore: ) if intent.skipped or intent.new_state is None: - raise ValueError( - f"manual_match refused: {intent.reason or 'no state change'}" + current = ( + claim.state.value + if hasattr(claim.state, "value") + else str(claim.state) + ) + raise InvalidStateError( + current_state=current, + activity_kind=intent.activity_kind, ) new_state = intent.new_state diff --git a/backend/tests/test_api_gets.py b/backend/tests/test_api_gets.py index db1c17e..85882a2 100644 --- a/backend/tests/test_api_gets.py +++ b/backend/tests/test_api_gets.py @@ -397,3 +397,243 @@ def test_get_reconciliation_unmatched_returns_orphans(client: TestClient): 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. member_id="M1" so the 835 PCN-based auto-match can't pair them. + 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, + ), + ), + )) + + # 835 Remittance. PCN="CLM-1" but member_id on the claim side is "M1", + # 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", + 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