From 05a49be9644e858d8fceb323b63fb9e08c7bb371 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 21:53:52 -0600 Subject: [PATCH] feat(backend): add Match + ActivityEvent ORM models --- backend/src/cyclone/db.py | 45 +++++++++++++++++++++- backend/tests/test_db_models.py | 68 ++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index 6085673..61efa04 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -177,8 +177,6 @@ class Batch(Base): # alone (no migration runner) get the same index direction as prod. Index("ix_batches_parsed_at", text("parsed_at DESC")), ) - # TODO(T6): when ActivityEvent model is added, mirror this pattern for - # `ix_activity_events_ts` on activity_events(ts DESC) per 0001_initial.sql:86. class Claim(Base): @@ -283,3 +281,46 @@ class CasAdjustment(Base): __table_args__ = ( Index("ix_cas_adjustments_remittance_id", "remittance_id"), ) + + +class Match(Base): + __tablename__ = "matches" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + claim_id: Mapped[str] = mapped_column( + String(64), ForeignKey("claims.id", ondelete="CASCADE"), + nullable=False, unique=True, + ) + remittance_id: Mapped[str] = mapped_column( + String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False + ) + strategy: Mapped[str] = mapped_column(String(16), nullable=False) + matched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + prior_claim_state: Mapped[Optional[ClaimState]] = mapped_column( + Enum(ClaimState, native_enum=False), nullable=True + ) + is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + __table_args__ = ( + Index("ix_matches_remittance_id", "remittance_id"), + Index("ix_matches_matched_at", "matched_at"), + ) + + +class ActivityEvent(Base): + __tablename__ = "activity_events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + kind: Mapped[str] = mapped_column(String(32), nullable=False) + batch_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + claim_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + remittance_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + payload_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True) + + __table_args__ = ( + # Migration declares this as DESC; mirror the qualifier so create_all + # on a fresh DB (without migrations) produces identical DDL. + Index("ix_activity_events_ts", text("ts DESC")), + Index("ix_activity_events_kind", "kind"), + ) diff --git a/backend/tests/test_db_models.py b/backend/tests/test_db_models.py index 772e03a..8138029 100644 --- a/backend/tests/test_db_models.py +++ b/backend/tests/test_db_models.py @@ -10,7 +10,7 @@ import pytest from sqlalchemy.exc import IntegrityError from cyclone import db -from cyclone.db import Base, Batch, CasAdjustment, Claim, ClaimState, Remittance +from cyclone.db import ActivityEvent, Base, Batch, CasAdjustment, Claim, ClaimState, Match, Remittance @pytest.fixture(autouse=True) @@ -293,3 +293,69 @@ def test_remittance_cascade_delete_drops_cas_adjustments(): select(CasAdjustment).where(CasAdjustment.remittance_id == "CLP-1") ).all() assert len(cas) == 0 + + +def test_create_match_and_activity_event(): + with db.SessionLocal()() as s: + s.add(Batch( + id="b1", kind="835", input_filename="x", + parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc), + )) + s.add(Claim( + id="CLM-1", batch_id="b1", patient_control_number="CLM-1", + charge_amount=Decimal("124.00"), state=ClaimState.SUBMITTED, + )) + s.add(Remittance( + id="CLP-1", batch_id="b1", payer_claim_control_number="CLM-1", + status_code="1", total_charge=Decimal("124.00"), total_paid=Decimal("124.00"), + received_at=datetime(2026, 6, 19, tzinfo=timezone.utc), + )) + m = Match( + claim_id="CLM-1", remittance_id="CLP-1", + strategy="auto", is_reversal=False, + matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc), + ) + s.add(m) + s.flush() + s.add(ActivityEvent( + ts=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc), + kind="reconcile", batch_id="b1", claim_id="CLM-1", + payload_json={"matched": 1}, + )) + s.commit() + + with db.SessionLocal()() as s: + loaded = s.get(Match, 1) # autoincrement id + assert loaded is not None + assert loaded.strategy == "auto" + assert loaded.is_reversal is False + + events = s.query(ActivityEvent).all() + assert len(events) == 1 + assert events[0].kind == "reconcile" + assert events[0].payload_json == {"matched": 1} + + +def test_match_unique_per_claim(): + """A Claim can only have one current Match row.""" + import sqlalchemy.exc + with db.SessionLocal()() as s: + s.add(Batch( + id="b1", kind="835", input_filename="x", + parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc), + )) + s.add(Claim(id="CLM-1", batch_id="b1", patient_control_number="CLM-1", + charge_amount=Decimal("0"), state=ClaimState.SUBMITTED)) + s.add(Remittance(id="CLP-1", batch_id="b1", payer_claim_control_number="x", + status_code="1", received_at=datetime.now(timezone.utc))) + s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y", + status_code="1", received_at=datetime.now(timezone.utc))) + s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto", + matched_at=datetime.now(timezone.utc))) + s.commit() + + s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual", + matched_at=datetime.now(timezone.utc))) + with pytest.raises(sqlalchemy.exc.IntegrityError): + s.commit() + s.rollback()