fix(transactions): stop Enable Banking re-sync from re-importing duplicates (#630)

* fix(transactions): make content-dedup bridge resilient to PSD2 description drift

Enable Banking re-syncs were re-importing every overlapping transaction as a
duplicate. Two changes in the June 1 deploy combined to defeat both dedup layers
at once: the external_id format changed (old rows' stored ids no longer match the
new scheme, so the exact-match layer misses) AND PSD2 descriptions were enriched
("TIC" -> "TIC  BG 0000005786439 Bg-bet. via internet"), so the content-dedup
bridge — which compared a fixed 24-char description prefix for equality — also
missed. Result: a full re-import (observed: 53 of 54 "new" rows were dupes).

The external_id format has changed several times historically and 7,120 of 9,393
old rows have no reconstructable canonical id, so a backfill is not viable; the
content bridge is the mechanism meant to survive id-scheme changes. Harden it:

- Split the bridge into bucketing (date, öre) and matching (description), and
  match by prefix-containment instead of fixed-prefix equality. PSD2 enrichment
  is prefix-preserving, so the enriched re-import bridges its stored original,
  while genuinely-distinct same-(date,amount) rows (distinct descriptions) are
  kept apart. Consumed with counting semantics + longest-match, so N stored twins
  dedup exactly N incoming.
- Replace contentDedupKey with contentBucketKey + descriptionsBridge; update the
  live pipeline (ingest.ts) and the v1 dry-run preview to the same logic.
- Freeze the external_id format with a regression test + a header warning: any
  future format change must ship a coordinated backfill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test/refactor: address PR review — sanitize fixtures, mirror preview counting

- Replace real customer names ("Carl Bennet AB", "Brorsan AB") and prod-derived
  reference strings in tests with clearly fictional stand-ins (compliance A.8.33).
- v1 dry-run preview: use the same longest-match + counting/consume semantics as
  the live pipeline so a batch of N copies against M booked twins previews M skips,
  not N (greptile P2). Update stale pitfall docs: content dedup is now
  date+amount+description (prefix-containment), and the preview is booked-only so
  its skip count is a lower bound on the live skip count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(transactions): scope content-dedup bridge by cash account

The content-dedup bridge buckets by (date, öre) company-wide, with no account
scope — while bank reconciliation IS account-scoped (cash_account_id). For a
company with multiple bank accounts, a transaction on account A could therefore
deduplicate a genuinely-different transaction on account B that shares the same
date, amount, and a prefix-bridging description (round-number fees/transfers are
the realistic trigger), dropping a real row before it reaches reconciliation.

Layer 1 (external_id) is already account-safe because the account IBAN is
embedded in the id; only the fuzzy content bridge was account-blind.

Add an account guard: store cash_account_id alongside each bucket entry and only
bridge when BOTH the incoming batch and the stored entry have a known
cash_account_id that matches. A null on either side falls back to bridge-allowed,
so single-account companies and legacy (un-backfilled) rows are unchanged — and
CSV-vs-PSD2 dedup for the same account still works. Affects the 11 multi-account
companies; everyone else is behaviourally identical.

Note: external_id format heterogeneity (old entry_reference / old date+amount /
new öre+index schemes) is harmless downstream — nothing parses the id; it is an
opaque exact-match dedup key and a display string. Reconciliation, invoice/
supplier/payment matching, and reporting all key off real transaction columns,
never external_id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(transactions): blank description never wildcards a described row

PR review (OWASP V8.2.1 + Swedish compliance) flagged that descriptionsBridge
returned true whenever either side was empty, so a blank stored/incoming title
could wildcard-match any same-(date,öre) transaction and silently consume a real
one. Every live caller normalizes blanks to FALLBACK_DESCRIPTION upstream, so the
branch was unreachable in production — but make the function safe in isolation:
a blank now bridges only another blank (date+öre identity), never a described row.
No live behaviour change; removes the footgun.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-02 15:59:10 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ff01640f60
commit 331ae11867
5 changed files with 428 additions and 95 deletions
@@ -22,7 +22,7 @@ import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { ingestTransactions } from '@/lib/transactions/ingest'
import { contentDedupKey } from '@/lib/transactions/external-id'
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription } from '@/lib/transactions/external-id'
import type { RawTransaction } from '@/types'
const RawTx = z.object({
@@ -70,10 +70,10 @@ registerEndpoint({
'Single ad-hoc transactions (use the dashboard). Documents/receipts (use the documents endpoint). Manually-created journal entries (Phase 4).',
pitfalls: [
'external_id is the primary dedup key — make it stable for the same physical transaction across reruns.',
'Content-based dedup (date+amount) runs in addition: a CSV row that matches an already-booked transaction by date+amount is skipped even if external_id differs.',
'Content-based dedup runs in addition: a row matching an already-booked transaction by date, amount AND description (prefix-containment, to survive PSD2 title enrichment) is skipped even if external_id differs.',
'raw_insert_only=true skips ALL post-insert pipeline steps (matching, categorization). Use for viewer-only imports.',
'Max 500 items per call. For larger imports, split into pages of 500.',
'Dry-run runs both dedup checks (external_id AND content-based date+amount against booked rows), matching the live pipeline. Numbers should agree barring concurrent imports between preview and commit.',
'Dry-run previews external_id + content dedup against BOOKED rows only; the live pipeline also dedups against unbooked bank-synced rows, so preview skips are a lower bound on the live skip count.',
],
example: {
request: {
@@ -153,25 +153,52 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
const { data: bookedInRange } = await ctx.supabase
.from('transactions')
.select('date, amount, description')
.select('date, amount, original_description, description')
.eq('company_id', ctx.companyId!)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
.lte('date', dateTo)
// Build the content-dedup key with the SAME helper the live pipeline uses
// (lib/transactions/ingest.ts), so the preview's content-match decision
// matches the eventual ingest exactly: öre-normalized amount (handles a
// PostgREST numeric returned as a string) plus the description prefix.
const bookedKeys = new Set(
(bookedInRange ?? []).map((r) => {
const row = r as { date: string; amount: number | string; description: string | null }
return contentDedupKey(row.date, row.amount, row.description)
}),
)
// Mirror the live pipeline's content-dedup bridge (lib/transactions/ingest.ts):
// bucket booked rows by (date, öre) and match by description prefix-containment
// (keyed off the immutable original_description), consumed with the SAME
// longest-match + counting semantics — so a batch of N copies against M booked
// twins previews M skips and N−M imports, not N. Scope note: like the live
// pipeline's booked map this previews ONLY booked rows; the unbooked
// enable_banking overlap check is not modelled here.
const bookedBuckets = new Map<string, string[]>()
for (const r of bookedInRange ?? []) {
const row = r as { date: string; amount: number | string; original_description: string | null; description: string | null }
const key = contentBucketKey(row.date, row.amount)
const desc = normalizeImportedDescription(row.original_description ?? row.description).toLowerCase().trim()
const bucket = bookedBuckets.get(key)
if (bucket) bucket.push(desc)
else bookedBuckets.set(key, [desc])
}
// Consume the longest bridging stored description (counting semantics) — the
// same logic as ingest.ts consumeBridgingTwin, on this preview's mutable copy.
const consumeBookedTwin = (date: string, amount: number, desc: string): boolean => {
const descs = bookedBuckets.get(contentBucketKey(date, amount))
if (!descs || descs.length === 0) return false
let bestIdx = -1
let bestLen = -1
for (let i = 0; i < descs.length; i++) {
if (descriptionsBridge(desc, descs[i]) && descs[i].length > bestLen) {
bestIdx = i
bestLen = descs[i].length
}
}
if (bestIdx === -1) return false
descs.splice(bestIdx, 1)
return true
}
const previewRows = body.transactions.map((tx) => {
const extIdHit = knownExtIds.has(tx.external_id)
const contentHit = bookedKeys.has(contentDedupKey(tx.date, tx.amount, tx.description))
// external_id precedence mirrors the live pipeline: a row caught by
// external_id must NOT consume a booked twin (so it stays available for a
// genuine content-only duplicate later in the batch).
const contentHit =
!extIdHit && consumeBookedTwin(tx.date, tx.amount, normalizeImportedDescription(tx.description))
const wouldSkip = extIdHit || contentHit
const reason = extIdHit
? 'external_id_match'