From cf2e52efb8eaa48a0b391b3cfc80abc8ba1452ce Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 23:07:48 -0600 Subject: [PATCH] feat(backend): persist CasAdjustment rows from 835 SVC.adjustments --- backend/src/cyclone/store.py | 38 +++++++++- backend/tests/test_store_reconcile.py | 99 +++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_store_reconcile.py diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index 7592ff4..eda88ab 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -167,6 +167,12 @@ def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> Remittance: """Build a Remittance ORM row from a ClaimPayment. NOT yet persisted.""" received_at = utcnow() # Adjustment amount: sum the CAS rows for the first service line. + # NOTE: This is a best-effort placeholder used until the reconciliation + # pass (T10) overwrites it from the persisted CasAdjustment rows. The + # authoritative value comes from `reconcile.run()`, which sums + # ``CasAdjustment.amount`` per ``remittance_id`` and writes the result + # back to ``Remittance.adjustment_amount``. We keep this stub so the + # row has a sane value if reconciliation is disabled or fails. adjustment = Decimal("0") if cp.service_payments: sp = cp.service_payments[0] @@ -194,6 +200,27 @@ def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> Remittance: ) +def _cas_adjustment_row(adj, remittance_id: str) -> "db.CasAdjustment": + """Build a CasAdjustment ORM row from a ClaimAdjustment. NOT yet persisted. + + One row per SVC-level CAS adjustment is persisted so the T10 + reconcile aggregator can compute ``Remittance.adjustment_amount`` + as ``SUM(CasAdjustment.amount) WHERE remittance_id = ...``. + + ``quantity`` is optional in the X12 CAS spec; we coerce to Decimal + only when present to keep the column NULL for the common no-QTY case. + """ + from cyclone.db import CasAdjustment + quantity = getattr(adj, "quantity", None) + return CasAdjustment( + remittance_id=remittance_id, + group_code=adj.group_code, + reason_code=adj.reason_code, + amount=Decimal(str(adj.amount)), + quantity=Decimal(str(quantity)) if quantity is not None else None, + ) + + # --------------------------------------------------------------------------- # UI mappers: ORM rows → simpler UI types. # --------------------------------------------------------------------------- @@ -471,7 +498,16 @@ class CycloneStore: cp.payer_claim_control_number, record.id, ) continue - s.add(_remittance_835_row(cp, record.id)) + remit_row = _remittance_835_row(cp, record.id) + s.add(remit_row) + # Flush so remit_row.id (FK target of cas_adjustments) is + # populated. SQLAlchemy assigns the PK on flush; without + # this the CasAdjustment inserts below would reference an + # unset id and violate the FK. + s.flush() + for svc in cp.service_payments: + for adj in svc.adjustments: + s.add(_cas_adjustment_row(adj, remit_row.id)) s.add(ActivityEvent( ts=record.parsed_at, kind="remit_received", diff --git a/backend/tests/test_store_reconcile.py b/backend/tests/test_store_reconcile.py new file mode 100644 index 0000000..a35434f --- /dev/null +++ b/backend/tests/test_store_reconcile.py @@ -0,0 +1,99 @@ +"""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"): + cp = ClaimPayment( + payer_claim_control_number=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" \ No newline at end of file