From 48d4739864e4bc66e8cfe9f4fc69aa682db19384 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 19:35:17 -0600 Subject: [PATCH] feat(sp7): GET /api/claims/{id}/line-reconciliation endpoint --- backend/src/cyclone/api.py | 197 ++++++++++++++++++ backend/tests/test_api_line_reconciliation.py | 112 ++++++++++ 2 files changed, 309 insertions(+) create mode 100644 backend/tests/test_api_line_reconciliation.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index d62ba7c..e5010e9 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -1212,6 +1212,203 @@ def get_claim_detail_endpoint(claim_id: str) -> dict: return body +@app.get("/api/claims/{claim_id}/line-reconciliation") +def get_claim_line_reconciliation(claim_id: str) -> dict: + """Per-line reconciliation view for the ClaimDrawer tab. + + Spec §5.1. Returns the 837 service lines and 835 SVC composites + side-by-side, with per-line CAS adjustments and a summary block. + + Architecture note: 837 service lines live in ``Claim.raw_json`` + (not a separate ORM table), so the 837-side rows are read from the + JSON blob; the 835-side rows come from ``ServiceLinePayment`` ORM. + ``LineReconciliation.claim_service_line_number`` stores the 1-based + line number to join them. + """ + from sqlalchemy import select + from cyclone.db import ( + LineReconciliation, ServiceLinePayment, CasAdjustment, + ) + import json as _json + from decimal import Decimal + + with db.SessionLocal()() as s: + claim = s.get(db.Claim, claim_id) + if claim is None: + raise HTTPException( + status_code=404, + detail={"error": "Not found", "detail": f"Claim {claim_id} not found"}, + ) + + # 837 service lines: from raw_json. + raw = claim.raw_json or {} + claim_lines_raw = raw.get("service_lines") or [] + # Normalize to dicts for the response. + claim_lines = [_claim_line_dict(d) for d in claim_lines_raw] + + # 835 service payments: ORM rows from the matched remit. + remits = list( + s.execute( + select(db.Remittance).where(db.Remittance.claim_id == claim_id) + ).scalars().all() + ) + svc_payments: list[dict] = [] + svc_ids: list[int] = [] + if remits: + svc_rows = list( + s.execute( + select(ServiceLinePayment).where( + ServiceLinePayment.remittance_id.in_([r.id for r in remits]) + ).order_by(ServiceLinePayment.line_number) + ).scalars().all() + ) + for svc in svc_rows: + d = _svc_to_dict(svc) + svc_payments.append(d) + svc_ids.append(svc.id) + + # LineReconciliation rows. + lrs = list( + s.execute( + select(LineReconciliation).where(LineReconciliation.claim_id == claim_id) + ).scalars().all() + ) + # Index by claim_service_line_number and service_line_payment_id. + lr_by_claim_num: dict[int, LineReconciliation] = { + lr.claim_service_line_number: lr for lr in lrs if lr.claim_service_line_number is not None + } + lr_by_svc: dict[int, LineReconciliation] = { + lr.service_line_payment_id: lr for lr in lrs if lr.service_line_payment_id is not None + } + + # CAS rows grouped by svc id. + cas_by_svc: dict[int, list[CasAdjustment]] = {} + if svc_ids: + cas_rows = list( + s.execute( + select(CasAdjustment).where(CasAdjustment.service_line_payment_id.in_(svc_ids)) + ).scalars().all() + ) + for c in cas_rows: + cas_by_svc.setdefault(c.service_line_payment_id, []).append(c) + + # Build output lines array, preserving 837 order then 835-only. + svc_by_id: dict[int, dict] = {d["id"]: d for d in svc_payments} + lines_out: list[dict] = [] + billed_total = Decimal("0") + paid_total = Decimal("0") + adjustment_total = Decimal("0") + matched_count = 0 + used_svc_ids: set[int] = set() + + for cl in claim_lines: + billed_total += Decimal(str(cl["charge"])) + lr = lr_by_claim_num.get(cl["line_number"]) + if lr is None: + lines_out.append({ + "claim_service_line": cl, + "service_line_payment": None, + "status": "unmatched_837_only", + "adjustments": [], + }) + continue + svc_id = lr.service_line_payment_id + svc = svc_by_id.get(svc_id) if svc_id else None + if svc_id is not None: + used_svc_ids.add(svc_id) + cas_list = cas_by_svc.get(svc_id, []) if svc_id is not None else [] + cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0")) + if svc: + paid_total += Decimal(str(svc["payment"])) + adjustment_total += cas_total + if lr.status == "matched": + matched_count += 1 + lines_out.append({ + "claim_service_line": cl, + "service_line_payment": svc, + "status": lr.status, + "adjustments": [ + {"group_code": c.group_code, "reason_code": c.reason_code, + "amount": str(Decimal(str(c.amount)))} + for c in cas_list + ], + }) + + # 835-only lines (no claim match). + for lr in lrs: + if lr.claim_service_line_number is not None: + continue + svc_id = lr.service_line_payment_id + if svc_id is None: + continue + if svc_id in used_svc_ids: + continue + svc = svc_by_id.get(svc_id) + cas_list = cas_by_svc.get(svc_id, []) + cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0")) + if svc: + paid_total += Decimal(str(svc["payment"])) + adjustment_total += cas_total + lines_out.append({ + "claim_service_line": None, + "service_line_payment": svc, + "status": lr.status, + "adjustments": [ + {"group_code": c.group_code, "reason_code": c.reason_code, + "amount": str(Decimal(str(c.amount)))} + for c in cas_list + ], + }) + + return { + "claim_id": claim_id, + "summary": { + "billed_total": str(billed_total), + "paid_total": str(paid_total), + "adjustment_total": str(adjustment_total), + "matched_lines": matched_count, + "total_lines": len(claim_lines), + }, + "lines": lines_out, + } + + +def _claim_line_dict(d: dict) -> dict: + """Project an 837 service-line dict from ``Claim.raw_json`` to wire shape.""" + from decimal import Decimal + proc = d.get("procedure") or {} + charge = d.get("charge") + units = d.get("units") + return { + "line_number": d.get("line_number"), + "procedure_qualifier": proc.get("qualifier", "HC"), + "procedure_code": proc.get("code", ""), + "modifiers": proc.get("modifiers") or [], + "charge": str(Decimal(str(charge))) if charge is not None else "0", + "units": str(Decimal(str(units))) if units is not None else None, + "unit_type": d.get("unit_type"), + "service_date": d.get("service_date"), + } + + +def _svc_to_dict(svc) -> dict: + """Project an ORM ``ServiceLinePayment`` to wire shape.""" + import json as _json + from decimal import Decimal + return { + "id": svc.id, + "line_number": svc.line_number, + "procedure_qualifier": svc.procedure_qualifier, + "procedure_code": svc.procedure_code, + "modifiers": _json.loads(svc.modifiers_json or "[]"), + "charge": str(Decimal(str(svc.charge))), + "payment": str(Decimal(str(svc.payment))), + "units": str(Decimal(str(svc.units))) if svc.units is not None else None, + "unit_type": svc.unit_type, + "service_date": svc.service_date.isoformat() if svc.service_date else None, + } + + @app.get("/api/reconciliation/unmatched") def get_reconciliation_unmatched() -> dict: """Return unmatched Claims (left) and unmatched Remittances (right). diff --git a/backend/tests/test_api_line_reconciliation.py b/backend/tests/test_api_line_reconciliation.py new file mode 100644 index 0000000..c05abf3 --- /dev/null +++ b/backend/tests/test_api_line_reconciliation.py @@ -0,0 +1,112 @@ +"""SP7 — endpoint test for GET /api/claims/{id}/line-reconciliation. + +Spec §5.1. + +Architecture note: 837 service lines live in ``Claim.raw_json``, not a +separate ORM table, so the test seeds them via the raw_json blob. +""" +from datetime import date, datetime, timezone +from decimal import Decimal +import json +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from cyclone import db +from cyclone.api import app +from cyclone.parsers.models_835 import ClaimPayment, ServicePayment +from cyclone.reconcile import run +from cyclone.store import _persist_835_remit, _remittance_835_row + + +@pytest.fixture +def client(): + return TestClient(app) + + +def _seed(client): + """Insert one claim + matching remit + 4 service lines (3 matched, 1 unmatched 837). + Returns the claim id.""" + bid = str(uuid.uuid4()) + claim_id = "CLM-API-1" + svc_lines_json = [] + # 4 837 service lines: 99213, 99214, 99215, 90837 (last one is 837-only — no SVC) + for i, code in enumerate(["99213", "99214", "99215", "90837"], start=1): + svc_lines_json.append({ + "line_number": i, + "procedure": {"qualifier": "HC", "code": code, "modifiers": []}, + "charge": "100.00", + "unit_type": "UN", + "units": "1", + "place_of_service": "11", + "service_date": f"2026-06-0{i}", + "provider_reference": None, + }) + + with db.SessionLocal()() as s: + s.add(db.Batch( + id=bid, kind="835", input_filename="x.835", + parsed_at=datetime.now(timezone.utc), + totals_json={"total_claims": 1, "total_paid": "240"}, + validation_json={"passed": True, "warnings": [], "errors": []}, + raw_result_json={"_": "stub"}, + )) + s.add(db.Claim( + id=claim_id, + patient_control_number="PCN-API", + charge_amount=Decimal("300.00"), + provider_npi="1234567890", + payer_id="SKCO0", + service_date_from=date(2026, 6, 1), + service_date_to=date(2026, 6, 4), + state="submitted", + batch_id=bid, + raw_json={"service_lines": svc_lines_json}, + )) + # Remit pays the first 3 lines (line 4 is 837-only) + cp = ClaimPayment( + payer_claim_control_number="PCN-API", + status_code="1", + total_charge=Decimal("300.00"), + total_paid=Decimal("240.00"), + service_payments=[ + ServicePayment( + line_number=i, procedure_qualifier="HC", + procedure_code=code, charge=Decimal("100.00"), + payment=Decimal("80.00"), + units=Decimal("1"), + service_date=date(2026, 6, i), + ) + for i, code in enumerate(["99213", "99214", "99215"], start=1) + ], + ) + remit = _remittance_835_row(cp, bid) + s.add(remit) + s.flush() + _persist_835_remit(s, cp, remit.id) + s.commit() + + # Run reconcile so matched_remittance_id gets set + LineReconciliation rows persist + run(s, bid) + s.commit() + + return claim_id + + +def test_endpoint_returns_documented_shape(client): + claim_id = _seed(client) + r = client.get(f"/api/claims/{claim_id}/line-reconciliation") + assert r.status_code == 200, r.text + body = r.json() + assert body["claim_id"] == claim_id + assert body["summary"]["total_lines"] == 4 + assert body["summary"]["matched_lines"] == 3 + statuses = sorted(row["status"] for row in body["lines"]) + assert statuses == ["matched", "matched", "matched", "unmatched_837_only"] + + +def test_endpoint_404_for_unknown_claim(client): + r = client.get("/api/claims/UNKNOWN/line-reconciliation") + assert r.status_code == 404