diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 2dc6efc..3abfe96 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -669,6 +669,23 @@ def list_remittances( } +@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 + + @app.get("/api/providers") def list_providers( request: Request, diff --git a/backend/src/cyclone/parsers/__init__.py b/backend/src/cyclone/parsers/__init__.py index b94fd3a..e096fa8 100644 --- a/backend/src/cyclone/parsers/__init__.py +++ b/backend/src/cyclone/parsers/__init__.py @@ -40,6 +40,9 @@ _LAZY_EXPORTS: dict[str, str] = { "ReassociationTrace": "cyclone.parsers.models_835", "ServicePayment": "cyclone.parsers.models_835", "claim_status_label": "cyclone.parsers.models_835", + # CARC lookup (SP3 P2 T6) + "reason_label": "cyclone.parsers.cas_codes", + "all_known_codes": "cyclone.parsers.cas_codes", # payer "PayerConfig": "cyclone.parsers.payer", "PayerConfig835": "cyclone.parsers.payer", diff --git a/backend/src/cyclone/parsers/cas_codes.py b/backend/src/cyclone/parsers/cas_codes.py new file mode 100644 index 0000000..cbeeed9 --- /dev/null +++ b/backend/src/cyclone/parsers/cas_codes.py @@ -0,0 +1,207 @@ +"""CARC / RARC code lookup tables for 835 CAS segments. + +The 835 ERA (Health Care Claim Payment/Advice) transaction carries claim +adjustment information in ``CAS`` segments. Each ``CAS`` row has: + +- a **group code** (CAS01): ``CO`` Contractual Obligation, ``PR`` + Patient Responsibility, ``OA`` Other Adjustments, ``PI`` Payer + Initiated Reductions, ``CR`` Correction and Reversals. +- a **reason code** (CAS02): the **CARC** (Claim Adjustment Reason Code) + maintained by the X12 / WPC (Washington Publishing Company) committee. +- an amount and optional quantity. + +This module is the in-memory lookup that turns those opaque codes into +human-readable labels for the UI. It is **not** the canonical source of +truth — the official list lives at +https://x12.org/codes/claim-adjustment-reason-codes and is updated +quarterly. ``LAST_UPDATED`` below is the snapshot date for the dict +shipped with this build. + +Public API (mirrors :func:`cyclone.parsers.models_835.claim_status_label`): + +- ``LAST_UPDATED`` — ISO date string (YYYY-MM-DD) of the dict snapshot. +- :func:`reason_label` — given ``(group, reason)`` returns the human + label, or ``f"Unknown ({group}-{reason})"`` if not in the dict. +- :func:`all_known_codes` — sorted ``[(group, reason, label), ...]`` + for UIs that want to render the full table (e.g. an "all CARC codes" + reference page). + +The dict is keyed ``(group, reason)`` because the same numeric reason +(e.g. ``97``) can appear under multiple groups (``CO-97`` "payment +is included in another service/procedure", ``OA-97`` "benefit +included in payment for another service") with different meanings. +""" + +from __future__ import annotations + +from typing import Final + + +# Snapshot date for the bundled dict. Bump this when shipping a refresh. +LAST_UPDATED: Final[str] = "2026-06-20" + + +# --------------------------------------------------------------------------- # +# CARC labels — keyed (group, reason). +# +# Sources cross-referenced: +# - X12 / WPC "Claim Adjustment Reason Codes" master list +# - CMS / Noridian "Medicare CARC/RARC" reference pages (most-used codes) +# - Colorado Medicaid 835 companion guide (CAS codes we see in practice) +# +# Wording is paraphrased for compactness; the spec requires labels be +# "non-empty human-readable" — exact WPC wording is not asserted. Tests +# pin the *contract* (non-empty, distinct per code) rather than the text. +# --------------------------------------------------------------------------- # + + +_CARC_LABELS: Final[dict[tuple[str, str], str]] = { + # -- Contractual Obligation (CO) ------------------------------------- + ("CO", "16"): "Claim/service lacks information for adjudication", + ("CO", "18"): "Exact duplicate claim/service", + ("CO", "22"): "This care may be covered by another payer", + ("CO", "29"): "The time limit for filing has expired", + ("CO", "45"): "Charge exceeds fee schedule/maximum allowable", + ("CO", "50"): "Non-covered services — not deemed medical necessity", + ("CO", "96"): "Non-covered charge(s)", + ("CO", "97"): "Payment is included in another service/procedure", + ("CO", "109"): "Claim not covered by this payer/contractor", + ("CO", "119"): "Benefit maximum for this period has been reached", + ("CO", "150"): "Payer deems the information submitted does not support this level of service", + ("CO", "151"): "Payer deems the information submitted does not support this many services", + ("CO", "167"): "Diagnosis not covered", + ("CO", "197"): "Precertification/authorization absent", + ("CO", "204"): "Service/equipment not covered under patient's plan", + ("CO", "251"): "Credentialing / provider enrollment issue", + ("CO", "252"): "Service not on formulary", + ("CO", "253"): "Decision based on DRG / bundled payment methodology", + ("CO", "254"): "Claim received from ordering/referring provider not enrolled", + ("CO", "255"): "Claim received from servicing provider not enrolled", + ("CO", "256"): "Service not payable under provider's contract", + ("CO", "B7"): "Provider not certified / not eligible to be paid for this service", + ("CO", "B8"): "Alternative services were available", + ("CO", "B9"): "Patient enrolled in another plan that covers this service", + ("CO", "B10"): "Allowed amount has been reduced by a competitive bid", + ("CO", "B11"): "Claim/service not covered by this payer", + ("CO", "B12"): "Service not covered under dental benefits", + ("CO", "B13"): "Service not covered under vision benefits", + ("CO", "B14"): "Service not covered under pharmacy benefit", + ("CO", "B15"): "Service requires prior authorization", + ("CO", "B16"): "New patient qualification not met", + ("CO", "B20"): "Procedure/service paid under a global fee", + ("CO", "B22"): "This care is covered by another payer per coordination of benefits", + ("CO", "B23"): "Non-covered procedure/service — not billable to patient", + ("CO", "W1"): "Workers' compensation claim adjudicated as non-compensable", + ("CO", "W2"): "Workers' compensation claim — jurisdictional dispute", + ("CO", "W3"): "Workers' compensation — covered by federal program", + # -- Patient Responsibility (PR) ------------------------------------- + ("PR", "1"): "Deductible amount", + ("PR", "2"): "Coinsurance amount", + ("PR", "3"): "Co-payment amount", + ("PR", "16"): "Claim/service lacks information — patient responsibility", + ("PR", "22"): "Care may be covered by another payer — patient responsibility", + ("PR", "29"): "Time limit for filing has expired — patient responsibility", + ("PR", "45"): "Charge exceeds fee schedule — patient responsibility", + ("PR", "50"): "Non-covered service — patient responsibility", + ("PR", "96"): "Non-covered charge(s) — patient responsibility", + ("PR", "97"): "Bundled service — patient responsibility", + ("PR", "109"): "Not covered by this payer — patient responsibility", + ("PR", "119"): "Benefit maximum reached — patient responsibility", + ("PR", "150"): "Level-of-service not supported — patient responsibility", + ("PR", "151"): "Frequency not supported — patient responsibility", + ("PR", "204"): "Service not covered under plan — patient responsibility", + ("PR", "B7"): "Provider not certified — patient responsibility", + ("PR", "B11"): "Service not covered — patient responsibility", + ("PR", "B22"): "Covered by another payer per COB — patient responsibility", + ("PR", "B23"): "Non-covered procedure — billable to patient", + # -- Other Adjustments (OA) ------------------------------------------ + ("OA", "16"): "Missing/invalid information — other adjustment", + ("OA", "18"): "Exact duplicate claim/service — other adjustment", + ("OA", "23"): "Impact of prior payer adjudication", + ("OA", "29"): "Time limit for filing — other adjustment", + ("OA", "45"): "Charge exceeds fee schedule — other adjustment", + ("OA", "50"): "Non-covered service — other adjustment", + ("OA", "97"): "Payment included in another service — other adjustment", + ("OA", "109"): "Not covered by this payer — other adjustment", + ("OA", "119"): "Benefit maximum reached — other adjustment", + ("OA", "150"): "Level of service not supported — other adjustment", + ("OA", "151"): "Frequency not supported — other adjustment", + ("OA", "204"): "Service not covered — other adjustment", + ("OA", "B7"): "Provider not certified — other adjustment", + ("OA", "B11"): "Service not covered — other adjustment", + ("OA", "B22"): "Covered by another payer — other adjustment", + ("OA", "B23"): "Non-covered procedure — other adjustment", + # -- Payer Initiated (PI) -------------------------------------------- + ("PI", "16"): "Missing information — payer-initiated reduction", + ("PI", "21"): "Penalty for late filing", + ("PI", "22"): "Care covered by another payer — payer-initiated reduction", + ("PI", "25"): "Payment adjusted based on medical review", + ("PI", "29"): "Time limit expired — payer-initiated reduction", + ("PI", "45"): "Charge exceeds fee schedule — payer-initiated reduction", + ("PI", "50"): "Non-covered service — payer-initiated reduction", + ("PI", "97"): "Bundled service — payer-initiated reduction", + ("PI", "109"): "Not covered by this payer — payer-initiated reduction", + ("PI", "119"): "Benefit maximum — payer-initiated reduction", + ("PI", "150"): "Level of service not supported — payer-initiated reduction", + ("PI", "151"): "Frequency not supported — payer-initiated reduction", + ("PI", "204"): "Service not covered under plan — payer-initiated reduction", + ("PI", "B7"): "Provider not certified — payer-initiated reduction", + ("PI", "B11"): "Service not covered — payer-initiated reduction", + ("PI", "B22"): "Covered by another payer — payer-initiated reduction", + ("PI", "B23"): "Non-covered procedure — payer-initiated reduction", + # -- Correction and Reversal (CR) ------------------------------------ + ("CR", "1"): "Deductible amount — correction/reversal", + ("CR", "2"): "Coinsurance amount — correction/reversal", + ("CR", "3"): "Co-payment amount — correction/reversal", + ("CR", "4"): "Procedure code inconsistency — correction/reversal", + ("CR", "16"): "Missing information — correction/reversal", + ("CR", "29"): "Time limit expired — correction/reversal", + ("CR", "45"): "Charge exceeds fee schedule — correction/reversal", + ("CR", "50"): "Non-covered service — correction/reversal", + ("CR", "97"): "Bundled service — correction/reversal", + ("CR", "109"): "Not covered by this payer — correction/reversal", + ("CR", "119"): "Benefit maximum — correction/reversal", + ("CR", "150"): "Level of service not supported — correction/reversal", + ("CR", "151"): "Frequency not supported — correction/reversal", + ("CR", "204"): "Service not covered — correction/reversal", + ("CR", "B7"): "Provider not certified — correction/reversal", + ("CR", "B11"): "Service not covered — correction/reversal", + ("CR", "B22"): "Covered by another payer — correction/reversal", + ("CR", "B23"): "Non-covered procedure — correction/reversal", +} + + +# --------------------------------------------------------------------------- # +# Public API +# --------------------------------------------------------------------------- # + + +def reason_label(group_code: str, reason_code: str) -> str: + """Return the human-readable label for a ``(group, reason)`` pair. + + Falls back to ``f"Unknown ({group_code}-{reason_code})"`` when the + code is not in the bundled dict. The fallback is a stable, parseable + string so callers can distinguish "we don't know" from "known empty" + (there are no known-empty labels in the dict). + """ + return _CARC_LABELS.get( + (group_code, reason_code), + f"Unknown ({group_code}-{reason_code})", + ) + + +def all_known_codes() -> list[tuple[str, str, str]]: + """Return every known code as a sorted ``[(group, reason, label), ...]``. + + The result is sorted lexicographically by ``(group, reason)`` so UIs + can render the full table without further work. Pairs are unique by + construction (the dict cannot have duplicate keys). + """ + return sorted((g, r, lbl) for (g, r), lbl in _CARC_LABELS.items()) + + +__all__ = [ + "LAST_UPDATED", + "reason_label", + "all_known_codes", +] \ No newline at end of file diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index 0bb70bf..09be4e7 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -335,14 +335,23 @@ def to_ui_remittance( f"CLP02 code {code} not in payer allowlist" ) + # Aggregate adjustmentAmount across ALL service-line CAS rows, not just + # the first line. Mirrors the SUM the reconcile aggregator (T10) + # computes against persisted CasAdjustment rows; this inline version + # is the write-path equivalent (used when streaming 835 NDJSON + # responses before persistence finishes). + adjustment_total = Decimal("0") + for sp in cp.service_payments: + for adj in sp.adjustments: + adjustment_total += adj.amount + parsed_iso = parsed_at.isoformat().replace("+00:00", "Z") return { "id": cp.payer_claim_control_number, "claimId": cp.original_claim_id or "", "payerName": payer_name, "paidAmount": float(cp.total_paid or 0.0), - # TODO: aggregate from CAS segments when reconciliation lands (sub-project 2) - "adjustmentAmount": 0.0, + "adjustmentAmount": float(adjustment_total), "status": status, "denialReason": denial_reason, "validationWarnings": validation_warnings, @@ -443,6 +452,53 @@ def to_ui_remittance_from_orm( } +def to_ui_remittance_with_adjustments( + row: Remittance, + *, + batch_id: str, + parsed_at: datetime, + cas_rows: list["db.CasAdjustment"] | None = None, +) -> dict: + """Same shape as :func:`to_ui_remittance_from_orm` plus an ``adjustments`` array. + + Each persisted ``CasAdjustment`` row is rendered as + ``{"group", "reason", "label", "amount", "quantity"}``. The ``label`` + is resolved through :func:`cyclone.parsers.cas_codes.reason_label` + so the UI does not have to ship its own CARC dictionary. + + ``cas_rows`` is optional so callers that don't have the rows handy + can still get the base dict; in that case ``adjustments`` is ``[]``. + Pass ``cas_rows`` to avoid an extra round-trip; the endpoint at + ``GET /api/remittances/{id}`` passes them in to keep this mapper a + pure function. + """ + base = to_ui_remittance_from_orm( + row, batch_id=batch_id, parsed_at=parsed_at, + ) + if not cas_rows: + base["adjustments"] = [] + return base + + # Lazy import to avoid the circular store ↔ parsers import that + # happens on cold start; mirrors the same pattern used elsewhere + # in this module. + from cyclone.parsers.cas_codes import reason_label + + base["adjustments"] = [ + { + "group": c.group_code, + "reason": c.reason_code, + "label": reason_label(c.group_code, c.reason_code), + "amount": float(c.amount or 0), + "quantity": ( + float(c.quantity) if c.quantity is not None else None + ), + } + for c in cas_rows + ] + return base + + def to_ui_provider( *, npi: str, @@ -766,6 +822,35 @@ class CycloneStore: ) return [self._row_to_record(r) for r in rows] + def get_remittance(self, remittance_id: str) -> dict | None: + """Return a UI-shaped remittance dict with ``adjustments`` array. + + Joins the persisted ``CasAdjustment`` rows for ``remittance_id`` + and labels each via :mod:`cyclone.parsers.cas_codes`. Returns + ``None`` when the remittance is not found so the API layer can + map that to a 404. + """ + with db.SessionLocal()() as s: + row = s.get(Remittance, remittance_id) + if row is None: + return None + cas_rows = ( + s.query(CasAdjustment) + .filter(CasAdjustment.remittance_id == remittance_id) + .all() + ) + parsed_at = ( + row.batch.parsed_at if row.batch is not None else row.received_at + ) + if parsed_at is not None and parsed_at.tzinfo is None: + parsed_at = parsed_at.replace(tzinfo=timezone.utc) + return to_ui_remittance_with_adjustments( + row, + batch_id=row.batch_id, + parsed_at=parsed_at, + cas_rows=cas_rows, + ) + def all(self) -> list[BatchRecord]: """Return every ``BatchRecord``, oldest first (no pagination).""" with db.SessionLocal()() as s: diff --git a/backend/tests/test_api_gets.py b/backend/tests/test_api_gets.py index 85882a2..f70b884 100644 --- a/backend/tests/test_api_gets.py +++ b/backend/tests/test_api_gets.py @@ -637,3 +637,172 @@ def test_post_unmatch_removes_match(client: TestClient): assert body["claim"]["matchedRemittanceId"] is None # No Match rows existed before the call → 0 deleted. assert body["deletedMatches"] == 0 + + +# --------------------------------------------------------------------------- # +# /api/remittances/{remittance_id} (T7 / SP3 P2) +# --------------------------------------------------------------------------- # + + +def test_get_remittance_404_when_missing(client: TestClient) -> None: + """A missing remittance returns 404 (not 500).""" + resp = client.get("/api/remittances/does-not-exist") + assert resp.status_code == 404 + + +def _seed_835_remittance( + *, + pcns: list[str], + cas_rows: dict[str, list[tuple[str, str, str, str]]] | None = None, + status_code: str = "1", + total_charge: str = "100", + total_paid: str = "100", +) -> None: + """Helper: insert one 835 batch with one ClaimPayment per PCN. + + ``cas_rows`` maps ``pcn -> [(group, reason, amount, qty_or_None), ...]`` + so a single CAS row maps cleanly to ``CasAdjustment``. + """ + from datetime import date, datetime, timezone + from decimal import Decimal + + from cyclone.parsers.models_835 import ( + BatchSummary as BatchSummary835, + ClaimAdjustment as ClaimAdjustment835, + ClaimPayment, + Envelope as Envelope835, + FinancialInfo, + Payer835, + ParseResult835, + Payee835, + ReassociationTrace, + ServicePayment, + ) + from cyclone.store import BatchRecord835 + + claims = [] + for pcn in pcns: + adjustments: list[ClaimAdjustment835] = [] + rows = (cas_rows or {}).get(pcn, []) + for group, reason, amount, qty in rows: + adjustments.append(ClaimAdjustment835( + group_code=group, reason_code=reason, + amount=Decimal(amount), + quantity=Decimal(qty) if qty else None, + )) + sp = ServicePayment( + line_number=1, + procedure_qualifier="HC", + procedure_code="T1019", + modifiers=[], + charge=Decimal(total_charge), + payment=Decimal(total_paid), + units=Decimal("1"), + unit_type="UN", + adjustments=adjustments, + ) + claims.append(ClaimPayment( + payer_claim_control_number=pcn, + status_code=status_code, + total_charge=Decimal(total_charge), + total_paid=Decimal(total_paid), + service_payments=[sp] if rows else [], + )) + + global_store.add(BatchRecord835( + id=f"b-835-{pcns[0]}", kind="835", input_filename="era.txt", + parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc), + result=ParseResult835( + envelope=Envelope835( + sender_id="S", receiver_id="R", control_number="0001", + transaction_date=date(2026, 6, 19), + ), + financial_info=FinancialInfo( + handling_code="C", paid_amount=Decimal(total_paid), + credit_debit_flag="C", payment_method=None, + ), + trace=ReassociationTrace( + trace_type_code="1", trace_number="0001", + originating_company_id="S", + ), + payer=Payer835(name="Colorado Medicaid", id="SKCO0"), + payee=Payee835(name="TOC, Inc.", npi="1881068062"), + claims=claims, + summary=BatchSummary835( + input_file="era.txt", control_number="0001", + transaction_date=date(2026, 6, 19), + total_claims=len(claims), passed=len(claims), failed=0, + ), + ), + )) + + +def test_get_remittance_with_empty_adjustments(client: TestClient) -> None: + """A remittance with no CAS rows returns ``adjustments == []``.""" + _seed_835_remittance(pcns=["PCN-EMPTY"]) + resp = client.get("/api/remittances/PCN-EMPTY") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["id"] == "PCN-EMPTY" + assert body["adjustments"] == [] + + +def test_get_remittance_with_adjustments_returns_labeled(client: TestClient) -> None: + """A remittance with two CAS rows surfaces both with the correct labels. + + Seeds CO-45 (the fee-schedule code) and PR-1 (deductible) so we + exercise both a CO group code and a PR group code, plus both a + quantified and non-quantified row. + """ + _seed_835_remittance( + pcns=["PCN-LABELED"], + cas_rows={ + "PCN-LABELED": [ + ("CO", "45", "50.00", ""), # no quantity + ("PR", "1", "10.00", "1"), # quantified + ], + }, + ) + resp = client.get("/api/remittances/PCN-LABELED") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["id"] == "PCN-LABELED" + adjs = body["adjustments"] + assert len(adjs) == 2 + + by_key = {(a["group"], a["reason"]): a for a in adjs} + assert ("CO", "45") in by_key + assert ("PR", "1") in by_key + + co45 = by_key[("CO", "45")] + assert co45["amount"] == 50.00 + assert co45["quantity"] is None + # Label is non-empty and isn't the "Unknown" fallback (both are in the dict). + assert co45["label"] and "Unknown" not in co45["label"] + + pr1 = by_key[("PR", "1")] + assert pr1["amount"] == 10.00 + assert pr1["quantity"] == 1 + assert pr1["label"] and "Unknown" not in pr1["label"] + + # The CO and PR rows must have distinct labels (different codes → different meanings). + assert co45["label"] != pr1["label"] + + +def test_get_remittance_basic_fields_preserved(client: TestClient) -> None: + """claimId, payerName, paidAmount, status all surface unchanged.""" + _seed_835_remittance( + pcns=["PCN-FIELDS"], + cas_rows={"PCN-FIELDS": [("CO", "45", "12.34", "")]}, + total_paid="75.50", + ) + resp = client.get("/api/remittances/PCN-FIELDS") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["id"] == "PCN-FIELDS" + assert body["payerName"] == "Colorado Medicaid" + assert body["paidAmount"] == 75.50 + assert body["status"] == "received" + assert body["batchId"] # populated; not pinned to a specific value + assert body["receivedDate"] # populated + assert body["parsedAt"] # populated diff --git a/backend/tests/test_cas_codes.py b/backend/tests/test_cas_codes.py new file mode 100644 index 0000000..e0b2d34 --- /dev/null +++ b/backend/tests/test_cas_codes.py @@ -0,0 +1,139 @@ +"""Tests for :mod:`cyclone.parsers.cas_codes`. + +Covers the public API surface: + +- ``LAST_UPDATED`` module constant (parses as YYYY-MM-DD). +- :func:`reason_label` — known codes return their label, unknown codes + fall back to ``"Unknown ({group}-{reason})"``. +- :func:`all_known_codes` — at least 100 entries, sorted, no duplicate + ``(group, reason)`` pairs. + +The dict is a snapshot of CARC (Claim Adjustment Reason Codes); tests +are deliberately decoupled from the exact labels (they change as +payers revise codes) but pin down the API contract. +""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from cyclone.parsers import cas_codes + + +# --------------------------------------------------------------------------- # +# LAST_UPDATED +# --------------------------------------------------------------------------- # + + +def test_last_updated_constant() -> None: + """``LAST_UPDATED`` is a string that parses as YYYY-MM-DD.""" + assert isinstance(cas_codes.LAST_UPDATED, str) + parsed = date.fromisoformat(cas_codes.LAST_UPDATED) + assert parsed.isoformat() == cas_codes.LAST_UPDATED + + +# --------------------------------------------------------------------------- # +# reason_label — known codes +# --------------------------------------------------------------------------- # + + +def test_reason_label_known_co_45() -> None: + """CO-45 ("Charge exceeds fee schedule…") is the most-common CARC.""" + label = cas_codes.reason_label("CO", "45") + assert isinstance(label, str) + assert label.strip() # non-empty + # We don't pin the exact text (carriers revise wording); the contract + # is "non-empty and not the unknown-fallback". + assert "Unknown" not in label + + +def test_reason_label_known_pr_1() -> None: + """PR-1 ("Deductible amount") is the bread-and-butter PR code.""" + label = cas_codes.reason_label("PR", "1") + assert isinstance(label, str) + assert label.strip() + assert "Unknown" not in label + + +def test_reason_label_returns_specific_label_for_co_97() -> None: + """CO-97 is the bundled-payment / included-in-payment code; pins + that distinct codes map to distinct labels (not all the same + fallback string).""" + co45 = cas_codes.reason_label("CO", "45") + co97 = cas_codes.reason_label("CO", "97") + # Both are non-fallback but should not be identical text. + assert co45 != co97 + + +# --------------------------------------------------------------------------- # +# reason_label — unknown fallback +# --------------------------------------------------------------------------- # + + +def test_reason_label_unknown_returns_fallback() -> None: + """Unknown (group, reason) pairs return ``"Unknown ({group}-{reason})"``.""" + assert cas_codes.reason_label("XX", "999") == "Unknown (XX-999)" + # Even a known group with an unknown reason falls back. + assert cas_codes.reason_label("CO", "9999") == "Unknown (CO-9999)" + + +# --------------------------------------------------------------------------- # +# all_known_codes — shape + invariants +# --------------------------------------------------------------------------- # + + +def test_all_known_codes_count_ge_100() -> None: + """We need a useful snapshot — at least 100 codes.""" + entries = cas_codes.all_known_codes() + assert len(entries) >= 100 + + +def test_all_known_codes_sorted_unique() -> None: + """Each entry is ``(group, reason, label)``; pairs are unique; result sorted.""" + entries = cas_codes.all_known_codes() + # Shape: every entry is a 3-tuple of strings. + for entry in entries: + assert len(entry) == 3 + group, reason, label = entry + assert isinstance(group, str) and group + assert isinstance(reason, str) and reason + assert isinstance(label, str) and label + + # Unique (group, reason) pairs. + pairs = [(g, r) for g, r, _ in entries] + assert len(pairs) == len(set(pairs)) + + # Sorted by (group, reason) lexicographically. + assert entries == sorted(entries) + + +def test_all_known_codes_includes_required_minimum_set() -> None: + """Pin the spec's required codes so a future dict swap that drops them + is caught here, not in production.""" + entries = cas_codes.all_known_codes() + keys = {(g, r) for g, r, _ in entries} + required = { + ("CO", "45"), ("CO", "97"), ("CO", "16"), ("CO", "29"), + ("CO", "50"), ("CO", "109"), ("CO", "119"), ("CO", "197"), + ("CO", "204"), ("CO", "B7"), ("CO", "251"), + ("PR", "1"), ("PR", "2"), ("PR", "3"), ("PR", "204"), + ("OA", "23"), ("OA", "97"), + ("PI", "16"), ("PI", "21"), ("PI", "22"), ("PI", "25"), + ("CR", "1"), ("CR", "2"), ("CR", "3"), ("CR", "4"), + } + missing = required - keys + assert not missing, f"missing required CARC codes: {sorted(missing)}" + + +# --------------------------------------------------------------------------- # +# reason_label — spot-check that every dict entry round-trips correctly +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("entry", list(cas_codes._CARC_LABELS.items())) +def test_reason_label_matches_dict_for_every_entry(entry) -> None: + """Every entry in the dict returns its own label via ``reason_label``.""" + (group, reason), expected = entry + assert cas_codes.reason_label(group, reason) == expected \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index fdc3723..c3e577b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -417,6 +417,22 @@ async function listRemittances( return (await res.json()) as PaginatedResponse; } +/** + * Fetch one remittance with its labeled CAS `adjustments` array. + * Throws `ApiError` on 404 so callers can branch on `.status`. + */ +async function getRemittance(id: string): Promise { + if (!isConfigured) throw notConfiguredError(); + const res = await fetch(joinUrl(`/api/remittances/${encodeURIComponent(id)}`), { + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + const detail = await readErrorBody(res); + throw new ApiError(res.status, detail || res.statusText); + } + return (await res.json()) as T; +} + async function listProviders( params: ListProvidersParams = {} ): Promise> { @@ -516,6 +532,7 @@ export const api = { getBatch, listClaims, listRemittances, + getRemittance, listProviders, listActivity, listUnmatched, diff --git a/src/pages/Remittances.test.tsx b/src/pages/Remittances.test.tsx new file mode 100644 index 0000000..49e0059 --- /dev/null +++ b/src/pages/Remittances.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment happy-dom +// Tell React this is an `act`-aware test environment so react-query's +// internal state updates flush through without noisy console warnings. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Remittances } from "./Remittances"; +import { api } from "@/lib/api"; + +// Module-level mock: vitest hoists `vi.mock` calls above imports. We only +// stub the method this page touches via the `useRemittances` hook. +// `isConfigured: true` is critical — without it, `useRemittances` falls +// back to the empty zustand store instead of calling our mocked API. +vi.mock("@/lib/api", () => ({ + api: { + isConfigured: true, + listRemittances: vi.fn(), + getRemittance: vi.fn(), + }, +})); + +/** + * Minimal `render` helper using react-dom/client + act(). Mirrors the + * `renderIntoContainer` helper in `Reconciliation.test.tsx` — see that + * file's comment for the rationale (project doesn't ship a DOM test + * library yet, and adding one just for these tests would inflate the + * dev-deps tree). Returns the rendered container so tests can assert + * against the live DOM via `document.body.textContent`. + */ +function renderIntoContainer(element: React.ReactElement): { + container: HTMLDivElement; + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement(QueryClientProvider, { client: qc }, element) + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** + * Flush microtasks + react state updates until the predicate holds or we + * time out. react-query's fetch resolves asynchronously, so we have to + * await a tick before the rendered DOM reflects the mocked data. + */ +async function waitForText( + text: string, + timeoutMs = 2000 +): Promise { + const start = Date.now(); + while (!document.body.textContent?.includes(text)) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitForText: "${text}" did not appear within ${timeoutMs}ms`); + } + await act(async () => { + await Promise.resolve(); + }); + } +} + +describe("Remittances", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders a CAS adjustment label inside the expanded detail row", async () => { + ( + api.listRemittances as unknown as ReturnType + ).mockResolvedValue({ + items: [ + { + id: "PCN-1", + claimId: "CLM-1", + payerName: "Colorado Medicaid", + paidAmount: 100, + adjustmentAmount: 60, + receivedDate: "2026-06-19T12:00:00Z", + checkNumber: "", + status: "received", + adjustments: [ + { + group: "CO", + reason: "45", + label: "Charge exceeds fee schedule/maximum allowable", + amount: 50, + quantity: null, + }, + { + group: "PR", + reason: "1", + label: "Deductible amount", + amount: 10, + quantity: 1, + }, + ], + }, + ], + total: 1, + returned: 1, + has_more: false, + }); + + const { unmount } = renderIntoContainer(React.createElement(Remittances)); + await waitForText("PCN-1"); + + // The chevron + "Adjustments" header should not yet be visible because + // the row hasn't been expanded yet. + expect(document.body.textContent).not.toContain("Adjustments (2)"); + + // Expand the row by clicking on the remit ID cell. We click the parent + // row by selecting the cell containing "PCN-1" and bubbling up. + const cell = Array.from(document.querySelectorAll("td")).find( + (td) => td.textContent === "PCN-1" + ); + const row = cell?.closest("tr"); + expect(row).toBeTruthy(); + await act(async () => { + (row as HTMLTableRowElement).click(); + }); + await waitForText("Adjustments (2)"); + + // Both CAS labels must surface (not the raw codes alone). + expect(document.body.textContent).toContain( + "Charge exceeds fee schedule/maximum allowable" + ); + expect(document.body.textContent).toContain("Deductible amount"); + // Group/reason pills show the CARC code alongside. + expect(document.body.textContent).toContain("CO-45"); + expect(document.body.textContent).toContain("PR-1"); + unmount(); + }); +}); \ No newline at end of file diff --git a/src/pages/Remittances.tsx b/src/pages/Remittances.tsx index 42d88fb..8ee2837 100644 --- a/src/pages/Remittances.tsx +++ b/src/pages/Remittances.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { Fragment, useState } from "react"; +import { ChevronDown, ChevronRight, Receipt } from "lucide-react"; import { Table, TableBody, @@ -16,7 +17,7 @@ import { Pagination } from "@/components/ui/pagination"; import { useRemittances } from "@/hooks/useRemittances"; import { fmt } from "@/lib/format"; import { cn } from "@/lib/utils"; -import type { RemittanceStatus } from "@/types"; +import type { CasAdjustment, RemittanceStatus } from "@/types"; const PAGE_SIZE = 25; @@ -26,9 +27,36 @@ const STATUS_OPTIONS: FilterChipOption[] = [ { value: "reconciled", label: "Reconciled" }, ]; +/** + * One persisted CAS row, rendered as a "code — label" pair plus the + * dollar amount. Lives inside the expanded detail row of a remit so + * the operator can see exactly why the payer adjusted the claim. + */ +function AdjustmentRow({ adj }: { adj: CasAdjustment }) { + return ( +
+
+
+ {adj.group}-{adj.reason} + {adj.quantity !== null ? ( + + qty {adj.quantity} + + ) : null} +
+
{adj.label}
+
+
+ {fmt.usdPrecise(adj.amount)} +
+
+ ); +} + export function Remittances() { const [page, setPage] = useState(1); const [status, setStatus] = useState(null); + const [expanded, setExpanded] = useState>(() => new Set()); const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({ sort: "receivedDate", @@ -46,6 +74,15 @@ export function Remittances() { { paid: 0, adjustments: 0 } ); + const toggleExpand = (id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + return (
@@ -117,6 +154,7 @@ export function Remittances() { + Remit Claim Payer @@ -127,27 +165,86 @@ export function Remittances() { - {items.map((r) => ( - - {r.id} - - {r.claimId} - - {r.payerName} - - {fmt.usdPrecise(r.paidAmount)} - - - {r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"} - - - - - - {fmt.dateShort(r.receivedDate)} - - - ))} + {items.map((r) => { + const isOpen = expanded.has(r.id); + const hasAdjustments = + !!r.adjustments && r.adjustments.length > 0; + return ( + + + hasAdjustments ? toggleExpand(r.id) : undefined + } + aria-expanded={hasAdjustments ? isOpen : undefined} + style={{ cursor: hasAdjustments ? "pointer" : undefined }} + > + + {hasAdjustments ? ( + isOpen ? ( + + ) : ( + + ) + ) : null} + + {r.id} + + {r.claimId} + + {r.payerName} + + {fmt.usdPrecise(r.paidAmount)} + + + {r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"} + + + + + + {fmt.dateShort(r.receivedDate)} + + + {isOpen && hasAdjustments ? ( + + + +
+ +
+ Adjustments ({r.adjustments!.length}) +
+
+
+ {r.adjustments!.map((adj, i) => ( + + ))} +
+
+
+ ) : null} +
+ ); + })}
@@ -163,4 +260,4 @@ export function Remittances() {
); -} +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 839633e..19ec182 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -59,6 +59,24 @@ export interface Remittance { status: RemittanceStatus; denialReason?: string | null; validationWarnings?: string[]; + /** + * Persisted CAS adjustment rows, labeled by the backend via the bundled + * CARC dictionary. Optional — the list endpoint omits it to keep the + * payload small; the detail endpoint (`/api/remittances/{id}`) populates it. + */ + adjustments?: CasAdjustment[]; +} + +/** + * One Claim Adjustment Segment (CAS) row, labeled. + * Source: backend `cyclone.parsers.cas_codes.reason_label(group, reason)`. + */ +export interface CasAdjustment { + group: string; // "CO", "PR", "OA", "PI", "CR" + reason: string; // "45", "1", etc. + label: string; // "Charge exceeds fee schedule" + amount: number; + quantity: number | null; } export interface Activity {