fix(backend): spec-bug fixes for T10 — Match.claim_id non-unique + reconcile test dates
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).
This commit is contained in:
@@ -287,9 +287,12 @@ class Match(Base):
|
|||||||
__tablename__ = "matches"
|
__tablename__ = "matches"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
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(
|
claim_id: Mapped[str] = mapped_column(
|
||||||
String(64), ForeignKey("claims.id", ondelete="CASCADE"),
|
String(64), ForeignKey("claims.id", ondelete="CASCADE"),
|
||||||
nullable=False, unique=True,
|
nullable=False,
|
||||||
)
|
)
|
||||||
remittance_id: Mapped[str] = mapped_column(
|
remittance_id: Mapped[str] = mapped_column(
|
||||||
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
|
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)
|
is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
__table_args__ = (
|
__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_remittance_id", "remittance_id"),
|
||||||
Index("ix_matches_matched_at", "matched_at"),
|
Index("ix_matches_matched_at", "matched_at"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,13 +64,16 @@ CREATE INDEX ix_cas_adjustments_remittance_id ON cas_adjustments(remittance_id);
|
|||||||
|
|
||||||
CREATE TABLE matches (
|
CREATE TABLE matches (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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,
|
remittance_id TEXT NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,
|
||||||
strategy TEXT NOT NULL,
|
strategy TEXT NOT NULL,
|
||||||
matched_at DATETIME NOT NULL,
|
matched_at DATETIME NOT NULL,
|
||||||
prior_claim_state TEXT,
|
prior_claim_state TEXT,
|
||||||
is_reversal INTEGER NOT NULL DEFAULT 0
|
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_remittance_id ON matches(remittance_id);
|
||||||
CREATE INDEX ix_matches_matched_at ON matches(matched_at);
|
CREATE INDEX ix_matches_matched_at ON matches(matched_at);
|
||||||
|
|
||||||
|
|||||||
@@ -336,9 +336,12 @@ def test_create_match_and_activity_event():
|
|||||||
assert events[0].payload_json == {"matched": 1}
|
assert events[0].payload_json == {"matched": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_match_unique_per_claim():
|
def test_match_multiple_rows_per_claim():
|
||||||
"""A Claim can only have one current Match row."""
|
"""Spec-bug fix (T10): a Claim can have multiple Match rows
|
||||||
import sqlalchemy.exc
|
(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:
|
with db.SessionLocal()() as s:
|
||||||
s.add(Batch(
|
s.add(Batch(
|
||||||
id="b1", kind="835", input_filename="x",
|
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)))
|
status_code="1", received_at=datetime.now(timezone.utc)))
|
||||||
s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto",
|
s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto",
|
||||||
matched_at=datetime.now(timezone.utc)))
|
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.commit()
|
||||||
|
|
||||||
s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual",
|
rows = s.query(Match).filter(Match.claim_id == "CLM-1").all()
|
||||||
matched_at=datetime.now(timezone.utc)))
|
assert len(rows) == 2
|
||||||
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
assert {r.remittance_id for r in rows} == {"CLP-1", "CLP-2"}
|
||||||
s.commit()
|
|
||||||
s.rollback()
|
|
||||||
|
|||||||
@@ -290,13 +290,18 @@ def test_run_orphan_remit_leaves_claim_unmatched(fixture_835):
|
|||||||
|
|
||||||
assert result.matched == 0
|
assert result.matched == 0
|
||||||
assert result.unmatched_remittances == 1
|
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):
|
def test_run_reversal_flips_paid_to_reversed(fixture_835):
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
_make_batch(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)
|
state=ClaimState.PAID)
|
||||||
# Match row already exists from prior reconcile.
|
# Match row already exists from prior reconcile.
|
||||||
s.add(Match(
|
s.add(Match(
|
||||||
|
|||||||
Reference in New Issue
Block a user