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
+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