644 lines
17 KiB
TypeScript
644 lines
17 KiB
TypeScript
/**
|
|
* SIE File Parser
|
|
*
|
|
* Parses SIE (Standard Import Export) files, the Swedish standard format
|
|
* for accounting data exchange. Supports SIE1-SIE4 formats.
|
|
*
|
|
* SIE4 is the most complete format with full transaction history.
|
|
* SIE1 contains only year-end balances.
|
|
*
|
|
* Reference: https://sie.se/format/
|
|
*/
|
|
|
|
import type {
|
|
SIEType,
|
|
SIEEncoding,
|
|
SIEHeader,
|
|
SIEAccount,
|
|
SIEBalance,
|
|
SIEVoucher,
|
|
SIETransactionLine,
|
|
ParsedSIEFile,
|
|
ParseIssue,
|
|
ParseIssueSeverity,
|
|
ValidationResult,
|
|
} from './types'
|
|
|
|
// CP437 to UTF-8 mapping for Swedish characters
|
|
// CP437 was the standard encoding for DOS/early Windows
|
|
const CP437_MAP: Record<number, string> = {
|
|
0x8e: 'Ä', // Ä
|
|
0x8f: 'Å', // Å
|
|
0x99: 'Ö', // Ö
|
|
0x84: 'ä', // ä
|
|
0x86: 'å', // å
|
|
0x94: 'ö', // ö
|
|
0x81: 'ü', // ü
|
|
0x9a: 'Ü', // Ü
|
|
0x92: 'Æ', // Æ (not common but in CP437)
|
|
0x91: 'æ', // æ
|
|
0x9d: 'Ø', // Ø (Norwegian)
|
|
0x9b: 'ø', // ø
|
|
}
|
|
|
|
/**
|
|
* Detect the encoding of a SIE file by looking for Swedish characters
|
|
*/
|
|
export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
|
|
const bytes = new Uint8Array(buffer)
|
|
|
|
// Check for UTF-8 BOM
|
|
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
return 'utf8'
|
|
}
|
|
|
|
// Look for CP437 Swedish characters in first 1000 bytes
|
|
const sampleSize = Math.min(bytes.length, 1000)
|
|
let cp437Count = 0
|
|
let utf8Count = 0
|
|
|
|
for (let i = 0; i < sampleSize; i++) {
|
|
const byte = bytes[i]
|
|
|
|
// Check for CP437 Swedish characters
|
|
if (CP437_MAP[byte]) {
|
|
cp437Count++
|
|
}
|
|
|
|
// 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) {
|
|
const nextByte = bytes[i + 1]
|
|
if ([0x84, 0x85, 0x96, 0xa4, 0xa5, 0xb6].includes(nextByte)) {
|
|
utf8Count++
|
|
}
|
|
}
|
|
}
|
|
|
|
return utf8Count > cp437Count ? 'utf8' : 'cp437'
|
|
}
|
|
|
|
/**
|
|
* Decode a buffer to string using the specified encoding
|
|
*/
|
|
export function decodeBuffer(buffer: ArrayBuffer, encoding: SIEEncoding): string {
|
|
if (encoding === 'utf8') {
|
|
const decoder = new TextDecoder('utf-8')
|
|
return decoder.decode(buffer)
|
|
}
|
|
|
|
// CP437 decoding
|
|
const bytes = new Uint8Array(buffer)
|
|
let result = ''
|
|
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
const byte = bytes[i]
|
|
|
|
if (CP437_MAP[byte]) {
|
|
result += CP437_MAP[byte]
|
|
} else if (byte < 128) {
|
|
result += String.fromCharCode(byte)
|
|
} else {
|
|
// For other high bytes, try to preserve as-is
|
|
result += String.fromCharCode(byte)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Parse a date from SIE format (YYYYMMDD)
|
|
*/
|
|
function parseSIEDate(dateStr: string): Date | null {
|
|
if (!dateStr || dateStr.length !== 8) {
|
|
return null
|
|
}
|
|
|
|
const year = parseInt(dateStr.substring(0, 4), 10)
|
|
const month = parseInt(dateStr.substring(4, 6), 10) - 1
|
|
const day = parseInt(dateStr.substring(6, 8), 10)
|
|
|
|
if (isNaN(year) || isNaN(month) || isNaN(day)) {
|
|
return null
|
|
}
|
|
|
|
return new Date(year, month, day)
|
|
}
|
|
|
|
/**
|
|
* Parse a quoted string field from SIE
|
|
* Handles: "value" or value
|
|
*/
|
|
function parseStringField(field: string): string {
|
|
if (!field) return ''
|
|
|
|
// Remove surrounding quotes if present
|
|
if (field.startsWith('"') && field.endsWith('"')) {
|
|
return field.slice(1, -1).replace(/\\"/g, '"')
|
|
}
|
|
|
|
return field
|
|
}
|
|
|
|
/**
|
|
* Parse a numeric field from SIE
|
|
*/
|
|
function parseNumberField(field: string): number {
|
|
if (!field) return 0
|
|
// SIE uses dot as decimal separator
|
|
return parseFloat(field.replace(',', '.')) || 0
|
|
}
|
|
|
|
/**
|
|
* Split a SIE line into fields, respecting quoted strings and braced object lists
|
|
*/
|
|
function splitSIELine(line: string): string[] {
|
|
const fields: string[] = []
|
|
let current = ''
|
|
let inQuotes = false
|
|
let braceDepth = 0
|
|
let escaped = false
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const char = line[i]
|
|
|
|
if (escaped) {
|
|
current += char
|
|
escaped = false
|
|
continue
|
|
}
|
|
|
|
if (char === '\\') {
|
|
escaped = true
|
|
current += char
|
|
continue
|
|
}
|
|
|
|
if (char === '"' && braceDepth === 0) {
|
|
inQuotes = !inQuotes
|
|
current += char
|
|
continue
|
|
}
|
|
|
|
// Track brace depth for object lists like {1 "ProjectA"}
|
|
if (char === '{' && !inQuotes) {
|
|
braceDepth++
|
|
current += char
|
|
continue
|
|
}
|
|
|
|
if (char === '}' && !inQuotes) {
|
|
braceDepth = Math.max(0, braceDepth - 1)
|
|
current += char
|
|
continue
|
|
}
|
|
|
|
if (char === ' ' && !inQuotes && braceDepth === 0) {
|
|
if (current) {
|
|
fields.push(current)
|
|
current = ''
|
|
}
|
|
continue
|
|
}
|
|
|
|
current += char
|
|
}
|
|
|
|
if (current) {
|
|
fields.push(current)
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
/**
|
|
* Add an issue to the issues list
|
|
*/
|
|
function addIssue(
|
|
issues: ParseIssue[],
|
|
severity: ParseIssueSeverity,
|
|
line: number,
|
|
message: string,
|
|
tag?: string
|
|
): void {
|
|
issues.push({ severity, line, message, tag })
|
|
}
|
|
|
|
/**
|
|
* Parse a SIE file content string
|
|
*/
|
|
export function parseSIEFile(content: string): ParsedSIEFile {
|
|
const lines = content.split(/\r?\n/)
|
|
const issues: ParseIssue[] = []
|
|
|
|
// Initialize header with defaults
|
|
const header: SIEHeader = {
|
|
sieType: 4,
|
|
program: null,
|
|
programVersion: null,
|
|
generatedDate: null,
|
|
format: null,
|
|
companyName: null,
|
|
orgNumber: null,
|
|
address: null,
|
|
fiscalYears: [],
|
|
currency: 'SEK',
|
|
}
|
|
|
|
const accounts: SIEAccount[] = []
|
|
const openingBalances: SIEBalance[] = []
|
|
const closingBalances: SIEBalance[] = []
|
|
const resultBalances: SIEBalance[] = []
|
|
const vouchers: SIEVoucher[] = []
|
|
|
|
// Track current voucher being parsed (inside #VER { ... })
|
|
let currentVoucher: SIEVoucher | null = null
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const lineNum = i + 1
|
|
const line = lines[i].trim()
|
|
|
|
// Skip empty lines
|
|
if (!line) continue
|
|
|
|
// Handle voucher block end
|
|
if (line === '}') {
|
|
if (currentVoucher) {
|
|
// Validate voucher balance
|
|
const total = currentVoucher.lines.reduce((sum, l) => sum + l.amount, 0)
|
|
if (Math.abs(total) > 0.01) {
|
|
addIssue(
|
|
issues,
|
|
'error',
|
|
lineNum,
|
|
`Voucher ${currentVoucher.series}${currentVoucher.number} is not balanced (diff: ${total.toFixed(2)})`,
|
|
'VER'
|
|
)
|
|
}
|
|
vouchers.push(currentVoucher)
|
|
currentVoucher = null
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Handle voucher block start
|
|
if (line === '{') {
|
|
continue
|
|
}
|
|
|
|
// Skip lines that don't start with #
|
|
if (!line.startsWith('#')) {
|
|
continue
|
|
}
|
|
|
|
// Parse the tag and fields
|
|
const fields = splitSIELine(line)
|
|
const tag = fields[0].substring(1).toUpperCase()
|
|
|
|
try {
|
|
switch (tag) {
|
|
case 'FLAGGA':
|
|
// Flag for file handling - ignore
|
|
break
|
|
|
|
case 'FORMAT':
|
|
header.format = parseStringField(fields[1])
|
|
break
|
|
|
|
case 'SIETYP':
|
|
header.sieType = parseInt(fields[1], 10) as SIEType
|
|
if (![1, 2, 3, 4].includes(header.sieType)) {
|
|
addIssue(issues, 'warning', lineNum, `Unknown SIE type: ${fields[1]}`, tag)
|
|
header.sieType = 4
|
|
}
|
|
break
|
|
|
|
case 'PROGRAM':
|
|
header.program = parseStringField(fields[1])
|
|
header.programVersion = parseStringField(fields[2])
|
|
break
|
|
|
|
case 'GEN':
|
|
if (fields[1]) {
|
|
header.generatedDate = parseSIEDate(fields[1])
|
|
}
|
|
break
|
|
|
|
case 'ORGNR':
|
|
header.orgNumber = parseStringField(fields[1])
|
|
break
|
|
|
|
case 'FNAMN':
|
|
header.companyName = parseStringField(fields[1])
|
|
break
|
|
|
|
case 'ADRESS':
|
|
header.address = [fields[1], fields[2], fields[3], fields[4]]
|
|
.filter(Boolean)
|
|
.map(parseStringField)
|
|
.join(', ')
|
|
break
|
|
|
|
case 'VALUTA':
|
|
header.currency = parseStringField(fields[1]) || 'SEK'
|
|
break
|
|
|
|
case 'RAR': {
|
|
// #RAR yearIndex start end
|
|
const yearIndex = parseInt(fields[1], 10)
|
|
const start = parseSIEDate(fields[2])
|
|
const end = parseSIEDate(fields[3])
|
|
|
|
if (start && end) {
|
|
header.fiscalYears.push({ yearIndex, start, end })
|
|
} else {
|
|
addIssue(issues, 'warning', lineNum, 'Invalid fiscal year dates', tag)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'KONTO': {
|
|
// #KONTO number "name"
|
|
const number = fields[1]
|
|
const name = parseStringField(fields[2])
|
|
|
|
if (number && name) {
|
|
accounts.push({ number, name })
|
|
} else {
|
|
addIssue(issues, 'warning', lineNum, 'Invalid account definition', tag)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'SRU': {
|
|
// #SRU accountNumber sruCode
|
|
const accountNum = fields[1]
|
|
const sruCode = fields[2]
|
|
const account = accounts.find((a) => a.number === accountNum)
|
|
if (account) {
|
|
account.sruCode = sruCode
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'KTYP': {
|
|
// #KTYP accountNumber type
|
|
const accountNum = fields[1]
|
|
const accountType = fields[2]
|
|
const account = accounts.find((a) => a.number === accountNum)
|
|
if (account) {
|
|
account.accountType = accountType
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'IB': {
|
|
// #IB yearIndex accountNumber amount [quantity]
|
|
const yearIndex = parseInt(fields[1], 10)
|
|
const account = fields[2]
|
|
const amount = parseNumberField(fields[3])
|
|
const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
|
|
|
|
if (account) {
|
|
openingBalances.push({ yearIndex, account, amount, quantity })
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'UB': {
|
|
// #UB yearIndex accountNumber amount [quantity]
|
|
const yearIndex = parseInt(fields[1], 10)
|
|
const account = fields[2]
|
|
const amount = parseNumberField(fields[3])
|
|
const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
|
|
|
|
if (account) {
|
|
closingBalances.push({ yearIndex, account, amount, quantity })
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'RES': {
|
|
// #RES yearIndex accountNumber amount [quantity]
|
|
const yearIndex = parseInt(fields[1], 10)
|
|
const account = fields[2]
|
|
const amount = parseNumberField(fields[3])
|
|
const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
|
|
|
|
if (account) {
|
|
resultBalances.push({ yearIndex, account, amount, quantity })
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'VER': {
|
|
// #VER series number date "description" [regdate] [signature]
|
|
const series = parseStringField(fields[1])
|
|
const number = parseInt(fields[2], 10)
|
|
const date = parseSIEDate(fields[3])
|
|
const description = parseStringField(fields[4])
|
|
|
|
if (series && !isNaN(number) && date) {
|
|
currentVoucher = {
|
|
series,
|
|
number,
|
|
date,
|
|
description: description || '',
|
|
lines: [],
|
|
}
|
|
|
|
// Optional registration date and signature
|
|
if (fields[5]) {
|
|
currentVoucher.registrationDate = parseSIEDate(fields[5]) || undefined
|
|
}
|
|
if (fields[6]) {
|
|
currentVoucher.signature = parseStringField(fields[6])
|
|
}
|
|
} else {
|
|
addIssue(issues, 'error', lineNum, 'Invalid voucher definition', tag)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'TRANS': {
|
|
// #TRANS accountNumber {objectList} amount [date] [description] [quantity] [signature]
|
|
if (!currentVoucher) {
|
|
addIssue(issues, 'error', lineNum, 'TRANS outside of VER block', tag)
|
|
break
|
|
}
|
|
|
|
// Parse account and skip object list (in braces)
|
|
let fieldIndex = 1
|
|
const account = fields[fieldIndex++]
|
|
|
|
// Skip object list if present (now a single field thanks to brace-aware splitting)
|
|
if (fields[fieldIndex]?.startsWith('{')) {
|
|
fieldIndex++
|
|
}
|
|
|
|
const amount = parseNumberField(fields[fieldIndex++])
|
|
|
|
const transLine: SIETransactionLine = {
|
|
account,
|
|
amount,
|
|
}
|
|
|
|
// Optional fields
|
|
if (fields[fieldIndex]) {
|
|
transLine.date = parseSIEDate(fields[fieldIndex++]) || undefined
|
|
}
|
|
if (fields[fieldIndex]) {
|
|
transLine.description = parseStringField(fields[fieldIndex++])
|
|
}
|
|
if (fields[fieldIndex]) {
|
|
transLine.quantity = parseNumberField(fields[fieldIndex++])
|
|
}
|
|
if (fields[fieldIndex]) {
|
|
transLine.signature = parseStringField(fields[fieldIndex++])
|
|
}
|
|
|
|
currentVoucher.lines.push(transLine)
|
|
break
|
|
}
|
|
|
|
case 'RTRANS':
|
|
case 'BTRANS':
|
|
// BTRANS = Balance transactions (preliminary/carried-forward balances)
|
|
// RTRANS = Reversed/corrected transactions
|
|
// These are supplementary lines and should NOT be included in balance validation
|
|
// or imported as regular transaction lines. Skip them.
|
|
break
|
|
|
|
default:
|
|
// Unknown tag - add info issue for notable ones
|
|
if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'KPTYP', 'DIM', 'OBJEKT', 'OIB', 'OUB', 'PBUDGET', 'PSALDO'].includes(tag)) {
|
|
addIssue(issues, 'info', lineNum, `Unknown tag: #${tag}`, tag)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
addIssue(
|
|
issues,
|
|
'error',
|
|
lineNum,
|
|
`Error parsing ${tag}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
tag
|
|
)
|
|
}
|
|
}
|
|
|
|
// Calculate statistics
|
|
const currentFiscalYear = header.fiscalYears.find((fy) => fy.yearIndex === 0)
|
|
const totalTransactionLines = vouchers.reduce((sum, v) => sum + v.lines.length, 0)
|
|
|
|
return {
|
|
header,
|
|
accounts,
|
|
openingBalances,
|
|
closingBalances,
|
|
resultBalances,
|
|
vouchers,
|
|
issues,
|
|
stats: {
|
|
totalAccounts: accounts.length,
|
|
totalVouchers: vouchers.length,
|
|
totalTransactionLines,
|
|
fiscalYearStart: currentFiscalYear?.start || null,
|
|
fiscalYearEnd: currentFiscalYear?.end || null,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a parsed SIE file
|
|
*/
|
|
export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult {
|
|
const errors: string[] = []
|
|
const warnings: string[] = []
|
|
|
|
// Check for SIE type
|
|
if (!parsed.header.sieType) {
|
|
errors.push('Missing SIE type (#SIETYP)')
|
|
}
|
|
|
|
// Check for company info
|
|
if (!parsed.header.companyName) {
|
|
warnings.push('No company name found (#FNAMN)')
|
|
}
|
|
|
|
// Check for fiscal year
|
|
if (parsed.header.fiscalYears.length === 0) {
|
|
errors.push('No fiscal year defined (#RAR)')
|
|
}
|
|
|
|
// Check for accounts
|
|
if (parsed.accounts.length === 0) {
|
|
warnings.push('No accounts found (#KONTO)')
|
|
}
|
|
|
|
// Check for unbalanced vouchers
|
|
for (const voucher of parsed.vouchers) {
|
|
const total = voucher.lines.reduce((sum, l) => sum + l.amount, 0)
|
|
if (Math.abs(total) > 0.01) {
|
|
errors.push(
|
|
`Voucher ${voucher.series}${voucher.number} on ${voucher.date.toISOString().split('T')[0]} is not balanced (diff: ${total.toFixed(2)})`
|
|
)
|
|
}
|
|
}
|
|
|
|
// Check for accounts referenced but not defined
|
|
const definedAccounts = new Set(parsed.accounts.map((a) => a.number))
|
|
const referencedAccounts = new Set<string>()
|
|
|
|
for (const balance of [...parsed.openingBalances, ...parsed.closingBalances, ...parsed.resultBalances]) {
|
|
referencedAccounts.add(balance.account)
|
|
}
|
|
|
|
for (const voucher of parsed.vouchers) {
|
|
for (const line of voucher.lines) {
|
|
referencedAccounts.add(line.account)
|
|
}
|
|
}
|
|
|
|
for (const account of referencedAccounts) {
|
|
if (!definedAccounts.has(account)) {
|
|
warnings.push(`Account ${account} referenced but not defined in #KONTO`)
|
|
}
|
|
}
|
|
|
|
// Check opening balance is balanced (for balance sheet accounts)
|
|
const ibTotal = parsed.openingBalances
|
|
.filter((b) => b.yearIndex === 0)
|
|
.reduce((sum, b) => sum + b.amount, 0)
|
|
|
|
if (Math.abs(ibTotal) > 0.01) {
|
|
warnings.push(`Opening balances not balanced (diff: ${ibTotal.toFixed(2)})`)
|
|
}
|
|
|
|
// Add parse issues as errors/warnings
|
|
for (const issue of parsed.issues) {
|
|
if (issue.severity === 'error') {
|
|
errors.push(`Line ${issue.line}: ${issue.message}`)
|
|
} else if (issue.severity === 'warning') {
|
|
warnings.push(`Line ${issue.line}: ${issue.message}`)
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate a hash of the file content for duplicate detection
|
|
*/
|
|
export async function calculateFileHash(content: string): Promise<string> {
|
|
const encoder = new TextEncoder()
|
|
const data = encoder.encode(content)
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
}
|