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>
169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
/**
|
|
* 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,
|
|
},
|
|
}
|
|
},
|
|
}
|