Files
cyclone/backend/tests/test_store_reconcile.py
T

263 lines
9.4 KiB
Python

"""Integration tests for CycloneStore.add() with 835 batches.
Covers: parse-835 persist → reconcile trigger → CAS aggregation.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from cyclone import db
from cyclone.parsers.models_835 import (
ClaimPayment, ClaimAdjustment, ServicePayment,
ParseResult835, Envelope, FinancialInfo, ReassociationTrace,
Payer835, Payee835, BatchSummary,
)
from cyclone.store import CycloneStore
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
yield
db._reset_for_tests()
def _make_remit_with_cas(remit_id="CLP-1",
status="1", charge="124.00", paid="62.00",
cas_amount="62.00", pcn=None):
"""Build a single ClaimPayment with one CAS adjustment.
``pcn`` defaults to ``remit_id`` for backward compatibility with T11;
pass it explicitly when the PCN must differ from the row PK (the
T12 tests need this so the Remittance PK can be matched against a
different ``payer_claim_control_number``).
"""
cp = ClaimPayment(
payer_claim_control_number=pcn if pcn is not None else remit_id,
status_code=status,
status_label="Primary",
total_charge=charge, total_paid=paid,
service_payments=[
ServicePayment(
line_number=1, procedure_qualifier="HC", procedure_code="99213",
charge=charge, payment=paid,
adjustments=[ClaimAdjustment(group_code="CO", reason_code="45",
amount=cas_amount)],
),
],
)
return cp
def _make_835_result(claims):
# NOTE: field names below follow the actual model definitions in
# cyclone/parsers/models_835.py — the plan snippet (lines 2550-2566)
# referenced stale field names (e.g. credit_debit/handling_code) that
# no longer exist after the model was migrated.
return ParseResult835(
envelope=Envelope(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
financial_info=FinancialInfo(
handling_code="C", paid_amount=Decimal("0"),
credit_debit_flag="C", payment_method=None,
),
trace=ReassociationTrace(
trace_type_code="1", trace_number="0001",
originating_company_id="S",
),
payer=Payer835(name="X", id="SKCO0"),
payee=Payee835(name="Y", npi="1234567890"),
claims=claims,
summary=BatchSummary(
input_file="era.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=len(claims), passed=len(claims), failed=0,
),
)
def test_add_835_aggregates_cas_into_adjustment_amount():
s = CycloneStore()
cp = _make_remit_with_cas()
from cyclone.store import BatchRecord835
rec = BatchRecord835(
id="b-1", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
)
s.add(rec)
with db.SessionLocal()() as session:
from cyclone.db import Remittance, CasAdjustment
r = session.query(Remittance).first()
assert r is not None
assert r.adjustment_amount == 62.0
cas_rows = session.query(CasAdjustment).all()
assert len(cas_rows) == 1
assert cas_rows[0].group_code == "CO"
assert cas_rows[0].reason_code == "45"
# ---------------------------------------------------------------------------
# T12: list_unmatched / manual_match / manual_unmatch
# ---------------------------------------------------------------------------
def test_list_unmatched_returns_orphan_remit():
"""An 835 with a remittance that has no paired Claim surfaces it."""
s = CycloneStore()
# Use a distinct PCN from the PK so the row is uniquely identifiable.
cp = _make_remit_with_cas(remit_id="CLP-ORPHAN", pcn="PCN-NEW")
from cyclone.store import BatchRecord835
rec = BatchRecord835(
id="b-1", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
)
s.add(rec)
out = s.list_unmatched(kind="both")
assert len(out["remittances"]) == 1
assert out["remittances"][0]["payerClaimControlNumber"] == "PCN-NEW"
assert out["claims"] == []
def test_manual_match_pairs_claim_with_orphan_remit():
"""Pairing an unmatched claim with an unmatched remittance works,
updates Claim.state, records the Match row, and removes both sides
from the unmatched list.
The 837 subscriber's member_id ("M1") intentionally differs from the
835 remit's PCN ("CLM-1") so ``reconcile.run`` doesn't auto-pair them
on ingest — we want them to land in the unmatched list so manual
pairing has something to do.
"""
from cyclone.store import BatchRecord837, BatchRecord835
from cyclone.parsers.models import (
BatchSummary, BillingProvider, ClaimHeader, ClaimOutput,
Envelope, Payer, ParseResult, Subscriber, ValidationReport,
)
s = CycloneStore()
# Add a Claim via 837. member_id="M1" so PCN-based auto-match won't
# pair it with the 835 remit's payer_claim_control_number="CLM-1".
co = ClaimOutput(
claim_id="CLM-1",
control_number="0001",
transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(name="Test", npi="1234567890"),
subscriber=Subscriber(
first_name="Jane", last_name="Doe", member_id="M1",
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id="CLM-1", total_charge=Decimal("100"),
frequency_code="1", place_of_service="11",
),
diagnoses=[],
service_lines=[],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
pr837 = ParseResult(
envelope=Envelope(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
claims=[co],
summary=BatchSummary(
input_file="c.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
)
s.add(BatchRecord837(
id="b-837", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=pr837,
))
# Add an 835 with an orphan remit. paid == charge so apply_payment
# picks ClaimState.PAID (the test asserts result["claim"]["state"]
# == "paid"). cas_amount=0 keeps things simple.
cp = _make_remit_with_cas(
remit_id="CLP-1", charge="100", paid="100", cas_amount="0",
)
s.add(BatchRecord835(
id="b-835", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
out = s.list_unmatched(kind="both")
assert len(out["claims"]) == 1
assert len(out["remittances"]) == 1
claim_id = out["claims"][0]["id"]
remit_id = out["remittances"][0]["id"]
result = s.manual_match(claim_id, remit_id)
assert result["claim"]["state"] == "paid"
assert result["match"]["strategy"] == "manual"
# Symmetric FK write — claim side...
assert result["claim"]["matchedRemittanceId"] == remit_id
assert result["match"]["claimId"] == claim_id
assert result["match"]["remittanceId"] == remit_id
# Both sides now filtered out of the unmatched list.
out = s.list_unmatched(kind="both")
assert out["claims"] == []
assert out["remittances"] == []
# Match row was persisted with strategy="manual".
with db.SessionLocal()() as session:
from cyclone.db import Claim, Match
claim_row = session.get(Claim, claim_id)
assert claim_row is not None
assert claim_row.matched_remittance_id == remit_id
match_rows = (
session.query(Match).filter(Match.claim_id == claim_id).all()
)
assert len(match_rows) == 1
assert match_rows[0].strategy == "manual"
def test_manual_match_conflict_raises():
"""Pairing a claim that already has a match raises AlreadyMatchedError.
We construct the matched claim directly (no 837 ingest) so the test
is fully independent of the 837 write path. The 835 batch is added
only to satisfy the FK on the Claim's batch_id — the remittance
itself never participates in the manual_match call.
"""
from cyclone.store import AlreadyMatchedError, BatchRecord835
s = CycloneStore()
cp = _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-A")
s.add(BatchRecord835(
id="b-1", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
from cyclone.db import Claim
with db.SessionLocal()() as sess:
sess.add(Claim(
id="CLM-X", batch_id="b-1",
patient_control_number="PCN-A",
charge_amount=Decimal("100"), state="paid",
matched_remittance_id="CLP-1:b1xxxxxx",
))
sess.commit()
with pytest.raises(AlreadyMatchedError):
s.manual_match("CLM-X", "CLP-1:b1xxxxxx")