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