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:
co-authored by
Claude Opus 4.6
parent
a1ca96453c
commit
e8fb84b4fd
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface BankFileParseIssue {
|
||||
/** Supported bank file format identifiers */
|
||||
export type BankFileFormatId =
|
||||
| 'nordea'
|
||||
| 'nordea_business'
|
||||
| 'seb'
|
||||
| 'swedbank'
|
||||
| 'handelsbanken'
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user