Files
accounted/lib/import/bank-file/formats/seb.ts
T
6404591b89 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>
2026-08-14 13:05:22 +02:00

205 lines
7.9 KiB
TypeScript

/**
* SEB CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator (parse also accepts
* comma-delimited variants via delimiter sniffing; detect stays strict)
* 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 (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'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
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',
description: 'SEB CSV (semicolon-delimited)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
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')
if (hasBookingDate && hasSebSecondary) return true
const headers = firstLine.split(';').map((h) => h.trim().replace(/"/g, ''))
return isTransaktionerHeader(headers)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header to find column indices. SEB normally exports
// semicolon-delimited files, but some export surfaces use commas: sniff
// the delimiter from the header line instead of assuming ';'.
const headerLine = lines[0] || ''
const semicolons = (headerLine.match(/;/g) || []).length
const commas = (headerLine.match(/,/g) || []).length
const delimiter = commas > semicolons ? ',' : ';'
const headers = parseCSVLine(headerLine, delimiter).map((h) =>
h.trim().toLowerCase().replace(/"/g, '')
)
// 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
// cannot steal files from other bank profiles during auto-detection.
dateIdx = headers.findIndex((h) => h === 'datum')
}
const descIdx = headers.findIndex(
(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 && !hasSplitAmount)) {
issues.push({
row: 1,
message: 'Kunde inte identifiera nödvändiga kolumner (datum, belopp)',
severity: 'error',
})
return {
format: 'seb',
format_name: 'SEB',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, delimiter).map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = fields[descIdx >= 0 ? descIdx : dateIdx + 1] || 'Unknown'
const amountStr = hasSplitAmount
? fields[depositIdx] || fields[withdrawalIdx]
: fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
issues.push({ row: i + 1, message: 'Obligatoriska fält saknas', severity: 'warning' })
skippedRows++
continue
}
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++
continue
}
const normalizedDate = normalizeDate(date)
if (!normalizedDate) {
issues.push({ row: i + 1, message: `Ogiltigt datum: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date: normalizedDate,
description: description.trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'seb',
format_name: 'SEB',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}