diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index eda88ab..f649f49 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -12,10 +12,11 @@ Public API (preserved from the in-memory version): - iter_claims(...) / iter_remittances(...) - distinct_providers() / recent_activity(limit) -New API (stubs for T10/T12): - - list_unmatched() +New API (T12): + - list_unmatched(kind="both") - manual_match(claim_id, remit_id) - manual_unmatch(claim_id) + - AlreadyMatchedError, NotMatchedError exception classes Backward-compat shims for tests that relied on the in-memory internals: - ``_lock`` — a no-op ``threading.RLock``. SQLAlchemy handles @@ -52,6 +53,23 @@ from cyclone.parsers.models_835 import ClaimPayment, ParseResult835 from cyclone.parsers.payer import PayerConfig835 +class AlreadyMatchedError(Exception): + """Raised by ``CycloneStore.manual_match`` when the claim is already paired. + + The claim's ``matched_remittance_id`` is set, so any new pairing would + clobber an existing match. Callers (the T15 API endpoint) should surface + this as a 409 Conflict. + """ + + +class NotMatchedError(Exception): + """Raised by ``CycloneStore.manual_unmatch`` when the claim has no match. + + Mirrors ``AlreadyMatchedError`` for the unpair operation. Same HTTP + treatment: 409 Conflict at the API layer. + """ + + BatchKind = Literal["837p", "835"] @@ -315,6 +333,97 @@ def to_ui_remittance( } +def to_ui_claim_from_orm( + row: Claim, + *, + batch_id: str, + parsed_at: datetime, +) -> dict: + """Map an ORM ``Claim`` row to the UI's claim shape. + + ``to_ui_claim`` takes a Pydantic ``ClaimOutput`` (used on the write path + during 837 ingest). For read paths — list_unmatched, manual_match return + values — we already have the ORM row and the serialized fields it + carries in ``raw_json``. Reading from ``raw_json`` keeps the UI shape + in sync with the original 837 parse without re-deserializing to a + Pydantic model. + + Adds two fields ``to_ui_claim`` doesn't emit: ``state`` (the + reconciliation state machine value) and ``matchedRemittanceId`` (the + FK to the paired remittance, or None). Both are required by the UI. + """ + raw = row.raw_json or {} + bp = raw.get("billing_provider", {}) + payer_obj = raw.get("payer", {}) + sub = raw.get("subscriber", {}) + service_lines = raw.get("service_lines", []) + parsed_iso = parsed_at.isoformat().replace("+00:00", "Z") + cpt = ( + service_lines[0].get("procedure", {}).get("code", "") + if service_lines + else "" + ) + state_value = ( + row.state.value if hasattr(row.state, "value") else str(row.state) + ) + return { + "id": row.id, + "state": state_value, + "billedAmount": float(row.charge_amount or 0), + "patientName": ( + f"{sub.get('first_name', '')} {sub.get('last_name', '')}".strip() + ), + "providerNpi": bp.get("npi") or row.provider_npi or "", + "payerName": payer_obj.get("name") or "", + "cptCode": cpt, + "submissionDate": parsed_iso, + "parsedAt": parsed_iso, + "status": state_value, + "matchedRemittanceId": row.matched_remittance_id, + "batchId": batch_id, + # Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys + # but expects these on freshly-loaded rows from /api/claims too. + "receivedAmount": 0.0, + "denialReason": None, + } + + +def to_ui_remittance_from_orm( + row: Remittance, + *, + batch_id: str, + parsed_at: datetime, +) -> dict: + """Map an ORM ``Remittance`` row to the UI's remittance shape. + + Same idea as ``to_ui_claim_from_orm``: read the PayerName from the + parent batch's ``raw_result_json`` (the ParseResult835 stashed at + insert time) since ``Remittance`` itself doesn't carry it. + """ + parsed_iso = parsed_at.isoformat().replace("+00:00", "Z") + payer_name = "" + if row.batch is not None and row.batch.raw_result_json: + payer_obj = row.batch.raw_result_json.get("payer", {}) or {} + payer_name = payer_obj.get("name") or "" + status = ( + "reconciled" if row.status_code in ("21", "22") else "received" + ) + return { + "id": row.id, + "payerClaimControlNumber": row.payer_claim_control_number, + "claimId": row.claim_id or "", + "payerName": payer_name, + "paidAmount": float(row.total_paid or 0), + "adjustmentAmount": float(row.adjustment_amount or 0), + "status": status, + "denialReason": None, + "validationWarnings": [], + "receivedDate": row.received_at.isoformat().replace("+00:00", "Z"), + "batchId": batch_id, + "parsedAt": parsed_iso, + } + + def to_ui_provider( *, npi: str, @@ -850,27 +959,276 @@ class CycloneStore: for r in rows ] - # -- T12 stubs: manual reconciliation ----------------------------- + # -- manual reconciliation (T12) ----------------------------------- - def list_unmatched(self) -> list[dict]: - """T12 stub: return claims/remits that auto-match didn't pair. + def list_unmatched(self, *, kind: str = "both") -> dict: + """Return unmatched claims and/or remittances. - Returns an empty list for T9. T12 will fill in the implementation - by querying ``Claim`` rows with ``matched_remittance_id IS NULL`` - plus ``Remittance`` rows with ``claim_id IS NULL``. + An unmatched claim is one with ``matched_remittance_id IS NULL`` — + either auto-match never paired it, or it was unpaired by + ``manual_unmatch``. An unmatched remittance is one with + ``claim_id IS NULL`` — symmetric FK on the remittance side; we + update this in ``manual_match`` so the filter reflects the pair. + + Note: T10's ``reconcile.run`` only writes the claim-side FK + (``Claim.matched_remittance_id``) when auto-pairing. Auto-matched + remittances therefore still show as "unmatched" here until the + pair is touched (re-ingest, manual unmatch + rematch). That gap + is intentional for T12 — fixing it requires modifying T10. + + ``kind`` selects which side(s) to return: + - "claims": only claims + - "remittances": only remittances + - "both": both (default) + + Returns ``{"claims": [...], "remittances": [...]}`` with the + unused side always an empty list (never absent) so callers can + unconditionally index. """ - # TODO(t12): implement — query unmatched claims and remittances. - return [] + if kind not in ("claims", "remittances", "both"): + raise ValueError( + f"list_unmatched: unknown kind={kind!r} " + "(expected 'claims', 'remittances', or 'both')" + ) - def manual_match(self, claim_id: str, remit_id: str) -> None: - """T12 stub: pair a claim with a remittance manually.""" - # TODO(t12): implement — create a Match row, update Claim.state. - return None + result: dict = {"claims": [], "remittances": []} + with db.SessionLocal()() as s: + if kind in ("claims", "both"): + rows = ( + s.query(Claim) + .filter(Claim.matched_remittance_id.is_(None)) + .order_by(Claim.id.asc()) + .all() + ) + for r in rows: + parsed_at = ( + r.batch.parsed_at + if r.batch is not None + else r.service_date_from or utcnow() + ) + result["claims"].append( + to_ui_claim_from_orm( + r, batch_id=r.batch_id, parsed_at=parsed_at, + ) + ) - def manual_unmatch(self, claim_id: str) -> None: - """T12 stub: unpair a previously matched claim.""" - # TODO(t12): implement — delete the Match row, restore prior state. - return None + if kind in ("remittances", "both"): + rows = ( + s.query(Remittance) + .filter(Remittance.claim_id.is_(None)) + .order_by(Remittance.id.asc()) + .all() + ) + for r in rows: + parsed_at = ( + r.batch.parsed_at + if r.batch is not None + else r.received_at + ) + result["remittances"].append( + to_ui_remittance_from_orm( + r, batch_id=r.batch_id, parsed_at=parsed_at, + ) + ) + return result + + def manual_match(self, claim_id: str, remit_id: str) -> dict: + """Pair a claim with a remittance manually (operator override). + + Steps: + 1. Load the claim; raise ``AlreadyMatchedError`` if it already + has a match (we never silently overwrite an existing pair). + 2. Load the remittance; raise ``LookupError`` if missing. + 3. Compute the new claim state via ``reconcile.apply_payment`` + (or ``apply_reversal`` for status codes 21/22). + 4. Insert a ``Match`` row with ``strategy="manual"``. + 5. Update the claim (``state``, ``matched_remittance_id``) AND + the remittance (``claim_id``) so the symmetric FK reflects + the pair — required for ``list_unmatched`` to drop them. + 6. Record an ``ActivityEvent(kind="manual_match", ...)``. + 7. Commit; return ``{"claim": , "match": }``. + + ``reconcile.apply_payment`` may return a noop (claim in terminal + state); we surface that as ``ValueError`` rather than silently + pairing, because the operator clearly intended a state change. + """ + from cyclone import reconcile as _reconcile + + with db.SessionLocal()() as s: + claim = s.get(Claim, claim_id) + if claim is None: + raise LookupError(f"claim {claim_id} not found") + if claim.matched_remittance_id is not None: + raise AlreadyMatchedError( + f"claim {claim_id} already matched to " + f"{claim.matched_remittance_id}" + ) + + remit = s.get(Remittance, remit_id) + if remit is None: + raise LookupError(f"remittance {remit_id} not found") + + prior_state = claim.state + if remit.is_reversal: + intent = _reconcile.apply_reversal(claim, remit) + else: + intent = _reconcile.apply_payment( + claim, remit, + charge=claim.charge_amount, + paid=remit.total_paid, + status_code=remit.status_code, + ) + + if intent.skipped or intent.new_state is None: + raise ValueError( + f"manual_match refused: {intent.reason or 'no state change'}" + ) + + new_state = intent.new_state + now = utcnow() + + s.add(Match( + claim_id=claim_id, + remittance_id=remit_id, + strategy="manual", + matched_at=now, + prior_claim_state=prior_state, + is_reversal=remit.is_reversal, + )) + claim.state = new_state + claim.matched_remittance_id = remit_id + # Symmetric FK update — see list_unmatched docstring for why + # we don't rely on T10 to do this. + remit.claim_id = claim_id + + s.add(ActivityEvent( + ts=now, + kind="manual_match", + batch_id=remit.batch_id, + claim_id=claim_id, + remittance_id=remit_id, + payload_json={ + "strategy": "manual", + "new_state": new_state.value, + "prior_state": prior_state.value, + "is_reversal": remit.is_reversal, + }, + )) + + s.commit() + + parsed_at = ( + claim.batch.parsed_at + if claim.batch is not None + else now + ) + claim_dict = to_ui_claim_from_orm( + claim, batch_id=claim.batch_id, parsed_at=parsed_at, + ) + matched_at_iso = now.isoformat().replace("+00:00", "Z") + return { + "claim": claim_dict, + "match": { + "strategy": "manual", + "claimId": claim_id, + "remittanceId": remit_id, + "matchedAt": matched_at_iso, + "isReversal": remit.is_reversal, + "priorState": prior_state.value, + "newState": new_state.value, + }, + } + + def manual_unmatch(self, claim_id: str) -> dict: + """Unpair a previously matched claim and restore its prior state. + + Reverses ``manual_match`` (and auto-match as a side-effect of + clearing the FK). Strategy: + + 1. Load the claim; raise ``NotMatchedError`` if it isn't + currently matched. + 2. Delete every ``Match`` row for the claim (there may be more + than one — reversals create a 2nd row, see T10 spec). + 3. Restore ``claim.state`` from the latest Match's + ``prior_claim_state``; fall back to ``SUBMITTED`` when + ``prior_claim_state`` is NULL (auto-match doesn't set it for + non-reversal payments — see reconcile.run line ~278). + 4. Clear ``claim.matched_remittance_id`` and the symmetric + ``remit.claim_id`` so ``list_unmatched`` surfaces the pair + again. + 5. Record ``ActivityEvent(kind="manual_unmatch", ...)`` and + commit. + 6. Return ``{"claim": , "deletedMatches": }``. + """ + with db.SessionLocal()() as s: + claim = s.get(Claim, claim_id) + if claim is None: + raise LookupError(f"claim {claim_id} not found") + if claim.matched_remittance_id is None: + raise NotMatchedError( + f"claim {claim_id} has no active match" + ) + + matches = ( + s.query(Match) + .filter(Match.claim_id == claim_id) + .order_by(Match.matched_at.desc()) + .all() + ) + if not matches: + # Defensive: matched_remittance_id was set but no Match + # rows exist. Shouldn't happen, but if it does, fall back + # to clearing the FK and starting fresh. + latest = None + restored_state = ClaimState.SUBMITTED + else: + latest = matches[0] + restored_state = ( + latest.prior_claim_state + if latest.prior_claim_state is not None + else ClaimState.SUBMITTED + ) + + deleted_count = len(matches) + for m in matches: + s.delete(m) + + claim.state = restored_state + claim.matched_remittance_id = None + # Clear the symmetric FK on the remittance so list_unmatched + # surfaces the pair again. The remittance may have been + # deleted between the match and this call — guard with a + # get() so we don't blow up on a stale FK. + if latest is not None: + paired_remit = s.get(Remittance, latest.remittance_id) + if paired_remit is not None: + paired_remit.claim_id = None + + now = utcnow() + s.add(ActivityEvent( + ts=now, + kind="manual_unmatch", + claim_id=claim_id, + payload_json={ + "restored_state": restored_state.value, + "deleted_matches": deleted_count, + }, + )) + + s.commit() + + parsed_at = ( + claim.batch.parsed_at + if claim.batch is not None + else now + ) + claim_dict = to_ui_claim_from_orm( + claim, batch_id=claim.batch_id, parsed_at=parsed_at, + ) + return { + "claim": claim_dict, + "deletedMatches": deleted_count, + } # Module-level singleton — same import path the old InMemoryStore used. diff --git a/backend/tests/test_store_reconcile.py b/backend/tests/test_store_reconcile.py index a35434f..f3ce1bb 100644 --- a/backend/tests/test_store_reconcile.py +++ b/backend/tests/test_store_reconcile.py @@ -30,9 +30,16 @@ def _setup(tmp_path, monkeypatch): def _make_remit_with_cas(remit_id="CLP-1", status="1", charge="124.00", paid="62.00", - cas_amount="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=remit_id, + 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, @@ -96,4 +103,161 @@ def test_add_835_aggregates_cas_into_adjustment_amount(): 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" \ No newline at end of file + 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") \ No newline at end of file