From eb674f890f558008cd5451776fbdc1aa4fca4441 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 06:29:01 -0600 Subject: [PATCH] refactor(api): split remittances router (list + stream + detail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the 3 /api/remittances endpoints from api.py into cyclone.api_routers.remittances: GET /api/remittances (paginated list with filters) GET /api/remittances/stream (NDJSON live-tail) GET /api/remittances/{remit_id} (detail with CAS adjustments) Mirror of the claims router for the 835 / Remittance resource. No private helpers — every endpoint is a thin wrapper over store.iter_remittances() / store.get_remittance(). remittances_stream is re-exported at the cyclone.api module level so test_api_stream_live.py's direct import keeps working. Route ordering preserved: /stream is declared before /{remit_id} in the router source so FastAPI's first-match routing doesn't swallow the literal 'stream' segment as a remittance id. api.py: 1841 -> 1753 (-88). Net diff: +170 / -96. Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline. 11/11 remittance-targeted tests pass. Live smoke: all 3 routes return expected status codes (200 for list/stream, 404 for missing-remit detail). --- backend/src/cyclone/api.py | 96 +------------ .../src/cyclone/api_routers/remittances.py | 130 ++++++++++++++++++ 2 files changed, 134 insertions(+), 92 deletions(-) create mode 100644 backend/src/cyclone/api_routers/remittances.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 10d670c..d2c719b 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -150,6 +150,7 @@ from cyclone.api_routers import ( # noqa: E402 config, health, providers, + remittances, ta1_acks, ) @@ -162,6 +163,7 @@ app.include_router(config.router) app.include_router(activity.router) app.include_router(batches.router) app.include_router(claims.router) +app.include_router(remittances.router) # Backwards-compat re-exports: test_api_stream_live.py imports these # names directly from ``cyclone.api`` to invoke the endpoint coroutines @@ -169,6 +171,7 @@ app.include_router(claims.router) # while the bodies live in the routers package. activity_stream = activity.activity_stream_endpoint claims_stream = claims.claims_stream_endpoint +remittances_stream = remittances.remittances_stream_endpoint def _resolve_payer(name: str) -> PayerConfig: @@ -1310,100 +1313,9 @@ def post_reconciliation_unmatch(body: dict) -> dict: ) -@app.get("/api/remittances") -def list_remittances( - request: Request, - batch_id: str | None = Query(None), - payer: str | None = Query(None), - claim_id: str | None = Query(None), - date_from: str | None = Query(None), - date_to: str | None = Query(None), - sort: str | None = Query(None), - order: str = Query("desc"), - limit: int = Query(100, ge=1, le=1000), - offset: int = Query(0, ge=0), -) -> Any: - common = dict( - batch_id=batch_id, - payer=payer, - claim_id=claim_id, - date_from=date_from, - date_to=date_to, - ) - items = list(store.iter_remittances( - sort=sort, order=order, limit=limit, offset=offset, **common, - )) - total = len(list(store.iter_remittances(**common))) - returned = len(items) - has_more = total > offset + returned - if _wants_ndjson(request): - return StreamingResponse( - _ndjson_stream_list(items, total, returned, has_more), - media_type="application/x-ndjson", - ) - return { - "items": items, - "total": total, - "returned": returned, - "has_more": has_more, - } +# --- /api/remittances lives in cyclone.api_routers.remittances --- -@app.get("/api/remittances/stream") -async def remittances_stream( - request: Request, - payer: str | None = Query(None), - claim_id: str | None = Query(None), - date_from: str | None = Query(None), - date_to: str | None = Query(None), - sort: str | None = Query(None), - order: str = Query("desc"), - limit: int = Query(100, ge=1, le=1000), -) -> StreamingResponse: - """Stream Remittances as NDJSON: snapshot first, then live events. - - Subscribes to ``remittance_written``. Default sort is - ``-received_date`` (newest-first), matching the list endpoint's - most common sort. - - NOTE: registered before ``/api/remittances/{remittance_id}`` so - the literal ``stream`` path segment doesn't get matched as a - remittance id. - """ - bus: EventBus = request.app.state.event_bus - - async def gen() -> AsyncIterator[bytes]: - rows = store.iter_remittances( - payer=payer, claim_id=claim_id, - date_from=date_from, date_to=date_to, - sort=sort or "-received_date", order=order, limit=limit, - ) - for row in rows: - yield _ndjson_line({"type": "item", "data": row}) - yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}}) - - async for chunk in _tail_events(request, bus, ["remittance_written"]): - yield chunk - - return StreamingResponse(gen(), media_type="application/x-ndjson") - - -@app.get("/api/remittances/{remittance_id}") -def get_remittance(remittance_id: str) -> dict: - """Return one remittance with its labeled CAS ``adjustments`` array. - - Path param is ``remittance_id`` (not ``id``) to avoid shadowing - FastAPI's internal ``id`` name and to keep OpenAPI docs self- - describing. Returns 404 when the remittance is missing — never 500. - """ - body = store.get_remittance(remittance_id) - if body is None: - raise HTTPException( - status_code=404, - detail={"error": "Not found", "detail": f"Remittance {remittance_id} not found"}, - ) - return body - diff --git a/backend/src/cyclone/api_routers/remittances.py b/backend/src/cyclone/api_routers/remittances.py new file mode 100644 index 0000000..ffd6a1a --- /dev/null +++ b/backend/src/cyclone/api_routers/remittances.py @@ -0,0 +1,130 @@ +"""``/api/remittances`` — list, live-tail, detail endpoints for 835 ERAs. + +Mirror of the ``claims`` router for the 835 / Remittance resource: + +* ``GET /api/remittances`` — paginated list with filters + (batch_id / payer / claim_id / date range / sort). +* ``GET /api/remittances/stream`` — NDJSON snapshot + live tail via the + ``remittance_written`` EventBus kind. +* ``GET /api/remittances/{remittance_id}`` — detail with the labeled + CAS ``adjustments`` array. + +**Route ordering.** ``/stream`` is declared **before** ``/{remittance_id}`` +so FastAPI's first-match routing doesn't swallow the literal ``stream`` +segment as a remittance id. Same order as the prior inline code. +""" +from __future__ import annotations + +from typing import Any, AsyncIterator + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import StreamingResponse + +from cyclone.api_helpers import ( + ndjson_line, + ndjson_stream_list, + tail_events, + wants_ndjson, +) +from cyclone.pubsub import EventBus +from cyclone.store import store + +router = APIRouter() + + +@router.get("/api/remittances") +def list_remittances_endpoint( + request: Request, + batch_id: str | None = Query(None), + payer: str | None = Query(None), + claim_id: str | None = Query(None), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + sort: str | None = Query(None), + order: str = Query("desc"), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), +) -> Any: + """Paginated remittances list with filters; supports NDJSON streaming. + + ``has_more`` is computed as ``total > offset + returned`` so paginating + forward keeps returning true until the last page is reached. + """ + common = dict( + batch_id=batch_id, + payer=payer, + claim_id=claim_id, + date_from=date_from, + date_to=date_to, + ) + items = list(store.iter_remittances( + sort=sort, order=order, limit=limit, offset=offset, **common, + )) + total = len(list(store.iter_remittances(**common))) + returned = len(items) + has_more = total > offset + returned + if wants_ndjson(request): + return StreamingResponse( + ndjson_stream_list(items, total, returned, has_more), + media_type="application/x-ndjson", + ) + return { + "items": items, + "total": total, + "returned": returned, + "has_more": has_more, + } + + +@router.get("/api/remittances/stream") +async def remittances_stream_endpoint( + request: Request, + payer: str | None = Query(None), + claim_id: str | None = Query(None), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + sort: str | None = Query(None), + order: str = Query("desc"), + limit: int = Query(100, ge=1, le=1000), +) -> StreamingResponse: + """Stream Remittances as NDJSON: snapshot first, then live events. + + Subscribes to ``remittance_written``. Default sort is + ``-received_date`` (newest-first), matching the list endpoint's + most common sort. + + NOTE: registered before ``/api/remittances/{remittance_id}`` so + the literal ``stream`` path segment doesn't get matched as a + remittance id. + """ + bus: EventBus = request.app.state.event_bus + + async def gen() -> AsyncIterator[bytes]: + rows = store.iter_remittances( + payer=payer, claim_id=claim_id, + date_from=date_from, date_to=date_to, + sort=sort or "-received_date", order=order, limit=limit, + ) + for row in rows: + yield ndjson_line({"type": "item", "data": row}) + yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}}) + + async for chunk in tail_events(request, bus, ["remittance_written"]): + yield chunk + + return StreamingResponse(gen(), media_type="application/x-ndjson") + + +@router.get("/api/remittances/{remittance_id}") +def get_remittance_endpoint(remittance_id: str) -> dict: + """Return one remittance with its labeled CAS ``adjustments`` array. + + Returns 404 when the remittance is missing — never 500. + """ + body = store.get_remittance(remittance_id) + if body is None: + raise HTTPException( + status_code=404, + detail={"error": "Not found", "detail": f"Remittance {remittance_id} not found"}, + ) + return body