From 5be6299b3a96e105a1bed5125fcf62789114c5ad Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 21:36:02 -0600 Subject: [PATCH] feat(backend): add Batch + Claim ORM models + ClaimState enum --- backend/src/cyclone/db.py | 131 +++++++++++++++++++++++++++++++- backend/tests/test_db_models.py | 80 +++++++++++++++++++ 2 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_db_models.py diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index 61b467f..a64f9c2 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -9,11 +9,27 @@ re-uses the same engine. from __future__ import annotations +import enum import os +from datetime import date, datetime +from decimal import Decimal from pathlib import Path +from typing import Optional import sqlalchemy as sa -from sqlalchemy.orm import DeclarativeBase, sessionmaker +from sqlalchemy import ( + Date, + DateTime, + Enum, + ForeignKey, + Index, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker +from sqlalchemy.types import TypeDecorator DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "cyclone" / "cyclone.db" @@ -63,8 +79,11 @@ def init_db() -> None: # Import here to avoid circular imports with models. from cyclone import db_migrate # noqa: WPS433 (deliberate late import) - Base.metadata.create_all(_engine) + # Run migrations first so the schema is the source of truth. + # `create_all` below is a safety net for tests that may not run + # the migration runner; with the migration applied, it is a no-op. db_migrate.run(_engine) + Base.metadata.create_all(_engine) _SessionLocal = sessionmaker( bind=_engine, @@ -95,3 +114,111 @@ def SessionLocal() -> sessionmaker[sa.orm.Session]: if _SessionLocal is None: raise RuntimeError("db.init_db() has not been called") return _SessionLocal + + +class ClaimState(str, enum.Enum): + SUBMITTED = "submitted" + RECEIVED = "received" + PAID = "paid" + PARTIAL = "partial" + DENIED = "denied" + RECONCILED = "reconciled" + REVERSED = "reversed" + + +class JSONText(TypeDecorator): + """Stores a dict as a JSON string in a TEXT column. + + Matches the migration's `totals_json TEXT`, `validation_json TEXT`, + `raw_result_json TEXT`, `raw_json TEXT` columns. The application + code works with dicts; the database stores JSON strings. + """ + + impl = Text + cache_ok = True + + def process_bind_param(self, value, dialect): # type: ignore[no-untyped-def] + if value is None: + return None + import json + + return json.dumps(value) + + def process_result_value(self, value, dialect): # type: ignore[no-untyped-def] + if value is None: + return None + import json + + return json.loads(value) + + +class Batch(Base): + __tablename__ = "batches" + + id: Mapped[str] = mapped_column(String(32), primary_key=True) + kind: Mapped[str] = mapped_column(String(8), nullable=False) + input_filename: Mapped[str] = mapped_column(String(512), nullable=False) + parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + totals_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True) + validation_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True) + raw_result_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True) + + claims: Mapped[list["Claim"]] = relationship( + back_populates="batch", cascade="all, delete-orphan" + ) + remittances: Mapped[list["Remittance"]] = relationship( + back_populates="batch", cascade="all, delete-orphan" + ) + + __table_args__ = ( + Index("ix_batches_parsed_at", "parsed_at"), + ) + + +class Claim(Base): + __tablename__ = "claims" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + batch_id: Mapped[str] = mapped_column( + String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False + ) + patient_control_number: Mapped[str] = mapped_column(String(64), nullable=False) + service_date_from: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + service_date_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + charge_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0")) + provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + payer_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + state: Mapped[ClaimState] = mapped_column( + Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED + ) + state_before_reversal: Mapped[Optional[ClaimState]] = mapped_column( + Enum(ClaimState, native_enum=False), nullable=True + ) + matched_remittance_id: Mapped[Optional[str]] = mapped_column( + String(64), ForeignKey("remittances.id"), nullable=True + ) + raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True) + + batch: Mapped["Batch"] = relationship(back_populates="claims") + + __table_args__ = ( + UniqueConstraint("batch_id", "patient_control_number", name="uq_claims_batch_pcn"), + Index("ix_claims_state", "state"), + Index("ix_claims_patient_control_number", "patient_control_number"), + Index("ix_claims_service_date_from", "service_date_from"), + ) + + +# 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" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + batch_id: Mapped[str] = mapped_column( + String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False + ) + batch: Mapped["Batch"] = relationship(back_populates="remittances") diff --git a/backend/tests/test_db_models.py b/backend/tests/test_db_models.py new file mode 100644 index 0000000..c1685d3 --- /dev/null +++ b/backend/tests/test_db_models.py @@ -0,0 +1,80 @@ +"""Tests for ORM model definitions and CRUD.""" + +from __future__ import annotations + +import enum +from datetime import date, datetime, timezone +from decimal import Decimal + +import pytest + +from cyclone import db +from cyclone.db import Base, Batch, Claim, ClaimState + + +@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() + + +def test_claim_state_enum_values(): + assert ClaimState.SUBMITTED.value == "submitted" + assert ClaimState.RECEIVED.value == "received" + assert ClaimState.PAID.value == "paid" + assert ClaimState.PARTIAL.value == "partial" + assert ClaimState.DENIED.value == "denied" + assert ClaimState.RECONCILED.value == "reconciled" + assert ClaimState.REVERSED.value == "reversed" + # 7 values total. + assert len(list(ClaimState)) == 7 + + +def test_create_and_query_batch(): + with db.SessionLocal()() as s: + b = Batch( + id="b1", + kind="837p", + input_filename="test.txt", + parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc), + totals_json={"total_claims": 1}, + validation_json={"passed": True, "warnings": [], "errors": []}, + raw_result_json={"_": "stub"}, + ) + s.add(b) + s.commit() + + with db.SessionLocal()() as s: + loaded = s.get(Batch, "b1") + assert loaded is not None + assert loaded.kind == "837p" + assert loaded.totals_json == {"total_claims": 1} + + +def test_create_and_query_claim_with_default_state(): + with db.SessionLocal()() as s: + s.add(Batch( + id="b1", kind="837p", input_filename="x", + parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc), + )) + c = Claim( + id="CLM-1", + batch_id="b1", + patient_control_number="CLM-1", + service_date_from=date(2026, 6, 1), + service_date_to=date(2026, 6, 1), + charge_amount=Decimal("124.00"), + provider_npi="1234567890", + payer_id="SKCO0", + state=ClaimState.SUBMITTED, + ) + s.add(c) + s.commit() + + with db.SessionLocal()() as s: + loaded = s.get(Claim, "CLM-1") + assert loaded is not None + assert loaded.state == ClaimState.SUBMITTED + assert loaded.charge_amount == Decimal("124.00") + assert loaded.service_date_from == date(2026, 6, 1)