feat: import system improvements, INK2 fix, and Swedish text corrections (#10)

- SIE parser: Windows-1252 and CP437 encoding detection and decoding
- Bank file parser: add Nordea Business (Företag) CSV format
- Bank file parser: improve format detection for SEB, Länsförsäkringar, generic CSV
- INK2 engine: calculate årets resultat (7222) from income statement for open fiscal years
- Dashboard: parallel Supabase queries, simplified dashboard page
- Fix Swedish characters (å, ä, ö) in BAS data descriptions, validation messages, AI consent disclosures
- Import wizard UI improvements across all steps
- Migration: add 'bas_range' match type to sie_account_mappings constraint
- Extensive new tests for SIE parser encoding and bank file parser

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-11 18:05:37 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a1ca96453c
commit e8fb84b4fd
39 changed files with 1129 additions and 425 deletions
+1 -1
View File
@@ -1977,7 +1977,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '16',
account_type: 'asset',
normal_balance: 'debit',
description: 'Fordran på Skatteverket nar ingående moms överstiger utgående moms.',
description: 'Fordran på Skatteverket när ingående moms överstiger utgående moms.',
sru_code: '7212',
k2_excluded: false,
},
@@ -602,7 +602,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'credit',
description: 'Foregaende ars resultat innan det fordelats till balanserat resultat eller utdelning.',
description: 'Föregående års resultat innan det fördelats till balanserat resultat eller utdelning.',
sru_code: '7221',
k2_excluded: false,
},
@@ -1779,7 +1779,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '78',
account_type: 'expense',
normal_balance: 'debit',
description: 'Planmässig avskrivning av goodwill, patent och andra immateriella tillgangar.',
description: 'Planmässig avskrivning av goodwill, patent och andra immateriella tillgångar.',
sru_code: '7325',
k2_excluded: false,
},
+4 -4
View File
@@ -35,11 +35,11 @@ describe('validateSwedishPersonalNumber', () => {
})
it('rejects invalid month', () => {
expect(validateSwedishPersonalNumber('199913011234')).toBe('Ogiltig manad')
expect(validateSwedishPersonalNumber('199913011234')).toBe('Ogiltig månad')
})
it('rejects month 00', () => {
expect(validateSwedishPersonalNumber('199900011234')).toBe('Ogiltig manad')
expect(validateSwedishPersonalNumber('199900011234')).toBe('Ogiltig månad')
})
it('rejects invalid day', () => {
@@ -51,12 +51,12 @@ describe('validateSwedishPersonalNumber', () => {
})
it('rejects year before 1900', () => {
expect(validateSwedishPersonalNumber('189901011234')).toBe('Ogiltigt ar')
expect(validateSwedishPersonalNumber('189901011234')).toBe('Ogiltigt år')
})
it('rejects future year', () => {
const futureYear = new Date().getFullYear() + 1
expect(validateSwedishPersonalNumber(`${futureYear}01011234`)).toBe('Ogiltigt ar')
expect(validateSwedishPersonalNumber(`${futureYear}01011234`)).toBe('Ogiltigt år')
})
it('rejects invalid Luhn checksum', () => {
+5 -5
View File
@@ -33,18 +33,18 @@ export const AI_DATA_DISCLOSURES: Record<AiExtensionId, {
}> = {
'receipt-ocr': {
provider: 'Anthropic',
dataTypes: ['Kvittobilder', 'Extraherad text fran kvitton'],
purpose: 'Automatisk avlasning och kategorisering av kvitton',
dataTypes: ['Kvittobilder', 'Extraherad text från kvitton'],
purpose: 'Automatisk avläsning och kategorisering av kvitton',
},
'ai-categorization': {
provider: 'Anthropic, OpenAI',
dataTypes: ['Transaktionsbeskrivningar', 'Belopp', 'Bokformallar'],
dataTypes: ['Transaktionsbeskrivningar', 'Belopp', 'Bokförmallar'],
purpose: 'Automatisk kategorisering av banktransaktioner',
},
'ai-chat': {
provider: 'Anthropic, OpenAI',
dataTypes: ['Chattmeddelanden', 'Bokforingsdata som refereras i chatten'],
purpose: 'AI-assistent for bokforingsfragor',
dataTypes: ['Chattmeddelanden', 'Bokföringsdata som refereras i chatten'],
purpose: 'AI-assistent för bokföringsfrågor',
},
}
+2 -2
View File
@@ -15,9 +15,9 @@ export function validateSwedishPersonalNumber(pnr: string): string | null {
const month = parseInt(cleaned.slice(4, 6))
const day = parseInt(cleaned.slice(6, 8))
if (month < 1 || month > 12) return 'Ogiltig manad'
if (month < 1 || month > 12) return 'Ogiltig månad'
if (day < 1 || day > 31) return 'Ogiltig dag'
if (year < 1900 || year > new Date().getFullYear()) return 'Ogiltigt ar'
if (year < 1900 || year > new Date().getFullYear()) return 'Ogiltigt år'
// Luhn check on the last 10 digits (YYMMDDXXXX)
const luhnDigits = cleaned.slice(2)
+230 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseSIEFile, validateSIEFile } from '../sie-parser'
import { parseSIEFile, validateSIEFile, detectEncoding, decodeBuffer } from '../sie-parser'
// --- SIE content fixtures ---
@@ -433,3 +433,232 @@ describe('validateSIEFile', () => {
expect(ibWarning).toBeUndefined()
})
})
// --- Fix 2: Windows-1252 encoding detection and decoding ---
describe('detectEncoding — Windows-1252', () => {
it('detects Windows-1252 when Swedish chars use Win-1252 byte values', () => {
// Build a buffer with Windows-1252 encoded Swedish text: "#FNAMN Företag"
// å=0xE5, ä=0xE4, ö=0xF6 in Windows-1252 (NOT in CP437 map)
const text = '#FNAMN F'
const encoder = new TextEncoder()
const prefix = encoder.encode(text)
// Add ö (0xF6) r (0x72) e (0x65) t (0x74) a (0x61) g (0x67)
const buf = new Uint8Array(prefix.length + 6)
buf.set(prefix)
buf[prefix.length] = 0xf6 // ö in Windows-1252
buf[prefix.length + 1] = 0x72 // r
buf[prefix.length + 2] = 0x65 // e
buf[prefix.length + 3] = 0x74 // t
buf[prefix.length + 4] = 0x61 // a
buf[prefix.length + 5] = 0x67 // g
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('windows1252')
})
it('detects UTF-8 BOM even when Windows-1252 bytes are present', () => {
const buf = new Uint8Array([0xef, 0xbb, 0xbf, 0x23, 0xe5]) // BOM + # + å-win1252
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('utf8')
})
it('detects CP437 when CP437-specific bytes are present', () => {
// 0x86 = å in CP437 (not in Win-1252 Swedish set)
const buf = new Uint8Array([0x23, 0x86, 0x86, 0x86])
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('cp437')
})
it('detects UTF-8 multi-byte Swedish chars', () => {
// å in UTF-8 = C3 A5, ä = C3 A4
const buf = new Uint8Array([0x23, 0xc3, 0xa5, 0xc3, 0xa4])
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('utf8')
})
})
describe('decodeBuffer — Windows-1252', () => {
it('decodes Windows-1252 Swedish characters correctly', () => {
// "åäö" in Windows-1252 = [0xE5, 0xE4, 0xF6]
const buf = new Uint8Array([0xe5, 0xe4, 0xf6])
const result = decodeBuffer(buf.buffer, 'windows1252')
expect(result).toBe('åäö')
})
it('decodes Windows-1252 uppercase Swedish characters correctly', () => {
// "ÅÄÖ" in Windows-1252 = [0xC5, 0xC4, 0xD6]
const buf = new Uint8Array([0xc5, 0xc4, 0xd6])
const result = decodeBuffer(buf.buffer, 'windows1252')
expect(result).toBe('ÅÄÖ')
})
it('decodes CP437 Swedish characters correctly', () => {
// å in CP437 = 0x86, ä = 0x84, ö = 0x94
const buf = new Uint8Array([0x86, 0x84, 0x94])
const result = decodeBuffer(buf.buffer, 'cp437')
expect(result).toBe('åäö')
})
})
// --- Fix 3: Invalid date rejection ---
describe('parseSIEFile — invalid date handling', () => {
it('rejects Feb 30 (auto-rolled dates) in #RAR', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20240230', // Feb 30 is invalid
].join('\n')
const result = parseSIEFile(content)
// RAR with invalid end date should produce a warning and not add the fiscal year
expect(result.header.fiscalYears).toHaveLength(0)
expect(result.issues.some((i) => i.message.includes('Invalid fiscal year dates'))).toBe(true)
})
it('rejects Apr 31 in #VER date', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1930 "Företagskonto"',
'#KONTO 3001 "Försäljning"',
'#VER A 1 20240431 "Invalid date"', // Apr 31 is invalid
'{',
'#TRANS 1930 {} 1000.00',
'#TRANS 3001 {} -1000.00',
'}',
].join('\n')
const result = parseSIEFile(content)
// Voucher should not be created because date is invalid
expect(result.vouchers).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'error' && i.message.includes('Invalid voucher definition'))).toBe(true)
})
it('accepts valid leap year date Feb 29', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1930 "Konto"',
'#KONTO 3001 "Konto"',
'#VER A 1 20240229 "Leap year"',
'{',
'#TRANS 1930 {} 1000.00',
'#TRANS 3001 {} -1000.00',
'}',
].join('\n')
const result = parseSIEFile(content)
expect(result.vouchers).toHaveLength(1)
expect(result.vouchers[0].date).toEqual(new Date(2024, 1, 29))
})
it('rejects Feb 29 in non-leap year', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20230101 20231231',
'#KONTO 1930 "Konto"',
'#VER A 1 20230229 "Not a leap year"',
'{',
'#TRANS 1930 {} 1000.00',
'}',
].join('\n')
const result = parseSIEFile(content)
expect(result.vouchers).toHaveLength(0)
expect(result.issues.some((i) => i.message.includes('Invalid voucher definition'))).toBe(true)
})
})
// --- Fix 4: Missing amount handling ---
describe('parseSIEFile — missing amount handling', () => {
it('skips #IB with missing amount and adds warning', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#IB 0 1510', // No amount field
].join('\n')
const result = parseSIEFile(content)
expect(result.openingBalances).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #IB'))).toBe(true)
})
it('skips #UB with missing amount and adds warning', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#UB 0 1510', // No amount field
].join('\n')
const result = parseSIEFile(content)
expect(result.closingBalances).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #UB'))).toBe(true)
})
it('skips #RES with missing amount and adds warning', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 3001 "Försäljning"',
'#RES 0 3001', // No amount field
].join('\n')
const result = parseSIEFile(content)
expect(result.resultBalances).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #RES'))).toBe(true)
})
it('skips #TRANS with missing amount and adds warning', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1930 "Företagskonto"',
'#VER A 1 20240115 "Test"',
'{',
'#TRANS 1930 {}', // No amount field
'}',
].join('\n')
const result = parseSIEFile(content)
expect(result.vouchers).toHaveLength(1)
expect(result.vouchers[0].lines).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #TRANS'))).toBe(true)
})
it('still parses valid #IB lines alongside missing-amount ones', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#KONTO 1930 "Företagskonto"',
'#IB 0 1510', // Missing → skipped
'#IB 0 1930 100000.00', // Valid → kept
].join('\n')
const result = parseSIEFile(content)
expect(result.openingBalances).toHaveLength(1)
expect(result.openingBalances[0].account).toBe('1930')
expect(result.openingBalances[0].amount).toBe(100000)
})
})
@@ -8,6 +8,8 @@
import { detectFileFormat, parseBankFile, generateExternalId, generateFileHash, getFormat, getAllFormats } from '../parser'
import type { ParsedBankTransaction, BankFileFormatId } from '../types'
import { parseGenericCSV } from '../formats/generic-csv'
import { parseCSVLine } from '../formats/nordea'
// ---------------------------------------------------------------------------
// Test data — realistic CSV/XML content for each Swedish bank format
@@ -171,6 +173,22 @@ const EMPTY_FILE = ''
const HEADER_ONLY_NORDEA = 'Datum,Transaktion,Kategori,Belopp,Saldo\n'
const NORDEA_BUSINESS_CSV = [
'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta',
'2024-01-15;-99,00;;SPOTIFY AB;SPOTIFY AB;Kortköp;12 345,67;SEK',
'2024-01-14;-432,50;;ICA MAXI;ICA MAXI LINDHAGEN;Kortköp;12 444,67;SEK',
'2024-01-13;25 000,00;ARBETSGIVAREN AB;;ARBETSGIVAREN AB;Löneutbetalning;12 877,17;SEK',
].join('\n')
const NORDEA_BUSINESS_CSV_SWEDISH_CHARS = [
'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta',
'2024-03-01;-85,00;;GÖTEBORGS HAMNCAFÉ;GÖTEBORGS HAMNCAFÉ;Kortköp;5 000,00;SEK',
'2024-03-02;-249,00;;ÅHLENS CITY;ÅHLENS CITY;Kortköp;4 751,00;SEK',
'2024-03-03;1 200,00;ÄRLA GÅRD AB;;ÄRLA GÅRD AB;Betalning;5 951,00;SEK',
].join('\n')
const HEADER_ONLY_NORDEA_BUSINESS = 'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta\n'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -182,6 +200,18 @@ describe('detectFileFormat', () => {
expect(format!.id).toBe('nordea')
})
it('detects Nordea Business CSV from semicolon-delimited header with bokföringsdag and rubrik', () => {
const format = detectFileFormat(NORDEA_BUSINESS_CSV, 'PLUSGIROKONTO FTG 212 68 87-5.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea_business')
})
it('does not confuse Nordea Business with SEB (SEB has valutadag)', () => {
const format = detectFileFormat(NORDEA_BUSINESS_CSV, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea_business')
})
it('detects SEB CSV from semicolon-delimited header with bokföringsdag', () => {
const format = detectFileFormat(SEB_CSV, 'kontoutdrag.csv')
expect(format).not.toBeNull()
@@ -370,6 +400,104 @@ describe('parseBankFile — Nordea format', () => {
})
})
describe('parseBankFile — Nordea Business format', () => {
it('parses semicolon-delimited CSV with correct columns', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
expect(result.format).toBe('nordea_business')
expect(result.format_name).toBe('Nordea Företag')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses negative amounts', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
const spotify = result.transactions[0]
expect(spotify.amount).toBe(-99)
expect(spotify.date).toBe('2024-01-15')
expect(spotify.currency).toBe('SEK')
})
it('correctly parses positive amounts with space thousands separator', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
const salary = result.transactions[2]
expect(salary.amount).toBe(25000)
expect(salary.date).toBe('2024-01-13')
})
it('builds description from Namn and Rubrik columns', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
const spotify = result.transactions[0]
expect(spotify.description).toBe('SPOTIFY AB — Kortköp')
const salary = result.transactions[2]
expect(salary.description).toBe('ARBETSGIVAREN AB — Löneutbetalning')
})
it('extracts counterparty from Mottagare (expense) or Avsändare (income)', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
// Expense: counterparty from Mottagare
expect(result.transactions[0].counterparty).toBe('SPOTIFY AB')
// Income: counterparty from Avsändare
expect(result.transactions[2].counterparty).toBe('ARBETSGIVAREN AB')
})
it('parses balance field', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
expect(result.transactions[0].balance).toBe(12345.67)
expect(result.transactions[2].balance).toBe(12877.17)
})
it('handles Swedish characters', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV_SWEDISH_CHARS, 'nordea_ftg.csv')
expect(result.transactions).toHaveLength(3)
expect(result.transactions[0].description).toContain('GÖTEBORGS HAMNCAFÉ')
expect(result.transactions[1].description).toContain('ÅHLENS CITY')
expect(result.transactions[2].description).toContain('ÄRLA GÅRD AB')
})
it('stores raw_line for each transaction', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
result.transactions.forEach((tx) => {
expect(tx.raw_line).toBeDefined()
expect(tx.raw_line!.length).toBeGreaterThan(0)
})
})
it('handles header-only file with no data rows', () => {
const result = parseBankFile(HEADER_ONLY_NORDEA_BUSINESS, 'nordea_ftg.csv')
expect(result.format).toBe('nordea_business')
expect(result.transactions).toHaveLength(0)
expect(result.date_from).toBeNull()
expect(result.date_to).toBeNull()
expect(result.stats.parsed_rows).toBe(0)
})
it('calculates correct date range', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
it('calculates correct income and expense stats', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
expect(result.stats.skipped_rows).toBe(0)
})
})
describe('parseBankFile — SEB format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
@@ -1265,3 +1393,154 @@ describe('edge cases and robustness', () => {
expect(result.transactions[0].amount).toBe(0)
})
})
// --- Fix 5: SEB duplicate condition removal ---
describe('SEB detection — no duplicate conditions', () => {
it('detects SEB with bokföringsdag header', () => {
const content = 'Bokföringsdag;Valutadag;Text;Belopp;Saldo\n2024-01-15;2024-01-15;Test;-100,00;5000,00'
const format = detectFileFormat(content, 'seb.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('seb')
})
it('detects SEB with bokforingsdatum header (no diacritics)', () => {
const content = 'Bokforingsdatum;Valutadag;Text;Belopp;Saldo\n2024-01-15;2024-01-15;Test;-100,00;5000,00'
const format = detectFileFormat(content, 'seb.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('seb')
})
})
// --- Fix 6: Länsförsäkringar false positive prevention ---
describe('Länsförsäkringar detection — false positive prevention', () => {
it('detects valid LF data rows with comma-decimal amounts', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('detects LF header-less data with two dates and comma-decimal amount', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV_NO_HEADER, 'lf.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('does not false-positive on generic semicolon CSV with two date columns but non-numeric 5th field', () => {
// This CSV has two date columns + 5 fields but the 5th field is not a number
const falsePositive = [
'"2024-01-15";"2024-01-15";"Typ";"Text";"not-a-number"',
].join('\n')
const format = detectFileFormat(falsePositive, 'generic.csv')
// Should NOT detect as Länsförsäkringar
expect(format?.id).not.toBe('lansforsakringar')
})
})
// --- Fix 7: Generic CSV column bounds checking ---
describe('parseGenericCSV — column bounds checking', () => {
it('skips rows with too few columns and adds warning', () => {
const content = [
'Date,Description,Amount',
'2024-01-15,Test,-100.00',
'2024-01-16,Short', // Only 2 columns, mapping needs column 2 (amount)
].join('\n')
const result = parseGenericCSV(content, {
date: 0,
description: 1,
amount: 2,
delimiter: ',',
decimal_separator: '.',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
})
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].description).toBe('Test')
expect(result.stats.skipped_rows).toBe(1)
expect(result.issues.some((i) => i.message.includes('columns but mapping requires'))).toBe(true)
})
it('skips rows when mapping index exceeds field count', () => {
const content = [
'A,B',
'val1,val2',
].join('\n')
const result = parseGenericCSV(content, {
date: 0,
description: 1,
amount: 5, // Column 5 does not exist (only 2 columns)
delimiter: ',',
decimal_separator: '.',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
})
expect(result.transactions).toHaveLength(0)
expect(result.stats.skipped_rows).toBe(1)
expect(result.issues).toHaveLength(1)
})
it('parses normally when all columns are within bounds', () => {
const content = [
'Date,Description,Amount',
'2024-01-15,SPOTIFY,-99.00',
'2024-01-16,SALARY,25000.00',
].join('\n')
const result = parseGenericCSV(content, {
date: 0,
description: 1,
amount: 2,
delimiter: ',',
decimal_separator: '.',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
})
expect(result.transactions).toHaveLength(2)
expect(result.stats.skipped_rows).toBe(0)
expect(result.issues).toHaveLength(0)
})
})
// --- Fix 8: parseCSVLine unclosed quote handling ---
describe('parseCSVLine — unclosed quote handling', () => {
it('handles normal quoted fields correctly', () => {
const fields = parseCSVLine('"hello","world"', ',')
expect(fields).toEqual(['hello', 'world'])
})
it('handles escaped quotes (doubled) inside fields', () => {
const fields = parseCSVLine('"he said ""hi""",other', ',')
expect(fields).toEqual(['he said "hi"', 'other'])
})
it('produces a result even with unclosed quotes instead of hanging', () => {
// Unclosed quote: the parser should not hang or crash
const fields = parseCSVLine('"unclosed,field2,field3', ',')
// With unclosed quote, everything after the opening quote is one field
// The important thing is it doesn't crash and returns something
expect(fields.length).toBeGreaterThan(0)
})
it('preserves data with unclosed quote rather than losing it', () => {
const fields = parseCSVLine('normal,"unclosed value', ',')
// Should have at least the first field and whatever was accumulated
expect(fields.length).toBe(2)
expect(fields[0]).toBe('normal')
// The unclosed quoted field should still contain the text
expect(fields[1]).toContain('unclosed value')
})
it('handles semicolon delimiter with quotes', () => {
const fields = parseCSVLine('"2024-01-15";"SPOTIFY AB";"-99,00"', ';')
expect(fields).toEqual(['2024-01-15', 'SPOTIFY AB', '-99,00'])
})
})
@@ -34,6 +34,18 @@ export function parseGenericCSV(
f.trim().replace(/^"|"$/g, '')
)
// Validate required column indices are within bounds
const maxRequired = Math.max(mapping.date, mapping.description, mapping.amount)
if (maxRequired >= fields.length) {
issues.push({
row: i + 1,
message: `Row has ${fields.length} columns but mapping requires column ${maxRequired + 1}`,
severity: 'warning',
})
skippedRows++
continue
}
const dateStr = fields[mapping.date]
const description = fields[mapping.description] || 'Unknown'
const amountStr = fields[mapping.amount]
@@ -27,10 +27,18 @@ 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.
*/
// Matches Swedish-format numbers like "-1 234,56", "1234,50", "-500,00"
const COMMA_NUMBER_RE = /^-?[\d\s]+,\d{1,2}$/
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])
const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
return (
fields.length >= 5 &&
DATE_RE.test(fields[0]) &&
DATE_RE.test(fields[1]) &&
COMMA_NUMBER_RE.test(fields[4].trim())
)
}
/**
@@ -0,0 +1,152 @@
/**
* Nordea Business CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Bokföringsdag, Belopp, Avsändare, Mottagare, Namn, Rubrik, Saldo, Valuta
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* This is the format used by Nordea Business / Internetbanken Företag
* (netbank.nordea.se), including Plusgiro and corporate accounts.
* It differs from the personal banking format which is comma-delimited.
*/
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 nordeaBusinessFormat: BankFileFormat = {
id: 'nordea_business',
name: 'Nordea Företag',
description: 'Nordea Företag CSV (Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// Nordea Business: semicolon-delimited with "bokföringsdag" and "rubrik"
// "rubrik" distinguishes from SEB (which has "valutadag"/"verifikationsnummer")
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') || firstLine.includes('bokforingsdag')) &&
(firstLine.includes('rubrik') || (firstLine.includes('avsändare') && firstLine.includes('mottagare')))
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header to find column indices dynamically
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex(
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdag')
)
const amountIdx = headers.findIndex((h) => h === 'belopp' || h.includes('belopp'))
const senderIdx = headers.findIndex((h) => h.includes('avsändare') || h.includes('avsandare'))
const receiverIdx = headers.findIndex((h) => h.includes('mottagare'))
const nameIdx = headers.findIndex((h) => h === 'namn')
const subjectIdx = headers.findIndex((h) => h === 'rubrik')
const balanceIdx = headers.findIndex((h) => h === 'saldo' || h.includes('saldo'))
const currencyIdx = headers.findIndex((h) => h === 'valuta' || h.includes('valuta'))
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (Bokföringsdag, Belopp)',
severity: 'error',
})
return {
format: 'nordea_business',
format_name: 'Nordea Företag',
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]?.trim()
const amountStr = fields[amountIdx]
if (!date || !amountStr) {
issues.push({ row: i + 1, message: 'Missing required fields', severity: 'warning' })
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
// Validate date format (YYYY-MM-DD)
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
// Build description from Namn + Rubrik (name is the counterparty, rubrik is the subject/memo)
const name = nameIdx >= 0 ? fields[nameIdx]?.trim() : ''
const subject = subjectIdx >= 0 ? fields[subjectIdx]?.trim() : ''
const description = [name, subject].filter(Boolean).join(' — ') || 'Unknown'
// Counterparty from Avsändare (incoming) or Mottagare (outgoing)
const sender = senderIdx >= 0 ? fields[senderIdx]?.trim() : null
const receiver = receiverIdx >= 0 ? fields[receiverIdx]?.trim() : null
const counterparty = (amount > 0 ? sender : receiver) || null
const balance = balanceIdx >= 0 && fields[balanceIdx] ? parseCommaDecimal(fields[balanceIdx]) : null
const currency = currencyIdx >= 0 && fields[currencyIdx] ? fields[currencyIdx].trim() : 'SEK'
transactions.push({
date,
description,
amount,
currency: currency || 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: counterparty || null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'nordea_business',
format_name: 'Nordea Företag',
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,
},
}
},
}
+2
View File
@@ -144,6 +144,8 @@ function parseCSVLine(line: string, delimiter: string): string[] {
}
}
// Push last field — if inQuotes is still true, the quote was unclosed.
// Treat the accumulated data as-is rather than silently merging fields.
fields.push(current)
return fields
}
+2 -3
View File
@@ -30,8 +30,7 @@ export const sebFormat: BankFileFormat = {
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') ||
firstLine.includes('bokforingsdatum') ||
firstLine.includes('bokföringsdag')) &&
firstLine.includes('bokforingsdatum')) &&
(firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer'))
)
},
@@ -50,7 +49,7 @@ export const sebFormat: BankFileFormat = {
// Find column indices dynamically
const dateIdx = headers.findIndex(
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdatum') || h.includes('bokföringsdag')
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdatum')
)
const descIdx = headers.findIndex(
(h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning')
+2
View File
@@ -8,6 +8,7 @@
import * as crypto from 'crypto'
import type { BankFileFormat, BankFileFormatId, BankFileParseResult, ParsedBankTransaction } from './types'
import { nordeaFormat } from './formats/nordea'
import { nordeaBusinessFormat } from './formats/nordea-business'
import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken'
@@ -27,6 +28,7 @@ import { genericCSVFormat } from './formats/generic-csv'
const FORMATS: BankFileFormat[] = [
camt053Format,
nordeaFormat,
nordeaBusinessFormat,
sebFormat,
swedbankFormat,
handelsbankenFormat,
+1
View File
@@ -44,6 +44,7 @@ export interface BankFileParseIssue {
/** Supported bank file format identifiers */
export type BankFileFormatId =
| 'nordea'
| 'nordea_business'
| 'seb'
| 'swedbank'
| 'handelsbanken'
+65 -6
View File
@@ -41,6 +41,17 @@ const CP437_MAP: Record<number, string> = {
0x9b: 'ø', // ø
}
// Windows-1252 bytes for Swedish characters (superset of ISO-8859-1)
// These bytes are NOT in the CP437 map, so they need separate detection.
const WIN1252_SWEDISH_BYTES = new Set([
0xe5, // å
0xe4, // ä
0xf6, // ö
0xc5, // Å
0xc4, // Ä
0xd6, // Ö
])
/**
* Detect the encoding of a SIE file by looking for Swedish characters
*/
@@ -52,10 +63,11 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
return 'utf8'
}
// Look for CP437 Swedish characters in first 1000 bytes
// Look for encoding-specific Swedish characters in first 1000 bytes
const sampleSize = Math.min(bytes.length, 1000)
let cp437Count = 0
let utf8Count = 0
let win1252Count = 0
for (let i = 0; i < sampleSize; i++) {
const byte = bytes[i]
@@ -65,6 +77,11 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
cp437Count++
}
// Check for Windows-1252 Swedish characters
if (WIN1252_SWEDISH_BYTES.has(byte)) {
win1252Count++
}
// Check for UTF-8 multi-byte sequences for Swedish chars
// Ä = C3 84, Å = C3 85, Ö = C3 96, ä = C3 A4, å = C3 A5, ö = C3 B6
if (byte === 0xc3 && i + 1 < sampleSize) {
@@ -75,7 +92,10 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
}
}
return utf8Count > cp437Count ? 'utf8' : 'cp437'
if (utf8Count > cp437Count && utf8Count > win1252Count) return 'utf8'
if (cp437Count > win1252Count) return 'cp437'
if (win1252Count > 0) return 'windows1252'
return 'cp437'
}
/**
@@ -87,6 +107,11 @@ export function decodeBuffer(buffer: ArrayBuffer, encoding: SIEEncoding): string
return decoder.decode(buffer)
}
if (encoding === 'windows1252') {
const decoder = new TextDecoder('windows-1252')
return decoder.decode(buffer)
}
// CP437 decoding
const bytes = new Uint8Array(buffer)
let result = ''
@@ -123,7 +148,14 @@ function parseSIEDate(dateStr: string): Date | null {
return null
}
return new Date(year, month, day)
const date = new Date(year, month, day)
// Reject invalid dates that auto-roll (e.g. Feb 30 → Mar 2)
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
return null
}
return date
}
/**
@@ -398,7 +430,14 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// #IB yearIndex accountNumber amount [quantity]
const yearIndex = parseInt(fields[1], 10)
const account = fields[2]
const amount = parseNumberField(fields[3])
const amountStr = fields[3]
if (!amountStr || amountStr.trim() === '') {
addIssue(issues, 'warning', lineNum, 'Missing amount in #IB, skipping line', tag)
break
}
const amount = parseNumberField(amountStr)
const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
if (account) {
@@ -411,7 +450,14 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// #UB yearIndex accountNumber amount [quantity]
const yearIndex = parseInt(fields[1], 10)
const account = fields[2]
const amount = parseNumberField(fields[3])
const amountStr = fields[3]
if (!amountStr || amountStr.trim() === '') {
addIssue(issues, 'warning', lineNum, 'Missing amount in #UB, skipping line', tag)
break
}
const amount = parseNumberField(amountStr)
const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
if (account) {
@@ -424,7 +470,14 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// #RES yearIndex accountNumber amount [quantity]
const yearIndex = parseInt(fields[1], 10)
const account = fields[2]
const amount = parseNumberField(fields[3])
const amountStr = fields[3]
if (!amountStr || amountStr.trim() === '') {
addIssue(issues, 'warning', lineNum, 'Missing amount in #RES, skipping line', tag)
break
}
const amount = parseNumberField(amountStr)
const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
if (account) {
@@ -479,6 +532,12 @@ export function parseSIEFile(content: string): ParsedSIEFile {
fieldIndex++
}
const transAmountStr = fields[fieldIndex]
if (!transAmountStr || transAmountStr.trim() === '') {
addIssue(issues, 'warning', lineNum, 'Missing amount in #TRANS, skipping line', tag)
break
}
const amount = parseNumberField(fields[fieldIndex++])
const transLine: SIETransactionLine = {
+1 -1
View File
@@ -9,7 +9,7 @@
export type SIEType = 1 | 2 | 3 | 4
// Encoding types supported by SIE files
export type SIEEncoding = 'cp437' | 'utf8'
export type SIEEncoding = 'cp437' | 'utf8' | 'windows1252'
// Import status
export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed'
+17 -3
View File
@@ -322,9 +322,6 @@ export async function generateINK2Declaration(
const totalAssets = rutor['7201'] + rutor['7202'] + rutor['7203'] +
rutor['7210'] + rutor['7211'] + rutor['7212']
const totalEquityLiabilities = rutor['7220'] + rutor['7221'] + rutor['7222'] +
rutor['7230'] + rutor['7231']
// Operating result = revenue - operating costs
const operatingResult = rutor['7310'] -
rutor['7320'] - rutor['7330'] - rutor['7340'] -
@@ -333,6 +330,23 @@ export async function generateINK2Declaration(
// Result after financial items
const resultAfterFinancial = operatingResult + rutor['7370'] + rutor['7380']
// Årets resultat (7222): During an open fiscal year, account 2099 has no balance —
// the profit only exists as the net of income statement accounts (class 3-8).
// After year-end closing, 2099 has the balance and income accounts are zeroed.
// Adding resultAfterFinancial handles both cases correctly (0 + profit, or profit + 0).
rutor['7222'] += roundToKrona(resultAfterFinancial)
breakdown['7222'].total = rutor['7222']
if (resultAfterFinancial !== 0) {
breakdown['7222'].accounts.push({
accountNumber: 'calc',
accountName: 'Beräknat resultat från resultaträkningen',
amount: roundToKrona(resultAfterFinancial),
})
}
const totalEquityLiabilities = rutor['7220'] + rutor['7221'] + rutor['7222'] +
rutor['7230'] + rutor['7231']
// Add warnings
if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')