From d0411b8186f07cbe5bcfea31c46253c55d2a2b4d Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 19:13:52 -0600 Subject: [PATCH] fix(store): spec compliance fixes per review --- backend/src/cyclone/store.py | 77 ++++++---- backend/tests/test_store.py | 272 ++++++++++++++++++++++++++++++----- src/types/index.ts | 2 + 3 files changed, 289 insertions(+), 62 deletions(-) diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index 7f69859..160ddd9 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -16,6 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field from cyclone.parsers.models import ClaimOutput, ParseResult from cyclone.parsers.models_835 import ClaimPayment, ParseResult835 +from cyclone.parsers.payer import PayerConfig835 BatchKind = Literal["837p", "835"] @@ -29,7 +30,7 @@ class BatchRecord(BaseModel): id: str kind: BatchKind input_filename: str - parsed_at: str # ISO 8601 UTC, e.g. "2026-06-19T12:00:00Z" + parsed_at: datetime # tz-aware UTC result: ParseResult | ParseResult835 = Field(discriminator=None) @@ -121,6 +122,7 @@ class InMemoryStore: for cp in result.claims: all_remits.append(to_ui_remittance( cp, batch_id=b.id, parsed_at=b.parsed_at, + payer_name=result.payer.name, )) if payer is not None: all_remits = [r for r in all_remits if r.get("payerName") == payer] @@ -187,9 +189,9 @@ class InMemoryStore: 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") +def utcnow() -> datetime: + """tz-aware UTC `datetime` (replaces the old `utcnow_iso` string helper).""" + return datetime.now(timezone.utc) # --------------------------------------------------------------------------- @@ -203,26 +205,29 @@ def to_ui_claim( claim: ClaimOutput, *, batch_id: str, - parsed_at: str, + parsed_at: datetime, ) -> 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 + Status rules (per spec section 6.2, first-match-wins): + - !passed and any R050_diagnosis_present error → draft + - !passed → denied + - passed and frequency_code == "1" → submitted + - passed and any warnings → pending + - otherwise → draft """ v = claim.validation if not v.passed: - status = "denied" - elif v.warnings: - status = "pending" + has_r050 = any(e.rule == "R050_diagnosis_present" for e in v.errors) + status = "draft" if has_r050 else "denied" elif claim.claim.frequency_code == "1": status = "submitted" + elif v.warnings: + status = "pending" else: status = "draft" + parsed_iso = parsed_at.isoformat().replace("+00:00", "Z") return { "id": claim.claim_id, "patientName": f"{claim.subscriber.first_name} {claim.subscriber.last_name}".strip(), @@ -237,9 +242,9 @@ def to_ui_claim( "receivedAmount": 0.0, "status": status, "denialReason": None, - "submissionDate": parsed_at, + "submissionDate": parsed_iso, "batchId": batch_id, - "parsedAt": parsed_at, + "parsedAt": parsed_iso, } @@ -247,13 +252,17 @@ def to_ui_remittance( cp: ClaimPayment, *, batch_id: str, - parsed_at: str, + parsed_at: datetime, + payer_config: PayerConfig835 | None = None, + payer_name: 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 + Status mapping (per spec section 6.2): + - status_code 21/22 → reconciled (reversal/correction) + - status_code 4 → received + denialReason from CAS + - status_code outside allowlist → received + validationWarning + - otherwise → received """ code = cp.status_code if code in {"21", "22"}: @@ -261,17 +270,35 @@ def to_ui_remittance( else: status = "received" + denial_reason: str | None = None + if code == "4" and cp.service_payments: + sp = cp.service_payments[0] + if sp.adjustments: + adj = sp.adjustments[0] + denial_reason = ( + f"{adj.group_code}-{adj.reason_code}: ${float(adj.amount):.2f}" + ) + + cfg = payer_config if payer_config is not None else PayerConfig835.generic_835() + validation_warnings: list[str] = [] + if code not in cfg.allowed_status_codes: + validation_warnings.append( + f"CLP02 code {code} not in payer allowlist" + ) + + parsed_iso = parsed_at.isoformat().replace("+00:00", "Z") return { "id": cp.payer_claim_control_number, "claimId": cp.original_claim_id or "", - "payerName": "", + "payerName": payer_name, "paidAmount": float(cp.total_paid or 0.0), "adjustmentAmount": 0.0, "status": status, - "denialReason": None, - "receivedDate": parsed_at, + "denialReason": denial_reason, + "validationWarnings": validation_warnings, + "receivedDate": parsed_iso, "batchId": batch_id, - "parsedAt": parsed_at, + "parsedAt": parsed_iso, } @@ -307,7 +334,7 @@ def to_activity_event( id: str, kind: str, message: str, - timestamp: str, + timestamp: datetime, npi: str | None = None, amount: float | None = None, ) -> dict: @@ -315,7 +342,7 @@ def to_activity_event( "id": id, "kind": kind, "message": message, - "timestamp": timestamp, + "timestamp": timestamp.isoformat().replace("+00:00", "Z"), "npi": npi, "amount": amount, } diff --git a/backend/tests/test_store.py b/backend/tests/test_store.py index 71a7a75..4d3bb95 100644 --- a/backend/tests/test_store.py +++ b/backend/tests/test_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, timezone from decimal import Decimal from cyclone.parsers.models import ( @@ -16,7 +17,17 @@ from cyclone.parsers.models import ( ValidationIssue, ValidationReport, ) -from cyclone.parsers.models_835 import ClaimPayment +from cyclone.parsers.models_835 import ( + ClaimAdjustment, + ClaimPayment, + FinancialInfo, + ParseResult835, + Payer835, + Payee835, + ReassociationTrace, + ServicePayment, +) +from cyclone.parsers.payer import PayerConfig835 from cyclone.store import BatchRecord, InMemoryStore, store @@ -24,8 +35,10 @@ from cyclone.store import BatchRecord, InMemoryStore, store # Stubs (match real model field names) # --------------------------------------------------------------------------- +_PARSED_AT = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc) -def _make_claim(claim_id: str = "CLM-1") -> ClaimOutput: + +def _make_claim(claim_id: str = "CLM-1", *, frequency_code: str = "1") -> ClaimOutput: """Minimal ClaimOutput stub. Real parser not needed here.""" return ClaimOutput( claim_id=claim_id, @@ -51,7 +64,7 @@ def _make_claim(claim_id: str = "CLM-1") -> ClaimOutput: total_charge=Decimal("0.00"), place_of_service="11", facility_code_qualifier=None, - frequency_code="1", + frequency_code=frequency_code, provider_signature=None, assignment=None, release_of_info=None, @@ -89,6 +102,67 @@ def _make_result() -> ParseResult: ) +def _make_remit( + *, + status_code: str = "1", + service_payments: list[ServicePayment] | None = None, +) -> ClaimPayment: + return ClaimPayment( + payer_claim_control_number="PCN-1", + status_code=status_code, + status_label=None, + 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=service_payments or [], + ) + + +def _make_835_result(claims: list[ClaimPayment] | None = None) -> ParseResult835: + return ParseResult835( + envelope=Envelope( + sender_id="SENDER", + receiver_id="RECEIVER", + control_number="0001", + transaction_date="2026-06-19", + transaction_time=None, + implementation_guide=None, + ), + financial_info=FinancialInfo( + handling_code="C", + paid_amount=Decimal("80.00"), + credit_debit_flag="C", + payment_method=None, + payer_tax_id=None, + payment_date=None, + ), + trace=ReassociationTrace( + trace_type_code="1", + trace_number="TRN1", + originating_company_id="OC1", + payer_id=None, + ), + payer=Payer835(name="CO_TXIX", id="7912900843", address=None, contact_url=None), + payee=Payee835(name="Acme Clinic", npi="1234567890", address=None), + claims=claims or [], + summary=BatchSummary( + input_file="test.txt", + control_number="0001", + transaction_date="2026-06-19", + total_claims=1, + passed=1, + failed=0, + failed_claim_ids=[], + issues_by_rule={}, + output_dir=None, + ), + ) + + # --------------------------------------------------------------------------- # Task 1: BatchRecord + InMemoryStore # --------------------------------------------------------------------------- @@ -111,7 +185,7 @@ def test_add_and_get_round_trip(): id="abc123", kind="837p", input_filename="test.txt", - parsed_at="2026-06-19T12:00:00Z", + parsed_at=_PARSED_AT, result=result, ) s.add(rec) @@ -124,6 +198,19 @@ def test_get_missing_returns_none(): assert s.get("does-not-exist") is None +def test_batch_record_parsed_at_accepts_datetime(): + """Spec 6.1: parsed_at is a tz-aware datetime, not a string.""" + rec = BatchRecord( + id="x", + kind="837p", + input_filename="a.txt", + parsed_at=_PARSED_AT, + result=_make_result(), + ) + assert isinstance(rec.parsed_at, datetime) + assert rec.parsed_at.tzinfo is not None + + # --------------------------------------------------------------------------- # Task 2: Mappers # --------------------------------------------------------------------------- @@ -131,54 +218,150 @@ def test_get_missing_returns_none(): 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") + claim = _make_claim("CLM-1") # passed=True, frequency_code="1" + out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT) assert out["id"] == "CLM-1" - assert out["status"] == "submitted" # passed=True, frequency_code="1" + assert out["status"] == "submitted" assert out["providerNpi"] == "1234567890" assert out["batchId"] == "b1" + assert out["parsedAt"] == "2026-06-19T12:00:00Z" -def test_to_ui_claim_maps_status_pending_when_warnings(): +def test_to_ui_claim_maps_status_pending_when_warnings_no_freq_one(): + """passed + warnings + non-1 frequency_code → pending (rule 2).""" from cyclone.store import to_ui_claim - claim = _make_claim("CLM-2") + claim = _make_claim("CLM-2", frequency_code="7") 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") + out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT) assert out["status"] == "pending" +def test_to_ui_claim_submitted_takes_precedence_over_pending(): + """passed + frequency_code=1 + warnings → submitted (rule 1 beats rule 2).""" + from cyclone.store import to_ui_claim + claim = _make_claim("CLM-3", frequency_code="1") + claim.validation = ValidationReport( + passed=True, errors=[], + warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")], + ) + out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT) + assert out["status"] == "submitted" + + def test_to_ui_claim_maps_status_denied_when_failed(): from cyclone.store import to_ui_claim - claim = _make_claim("CLM-3") + claim = _make_claim("CLM-4") 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") + out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT) assert out["status"] == "denied" +def test_to_ui_claim_maps_status_draft_when_r050_diagnosis_present(): + """!passed with an R050_diagnosis_present error → draft (carve-out).""" + from cyclone.store import to_ui_claim + claim = _make_claim("CLM-5") + claim.validation = ValidationReport( + passed=False, + errors=[ValidationIssue( + rule="R050_diagnosis_present", severity="error", + message="missing principal diagnosis", + )], + warnings=[], + ) + out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT) + assert out["status"] == "draft" + + +def test_to_ui_claim_maps_status_draft_when_passed_no_freq_no_warnings(): + """passed + non-1 frequency + no warnings → draft.""" + from cyclone.store import to_ui_claim + claim = _make_claim("CLM-6", frequency_code="7") + out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT) + assert out["status"] == "draft" + + 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") + cp = _make_remit(status_code="1") + out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT) assert out["status"] == "received" + assert out["denialReason"] is None + assert out["validationWarnings"] == [] + + +def test_to_ui_remittance_denial_reason_from_cas_for_code_4(): + """CLP02 == 4 → received with denialReason populated from first CAS.""" + from cyclone.store import to_ui_remittance + sp = ServicePayment( + line_number=1, + procedure_qualifier="HC", + procedure_code="99213", + modifiers=[], + charge=Decimal("100.00"), + payment=Decimal("0.00"), + units=None, + unit_type=None, + service_date=None, + adjustments=[ + ClaimAdjustment( + group_code="CO", reason_code="97", + amount=Decimal("100.00"), quantity=None, + ), + ], + ) + cp = _make_remit(status_code="4", service_payments=[sp]) + out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT) + assert out["status"] == "received" + assert out["denialReason"] == "CO-97: $100.00" + + +def test_to_ui_remittance_denial_reason_none_when_no_cas_for_code_4(): + """CLP02 == 4 with no CAS segments → denialReason stays None.""" + from cyclone.store import to_ui_remittance + cp = _make_remit(status_code="4", service_payments=[]) + out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT) + assert out["status"] == "received" + assert out["denialReason"] is None + + +def test_to_ui_remittance_validation_warning_for_invalid_clp02(): + """CLP02 outside the allowlist → received + validationWarnings populated.""" + from cyclone.store import to_ui_remittance + cp = _make_remit(status_code="99") # not in {"1","2","3","4","19","20","21","22","23","25"} + out = to_ui_remittance( + cp, batch_id="b1", parsed_at=_PARSED_AT, + payer_config=PayerConfig835.generic_835(), + ) + assert out["status"] == "received" + assert any("99" in w and "allowlist" in w for w in out["validationWarnings"]) + + +def test_to_ui_remittance_no_warning_for_allowed_clp02(): + """CLP02 in the allowlist → empty validationWarnings.""" + from cyclone.store import to_ui_remittance + cp = _make_remit(status_code="1") + out = to_ui_remittance( + cp, batch_id="b1", parsed_at=_PARSED_AT, + payer_config=PayerConfig835.generic_835(), + ) + assert out["validationWarnings"] == [] + + +def test_to_ui_remittance_fills_payer_name(): + """payer_name kwarg flows into the remittance's payerName field.""" + from cyclone.store import to_ui_remittance + cp = _make_remit(status_code="1") + out = to_ui_remittance( + cp, batch_id="b1", parsed_at=_PARSED_AT, payer_name="CO_TXIX", + ) + assert out["payerName"] == "CO_TXIX" def test_to_ui_provider_aggregates_claims(): @@ -192,17 +375,18 @@ def test_to_ui_provider_aggregates_claims(): assert out["claimCount"] == 5 -def test_to_activity_event_uses_iso_timestamp(): +def test_to_activity_event_serializes_datetime_to_iso_z(): 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", + timestamp=_PARSED_AT, npi="1234567890", amount=100.0, ) assert out["id"] == "a1" assert out["kind"] == "claim_submitted" assert out["amount"] == 100.0 + assert out["timestamp"] == "2026-06-19T12:00:00Z" # --------------------------------------------------------------------------- @@ -213,16 +397,16 @@ def test_to_activity_event_uses_iso_timestamp(): def test_iter_claims_filters_by_status(): s = InMemoryStore() result = _make_result() - a = _make_claim("A") - b = _make_claim("B") + a = _make_claim("A", frequency_code="1") # passed + freq=1 → submitted + b = _make_claim("B", frequency_code="7") b.validation = ValidationReport( passed=True, errors=[], warnings=[ValidationIssue(rule="W1", severity="warning", message="x")], - ) + ) # passed + freq=7 + warnings → pending result.claims = [a, b] s.add(BatchRecord( id="1", kind="837p", input_filename="a.txt", - parsed_at="2026-06-19T12:00:00Z", result=result, + parsed_at=_PARSED_AT, result=result, )) out = list(s.iter_claims(status="submitted")) assert all(c["status"] == "submitted" for c in out) @@ -236,7 +420,7 @@ def test_iter_claims_filters_by_batch_id(): s = InMemoryStore() s.add(BatchRecord( id="b1", kind="837p", input_filename="a.txt", - parsed_at="2026-06-19T12:00:00Z", result=_make_result(), + parsed_at=_PARSED_AT, result=_make_result(), )) out = list(s.iter_claims(batch_id="b1")) assert all(c["batchId"] == "b1" for c in out) @@ -253,7 +437,7 @@ def test_iter_claims_sorts_by_billed_amount_desc(): result.claims[1].claim.total_charge = Decimal("500.00") s.add(BatchRecord( id="b1", kind="837p", input_filename="a.txt", - parsed_at="2026-06-19T12:00:00Z", result=result, + parsed_at=_PARSED_AT, result=result, )) out = list(s.iter_claims(sort="billedAmount", order="desc")) assert out[0]["id"] == "expensive" @@ -266,7 +450,7 @@ def test_distinct_providers_aggregates_claims(): result.claims = [_make_claim("A"), _make_claim("B")] s.add(BatchRecord( id="b1", kind="837p", input_filename="a.txt", - parsed_at="2026-06-19T12:00:00Z", result=result, + parsed_at=_PARSED_AT, result=result, )) out = s.distinct_providers() assert len(out) == 1 @@ -275,15 +459,29 @@ def test_distinct_providers_aggregates_claims(): def test_recent_activity_returns_newest_first(): + older = datetime(2026, 6, 19, 11, 0, 0, tzinfo=timezone.utc) s = InMemoryStore() s.add(BatchRecord( id="b1", kind="837p", input_filename="a.txt", - parsed_at="2026-06-19T11:00:00Z", result=_make_result(), + parsed_at=older, result=_make_result(), )) s.add(BatchRecord( id="b2", kind="837p", input_filename="b.txt", - parsed_at="2026-06-19T12:00:00Z", result=_make_result(), + parsed_at=_PARSED_AT, result=_make_result(), )) out = s.recent_activity(limit=10) assert out[0]["timestamp"] == "2026-06-19T12:00:00Z" assert out[1]["timestamp"] == "2026-06-19T11:00:00Z" + + +def test_iter_remittances_fills_payer_name_from_batch(): + """iter_remittances should populate payerName from ParseResult835.payer.name.""" + s = InMemoryStore() + cp = _make_remit(status_code="1") + s.add(BatchRecord( + id="835-1", kind="835", input_filename="a.txt", + parsed_at=_PARSED_AT, result=_make_835_result(claims=[cp]), + )) + out = list(s.iter_remittances()) + assert len(out) == 1 + assert out[0]["payerName"] == "CO_TXIX" diff --git a/src/types/index.ts b/src/types/index.ts index f87a20e..580c02b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -57,6 +57,8 @@ export interface Remittance { receivedDate: string; checkNumber: string; status: RemittanceStatus; + denialReason?: string | null; + validationWarnings?: string[]; } export interface Activity {