Import dedup transparency + override; bulk transaction actions

Paste-import now returns each skipped duplicate WITH the matched
existing row (id, merchant, date, amount, source) instead of a bare
count, and accepts allow_duplicates to force-import false positives —
the practical fix for same-day identical purchases hashing together
(UX-20; supersedes the BE-08 hash change, which would have orphaned
every existing hash). POST /transactions/bulk applies
delete/set_category/set_deferred to up to 500 ids atomically. 82 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-10 20:46:23 -06:00
parent 5612f69f11
commit ce3cc69846
3 changed files with 215 additions and 8 deletions

View File

@@ -26,12 +26,29 @@ class PasteImportRequest(BaseModel):
text: str = Field(max_length=1_000_000)
bank: Bank = Bank.BAC
source: TransactionSource = TransactionSource.CREDIT_CARD
# Override for false-positive dedup (legitimate same-day, same-amount
# repeat purchases hash identically) — review UX-20/BE-08.
allow_duplicates: bool = False
class SkippedDuplicate(BaseModel):
line: int
merchant: str
date: str
amount: float
currency: str
existing_id: int
existing_merchant: str
existing_date: str
existing_amount: float
existing_source: str
class PasteImportResult(BaseModel):
imported: int
duplicates: int
errors: list[str]
skipped: list[SkippedDuplicate] = []
def make_reference_hash(date: datetime, merchant: str, amount: float, currency: str) -> str:
@@ -103,6 +120,7 @@ def paste_import(
imported = 0
duplicates = 0
errors: list[str] = []
skipped: list[SkippedDuplicate] = []
lines = req.text.strip().splitlines()
@@ -120,13 +138,29 @@ def paste_import(
parsed["date"], parsed["merchant"], parsed["amount"], parsed["currency"]
)
# Check for duplicates
existing = session.exec(
select(Transaction).where(Transaction.reference == ref_hash)
).first()
if existing:
duplicates += 1
continue
# Check for duplicates — and SHOW the user what matched instead of
# silently dropping the line (UX-20).
if not req.allow_duplicates:
existing = session.exec(
select(Transaction).where(Transaction.reference == ref_hash)
).first()
if existing:
duplicates += 1
skipped.append(
SkippedDuplicate(
line=i,
merchant=parsed["merchant"],
date=parsed["date"].isoformat(),
amount=parsed["amount"],
currency=parsed["currency"],
existing_id=existing.id or 0,
existing_merchant=existing.merchant,
existing_date=existing.date.isoformat(),
existing_amount=float(existing.amount),
existing_source=existing.source.value,
)
)
continue
tx = Transaction(
amount=parsed["amount"],
@@ -147,4 +181,6 @@ def paste_import(
if imported > 0:
session.commit()
return PasteImportResult(imported=imported, duplicates=duplicates, errors=errors)
return PasteImportResult(
imported=imported, duplicates=duplicates, errors=errors, skipped=skipped
)