a84d2723e0
* feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import A verifikat migrated from Fortnox/Visma lost the trail of what had been corrected in the source system: the parser skipped #BTRANS (struck lines) and #RTRANS (lines added by a rättelse) and nothing else read them. The final state is still built from #TRANS only, exactly as SIE 4B prescribes (#RTRANS is always twinned by an identical #TRANS, so summing all three double-counts, #63). The two history record types now ride along the voucher as `corrections` and land, inside the same atomic import transaction, as one journal_entry_rattelse_log row per corrected voucher with source='sie_import', the file's sie_import_id and the SIE `sign` (who corrected in the source system; SIE carries who, never when). Why the problem occurred: the March fix for double-counting chose "skip" over "keep aside" because nowhere existed to keep the history. The inline rättelse log (July) created that place, and every reader of it (verifikat page, "Rättad" marker, behandlingshistorik, full archive) already renders struck/added snapshots, so the history now flows through one table. What was removed or simplified instead: no new table, no per-import toggle, no fifth RPC parameter (sie_import_id travels inside each payload entry so the (uuid,uuid,uuid,jsonb) signature, grants and statement_timeout stay put and PostgREST sees no overload). The parser's three identical TRANS/RTRANS/BTRANS field parsers collapsed into one helper; the TRANS-only ledger path is byte-for-byte the same. Why this over the proposed shape: the reporter suggested an own table or column. A separate store would need its own readers, RLS, archive classification and behandlingshistorik wiring; the rättelselogg already has all four. Storing history in sie_imports.migration_documentation was rejected as aggregate JSON that no per-verifikat surface reads. Import-sourced log rows survive undo/replace like every other log row (no FK on purpose); a re-import writes fresh rows against fresh entry ids. Parser also warns when an #RTRANS is not followed by its identical #TRANS twin (a spec violation that would silently drop a line from the final state) and the record-type comments now match the spec wording. Migration 20260909132618: three nullable/defaulted columns + CHECKs on journal_entry_rattelse_log, sie_correction_snapshots() helper, import_sie_journal_entries body verbatim plus the history insert. No backfill; existing imports and log rows untouched. Fixes #2427 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn * fix(import): verify the SIE import id before it becomes provenance, keep per-line signatures Review findings on PR #2458, one pass: - Superagent P2: import_sie_journal_entries stored the caller-supplied sieImportId as WORM audit provenance without checking it. The RPC now requires the id to be one of the importing company's own sie_imports rows and fails closed (42501, whole import rolled back) on a foreign or fabricated id. pg-real test added. - Compliance review: the voucher-level external_signature collapsed distinct correctors per line. Each struck/added snapshot now carries its own SIE sign (importer + sie_correction_snapshots), the summary column stays as the first one. - Compliance review: created_at on imported rows is the import moment. Behandlingshistoriken now says so in the event details instead of leaving it implicit (the verifikat page already avoided a date). Migration file is unshipped (not on main); staging re-applied under the same version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1267 lines
42 KiB
TypeScript
1267 lines
42 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 { monthsBetween } from '@/lib/bookkeeping/validate-period-duration'
|
||
import type {
|
||
SIEType,
|
||
SIEEncoding,
|
||
SIEHeader,
|
||
SIEAccount,
|
||
SIEBalance,
|
||
SIEVoucher,
|
||
SIETransactionLine,
|
||
SIEDimension,
|
||
SIEDimensionValue,
|
||
ParsedSIEFile,
|
||
ParseIssue,
|
||
ParseIssueSeverity,
|
||
ValidationResult,
|
||
} from './types'
|
||
|
||
// CP437 to UTF-8 mapping: full 0x80-0x9F range
|
||
// CP437 was the standard encoding for DOS/early Windows (used by SIE #FORMAT PC8)
|
||
const CP437_MAP: Record<number, string> = {
|
||
// 0x80-0x8F
|
||
0x80: 'Ç', // Ç
|
||
0x81: 'ü', // ü
|
||
0x82: 'é', // é
|
||
0x83: 'â', // â
|
||
0x84: 'ä', // ä
|
||
0x85: 'à', // à
|
||
0x86: 'å', // å
|
||
0x87: 'ç', // ç
|
||
0x88: 'ê', // ê
|
||
0x89: 'ë', // ë
|
||
0x8a: 'è', // è
|
||
0x8b: 'ï', // ï
|
||
0x8c: 'î', // î
|
||
0x8d: 'ì', // ì
|
||
0x8e: 'Ä', // Ä
|
||
0x8f: 'Å', // Å
|
||
// 0x90-0x9F
|
||
0x90: 'É', // É
|
||
0x91: 'æ', // æ
|
||
0x92: 'Æ', // Æ
|
||
0x93: 'ô', // ô
|
||
0x94: 'ö', // ö
|
||
0x95: 'ò', // ò
|
||
0x96: 'û', // û
|
||
0x97: 'ù', // ù
|
||
0x98: 'ÿ', // ÿ
|
||
0x99: 'Ö', // Ö
|
||
0x9a: 'Ü', // Ü
|
||
0x9b: 'ø', // ø (Norwegian)
|
||
0x9c: '£', // £
|
||
0x9d: 'Ø', // Ø (Norwegian)
|
||
0x9e: '×', // ×
|
||
0x9f: 'ƒ', // ƒ
|
||
}
|
||
|
||
// 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.
|
||
*
|
||
* Strategy:
|
||
* 1. UTF-8 BOM → utf8
|
||
* 2. `#FORMAT PC8` in raw bytes → cp437 (SIE standard header for CP437)
|
||
* 3. Range-based discrimination: CP437 Swedish chars live in 0x80-0x9F,
|
||
* Windows-1252 Swedish chars live in 0xC0-0xFF. These ranges don't overlap,
|
||
* so presence in one range rules out the other.
|
||
* 4. UTF-8 multi-byte sequences (0xC3 + continuation) are detected with proper
|
||
* skipping of continuation bytes to avoid false CP437 counts.
|
||
*
|
||
* Scans the entire buffer (not a sample): SIE files are capped at 50 MB and
|
||
* Swedish characters often only appear deep in voucher descriptions, well past
|
||
* any small header sample.
|
||
*/
|
||
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'
|
||
}
|
||
|
||
// NOTE: #FORMAT PC8 is NOT used for encoding detection.
|
||
// Almost all SIE files declare #FORMAT PC8 regardless of actual encoding
|
||
// (Fortnox, Bokio, Dooer etc. export UTF-8 with #FORMAT PC8).
|
||
// Instead, we detect encoding from actual byte patterns.
|
||
|
||
let cp437Count = 0 // Swedish chars in 0x80-0x9F (CP437 range)
|
||
let utf8Count = 0 // Valid UTF-8 multi-byte Swedish sequences
|
||
let win1252Count = 0 // Swedish chars in 0xC0-0xFF (Win-1252 range)
|
||
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
const byte = bytes[i]
|
||
|
||
// Check for UTF-8 multi-byte sequences for Swedish chars FIRST
|
||
// to avoid false CP437/Win-1252 counts from continuation bytes.
|
||
// Ä = C3 84, Å = C3 85, Ö = C3 96, ä = C3 A4, å = C3 A5, ö = C3 B6, é = C3 A9
|
||
if (byte === 0xc3 && i + 1 < bytes.length) {
|
||
const nextByte = bytes[i + 1]
|
||
if ([0x84, 0x85, 0x96, 0xa4, 0xa5, 0xb6, 0xa9].includes(nextByte)) {
|
||
utf8Count++
|
||
i++ // Skip continuation byte to avoid false CP437 count (e.g. 0x84 = ä in CP437)
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Check for CP437 Swedish characters (0x80-0x9F range)
|
||
if (CP437_MAP[byte]) {
|
||
cp437Count++
|
||
}
|
||
|
||
// Check for Windows-1252 Swedish characters (0xC0-0xFF range)
|
||
if (WIN1252_SWEDISH_BYTES.has(byte)) {
|
||
win1252Count++
|
||
}
|
||
}
|
||
|
||
if (utf8Count > cp437Count && utf8Count > win1252Count) return 'utf8'
|
||
if (cp437Count > win1252Count) return 'cp437'
|
||
if (win1252Count > 0) return 'windows1252'
|
||
|
||
// Pure ASCII (no high bytes): UTF-8 is a superset of ASCII
|
||
return 'utf8'
|
||
}
|
||
|
||
/**
|
||
* Decode a buffer to string using the specified encoding.
|
||
*
|
||
* After decoding, validates the result for U+FFFD replacement characters
|
||
* (which signal that the chosen encoding was wrong). When found, retries
|
||
* with each alternate encoding and returns the first result without U+FFFD.
|
||
* This guards against `detectEncoding` heuristic misses on files where
|
||
* Swedish characters are rare or absent in the bytes the detector looked at.
|
||
*/
|
||
export function decodeBuffer(buffer: ArrayBuffer, encoding: SIEEncoding): string {
|
||
const primary = decodeBufferRaw(buffer, encoding)
|
||
if (!primary.includes('\uFFFD')) return primary
|
||
|
||
const alternates: SIEEncoding[] = (['utf8', 'windows1252', 'cp437'] as const).filter(
|
||
(e) => e !== encoding
|
||
)
|
||
for (const alt of alternates) {
|
||
const candidate = decodeBufferRaw(buffer, alt)
|
||
if (!candidate.includes('\uFFFD')) return candidate
|
||
}
|
||
return primary
|
||
}
|
||
|
||
function decodeBufferRaw(buffer: ArrayBuffer, encoding: SIEEncoding): string {
|
||
if (encoding === 'utf8') {
|
||
const decoder = new TextDecoder('utf-8')
|
||
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 = ''
|
||
|
||
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) into a Date object.
|
||
* Used for voucher dates where Date arithmetic is needed.
|
||
*/
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/**
|
||
* Parse a date from SIE format (YYYYMMDD) into an ISO date string "YYYY-MM-DD".
|
||
* Used for fiscal year dates and generated dates to avoid timezone issues
|
||
* during JSON serialization (Date objects shift when crossing UTC boundaries).
|
||
*/
|
||
function parseSIEDateString(dateStr: string): string | null {
|
||
if (!dateStr || dateStr.length !== 8) {
|
||
return null
|
||
}
|
||
|
||
const year = dateStr.substring(0, 4)
|
||
const month = dateStr.substring(4, 6)
|
||
const day = dateStr.substring(6, 8)
|
||
|
||
const y = parseInt(year, 10)
|
||
const m = parseInt(month, 10)
|
||
const d = parseInt(day, 10)
|
||
|
||
if (isNaN(y) || isNaN(m) || isNaN(d)) {
|
||
return null
|
||
}
|
||
|
||
// Validate by round-tripping through Date (rejects Feb 30, etc.)
|
||
const date = new Date(y, m - 1, d)
|
||
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
|
||
return null
|
||
}
|
||
|
||
return `${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('"')) {
|
||
// `\"` is a literal quote and `\\` a literal backslash (what the export
|
||
// in lib/reports/sie-export.ts writes).
|
||
return field.slice(1, -1).replace(/\\(["\\])/g, '$1')
|
||
}
|
||
|
||
return field
|
||
}
|
||
|
||
/**
|
||
* Parse a numeric field from SIE
|
||
*/
|
||
function parseNumberField(field: string): number {
|
||
if (!field) return 0
|
||
// Strip quotes and use dot as decimal separator
|
||
const cleaned = parseStringField(field)
|
||
return parseFloat(cleaned.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
|
||
}
|
||
|
||
// SIE 4 spec allows either space or tab as field separator (programs like
|
||
// Bollbok export tab-separated lines). Quoted strings and brace-bounded
|
||
// dimension lists preserve any interior whitespace via the guards above.
|
||
if ((char === ' ' || char === '\t') && !inQuotes && braceDepth === 0) {
|
||
if (current) {
|
||
fields.push(current)
|
||
current = ''
|
||
}
|
||
continue
|
||
}
|
||
|
||
current += char
|
||
}
|
||
|
||
if (current) {
|
||
fields.push(current)
|
||
}
|
||
|
||
return fields
|
||
}
|
||
|
||
/**
|
||
* Parse a #TRANS object list (`{1 "KS01" 6 "P001"}`) into an SIE dim
|
||
* number → object code map. The list arrives as ONE field thanks to the
|
||
* brace-aware splitter; the inner content is itself space-separated with
|
||
* SIE quoting, so it re-runs through splitSIELine. Returns undefined for an
|
||
* empty list ({}), malformed pairs are skipped with a warning issue.
|
||
*/
|
||
function parseObjectList(
|
||
raw: string,
|
||
issues: ParseIssue[],
|
||
lineNum: number
|
||
): Record<string, string> | undefined {
|
||
const inner = raw.replace(/^\{/, '').replace(/\}$/, '').trim()
|
||
if (!inner) return undefined
|
||
|
||
const parts = splitSIELine(inner)
|
||
if (parts.length % 2 !== 0) {
|
||
addIssue(issues, 'warning', lineNum, `Objektlista med udda antal fält ignoreras delvis: ${raw}`, 'TRANS')
|
||
}
|
||
|
||
const dims: Record<string, string> = {}
|
||
for (let i = 0; i + 1 < parts.length; i += 2) {
|
||
const dimNoRaw = parseStringField(parts[i])
|
||
const code = parseStringField(parts[i + 1]).trim()
|
||
const dimNo = parseInt(dimNoRaw, 10)
|
||
if (isNaN(dimNo) || dimNo < 1 || !code) {
|
||
addIssue(issues, 'warning', lineNum, `Ogiltigt objektpar i objektlista: ${dimNoRaw} ${code}`, 'TRANS')
|
||
continue
|
||
}
|
||
// Canonical numeric key ('01' → '1'): matches normalizeLineDimensions.
|
||
dims[String(dimNo)] = code
|
||
}
|
||
return Object.keys(dims).length > 0 ? dims : undefined
|
||
}
|
||
|
||
/**
|
||
* 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 the fields of a #TRANS / #RTRANS / #BTRANS record (identical layout):
|
||
* #TAG accountNumber {objectList} amount [date] [description] [quantity] [signature]
|
||
* Returns null (after reporting) when the amount is missing.
|
||
*/
|
||
function parseTransactionLine(
|
||
fields: string[],
|
||
tag: string,
|
||
issues: ParseIssue[],
|
||
lineNum: number
|
||
): SIETransactionLine | null {
|
||
// Parse account and capture the object list (in braces)
|
||
let fieldIndex = 1
|
||
const account = parseStringField(fields[fieldIndex++])
|
||
|
||
// Object list (single field thanks to brace-aware splitting):
|
||
// dimension tags like {1 "KS01" 6 "P001"}. Parsed onto the line so
|
||
// import is lossless (dimensions plan PR5).
|
||
let objectListRaw: string | null = null
|
||
if (fields[fieldIndex]?.startsWith('{')) {
|
||
objectListRaw = fields[fieldIndex]
|
||
fieldIndex++
|
||
}
|
||
|
||
const transAmountStr = fields[fieldIndex]
|
||
if (!transAmountStr || transAmountStr.trim() === '') {
|
||
addIssue(issues, 'warning', lineNum, `Belopp saknas i #${tag}: raden hoppas över`, tag)
|
||
return null
|
||
}
|
||
|
||
const amount = parseNumberField(fields[fieldIndex++])
|
||
|
||
const transLine: SIETransactionLine = {
|
||
account,
|
||
amount,
|
||
}
|
||
|
||
if (objectListRaw) {
|
||
const dims = parseObjectList(objectListRaw, issues, lineNum)
|
||
if (dims) {
|
||
transLine.dimensions = dims
|
||
}
|
||
}
|
||
|
||
// Optional fields
|
||
if (fields[fieldIndex]) {
|
||
transLine.date = parseSIEDate(parseStringField(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++])
|
||
}
|
||
|
||
return transLine
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
// Per SIE spec: if #SIETYP is absent, assume type 1 (closing balances only)
|
||
const header: SIEHeader = {
|
||
sieType: 1,
|
||
flagga: null,
|
||
program: null,
|
||
programVersion: null,
|
||
generatedDate: null,
|
||
format: null,
|
||
companyName: null,
|
||
orgNumber: null,
|
||
address: null,
|
||
fiscalYears: [],
|
||
currency: 'SEK',
|
||
kontoPlanType: null,
|
||
}
|
||
|
||
const accounts: SIEAccount[] = []
|
||
const openingBalances: SIEBalance[] = []
|
||
const closingBalances: SIEBalance[] = []
|
||
const resultBalances: SIEBalance[] = []
|
||
const vouchers: SIEVoucher[] = []
|
||
const dimensions: SIEDimension[] = []
|
||
const dimensionValues: SIEDimensionValue[] = []
|
||
let objectBalanceCount = 0
|
||
|
||
// Track current voucher being parsed (inside #VER { ... })
|
||
let currentVoucher: SIEVoucher | null = null
|
||
|
||
// SIE 4B: an #RTRANS row must be immediately followed by an identical
|
||
// #TRANS row (the twin older readers use). Remembered here so the twin
|
||
// can be verified; a missing twin is reported, since the final state is
|
||
// built from #TRANS only and would silently lack that line.
|
||
let pendingRtrans: { line: SIETransactionLine; lineNum: number } | null = null
|
||
|
||
const reportMissingRtransTwin = (): void => {
|
||
if (!pendingRtrans) return
|
||
addIssue(
|
||
issues,
|
||
'warning',
|
||
pendingRtrans.lineNum,
|
||
`#RTRANS ${pendingRtrans.line.account} ${pendingRtrans.line.amount.toFixed(2)} följs inte av en identisk #TRANS-rad: rättelseraden ingår inte i verifikatets slutliga rader`,
|
||
'RTRANS'
|
||
)
|
||
pendingRtrans = 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 === '}') {
|
||
reportMissingRtransTwin()
|
||
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,
|
||
`Verifikation ${currentVoucher.series}${currentVoucher.number} balanserar inte (differens: ${total.toFixed(2)} kr)`,
|
||
'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()
|
||
|
||
if (pendingRtrans && tag !== 'TRANS') {
|
||
reportMissingRtransTwin()
|
||
}
|
||
|
||
try {
|
||
switch (tag) {
|
||
case 'FLAGGA':
|
||
header.flagga = parseInt(fields[1], 10) || 0
|
||
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, `Okänd SIE-typ: ${fields[1]}. Filen tolkas som SIE4.`, 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 = parseSIEDateString(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 'KPTYP':
|
||
header.kontoPlanType = parseStringField(fields[1])
|
||
break
|
||
|
||
case 'RAR': {
|
||
// #RAR yearIndex start end
|
||
//
|
||
// Validated for EVERY year index, not just 0: prior-year records
|
||
// (#RAR -1, -2, ...) land in header.fiscalYears too, and a bogus
|
||
// entry there used to pass through silently. Malformed records are
|
||
// reported and skipped so fiscalYears never carries an entry the
|
||
// rest of the pipeline cannot trust. The one exception is the
|
||
// 18-month BFL 3 kap. cap: an over-long span is reported as a
|
||
// warning but the entry is KEPT, because executeSIEImport refuses
|
||
// the current year (#RAR 0) with a precise Swedish error that needs
|
||
// the real dates, and dropping the record here would degrade that
|
||
// message to "no fiscal year defined".
|
||
const yearIndex = parseInt(fields[1], 10)
|
||
const start = parseSIEDateString(fields[2])
|
||
const end = parseSIEDateString(fields[3])
|
||
|
||
if (!Number.isInteger(yearIndex)) {
|
||
addIssue(issues, 'warning', lineNum, `Ogiltigt årsindex i #RAR: "${fields[1] ?? ''}"`, tag)
|
||
break
|
||
}
|
||
|
||
if (!start || !end) {
|
||
addIssue(issues, 'warning', lineNum, 'Invalid fiscal year dates', tag)
|
||
break
|
||
}
|
||
|
||
if (end < start) {
|
||
addIssue(
|
||
issues,
|
||
'warning',
|
||
lineNum,
|
||
`Räkenskapsårets slutdatum (${end}) ligger före startdatumet (${start}) i #RAR ${yearIndex}`,
|
||
tag
|
||
)
|
||
break
|
||
}
|
||
|
||
const rarMonths = monthsBetween(start, end)
|
||
if (rarMonths > 18) {
|
||
addIssue(
|
||
issues,
|
||
'warning',
|
||
lineNum,
|
||
`Räkenskapsåret i #RAR ${yearIndex} (${start} till ${end}) omfattar ${rarMonths} månader: ett räkenskapsår får vara högst 18 månader (BFL 3 kap.)`,
|
||
tag
|
||
)
|
||
}
|
||
|
||
header.fiscalYears.push({ yearIndex, start, end })
|
||
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
|
||
// Bollbok 2025 writes the type unquoted (T), Bollbok 2026 writes it
|
||
// quoted ("T"). parseStringField strips the quotes in both cases.
|
||
const accountNum = fields[1]
|
||
const accountType = parseStringField(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 amountStr = fields[3]
|
||
|
||
if (!amountStr || amountStr.trim() === '') {
|
||
addIssue(issues, 'warning', lineNum, 'Belopp saknas i #IB: raden hoppas över', tag)
|
||
break
|
||
}
|
||
|
||
const amount = parseNumberField(amountStr)
|
||
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 amountStr = fields[3]
|
||
|
||
if (!amountStr || amountStr.trim() === '') {
|
||
addIssue(issues, 'warning', lineNum, 'Belopp saknas i #UB: raden hoppas över', tag)
|
||
break
|
||
}
|
||
|
||
const amount = parseNumberField(amountStr)
|
||
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 amountStr = fields[3]
|
||
|
||
if (!amountStr || amountStr.trim() === '') {
|
||
addIssue(issues, 'warning', lineNum, 'Belopp saknas i #RES: raden hoppas över', tag)
|
||
break
|
||
}
|
||
|
||
const amount = parseNumberField(amountStr)
|
||
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]
|
||
// Some programs quote all fields, so strip quotes from number/date too
|
||
const series = parseStringField(fields[1])
|
||
const number = parseInt(parseStringField(fields[2]), 10)
|
||
const date = parseSIEDate(parseStringField(fields[3]))
|
||
const description = parseStringField(fields[4])
|
||
|
||
if (!isNaN(number) && date) {
|
||
currentVoucher = {
|
||
series,
|
||
number,
|
||
date,
|
||
description: description || '',
|
||
lines: [],
|
||
}
|
||
|
||
// Optional registration date and signature
|
||
if (fields[5]) {
|
||
currentVoucher.registrationDate = parseSIEDate(parseStringField(fields[5])) || undefined
|
||
}
|
||
if (fields[6]) {
|
||
currentVoucher.signature = parseStringField(fields[6])
|
||
}
|
||
} else {
|
||
addIssue(issues, 'error', lineNum, 'Ogiltig verifikationsdefinition: nummer eller datum kunde inte tolkas', tag)
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'TRANS':
|
||
case 'RTRANS':
|
||
case 'BTRANS': {
|
||
// #TRANS = the voucher's final lines (its current state).
|
||
// #BTRANS = "removed transaction item": a line struck in the source
|
||
// system after posting (how the voucher looked before the rättelse).
|
||
// #RTRANS = "supplementary transaction item": a line added by a
|
||
// rättelse. Per SIE 4B it is always immediately followed by an
|
||
// identical #TRANS row, so the line is ALSO in the final state.
|
||
//
|
||
// When a voucher has been corrected, Fortnox/Visma emit all three
|
||
// types. Only #TRANS is booked (summing all three would double-count
|
||
// and make balanced vouchers look unbalanced, #63). #BTRANS/#RTRANS
|
||
// are kept aside as `corrections`: the correction history behind the
|
||
// verifikat, persisted by the import into the rättelselogg (#2427).
|
||
if (!currentVoucher) {
|
||
addIssue(issues, 'error', lineNum, `#${tag} utanför verifikationsblock (#VER): filen kan vara skadad`, tag)
|
||
break
|
||
}
|
||
|
||
const transLine = parseTransactionLine(fields, tag, issues, lineNum)
|
||
if (!transLine) {
|
||
break
|
||
}
|
||
|
||
if (tag === 'BTRANS') {
|
||
const corrections = (currentVoucher.corrections ??= { struck: [], added: [] })
|
||
corrections.struck.push(transLine)
|
||
break
|
||
}
|
||
|
||
if (tag === 'RTRANS') {
|
||
const corrections = (currentVoucher.corrections ??= { struck: [], added: [] })
|
||
corrections.added.push(transLine)
|
||
pendingRtrans = { line: transLine, lineNum }
|
||
break
|
||
}
|
||
|
||
if (pendingRtrans) {
|
||
const twin = pendingRtrans.line
|
||
if (twin.account === transLine.account && Math.abs(twin.amount - transLine.amount) < 0.005) {
|
||
pendingRtrans = null
|
||
} else {
|
||
reportMissingRtransTwin()
|
||
}
|
||
}
|
||
|
||
currentVoucher.lines.push(transLine)
|
||
break
|
||
}
|
||
|
||
case 'DIM': {
|
||
// #DIM dimNo "name"
|
||
const dimNo = parseInt(parseStringField(fields[1]), 10)
|
||
const name = parseStringField(fields[2])
|
||
if (!isNaN(dimNo) && dimNo >= 1) {
|
||
dimensions.push({ sieDimNo: dimNo, name: name || '' })
|
||
} else {
|
||
addIssue(issues, 'warning', lineNum, 'Ogiltig dimensionsdefinition: numret kunde inte tolkas', tag)
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'UNDERDIM': {
|
||
// #UNDERDIM dimNo "name" parentDimNo
|
||
const dimNo = parseInt(parseStringField(fields[1]), 10)
|
||
const name = parseStringField(fields[2])
|
||
const parent = parseInt(parseStringField(fields[3]), 10)
|
||
if (!isNaN(dimNo) && dimNo >= 1 && !isNaN(parent) && parent >= 1) {
|
||
dimensions.push({ sieDimNo: dimNo, name: name || '', parentSieDimNo: parent })
|
||
} else {
|
||
addIssue(issues, 'warning', lineNum, 'Ogiltig underdimension: nummer eller överdimension kunde inte tolkas', tag)
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'OBJEKT': {
|
||
// #OBJEKT dimNo "code" "name"
|
||
const dimNo = parseInt(parseStringField(fields[1]), 10)
|
||
const code = parseStringField(fields[2]).trim()
|
||
const name = parseStringField(fields[3])
|
||
if (!isNaN(dimNo) && dimNo >= 1 && code) {
|
||
dimensionValues.push({ sieDimNo: dimNo, code, name: name || code })
|
||
} else {
|
||
addIssue(issues, 'warning', lineNum, 'Ogiltigt objekt: dimension eller kod kunde inte tolkas', tag)
|
||
}
|
||
break
|
||
}
|
||
|
||
default:
|
||
// Unknown tag - add info issue for notable ones. OIB/OUB (per-object
|
||
// opening/closing balances) are counted and surfaced as ONE info
|
||
// issue below: dimension reporting is P&L-only in v1, so
|
||
// object-level balance records have no consumer yet, but dropping
|
||
// them must never be silent (#866 review).
|
||
if (tag === 'OIB' || tag === 'OUB') {
|
||
objectBalanceCount++
|
||
} else if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'PBUDGET', 'PSALDO'].includes(tag)) {
|
||
addIssue(issues, 'info', lineNum, `Okänd tagg: #${tag}, ignoreras`, tag)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
addIssue(
|
||
issues,
|
||
'error',
|
||
lineNum,
|
||
`Fel vid tolkning av #${tag}: ${error instanceof Error ? error.message : 'Okänt fel'}`,
|
||
tag
|
||
)
|
||
}
|
||
}
|
||
|
||
// Collect accounts referenced in balances and vouchers but missing from #KONTO
|
||
const definedAccountNumbers = new Set(accounts.map((a) => a.number))
|
||
const referencedAccounts = new Set<string>()
|
||
|
||
for (const balance of [...openingBalances, ...closingBalances, ...resultBalances]) {
|
||
if (balance.account && !definedAccountNumbers.has(balance.account)) {
|
||
referencedAccounts.add(balance.account)
|
||
}
|
||
}
|
||
for (const voucher of vouchers) {
|
||
for (const line of voucher.lines) {
|
||
if (line.account && !definedAccountNumbers.has(line.account)) {
|
||
referencedAccounts.add(line.account)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const accountNumber of referencedAccounts) {
|
||
accounts.push({ number: accountNumber, name: '' })
|
||
addIssue(issues, 'info', 0, `Account ${accountNumber} added from transaction data (not in #KONTO)`)
|
||
}
|
||
|
||
// Silent-failure diagnostic: if the raw input declares #IB / #VER records
|
||
// but parsing produced none, surface a warning instead of letting the file
|
||
// look empty. Historically a tab-separator or encoding mismatch could swallow
|
||
// all balance/voucher records without any visible signal.
|
||
//
|
||
// Suppressed when per-record 'error' issues already exist for the same tag:
|
||
// in that case the parser already pinpointed the root cause (e.g. malformed
|
||
// verification definition), so the generic "check separator/encoding" hint
|
||
// would be misleading.
|
||
const rawIBCount = lines.filter((l) => /^\s*#IB\b/.test(l)).length
|
||
const rawVERCount = lines.filter((l) => /^\s*#VER\b/.test(l)).length
|
||
const hasIBError = issues.some((i) => i.severity === 'error' && i.tag === 'IB')
|
||
const hasVERError = issues.some((i) => i.severity === 'error' && i.tag === 'VER')
|
||
if (rawIBCount > 0 && openingBalances.length === 0 && !hasIBError) {
|
||
addIssue(
|
||
issues,
|
||
'warning',
|
||
0,
|
||
`${rawIBCount} #IB-rader hittades men inga ingående saldon kunde tolkas: kontrollera fältavskiljare och teckenkodning`,
|
||
'IB'
|
||
)
|
||
}
|
||
if (rawVERCount > 0 && vouchers.length === 0 && !hasVERError) {
|
||
addIssue(
|
||
issues,
|
||
'warning',
|
||
0,
|
||
`${rawVERCount} #VER-rader hittades men inga verifikationer kunde tolkas: kontrollera fältavskiljare och teckenkodning`,
|
||
'VER'
|
||
)
|
||
}
|
||
|
||
// Dimension visibility: the preview step renders parse issues, so these
|
||
// make dimension handling explicit BEFORE the user executes the import.
|
||
if (objectBalanceCount > 0) {
|
||
addIssue(
|
||
issues,
|
||
'info',
|
||
0,
|
||
`${objectBalanceCount} objektbalansrader (#OIB/#OUB) hoppades över: balanser per objekt stöds inte ännu`,
|
||
'OIB'
|
||
)
|
||
}
|
||
const taggedLineCount = vouchers.reduce(
|
||
(sum, v) => sum + v.lines.filter((l) => l.dimensions).length,
|
||
0
|
||
)
|
||
if (dimensions.length > 0 || dimensionValues.length > 0 || taggedLineCount > 0) {
|
||
addIssue(
|
||
issues,
|
||
'info',
|
||
0,
|
||
`Filen innehåller dimensionsdata (kostnadsställen/projekt): ${taggedLineCount} taggade rader, dimensionerna följer med importen`,
|
||
'DIM'
|
||
)
|
||
}
|
||
|
||
// 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,
|
||
dimensions,
|
||
dimensionValues,
|
||
issues,
|
||
stats: {
|
||
totalAccounts: accounts.length,
|
||
totalVouchers: vouchers.length,
|
||
totalTransactionLines,
|
||
fiscalYearStart: currentFiscalYear?.start || null,
|
||
fiscalYearEnd: currentFiscalYear?.end || null,
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Wording that identifies a voucher as the year's opening balance
|
||
* (ingående balans). Shared between the parser's OB-voucher candidate
|
||
* detection below and the importer's isLikelyOpeningBalance tagging
|
||
* (lib/import/sie-import.ts) so the two checks can never drift apart.
|
||
*/
|
||
export const OPENING_BALANCE_DESCRIPTION_RE = /ing[åa]ende balans|ing[åa]ende saldo|opening balance/i
|
||
|
||
/**
|
||
* Vouchers mentioning share capital are never treated as opening balances:
|
||
* a share-capital deposit dated on the FY start is a real bank movement.
|
||
*/
|
||
export const SHARE_CAPITAL_DESCRIPTION_RE = /aktiekapital/i
|
||
|
||
/**
|
||
* Determine if an account is balance sheet (class 1-2) or P&L (class 3-8)
|
||
*/
|
||
export function isBalanceSheetAccount(accountNumber: string): boolean {
|
||
const firstDigit = parseInt(accountNumber.charAt(0), 10)
|
||
return firstDigit >= 1 && firstDigit <= 2
|
||
}
|
||
|
||
/**
|
||
* Format a Date to "YYYY-MM-DD" using LOCAL components.
|
||
* parseSIEDate() builds local-time Dates, so toISOString() would shift the
|
||
* day across the UTC boundary in non-UTC timezones: never use it here.
|
||
*/
|
||
function formatLocalDate(date: Date): string {
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
return `${year}-${month}-${day}`
|
||
}
|
||
|
||
/**
|
||
* True when the file contains a voucher that looks like the year's opening
|
||
* balance: dated on the fiscal-year start, only balance-sheet accounts,
|
||
* IB wording in the description and no share-capital mention.
|
||
*
|
||
* Raw-file mirror of the importer's isLikelyOpeningBalance check
|
||
* (lib/import/sie-import.ts), but deliberately MORE eager: it runs on
|
||
* source account numbers with no knowledge of account mappings, so a
|
||
* candidate containing an unmapped line still counts here even though the
|
||
* importer would later skip that voucher as unmapped. In that residual case
|
||
* no IB is created at all: the user falls back to the manual
|
||
* "Märk som ingående balans" action in Bankavstämning.
|
||
*/
|
||
export function hasOpeningBalanceVoucherCandidate(parsed: ParsedSIEFile): boolean {
|
||
const fyStart = parsed.stats.fiscalYearStart
|
||
if (!fyStart) return false
|
||
|
||
return parsed.vouchers.some(
|
||
(v) =>
|
||
v.lines.length > 0 &&
|
||
formatLocalDate(v.date) === fyStart.slice(0, 10) &&
|
||
v.lines.every((l) => isBalanceSheetAccount(l.account)) &&
|
||
OPENING_BALANCE_DESCRIPTION_RE.test(v.description || '') &&
|
||
!SHARE_CAPITAL_DESCRIPTION_RE.test(v.description || '')
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Resolve the opening balances the import should actually book (issue #675).
|
||
*
|
||
* Some systems export no #IB 0 records at all: the current year's IB exists
|
||
* only implicitly via the SIE continuity invariant IB(year 0) = UB(year -1).
|
||
* Every IB consumer goes through this helper so the precedence below is the
|
||
* single source of truth:
|
||
*
|
||
* 1. Explicit #IB 0 records: trusted as-is, never merged with #UB -1.
|
||
* 2. An opening-balance #VER candidate: the voucher itself serves as IB
|
||
* during voucher import (tagged source_type 'opening_balance');
|
||
* deriving from #UB -1 as well would double-count every
|
||
* balance-sheet account.
|
||
* 3. #UB -1 records, re-labeled to yearIndex 0 and filtered to
|
||
* balance-sheet accounts (result accounts must always open at zero).
|
||
* 4. Nothing: the file genuinely carries no opening balances.
|
||
*/
|
||
export function getEffectiveOpeningBalances(parsed: ParsedSIEFile): {
|
||
balances: SIEBalance[]
|
||
derivedFromPriorYearUB: boolean
|
||
} {
|
||
const explicit = parsed.openingBalances.filter((b) => b.yearIndex === 0)
|
||
if (explicit.length > 0) {
|
||
return { balances: explicit, derivedFromPriorYearUB: false }
|
||
}
|
||
|
||
if (hasOpeningBalanceVoucherCandidate(parsed)) {
|
||
return { balances: [], derivedFromPriorYearUB: false }
|
||
}
|
||
|
||
const derived = parsed.closingBalances
|
||
.filter((b) => b.yearIndex === -1 && isBalanceSheetAccount(b.account))
|
||
.map((b) => ({ ...b, yearIndex: 0 }))
|
||
|
||
return { balances: derived, derivedFromPriorYearUB: derived.length > 0 }
|
||
}
|
||
|
||
/**
|
||
* Validate a parsed SIE file
|
||
*/
|
||
export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult {
|
||
const errors: string[] = []
|
||
const warnings: string[] = []
|
||
|
||
// Check #FLAGGA for already-imported files
|
||
if (parsed.header.flagga === 1) {
|
||
warnings.push('Filen är markerad som redan importerad (#FLAGGA 1). Kontrollera att den inte redan har importerats i ett annat system.')
|
||
}
|
||
|
||
// Check for SIE type
|
||
if (!parsed.header.sieType) {
|
||
errors.push('SIE-typ saknas (#SIETYP). Filen kanske inte är en giltig SIE-fil: kontrollera att du exporterat i rätt format.')
|
||
}
|
||
|
||
// Check for company info
|
||
if (!parsed.header.companyName) {
|
||
warnings.push('Företagsnamn saknas (#FNAMN): vanligtvis ofarligt men bör kontrolleras')
|
||
}
|
||
|
||
// Check for fiscal year
|
||
if (parsed.header.fiscalYears.length === 0) {
|
||
errors.push('Inget räkenskapsår definierat (#RAR). Filen saknar information om vilken period bokföringen gäller: kontrollera att exporten inkluderar räkenskapsårsdata.')
|
||
}
|
||
|
||
// Check for accounts
|
||
if (parsed.accounts.length === 0) {
|
||
warnings.push('Inga konton hittades (#KONTO). Om filen bara innehåller saldon (SIE1) är detta normalt.')
|
||
}
|
||
|
||
// Warn if non-BAS kontoplan declared: mapping logic assumes BAS number ranges
|
||
if (parsed.header.kontoPlanType) {
|
||
const planType = parsed.header.kontoPlanType.toUpperCase()
|
||
// EUBAS97 is one of the four kontoplanstyp values the SIE 4B spec
|
||
// enumerates (BAS95, BAS96, EUBAS97, NE2007), and the spec routes every
|
||
// BAS2xxx chart through it. Matched exactly, not by prefix, so this stays
|
||
// pinned to the spec's own table.
|
||
const isBAS = planType.startsWith('BAS') || planType === 'EUBAS97' || planType === 'EUBAS' || planType === 'EU-BAS'
|
||
if (!isBAS) {
|
||
warnings.push(
|
||
`Kontoplanstyp "${parsed.header.kontoPlanType}" är inte BAS-baserad. Automatisk kontomappning kan bli felaktig: granska alla mappningar manuellt i nästa steg.`
|
||
)
|
||
}
|
||
}
|
||
|
||
// Check for unbalanced vouchers
|
||
const unbalancedVouchers: string[] = []
|
||
for (const voucher of parsed.vouchers) {
|
||
const total = voucher.lines.reduce((sum, l) => sum + l.amount, 0)
|
||
if (Math.abs(total) > 0.01) {
|
||
unbalancedVouchers.push(
|
||
`${voucher.series}${voucher.number} (${voucher.date.toISOString().split('T')[0]}, diff: ${total.toFixed(2)} kr)`
|
||
)
|
||
}
|
||
}
|
||
if (unbalancedVouchers.length > 0) {
|
||
const shown = unbalancedVouchers.slice(0, 5)
|
||
const remaining = unbalancedVouchers.length - shown.length
|
||
errors.push(
|
||
`${unbalancedVouchers.length} verifikation(er) balanserar inte (debet ≠ kredit): ${shown.join(', ')}${remaining > 0 ? ` och ${remaining} till` : ''}. Kontrollera att exporten från källsystemet är komplett.`
|
||
)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
const undefinedAccounts: string[] = []
|
||
for (const account of referencedAccounts) {
|
||
if (!definedAccounts.has(account)) {
|
||
undefinedAccounts.push(account)
|
||
}
|
||
}
|
||
if (undefinedAccounts.length > 0) {
|
||
const shown = undefinedAccounts.slice(0, 10)
|
||
const remaining = undefinedAccounts.length - shown.length
|
||
warnings.push(
|
||
`${undefinedAccounts.length} konto(n) används i verifikationer men definieras inte i #KONTO: ${shown.join(', ')}${remaining > 0 ? ` och ${remaining} till` : ''}. Kontona skapas automatiskt vid import.`
|
||
)
|
||
}
|
||
|
||
// Check opening balance is balanced (for balance sheet accounts).
|
||
// Uses the effective set so files without #IB 0 (where IB is derived from
|
||
// #UB -1, issue #675) still get the 2099-adjustment heads-up.
|
||
const effectiveIB = getEffectiveOpeningBalances(parsed)
|
||
|
||
if (effectiveIB.derivedFromPriorYearUB) {
|
||
warnings.push(
|
||
'Filen saknar ingående balanser (#IB) för aktuellt räkenskapsår: de härleds från föregående års utgående balans (#UB -1) vid import.'
|
||
)
|
||
}
|
||
|
||
const ibTotal = effectiveIB.balances.reduce((sum, b) => sum + b.amount, 0)
|
||
|
||
if (Math.abs(ibTotal) > 0.01) {
|
||
warnings.push(`Ingående balanser balanserar inte (differens: ${ibTotal.toFixed(2)} kr). En automatisk justeringspost mot konto 2099 skapas vid import.`)
|
||
}
|
||
|
||
// Completed fiscal year whose vouchers leave a residual on P&L accounts:
|
||
// the year's result was never transferred to equity (omföring saknas).
|
||
// Later years derive their opening balance from balance-sheet accounts
|
||
// only, so the residual becomes a permanent balansräkning differens for
|
||
// every subsequent year. SIE amounts are debit-positive, so the class 3-8
|
||
// sum is the un-transferred result with flipped sign.
|
||
const currentFiscalYear = parsed.header.fiscalYears.find((fy) => fy.yearIndex === 0)
|
||
if (currentFiscalYear?.end && currentFiscalYear.end < formatLocalDate(new Date())) {
|
||
const plResidual = parsed.vouchers.reduce(
|
||
(sum, voucher) =>
|
||
sum +
|
||
voucher.lines.reduce(
|
||
(lineSum, line) =>
|
||
lineSum + (isBalanceSheetAccount(line.account) ? 0 : line.amount),
|
||
0
|
||
),
|
||
0
|
||
)
|
||
if (Math.abs(plResidual) > 0.01) {
|
||
warnings.push(
|
||
`Räkenskapsåret är avslutat men filen saknar omföring av årets resultat (${Math.abs(plResidual).toFixed(2)} kr ligger kvar på resultatkonton). ` +
|
||
`Om senare räkenskapsår importeras kommer balansräkningen att visa en differens på ${Math.abs(plResidual).toFixed(2)} kr tills omföringen bokförs.`
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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('')
|
||
}
|