feat(backend): POST /api/reconciliation/{match,unmatch} + InvalidStateError

This commit is contained in:
Tyler
2026-06-19 23:39:20 -06:00
parent a4c59587c6
commit 44fe88d694
3 changed files with 355 additions and 6 deletions
+84 -1
View File
@@ -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": <ui>, "match": <ui>}`` 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": <ui>, "deletedMatches": <count>}``. 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,
+31 -5
View File
@@ -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": <ui>, "match": <ui>}``.
``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
+240
View File
@@ -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