* 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>
205 lines
7.9 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
},
|
|
}
|