diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index 59ba3b4..2596aa7 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -20,11 +20,13 @@ from typing import Optional import sqlalchemy as sa from sqlalchemy import ( + Boolean, Date, DateTime, Enum, ForeignKey, Index, + Integer, Numeric, String, Text, @@ -223,11 +225,6 @@ class Claim(Base): ) -# Minimal stub for forward relationship/FK resolution. The full Remittance -# model (with payer_claim_control_number, status_code, total_charge, etc.) -# is defined in Task 5 and will replace this stub. The stub declares only -# the columns needed for `Batch.remittances` relationship resolution and -# `Claim.matched_remittance_id` FK metadata resolution. class Remittance(Base): __tablename__ = "remittances" @@ -235,4 +232,48 @@ class Remittance(Base): batch_id: Mapped[str] = mapped_column( String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False ) + payer_claim_control_number: Mapped[str] = mapped_column(String(64), nullable=False) + claim_id: Mapped[Optional[str]] = mapped_column( + String(64), ForeignKey("claims.id"), nullable=True + ) + status_code: Mapped[str] = mapped_column(String(4), nullable=False) + status_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + total_charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0")) + total_paid: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0")) + patient_responsibility: Mapped[Optional[Decimal]] = mapped_column(Numeric(12, 2), nullable=True) + adjustment_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0")) + received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + service_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True) + batch: Mapped["Batch"] = relationship(back_populates="remittances") + cas_adjustments: Mapped[list["CasAdjustment"]] = relationship( + back_populates="remittance", cascade="all, delete-orphan" + ) + + __table_args__ = ( + UniqueConstraint("batch_id", "payer_claim_control_number", name="uq_remits_batch_pcn"), + Index("ix_remittances_claim_id", "claim_id"), + Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"), + Index("ix_remittances_status_code", "status_code"), + ) + + +class CasAdjustment(Base): + __tablename__ = "cas_adjustments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + remittance_id: Mapped[str] = mapped_column( + String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False + ) + group_code: Mapped[str] = mapped_column(String(4), nullable=False) + reason_code: Mapped[str] = mapped_column(String(8), nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + quantity: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True) + + remittance: Mapped["Remittance"] = relationship(back_populates="cas_adjustments") + + __table_args__ = ( + Index("ix_cas_adjustments_remittance_id", "remittance_id"), + ) diff --git a/backend/tests/test_db_models.py b/backend/tests/test_db_models.py index cd409d9..8de4975 100644 --- a/backend/tests/test_db_models.py +++ b/backend/tests/test_db_models.py @@ -9,7 +9,7 @@ from decimal import Decimal import pytest from cyclone import db -from cyclone.db import Base, Batch, Claim, ClaimState +from cyclone.db import Base, Batch, CasAdjustment, Claim, ClaimState, Remittance @pytest.fixture(autouse=True) @@ -155,3 +155,64 @@ def test_batch_cascade_delete_drops_claims(): with db.SessionLocal()() as s: assert s.get(Batch, "b1") is None assert s.get(Claim, "CLM-1") is None + + +def test_create_and_query_remittance(): + with db.SessionLocal()() as s: + s.add(Batch( + id="b1", kind="835", input_filename="x", + parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc), + )) + r = Remittance( + id="CLP-1", + batch_id="b1", + payer_claim_control_number="CLP-1", + status_code="1", + total_charge=Decimal("124.00"), + total_paid=Decimal("124.00"), + received_at=datetime(2026, 6, 19, tzinfo=timezone.utc), + service_date=date(2026, 6, 1), + is_reversal=False, + ) + s.add(r) + s.commit() + + with db.SessionLocal()() as s: + loaded = s.get(Remittance, "CLP-1") + assert loaded is not None + assert loaded.status_code == "1" + assert loaded.total_paid == Decimal("124.00") + assert loaded.is_reversal is False + assert loaded.adjustment_amount == Decimal("0") # default + + +def test_cas_adjustment_aggregation(): + """Sum of CAS rows for a Remittance equals expected total.""" + with db.SessionLocal()() as s: + s.add(Batch( + id="b1", kind="835", input_filename="x", + parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc), + )) + r = Remittance( + id="CLP-1", batch_id="b1", + payer_claim_control_number="CLP-1", status_code="1", + total_charge=Decimal("124.00"), total_paid=Decimal("62.00"), + received_at=datetime(2026, 6, 19, tzinfo=timezone.utc), + ) + s.add(r) + s.flush() + s.add_all([ + CasAdjustment(remittance_id="CLP-1", group_code="CO", reason_code="45", + amount=Decimal("30.00")), + CasAdjustment(remittance_id="CLP-1", group_code="PR", reason_code="1", + amount=Decimal("32.00")), + ]) + s.commit() + + from sqlalchemy import select, func + with db.SessionLocal()() as s: + total = s.execute( + select(func.sum(CasAdjustment.amount)) + .where(CasAdjustment.remittance_id == "CLP-1") + ).scalar_one() + assert total == Decimal("62.00")