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
+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 = {