From 299c1a85a3825ceba59b5ce9a4e03e3e1c5e2693 Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 6 Jul 2026 14:40:49 -0600 Subject: [PATCH] feat(sp36): extract eligibility router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move POST /api/eligibility/request and POST /api/eligibility/parse-271 from api.py to api_routers/eligibility.py, plus the _validate_eligibility_request single-router helper. Behaviour-preserving — endpoint URLs, params, status codes, response shapes, and 400/422/500 error envelopes are unchanged. The auth gate moves from per-route to router-level (semantically equivalent). --- backend/src/cyclone/api.py | 198 ---------------- backend/src/cyclone/api_routers/__init__.py | 2 + .../src/cyclone/api_routers/eligibility.py | 223 ++++++++++++++++++ 3 files changed, 225 insertions(+), 198 deletions(-) create mode 100644 backend/src/cyclone/api_routers/eligibility.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index ef1ea49..c4bf828 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -65,25 +65,14 @@ def _actor_user_id(request: Request) -> int | None: return getattr(user, "id", None) from cyclone.parsers.exceptions import CycloneParseError from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult -from cyclone.parsers.models_270 import ( - EligibilityBenefitInquiry, - InformationReceiver270, - InformationSource270, - ParseResult270, - Subscriber270, -) -from cyclone.parsers.models_271 import ParseResult271 from cyclone.parsers.models_835 import ParseResult835 from cyclone.parsers.payer import PayerConfig, PayerConfig835 -from cyclone.parsers.parse_270 import parse as parse_270_text -from cyclone.parsers.parse_271 import parse as parse_271_text from cyclone.parsers.parse_277ca import parse_277ca_text from cyclone.parsers.parse_837 import parse from cyclone.parsers.parse_835 import parse as parse_835 from cyclone.parsers.parse_999 import parse_999_text from cyclone.parsers.parse_ta1 import parse_ta1_text from cyclone.parsers.segments import tokenize as _tokenize_segments -from cyclone.parsers.serialize_270 import serialize_270 from cyclone.parsers.serialize_999 import serialize_999 from cyclone.parsers.serialize_837 import SerializeError as SerializeError837, serialize_837, serialize_837_for_resubmit from cyclone.parsers.batch_ack_builder import build_ack_for_batch @@ -2827,193 +2816,6 @@ async def activity_stream( # --------------------------------------------------------------------------- # -# --------------------------------------------------------------------------- # -# 270 / 271 eligibility (SP3 P4 T23–T24) — API-only, no DB persistence -# --------------------------------------------------------------------------- # - - -def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]: - """Build a :class:`ParseResult270` from a request body dict. - - The body shape is the minimum surface needed to build a valid 270 - inquiry (per spec section 3.4 — operator-driven, ephemeral): - - :: - - { - "subscriber": {first_name, last_name, member_id, dob}, - "provider": {npi, name}, - "payer": {id, name}, - "service_type_code": "1" - } - - Returns ``(ParseResult270, service_type_code)``. Raises - :class:`HTTPException` (400) when the body is missing required - fields. - """ - subscriber_in = body.get("subscriber") or {} - provider_in = body.get("provider") or {} - payer_in = body.get("payer") or {} - service_type_code = (body.get("service_type_code") or "").strip() - - # Required-field checks. We surface a single 400 with the first - # missing field name to match the rest of the API's error contract. - if not service_type_code: - raise HTTPException( - status_code=400, - detail={"error": "Bad request", "detail": "service_type_code is required"}, - ) - if not subscriber_in.get("member_id"): - raise HTTPException( - status_code=400, - detail={"error": "Bad request", "detail": "subscriber.member_id is required"}, - ) - if not provider_in.get("npi"): - raise HTTPException( - status_code=400, - detail={"error": "Bad request", "detail": "provider.npi is required"}, - ) - if not payer_in.get("name"): - raise HTTPException( - status_code=400, - detail={"error": "Bad request", "detail": "payer.name is required"}, - ) - - # Build the Pydantic models. The serializer handles all envelope - # generation (sender_id/receiver_id/control_number/transaction_date - # are filled in by the serializer with sensible defaults). - from datetime import date as _date - - subscriber_dob_raw = subscriber_in.get("dob") - subscriber_dob: _date | None = None - if subscriber_dob_raw: - try: - subscriber_dob = _date.fromisoformat(subscriber_dob_raw) - except (TypeError, ValueError) as exc: - raise HTTPException( - status_code=400, - detail={ - "error": "Bad request", - "detail": f"subscriber.dob must be YYYY-MM-DD: {exc}", - }, - ) from exc - - result = ParseResult270( - envelope=Envelope( - sender_id="SUBMITTERID", - receiver_id=str(payer_in.get("id") or "RECEIVERID"), - control_number="000000001", - transaction_date=_date.today(), - implementation_guide="005010X279A1", - ), - information_source=InformationSource270( - name=str(payer_in["name"]), - id=str(payer_in.get("id") or "") or None, - ), - information_receiver=InformationReceiver270( - name=str(provider_in.get("name") or ""), - npi=str(provider_in["npi"]), - ), - subscriber=Subscriber270( - member_id=str(subscriber_in["member_id"]), - first_name=str(subscriber_in.get("first_name") or "") or None, - last_name=str(subscriber_in.get("last_name") or "") or None, - dob=subscriber_dob, - ), - inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)], - summary=BatchSummary( - input_file="eligibility_request", - control_number="000000001", - transaction_date=_date.today(), - total_claims=1, - passed=1, - failed=0, - ), - ) - return result, service_type_code - - -@app.post("/api/eligibility/request", dependencies=[Depends(matrix_gate)]) -def post_eligibility_request(body: dict) -> Any: - """Build a 270 eligibility inquiry from a small JSON body. - - Returns ``{"raw_270_text": , "parsed": }`` - so the operator can either download the raw text (paste into a - payer portal) or render the parsed fields directly. Per spec - section 3.4, nothing is persisted to the DB. - """ - try: - result, _ = _validate_eligibility_request(body) - except HTTPException: - raise - except (KeyError, TypeError, ValueError) as exc: - raise HTTPException( - status_code=400, - detail={"error": "Bad request", "detail": f"Malformed body: {exc}"}, - ) from exc - - raw_270_text = serialize_270(result) - return { - "raw_270_text": raw_270_text, - "parsed": json.loads(result.model_dump_json()), - } - - -@app.post("/api/eligibility/parse-271", dependencies=[Depends(matrix_gate)]) -async def post_eligibility_parse_271( - file: UploadFile = File(...), -) -> Any: - """Parse a 271 eligibility response and return the structured summary. - - Accepts the raw 271 text as a file upload (multipart/form-data), - mirrors the ``/api/parse-999`` contract. Per spec section 3.4 the - result is NOT persisted — the operator re-pastes the 271 each - time they need a fresh read. - - The response body is a JSON object with three top-level keys: - ``coverage_benefits``, ``subscriber``, and ``summary``. 400 is - returned on empty / undecodable / malformed EDI; 200 on success. - """ - raw = await file.read() - if not raw: - return JSONResponse( - status_code=400, - content={"error": "Empty file", "detail": "Uploaded file contained no bytes."}, - ) - try: - text = raw.decode("utf-8") - except UnicodeDecodeError as exc: - return JSONResponse( - status_code=400, - content={"error": "Encoding error", "detail": str(exc)}, - ) - - try: - result = parse_271_text(text, input_file=file.filename or "") - except CycloneParseError as exc: - return JSONResponse( - status_code=400, - content={"error": "Parse error", "detail": str(exc)}, - ) - except Exception as exc: # pragma: no cover - safety net - log.exception("Unexpected parser failure on 271") - return JSONResponse( - status_code=500, - content={"error": "Internal server error", "detail": str(exc)}, - ) - - return { - "coverage_benefits": [ - json.loads(cb.model_dump_json()) for cb in result.coverage_benefits - ], - "subscriber": json.loads(result.subscriber.model_dump_json()), - "summary": json.loads(result.summary.model_dump_json()), - "envelope": json.loads(result.envelope.model_dump_json()), - "information_source": json.loads(result.information_source.model_dump_json()), - "information_receiver": json.loads(result.information_receiver.model_dump_json()), - } - - # --------------------------------------------------------------------------- # SP9: providers / payers / clearhouse endpoints # --------------------------------------------------------------------------- diff --git a/backend/src/cyclone/api_routers/__init__.py b/backend/src/cyclone/api_routers/__init__.py index 297d2fa..281c24e 100644 --- a/backend/src/cyclone/api_routers/__init__.py +++ b/backend/src/cyclone/api_routers/__init__.py @@ -15,6 +15,7 @@ from cyclone.api_routers import ( admin, claim_acks, dashboard, + eligibility, health, ta1_acks, ) @@ -24,6 +25,7 @@ routers: list[APIRouter] = [ admin.router, # gated claim_acks.router, # gated dashboard.router, # gated + eligibility.router, # gated health.router, # public — health probes must work pre-auth ta1_acks.router, # gated ] diff --git a/backend/src/cyclone/api_routers/eligibility.py b/backend/src/cyclone/api_routers/eligibility.py new file mode 100644 index 0000000..dd198ec --- /dev/null +++ b/backend/src/cyclone/api_routers/eligibility.py @@ -0,0 +1,223 @@ +"""``/api/eligibility/request`` and ``/api/eligibility/parse-271`` — API-only eligibility pair. + +Builds a 270 inquiry from a small JSON body and parses a 271 response. +Nothing is persisted to the DB — these are operator-driven, ephemeral +operations per SP3 (P4 T23–T24). The 270 serializer pulls X12 from a +``ParseResult270`` Pydantic; the 271 parser builds the same structure +in reverse from the wire format. + +Why these are not ``GET /api/eligibility/...``: the 270 build is +operator-initiated (pay-portal paste-back), so the inbound surface is +a JSON ``POST``. The 271 inbound is a multipart file upload — same +shape as ``/api/parse-999`` — so the file can be the actual 271 text +saved from the payer portal. + +SP36 Task 5: this block moved here from ``api.py:2832`` (``270 / 271 +eligibility`` divider). +""" +from __future__ import annotations + +import json +import logging +from datetime import date as _date +from typing import Any + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import JSONResponse + +from cyclone.auth.deps import matrix_gate +from cyclone.parsers.exceptions import CycloneParseError +from cyclone.parsers.models import BatchSummary, Envelope +from cyclone.parsers.models_270 import ( + EligibilityBenefitInquiry, + InformationReceiver270, + InformationSource270, + ParseResult270, + Subscriber270, +) +from cyclone.parsers.parse_271 import parse as parse_271_text +from cyclone.parsers.serialize_270 import serialize_270 + +log = logging.getLogger(__name__) + +router = APIRouter(dependencies=[Depends(matrix_gate)]) + + +def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]: + """Build a :class:`ParseResult270` from a request body dict. + + The body shape is the minimum surface needed to build a valid 270 + inquiry (per spec section 3.4 — operator-driven, ephemeral): + + :: + + { + "subscriber": {first_name, last_name, member_id, dob}, + "provider": {npi, name}, + "payer": {id, name}, + "service_type_code": "1" + } + + Returns ``(ParseResult270, service_type_code)``. Raises + :class:`HTTPException` (400) when the body is missing required + fields. + """ + subscriber_in = body.get("subscriber") or {} + provider_in = body.get("provider") or {} + payer_in = body.get("payer") or {} + service_type_code = (body.get("service_type_code") or "").strip() + + # Required-field checks. We surface a single 400 with the first + # missing field name to match the rest of the API's error contract. + if not service_type_code: + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": "service_type_code is required"}, + ) + if not subscriber_in.get("member_id"): + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": "subscriber.member_id is required"}, + ) + if not provider_in.get("npi"): + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": "provider.npi is required"}, + ) + if not payer_in.get("name"): + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": "payer.name is required"}, + ) + + # Build the Pydantic models. The serializer handles all envelope + # generation (sender_id/receiver_id/control_number/transaction_date + # are filled in by the serializer with sensible defaults). + subscriber_dob_raw = subscriber_in.get("dob") + subscriber_dob: _date | None = None + if subscriber_dob_raw: + try: + subscriber_dob = _date.fromisoformat(subscriber_dob_raw) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=400, + detail={ + "error": "Bad request", + "detail": f"subscriber.dob must be YYYY-MM-DD: {exc}", + }, + ) from exc + + result = ParseResult270( + envelope=Envelope( + sender_id="SUBMITTERID", + receiver_id=str(payer_in.get("id") or "RECEIVERID"), + control_number="000000001", + transaction_date=_date.today(), + implementation_guide="005010X279A1", + ), + information_source=InformationSource270( + name=str(payer_in["name"]), + id=str(payer_in.get("id") or "") or None, + ), + information_receiver=InformationReceiver270( + name=str(provider_in.get("name") or ""), + npi=str(provider_in["npi"]), + ), + subscriber=Subscriber270( + member_id=str(subscriber_in["member_id"]), + first_name=str(subscriber_in.get("first_name") or "") or None, + last_name=str(subscriber_in.get("last_name") or "") or None, + dob=subscriber_dob, + ), + inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)], + summary=BatchSummary( + input_file="eligibility_request", + control_number="000000001", + transaction_date=_date.today(), + total_claims=1, + passed=1, + failed=0, + ), + ) + return result, service_type_code + + +@router.post("/api/eligibility/request") +def post_eligibility_request(body: dict) -> Any: + """Build a 270 eligibility inquiry from a small JSON body. + + Returns ``{"raw_270_text": , "parsed": }`` + so the operator can either download the raw text (paste into a + payer portal) or render the parsed fields directly. Per spec + section 3.4, nothing is persisted to the DB. + """ + try: + result, _ = _validate_eligibility_request(body) + except HTTPException: + raise + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": f"Malformed body: {exc}"}, + ) from exc + + raw_270_text = serialize_270(result) + return { + "raw_270_text": raw_270_text, + "parsed": json.loads(result.model_dump_json()), + } + + +@router.post("/api/eligibility/parse-271") +async def post_eligibility_parse_271( + file: UploadFile = File(...), +) -> Any: + """Parse a 271 eligibility response and return the structured summary. + + Accepts the raw 271 text as a file upload (multipart/form-data), + mirrors the ``/api/parse-999`` contract. Per spec section 3.4 the + result is NOT persisted — the operator re-pastes the 271 each + time they need a fresh read. + + The response body is a JSON object with three top-level keys: + ``coverage_benefits``, ``subscriber``, and ``summary``. 400 is + returned on empty / undecodable / malformed EDI; 200 on success. + """ + raw = await file.read() + if not raw: + return JSONResponse( + status_code=400, + content={"error": "Empty file", "detail": "Uploaded file contained no bytes."}, + ) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + return JSONResponse( + status_code=400, + content={"error": "Encoding error", "detail": str(exc)}, + ) + + try: + result = parse_271_text(text, input_file=file.filename or "") + except CycloneParseError as exc: + return JSONResponse( + status_code=400, + content={"error": "Parse error", "detail": str(exc)}, + ) + except Exception as exc: # pragma: no cover - safety net + log.exception("Unexpected parser failure on 271") + return JSONResponse( + status_code=500, + content={"error": "Internal server error", "detail": str(exc)}, + ) + + return { + "coverage_benefits": [ + json.loads(cb.model_dump_json()) for cb in result.coverage_benefits + ], + "subscriber": json.loads(result.subscriber.model_dump_json()), + "summary": json.loads(result.summary.model_dump_json()), + "envelope": json.loads(result.envelope.model_dump_json()), + "information_source": json.loads(result.information_source.model_dump_json()), + "information_receiver": json.loads(result.information_receiver.model_dump_json()), + }