Mcp/template data feedback (#617)

* fix(booking-templates): scope template list to the active company

GET /api/settings/booking-templates relied solely on the btl_select RLS
policy, which is membership-wide (user_company_ids) and returns templates
from every company the user belongs to. A user who owns multiple companies
saw all their templates merged regardless of which company was active.

Narrow the list in the API layer (mirroring counterparty-templates) to
system + the active company + the active company's team. RLS stays the
security backstop; this fixes the cross-company merge within a single
user's own view (it was never a cross-tenant data leak).

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

* fix(import): show proper message for duplicate bank file upload

The bank file import page mis-parsed the structured error envelope
({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE
(409) fell through to the generic "Kunde inte läsa filen" fallback.
The upload step also hardcoded that same string as the error heading,
so duplicates were doubly misreported as parse failures.

- Parse the structured envelope by error.code; surface error.message
  for all codes instead of rendering the error object.
- Add a dedicated BANK_FILE_DUPLICATE message using the importedAt /
  importedCount details the route already returns.
- Add an optional errorTitle prop to BankFileUploadStep (defaults to
  the previous text) and pass "Filen är redan importerad" for dupes.

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

* feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling

- Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions.
- Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates.
- Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources.
- Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability.

feat(migrations): add new database migrations for transaction handling

- Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation.
- Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity.

* feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines

* feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-01 14:45:49 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2c59c3633f
commit c6c86cded4
54 changed files with 3673 additions and 158 deletions
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import {
amountToOre,
buildStableExternalIds,
contentDedupKey,
normalizeImportedDescription,
FALLBACK_DESCRIPTION,
} from '../external-id'
describe('amountToOre', () => {
it('normalizes a JS number to integer öre', () => {
expect(amountToOre(1234.5)).toBe(123450)
expect(amountToOre(-250)).toBe(-25000)
expect(amountToOre(0)).toBe(0)
})
it('normalizes a numeric string (PostgREST representation) to the same öre', () => {
// The core fix: a DB-fetched numeric string and a raw JS number for the
// same amount must collapse to the same integer.
expect(amountToOre('1234.50')).toBe(123450)
expect(amountToOre('1234.5')).toBe(amountToOre(1234.5))
expect(amountToOre('-250.00')).toBe(amountToOre(-250))
expect(amountToOre('100')).toBe(10000)
})
it('rounds sub-öre noise deterministically (never toFixed)', () => {
expect(amountToOre(0.1 + 0.2)).toBe(30) // 0.30000000000000004 → 30
expect(amountToOre(19.995)).toBe(2000)
})
})
describe('buildStableExternalIds', () => {
it('derives the id from account + date + öre, not from any bank id', () => {
const ids = buildStableExternalIds('eb', 'SE123', [{ date: '2024-06-15', amount: -500 }])
expect(ids).toEqual(['eb_SE123_2024-06-15_-50000_0'])
})
it('disambiguates genuinely identical transactions with an occurrence index', () => {
const ids = buildStableExternalIds('eb', 'acc', [
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-15', amount: -250 },
])
expect(ids).toEqual([
'eb_acc_2024-06-15_-25000_0',
'eb_acc_2024-06-15_-25000_1',
'eb_acc_2024-06-15_-25000_2',
])
})
it('produces the SAME set of ids regardless of provider ordering (re-sync dedupe)', () => {
const a = buildStableExternalIds('eb', 'acc', [
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-16', amount: -100 },
{ date: '2024-06-15', amount: -250 },
])
// Same transactions, different order on a later sync.
const b = buildStableExternalIds('eb', 'acc', [
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-16', amount: -100 },
])
expect(new Set(a)).toEqual(new Set(b))
})
it('treats string and number amounts as the same id (provider type drift)', () => {
const num = buildStableExternalIds('eb', 'acc', [{ date: '2024-06-15', amount: 1234.5 }])
const str = buildStableExternalIds('eb', 'acc', [{ date: '2024-06-15', amount: '1234.50' }])
expect(num).toEqual(str)
})
it('keeps distinct amounts and dates on separate occurrence counters', () => {
const ids = buildStableExternalIds('eb', 'acc', [
{ date: '2024-06-15', amount: -250 },
{ date: '2024-06-15', amount: -100 },
{ date: '2024-06-16', amount: -250 },
{ date: '2024-06-15', amount: -250 },
])
expect(ids).toEqual([
'eb_acc_2024-06-15_-25000_0',
'eb_acc_2024-06-15_-10000_0',
'eb_acc_2024-06-16_-25000_0',
'eb_acc_2024-06-15_-25000_1',
])
})
it('returns an empty array for an empty batch', () => {
expect(buildStableExternalIds('eb', 'acc', [])).toEqual([])
})
})
describe('contentDedupKey', () => {
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)
})
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('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('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, ''))
})
})
describe('normalizeImportedDescription', () => {
it('maps empty / whitespace-only titles to the Swedish neutral', () => {
expect(normalizeImportedDescription('')).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription(' ')).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription(null)).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription(undefined)).toBe(FALLBACK_DESCRIPTION)
})
it('maps the legacy English "Unknown" sentinel to the Swedish neutral (case-insensitive)', () => {
expect(normalizeImportedDescription('Unknown')).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription('unknown')).toBe(FALLBACK_DESCRIPTION)
expect(normalizeImportedDescription(' UNKNOWN ')).toBe(FALLBACK_DESCRIPTION)
})
it('preserves a real title and trims surrounding whitespace', () => {
expect(normalizeImportedDescription('ICA Maxi Solna')).toBe('ICA Maxi Solna')
expect(normalizeImportedDescription(' Lön juni ')).toBe('Lön juni')
})
it('does NOT clobber a real title that merely contains the word "unknown"', () => {
expect(normalizeImportedDescription('Unknown Pizza AB')).toBe('Unknown Pizza AB')
})
})
+145
View File
@@ -211,6 +211,85 @@ describe('ingestTransactions', () => {
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 2b-edit. Edit-safety regression: a user-edited stored title must NOT
// reopen the duplicate-import window. The content bridge keys off the
// immutable original_description, so a re-import whose bank text still
// matches the original is deduped even though the stored (editable)
// description was changed.
// -----------------------------------------------------------------------
it('dedupes against the original bank description even after the stored title was edited', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2024-06-15',
amount: -250.0,
description: 'ICA Maxi Solna', // original bank text, re-imported via CSV
external_id: 'lunar_csvhash999', // different external_id → primary dedup misses
import_source: 'csv_lunar',
})
// Booked transaction map query — none
enqueue({ data: [], error: null })
// Unbooked bank-synced row whose TITLE was edited by the user, but whose
// original_description still holds the bank's verbatim text.
enqueue({
data: [
{
date: '2024-06-15',
amount: -250.0,
original_description: 'ICA Maxi Solna',
description: 'Mataffär (egen rubrik)',
},
],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — external_id differs, so no match
enqueue({ data: [], error: null })
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
// -----------------------------------------------------------------------
// 2b-unknown. Legacy 'Unknown'/empty rows must still dedup: the stored-side
// content key is normalized the same way as the incoming side, so an
// existing row whose original_description is the legacy 'Unknown'
// sentinel matches an incoming 'Unknown' re-import (both → 'Okänd
// transaktion'). Without symmetric normalization this row would
// re-import as a duplicate.
// -----------------------------------------------------------------------
it('dedupes legacy "Unknown" rows by normalizing both the stored and incoming keys', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2024-06-15',
amount: -250.0,
description: 'Unknown', // legacy English sentinel re-imported via CSV
external_id: 'lunar_csvhashU',
import_source: 'csv_lunar',
})
// Booked transaction map query — none
enqueue({ data: [], error: null })
// Unbooked bank-synced row whose original_description is the legacy sentinel.
enqueue({
data: [{ date: '2024-06-15', amount: -250.0, original_description: 'Unknown', description: 'Unknown' }],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — external_id differs, so no match
enqueue({ data: [], error: null })
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
// -----------------------------------------------------------------------
// 2c. No false positive: same date+amount but different description does
// NOT trigger content dedup — guards against the historical concern
@@ -791,6 +870,72 @@ describe('ingestTransactions', () => {
expect(result.duplicates).toBe(0)
})
it('bridges the external_id scheme change: a booked OLD-scheme eb_ row is caught by content dedup on re-sync', async () => {
// Transition scenario: an enable_banking row was imported+booked under the
// OLD unstable scheme (eb_{iban}_{txid}). After deploy, the re-sync derives
// a NEW content-based external_id that will NOT match by external_id, so
// layer-1 misses. Layer 1b (booked content dedup) MUST catch it, otherwise
// the user sees the exact duplicate the fix is meant to prevent.
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
external_id: 'eb_SE123_2024-06-15_-25000_0', // new scheme
date: '2024-06-15',
amount: -250,
description: 'ICA Maxi Solna',
import_source: 'enable_banking',
})
// Booked map: the SAME transaction still carries its OLD-scheme external_id
// in the DB; dedup matches on content, not on external_id.
enqueue({
data: [{ date: '2024-06-15', amount: -250, description: 'ICA Maxi Solna' }],
error: null,
})
// Unbooked enable_banking map — none
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — DB still holds eb_SE123_{old_txid}, so the
// new external_id finds NO match here.
enqueue({ data: [], error: null })
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
it('dedupes against a booked row whose amount is a numeric string (PostgREST), not a number', async () => {
// Regression: PostgREST can serialize a `numeric` column as a string
// ("-250.00") while the incoming raw amount is a JS number (-250). Before
// the öre-normalized dedup key these never compared equal, so content dedup
// silently missed and the row was re-imported as a duplicate.
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
external_id: 'eb_acc_2024-06-15_-25000_0',
date: '2024-06-15',
amount: -250,
description: 'ICA Maxi Solna',
})
// Booked map: same transaction, amount as a STRING with trailing zeros.
enqueue({
data: [{ date: '2024-06-15', amount: '-250.00', description: 'ICA Maxi Solna' }],
error: null,
})
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no match by external_id — the id scheme changed)
enqueue({ data: [], error: null })
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
it('handles multiple booked transactions with same date+amount correctly', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
+139
View File
@@ -0,0 +1,139 @@
/**
* Shared helpers for deriving stable bank-transaction `external_id`s and for
* normalizing monetary amounts used in dedup keys.
*
* Why this exists
* ---------------
* The transactions table is deduplicated on `(company_id, external_id)` (a
* partial unique index, see migration 20260330130000). The dedup is therefore
* only as good as the stability of `external_id` across re-syncs.
*
* For Enable Banking (PSD2 / Berlin Group) the previous scheme keyed
* `external_id` off the bank's `entry_reference` / `transaction_id`
* (`eb_{account}_{tx.id}`). Many Swedish ASPSPs do NOT return those fields
* stably across requests — a later "synka nu" can return the same underlying
* transaction with a different id, which produced a *new* `external_id` and
* therefore a duplicate row (including for transactions the user had already
* booked). See `buildStableExternalIds` for the content-derived replacement.
*/
/**
* Normalize a monetary amount to integer öre (hundredths) for stable,
* representation-agnostic comparison.
*
* PostgREST may return a `numeric` column as a JS number OR as a string
* (preserving precision), so `1234.5` and the string `"1234.50"` can describe
* the same amount. Interpolating either directly into a dedup key yields
* different strings (`"1234.5"` vs `"1234.50"`), silently breaking content
* dedup. Rounding to integer öre collapses both to `123450`.
*
* Uses the project-standard `Math.round(x * 100)` (never `toFixed`).
*/
export function amountToOre(amount: number | string): number {
return Math.round(Number(amount) * 100)
}
/**
* Swedish-first placeholder for transactions a bank/import source gives no
* usable title. Centralized so every import path and the tests agree.
*/
export const FALLBACK_DESCRIPTION = 'Okänd transaktion'
/**
* Normalize an imported transaction title for storage and display.
*
* Maps both an empty/whitespace title AND the legacy English 'Unknown'
* sentinel — still emitted by the bank-file format parsers and as the Enable
* Banking converter's last resort — to a Swedish-first neutral. Applied once at
* the ingest boundary so every source (PSD2 sync + CSV/CAMT import) inherits
* it; the bank's verbatim text is preserved separately in
* `transactions.original_description`. Match on the exact 'unknown' sentinel
* (case-insensitive) so a real description that merely contains the word is
* never clobbered.
*/
export function normalizeImportedDescription(raw: string | null | undefined): string {
const trimmed = (raw ?? '').trim()
if (!trimmed || trimmed.toLowerCase() === 'unknown') return FALLBACK_DESCRIPTION
return trimmed
}
/**
* Build stable, collision-safe `external_id`s for a batch of bank transactions
* whose provider does not supply a reliable stable id (e.g. Enable Banking).
*
* The id is derived from content — `{prefix}_{accountScope}_{date}_{öre}_{n}`
* — where `n` is an occurrence index that disambiguates genuinely identical
* transactions (same account, date and amount) within the batch.
*
* 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
* collides with the existing rows on `(company_id, external_id)` and is
* skipped — even after the user has booked them. (The id *set* is what the
* unique index enforces; which physical row maps to `..._0` vs `..._1` need
* not be stable, only the set.)
* - **No false dedupe**: two legitimately distinct transactions that share a
* date and amount get different ids (`..._0`, `..._1`) and are both kept.
* 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
* `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).
*
* @param prefix Source tag, e.g. `'eb'` for Enable Banking.
* @param accountScope Stable per-account scope (prefer IBAN, fall back to the
* provider account uid). Keeps ids unique across accounts.
* Callers should pass a whitespace/case-normalized IBAN so
* formatting variants ("SE45 5000…" vs "SE455000…") don't
* produce different ids for the same account.
* @param txns Batch in provider order; each needs `date` + `amount`.
*/
export function buildStableExternalIds(
prefix: string,
accountScope: string,
txns: Array<{ date: string; amount: number | string }>
): string[] {
const occurrences = new Map<string, number>()
return txns.map((tx) => {
const fingerprint = `${tx.date}_${amountToOre(tx.amount)}`
const n = occurrences.get(fingerprint) ?? 0
occurrences.set(fingerprint, n + 1)
return `${prefix}_${accountScope}_${fingerprint}_${n}`
})
}
/**
* Stable content-dedup key used to bridge transactions across `external_id`
* schemes and import sources (PSD2 ⇄ CSV, old id scheme ⇄ new id scheme).
*
* 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.
*/
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}`
}
+35 -19
View File
@@ -7,6 +7,7 @@ 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 type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
// Re-export types for backward compatibility
@@ -25,18 +26,6 @@ interface ExistingTransactionMaps {
unbookedEnableBanking: Map<string, number>
}
/**
* Stable content-dedup key. Includes a normalized description prefix so the
* two-tuple (date, amount) doesn't false-positive across unrelated transfers
* that happen to share a date and amount. Lunar's CSV "Text" column and
* PSD2's `description || counterparty_name` (see enable-banking/lib/sync.ts)
* agree well enough in practice for the same underlying transaction.
*/
function contentDedupKey(date: string, amount: number | string, description: string | null | undefined): string {
const descPrefix = (description || '').toLowerCase().trim().slice(0, 24)
return `${date}|${amount}|${descPrefix}`
}
async function buildExistingTransactionMaps(
supabase: SupabaseClient,
companyId: string,
@@ -53,7 +42,7 @@ async function buildExistingTransactionMaps(
try {
const { data: bookedRows } = await supabase
.from('transactions')
.select('date, amount, description')
.select('date, amount, original_description, description')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
@@ -61,7 +50,15 @@ async function buildExistingTransactionMaps(
if (bookedRows) {
for (const tx of bookedRows) {
const key = contentDedupKey(tx.date, tx.amount, tx.description)
// Key off the immutable bank original, not the user-editable
// 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(
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
)
booked.set(key, (booked.get(key) || 0) + 1)
}
}
@@ -72,7 +69,7 @@ async function buildExistingTransactionMaps(
try {
const { data: unbookedBank } = await supabase
.from('transactions')
.select('date, amount, description')
.select('date, amount, original_description, description')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('import_source', 'enable_banking')
@@ -81,7 +78,13 @@ async function buildExistingTransactionMaps(
if (unbookedBank) {
for (const tx of unbookedBank) {
const key = contentDedupKey(tx.date, tx.amount, tx.description)
// See booked-map note: dedup on the immutable bank original so a
// user title edit cannot reopen the duplicate-import window.
const key = contentDedupKey(
tx.date,
tx.amount,
normalizeImportedDescription(tx.original_description ?? tx.description),
)
unbookedEnableBanking.set(key, (unbookedEnableBanking.get(key) || 0) + 1)
}
}
@@ -216,6 +219,14 @@ export async function ingestTransactions(
const matchedSupplierInvoiceIds = new Set<string>()
for (const raw of rawTransactions) {
// Normalize the source title once. Guarantees a non-empty, Swedish-first
// label for every import path (PSD2 sync + all bank-file CSV/CAMT parsers
// funnel into raw.description) — catching both empty/whitespace titles and
// the legacy English 'Unknown' sentinel. This normalized value is stored as
// both description and original_description below; it's what the user sees
// and edits, and what the content-dedup key is built from.
const description = normalizeImportedDescription(raw.description)
// 1. Check for duplicates via external_id (batch pre-fetched)
if (existingExternalIds.has(raw.external_id)) {
result.duplicates++
@@ -223,8 +234,9 @@ export async function ingestTransactions(
}
// 1b. Content-based dedup: skip if an already-booked transaction
// exists with the same date, amount, and description prefix.
const contentKey = contentDedupKey(raw.date, raw.amount, raw.description)
// 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)
@@ -260,7 +272,11 @@ export async function ingestTransactions(
bank_connection_id: raw.bank_connection_id || null,
external_id: raw.external_id,
date: raw.date,
description: raw.description,
description: description,
// Immutable bank/PSD2 original — captured once, never overwritten by a
// title edit. Equals description at insert; they diverge only if the
// user later edits the title.
original_description: description,
amount: raw.amount,
currency: raw.currency,
amount_sek: amountSek,