From 6404591b89784373cb9eef3ba8d381695ba16585 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 14 Aug 2026 13:05:22 +0200 Subject: [PATCH] =?UTF-8?q?fix(import):=20parse=20the=20SEB=20Transaktione?= =?UTF-8?q?r=20CSV=20layout=20(split=20Ins=C3=A4ttningar/Uttag)=20(#1616)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(import): parse the SEB Transaktioner CSV layout (split Insättningar/Uttag) The SEB profile only understood the Kontoutdrag export layout. The Transaktioner page (the path most users find first) exports a different header: Bokförd;Valutadatum;Text;Typ;Insättningar;Uttag;Bokfört saldo, with dot decimals and the amount split across two columns. No profile detected it, so auto-detection found nothing and an explicit SEB choice failed on column detection. Teach the SEB profile the layout: detect on the Insättningar/Uttag pair (unique among supported formats), accept Bokförd as a booking-date column, and combine the split amount (Uttag carries its own minus; unsigned magnitudes are normalized to expenses). Fixture header and first data row are verbatim from a user-provided export. The import help text now lists both SEB export paths. Co-Authored-By: Claude Fable 5 * docs: decision log for SEB Transaktioner parser design Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + components/import/BankFileUploadStep.tsx | 2 +- lib/import/bank-file/__tests__/parser.test.ts | 65 +++++++++++++++++ lib/import/bank-file/formats/seb.ts | 71 ++++++++++++++++--- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 19699b40..cc1ab61d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -984,6 +984,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Radius ladder locked (convention 16): 4 tiers by role (pill toolbar controls / rounded-xl overlays / rounded-lg surfaces / rounded-sm leaves); rounded-md, bare rounded, rounded-2xl and rounded-[Npx] retired app-wide, hard-failed by check:guards off-ladder-radius. Before: 7 radii in circulation with no rule; one toolbar row on /transactions mixed 4 shape languages. [2026-08-13] Toolbar shape language: pills won over "inputs stay rectangles". Search in a page toolbar is a pill (ToolbarSearch, h-8) matching chips/pickers/buttons beside it; the same search inside a dialog or form keeps rounded-lg Input. Rationale: convention 8 already made pickers pill chips and convention 3 made buttons pills, so the rectangle search/segmented were the odd ones out; one row = one shape reads as trust. [2026-08-13] Dialogs promoted 8px -> 12px (rounded-xl): SettingsModal, slide-over and CommandPalette were already 12px, so dialog.tsx was the overlay-tier outlier, not the rule. +[2026-08-14] SEB Transaktioner layout gets native seb-profile support (#1616), not a new format or generic_csv mapping: detect keys on the Insättningar/Uttag column pair (unique among supported formats, cannot steal files), and an unsigned Uttag magnitude is normalized to negative since the column is withdrawals by definition. User-provided sample rows are the verbatim fixture; #1565's explicit-choice fallback had nothing to fall back to because no profile parsed this layout at all. [2026-08-14] QuickReviewDialog inbox picker links picked docs with inbox_item_id but deliberately NOT transaction_id: the dialog's existing upload path never pinned transactions.document_id either, and adding the pin would be a drive-by behaviour change to uploads. Known asymmetry with TransactionBookingDialog, which pins both; revisit as its own change if the underlag indicator matters on rows booked from att-göra. Verified safe on the compliance side: since migration 20260703160000 every "saknar underlag" predicate (verifikat_without_documents, transactions_without_documents, the MCP tools, full-archive-export, the worklist badge) keys on document_attachments.journal_entry_id, never on transactions.document_id, so an unpinned row is never reported as missing underlag. [2026-08-14] The onboarding branch question ("Var fanns bokföringen innan?") became its own journey step at the existing KLART station (done -> source, same station grammar as momsyn/moms under MOMSEN) instead of a sixth rail station: a 6-point rail crowds the 680px band's 150px label slots, "BOKFÖRINGEN INNAN" would sit next to the existing "BOKFÖRINGEN" station, and mode='add' (which never sees the branch question) would need an asymmetric rail. The done screen ends in a revealed Fortsätt that dispatches DONE_CONTINUE (mode='first' only, reducer-guarded). [2026-08-14] Migration /preview fetches ALL allowed fiscal years (dropped latestOnly): the connect step's "Hittade X konton och Y verifikationer" renders from /preview's sieStats, not /sie-data's generateImportPreview, so fixing only /sie-data would have left the founder-reported "0 verifikationer" (actual: 4153) on screen. Costs one SIE export per extra year at connect time, the same work /sie-data repeats right after; honest counts won over latency. diff --git a/components/import/BankFileUploadStep.tsx b/components/import/BankFileUploadStep.tsx index 60ce2b6c..154eb96c 100644 --- a/components/import/BankFileUploadStep.tsx +++ b/components/import/BankFileUploadStep.tsx @@ -235,7 +235,7 @@ export default function BankFileUploadStep({

SEB

- Logga in → Konton → Kontoutdrag → Hämta som fil (CSV) + Logga in → Konton → Transaktioner → Exportera (CSV), eller Kontoutdrag → Hämta som fil (CSV)

diff --git a/lib/import/bank-file/__tests__/parser.test.ts b/lib/import/bank-file/__tests__/parser.test.ts index 5c28821e..12d646d4 100644 --- a/lib/import/bank-file/__tests__/parser.test.ts +++ b/lib/import/bank-file/__tests__/parser.test.ts @@ -51,6 +51,15 @@ const SEB_PRIVAT_CSV = [ '2024-10-31;2024-10-31;5841990687;H31520956893;433,16;5147,56', ].join('\n') +// SEB "Transaktioner" web export: UTF-8 BOM, CRLF, dot decimals, and the +// amount split across Insättningar/Uttag (Uttag rows carry their own minus). +// Header and first data row are verbatim from a user-provided export +// (2026-08); the deposit row is synthetic. +const SEB_TRANSAKTIONER_CSV = + '\uFEFFBokförd;Valutadatum;Text;Typ;Insättningar;Uttag;Bokfört saldo\r\n' + + '2026-07-21;2026-07-21;SAN FRANCISC/26-07-20;Kortköp;;-89.44;433217.91\r\n' + + '2026-07-18;2026-07-18;KUNDINBETALNING;Insättning;12500.00;;433307.35\r\n' + const SWEDBANK_CSV = [ 'Kontouppgifter', 'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo', @@ -824,6 +833,62 @@ describe('parseBankFile: SEB format', () => { const deposit = result.transactions[2] expect(deposit.amount).toBe(433.16) }) + + it('auto-detects the SEB Transaktioner layout (Bokförd + Insättningar/Uttag)', () => { + const format = detectFileFormat(SEB_TRANSAKTIONER_CSV, 'transaktioner.csv') + expect(format).not.toBeNull() + expect(format!.id).toBe('seb') + }) + + it('parses the Transaktioner layout: BOM, CRLF, dot decimals, split amount columns', () => { + const result = parseBankFile(SEB_TRANSAKTIONER_CSV, 'transaktioner.csv') + + expect(result.format).toBe('seb') + expect(result.transactions).toHaveLength(2) + expect(result.issues).toHaveLength(0) + + const cardPurchase = result.transactions[0] + expect(cardPurchase.date).toBe('2026-07-21') + expect(cardPurchase.description).toBe('SAN FRANCISC/26-07-20') + expect(cardPurchase.amount).toBe(-89.44) + expect(cardPurchase.balance).toBe(433217.91) + + const deposit = result.transactions[1] + expect(deposit.date).toBe('2026-07-18') + expect(deposit.amount).toBe(12500) + expect(deposit.balance).toBe(433307.35) + }) + + it('parses the Transaktioner layout natively on an explicit SEB choice (no fallback)', () => { + const result = parseBankFile(SEB_TRANSAKTIONER_CSV, 'transaktioner.csv', 'seb') + + expect(result.format).toBe('seb') + expect(result.transactions).toHaveLength(2) + // Native parse: no "another format was used instead" info issue. + expect(result.issues).toHaveLength(0) + }) + + it('normalizes an unsigned Uttag magnitude to an expense', () => { + const unsignedWithdrawal = + 'Bokförd;Valutadatum;Text;Typ;Insättningar;Uttag;Bokfört saldo\n' + + '2026-07-21;2026-07-21;BANKAVGIFT;Avgift;;120.00;1000.00' + const result = parseBankFile(unsignedWithdrawal, 'transaktioner.csv', 'seb') + + expect(result.transactions).toHaveLength(1) + expect(result.transactions[0].amount).toBe(-120) + }) + + it('skips a Transaktioner row where both Insättningar and Uttag are empty', () => { + const emptyAmounts = + 'Bokförd;Valutadatum;Text;Typ;Insättningar;Uttag;Bokfört saldo\n' + + '2026-07-21;2026-07-21;SPÄRRAD RAD;Info;;;1000.00\n' + + '2026-07-20;2026-07-20;KORTKÖP;Kortköp;;-50.00;950.00' + const result = parseBankFile(emptyAmounts, 'transaktioner.csv', 'seb') + + expect(result.transactions).toHaveLength(1) + expect(result.transactions[0].amount).toBe(-50) + expect(result.stats.skipped_rows).toBe(1) + }) }) describe('parseBankFile: Swedbank format', () => { diff --git a/lib/import/bank-file/formats/seb.ts b/lib/import/bank-file/formats/seb.ts index 8beed391..f28a8e03 100644 --- a/lib/import/bank-file/formats/seb.ts +++ b/lib/import/bank-file/formats/seb.ts @@ -3,13 +3,18 @@ * * Format: Semicolon-delimited, comma decimal separator (parse also accepts * comma-delimited variants via delimiter sniffing; detect stays strict) - * Columns vary but typically: Bokföringsdag, Valutadag, Verifikationsnummer, - * Text/mottagare, Belopp, Saldo + * Two layouts, depending on which internet-bank page exported the file: + * - Kontoutdrag: Bokföringsdag/Bokföringsdatum, Valutadag, Verifikationsnummer, + * Text/mottagare, Belopp, Saldo + * - Transaktioner: Bokförd, Valutadatum, Text, Typ, Insättningar, Uttag, + * Bokfört saldo (dot decimal separator, amount split across two columns + * where Uttag rows carry their own minus sign) * Date format: YYYY-MM-DD - * Encoding: UTF-8 or Windows-1252 + * Encoding: UTF-8 (optionally with BOM) or Windows-1252 */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' +import { roundOre } from '@/lib/money' import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' import { parseCSVLine } from './nordea' @@ -19,6 +24,26 @@ function parseCommaDecimal(value: string): number { return parseFloat(cleaned) } +const DEPOSIT_COLUMN_RE = /^ins(ä|a)ttning/ +const WITHDRAWAL_COLUMN_RE = /^uttag/ + +/** + * The Transaktioner web export: a "Bokförd" (or Valutadatum) date column plus + * the amount split across Insättningar/Uttag. The split amount pair is unique + * to this layout among the supported semicolon formats, so requiring both + * columns cannot steal files from other bank profiles. + */ +function isTransaktionerHeader(headers: string[]): boolean { + const hasDate = + headers.some((h) => h === 'bokförd' || h === 'bokford') || + headers.some((h) => /valuta(dag|datum)/.test(h)) + return ( + hasDate && + headers.some((h) => DEPOSIT_COLUMN_RE.test(h)) && + headers.some((h) => WITHDRAWAL_COLUMN_RE.test(h)) + ) +} + export const sebFormat: BankFileFormat = { id: 'seb', name: 'SEB', @@ -28,13 +53,17 @@ export const sebFormat: BankFileFormat = { detect(content: string, _filename: string): boolean { const prepared = prepareContent(content) const firstLine = prepared.split('\n')[0]?.toLowerCase() || '' - // SEB uses semicolon delimiter. Header always has a bokföringsdag/bokföringsdatum - // column plus either valutadag/valutadatum or verifikationsnummer. The secondary - // check distinguishes SEB from Länsförsäkringar (which also has bokföringsdag). + if (!firstLine.includes(';')) return false + // Kontoutdrag layout: a bokföringsdag/bokföringsdatum column plus either + // valutadag/valutadatum or verifikationsnummer. The secondary check + // distinguishes SEB from Länsförsäkringar (which also has bokföringsdag). const hasBookingDate = /bokf(ö|o)ringsda(g|tum)/.test(firstLine) const hasSebSecondary = /valuta(dag|datum)/.test(firstLine) || firstLine.includes('verifikationsnummer') - return firstLine.includes(';') && hasBookingDate && hasSebSecondary + if (hasBookingDate && hasSebSecondary) return true + + const headers = firstLine.split(';').map((h) => h.trim().replace(/"/g, '')) + return isTransaktionerHeader(headers) }, parse(content: string): BankFileParseResult { @@ -58,6 +87,10 @@ export const sebFormat: BankFileFormat = { // Find column indices dynamically let dateIdx = headers.findIndex((h) => /bokf(ö|o)ringsda(g|tum)/.test(h)) + if (dateIdx === -1) { + // Transaktioner layout: the booking date column is just "Bokförd". + dateIdx = headers.findIndex((h) => h === 'bokförd' || h === 'bokford') + } if (dateIdx === -1) { // Lowest-priority tier: a bare "Datum" column. Only honored here in // parse (an explicit user choice), never in detect, so this profile @@ -68,9 +101,15 @@ export const sebFormat: BankFileFormat = { (h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning') ) const amountIdx = headers.findIndex((h) => h.includes('belopp')) + // Transaktioner layout: no Belopp column; the amount is split across + // Insättningar (positive) and Uttag (exported with its own minus sign). + const depositIdx = amountIdx === -1 ? headers.findIndex((h) => DEPOSIT_COLUMN_RE.test(h)) : -1 + const withdrawalIdx = + amountIdx === -1 ? headers.findIndex((h) => WITHDRAWAL_COLUMN_RE.test(h)) : -1 + const hasSplitAmount = depositIdx !== -1 && withdrawalIdx !== -1 const balanceIdx = headers.findIndex((h) => h.includes('saldo')) - if (dateIdx === -1 || amountIdx === -1) { + if (dateIdx === -1 || (amountIdx === -1 && !hasSplitAmount)) { issues.push({ row: 1, message: 'Kunde inte identifiera nödvändiga kolumner (datum, belopp)', @@ -95,7 +134,9 @@ export const sebFormat: BankFileFormat = { const date = fields[dateIdx] const description = fields[descIdx >= 0 ? descIdx : dateIdx + 1] || 'Unknown' - const amountStr = fields[amountIdx] + const amountStr = hasSplitAmount + ? fields[depositIdx] || fields[withdrawalIdx] + : fields[amountIdx] const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined if (!date || !amountStr) { @@ -104,7 +145,17 @@ export const sebFormat: BankFileFormat = { continue } - const amount = parseCommaDecimal(amountStr) + let amount: number + if (hasSplitAmount) { + const deposit = fields[depositIdx] ? parseCommaDecimal(fields[depositIdx]) : 0 + const rawWithdrawal = fields[withdrawalIdx] ? parseCommaDecimal(fields[withdrawalIdx]) : 0 + // Uttag values carry their own minus sign in SEB's export; normalize + // so a variant exporting magnitudes still lands as an expense. + const withdrawal = rawWithdrawal > 0 ? -rawWithdrawal : rawWithdrawal + amount = roundOre(deposit + withdrawal) + } else { + amount = parseCommaDecimal(amountStr) + } if (isNaN(amount)) { issues.push({ row: i + 1, message: `Ogiltigt belopp: ${amountStr}`, severity: 'warning' }) skippedRows++