From 502862826901215e629a5c843af9a8d27bce255d Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 6 Jul 2026 14:49:36 -0600 Subject: [PATCH] feat(sp36): extract payers router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move GET /api/payers/{payer_id}/summary from api.py to api_routers/payers.py, plus the in-process memo (constants _SUMMARY_TTL_S, _summary_cache and helper _clear_summary_cache). Behaviour-preserving — endpoint URL, params, response shape, 404-on-missing behavior, and the 60s cache TTL are unchanged. Add a one-line backward-compat shim at the bottom of api.py that re-imports _clear_summary_cache from the new home. This keeps test_payer_summary.py working (its fixture calls api_mod._clear_summary_cache() between requests to wipe the cache) without modifying the test — SP36 explicitly forbids test edits for a structural refactor. --- backend/src/cyclone/api.py | 133 ++--------------- backend/src/cyclone/api_routers/__init__.py | 2 + backend/src/cyclone/api_routers/payers.py | 149 ++++++++++++++++++++ 3 files changed, 161 insertions(+), 123 deletions(-) create mode 100644 backend/src/cyclone/api_routers/payers.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index c4bf828..995f5a6 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -27,7 +27,6 @@ import os import uuid from datetime import datetime, timezone from contextlib import asynccontextmanager -from time import monotonic from typing import Any, AsyncIterator from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile @@ -3186,128 +3185,6 @@ def list_payer_configs(payer_id: str): # --------------------------------------------------------------------------- # -# SP21 Task 1.5: payer-level summary for the drill-down panel -# --------------------------------------------------------------------------- # - -# Per-payer_id memoization for /api/payers/{payer_id}/summary. The drill- -# down UI hammers this on every hover; the underlying query is O(claims) -# per payer so a 60s TTL keeps the panel responsive. Process-local on -# purpose — invalidation is driven by the TTL alone for now (see note -# below). -_SUMMARY_TTL_S = 60.0 -_summary_cache: dict[str, tuple[float, dict]] = {} - - -def _clear_summary_cache() -> None: - """Test hook: wipe the process-local cache. - - The 60s TTL means tests that want to assert on a recomputed payload - must clear the cache between requests. Not used by production code. - """ - _summary_cache.clear() - - -# Pubsub invalidation is intentionally NOT wired. The ``claim_written`` -# and ``remittance_written`` payloads emitted by ``store.add`` are the -# UI-shaped dicts from ``to_ui_claim_from_orm`` / ``to_ui_remittance_from_orm``, -# neither of which carries the X12 ``payer_id`` (NM1*PR*PI qualifier). -# They carry ``payerName`` only, which is the human-readable name and not -# the URL key we cache on. Wiring a subscriber here would either need a -# DB lookup per event (re-deriving payer_id from Claim.id) or a different -# cache key — both are out of scope for this task. The 60s TTL keeps -# staleness bounded; a follow-up can wire targeted invalidation if the -# UI proves TTL-bounded staleness is unacceptable. - - -@app.get("/api/payers/{payer_id}/summary", dependencies=[Depends(matrix_gate)]) -def get_payer_summary(payer_id: str): - """Payer-level rollup for the drill-down panel. - - Returns billed/received totals, denial rate, and top providers for - the given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``). - Cached in-process for ``_SUMMARY_TTL_S`` seconds. - - 404 when no claims AND no remits reference this payer_id — the UI - uses that to distinguish a typo from a payer with zero traffic - (the latter would still return a valid 200 with zeroed totals). - """ - now = monotonic() - cached = _summary_cache.get(payer_id) - if cached and (now - cached[0]) < _SUMMARY_TTL_S: - return cached[1] - - log.debug("payer summary cache miss", extra={"payer_id": payer_id}) - - # Query claims + remits scoped to this payer_id. We bypass - # ``store.iter_claims`` because that helper filters by payer NAME - # substring, not by the X12 PI qualifier we use as the URL key. - with db.SessionLocal()() as s: - claim_rows: list[Claim] = ( - s.query(Claim).filter(Claim.payer_id == payer_id).all() - ) - claim_ids = [c.id for c in claim_rows] - remit_rows: list[Remittance] = [] - if claim_ids: - remit_rows = ( - s.query(Remittance) - .filter(Remittance.claim_id.in_(claim_ids)) - .all() - ) - - if not claim_rows and not remit_rows: - raise HTTPException( - status_code=404, - detail=f"Payer {payer_id!r} not found", - ) - - billed_total = sum(float(c.charge_amount or 0) for c in claim_rows) - received_total = sum(float(r.total_paid or 0) for r in remit_rows) - denied = sum( - 1 for c in claim_rows if c.state == ClaimState.DENIED - ) - claim_count = len(claim_rows) - denial_rate = (denied / claim_count) if claim_count else 0.0 - - provider_counts: dict[str, int] = {} - for c in claim_rows: - npi = c.provider_npi - if not npi: - continue - provider_counts[npi] = provider_counts.get(npi, 0) + 1 - top_providers = [ - {"npi": npi, "count": count} - for npi, count in sorted( - provider_counts.items(), key=lambda kv: -kv[1] - )[:5] - ] - - # Best-effort payer display name from the first claim's raw - # payer object (NM1*PR name element, e.g. "COHCPF"). Falls - # back to the id when no parsed envelope is available. - payer_name = payer_id - for c in claim_rows: - raw = c.raw_json or {} - p = raw.get("payer") if isinstance(raw, dict) else None - if isinstance(p, dict) and p.get("name"): - payer_name = p["name"] - break - - payload = { - "payer_id": payer_id, - "name": payer_name, - "claim_count": claim_count, - "billed_total": billed_total, - "received_total": received_total, - "denial_rate": denial_rate, - "top_providers": top_providers, - } - _summary_cache[payer_id] = (now, payload) - return payload - - -# --------------------------------------------------------------------------- # -# Auth routers (login/logout/me + admin user management) # -# --------------------------------------------------------------------------- # from cyclone.auth.routes import router as auth_router @@ -3316,5 +3193,15 @@ from cyclone.auth.admin import router as admin_users_router app.include_router(auth_router) app.include_router(admin_users_router, dependencies=[Depends(matrix_gate)]) +# SP36 Task 6 backward-compat: ``_clear_summary_cache`` moved to +# ``cyclone.api_routers.payers`` (it's a single-router helper per +# spec D4). The test fixture in ``tests/test_payer_summary.py`` +# calls ``api_mod._clear_summary_cache()`` between requests to wipe +# the in-process cache. Per the SP-N invariant that we don't +# rewrite tests for a structural refactor, expose the helper on +# this module's namespace via a one-line re-import. Delete this +# line once the test is updated to import from the new home. +from cyclone.api_routers.payers import _clear_summary_cache # noqa: F401 # SP36 backward-compat shim + __all__ = ["app"] diff --git a/backend/src/cyclone/api_routers/__init__.py b/backend/src/cyclone/api_routers/__init__.py index 281c24e..9fe7b58 100644 --- a/backend/src/cyclone/api_routers/__init__.py +++ b/backend/src/cyclone/api_routers/__init__.py @@ -17,6 +17,7 @@ from cyclone.api_routers import ( dashboard, eligibility, health, + payers, ta1_acks, ) @@ -27,6 +28,7 @@ routers: list[APIRouter] = [ dashboard.router, # gated eligibility.router, # gated health.router, # public — health probes must work pre-auth + payers.router, # gated ta1_acks.router, # gated ] diff --git a/backend/src/cyclone/api_routers/payers.py b/backend/src/cyclone/api_routers/payers.py new file mode 100644 index 0000000..da79f1e --- /dev/null +++ b/backend/src/cyclone/api_routers/payers.py @@ -0,0 +1,149 @@ +"""``GET /api/payers/{payer_id}/summary`` — payer-level rollup for the drill-down panel. + +Returns billed/received totals, denial rate, and the top providers for +a given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``). +Cached in-process for ``_SUMMARY_TTL_S`` seconds via a per-payer_id +memo. The drill-down UI hammers this on every hover; the underlying +query is O(claims) per payer, so the TTL keeps the panel responsive. + +Pubsub invalidation is intentionally NOT wired (see the long comment +in the body). The 60s TTL keeps staleness bounded; a follow-up can +wire targeted invalidation if the UI proves TTL-bounded staleness is +unacceptable. + +404 when no claims AND no remits reference this payer_id — the UI +uses that to distinguish a typo from a payer with zero traffic (the +latter would still return a valid 200 with zeroed totals). + +SP36 Task 6: this block moved here from ``api.py:3188`` (the +``SP21 Task 1.5: payer-level summary`` divider). +""" +from __future__ import annotations + +import logging +from time import monotonic + +from fastapi import APIRouter, Depends, HTTPException + +from cyclone import db +from cyclone.auth.deps import matrix_gate +from cyclone.db import Claim, ClaimState, Remittance + +log = logging.getLogger(__name__) + +router = APIRouter(dependencies=[Depends(matrix_gate)]) + +# Per-payer_id memoization for /api/payers/{payer_id}/summary. The drill- +# down UI hammers this on every hover; the underlying query is O(claims) +# per payer so a 60s TTL keeps the panel responsive. Process-local on +# purpose — invalidation is driven by the TTL alone for now (see note +# below). +_SUMMARY_TTL_S = 60.0 +_summary_cache: dict[str, tuple[float, dict]] = {} + + +def _clear_summary_cache() -> None: + """Test hook: wipe the process-local cache. + + The 60s TTL means tests that want to assert on a recomputed payload + must clear the cache between requests. Not used by production code. + """ + _summary_cache.clear() + + +# Pubsub invalidation is intentionally NOT wired. The ``claim_written`` +# and ``remittance_written`` payloads emitted by ``store.add`` are the +# UI-shaped dicts from ``to_ui_claim_from_orm`` / ``to_ui_remittance_from_orm``, +# neither of which carries the X12 ``payer_id`` (NM1*PR*PI qualifier). +# They carry ``payerName`` only, which is the human-readable name and not +# the URL key we cache on. Wiring a subscriber here would either need a +# DB lookup per event (re-deriving payer_id from Claim.id) or a different +# cache key — both are out of scope for this task. The 60s TTL keeps +# staleness bounded; a follow-up can wire targeted invalidation if the +# UI proves TTL-bounded staleness is unacceptable. + + +@router.get("/api/payers/{payer_id}/summary") +def get_payer_summary(payer_id: str): + """Payer-level rollup for the drill-down panel. + + Returns billed/received totals, denial rate, and top providers for + the given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``). + Cached in-process for ``_SUMMARY_TTL_S`` seconds. + + 404 when no claims AND no remits reference this payer_id — the UI + uses that to distinguish a typo from a payer with zero traffic + (the latter would still return a valid 200 with zeroed totals). + """ + now = monotonic() + cached = _summary_cache.get(payer_id) + if cached and (now - cached[0]) < _SUMMARY_TTL_S: + return cached[1] + + log.debug("payer summary cache miss", extra={"payer_id": payer_id}) + + # Query claims + remits scoped to this payer_id. We bypass + # ``store.iter_claims`` because that helper filters by payer NAME + # substring, not by the X12 PI qualifier we use as the URL key. + with db.SessionLocal()() as s: + claim_rows: list[Claim] = ( + s.query(Claim).filter(Claim.payer_id == payer_id).all() + ) + claim_ids = [c.id for c in claim_rows] + remit_rows: list[Remittance] = [] + if claim_ids: + remit_rows = ( + s.query(Remittance) + .filter(Remittance.claim_id.in_(claim_ids)) + .all() + ) + + if not claim_rows and not remit_rows: + raise HTTPException( + status_code=404, + detail=f"Payer {payer_id!r} not found", + ) + + billed_total = sum(float(c.charge_amount or 0) for c in claim_rows) + received_total = sum(float(r.total_paid or 0) for r in remit_rows) + denied = sum( + 1 for c in claim_rows if c.state == ClaimState.DENIED + ) + claim_count = len(claim_rows) + denial_rate = (denied / claim_count) if claim_count else 0.0 + + provider_counts: dict[str, int] = {} + for c in claim_rows: + npi = c.provider_npi + if not npi: + continue + provider_counts[npi] = provider_counts.get(npi, 0) + 1 + top_providers = [ + {"npi": npi, "count": count} + for npi, count in sorted( + provider_counts.items(), key=lambda kv: -kv[1] + )[:5] + ] + + # Best-effort payer display name from the first claim's raw + # payer object (NM1*PR name element, e.g. "COHCPF"). Falls + # back to the id when no parsed envelope is available. + payer_name = payer_id + for c in claim_rows: + raw = c.raw_json or {} + p = raw.get("payer") if isinstance(raw, dict) else None + if isinstance(p, dict) and p.get("name"): + payer_name = p["name"] + break + + payload = { + "payer_id": payer_id, + "name": payer_name, + "claim_count": claim_count, + "billed_total": billed_total, + "received_total": received_total, + "denial_rate": denial_rate, + "top_providers": top_providers, + } + _summary_cache[payer_id] = (now, payload) + return payload