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:
co-authored by
Claude Opus 4.6
parent
431f385b4b
commit
bbb82866ee
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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'))
|
||||
)
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export type BankFileFormatId =
|
||||
| 'seb'
|
||||
| 'swedbank'
|
||||
| 'handelsbanken'
|
||||
| 'lansforsakringar'
|
||||
| 'ica_banken'
|
||||
| 'skandia'
|
||||
| 'lunar'
|
||||
| 'generic_csv'
|
||||
| 'camt053'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user