fix(store): spec compliance fixes per review
This commit is contained in:
@@ -16,6 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from cyclone.parsers.models import ClaimOutput, ParseResult
|
from cyclone.parsers.models import ClaimOutput, ParseResult
|
||||||
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
|
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
|
||||||
|
from cyclone.parsers.payer import PayerConfig835
|
||||||
|
|
||||||
|
|
||||||
BatchKind = Literal["837p", "835"]
|
BatchKind = Literal["837p", "835"]
|
||||||
@@ -29,7 +30,7 @@ class BatchRecord(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
kind: BatchKind
|
kind: BatchKind
|
||||||
input_filename: str
|
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)
|
result: ParseResult | ParseResult835 = Field(discriminator=None)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ class InMemoryStore:
|
|||||||
for cp in result.claims:
|
for cp in result.claims:
|
||||||
all_remits.append(to_ui_remittance(
|
all_remits.append(to_ui_remittance(
|
||||||
cp, batch_id=b.id, parsed_at=b.parsed_at,
|
cp, batch_id=b.id, parsed_at=b.parsed_at,
|
||||||
|
payer_name=result.payer.name,
|
||||||
))
|
))
|
||||||
if payer is not None:
|
if payer is not None:
|
||||||
all_remits = [r for r in all_remits if r.get("payerName") == payer]
|
all_remits = [r for r in all_remits if r.get("payerName") == payer]
|
||||||
@@ -187,9 +189,9 @@ class InMemoryStore:
|
|||||||
store = InMemoryStore()
|
store = InMemoryStore()
|
||||||
|
|
||||||
|
|
||||||
def utcnow_iso() -> str:
|
def utcnow() -> datetime:
|
||||||
"""ISO 8601 UTC timestamp, second precision, suffixed with 'Z'."""
|
"""tz-aware UTC `datetime` (replaces the old `utcnow_iso` string helper)."""
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -203,26 +205,29 @@ def to_ui_claim(
|
|||||||
claim: ClaimOutput,
|
claim: ClaimOutput,
|
||||||
*,
|
*,
|
||||||
batch_id: str,
|
batch_id: str,
|
||||||
parsed_at: str,
|
parsed_at: datetime,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Map a 837P ClaimOutput to the UI's `Claim` shape.
|
"""Map a 837P ClaimOutput to the UI's `Claim` shape.
|
||||||
|
|
||||||
Status rules (per spec section 6.2, evaluated in priority order):
|
Status rules (per spec section 6.2, first-match-wins):
|
||||||
|
- !passed and any R050_diagnosis_present error → draft
|
||||||
- !passed → denied
|
- !passed → denied
|
||||||
- passed and any warnings → pending
|
|
||||||
- passed and frequency_code == "1" → submitted
|
- passed and frequency_code == "1" → submitted
|
||||||
|
- passed and any warnings → pending
|
||||||
- otherwise → draft
|
- otherwise → draft
|
||||||
"""
|
"""
|
||||||
v = claim.validation
|
v = claim.validation
|
||||||
if not v.passed:
|
if not v.passed:
|
||||||
status = "denied"
|
has_r050 = any(e.rule == "R050_diagnosis_present" for e in v.errors)
|
||||||
elif v.warnings:
|
status = "draft" if has_r050 else "denied"
|
||||||
status = "pending"
|
|
||||||
elif claim.claim.frequency_code == "1":
|
elif claim.claim.frequency_code == "1":
|
||||||
status = "submitted"
|
status = "submitted"
|
||||||
|
elif v.warnings:
|
||||||
|
status = "pending"
|
||||||
else:
|
else:
|
||||||
status = "draft"
|
status = "draft"
|
||||||
|
|
||||||
|
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
|
||||||
return {
|
return {
|
||||||
"id": claim.claim_id,
|
"id": claim.claim_id,
|
||||||
"patientName": f"{claim.subscriber.first_name} {claim.subscriber.last_name}".strip(),
|
"patientName": f"{claim.subscriber.first_name} {claim.subscriber.last_name}".strip(),
|
||||||
@@ -237,9 +242,9 @@ def to_ui_claim(
|
|||||||
"receivedAmount": 0.0,
|
"receivedAmount": 0.0,
|
||||||
"status": status,
|
"status": status,
|
||||||
"denialReason": None,
|
"denialReason": None,
|
||||||
"submissionDate": parsed_at,
|
"submissionDate": parsed_iso,
|
||||||
"batchId": batch_id,
|
"batchId": batch_id,
|
||||||
"parsedAt": parsed_at,
|
"parsedAt": parsed_iso,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -247,12 +252,16 @@ def to_ui_remittance(
|
|||||||
cp: ClaimPayment,
|
cp: ClaimPayment,
|
||||||
*,
|
*,
|
||||||
batch_id: str,
|
batch_id: str,
|
||||||
parsed_at: str,
|
parsed_at: datetime,
|
||||||
|
payer_config: PayerConfig835 | None = None,
|
||||||
|
payer_name: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Map an 835 ClaimPayment to the UI's `Remittance` shape.
|
"""Map an 835 ClaimPayment to the UI's `Remittance` shape.
|
||||||
|
|
||||||
Status mapping (per spec section 6.3):
|
Status mapping (per spec section 6.2):
|
||||||
- status_code 21/22 → reconciled (reversal/correction)
|
- status_code 21/22 → reconciled (reversal/correction)
|
||||||
|
- status_code 4 → received + denialReason from CAS
|
||||||
|
- status_code outside allowlist → received + validationWarning
|
||||||
- otherwise → received
|
- otherwise → received
|
||||||
"""
|
"""
|
||||||
code = cp.status_code
|
code = cp.status_code
|
||||||
@@ -261,17 +270,35 @@ def to_ui_remittance(
|
|||||||
else:
|
else:
|
||||||
status = "received"
|
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 {
|
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": "",
|
"payerName": payer_name,
|
||||||
"paidAmount": float(cp.total_paid or 0.0),
|
"paidAmount": float(cp.total_paid or 0.0),
|
||||||
"adjustmentAmount": 0.0,
|
"adjustmentAmount": 0.0,
|
||||||
"status": status,
|
"status": status,
|
||||||
"denialReason": None,
|
"denialReason": denial_reason,
|
||||||
"receivedDate": parsed_at,
|
"validationWarnings": validation_warnings,
|
||||||
|
"receivedDate": parsed_iso,
|
||||||
"batchId": batch_id,
|
"batchId": batch_id,
|
||||||
"parsedAt": parsed_at,
|
"parsedAt": parsed_iso,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -307,7 +334,7 @@ def to_activity_event(
|
|||||||
id: str,
|
id: str,
|
||||||
kind: str,
|
kind: str,
|
||||||
message: str,
|
message: str,
|
||||||
timestamp: str,
|
timestamp: datetime,
|
||||||
npi: str | None = None,
|
npi: str | None = None,
|
||||||
amount: float | None = None,
|
amount: float | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -315,7 +342,7 @@ def to_activity_event(
|
|||||||
"id": id,
|
"id": id,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"message": message,
|
"message": message,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp.isoformat().replace("+00:00", "Z"),
|
||||||
"npi": npi,
|
"npi": npi,
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
}
|
}
|
||||||
|
|||||||
+235
-37
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from cyclone.parsers.models import (
|
from cyclone.parsers.models import (
|
||||||
@@ -16,7 +17,17 @@ from cyclone.parsers.models import (
|
|||||||
ValidationIssue,
|
ValidationIssue,
|
||||||
ValidationReport,
|
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
|
from cyclone.store import BatchRecord, InMemoryStore, store
|
||||||
|
|
||||||
|
|
||||||
@@ -24,8 +35,10 @@ from cyclone.store import BatchRecord, InMemoryStore, store
|
|||||||
# Stubs (match real model field names)
|
# 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."""
|
"""Minimal ClaimOutput stub. Real parser not needed here."""
|
||||||
return ClaimOutput(
|
return ClaimOutput(
|
||||||
claim_id=claim_id,
|
claim_id=claim_id,
|
||||||
@@ -51,7 +64,7 @@ def _make_claim(claim_id: str = "CLM-1") -> ClaimOutput:
|
|||||||
total_charge=Decimal("0.00"),
|
total_charge=Decimal("0.00"),
|
||||||
place_of_service="11",
|
place_of_service="11",
|
||||||
facility_code_qualifier=None,
|
facility_code_qualifier=None,
|
||||||
frequency_code="1",
|
frequency_code=frequency_code,
|
||||||
provider_signature=None,
|
provider_signature=None,
|
||||||
assignment=None,
|
assignment=None,
|
||||||
release_of_info=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
|
# Task 1: BatchRecord + InMemoryStore
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -111,7 +185,7 @@ def test_add_and_get_round_trip():
|
|||||||
id="abc123",
|
id="abc123",
|
||||||
kind="837p",
|
kind="837p",
|
||||||
input_filename="test.txt",
|
input_filename="test.txt",
|
||||||
parsed_at="2026-06-19T12:00:00Z",
|
parsed_at=_PARSED_AT,
|
||||||
result=result,
|
result=result,
|
||||||
)
|
)
|
||||||
s.add(rec)
|
s.add(rec)
|
||||||
@@ -124,6 +198,19 @@ def test_get_missing_returns_none():
|
|||||||
assert s.get("does-not-exist") is 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
|
# Task 2: Mappers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -131,54 +218,150 @@ def test_get_missing_returns_none():
|
|||||||
|
|
||||||
def test_to_ui_claim_maps_status_submitted():
|
def test_to_ui_claim_maps_status_submitted():
|
||||||
from cyclone.store import to_ui_claim
|
from cyclone.store import to_ui_claim
|
||||||
claim = _make_claim("CLM-1")
|
claim = _make_claim("CLM-1") # passed=True, frequency_code="1"
|
||||||
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["id"] == "CLM-1"
|
assert out["id"] == "CLM-1"
|
||||||
assert out["status"] == "submitted" # passed=True, frequency_code="1"
|
assert out["status"] == "submitted"
|
||||||
assert out["providerNpi"] == "1234567890"
|
assert out["providerNpi"] == "1234567890"
|
||||||
assert out["batchId"] == "b1"
|
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
|
from cyclone.store import to_ui_claim
|
||||||
claim = _make_claim("CLM-2")
|
claim = _make_claim("CLM-2", frequency_code="7")
|
||||||
claim.validation = ValidationReport(
|
claim.validation = ValidationReport(
|
||||||
passed=True, errors=[],
|
passed=True, errors=[],
|
||||||
warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")],
|
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"
|
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():
|
def test_to_ui_claim_maps_status_denied_when_failed():
|
||||||
from cyclone.store import to_ui_claim
|
from cyclone.store import to_ui_claim
|
||||||
claim = _make_claim("CLM-3")
|
claim = _make_claim("CLM-4")
|
||||||
claim.validation = ValidationReport(
|
claim.validation = ValidationReport(
|
||||||
passed=False,
|
passed=False,
|
||||||
errors=[ValidationIssue(rule="E001", severity="error", message="hard")],
|
errors=[ValidationIssue(rule="E001", severity="error", message="hard")],
|
||||||
warnings=[],
|
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"
|
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():
|
def test_to_ui_remittance_maps_status_received_for_paid():
|
||||||
from cyclone.store import to_ui_remittance
|
from cyclone.store import to_ui_remittance
|
||||||
cp = ClaimPayment(
|
cp = _make_remit(status_code="1")
|
||||||
payer_claim_control_number="PCN-1",
|
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
|
||||||
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"
|
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():
|
def test_to_ui_provider_aggregates_claims():
|
||||||
@@ -192,17 +375,18 @@ def test_to_ui_provider_aggregates_claims():
|
|||||||
assert out["claimCount"] == 5
|
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
|
from cyclone.store import to_activity_event
|
||||||
out = to_activity_event(
|
out = to_activity_event(
|
||||||
id="a1", kind="claim_submitted",
|
id="a1", kind="claim_submitted",
|
||||||
message="Claim CLM-1 submitted",
|
message="Claim CLM-1 submitted",
|
||||||
timestamp="2026-06-19T12:00:00Z",
|
timestamp=_PARSED_AT,
|
||||||
npi="1234567890", amount=100.0,
|
npi="1234567890", amount=100.0,
|
||||||
)
|
)
|
||||||
assert out["id"] == "a1"
|
assert out["id"] == "a1"
|
||||||
assert out["kind"] == "claim_submitted"
|
assert out["kind"] == "claim_submitted"
|
||||||
assert out["amount"] == 100.0
|
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():
|
def test_iter_claims_filters_by_status():
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
result = _make_result()
|
result = _make_result()
|
||||||
a = _make_claim("A")
|
a = _make_claim("A", frequency_code="1") # passed + freq=1 → submitted
|
||||||
b = _make_claim("B")
|
b = _make_claim("B", frequency_code="7")
|
||||||
b.validation = ValidationReport(
|
b.validation = ValidationReport(
|
||||||
passed=True, errors=[],
|
passed=True, errors=[],
|
||||||
warnings=[ValidationIssue(rule="W1", severity="warning", message="x")],
|
warnings=[ValidationIssue(rule="W1", severity="warning", message="x")],
|
||||||
)
|
) # passed + freq=7 + warnings → pending
|
||||||
result.claims = [a, b]
|
result.claims = [a, b]
|
||||||
s.add(BatchRecord(
|
s.add(BatchRecord(
|
||||||
id="1", kind="837p", input_filename="a.txt",
|
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"))
|
out = list(s.iter_claims(status="submitted"))
|
||||||
assert all(c["status"] == "submitted" for c in out)
|
assert all(c["status"] == "submitted" for c in out)
|
||||||
@@ -236,7 +420,7 @@ def test_iter_claims_filters_by_batch_id():
|
|||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
s.add(BatchRecord(
|
s.add(BatchRecord(
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
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"))
|
out = list(s.iter_claims(batch_id="b1"))
|
||||||
assert all(c["batchId"] == "b1" for c in out)
|
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")
|
result.claims[1].claim.total_charge = Decimal("500.00")
|
||||||
s.add(BatchRecord(
|
s.add(BatchRecord(
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
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"))
|
out = list(s.iter_claims(sort="billedAmount", order="desc"))
|
||||||
assert out[0]["id"] == "expensive"
|
assert out[0]["id"] == "expensive"
|
||||||
@@ -266,7 +450,7 @@ def test_distinct_providers_aggregates_claims():
|
|||||||
result.claims = [_make_claim("A"), _make_claim("B")]
|
result.claims = [_make_claim("A"), _make_claim("B")]
|
||||||
s.add(BatchRecord(
|
s.add(BatchRecord(
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
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()
|
out = s.distinct_providers()
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
@@ -275,15 +459,29 @@ def test_distinct_providers_aggregates_claims():
|
|||||||
|
|
||||||
|
|
||||||
def test_recent_activity_returns_newest_first():
|
def test_recent_activity_returns_newest_first():
|
||||||
|
older = datetime(2026, 6, 19, 11, 0, 0, tzinfo=timezone.utc)
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
s.add(BatchRecord(
|
s.add(BatchRecord(
|
||||||
id="b1", kind="837p", input_filename="a.txt",
|
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(
|
s.add(BatchRecord(
|
||||||
id="b2", kind="837p", input_filename="b.txt",
|
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)
|
out = s.recent_activity(limit=10)
|
||||||
assert out[0]["timestamp"] == "2026-06-19T12:00:00Z"
|
assert out[0]["timestamp"] == "2026-06-19T12:00:00Z"
|
||||||
assert out[1]["timestamp"] == "2026-06-19T11: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"
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export interface Remittance {
|
|||||||
receivedDate: string;
|
receivedDate: string;
|
||||||
checkNumber: string;
|
checkNumber: string;
|
||||||
status: RemittanceStatus;
|
status: RemittanceStatus;
|
||||||
|
denialReason?: string | null;
|
||||||
|
validationWarnings?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity {
|
export interface Activity {
|
||||||
|
|||||||
Reference in New Issue
Block a user