feat(backend): POST /api/reconciliation/{match,unmatch} + InvalidStateError

This commit is contained in:
Tyler
2026-06-19 23:39:20 -06:00
parent a4c59587c6
commit 44fe88d694
3 changed files with 355 additions and 6 deletions
+240
View File
@@ -397,3 +397,243 @@ def test_get_reconciliation_unmatched_returns_orphans(client: TestClient):
assert body["claims"][0]["id"] == "CLM-1"
assert len(body["remittances"]) == 1
assert body["remittances"][0]["payerClaimControlNumber"] == "PCN-OTHER"
# --------------------------------------------------------------------------- #
# /api/reconciliation/match + /unmatch (T15)
# --------------------------------------------------------------------------- #
def test_post_match_happy_path(client: TestClient):
"""Match an unmatched claim + remit; both removed from unmatched bucket.
Setup is fully self-contained: we add a Claim (837 ingest) AND a
Remittance (835 ingest) with mismatched member_id/PCN so the auto-
matcher in ``reconcile.run`` does not pair them on ingest, then
POST to /api/reconciliation/match. Asserts the 200 response shape:
``body["claim"]["state"] == "paid"`` (charge == paid triggers
PAID in ``reconcile.apply_payment``) and the match row records
``strategy="manual"``.
The plan's snippet used stale Pydantic v1 dict fields for
``ClaimOutput`` / ``ParseResult``; the current models are typed
BaseModel instances, so we mirror the pattern from
``test_get_reconciliation_unmatched_returns_orphans`` and
``tests/test_store_reconcile.py::test_manual_match_pairs_claim_with_orphan_remit``.
"""
from datetime import date, datetime, timezone
from decimal import Decimal
from cyclone.parsers.models import (
BatchSummary, BillingProvider, ClaimHeader, ClaimOutput,
Envelope, Payer, ParseResult, Subscriber, ValidationReport,
)
from cyclone.parsers.models_835 import (
BatchSummary as BatchSummary835,
ClaimPayment, Envelope as Envelope835,
FinancialInfo, Payer835, ParseResult835, Payee835,
ReassociationTrace,
)
from cyclone.store import BatchRecord837, BatchRecord835
# 837 Claim. member_id="M1" so the 835 PCN-based auto-match can't pair them.
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=[],
)
global_store.add(BatchRecord837(
id="b-837", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=ParseResult(
claims=[co],
summary=BatchSummary(
input_file="c.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
),
))
# 835 Remittance. PCN="CLM-1" but member_id on the claim side is "M1",
# so the auto-matcher in reconcile.run does NOT pair them; we want
# an orphan so manual_match has work to do. charge == paid == 100
# so apply_payment picks ClaimState.PAID.
cp = ClaimPayment(
payer_claim_control_number="CLM-1",
status_code="1",
total_charge=Decimal("100"),
total_paid=Decimal("100"),
service_payments=[],
)
global_store.add(BatchRecord835(
id="b-835", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
result=ParseResult835(
envelope=Envelope835(
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=[cp],
summary=BatchSummary835(
input_file="era.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
),
))
out = client.get("/api/reconciliation/unmatched").json()
assert len(out["claims"]) == 1
assert len(out["remittances"]) == 1
claim_id = out["claims"][0]["id"]
remit_id = out["remittances"][0]["id"]
r = client.post(
"/api/reconciliation/match",
json={"claim_id": claim_id, "remit_id": remit_id},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["claim"]["state"] == "paid"
assert body["match"]["strategy"] == "manual"
assert body["claim"]["matchedRemittanceId"] == remit_id
assert body["match"]["claimId"] == claim_id
assert body["match"]["remittanceId"] == remit_id
# Both sides now filtered out of the unmatched list.
out2 = client.get("/api/reconciliation/unmatched").json()
assert out2["claims"] == []
assert out2["remittances"] == []
def test_post_match_conflict_returns_409(client: TestClient):
"""Matching an already-matched claim returns 409 with ``already_matched``.
Self-contained: builds its own Claim + Remittance via direct ORM
inserts (skips the 837/835 ingest path so the test doesn't depend
on the previous test's data — pytest's default file ordering runs
``test_post_match_happy_path`` first and that test drains the
unmatched bucket, so reading from
``/api/reconciliation/unmatched`` would yield nothing here and
the plan's ``pytest.skip(...)`` would silently no-op the test).
"""
from datetime import datetime, timezone
from decimal import Decimal
from cyclone import db
from cyclone.db import Batch, Claim, Remittance
with db.SessionLocal()() as s:
s.add(Batch(
id="b-conflict", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Claim(
id="CLM-CONFLICT", batch_id="b-conflict",
patient_control_number="CLM-CONFLICT",
charge_amount=Decimal("100"),
state="submitted",
matched_remittance_id=None,
))
s.add(Remittance(
id="CLP-CONFLICT", batch_id="b-conflict",
payer_claim_control_number="PCN-CONFLICT",
status_code="1", total_charge=Decimal("100"),
total_paid=Decimal("100"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.commit()
# First match succeeds.
r1 = client.post(
"/api/reconciliation/match",
json={"claim_id": "CLM-CONFLICT", "remit_id": "CLP-CONFLICT"},
)
assert r1.status_code == 200, r1.text
# Second match for the same claim → 409.
r2 = client.post(
"/api/reconciliation/match",
json={"claim_id": "CLM-CONFLICT", "remit_id": "CLP-CONFLICT"},
)
assert r2.status_code == 409, r2.text
detail = r2.json()["detail"]
assert detail["error"] == "already_matched"
def test_post_unmatch_removes_match(client: TestClient):
"""Unmatch reverses a manual pair and returns the claim to ``submitted``.
The plan snippet creates a Claim with ``state="paid"`` and a
``matched_remittance_id`` set, but **no** corresponding ``Match``
row. ``manual_unmatch`` is built to handle this defensive case —
``matched_remittance_id IS NOT NULL`` lets it through the first
guard, then with no ``Match`` rows it falls back to restoring
``ClaimState.SUBMITTED`` (the default prior state).
We also need an 835 batch row for the remittance's ``batch_id`` FK;
the remittance here is detached from auto-match (no Match row) so
we just need the batch row to exist.
"""
from datetime import datetime, timezone
from decimal import Decimal
from cyclone import db
from cyclone.db import Batch, Claim, Remittance
with db.SessionLocal()() as s:
s.add(Batch(
id="b-unmatch", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Claim(
id="CLM-X", batch_id="b-unmatch",
patient_control_number="CLM-X",
charge_amount=Decimal("100"),
state="paid",
matched_remittance_id="CLP-Y:b-unmatch",
))
s.add(Remittance(
id="CLP-Y:b-unmatch", batch_id="b-unmatch",
payer_claim_control_number="CLM-X",
status_code="1",
total_charge=Decimal("100"),
total_paid=Decimal("100"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.commit()
r = client.post(
"/api/reconciliation/unmatch",
json={"claim_id": "CLM-X"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["claim"]["state"] == "submitted"
assert body["claim"]["matchedRemittanceId"] is None
# No Match rows existed before the call → 0 deleted.
assert body["deletedMatches"] == 0