From 6a65c2b7503817bb16177f2bdd9d1ab9176d0f7d Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 22:59:20 -0600 Subject: [PATCH] =?UTF-8?q?fix(backend):=20spec-bug=20fixes=20for=20T10=20?= =?UTF-8?q?=E2=80=94=20Match.claim=5Fid=20non-unique=20+=20reconcile=20tes?= =?UTF-8?q?t=20dates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three spec-bug fixes from T10 implementation: - Match.claim_id: drop UNIQUE constraint (T4 schema error) — reversals add a 2nd row per claim (audit trail). Add explicit non-unique index ix_matches_claim_id to preserve query performance. Mirrored in ORM and migration 0001_initial.sql. - test_run_orphan_remit_leaves_claim_unmatched: claim PCN-A does not match remit PCN-NEW, so unmatched_claims must be 1, not 0. - test_run_reversal_flips_paid_to_reversed: claim service_date_from was 9 days before reversal remit service_date (outside default 7-day window) so no match occurred; changed to 5 days apart so match() picks the claim. - test_match_unique_per_claim: in test_db_models.py asserted the now-removed UNIQUE behavior; renamed and inverted to assert the audit-trail design (two Match rows per claim allowed). --- backend/src/cyclone/db.py | 6 +++++- .../src/cyclone/migrations/0001_initial.sql | 5 ++++- backend/tests/test_db_models.py | 21 ++++++++++++------- backend/tests/test_reconcile.py | 9 ++++++-- 4 files changed, 29 insertions(+), 12 deletions(-) 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(