docs: surface SP14 (Payer-Rejected UI + acknowledge) + SP15 (key rotation) #3
@@ -150,6 +150,7 @@ from cyclone.api_routers import ( # noqa: E402
|
|||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
providers,
|
providers,
|
||||||
|
remittances,
|
||||||
ta1_acks,
|
ta1_acks,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,6 +163,7 @@ app.include_router(config.router)
|
|||||||
app.include_router(activity.router)
|
app.include_router(activity.router)
|
||||||
app.include_router(batches.router)
|
app.include_router(batches.router)
|
||||||
app.include_router(claims.router)
|
app.include_router(claims.router)
|
||||||
|
app.include_router(remittances.router)
|
||||||
|
|
||||||
# Backwards-compat re-exports: test_api_stream_live.py imports these
|
# Backwards-compat re-exports: test_api_stream_live.py imports these
|
||||||
# names directly from ``cyclone.api`` to invoke the endpoint coroutines
|
# 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.
|
# while the bodies live in the routers package.
|
||||||
activity_stream = activity.activity_stream_endpoint
|
activity_stream = activity.activity_stream_endpoint
|
||||||
claims_stream = claims.claims_stream_endpoint
|
claims_stream = claims.claims_stream_endpoint
|
||||||
|
remittances_stream = remittances.remittances_stream_endpoint
|
||||||
|
|
||||||
|
|
||||||
def _resolve_payer(name: str) -> PayerConfig:
|
def _resolve_payer(name: str) -> PayerConfig:
|
||||||
@@ -1310,100 +1313,9 @@ def post_reconciliation_unmatch(body: dict) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/remittances")
|
# --- /api/remittances lives in cyclone.api_routers.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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user