diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index ed64c1d..1239952 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -14,8 +14,8 @@ from typing import Iterable, Literal from pydantic import BaseModel, ConfigDict, Field -from cyclone.parsers.models import ParseResult -from cyclone.parsers.models_835 import ParseResult835 +from cyclone.parsers.models import ClaimOutput, ParseResult +from cyclone.parsers.models_835 import ClaimPayment, ParseResult835 BatchKind = Literal["837p", "835"] @@ -66,3 +66,132 @@ store = InMemoryStore() def utcnow_iso() -> str: """ISO 8601 UTC timestamp, second precision, suffixed with 'Z'.""" return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# --------------------------------------------------------------------------- +# Mappers: rich backend models → simpler UI types. +# The shape of the returned dicts must match the TypeScript interfaces in +# `src/types/index.ts`. Keep both sides in sync. +# --------------------------------------------------------------------------- + + +def to_ui_claim( + claim: ClaimOutput, + *, + batch_id: str, + parsed_at: str, +) -> dict: + """Map a 837P ClaimOutput to the UI's `Claim` shape. + + Status rules (per spec section 6.2, evaluated in priority order): + - !passed → denied + - passed and any warnings → pending + - passed and frequency_code == "1" → submitted + - otherwise → draft + """ + v = claim.validation + if not v.passed: + status = "denied" + elif v.warnings: + status = "pending" + elif claim.claim.frequency_code == "1": + status = "submitted" + else: + status = "draft" + + return { + "id": claim.claim_id, + "patientName": f"{claim.subscriber.first_name} {claim.subscriber.last_name}".strip(), + "providerNpi": claim.billing_provider.npi, + "payerName": claim.payer.name, + "cptCode": ( + claim.service_lines[0].procedure.code + if claim.service_lines + else "" + ), + "billedAmount": float(claim.claim.total_charge or 0.0), + "receivedAmount": 0.0, + "status": status, + "denialReason": None, + "submissionDate": parsed_at, + "batchId": batch_id, + "parsedAt": parsed_at, + } + + +def to_ui_remittance( + cp: ClaimPayment, + *, + batch_id: str, + parsed_at: str, +) -> dict: + """Map an 835 ClaimPayment to the UI's `Remittance` shape. + + Status mapping (per spec section 6.3): + - status_code 21/22 → reconciled (reversal/correction) + - otherwise → received + """ + code = cp.status_code + if code in {"21", "22"}: + status = "reconciled" + else: + status = "received" + + return { + "id": cp.payer_claim_control_number, + "claimId": cp.original_claim_id or "", + "payerName": "", + "paidAmount": float(cp.total_paid or 0.0), + "adjustmentAmount": 0.0, + "status": status, + "denialReason": None, + "receivedDate": parsed_at, + "batchId": batch_id, + "parsedAt": parsed_at, + } + + +def to_ui_provider( + *, + npi: str, + name: str, + tax_id: str | None = None, + address: str | None = None, + city: str | None = None, + state: str | None = None, + zip: str | None = None, + phone: str | None = None, + claim_count: int = 0, + outstanding_ar: float = 0.0, +) -> dict: + return { + "npi": npi, + "name": name, + "taxId": tax_id or "", + "address": address or "", + "city": city or "", + "state": state or "", + "zip": zip or "", + "phone": phone or "", + "claimCount": claim_count, + "outstandingAr": float(outstanding_ar), + } + + +def to_activity_event( + *, + id: str, + kind: str, + message: str, + timestamp: str, + npi: str | None = None, + amount: float | None = None, +) -> dict: + return { + "id": id, + "kind": kind, + "message": message, + "timestamp": timestamp, + "npi": npi, + "amount": amount, + } diff --git a/backend/tests/test_store.py b/backend/tests/test_store.py index 61ed276..9511dae 100644 --- a/backend/tests/test_store.py +++ b/backend/tests/test_store.py @@ -122,3 +122,84 @@ def test_add_and_get_round_trip(): def test_get_missing_returns_none(): s = InMemoryStore() assert s.get("does-not-exist") is None + + +# --------------------------------------------------------------------------- +# Task 2: Mappers +# --------------------------------------------------------------------------- + + +def test_to_ui_claim_maps_status_submitted(): + from cyclone.store import to_ui_claim + claim = _make_claim("CLM-1") + out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z") + assert out["id"] == "CLM-1" + assert out["status"] == "submitted" # passed=True, frequency_code="1" + assert out["providerNpi"] == "1234567890" + assert out["batchId"] == "b1" + + +def test_to_ui_claim_maps_status_pending_when_warnings(): + from cyclone.store import to_ui_claim + claim = _make_claim("CLM-2") + claim.validation = ValidationReport( + passed=True, errors=[], + warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")], + ) + out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z") + assert out["status"] == "pending" + + +def test_to_ui_claim_maps_status_denied_when_failed(): + from cyclone.store import to_ui_claim + claim = _make_claim("CLM-3") + claim.validation = ValidationReport( + passed=False, + errors=[ValidationIssue(rule="E001", severity="error", message="hard")], + warnings=[], + ) + out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z") + assert out["status"] == "denied" + + +def test_to_ui_remittance_maps_status_received_for_paid(): + from cyclone.store import to_ui_remittance + cp = ClaimPayment( + payer_claim_control_number="PCN-1", + status_code="1", + status_label="Processed as Primary", + total_charge=Decimal("100.00"), + total_paid=Decimal("80.00"), + patient_responsibility=Decimal("0.00"), + claim_filing_indicator="MC", + original_claim_id=None, + frequency_code=None, + facility_type=None, + service_payments=[], + ) + out = to_ui_remittance(cp, batch_id="b1", parsed_at="2026-06-19T12:00:00Z") + assert out["status"] == "received" + + +def test_to_ui_provider_aggregates_claims(): + from cyclone.store import to_ui_provider + out = to_ui_provider( + npi="1234567890", name="Acme Clinic", tax_id="99-9999999", + address="1 Main", city="Denver", state="CO", zip="80202", phone="303-555-0100", + claim_count=5, outstanding_ar=250.0, + ) + assert out["npi"] == "1234567890" + assert out["claimCount"] == 5 + + +def test_to_activity_event_uses_iso_timestamp(): + from cyclone.store import to_activity_event + out = to_activity_event( + id="a1", kind="claim_submitted", + message="Claim CLM-1 submitted", + timestamp="2026-06-19T12:00:00Z", + npi="1234567890", amount=100.0, + ) + assert out["id"] == "a1" + assert out["kind"] == "claim_submitted" + assert out["amount"] == 100.0