feat(backend): POST /api/reconciliation/{match,unmatch} + InvalidStateError
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user