feat(parsers+api+ui): expose CARC-labeled CAS adjustments (SP3 P2)

This commit is contained in:
Tyler
2026-06-20 07:44:41 -06:00
parent 76eb33e029
commit 2a853857b1
10 changed files with 927 additions and 26 deletions
+17
View File
@@ -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,
+3
View File
@@ -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",
+207
View File
@@ -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",
]
+87 -2
View File
@@ -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: