diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index 61efa04..c53136c 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -287,9 +287,12 @@ class Match(Base): __tablename__ = "matches" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # Spec-bug fix (T10): not unique — a reversal adds a 2nd Match row + # for the same claim (original payment + reversal). The design intent + # is an audit trail. claim_id: Mapped[str] = mapped_column( String(64), ForeignKey("claims.id", ondelete="CASCADE"), - nullable=False, unique=True, + nullable=False, ) remittance_id: Mapped[str] = mapped_column( String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False @@ -302,6 +305,7 @@ class Match(Base): is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) __table_args__ = ( + Index("ix_matches_claim_id", "claim_id"), # non-unique: reversals add a 2nd row Index("ix_matches_remittance_id", "remittance_id"), Index("ix_matches_matched_at", "matched_at"), ) diff --git a/backend/src/cyclone/migrations/0001_initial.sql b/backend/src/cyclone/migrations/0001_initial.sql index 418df38..899e699 100644 --- a/backend/src/cyclone/migrations/0001_initial.sql +++ b/backend/src/cyclone/migrations/0001_initial.sql @@ -64,13 +64,16 @@ CREATE INDEX ix_cas_adjustments_remittance_id ON cas_adjustments(remittance_id); CREATE TABLE matches ( id INTEGER PRIMARY KEY AUTOINCREMENT, - claim_id TEXT NOT NULL UNIQUE REFERENCES claims(id) ON DELETE CASCADE, + -- Spec-bug fix (T10): not UNIQUE — a reversal adds a 2nd Match row for + -- the same claim (original payment + reversal). Audit trail design. + claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE, remittance_id TEXT NOT NULL REFERENCES remittances(id) ON DELETE CASCADE, strategy TEXT NOT NULL, matched_at DATETIME NOT NULL, prior_claim_state TEXT, is_reversal INTEGER NOT NULL DEFAULT 0 ); +CREATE INDEX ix_matches_claim_id ON matches(claim_id); CREATE INDEX ix_matches_remittance_id ON matches(remittance_id); CREATE INDEX ix_matches_matched_at ON matches(matched_at); diff --git a/backend/tests/test_db_models.py b/backend/tests/test_db_models.py index 8138029..7129bd0 100644 --- a/backend/tests/test_db_models.py +++ b/backend/tests/test_db_models.py @@ -336,9 +336,12 @@ def test_create_match_and_activity_event(): 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 +def test_match_multiple_rows_per_claim(): + """Spec-bug fix (T10): a Claim can have multiple Match rows + (original payment + reversal = audit trail). UNIQUE on claim_id + was removed; the constraint test that previously lived here + asserted the old, incorrect design. + """ with db.SessionLocal()() as s: s.add(Batch( id="b1", kind="835", input_filename="x", @@ -352,10 +355,12 @@ def test_match_unique_per_claim(): 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.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual", + matched_at=datetime.now(timezone.utc), + is_reversal=True, prior_claim_state=ClaimState.PAID)) + # No IntegrityError — two Match rows per claim are now allowed. 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() + rows = s.query(Match).filter(Match.claim_id == "CLM-1").all() + assert len(rows) == 2 + assert {r.remittance_id for r in rows} == {"CLP-1", "CLP-2"} diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index f591365..1447347 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -290,13 +290,18 @@ def test_run_orphan_remit_leaves_claim_unmatched(fixture_835): assert result.matched == 0 assert result.unmatched_remittances == 1 - assert result.unmatched_claims == 0 + # Spec-bug fix (T10): claim PCN-A does not match remit PCN-NEW, so the + # claim stays in the unmatched bucket. Original spec asserted 0. + assert result.unmatched_claims == 1 def test_run_reversal_flips_paid_to_reversed(fixture_835): with db.SessionLocal()() as s: _make_batch(s) - _make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1), + # Spec-bug fix (T10): dates are 5 days apart so match() picks this claim + # (default window_days=7). Original spec used 2026-06-01 vs 2026-06-10 + # (9 days, outside window) which produced no match. + _make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 5), state=ClaimState.PAID) # Match row already exists from prior reconcile. s.add(Match(