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'
+54 -21
View File
@@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest'
import {
amountToOre,
buildStableExternalIds,
contentDedupKey,
contentBucketKey,
descriptionsBridge,
normalizeImportedDescription,
FALLBACK_DESCRIPTION,
} from '../external-id'
@@ -87,35 +88,67 @@ describe('buildStableExternalIds', () => {
it('returns an empty array for an empty batch', () => {
expect(buildStableExternalIds('eb', 'acc', [])).toEqual([])
})
// FORMAT-FREEZE guard. `external_id` is a STORED key: changing the template
// string orphans every prior row (its stored id stops matching the new scheme)
// and re-imports the lot on the next sync — the June 2026 fleet-wide incident.
// If you must change the format, you MUST ship a coordinated backfill of
// existing rows; updating this assertion without one is the bug.
it('FORMAT IS FROZEN — changing this template silently orphans every prior external_id', () => {
expect(
buildStableExternalIds('eb', 'SE0000000000000000000000', [
{ date: '2026-04-07', amount: -11231 },
]),
).toEqual(['eb_SE0000000000000000000000_2026-04-07_-1123100_0'])
})
})
describe('contentDedupKey', () => {
describe('contentBucketKey', () => {
it('keys off (date, öre) only — no description', () => {
expect(contentBucketKey('2024-06-15', -250)).toBe('2024-06-15|-25000')
})
it('matches a JS number against a PostgREST numeric string for the same amount', () => {
// The core dedup-bridge fix: an incoming raw number and a DB-fetched string
// for the same amount + date + description must produce the SAME key.
const incoming = contentDedupKey('2024-06-15', -250, 'ICA Maxi Solna')
const stored = contentDedupKey('2024-06-15', '-250.00', 'ICA Maxi Solna')
expect(incoming).toBe(stored)
// A DB-fetched numeric string and a raw JS number for the same amount must
// land in the SAME bucket, otherwise the bridge silently misses.
expect(contentBucketKey('2024-06-15', -250)).toBe(contentBucketKey('2024-06-15', '-250.00'))
})
it('normalizes description (lowercase, trim, 24-char prefix)', () => {
expect(contentDedupKey('2024-06-15', -100, ' ICA Maxi Solna '))
.toBe(contentDedupKey('2024-06-15', -100, 'ica maxi solna'))
// Differs only past the 24-char prefix → same key.
expect(contentDedupKey('2024-06-15', -100, 'Betalning till leverantör AAA'))
.toBe(contentDedupKey('2024-06-15', -100, 'Betalning till leverantör BBB'))
it('separates distinct amounts and dates into distinct buckets', () => {
expect(contentBucketKey('2024-06-15', -250)).not.toBe(contentBucketKey('2024-06-15', -100))
expect(contentBucketKey('2024-06-15', -250)).not.toBe(contentBucketKey('2024-06-16', -250))
})
})
describe('descriptionsBridge', () => {
it('bridges prefix-preserving PSD2 enrichment (the June 2026 drift)', () => {
// The same transaction whose title grew between syncs must still bridge.
// (Synthetic stand-ins for the real prefix-preserving enrichment pattern.)
expect(descriptionsBridge('KAFFE', 'KAFFE BG 0000000000 Bg-bet. via internet')).toBe(true)
expect(descriptionsBridge('UTBETALNING Insättning', 'UTBETALNING')).toBe(true)
expect(descriptionsBridge('REF 000000 Europabetalning', 'REF 000000')).toBe(true)
})
it('keeps distinct transactions apart when description differs in the prefix', () => {
expect(contentDedupKey('2024-06-15', -250, 'ICA Maxi'))
.not.toBe(contentDedupKey('2024-06-15', -250, 'Coop Stockholm'))
it('is case- and whitespace-insensitive', () => {
expect(descriptionsBridge(' Mataffär Solna ', 'mataffär solna')).toBe(true)
})
it('treats a null/undefined description as an empty prefix', () => {
expect(contentDedupKey('2024-06-15', -100, null))
.toBe(contentDedupKey('2024-06-15', -100, undefined))
expect(contentDedupKey('2024-06-15', -100, null))
.toBe(contentDedupKey('2024-06-15', -100, ''))
it('does NOT bridge genuinely distinct descriptions sharing a date+amount', () => {
// Distinct reference codes on same-day same-amount rows (e.g. verification
// micro-deposits) must NOT collapse — each is a real transaction.
expect(descriptionsBridge('REF-AAAA1111', 'REF-BBBB2222')).toBe(false)
// Same common stem, diverging tails — still distinct.
expect(descriptionsBridge('PMT.Ref AAA', 'PMT.Ref BBB')).toBe(false)
expect(descriptionsBridge('Coffee', 'Lunch')).toBe(false)
})
it('does not let a blank description wildcard-match a described row', () => {
// A blank carries no signal: it must not consume a described same-(date,öre)
// row. Live callers normalize blanks to FALLBACK_DESCRIPTION, so this is
// defense-in-depth. Only two blanks bridge each other (date+öre identity).
expect(descriptionsBridge('', 'anything')).toBe(false)
expect(descriptionsBridge('anything', null)).toBe(false)
expect(descriptionsBridge(undefined, '')).toBe(true)
})
})
+180
View File
@@ -390,6 +390,186 @@ describe('ingestTransactions', () => {
expect(result.transaction_ids).toEqual(['tx-no-collision'])
})
// -----------------------------------------------------------------------
// 2d. Description drift: PSD2 enrichment is prefix-preserving, so an
// enriched re-import ("TIC" → "TIC BG … via internet") still bridges
// the stored original via prefix-containment. This is the June 2026
// incident: the external_id ALSO changed, so the bridge is the only net.
// -----------------------------------------------------------------------
it('dedupes an enriched re-import whose description extends the stored original', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2026-04-07',
amount: -11231,
description: 'KAFFE BG 0000000000 Bg-bet. via internet', // enriched
external_id: 'eb_SE00_2026-04-07_-1123100_0', // NEW-scheme id → external_id dedup misses
import_source: 'enable_banking',
})
enqueue({ data: [], error: null }) // booked map — none
// Unbooked enable_banking row carrying the SHORT original description.
enqueue({
data: [{ date: '2026-04-07', amount: -11231, original_description: 'KAFFE', description: 'KAFFE' }],
error: null,
})
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup — different scheme, no match
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
// -----------------------------------------------------------------------
// 2e. Order-independence: a genuinely-new row whose description does NOT
// bridge an existing same-(date,amount) row is kept, and the re-import
// that DOES bridge is deduped — regardless of provider ordering.
// -----------------------------------------------------------------------
it.each([
['new-first', ['Lunch', 'Coffee']],
['dup-first', ['Coffee', 'Lunch']],
])('keeps the distinct row and dedupes the bridging twin (%s)', async (_label, order) => {
const { supabase, enqueue } = createQueueMockSupabase()
const rows = order.map((desc, i) =>
makeRaw({
date: '2026-04-07',
amount: -250,
description: desc,
external_id: `csv_${desc}_${i}`,
import_source: 'csv_lunar',
}),
)
const insertedDesc = 'Lunch' // the non-bridging "Lunch" is always the row that gets inserted
enqueue({ data: [], error: null }) // booked map — none
// One unbooked enable_banking row "Coffee" — only the incoming "Coffee" bridges it.
enqueue({
data: [{ date: '2026-04-07', amount: -250, original_description: 'Coffee', description: 'Coffee' }],
error: null,
})
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup — no match
enqueue({
data: makeTransaction({ id: 'tx-lunch', description: insertedDesc, amount: -250 }),
error: null,
}) // insert for the non-bridging "Lunch"
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, rows)
expect(result.imported).toBe(1) // "Lunch" kept
expect(result.duplicates).toBe(1) // "Coffee" deduped
expect(result.transaction_ids).toEqual(['tx-lunch'])
})
// -----------------------------------------------------------------------
// 2f. Counting semantics: N stored twins dedup exactly N incoming bridging
// rows; the surplus is inserted (never silently collapsed).
// -----------------------------------------------------------------------
it('dedupes exactly as many incoming rows as there are stored twins (counting)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const rows = [1, 2, 3].map((n) =>
makeRaw({
date: '2026-04-07',
amount: -100,
description: `ICA Kortköp ${n}`,
external_id: `csv_ica_${n}`,
import_source: 'csv_lunar',
}),
)
enqueue({ data: [], error: null }) // booked map — none
// Two stored unbooked "ICA" twins → only two of the three incoming dedup.
enqueue({
data: [
{ date: '2026-04-07', amount: -100, original_description: 'ICA', description: 'ICA' },
{ date: '2026-04-07', amount: -100, original_description: 'ICA', description: 'ICA' },
],
error: null,
})
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup — no match
enqueue({
data: makeTransaction({ id: 'tx-ica-surplus', description: 'ICA Kortköp 3', amount: -100 }),
error: null,
}) // insert for the surplus third row
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, rows)
expect(result.duplicates).toBe(2)
expect(result.imported).toBe(1)
expect(result.transaction_ids).toEqual(['tx-ica-surplus'])
})
// -----------------------------------------------------------------------
// 2g. Cross-account guard: a transaction on one bank account must NOT
// deduplicate a genuinely-different one on ANOTHER account of the same
// company. The content bucket is company-wide (only external_id embeds
// the account), so the bridge also requires matching cash_account_id when
// both sides know it.
// -----------------------------------------------------------------------
it('does not dedupe a bridging twin that settled on a different cash account', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2026-04-07',
amount: -250,
description: 'Avgift',
external_id: 'eb_acctB_2026-04-07_-25000_0',
import_source: 'enable_banking',
})
const inserted = makeTransaction({ id: 'tx-acctB', amount: -250 })
enqueue({ data: [], error: null }) // booked map — none
// Unbooked enable_banking twin, but it settled on a DIFFERENT account (A).
enqueue({
data: [{ date: '2026-04-07', amount: -250, original_description: 'Avgift', description: 'Avgift', cash_account_id: 'acct-A' }],
error: null,
})
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup — no match
enqueue({ data: { id: 'acct-B' }, error: null }) // cash_accounts lookup → batch settled on account B
enqueue({ data: inserted, error: null }) // insert — not a duplicate
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
settlementAccount: '1931',
})
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
expect(result.transaction_ids).toEqual(['tx-acctB'])
})
it('dedupes a bridging twin on the SAME cash account', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2026-04-07',
amount: -250,
description: 'Avgift',
external_id: 'eb_acctA_2026-04-07_-25000_99', // different id → external_id dedup misses
import_source: 'enable_banking',
})
enqueue({ data: [], error: null }) // booked map — none
enqueue({
data: [{ date: '2026-04-07', amount: -250, original_description: 'Avgift', description: 'Avgift', cash_account_id: 'acct-A' }],
error: null,
})
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup — no match
enqueue({ data: { id: 'acct-A' }, error: null }) // cash_accounts lookup → batch settled on account A (same)
// No insert — deduped.
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
settlementAccount: '1930',
})
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
// -----------------------------------------------------------------------
// 3. Counts errors when insert fails
// -----------------------------------------------------------------------
+57 -30
View File
@@ -65,6 +65,14 @@ export function normalizeImportedDescription(raw: string | null | undefined): st
* — where `n` is an occurrence index that disambiguates genuinely identical
* transactions (same account, date and amount) within the batch.
*
* ⚠️ THE FORMAT STRING IS A STORED KEY. It is persisted to
* `transactions.external_id` and dedup compares incoming ids against the stored
* ones byte-for-byte. Changing this template silently orphans every prior row
* (its stored id no longer matches the new scheme) and re-imports them all on
* the next sync — this is exactly what happened in the June 2026 fleet-wide
* incident. Any format change MUST ship a coordinated backfill of existing rows
* and is locked by a frozen-format test (see `external-id.test.ts`).
*
* Properties this guarantees:
* - **Re-sync dedupe**: the same set of transactions produces the same *set*
* of ids regardless of the order the ASPSP returns them in, so a repeat sync
@@ -77,13 +85,13 @@ export function normalizeImportedDescription(raw: string | null | undefined): st
* This is the safeguard the bank-file importer already relies on via its
* `rowIndex` component (see `lib/import/bank-file/parser.ts`).
*
* Why description is NOT an input here (but IS in `contentDedupKey`): the
* Why description is NOT an input here (but IS in the content bridge): the
* `external_id` must be a *stable unique key*, so it cannot depend on a field
* that drifts — PSD2 enriches/reorders descriptions between a transaction's
* pending and booked states. The occurrence index gives uniqueness without
* that fragility. `contentDedupKey` has the opposite job — it is a best-effort
* *bridge* that must avoid dropping real transactions — so it keeps the
* description (see that function for the trade-off).
* that fragility. The content bridge (`contentBucketKey` + `descriptionsBridge`)
* has the opposite job — it is a best-effort *bridge* that must avoid dropping
* real transactions — so it keeps the description (see those for the trade-off).
*
* @param prefix Source tag, e.g. `'eb'` for Enable Banking.
* @param accountScope Stable per-account scope (prefer IBAN, fall back to the
@@ -108,32 +116,51 @@ export function buildStableExternalIds(
}
/**
* Stable content-dedup key used to bridge transactions across `external_id`
* schemes and import sources (PSD2 ⇄ CSV, old id scheme ⇄ new id scheme).
* Bucket key for the content-dedup bridge: `{date}|{öre}` — deliberately NO
* description. Transactions that share a date and amount fall into the same
* bucket; `descriptionsBridge` then decides, per pair, whether two rows in that
* bucket are the same transaction. Splitting bucketing (date+öre) from matching
* (description) is what lets the bridge survive description drift while still
* keeping genuinely-distinct same-(date,amount) transactions apart.
*
* Format: `{date}|{öre}|{normalized description prefix}`.
*
* This is a *best-effort* dedup signal, not a unique key. It is consumed with
* COUNTING semantics in the ingest pipeline (N existing matches consume N
* incoming), and its job is to skip re-imports WITHOUT ever dropping a real
* transaction. That asymmetry drives the two design choices here:
*
* - **öre via `amountToOre`** — a JS number (`-250`) and a PostgREST numeric
* string (`"-250.00"`) must collapse to the same key, otherwise dedup
* silently misses.
* - **description IS included** (unlike `external_id`) — two genuinely distinct
* transactions that merely share a date and amount (e.g. two SEK 250 card
* purchases) must NOT be collapsed into one, or a real transaction is lost.
* Including the description prefix biases toward keeping both. The cost is
* that if a description drifts between syncs the bridge can miss a true
* duplicate — an acceptable trade for an accounting ledger, where a visible,
* user-deletable duplicate is far safer than a silently dropped row.
* öre via `amountToOre` so a JS number (`-250`) and a PostgREST numeric string
* (`"-250.00"`) collapse to the same bucket, otherwise dedup silently misses.
*/
export function contentDedupKey(
date: string,
amount: number | string,
description: string | null | undefined
): string {
const descPrefix = (description || '').toLowerCase().trim().slice(0, 24)
return `${date}|${amountToOre(amount)}|${descPrefix}`
export function contentBucketKey(date: string, amount: number | string): string {
return `${date}|${amountToOre(amount)}`
}
/**
* Decide whether two normalized descriptions (same date+öre bucket) describe the
* same underlying transaction — the matching half of the content-dedup bridge.
*
* Returns true when either description is a prefix of the other. PSD2 enrichment
* is **prefix-preserving**: the same transaction's title grows between syncs
* ("TIC" → "TIC BG 0000005786439 Bg-bet. via internet", "UTBETALNING" →
* "UTBETALNING Insättning"), so prefix-containment bridges the two where a
* fixed-length prefix *equality* check (the pre-June-2026 scheme) missed and
* re-imported. A blank description carries no signal, so it never bridges a
* *described* row — otherwise an empty title would wildcard-match any
* same-(date,öre) transaction and could silently consume a real one; only two
* blanks bridge each other (date+öre identity). In practice every caller
* normalizes blanks to FALLBACK_DESCRIPTION upstream (see
* normalizeImportedDescription), so the blank path is defense-in-depth.
* Genuinely distinct descriptions ("Coffee" vs "Lunch", or two different
* reference codes that share a date and amount) are NOT prefixes of one another
* and never bridge, so two real same-(date,amount) transactions are kept apart.
*
* This is a *best-effort* signal, consumed with COUNTING semantics in the ingest
* pipeline (N existing matches consume N incoming): its job is to skip re-imports
* WITHOUT ever dropping a real transaction. The asymmetry favours keeping a
* visible, user-deletable duplicate over silently losing a row.
*/
export function descriptionsBridge(
a: string | null | undefined,
b: string | null | undefined
): boolean {
const x = (a ?? '').toLowerCase().trim()
const y = (b ?? '').toLowerCase().trim()
// A blank never wildcards a described row; only two blanks bridge each other.
if (x === '' || y === '') return x === y
return x.startsWith(y) || y.startsWith(x)
}
+95 -29
View File
@@ -7,15 +7,32 @@ import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matchi
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { contentDedupKey, normalizeImportedDescription } from '@/lib/transactions/external-id'
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription } from '@/lib/transactions/external-id'
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
// Re-export types for backward compatibility
export type { RawTransaction, IngestResult } from '@/types'
/**
* One existing row in a content-dedup bucket: its normalized/lowercased
* description plus the cash account it settled on (null for legacy rows that
* predate the cash_account_id backfill). `cashAccountId` is the cross-account
* guard — see `consumeBridgingTwin`.
*/
type BucketEntry = { desc: string; cashAccountId: string | null }
/**
* Content-dedup bucket: a `{date}|{öre}` key mapped to the multiset of existing
* rows in that bucket. Matching is by `descriptionsBridge` (prefix-containment)
* gated by the account guard, consumed with COUNTING semantics — one entry is
* spliced out per deduped incoming row — so two genuinely-distinct
* same-(date,amount) transactions are never collapsed.
*/
type DescBucket = Map<string, BucketEntry[]>
interface ExistingTransactionMaps {
/** Booked transactions (any source) — consumed by any incoming raw transaction. */
booked: Map<string, number>
booked: DescBucket
/**
* Unbooked enable_banking transactions — consumed by any incoming raw
* transaction regardless of source. Catches two cases: PSD2 reconnect
@@ -23,7 +40,22 @@ interface ExistingTransactionMaps {
* CSV imports overlapping an active PSD2 sync (same Lunar/etc tx arriving
* twice, once via PSD2 and once via file upload).
*/
unbookedEnableBanking: Map<string, number>
unbookedEnableBanking: DescBucket
}
/** Push a row into its (date, öre) bucket, normalizing the description. */
function addToBucket(
bucket: DescBucket,
date: string,
amount: number | string,
description: string,
cashAccountId: string | null,
): void {
const key = contentBucketKey(date, amount)
const entry: BucketEntry = { desc: description.toLowerCase().trim(), cashAccountId }
const entries = bucket.get(key)
if (entries) entries.push(entry)
else bucket.set(key, [entry])
}
async function buildExistingTransactionMaps(
@@ -31,8 +63,8 @@ async function buildExistingTransactionMaps(
companyId: string,
rawTransactions: RawTransaction[]
): Promise<ExistingTransactionMaps> {
const booked = new Map<string, number>()
const unbookedEnableBanking = new Map<string, number>()
const booked: DescBucket = new Map()
const unbookedEnableBanking: DescBucket = new Map()
if (rawTransactions.length === 0) return { booked, unbookedEnableBanking }
const dates = rawTransactions.map((t) => t.date).sort()
@@ -42,7 +74,7 @@ async function buildExistingTransactionMaps(
try {
const { data: bookedRows } = await supabase
.from('transactions')
.select('date, amount, original_description, description')
.select('date, amount, original_description, description, cash_account_id')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
@@ -54,12 +86,13 @@ async function buildExistingTransactionMaps(
// description: a title edit must never make the dedup bridge miss a
// genuine re-import. Falls back to description for rows predating the
// original_description column.
const key = contentDedupKey(
addToBucket(
booked,
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
tx.cash_account_id ?? null,
)
booked.set(key, (booked.get(key) || 0) + 1)
}
}
} catch {
@@ -69,7 +102,7 @@ async function buildExistingTransactionMaps(
try {
const { data: unbookedBank } = await supabase
.from('transactions')
.select('date, amount, original_description, description')
.select('date, amount, original_description, description, cash_account_id')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('import_source', 'enable_banking')
@@ -80,12 +113,13 @@ async function buildExistingTransactionMaps(
for (const tx of unbookedBank) {
// See booked-map note: dedup on the immutable bank original so a
// user title edit cannot reopen the duplicate-import window.
const key = contentDedupKey(
addToBucket(
unbookedEnableBanking,
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
tx.cash_account_id ?? null,
)
unbookedEnableBanking.set(key, (unbookedEnableBanking.get(key) || 0) + 1)
}
}
} catch {
@@ -252,25 +286,57 @@ export async function ingestTransactions(
continue
}
// 1b. Content-based dedup: skip if an already-booked transaction
// exists with the same date, amount, and description prefix. Built from the
// normalized description so it matches the stored original_description keys.
const contentKey = contentDedupKey(raw.date, raw.amount, description)
const bookedCount = existingMaps.booked.get(contentKey) || 0
if (bookedCount > 0) {
existingMaps.booked.set(contentKey, bookedCount - 1)
result.duplicates++
continue
// 1b/1c. Content-dedup bridge: skip if an existing booked row (any source)
// OR an unbooked enable_banking row shares this (date, öre) bucket and a
// *bridging* description (prefix-containment, see descriptionsBridge). This
// is the net that catches re-imports the external_id check misses — chiefly
// old-format ids re-synced after the id scheme changed, and PSD2 description
// enrichment between syncs ("TIC" → "TIC BG … via internet"). Booked first,
// then unbooked, preserving the historical 1b-before-1c order.
//
// Consumed with COUNTING semantics: each match splices one stored entry out
// of its bucket, so N stored twins dedup exactly N incoming and two
// genuinely-distinct same-(date,amount) transactions are kept apart. We
// consume the LONGEST bridging stored description first so a more-specific
// twin is matched before a generic one, leaving generic entries for shorter
// incoming rows.
//
// Account guard: when BOTH the incoming batch and a stored entry have a known
// cash_account_id, they must match — so a transaction on one bank account
// never deduplicates a genuinely-different one on another account of the same
// company (the content bucket is company-wide; only external_id embeds the
// account). A null on either side falls back to bridge-allowed, leaving
// single-account and legacy (un-backfilled) rows exactly as before.
//
// Residual trade-off: within one account, a genuinely-new row whose
// description is a prefix-extension of an existing same-(date,öre) row can be
// mis-deduped; it is rare, bounded to the ~90-day PSD2 window where old-format
// ids still overlap, and the frozen external_id (Layer 1) is the exact dedup
// going forward — accepted to stop the re-import flood (the inverse, a visible
// duplicate, was the reported pain).
const bucketKey = contentBucketKey(raw.date, raw.amount)
const consumeBridgingTwin = (bucket: DescBucket): boolean => {
const entries = bucket.get(bucketKey)
if (!entries || entries.length === 0) return false
let bestIdx = -1
let bestLen = -1
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
const sameAccount =
cashAccountId === null || entry.cashAccountId === null || entry.cashAccountId === cashAccountId
if (sameAccount && descriptionsBridge(description, entry.desc) && entry.desc.length > bestLen) {
bestIdx = i
bestLen = entry.desc.length
}
}
if (bestIdx === -1) return false
entries.splice(bestIdx, 1)
return true
}
// 1c. Overlap dedup: skip if an unbooked enable_banking row already
// exists with the same (date, amount, description prefix). Applies to
// any incoming source — PSD2 reconnects, CSV imports over an active
// PSD2 sync, etc. Description prefix prevents unrelated transfers from
// colliding on (date, amount) alone.
const unbookedEbCount = existingMaps.unbookedEnableBanking.get(contentKey) || 0
if (unbookedEbCount > 0) {
existingMaps.unbookedEnableBanking.set(contentKey, unbookedEbCount - 1)
if (
consumeBridgingTwin(existingMaps.booked) ||
consumeBridgingTwin(existingMaps.unbookedEnableBanking)
) {
result.duplicates++
continue
}