fix(import): parse the SEB Transaktioner CSV layout (split Insättningar/Uttag) (#1616)

* 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 <noreply@anthropic.com>

* docs: decision log for SEB Transaktioner parser design

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-14 13:05:22 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 3036c3d879
commit 6404591b89
4 changed files with 128 additions and 11 deletions
+1
View File
@@ -984,6 +984,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+1 -1
View File
@@ -235,7 +235,7 @@ export default function BankFileUploadStep({
<div>
<p className="font-medium">SEB</p>
<p className="text-muted-foreground">
Logga in → Konton → Kontoutdrag → Hämta som fil (CSV)
Logga in → Konton → Transaktioner → Exportera (CSV), eller Kontoutdrag → Hämta som fil (CSV)
</p>
</div>
<div>
@@ -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', () => {
+61 -10
View File
@@ -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++