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