Files
cyclone/backend/tests/test_store_reconcile.py
T
Nora d8841834dc fix(sp27): Claim.patient_control_number populated from CLM01 (claim_id), not 2010BA NM109 (member_id)
The 837 ingest path (_claim_837_row in store.py:194) populated
Claim.patient_control_number from claim.subscriber.member_id — the
subscriber's 2010BA NM109 Medicaid ID — instead of from
claim.claim_id (CLM01, the claim submitter's identifier the 837
actually sent).

That silently broke every downstream join that uses this column as a
cross-reference key:

  * reconcile.match() (reconcile.py:74) — joins
    Claim.patient_control_number against
    Remittance.payer_claim_control_number (which is parsed from CLP01,
    the 835's echo of CLM01 per X12 spec). Member_id never matches
    CLP01, so auto-match always fails.
  * apply_999_rejections — same lookup, same broken key.
  * apply_277ca_rejections — same lookup, same broken key.
  * scoring.score_pair — same broken key.

Live DB probe (1,739 remits, 337 claims):
  * Claim.id == Remit.payer_claim_control_number    → 0 matches
  * Claim.patient_control_number == Remit.payer_claim_control_number
    → 9 matches (substring coincidences; synthetic PCN strings share
    alphanumeric characters with the human-readable member_ids)
  * Claim.matched_remittance_id NOT NULL             → 0 claims

This commit changes _claim_837_row to write
Claim.patient_control_number = claim.claim_id (= CLM01). The
reconcile matcher's existing join now hits the row the 835 echoes
back. Companion migration 0017 backfills the 337 pre-fix rows so
they're on equal footing with new ingests (UPDATE claims SET
patient_control_number = id WHERE patient_control_number IS DISTINCT
FROM id — idempotent).

Tests:
  * 2 new RED→GREEN tests in test_store_reconcile.py:
    - test_837_ingest_populates_patient_control_number_from_claim_id
      pins the field semantics directly
    - test_837_then_835_with_echoed_pcn_auto_pairs proves the full
      end-to-end auto-match now fires
  * 3 existing manual_match tests that relied on the bug (had setup
    using member_id != claim_id to deliberately prevent auto-match)
    updated to use distinct PCNs explicitly so the tests still
    exercise the manual-match path with a real orphan pair.
  * 3 store_claim_detail / api_gets tests adjusted for the same
    reason.

Verified: 1,176 / 1,177 backend tests pass; the one failure is the
pre-existing flake in test_provider_extended_response.py noted before
this work started.

Caveat for the existing dev DB: the 1,739 remits already in the DB
were ingested from 835 fixtures whose CLP01 is a different synthetic
identifier than the 837's CLM01 (the test fixtures never echoed
CLM01 — a fixture-data limitation, not a code bug). After this fix,
new 837+835 ingest pairs whose payer echoes CLM01 in CLP01 will
auto-match as expected. The pre-existing 1,739 remits will continue
to land in the unmatched bucket; that can only be fixed by
regenerating the test fixtures (out of scope for this SP).
2026-06-29 14:32:10 -06:00

758 lines
28 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")
# ---------------------------------------------------------------------------
# SP7: manual_match populates LineReconciliation rows + CAS aggregates.
# ---------------------------------------------------------------------------
def test_manual_match_populates_line_reconciliation_rows():
"""manual_match must run line-level reconciliation, same as auto-match.
Without this, operators who manually pair claims see no per-line audit
data (every line shows up as ``unmatched_837_only`` in the UI). The
SP7 fix routes ``manual_match`` through ``reconcile._reconcile_pair``
after the claim↔remit FK is set, mirroring the auto-match path.
Setup:
* 837 claim with two SV1 lines (99213 + 99214).
* 835 remit with one SVC line (99213) — matches line 1, leaves
line 2 unmatched_837_only.
* member_id and PCN differ so auto-match doesn't pair them.
Expected post-match state:
* 1 ``matched`` LineReconciliation row (line 1 ↔ SVC 1).
* 1 ``unmatched_837_only`` LineReconciliation row (line 2).
* ``Remittance.adjustment_amount`` recomputed (0 — no CAS in this test).
* ``Remittance.claim_level_adjustment_amount`` recomputed (0).
"""
from sqlalchemy import select
from cyclone.db import LineReconciliation, Remittance
from cyclone.store import BatchRecord837, BatchRecord835
from cyclone.parsers.models import (
BatchSummary as BatchSummary837,
BillingProvider, ClaimHeader, ClaimOutput, Envelope,
Payer, ParseResult, Procedure, ServiceLine, Subscriber,
ValidationReport,
)
s = CycloneStore()
# 837 claim with two SV1 service lines.
co = ClaimOutput(
claim_id="CLM-MANUAL",
control_number="0001",
transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(name="Test", npi="1234567890"),
# member_id deliberately ≠ PCN below so auto-match skips this claim.
subscriber=Subscriber(first_name="Jane", last_name="Doe",
member_id="MANUAL"),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id="CLM-MANUAL", total_charge=Decimal("200.00"),
frequency_code="1", place_of_service="11",
),
diagnoses=[],
service_lines=[
ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code="99213",
modifiers=[]),
charge=Decimal("100.00"),
unit_type="UN",
units=Decimal("1"),
service_date=date(2026, 6, 19),
),
ServiceLine(
line_number=2,
procedure=Procedure(qualifier="HC", code="99214",
modifiers=[]),
charge=Decimal("100.00"),
unit_type="UN",
units=Decimal("1"),
service_date=date(2026, 6, 19),
),
],
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=BatchSummary837(
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-sp7", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=pr837,
))
# 835 remit with one SVC line that matches claim line 1.
# pcn deliberately ≠ claim_id so auto-match (which now joins on
# claim_id == pcn after the SP27 Task 17 PCN fix) doesn't pair them
# and the claim lands in the unmatched list for manual pairing.
cp = ClaimPayment(
payer_claim_control_number="ORPHAN-PCN-MANUAL",
status_code="1",
status_label="Primary",
total_charge=Decimal("200.00"),
total_paid=Decimal("200.00"),
service_payments=[
ServicePayment(
line_number=1, procedure_qualifier="HC",
procedure_code="99213",
charge=Decimal("100.00"),
payment=Decimal("100.00"),
units=Decimal("1"),
service_date=date(2026, 6, 19),
adjustments=[],
),
],
)
s.add(BatchRecord835(
id="b-835-sp7", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
# Verify auto-match didn't pair them (they should both be unmatched).
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"]
# Manual match — must populate LineReconciliation rows.
result = s.manual_match(claim_id, remit_id)
assert result["claim"]["state"] == "paid"
with db.SessionLocal()() as session:
lrs = list(
session.execute(
select(LineReconciliation)
.where(LineReconciliation.claim_id == claim_id)
.order_by(LineReconciliation.id)
).scalars().all()
)
# Two claim SV1 lines → exactly two LineReconciliation rows
# (matched + unmatched_837_only). No SVC-only rows since the
# single SVC line pairs with claim line 1.
assert len(lrs) == 2, [lr.status for lr in lrs]
statuses = sorted(lr.status for lr in lrs)
assert statuses == ["matched", "unmatched_837_only"], statuses
# The matched row references claim line 1 + SVC row 1.
matched = next(lr for lr in lrs if lr.status == "matched")
assert matched.claim_service_line_number == 1
assert matched.service_line_payment_id is not None
# The unmatched_837_only row references claim line 2 only.
unmatched = next(lr for lr in lrs if lr.status == "unmatched_837_only")
assert unmatched.claim_service_line_number == 2
assert unmatched.service_line_payment_id is None
# Remittance aggregates were recomputed (0 CAS in this test).
r = session.get(Remittance, remit_id)
assert r is not None
assert r.adjustment_amount == Decimal("0")
assert r.claim_level_adjustment_amount == Decimal("0")
def test_manual_match_idempotent_line_reconciliation():
"""Re-running ``manual_match`` semantics on a rematched claim must not
duplicate LineReconciliation rows.
The helper clears stale rows for the claim before inserting fresh
ones, so a manual_unmatch → manual_rematch cycle (possibly to a
different remittance) produces a clean slate per claim.
"""
from sqlalchemy import select
from cyclone.db import LineReconciliation
from cyclone.store import BatchRecord837, BatchRecord835
from cyclone.parsers.models import (
BatchSummary as BatchSummary837,
BillingProvider, ClaimHeader, ClaimOutput, Envelope,
Payer, ParseResult, Procedure, ServiceLine, Subscriber,
ValidationReport,
)
s = CycloneStore()
# 837 with two SV1 lines, member_id="IDEMP" so it doesn't auto-pair.
co = ClaimOutput(
claim_id="CLM-IDEMP",
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="IDEMP"),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id="CLM-IDEMP", total_charge=Decimal("200.00"),
frequency_code="1", place_of_service="11",
),
diagnoses=[],
service_lines=[
ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code="99213",
modifiers=[]),
charge=Decimal("100.00"),
unit_type="UN",
units=Decimal("1"),
service_date=date(2026, 6, 19),
),
ServiceLine(
line_number=2,
procedure=Procedure(qualifier="HC", code="99214",
modifiers=[]),
charge=Decimal("100.00"),
unit_type="UN",
units=Decimal("1"),
service_date=date(2026, 6, 19),
),
],
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=BatchSummary837(
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-idemp", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=pr837,
))
# 835 with two SVC lines — auto-match won't pair (pcn="ORPHAN-IDEMP"
# ≠ claim_id="CLM-IDEMP"), so manual_match has work to do.
cp = ClaimPayment(
payer_claim_control_number="ORPHAN-IDEMP",
status_code="1",
status_label="Primary",
total_charge=Decimal("200.00"),
total_paid=Decimal("200.00"),
service_payments=[
ServicePayment(
line_number=1, procedure_qualifier="HC",
procedure_code="99213",
charge=Decimal("100.00"),
payment=Decimal("100.00"),
units=Decimal("1"),
service_date=date(2026, 6, 19),
adjustments=[],
),
ServicePayment(
line_number=2, procedure_qualifier="HC",
procedure_code="99214",
charge=Decimal("100.00"),
payment=Decimal("100.00"),
units=Decimal("1"),
service_date=date(2026, 6, 19),
adjustments=[],
),
],
)
s.add(BatchRecord835(
id="b-835-idemp", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
out = s.list_unmatched(kind="both")
claim_id = out["claims"][0]["id"]
remit_id = out["remittances"][0]["id"]
# First match — creates LineReconciliation rows.
s.manual_match(claim_id, remit_id)
with db.SessionLocal()() as session:
first_count = len(list(
session.execute(
select(LineReconciliation)
.where(LineReconciliation.claim_id == claim_id)
).scalars().all()
))
assert first_count == 2
# Unmatch — clears FK but leaves LineReconciliation rows in place.
s.manual_unmatch(claim_id)
# Insert a CLP-level CAS row directly so we can verify aggregate
# recompute on the rematch. This simulates a forward-compat CLP CAS
# (today's parser doesn't produce these, see _persist_835_remit).
from cyclone.db import CasAdjustment
with db.SessionLocal()() as session:
session.add(CasAdjustment(
remittance_id=remit_id,
group_code="CO", reason_code="45",
amount=Decimal("20.00"),
quantity=None,
service_line_payment_id=None, # CLP-level
))
session.commit()
# Rematch — _reconcile_pair clears stale rows and inserts fresh ones.
s.manual_match(claim_id, remit_id)
with db.SessionLocal()() as session:
second = list(
session.execute(
select(LineReconciliation)
.where(LineReconciliation.claim_id == claim_id)
.order_by(LineReconciliation.id)
).scalars().all()
)
# Still exactly 2 rows — stale rows from the first match were
# deleted before the new ones were inserted.
assert len(second) == 2, [lr.status for lr in second]
statuses = sorted(lr.status for lr in second)
assert statuses == ["matched", "matched"], statuses
# CLP-level CAS aggregate now reflects the inserted row.
from cyclone.db import Remittance
r = session.get(Remittance, remit_id)
assert r is not None
assert r.adjustment_amount == Decimal("20.00")
assert r.claim_level_adjustment_amount == Decimal("20.00")
# ---------------------------------------------------------------------------
# SP27 Task 17: Claim.patient_control_number must be the CLM01 (claim_id)
# the 837 sent, NOT the 2010BA subscriber member_id. The reconcile matcher
# joins Claim.patient_control_number == Remittance.payer_claim_control_number,
# and the 835 echoes CLM01 in CLP01 per X12 spec. Storing member_id here
# silently breaks every auto-match in production (the 9/1,739 "matches"
# observed in prod data are substring coincidences between synthetic CLM01
# and the human-readable PCN).
# ---------------------------------------------------------------------------
def _make_837_result(claims):
"""Build a minimal ParseResult with the given ClaimOutputs."""
from cyclone.parsers.models import (
BatchSummary as BatchSummary837,
BillingProvider, ClaimHeader, ClaimOutput, Envelope,
Payer, ParseResult, Subscriber, ValidationReport,
)
return ParseResult(
envelope=Envelope(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
claims=claims,
summary=BatchSummary837(
input_file="c.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=len(claims), passed=len(claims), failed=0,
),
)
def _make_claim_output(claim_id="CLM-A", member_id="MEMBER-X",
charge="100.00",
service_date=date(2026, 6, 19)):
"""Single ClaimOutput with distinct claim_id and subscriber.member_id.
``service_date`` populates the SV1 service-line date so the
reconcile matcher's ``_pick_claim`` window check can find this
claim when the matching 835 has the same date in SVC.
"""
from cyclone.parsers.models import (
BillingProvider, ClaimHeader, ClaimOutput,
Subscriber, Payer, Procedure, ServiceLine,
ValidationReport,
)
return ClaimOutput(
claim_id=claim_id,
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=member_id),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(claim_id=claim_id, total_charge=Decimal(charge),
frequency_code="1", place_of_service="11"),
diagnoses=[],
service_lines=[
ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code="99213",
modifiers=[]),
charge=Decimal(charge),
unit_type="UN",
units=Decimal("1"),
service_date=service_date,
),
],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
def test_837_ingest_populates_patient_control_number_from_claim_id():
"""Claim.patient_control_number == claim_id (CLM01), not member_id.
RED: today the 837 ingest writes ``Claim.patient_control_number =
claim.subscriber.member_id`` (store.py:194). That breaks the
reconcile join against the 835's CLP01 (which echoes CLM01).
"""
from cyclone.db import Claim
from cyclone.store import BatchRecord837
s = CycloneStore()
s.add(BatchRecord837(
id="b-837-pcn", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_837_result([
_make_claim_output(claim_id="CLM-A", member_id="MEMBER-X"),
]),
))
with db.SessionLocal()() as session:
claim = session.get(Claim, "CLM-A")
assert claim is not None
assert claim.patient_control_number == "CLM-A", (
f"expected CLM01 (claim_id) 'CLM-A', "
f"got Claim.patient_control_number={claim.patient_control_number!r} "
f"(should be the value the 837 sent in CLM01, not the "
f"subscriber's 2010BA NM109 member_id)."
)
def test_837_then_835_with_echoed_pcn_auto_pairs():
"""End-to-end: 837 CLM01='CLM-A' + 835 CLP01='CLM-A' must auto-match.
RED: today ``Claim.patient_control_number == subscriber.member_id``
so the join on ``Claim.patient_control_number ==
Remittance.payer_claim_control_number`` fails even when the payer
echoes CLM01 correctly.
"""
from cyclone.db import Claim, Remittance
from cyclone.store import BatchRecord837, BatchRecord835
s = CycloneStore()
# 837: CLM01 = 'CLM-A', subscriber.member_id = 'MEMBER-X' (different)
s.add(BatchRecord837(
id="b-837-echo", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_837_result([
_make_claim_output(claim_id="CLM-A", member_id="MEMBER-X",
charge="100.00"),
]),
))
# 835: CLP01 = 'CLM-A' (the payer echoes CLM01 back), paid == charge
cp = ClaimPayment(
payer_claim_control_number="CLM-A",
status_code="1", status_label="Primary",
total_charge=Decimal("100.00"), total_paid=Decimal("100.00"),
service_payments=[
ServicePayment(
line_number=1, procedure_qualifier="HC",
procedure_code="99213",
charge=Decimal("100.00"), payment=Decimal("100.00"),
units=Decimal("1"),
service_date=date(2026, 6, 19),
adjustments=[],
),
],
)
s.add(BatchRecord835(
id="b-835-echo", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
with db.SessionLocal()() as session:
claim = session.get(Claim, "CLM-A")
r = session.query(Remittance).filter(
Remittance.payer_claim_control_number == "CLM-A"
).first()
# Both sides must have the FK set: Claim.matched_remittance_id
# AND Remittance.claim_id, written in the same transaction.
assert claim.matched_remittance_id is not None, (
"Claim should be auto-matched to the remit (CLM01 echoed "
"in CLP01), but matched_remittance_id is still NULL. The "
"837 ingest is storing patient_control_number from "
"subscriber.member_id instead of claim.claim_id."
)
assert r.claim_id == "CLM-A"
assert r.claim_id == claim.id
assert claim.state == "paid"