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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
3036c3d879
commit
6404591b89
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user