feat(parsers+api+ui): expose CARC-labeled CAS adjustments (SP3 P2)
This commit is contained in:
@@ -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")
|
@app.get("/api/providers")
|
||||||
def list_providers(
|
def list_providers(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ _LAZY_EXPORTS: dict[str, str] = {
|
|||||||
"ReassociationTrace": "cyclone.parsers.models_835",
|
"ReassociationTrace": "cyclone.parsers.models_835",
|
||||||
"ServicePayment": "cyclone.parsers.models_835",
|
"ServicePayment": "cyclone.parsers.models_835",
|
||||||
"claim_status_label": "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
|
# payer
|
||||||
"PayerConfig": "cyclone.parsers.payer",
|
"PayerConfig": "cyclone.parsers.payer",
|
||||||
"PayerConfig835": "cyclone.parsers.payer",
|
"PayerConfig835": "cyclone.parsers.payer",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -335,14 +335,23 @@ def to_ui_remittance(
|
|||||||
f"CLP02 code {code} not in payer allowlist"
|
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")
|
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
|
||||||
return {
|
return {
|
||||||
"id": cp.payer_claim_control_number,
|
"id": cp.payer_claim_control_number,
|
||||||
"claimId": cp.original_claim_id or "",
|
"claimId": cp.original_claim_id or "",
|
||||||
"payerName": payer_name,
|
"payerName": payer_name,
|
||||||
"paidAmount": float(cp.total_paid or 0.0),
|
"paidAmount": float(cp.total_paid or 0.0),
|
||||||
# TODO: aggregate from CAS segments when reconciliation lands (sub-project 2)
|
"adjustmentAmount": float(adjustment_total),
|
||||||
"adjustmentAmount": 0.0,
|
|
||||||
"status": status,
|
"status": status,
|
||||||
"denialReason": denial_reason,
|
"denialReason": denial_reason,
|
||||||
"validationWarnings": validation_warnings,
|
"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(
|
def to_ui_provider(
|
||||||
*,
|
*,
|
||||||
npi: str,
|
npi: str,
|
||||||
@@ -766,6 +822,35 @@ class CycloneStore:
|
|||||||
)
|
)
|
||||||
return [self._row_to_record(r) for r in rows]
|
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]:
|
def all(self) -> list[BatchRecord]:
|
||||||
"""Return every ``BatchRecord``, oldest first (no pagination)."""
|
"""Return every ``BatchRecord``, oldest first (no pagination)."""
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
|
|||||||
@@ -637,3 +637,172 @@ def test_post_unmatch_removes_match(client: TestClient):
|
|||||||
assert body["claim"]["matchedRemittanceId"] is None
|
assert body["claim"]["matchedRemittanceId"] is None
|
||||||
# No Match rows existed before the call → 0 deleted.
|
# No Match rows existed before the call → 0 deleted.
|
||||||
assert body["deletedMatches"] == 0
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -417,6 +417,22 @@ async function listRemittances<T = unknown>(
|
|||||||
return (await res.json()) as PaginatedResponse<T>;
|
return (await res.json()) as PaginatedResponse<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one remittance with its labeled CAS `adjustments` array.
|
||||||
|
* Throws `ApiError` on 404 so callers can branch on `.status`.
|
||||||
|
*/
|
||||||
|
async function getRemittance<T = unknown>(id: string): Promise<T> {
|
||||||
|
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<T = unknown>(
|
async function listProviders<T = unknown>(
|
||||||
params: ListProvidersParams = {}
|
params: ListProvidersParams = {}
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
@@ -516,6 +532,7 @@ export const api = {
|
|||||||
getBatch,
|
getBatch,
|
||||||
listClaims,
|
listClaims,
|
||||||
listRemittances,
|
listRemittances,
|
||||||
|
getRemittance,
|
||||||
listProviders,
|
listProviders,
|
||||||
listActivity,
|
listActivity,
|
||||||
listUnmatched,
|
listUnmatched,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<typeof vi.fn>
|
||||||
|
).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+121
-24
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -16,7 +17,7 @@ import { Pagination } from "@/components/ui/pagination";
|
|||||||
import { useRemittances } from "@/hooks/useRemittances";
|
import { useRemittances } from "@/hooks/useRemittances";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { RemittanceStatus } from "@/types";
|
import type { CasAdjustment, RemittanceStatus } from "@/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -26,9 +27,36 @@ const STATUS_OPTIONS: FilterChipOption[] = [
|
|||||||
{ value: "reconciled", label: "Reconciled" },
|
{ 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 (
|
||||||
|
<div className="flex items-start justify-between gap-4 py-1.5 border-b border-border/40 last:border-0">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{adj.group}-{adj.reason}
|
||||||
|
{adj.quantity !== null ? (
|
||||||
|
<span className="ml-2 text-muted-foreground/70">
|
||||||
|
qty {adj.quantity}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] text-foreground/90 truncate">{adj.label}</div>
|
||||||
|
</div>
|
||||||
|
<div className="display num text-[13px] tabular-nums whitespace-nowrap text-muted-foreground">
|
||||||
|
{fmt.usdPrecise(adj.amount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Remittances() {
|
export function Remittances() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
|
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
|
||||||
sort: "receivedDate",
|
sort: "receivedDate",
|
||||||
@@ -46,6 +74,15 @@ export function Remittances() {
|
|||||||
{ paid: 0, adjustments: 0 }
|
{ 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 (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in">
|
<div className="space-y-8 animate-fade-in">
|
||||||
<header>
|
<header>
|
||||||
@@ -117,6 +154,7 @@ export function Remittances() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-8" aria-label="Expand" />
|
||||||
<TableHead>Remit</TableHead>
|
<TableHead>Remit</TableHead>
|
||||||
<TableHead>Claim</TableHead>
|
<TableHead>Claim</TableHead>
|
||||||
<TableHead>Payer</TableHead>
|
<TableHead>Payer</TableHead>
|
||||||
@@ -127,27 +165,86 @@ export function Remittances() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((r) => (
|
{items.map((r) => {
|
||||||
<TableRow key={`${r.id}-${dataUpdatedAt}`} className={cn("animate-row-flash")}>
|
const isOpen = expanded.has(r.id);
|
||||||
<TableCell className="display num text-[13px]">{r.id}</TableCell>
|
const hasAdjustments =
|
||||||
<TableCell className="display num text-[13px] text-muted-foreground">
|
!!r.adjustments && r.adjustments.length > 0;
|
||||||
{r.claimId}
|
return (
|
||||||
</TableCell>
|
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
||||||
<TableCell>{r.payerName}</TableCell>
|
<TableRow
|
||||||
<TableCell className="text-right display num">
|
className={cn("animate-row-flash")}
|
||||||
{fmt.usdPrecise(r.paidAmount)}
|
onClick={() =>
|
||||||
</TableCell>
|
hasAdjustments ? toggleExpand(r.id) : undefined
|
||||||
<TableCell className="text-right display num text-muted-foreground">
|
}
|
||||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
aria-expanded={hasAdjustments ? isOpen : undefined}
|
||||||
</TableCell>
|
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
|
||||||
<TableCell>
|
>
|
||||||
<RemitStatusBadge status={r.status} />
|
<TableCell className="text-muted-foreground">
|
||||||
</TableCell>
|
{hasAdjustments ? (
|
||||||
<TableCell className="text-muted-foreground num text-[13px]">
|
isOpen ? (
|
||||||
{fmt.dateShort(r.receivedDate)}
|
<ChevronDown
|
||||||
</TableCell>
|
className="h-3.5 w-3.5"
|
||||||
</TableRow>
|
strokeWidth={1.75}
|
||||||
))}
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChevronRight
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="display num text-[13px]">{r.id}</TableCell>
|
||||||
|
<TableCell className="display num text-[13px] text-muted-foreground">
|
||||||
|
{r.claimId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.payerName}</TableCell>
|
||||||
|
<TableCell className="text-right display num">
|
||||||
|
{fmt.usdPrecise(r.paidAmount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right display num text-muted-foreground">
|
||||||
|
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RemitStatusBadge status={r.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground num text-[13px]">
|
||||||
|
{fmt.dateShort(r.receivedDate)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isOpen && hasAdjustments ? (
|
||||||
|
<TableRow
|
||||||
|
key={`${r.id}-${dataUpdatedAt}-detail`}
|
||||||
|
className="bg-muted/30 hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<TableCell />
|
||||||
|
<TableCell colSpan={7} className="py-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Receipt
|
||||||
|
className="h-3.5 w-3.5 text-muted-foreground"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Adjustments ({r.adjustments!.length})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pl-5">
|
||||||
|
{r.adjustments!.map((adj, i) => (
|
||||||
|
<AdjustmentRow
|
||||||
|
key={`${adj.group}-${adj.reason}-${i}`}
|
||||||
|
adj={adj}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
@@ -163,4 +260,4 @@ export function Remittances() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,24 @@ export interface Remittance {
|
|||||||
status: RemittanceStatus;
|
status: RemittanceStatus;
|
||||||
denialReason?: string | null;
|
denialReason?: string | null;
|
||||||
validationWarnings?: string[];
|
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 {
|
export interface Activity {
|
||||||
|
|||||||
Reference in New Issue
Block a user