From 7abe94a3a89bad15507215cfae130d61e7c6354f Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 6 Jul 2026 15:06:40 -0600 Subject: [PATCH] feat(sp36): extract reconciliation router Move GET /api/reconciliation/unmatched, GET /api/batch-diff, POST /api/reconciliation/match, and POST /api/reconciliation/unmatch from api.py to api_routers/reconciliation.py. Read views + manual match/unmatch write paths via store.manual_match / store.manual_unmatch. The Side-by-side batch-diff divider is preserved between routes 1 and 2; the lazy imports of cyclone.batch_diff.diff_batches_to_wire and cyclone.store.NotMatchedError inside their handlers are preserved verbatim. Orphan imports AlreadyMatchedError and InvalidStateError removed from api.py. --- backend/src/cyclone/api.py | 143 -------------- backend/src/cyclone/api_routers/__init__.py | 2 + .../src/cyclone/api_routers/reconciliation.py | 178 ++++++++++++++++++ 3 files changed, 180 insertions(+), 143 deletions(-) create mode 100644 backend/src/cyclone/api_routers/reconciliation.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index bd537d4..c4395ba 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -78,9 +78,7 @@ from cyclone.parsers.batch_ack_builder import build_ack_for_batch from cyclone.parsers.validator_835 import validate as validate_835 from cyclone.pubsub import EventBus from cyclone.store import ( - AlreadyMatchedError, BatchRecord, - InvalidStateError, store, utcnow, ) @@ -2447,147 +2445,6 @@ def _svc_to_dict(svc) -> dict: } -@app.get("/api/reconciliation/unmatched", dependencies=[Depends(matrix_gate)]) -def get_reconciliation_unmatched() -> dict: - """Return unmatched Claims (left) and unmatched Remittances (right). - - Powers the reconciliation review surface: every Claim with no - paired Remittance appears on the left, every Remittance with no - paired Claim appears on the right. The two lists are always present - (empty list, never absent) so the UI can index unconditionally. - """ - return store.list_unmatched(kind="both") - - -# --------------------------------------------------------------------------- # -# Side-by-side diff between two batches (SP3 P4 / T18) -# --------------------------------------------------------------------------- # - - -@app.get("/api/batch-diff", dependencies=[Depends(matrix_gate)]) -def get_batch_diff( - a: str | None = Query(None), - b: str | None = Query(None), -) -> dict: - """Return a side-by-side diff of two batches identified by id. - - Query params: ``a=``, ``b=`` (both required). - - Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the - projector shapes): - - ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt, - inputFilename, claimCount) - - ``added`` — claims present in B but not A - - ``removed`` — claims present in A but not B - - ``changed`` — claims present in both, with field deltas - - ``summary`` — precomputed counts - - Errors: - - 400 — missing ``a`` or ``b`` - - 404 — either batch id is unknown - - Pure read endpoint — never mutates the store. Both 837P and 835 - batches are accepted (mixed-kind diffs are valid: comparing the - submitted claims against the matching remittances). - """ - if not a or not b: - raise HTTPException( - status_code=400, - detail={"error": "Missing param", "detail": "Both ?a= and ?b= are required."}, - ) - try: - a_rec, b_rec = store.load_two_for_diff(a, b) - except LookupError as exc: - raise HTTPException( - status_code=404, - detail={"error": "Not found", "detail": str(exc)}, - ) - - # Lazy import — keeps the module's import surface small until the - # endpoint is actually hit. Mirrors the same pattern used by other - # endpoint-local helpers (e.g. reconciler). - from cyclone.batch_diff import diff_batches_to_wire - - return diff_batches_to_wire(a_rec, b_rec) - - -@app.post("/api/reconciliation/match", dependencies=[Depends(matrix_gate)]) -def post_reconciliation_match(body: dict) -> dict: - """Manually pair a Claim with a Remittance (operator override). - - Body: ``{"claim_id": ..., "remit_id": ...}``. Returns - ``{"claim": , "match": }`` 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", dependencies=[Depends(matrix_gate)]) -def post_reconciliation_unmatch(body: dict) -> dict: - """Remove the current match for a Claim; reset Claim to submitted. - - Body: ``{"claim_id": ...}``. Returns - ``{"claim": , "deletedMatches": }``. 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", dependencies=[Depends(matrix_gate)]) def list_remittances( request: Request, diff --git a/backend/src/cyclone/api_routers/__init__.py b/backend/src/cyclone/api_routers/__init__.py index 432032e..4d94f95 100644 --- a/backend/src/cyclone/api_routers/__init__.py +++ b/backend/src/cyclone/api_routers/__init__.py @@ -19,6 +19,7 @@ from cyclone.api_routers import ( eligibility, health, payers, + reconciliation, ta1_acks, ) @@ -31,6 +32,7 @@ routers: list[APIRouter] = [ eligibility.router, # gated health.router, # public — health probes must work pre-auth payers.router, # gated + reconciliation.router, # gated ta1_acks.router, # gated ] diff --git a/backend/src/cyclone/api_routers/reconciliation.py b/backend/src/cyclone/api_routers/reconciliation.py new file mode 100644 index 0000000..15952f6 --- /dev/null +++ b/backend/src/cyclone/api_routers/reconciliation.py @@ -0,0 +1,178 @@ +"""Reconciliation read views + manual match/unmatch write paths. + +Four endpoints: + +- ``GET /api/reconciliation/unmatched`` — list of unmatched Claims + and unmatched Remittances (``store.list_unmatched(kind="both")``). +- ``GET /api/batch-diff`` — side-by-side diff of two + batches (used by the Batch Diff page). Lazy-imports + :func:`cyclone.batch_diff.diff_batches_to_wire` to keep the + module's import surface small until the endpoint is actually + hit. +- ``POST /api/reconciliation/match`` — manually pair a Claim with + a Remittance (``store.manual_match``). Surfaces ``AlreadyMatchedError`` / + ``InvalidStateError`` as 409, and ``LookupError`` from the store + as 404 (``claim_or_remit_not_found``). +- ``POST /api/reconciliation/unmatch`` — unpair a Claim (``store.manual_unmatch``). + Surfaces ``NotMatchedError`` as 409. + +All four are read-or-manual-override surfaces used by the +Reconciliation page (the page that pairs Claims with Remittances +when neither side has an automatic match key). + +SP36 Task 8: this block moved here from ``api.py:2450`` (the +4 routes interleaved with a side-by-side batch-diff divider). +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query + +from cyclone.auth.deps import matrix_gate +from cyclone.store import ( + AlreadyMatchedError, + InvalidStateError, + store, +) + +router = APIRouter(dependencies=[Depends(matrix_gate)]) + + +@router.get("/api/reconciliation/unmatched") +def get_reconciliation_unmatched() -> dict: + """Return unmatched Claims (left) and unmatched Remittances (right). + + Powers the reconciliation review surface: every Claim with no + paired Remittance appears on the left, every Remittance with no + paired Claim appears on the right. The two lists are always present + (empty list, never absent) so the UI can index unconditionally. + """ + return store.list_unmatched(kind="both") + + +# --------------------------------------------------------------------------- # +# Side-by-side diff between two batches (SP3 P4 / T18) +# --------------------------------------------------------------------------- # + + +@router.get("/api/batch-diff") +def get_batch_diff( + a: str | None = Query(None), + b: str | None = Query(None), +) -> dict: + """Return a side-by-side diff of two batches identified by id. + + Query params: ``a=``, ``b=`` (both required). + + Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the + projector shapes): + - ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt, + inputFilename, claimCount) + - ``added`` — claims present in B but not A + - ``removed`` — claims present in A but not B + - ``changed`` — claims present in both, with field deltas + - ``summary`` — precomputed counts + + Errors: + - 400 — missing ``a`` or ``b`` + - 404 — either batch id is unknown + + Pure read endpoint — never mutates the store. Both 837P and 835 + batches are accepted (mixed-kind diffs are valid: comparing the + submitted claims against the matching remittances). + """ + if not a or not b: + raise HTTPException( + status_code=400, + detail={"error": "Missing param", "detail": "Both ?a= and ?b= are required."}, + ) + try: + a_rec, b_rec = store.load_two_for_diff(a, b) + except LookupError as exc: + raise HTTPException( + status_code=404, + detail={"error": "Not found", "detail": str(exc)}, + ) + + # Lazy import — keeps the module's import surface small until the + # endpoint is actually hit. Mirrors the same pattern used by other + # endpoint-local helpers (e.g. reconciler). + from cyclone.batch_diff import diff_batches_to_wire + + return diff_batches_to_wire(a_rec, b_rec) + + +@router.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": , "match": }`` 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", + ) + + +@router.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": , "deletedMatches": }``. 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", + )