feat(backend): persist CasAdjustment rows from 835 SVC.adjustments

This commit is contained in:
Tyler
2026-06-19 23:07:48 -06:00
parent 6a65c2b750
commit cf2e52efb8
2 changed files with 136 additions and 1 deletions
+37 -1
View File
@@ -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.""" """Build a Remittance ORM row from a ClaimPayment. NOT yet persisted."""
received_at = utcnow() received_at = utcnow()
# Adjustment amount: sum the CAS rows for the first service line. # 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") adjustment = Decimal("0")
if cp.service_payments: if cp.service_payments:
sp = cp.service_payments[0] 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. # UI mappers: ORM rows → simpler UI types.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -471,7 +498,16 @@ class CycloneStore:
cp.payer_claim_control_number, record.id, cp.payer_claim_control_number, record.id,
) )
continue 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( s.add(ActivityEvent(
ts=record.parsed_at, ts=record.parsed_at,
kind="remit_received", kind="remit_received",
+99
View File
@@ -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"