feat: add 4 new Swedish bank CSV parsers and improve transaction categorization

Add auto-detecting CSV parsers for Länsförsäkringar, ICA Banken, Skandia,
and Lunar. Refine SEB detection to avoid false matches. Update bank file
upload UI with new bank options and export instructions. Include booking
templates, improved AI categorization, and transaction review enhancements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-23 20:38:44 +01:00
co-authored by Claude Opus 4.6
parent 431f385b4b
commit bbb82866ee
24 changed files with 4366 additions and 48 deletions
+296 -1
View File
@@ -116,6 +116,51 @@ const CAMT053_XML_WITH_STRUCTURED_REF = `<?xml version="1.0" encoding="UTF-8"?>
</BkToCstmrStmt>
</Document>`
const LANSFORSAKRINGAR_CSV = [
'"Datum";"Bokföringsdag";"Typ";"Text";"Belopp";"Saldo"',
'"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"',
'"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"',
'"2024-01-13";"2024-01-13";"Insättning";"LÖNEUTBETALNING";"25 000,00";"12 877,17"',
].join('\n')
const LANSFORSAKRINGAR_CSV_NO_HEADER = [
'"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"',
'"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"',
].join('\n')
const ICA_BANKEN_CSV = [
'Kontonamn: Lönekonto',
'Kontonummer: 1234 567 890',
'Saldo: 12 877,17',
'Tillgängligt belopp: 12 877,17',
'Period: 2024-01-01 - 2024-01-31',
'Exporterad: 2024-02-01',
'Datum;Text;Belopp;Saldo',
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;ICA MAXI LINDHAGEN;-432,50;12444,67',
'2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const SKANDIA_CSV = [
'Datum;Beskrivning;Belopp;Saldo',
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67',
'2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const SKANDIA_CSV_WITH_BANKKATEGORI = [
'Datum;Beskrivning;Belopp;Saldo;Bankkategori',
'2024-01-15;SPOTIFY AB;-99,00;12345,67;Underhållning',
'2024-01-14;ICA MAXI;-432,50;12444,67;Livsmedel',
].join('\n')
const LUNAR_CSV = [
'Date,Text,Amount,Balance',
'2024-01-15,SPOTIFY AB,"-99,00","12.345,67"',
'2024-01-14,ICA MAXI LINDHAGEN,"-432,50","12.444,67"',
'2024-01-13,LÖNEUTBETALNING,"25.000,00","12.877,17"',
].join('\n')
const UNKNOWN_CSV = [
'id,name,value,timestamp',
'1,Widget A,100,2024-01-15T10:00:00',
@@ -185,6 +230,42 @@ describe('detectFileFormat', () => {
}
})
it('detects Länsförsäkringar CSV from header with "typ" keyword', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV, 'lansforsakringar.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('detects Länsförsäkringar CSV from two adjacent date fields (no header)', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV_NO_HEADER, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('detects ICA Banken CSV from metadata rows before header', () => {
const format = detectFileFormat(ICA_BANKEN_CSV, 'ica.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('ica_banken')
})
it('detects Skandia CSV from "beskrivning" header keyword', () => {
const format = detectFileFormat(SKANDIA_CSV, 'skandia.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('skandia')
})
it('detects Skandia CSV from "bankkategori" header keyword', () => {
const format = detectFileFormat(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('skandia')
})
it('detects Lunar CSV from English headers (date, text, amount, balance)', () => {
const format = detectFileFormat(LUNAR_CSV, 'lunar.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lunar')
})
it('returns null for unrecognized CSV content', () => {
const format = detectFileFormat(UNKNOWN_CSV, 'data.csv')
expect(format).toBeNull()
@@ -426,6 +507,216 @@ describe('parseBankFile — Handelsbanken format', () => {
})
})
describe('parseBankFile — Länsförsäkringar format', () => {
it('parses semicolon-delimited CSV with quoted fields and comma decimal separator', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.format).toBe('lansforsakringar')
expect(result.format_name).toBe('Länsförsäkringar')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts with comma decimal and space thousands', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('handles files without a header row (data-only)', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV_NO_HEADER, 'lf.csv')
expect(result.format).toBe('lansforsakringar')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(-432.5)
})
it('calculates stats correctly', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
})
describe('parseBankFile — ICA Banken format', () => {
it('parses semicolon-delimited CSV with metadata rows before header', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.format).toBe('ica_banken')
expect(result.format_name).toBe('ICA Banken')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('skips metadata rows and finds the correct header', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
// No transaction should contain metadata text
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain(expect.stringContaining('Kontonamn'))
expect(descriptions).not.toContain(expect.stringContaining('Exporterad'))
})
it('correctly parses amounts with comma decimal separator', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('calculates stats correctly', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
})
describe('parseBankFile — Skandia format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.format).toBe('skandia')
expect(result.format_name).toBe('Skandia')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts and descriptions', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[1].description).toBe('HEMKÖP FRIDHEMSPLAN')
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('handles files with bankkategori column', () => {
const result = parseBankFile(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv')
expect(result.format).toBe('skandia')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(-432.5)
})
it('calculates stats correctly', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
})
describe('parseBankFile — Lunar format', () => {
it('parses comma-delimited CSV with English headers', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.format).toBe('lunar')
expect(result.format_name).toBe('Lunar')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts with comma decimal and period thousand separator', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field with period thousand separator', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.transactions[0].balance).toBe(12345.67)
expect(result.transactions[2].balance).toBe(12877.17)
})
it('calculates stats correctly', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
it('does not confuse Lunar (English) with Nordea (Swedish) headers', () => {
// Nordea has Swedish headers, Lunar has English
const nordeaResult = detectFileFormat(NORDEA_CSV, 'test.csv')
const lunarResult = detectFileFormat(LUNAR_CSV, 'test.csv')
expect(nordeaResult!.id).toBe('nordea')
expect(lunarResult!.id).toBe('lunar')
})
})
describe('parseBankFile — camt.053 XML format', () => {
it('parses XML with credit and debit entries', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
@@ -850,13 +1141,17 @@ describe('getFormat and getAllFormats', () => {
it('getAllFormats returns all registered formats', () => {
const formats = getAllFormats()
expect(formats.length).toBeGreaterThanOrEqual(6)
expect(formats.length).toBeGreaterThanOrEqual(10)
const ids = formats.map((f) => f.id)
expect(ids).toContain('nordea')
expect(ids).toContain('seb')
expect(ids).toContain('swedbank')
expect(ids).toContain('handelsbanken')
expect(ids).toContain('lansforsakringar')
expect(ids).toContain('ica_banken')
expect(ids).toContain('skandia')
expect(ids).toContain('lunar')
expect(ids).toContain('camt053')
expect(ids).toContain('generic_csv')
})
+187
View File
@@ -0,0 +1,187 @@
/**
* ICA Banken CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Datum, Text, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - ~6 metadata rows before the actual data (account info, period, etc.)
* - Header row contains "datum" and "belopp"
* - Must skip metadata lines to find the real header
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
/**
* Check if a line looks like the ICA Banken data header.
* ICA Banken header: semicolon-delimited with "datum", "text", "belopp", "saldo"
*/
function isICAHeader(line: string): boolean {
const lower = line.toLowerCase().replace(/"/g, '')
if (!lower.includes(';')) return false
const fields = lower.split(';').map((f) => f.trim())
return (
fields.some((f) => f === 'datum') &&
fields.some((f) => f === 'belopp') &&
fields.some((f) => f === 'text')
)
}
/**
* Detect ICA Banken format: semicolon-delimited file with metadata lines
* before a header containing "datum" and "belopp".
*/
function detectICABanken(lines: string[]): boolean {
// Look for a header row within the first ~10 lines (skipping metadata)
let metadataCount = 0
for (let i = 0; i < Math.min(lines.length, 10); i++) {
if (isICAHeader(lines[i])) {
// Must have at least 2 metadata rows before header to distinguish from
// other semicolon-delimited formats (SEB, Handelsbanken, LF)
return metadataCount >= 2
}
metadataCount++
}
return false
}
export const icaBankenFormat: BankFileFormat = {
id: 'ica_banken',
name: 'ICA Banken',
description: 'ICA Banken CSV (semicolon-delimited, metadata rows before header)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((l) => l.trim() !== '')
return detectICABanken(lines)
},
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
// Find the header row
let headerLineIdx = -1
for (let i = 0; i < Math.min(lines.length, 10); i++) {
if (isICAHeader(lines[i])) {
headerLineIdx = i
break
}
}
if (headerLineIdx === -1) {
issues.push({
row: 1,
message: 'Could not find ICA Banken header row',
severity: 'error',
})
return {
format: 'ica_banken',
format_name: 'ICA Banken',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
// Parse header columns
const headers = lines[headerLineIdx].split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h === 'datum')
const descIdx = headers.findIndex((h) => h === 'text')
const amountIdx = headers.findIndex((h) => h === 'belopp')
const balanceIdx = headers.findIndex((h) => h === 'saldo')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: headerLineIdx + 1,
message: 'Could not identify required columns (datum, belopp)',
severity: 'error',
})
return {
format: 'ica_banken',
format_name: 'ICA Banken',
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 = headerLineIdx + 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').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: 'ica_banken',
format_name: 'ICA Banken',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - headerLineIdx - 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,
},
}
},
}
@@ -0,0 +1,168 @@
/**
* Länsförsäkringar CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator, double-quoted fields
* Columns: Datum, Bokföringsdag, Typ, Text, Belopp, (Saldo optional)
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Fields are double-quoted
* - Two adjacent date columns (Datum + Bokföringsdag) is unique to Länsförsäkringar
* - No guaranteed header row — detect by structure
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
/**
* Check if a line has the Länsförsäkringar structure:
* two adjacent YYYY-MM-DD date fields in a semicolon-delimited, quoted row.
*/
function isLFRow(line: string): boolean {
if (!line.includes(';')) return false
const fields = parseCSVLine(line, ';').map((f) => f.trim())
return fields.length >= 5 && DATE_RE.test(fields[0]) && DATE_RE.test(fields[1])
}
/**
* Check if a line looks like a Länsförsäkringar header row.
*/
function isLFHeader(line: string): boolean {
const lower = line.toLowerCase().replace(/"/g, '')
return (
lower.includes(';') &&
lower.includes('datum') &&
lower.includes('typ') &&
lower.includes('belopp')
)
}
export const lansforsakringarFormat: BankFileFormat = {
id: 'lansforsakringar',
name: 'Länsförsäkringar',
description: 'Länsförsäkringar CSV (semicolon-delimited, quoted fields)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((l) => l.trim() !== '')
if (lines.length < 1) return false
// Check for header with "typ" keyword (unique to LF among semicolon formats)
if (isLFHeader(lines[0])) return true
// Alternatively: detect data rows with two adjacent date fields
// Check first few non-empty lines for the two-date pattern
for (let i = 0; i < Math.min(lines.length, 3); i++) {
if (isLFRow(lines[i])) return true
}
return false
},
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
// Determine if first row is a header or data
let startIdx = 0
let dateIdx = 0
let descIdx = 3
let amountIdx = 4
let balanceIdx = 5
if (isLFHeader(lines[0])) {
// Parse header to find column indices
const headers = parseCSVLine(lines[0], ';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
dateIdx = headers.findIndex((h) => h === 'datum')
if (dateIdx === -1) dateIdx = 0
descIdx = headers.findIndex((h) => h === 'text' || h === 'beskrivning')
if (descIdx === -1) descIdx = 3
amountIdx = headers.findIndex((h) => h === 'belopp')
if (amountIdx === -1) amountIdx = 4
balanceIdx = headers.findIndex((h) => h === 'saldo')
startIdx = 1
}
for (let i = startIdx; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
if (fields.length < 5) {
issues.push({ row: i + 1, message: 'Too few columns', severity: 'warning' })
skippedRows++
continue
}
const date = fields[dateIdx]
const description = fields[descIdx] || 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 && balanceIdx < fields.length ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!DATE_RE.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
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()
const totalDataRows = lines.length - startIdx
return {
format: 'lansforsakringar',
format_name: 'Länsförsäkringar',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: totalDataRows,
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,
},
}
},
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Lunar CSV format parser
*
* Format: Comma-delimited, comma decimal separator (amounts are quoted)
* Columns: Date, Text, Amount, Balance (English headers)
* Date format: YYYY-MM-DD
* Encoding: UTF-8
*
* Notes:
* - English headers distinguish Lunar from Nordea (Swedish headers)
* - Amounts use comma as decimal separator but are quoted since the file
* delimiter is also comma
* - Thousand separator is period (e.g. "1.234,56")
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
function parseLunarAmount(value: string): number {
// Lunar format: "1.234,56" or "-1.234,56"
// Remove period (thousand separator), replace comma (decimal separator) with period
const cleaned = value.replace(/\./g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const lunarFormat: BankFileFormat = {
id: 'lunar',
name: 'Lunar',
description: 'Lunar CSV (comma-delimited, English headers)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// Lunar: comma-delimited with English headers
// Must NOT contain semicolons, must have "date", "text", "amount", "balance"
return (
!firstLine.includes(';') &&
firstLine.includes('date') &&
firstLine.includes('text') &&
firstLine.includes('amount') &&
firstLine.includes('balance')
)
},
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
const headerLine = lines[0] || ''
const headers = parseCSVLine(headerLine, ',').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h === 'date')
const descIdx = headers.findIndex((h) => h === 'text')
const amountIdx = headers.findIndex((h) => h === 'amount')
const balanceIdx = headers.findIndex((h) => h === 'balance')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (date, amount)',
severity: 'error',
})
return {
format: 'lunar',
format_name: 'Lunar',
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, ',').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseLunarAmount(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseLunarAmount(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').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: 'lunar',
format_name: 'Lunar',
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,
},
}
},
}
+4 -2
View File
@@ -25,12 +25,14 @@ export const sebFormat: BankFileFormat = {
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// SEB headers contain "bokföringsdag" or "bokforingsdatum" and use semicolons
// SEB headers contain "bokföringsdag"/"bokforingsdatum" AND "valutadag"/"verifikationsnummer"
// The secondary check distinguishes SEB from Länsförsäkringar (which also has "bokföringsdag")
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') ||
firstLine.includes('bokforingsdatum') ||
firstLine.includes('bokföringsdag'))
firstLine.includes('bokföringsdag')) &&
(firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer'))
)
},
+148
View File
@@ -0,0 +1,148 @@
/**
* Skandia CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Datum, Beskrivning/Text, Belopp, Saldo (possibly Bankkategori)
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Header contains "beskrivning" (unique keyword not used by SEB/Handelsbanken)
* or "bankkategori" (Skandia-specific column)
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const skandiaFormat: BankFileFormat = {
id: 'skandia',
name: 'Skandia',
description: 'Skandia CSV (semicolon-delimited)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase().replace(/"/g, '') || ''
if (!firstLine.includes(';')) return false
const fields = firstLine.split(';').map((f) => f.trim())
// "bankkategori" is unique to Skandia
if (fields.some((f) => f.includes('bankkategori'))) return true
// "beskrivning" as a standalone column header with semicolon delimiter
// Must also have "datum" and "belopp" to confirm it's a bank export
// Note: Handelsbanken uses "beskrivning" only as a fallback in descIdx logic,
// but its header detection is "reskontradatum"/"transaktionsdatum" which is checked first
if (
fields.some((f) => f === 'beskrivning') &&
fields.some((f) => f === 'datum') &&
fields.some((f) => f === 'belopp')
) {
return true
}
return false
},
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
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h === 'datum')
const descIdx = headers.findIndex((h) => h === 'beskrivning' || h === 'text')
const amountIdx = headers.findIndex((h) => h === 'belopp')
const balanceIdx = headers.findIndex((h) => h === 'saldo')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (datum, belopp)',
severity: 'error',
})
return {
format: 'skandia',
format_name: 'Skandia',
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 = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').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: 'skandia',
format_name: 'Skandia',
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,
},
}
},
}
+9
View File
@@ -11,12 +11,17 @@ import { nordeaFormat } from './formats/nordea'
import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken'
import { lansforsakringarFormat } from './formats/lansforsakringar'
import { icaBankenFormat } from './formats/ica-banken'
import { skandiaFormat } from './formats/skandia'
import { lunarFormat } from './formats/lunar'
import { camt053Format } from './formats/camt053'
import { genericCSVFormat } from './formats/generic-csv'
/**
* Ordered list of format detectors.
* camt.053 first (XML detection is unambiguous), then bank-specific CSV formats.
* New bank formats go after existing ones but before generic_csv.
* Generic CSV is last — it never auto-detects (manual fallback only).
*/
const FORMATS: BankFileFormat[] = [
@@ -25,6 +30,10 @@ const FORMATS: BankFileFormat[] = [
sebFormat,
swedbankFormat,
handelsbankenFormat,
lansforsakringarFormat,
icaBankenFormat,
skandiaFormat,
lunarFormat,
genericCSVFormat,
]
+4
View File
@@ -47,6 +47,10 @@ export type BankFileFormatId =
| 'seb'
| 'swedbank'
| 'handelsbanken'
| 'lansforsakringar'
| 'ica_banken'
| 'skandia'
| 'lunar'
| 'generic_csv'
| 'camt053'