feat: arcim migration wizard UX, import fixes, Sentry setup (#22)

* feat: import system improvements, INK2 fix, and Swedish text corrections

- 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>

* feat: arcim migration wizard UX fixes, Sentry setup, and extension scaffolding

Arcim migration wizard improvements:
- Progress bar now excludes non-interactive steps (migrating/result)
- Fix OAuth text to match target="_blank" behavior (new tab, not redirect)
- Display month names instead of "Månad X" in preview
- Fix Swedish typo "förifylla" in no-company-info message
- Replace native checkboxes with shadcn Switch in options step
- Add ConfirmationDialog before starting migration
- Show progress percentage during migration
- Add "Nästa steg" guidance and navigation links in result step
- Add "Försök igen" button in error state (returns to options)
- Add Bokio company ID help text (GUID from URL)
- Add Fortnox integration add-on hint on connection failure

Also includes: SIE import system improvements, INK2 fixes, Swedish text
corrections, Sentry error tracking setup, and arcim-migration extension
scaffolding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback

- Fix OAuth error recovery blank page (restore provider from URL params)
- Pass real userId to MigrationWizard instead of empty string
- Remove ~50 debug console.log statements from sie-import.ts
- Fix comment referencing account 3740 → 3741

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-13 16:24:54 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 82ad630448
commit 2ad8731dc9
44 changed files with 5108 additions and 122 deletions
+2 -2
View File
@@ -49,7 +49,7 @@ describe('sectors registry', () => {
})
it('should have 8 total extensions', () => {
expect(getAllExtensions().length).toBe(8)
expect(getAllExtensions().length).toBe(9)
})
it('should have unique slugs within each sector', () => {
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
it('getExtensionsBySector returns extensions for a sector', () => {
const extensions = getExtensionsBySector('general')
expect(extensions.length).toBe(8)
expect(extensions.length).toBe(9)
})
it('all extensions have required fields', () => {
@@ -3,4 +3,5 @@
export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'enable-banking',
'email',
'arcim-migration',
])
@@ -2,8 +2,10 @@
import type { Extension } from '../types'
import { enableBankingExtension } from '@/extensions/general/enable-banking'
import { emailExtension } from '@/extensions/general/email'
import { arcimMigrationExtension } from '@/extensions/general/arcim-migration'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
enableBankingExtension,
emailExtension,
arcimMigrationExtension,
]
@@ -30,5 +30,15 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"company_settings"
]
},
{
"slug": "arcim-migration",
"name": "Systemmigration (Arcim Sync)",
"sector": "general",
"category": "import",
"icon": "ArrowRightLeft",
"dataPattern": "manual",
"description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
"longDescription": "Flytta all bokföringsdata från ditt gamla system till gnubok. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration."
},
],
}
@@ -5,4 +5,5 @@ import type { WorkspaceComponentProps } from '../workspace-registry'
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
}
+1
View File
@@ -13,6 +13,7 @@ const LEGACY_GENERAL_EXTENSIONS = [
'ai-categorization',
'ai-chat',
'enable-banking',
'arcim-migration',
]
export async function isExtensionEnabled(
+80 -7
View File
@@ -8,6 +8,7 @@ import {
getMappingStats,
applyMappingOverride,
mappingsToMap,
isSystemAccount,
} from '../account-mapper'
// --- Helpers ---
@@ -54,6 +55,8 @@ const basAccounts: BASAccount[] = [
makeBASAccount('1510', 'Kundfordringar'),
makeBASAccount('1930', 'Företagskonto'),
makeBASAccount('2440', 'Leverantörsskulder'),
makeBASAccount('2640', 'Ingående moms'),
makeBASAccount('2641', 'Debiterad ingående moms'),
makeBASAccount('3001', 'Försäljning varor 25%'),
makeBASAccount('3002', 'Försäljning varor 12%'),
makeBASAccount('5010', 'Lokalhyra'),
@@ -94,7 +97,7 @@ describe('suggestMappings', () => {
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('3400')
expect(result[0].targetName).toBe('Försäljning tjänster')
expect(result[0].confidence).toBe(0.9)
expect(result[0].confidence).toBe(0.7)
expect(result[0].matchType).toBe('bas_range')
})
@@ -105,7 +108,7 @@ describe('suggestMappings', () => {
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('1241')
expect(result[0].targetName).toBe('Personbilar')
expect(result[0].confidence).toBe(0.9)
expect(result[0].confidence).toBe(0.7)
expect(result[0].matchType).toBe('bas_range')
})
@@ -164,9 +167,9 @@ describe('suggestMappings', () => {
// Unmapped (confidence 0) should come first
expect(result[0].sourceAccount).toBe('9999')
expect(result[0].confidence).toBe(0)
// bas_range (confidence 0.9) next
// bas_range (confidence 0.7) next
expect(result[1].sourceAccount).toBe('3400')
expect(result[1].confidence).toBe(0.9)
expect(result[1].confidence).toBe(0.7)
// Exact matches (confidence 1.0) come last
expect(result[2].confidence).toBe(1.0)
expect(result[3].confidence).toBe(1.0)
@@ -193,13 +196,33 @@ describe('suggestMappings', () => {
expect(result).toHaveLength(0)
})
it('redirects group header account 2640 to posting account 2641', () => {
const source = [makeSIEAccount('2640', 'Ingående moms')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].sourceAccount).toBe('2640')
expect(result[0].targetAccount).toBe('2641')
expect(result[0].targetName).toBe('Debiterad ingående moms')
expect(result[0].confidence).toBe(1.0)
expect(result[0].matchType).toBe('exact')
})
it('does not redirect 2641 (it is the posting account, not a group header)', () => {
const source = [makeSIEAccount('2641', 'Debiterad ingående moms')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('2641')
})
it('handles empty BAS accounts — bas_range fallback for valid accounts', () => {
const source = [makeSIEAccount('1510', 'Kundfordringar')]
const result = suggestMappings(source, [])
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('1510')
expect(result[0].confidence).toBe(0.9)
expect(result[0].confidence).toBe(0.7)
expect(result[0].matchType).toBe('bas_range')
})
@@ -360,8 +383,8 @@ describe('getMappingStats', () => {
)
const stats = getMappingStats(mappings)
// Average of (1.0 + 0.9) / 2 = 0.95
expect(stats.averageConfidence).toBe(0.95)
// Average of (1.0 + 0.7) / 2 = 0.85
expect(stats.averageConfidence).toBe(0.85)
})
it('returns 0 average confidence when nothing is mapped', () => {
@@ -446,3 +469,53 @@ describe('mappingsToMap', () => {
expect(map.size).toBe(1)
})
})
describe('isSystemAccount', () => {
it('returns true for Fortnox system account 0099', () => {
expect(isSystemAccount('0099')).toBe(true)
})
it('returns true for other 0xxx accounts', () => {
expect(isSystemAccount('0001')).toBe(true)
expect(isSystemAccount('0500')).toBe(true)
expect(isSystemAccount('0999')).toBe(true)
})
it('returns false for valid BAS accounts (1000-8999)', () => {
expect(isSystemAccount('1000')).toBe(false)
expect(isSystemAccount('1510')).toBe(false)
expect(isSystemAccount('3001')).toBe(false)
expect(isSystemAccount('8999')).toBe(false)
})
it('returns false for 9000+ accounts (handled separately as out-of-range)', () => {
expect(isSystemAccount('9000')).toBe(false)
expect(isSystemAccount('9999')).toBe(false)
})
it('returns false for non-4-digit numbers', () => {
expect(isSystemAccount('099')).toBe(false)
expect(isSystemAccount('00099')).toBe(false)
expect(isSystemAccount('abc')).toBe(false)
expect(isSystemAccount('')).toBe(false)
})
it('allows pre-filtering system accounts before suggestMappings', () => {
const allAccounts = [
makeSIEAccount('0099', 'Systemkonto'),
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('1930', 'Företagskonto'),
]
const bookkeepingAccounts = allAccounts.filter((a) => !isSystemAccount(a.number))
const excluded = allAccounts.filter((a) => isSystemAccount(a.number))
expect(bookkeepingAccounts).toHaveLength(2)
expect(excluded).toHaveLength(1)
expect(excluded[0].number).toBe('0099')
const mappings = suggestMappings(bookkeepingAccounts, basAccounts)
expect(mappings).toHaveLength(2)
expect(mappings.every((m) => m.targetAccount)).toBe(true)
})
})
+114 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { generateImportPreview } from '../sie-import'
import { generateImportPreview, validateIBBalance, isBalanceSheetAccount } from '../sie-import'
import type { ParsedSIEFile, AccountMapping } from '../types'
// --- Helpers ---
@@ -221,3 +221,116 @@ describe('generateImportPreview', () => {
})
})
})
describe('validateIBBalance', () => {
it('returns 0 roundingAdjustment when IB is balanced', () => {
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '2440', amount: -50000 },
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
expect(result.roundingAdjustment).toBe(0)
expect(result.fileImbalance).toBe(0)
expect(result.excludedAccountsTotal).toBe(0)
expect(result.lines).toHaveLength(2)
})
it('returns rounding adjustment for imbalance <= 1 SEK', () => {
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000.50 },
{ yearIndex: 0, account: '2440', amount: -50000 },
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
expect(result.roundingAdjustment).toBe(0.5)
expect(result.fileImbalance).toBe(0.5)
})
it('returns large adjustment for file-level imbalance (unallocated årets resultat)', () => {
// Simulates a Fortnox export where previous year result hasn't been allocated
// to equity — BS accounts don't balance because årets resultat is implicit
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50100 },
{ yearIndex: 0, account: '2440', amount: -50000 },
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
// The adjustment is 100 SEK — caller should book to 2099, never reject
expect(result.roundingAdjustment).toBe(100)
expect(result.fileImbalance).toBe(100)
expect(result.excludedAccountsTotal).toBe(0)
})
it('tracks excluded accounts separately from file imbalance (Fortnox system accounts)', () => {
// Simulates Fortnox 0099 carrying IB balance — file is balanced,
// but mapped accounts are not because 0099 is excluded from mapping
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '2440', amount: -150000 },
{ yearIndex: 0, account: '0099', amount: 100000 }, // System account, not mapped
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
// File-level: 50000 + (-150000) + 100000 = 0, balanced
expect(result.fileImbalance).toBe(0)
// Mapped-level: 50000 debit, 150000 credit = -100000 diff
expect(result.roundingAdjustment).toBe(-100000)
// The excluded 0099 accounts for the entire difference
expect(result.excludedAccountsTotal).toBe(100000)
// Only 2 lines (0099 excluded)
expect(result.lines).toHaveLength(2)
})
it('ignores non-current-year balances', () => {
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '2440', amount: -50000 },
{ yearIndex: -1, account: '1510', amount: 99999 }, // Previous year — ignored
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
expect(result.roundingAdjustment).toBe(0)
expect(result.lines).toHaveLength(2)
})
})
describe('isBalanceSheetAccount', () => {
it('returns true for class 1 (assets)', () => {
expect(isBalanceSheetAccount('1510')).toBe(true)
expect(isBalanceSheetAccount('1930')).toBe(true)
})
it('returns true for class 2 (liabilities/equity)', () => {
expect(isBalanceSheetAccount('2099')).toBe(true)
expect(isBalanceSheetAccount('2440')).toBe(true)
})
it('returns false for class 3 (revenue)', () => {
expect(isBalanceSheetAccount('3001')).toBe(false)
expect(isBalanceSheetAccount('3740')).toBe(false)
})
it('returns false for class 4-8 (expenses)', () => {
expect(isBalanceSheetAccount('4010')).toBe(false)
expect(isBalanceSheetAccount('5010')).toBe(false)
expect(isBalanceSheetAccount('6211')).toBe(false)
expect(isBalanceSheetAccount('7210')).toBe(false)
expect(isBalanceSheetAccount('8999')).toBe(false)
})
})
+139 -3
View File
@@ -383,7 +383,7 @@ describe('validateSIEFile', () => {
expect(validation.errors.some((e) => e.includes('not balanced'))).toBe(true)
})
it('adds warning for undefined account references', () => {
it('no longer warns about accounts referenced in #IB since parser auto-adds them', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
@@ -394,9 +394,12 @@ describe('validateSIEFile', () => {
].join('\n')
const parsed = parseSIEFile(content)
const validation = validateSIEFile(parsed)
// Parser now auto-adds 9999 to accounts list from #IB data
expect(parsed.accounts.map((a) => a.number)).toContain('9999')
expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(true)
const validation = validateSIEFile(parsed)
// No warning since account was auto-added by the parser
expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(false)
})
it('adds error for missing #RAR', () => {
@@ -436,6 +439,60 @@ describe('validateSIEFile', () => {
// --- Fix 2: Windows-1252 encoding detection and decoding ---
describe('detectEncoding — #FORMAT PC8 detection', () => {
it('returns cp437 when #FORMAT PC8 is present in the first 500 bytes', () => {
const text = '#FLAGGA 0\n#FORMAT PC8\n#SIETYP 4\n'
const encoder = new TextEncoder()
const buf = encoder.encode(text)
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('cp437')
})
it('returns cp437 even when Win-1252 bytes follow #FORMAT PC8', () => {
// #FORMAT PC8 header should take priority over any byte analysis
const prefix = new TextEncoder().encode('#FORMAT PC8\n#FNAMN F')
const buf = new Uint8Array(prefix.length + 3)
buf.set(prefix)
buf[prefix.length] = 0xf6 // ö in Win-1252
buf[prefix.length + 1] = 0xe4 // ä in Win-1252
buf[prefix.length + 2] = 0xe5 // å in Win-1252
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('cp437')
})
})
describe('detectEncoding — range-based discrimination', () => {
it('detects CP437 when bytes are in 0x80-0x9F range only', () => {
// 0x84=ä, 0x86=å, 0x94=ö in CP437 — all in 0x80-0x9F
const buf = new Uint8Array([0x23, 0x84, 0x86, 0x94, 0x84, 0x86])
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('cp437')
})
it('detects Win-1252 when bytes are in 0xC0-0xFF range only', () => {
// 0xE4=ä, 0xE5=å, 0xF6=ö in Win-1252 — all in 0xC0-0xFF
const buf = new Uint8Array([0x23, 0xe4, 0xe5, 0xf6, 0xe4, 0xe5])
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('windows1252')
})
it('does not double-count UTF-8 continuation bytes as CP437', () => {
// UTF-8: ä = C3 A4, å = C3 A5, ö = C3 B6
// Without skipping, 0xA4/0xA5/0xB6 are NOT in CP437 map so no false count,
// but 0x84/0x85 ARE in CP437 map — test that C3 84 (Ä in UTF-8) is not
// counted as CP437 0x84 (ä)
const buf = new Uint8Array([
0x23, // #
0xc3, 0x84, // Ä in UTF-8
0xc3, 0x85, // Å in UTF-8
0xc3, 0x96, // Ö in UTF-8
0xc3, 0xa4, // ä in UTF-8
0xc3, 0xa5, // å in UTF-8
])
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('utf8')
})
})
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"
@@ -662,3 +719,82 @@ describe('parseSIEFile — missing amount handling', () => {
expect(result.openingBalances[0].amount).toBe(100000)
})
})
// --- Fix B4: Account collection from transaction data ---
describe('parseSIEFile — account collection from transaction data', () => {
it('adds accounts from #TRANS that are missing from #KONTO', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
// 3001 is NOT defined in #KONTO but used in #TRANS
'#VER A 1 20240115 "Test"',
'{',
'#TRANS 1510 {} 10000.00',
'#TRANS 3001 {} -10000.00',
'}',
].join('\n')
const result = parseSIEFile(content)
// Should have both 1510 (from #KONTO) and 3001 (from #TRANS)
expect(result.accounts.map((a) => a.number)).toContain('1510')
expect(result.accounts.map((a) => a.number)).toContain('3001')
// The auto-added account should have empty name
const added = result.accounts.find((a) => a.number === '3001')
expect(added?.name).toBe('')
})
it('adds accounts from #IB that are missing from #KONTO', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#IB 0 1510 50000.00',
'#IB 0 2440 -50000.00', // 2440 not in #KONTO
].join('\n')
const result = parseSIEFile(content)
expect(result.accounts.map((a) => a.number)).toContain('2440')
})
it('does not duplicate accounts already in #KONTO', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#KONTO 3001 "Försäljning"',
'#VER A 1 20240115 "Test"',
'{',
'#TRANS 1510 {} 10000.00',
'#TRANS 3001 {} -10000.00',
'}',
].join('\n')
const result = parseSIEFile(content)
const count1510 = result.accounts.filter((a) => a.number === '1510').length
expect(count1510).toBe(1)
})
it('adds accounts from #UB and #RES that are missing from #KONTO', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#UB 0 1930 100000.00', // 1930 not in #KONTO
'#RES 0 3001 -50000.00', // 3001 not in #KONTO
].join('\n')
const result = parseSIEFile(content)
expect(result.accounts.map((a) => a.number)).toContain('1930')
expect(result.accounts.map((a) => a.number)).toContain('3001')
})
})
+37 -1
View File
@@ -25,6 +25,25 @@ export type MappableAccount = {
account_name: string
}
// Group header accounts that should redirect to their posting sub-account.
// These are BAS group headers not meant for direct posting.
const GROUP_HEADER_REDIRECTS: Record<string, string> = {
'2640': '2641', // Ingående moms → Debiterad ingående moms
}
/**
* Check if an account is a source-system internal account that should be
* excluded from import. BAS accounts use classes 1-8 (1000-8999). Account
* numbers starting with 0 (e.g. Fortnox 0099) are internal system accounts
* with no BAS equivalent — they should be silently filtered out rather than
* forcing the user to map them.
*/
export function isSystemAccount(accountNumber: string): boolean {
if (!/^\d{4}$/.test(accountNumber)) return false
const num = parseInt(accountNumber, 10)
return num < 1000
}
/**
* Check if an account number is in the valid BAS range (1000-8999).
* Standard Swedish BAS accounts are 4-digit numbers in classes 1-8.
@@ -60,6 +79,23 @@ function findBestMatch(
)
if (exactMatch) {
// Redirect group header accounts to their posting sub-account
const redirect = GROUP_HEADER_REDIRECTS[exactMatch.account_number]
if (redirect) {
const redirectTarget = basAccounts.find((a) => a.account_number === redirect)
if (redirectTarget) {
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: redirectTarget.account_number,
targetName: redirectTarget.account_name,
confidence: 1.0,
matchType: 'exact',
isOverride: false,
}
}
}
return {
sourceAccount: source.number,
sourceName: source.name,
@@ -80,7 +116,7 @@ function findBestMatch(
sourceName: source.name,
targetAccount: source.number,
targetName: source.name,
confidence: 0.9,
confidence: 0.7,
matchType: 'bas_range',
isOverride: false,
}
+715 -51
View File
@@ -14,16 +14,22 @@ import type {
ImportResult,
ImportPreview,
SIEImport,
MigrationDocumentation,
} from './types'
import type { CreateJournalEntryLineInput } from '@/types'
import { mappingsToMap, getMappingStats } from './account-mapper'
import { calculateFileHash } from './sie-parser'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
/**
* Format a date to ISO date string (YYYY-MM-DD)
*/
function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
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}`
}
/**
@@ -68,6 +74,7 @@ export function generateImportPreview(
unmapped: mappingStats.unmapped,
lowConfidence: mappingStats.lowConfidence,
},
excludedSystemAccounts: [],
issues: parsed.issues,
}
}
@@ -157,14 +164,82 @@ async function ensureFiscalPeriod(
}
/**
* Create opening balance journal entry from IB amounts
* Compute IB imbalance and validate it before creating the opening balance entry.
*
* Distinguishes between:
* - File-level imbalance: the raw SIE #IB data doesn't balance (source file error)
* - Mapping-level imbalance: caused by excluded accounts (system accounts like Fortnox 0099)
* that carry IB balances but are correctly filtered from mapping. This is expected and
* should be booked to 2099 with clear documentation.
*/
export function validateIBBalance(
parsed: ParsedSIEFile,
accountMap: Map<string, string>
): {
lines: CreateJournalEntryLineInput[]
roundingAdjustment: number
fileImbalance: number
excludedAccountsTotal: number
} {
const currentYearBalances = parsed.openingBalances.filter((b) => b.yearIndex === 0)
// First: check the raw file-level IB balance (all accounts, before mapping)
const rawTotal = currentYearBalances.reduce((sum, b) => sum + b.amount, 0)
const fileImbalance = Math.round(Math.abs(rawTotal) * 100) / 100
// Build mapped lines and track excluded account totals
const lines: CreateJournalEntryLineInput[] = []
let excludedTotal = 0
for (const balance of currentYearBalances) {
const targetAccount = accountMap.get(balance.account)
if (!targetAccount) {
// Account not in mapping (system account or unmapped) — track its IB contribution
excludedTotal += balance.amount
continue
}
if (balance.amount > 0) {
lines.push({
account_number: targetAccount,
debit_amount: balance.amount,
credit_amount: 0,
line_description: `IB ${balance.account}`,
})
} else if (balance.amount < 0) {
lines.push({
account_number: targetAccount,
debit_amount: 0,
credit_amount: Math.abs(balance.amount),
line_description: `IB ${balance.account}`,
})
}
}
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const mappedDiff = Math.round((totalDebit - totalCredit) * 100) / 100
return {
lines,
roundingAdjustment: Math.abs(mappedDiff) > 0.01 ? mappedDiff : 0,
fileImbalance,
excludedAccountsTotal: Math.round(excludedTotal * 100) / 100,
}
}
/**
* Create opening balance journal entry from IB amounts.
* The caller must validate the IB balance first via validateIBBalance().
* If roundingAdjustment is non-zero, it is booked explicitly to 2099 with clear text.
*/
async function createOpeningBalanceEntry(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
accountMap: Map<string, string>
accountMap: Map<string, string>,
roundingAdjustment: number
): Promise<string | null> {
const currentYearBalances = parsed.openingBalances.filter((b) => b.yearIndex === 0)
@@ -177,11 +252,8 @@ async function createOpeningBalanceEntry(
for (const balance of currentYearBalances) {
const targetAccount = accountMap.get(balance.account)
if (!targetAccount) {
continue // Skip unmapped accounts
}
if (!targetAccount) continue
// Opening balances: positive = debit, negative = credit
if (balance.amount > 0) {
lines.push({
account_number: targetAccount,
@@ -203,27 +275,21 @@ async function createOpeningBalanceEntry(
return null
}
// Check if balanced
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const diff = Math.abs(totalDebit - totalCredit)
// If not balanced, add an adjustment line to equity
if (diff > 0.01) {
const adjustment = totalDebit - totalCredit
if (adjustment > 0) {
// Add explicit rounding adjustment if needed (pre-validated by caller, <= 1 SEK)
if (Math.abs(roundingAdjustment) > 0.01) {
if (roundingAdjustment > 0) {
lines.push({
account_number: '2099', // Årets resultat (or similar equity account)
account_number: '2099',
debit_amount: 0,
credit_amount: adjustment,
line_description: 'Balanseringsdifferens',
credit_amount: roundingAdjustment,
line_description: `Avrundningsdifferens vid SIE-import, ${roundingAdjustment} SEK`,
})
} else {
lines.push({
account_number: '2099',
debit_amount: Math.abs(adjustment),
debit_amount: Math.abs(roundingAdjustment),
credit_amount: 0,
line_description: 'Balanseringsdifferens',
line_description: `Avrundningsdifferens vid SIE-import, ${roundingAdjustment} SEK`,
})
}
}
@@ -253,15 +319,58 @@ async function importVouchers(
parsed: ParsedSIEFile,
accountMap: Map<string, string>,
voucherSeries: string
): Promise<{ created: number; ids: string[]; errors: string[] }> {
): Promise<{
created: number
ids: string[]
errors: string[]
skippedEmpty: number
skippedSingleLine: number
skippedUnbalanced: number
skippedUnmapped: number
movementsByAccount: Map<string, number>
skippedDetails: {
voucherId: string
date: string
description: string
reason: 'unmapped' | 'empty' | 'unbalanced' | 'zero_lines' | 'single_line'
unmappedAccounts?: string[]
balanceDiff?: number
totalDebit?: number
totalCredit?: number
sourceLines?: { account: string; amount: number }[]
mappedLineCount?: number
originalLineCount?: number
}[]
voucherNumberMapping: Array<{ sourceId: string; targetNumber: number }>
}> {
const results = {
created: 0,
ids: [] as string[],
errors: [] as string[],
skippedEmpty: 0,
skippedSingleLine: 0,
skippedUnbalanced: 0,
skippedUnmapped: 0,
movementsByAccount: new Map<string, number>(),
skippedDetails: [] as {
voucherId: string
date: string
description: string
reason: 'unmapped' | 'empty' | 'unbalanced' | 'zero_lines' | 'single_line'
unmappedAccounts?: string[]
balanceDiff?: number
totalDebit?: number
totalCredit?: number
sourceLines?: { account: string; amount: number }[]
mappedLineCount?: number
originalLineCount?: number
}[],
voucherNumberMapping: [] as Array<{ sourceId: string; targetNumber: number }>,
}
// Pre-filter and prepare all valid vouchers
interface PreparedVoucher {
sourceId: string
date: string
description: string
lines: { account_number: string; debit_amount: number; credit_amount: number; line_description: string | null }[]
@@ -272,15 +381,14 @@ async function importVouchers(
for (const voucher of parsed.vouchers) {
const lines: PreparedVoucher['lines'] = []
let hasUnmappedAccount = false
const unmappedAccountSet = new Set<string>()
for (const line of voucher.lines) {
const targetAccount = accountMap.get(line.account)
if (!targetAccount) {
hasUnmappedAccount = true
results.errors.push(
`Voucher ${voucher.series}${voucher.number}: Unmapped account ${line.account}`
)
unmappedAccountSet.add(line.account)
continue
}
@@ -300,30 +408,117 @@ async function importVouchers(
line_description: line.description || null,
})
}
// Note: lines with amount === 0 are silently dropped
}
// Skip vouchers with unmapped accounts or too few lines
if (hasUnmappedAccount || lines.length < 2) {
const voucherId = `${voucher.series}${voucher.number}`
const voucherDate = formatDate(voucher.date)
// Skip vouchers with unmapped accounts
if (hasUnmappedAccount) {
results.skippedDetails.push({
voucherId,
date: voucherDate,
description: voucher.description,
reason: 'unmapped',
unmappedAccounts: [...unmappedAccountSet],
mappedLineCount: lines.length,
originalLineCount: voucher.lines.length,
sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
})
results.skippedUnmapped++
continue
}
// Validate balance
// Fix 3: Separate empty (0 lines) from single-line vouchers
if (lines.length === 0) {
results.skippedDetails.push({
voucherId,
date: voucherDate,
description: voucher.description,
reason: 'zero_lines',
mappedLineCount: 0,
originalLineCount: voucher.lines.length,
sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
})
results.skippedEmpty++
continue
}
if (lines.length === 1) {
results.skippedDetails.push({
voucherId,
date: voucherDate,
description: voucher.description,
reason: 'single_line',
mappedLineCount: 1,
originalLineCount: voucher.lines.length,
sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
})
results.skippedSingleLine++
continue
}
// Validate balance — Fix 2: Tiered rounding with öresutjämning (3741)
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
if (Math.abs(totalDebit - totalCredit) > 0.01) {
results.errors.push(
`Voucher ${voucher.series}${voucher.number}: Not balanced (debit: ${totalDebit}, credit: ${totalCredit})`
)
const balanceDiff = Math.round(Math.abs(totalDebit - totalCredit) * 100) / 100
if (balanceDiff > 1.00) {
// More than 1 SEK off — incomplete voucher in source system, skip
results.skippedDetails.push({
voucherId,
date: voucherDate,
description: voucher.description,
reason: 'unbalanced',
balanceDiff,
totalDebit: Math.round(totalDebit * 100) / 100,
totalCredit: Math.round(totalCredit * 100) / 100,
mappedLineCount: lines.length,
originalLineCount: voucher.lines.length,
sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
})
results.skippedUnbalanced++
continue
} else if (balanceDiff > 0.005) {
// Rounding difference <= 1 SEK — add explicit öresutjämning line (never modify existing lines)
const roundedDiff = Math.round((totalDebit - totalCredit) * 100) / 100
if (roundedDiff > 0) {
lines.push({
account_number: '3741',
debit_amount: 0,
credit_amount: Math.abs(roundedDiff),
line_description: 'Öresutjämning',
})
} else {
lines.push({
account_number: '3741',
debit_amount: Math.abs(roundedDiff),
credit_amount: 0,
line_description: 'Öresutjämning',
})
}
}
preparedVouchers.push({
sourceId: voucherId,
date: formatDate(voucher.date),
description: voucher.description || `Import: ${voucher.series}${voucher.number}`,
lines,
})
}
// Compute per-account net movements from vouchers that will be imported.
// Used for UB/RES reconciliation to generate migration adjustment entries.
for (const v of preparedVouchers) {
for (const l of v.lines) {
const net = l.debit_amount - l.credit_amount
results.movementsByAccount.set(
l.account_number,
(results.movementsByAccount.get(l.account_number) || 0) + net
)
}
}
if (preparedVouchers.length === 0) {
return results
}
@@ -404,6 +599,7 @@ async function importVouchers(
if (!entryId) continue
const voucher = batch[i]
const assignedNumber = currentVoucherNumber + batchStart + i
voucher.lines.forEach((line, lineIndex) => {
allLines.push({
journal_entry_id: entryId,
@@ -417,6 +613,12 @@ async function importVouchers(
})
})
// Fix 7: Capture voucher number mapping (source → target)
results.voucherNumberMapping.push({
sourceId: voucher.sourceId,
targetNumber: assignedNumber,
})
results.ids.push(entryId)
results.created++
}
@@ -433,11 +635,254 @@ async function importVouchers(
}
}
// Update voucher sequence to reflect all assigned numbers.
// next_voucher_number() was called once but we assigned N numbers manually,
// so the sequence only got incremented by 1. Fix with GREATEST to avoid races.
if (results.created > 0) {
const highestUsed = currentVoucherNumber + preparedVouchers.length - 1
await supabase.rpc('reserve_voucher_range', {
p_user_id: userId,
p_fiscal_period_id: fiscalPeriodId,
p_series: voucherSeries,
p_highest_used: highestUsed,
})
}
return results
}
/**
* Record the import in the database
* 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
}
/**
* Create a migration adjustment entry (omföringsverifikation) to reconcile
* imported voucher movements against the SIE file's closing balances.
*
* When unbalanced vouchers are skipped during import, the sum of imported
* movements will differ from the true account balances computed by the source
* system. This function:
* 1. Computes expected net movements from #UB (balance sheet) and #RES (result),
* separated by account class per Fix 8
* 2. Compares against actual imported movements
* 3. Books the per-account delta as a proper omföringsverifikation
*
* Per BFL 1999:1078 and BFNAR 2013:2, corrections must be documented through
* verifikationer with clear descriptions. This satisfies that requirement.
*/
async function createMigrationAdjustmentEntry(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
accountMap: Map<string, string>,
importedMovements: Map<string, number>,
skippedDetails: {
voucherId: string
date: string
reason: string
}[]
): Promise<{ entryId: string | null; deltaAccounts: number; warnings: string[] }> {
const warnings: string[] = []
const hasUB = parsed.closingBalances.some((b) => b.yearIndex === 0)
const hasRES = parsed.resultBalances.some((b) => b.yearIndex === 0)
if (!hasUB && !hasRES) {
return { entryId: null, deltaAccounts: 0, warnings }
}
// Fix 8: Separate BS/P&L reconciliation
// For BS accounts (class 1-2): expectedMovement = UB - IB (ignore RES)
// For P&L accounts (class 3-8): expectedMovement = RES (ignore IB/UB)
const expectedMovements = new Map<string, number>()
// Process IB — only for balance sheet accounts
for (const ib of parsed.openingBalances.filter((b) => b.yearIndex === 0)) {
const target = accountMap.get(ib.account)
if (!target) continue
if (!isBalanceSheetAccount(target)) {
// P&L account appearing in IB — likely malformed SIE
warnings.push(`P&L-konto ${ib.account} (→${target}) förekommer i #IB — ignoreras för resultaträkning`)
continue
}
expectedMovements.set(target, (expectedMovements.get(target) || 0) - ib.amount)
}
// Process UB — only for balance sheet accounts
for (const ub of parsed.closingBalances.filter((b) => b.yearIndex === 0)) {
const target = accountMap.get(ub.account)
if (!target) continue
if (!isBalanceSheetAccount(target)) {
warnings.push(`P&L-konto ${ub.account} (→${target}) förekommer i #UB — ignoreras för resultaträkning`)
continue
}
expectedMovements.set(target, (expectedMovements.get(target) || 0) + ub.amount)
}
// Process RES — only for P&L accounts
for (const res of parsed.resultBalances.filter((b) => b.yearIndex === 0)) {
const target = accountMap.get(res.account)
if (!target) continue
if (isBalanceSheetAccount(target)) {
warnings.push(`Balanskonto ${res.account} (→${target}) förekommer i #RES — ignoreras för balansräkning`)
continue
}
expectedMovements.set(target, (expectedMovements.get(target) || 0) + res.amount)
}
// Compute per-account delta: expected - imported
const lines: CreateJournalEntryLineInput[] = []
const allAccounts = new Set([...expectedMovements.keys(), ...importedMovements.keys()])
let deltaAccountCount = 0
for (const account of allAccounts) {
const expected = expectedMovements.get(account) || 0
const imported = importedMovements.get(account) || 0
const delta = Math.round((expected - imported) * 100) / 100
if (Math.abs(delta) < 0.01) continue
deltaAccountCount++
// Fix 4: Per-line text referencing what the adjustment concerns
const lineDesc = `Justering konto ${account}: delta ${delta} SEK från ${skippedDetails.length} exkl. verifikationer`
if (delta > 0) {
lines.push({
account_number: account,
debit_amount: delta,
credit_amount: 0,
line_description: lineDesc,
})
} else {
lines.push({
account_number: account,
debit_amount: 0,
credit_amount: Math.abs(delta),
line_description: lineDesc,
})
}
}
if (lines.length === 0) {
return { entryId: null, deltaAccounts: 0, warnings }
}
// The entry must balance. It should by construction, but verify and handle rounding.
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const balanceDiff = Math.round(Math.abs(totalDebit - totalCredit) * 100) / 100
if (balanceDiff > 0.005) {
const roundedDiff = Math.round((totalDebit - totalCredit) * 100) / 100
if (roundedDiff > 0) {
lines.push({
account_number: '3741',
debit_amount: 0,
credit_amount: Math.abs(roundedDiff),
line_description: 'Öresutjämning omföringsverifikation',
})
} else {
lines.push({
account_number: '3741',
debit_amount: Math.abs(roundedDiff),
credit_amount: 0,
line_description: 'Öresutjämning omföringsverifikation',
})
}
}
// Date the adjustment at fiscal year end
const fiscalYearEnd = parsed.stats.fiscalYearEnd
const entryDate = fiscalYearEnd ? formatDate(fiscalYearEnd) : formatDate(new Date())
// Fix 4: Build structured description with skipped voucher details
const skippedIds = skippedDetails.map(d => d.voucherId)
const skippedDates = skippedDetails.map(d => d.date).sort()
const firstId = skippedIds[0] || '?'
const lastId = skippedIds[skippedIds.length - 1] || '?'
const firstDate = skippedDates[0] || '?'
const lastDate = skippedDates[skippedDates.length - 1] || '?'
const entry = await createJournalEntry(supabase, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description: `Omföringsverifikation: justering för ${skippedDetails.length} exkluderade verifikationer (${firstId}–${lastId}, ${firstDate}–${lastDate}) vid SIE-import`,
source_type: 'import',
voucher_series: 'M',
lines,
})
return { entryId: entry.id, deltaAccounts: deltaAccountCount, warnings }
}
/**
* Ensure a specific account exists in the user's chart of accounts.
* Uses BAS reference for metadata when available, falls back to derivation.
*/
async function ensureAccountExists(
supabase: SupabaseClient,
userId: string,
accountNumber: string,
accountName: string
): Promise<void> {
const { data } = await supabase
.from('chart_of_accounts')
.select('id')
.eq('user_id', userId)
.eq('account_number', accountNumber)
.single()
if (data) return // Already exists
const basRef = getBASReference(accountNumber)
if (basRef) {
await supabase.from('chart_of_accounts').insert({
user_id: userId,
account_number: accountNumber,
account_name: basRef.account_name,
account_class: basRef.account_class,
account_group: basRef.account_group,
account_type: basRef.account_type,
normal_balance: basRef.normal_balance,
sru_code: basRef.sru_code ?? computeSRUCode(accountNumber),
k2_excluded: basRef.k2_excluded,
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
})
return
}
// Fallback: derive metadata from account number
const classNum = parseInt(accountNumber.charAt(0), 10)
const group = accountNumber.substring(0, 2)
const accountType = classNum === 1 ? 'asset'
: classNum === 2 ? (group === '21' ? 'untaxed_reserves' : (group === '20' ? 'equity' : 'liability'))
: classNum === 3 ? 'revenue'
: 'expense'
await supabase.from('chart_of_accounts').insert({
user_id: userId,
account_number: accountNumber,
account_name: accountName,
account_class: classNum,
account_group: group,
account_type: accountType,
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
sru_code: computeSRUCode(accountNumber),
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
})
}
/**
* Record the import in the database and archive the SIE file to Supabase Storage.
*/
async function recordImport(
supabase: SupabaseClient,
@@ -445,7 +890,8 @@ async function recordImport(
parsed: ParsedSIEFile,
fileContent: string,
filename: string,
result: ImportResult
result: ImportResult,
documentation?: MigrationDocumentation
): Promise<string> {
const fileHash = await calculateFileHash(fileContent)
@@ -471,6 +917,7 @@ async function recordImport(
fiscal_period_id: result.fiscalPeriodId,
opening_balance_entry_id: result.openingBalanceEntryId,
imported_at: new Date().toISOString(),
migration_documentation: documentation ?? null,
})
.select('id')
.single()
@@ -479,6 +926,22 @@ async function recordImport(
throw new Error(`Failed to record import: ${error?.message}`)
}
// Archive the SIE file to Supabase Storage (BFL 7 kap 1-2§ retention)
const storagePath = `${userId}/${data.id}.se`
const fileBlob = new Blob([fileContent], { type: 'text/plain; charset=cp437' })
const { error: uploadError } = await supabase.storage
.from('sie-files')
.upload(storagePath, fileBlob, { upsert: false })
if (uploadError) {
console.error(`[sie-import] Failed to archive SIE file: ${uploadError.message}`)
} else {
await supabase
.from('sie_imports')
.update({ file_storage_path: storagePath })
.eq('id', data.id)
}
return data.id
}
@@ -592,6 +1055,23 @@ export async function executeSIEImport(
// Build account mapping lookup
const accountMap = mappingsToMap(mappings)
// Ensure all mapped target accounts exist in chart_of_accounts.
// The mapping contains every account referenced in the SIE file; accounts
// that were not seeded during onboarding need to be created here so that
// journal entry lines can link to them via account_id.
const seenTargets = new Set<string>()
for (const mapping of mappings) {
if (mapping.targetAccount && !seenTargets.has(mapping.targetAccount)) {
seenTargets.add(mapping.targetAccount)
await ensureAccountExists(
supabase,
userId,
mapping.targetAccount,
mapping.targetName
)
}
}
// Create or find fiscal period
const fiscalYearStart = parsed.stats.fiscalYearStart
const fiscalYearEnd = parsed.stats.fiscalYearEnd
@@ -626,53 +1106,237 @@ export async function executeSIEImport(
result.fiscalPeriodId = existing.id
}
// Import opening balances
if (options.importOpeningBalances && parsed.openingBalances.length > 0 && result.fiscalPeriodId) {
result.openingBalanceEntryId = await createOpeningBalanceEntry(
supabase,
userId,
result.fiscalPeriodId,
parsed,
accountMap
)
// Track documentation data across import phases
let ibRoundingAdjustment = 0
let migrationAdjustmentInfo = { created: false, deltaAccounts: 0, entryId: null as string | null }
let voucherNumberMapping: Array<{ sourceId: string; targetNumber: number }> = []
let voucherStats = {
total: parsed.vouchers.length,
imported: 0,
skippedUnbalanced: 0,
skippedUnmapped: 0,
skippedSingleLine: 0,
skippedEmpty: 0,
}
const voucherSeries = options.voucherSeries || 'B'
if (result.openingBalanceEntryId) {
result.journalEntriesCreated++
result.journalEntryIds.push(result.openingBalanceEntryId)
// Validate and import opening balances.
//
// IB imbalance is NORMAL in Swedish SIE files for two common reasons:
// 1. Excluded system accounts (Fortnox 0099 etc.) carry IB balances
// 2. Previous year's result (årets resultat) hasn't been allocated to equity
// yet — the profit/loss is implicit, not an explicit IB on 2099
//
// In both cases, the correct treatment is to book the diff to 2099 with
// explicit documentation. We never reject based on IB imbalance — the
// original goal was to stop SILENT equity alteration, not prevent it.
if (options.importOpeningBalances && parsed.openingBalances.length > 0 && result.fiscalPeriodId) {
const ibValidation = validateIBBalance(parsed, accountMap)
if (ibValidation.lines.length > 0) {
const absAdj = Math.abs(ibValidation.roundingAdjustment)
if (absAdj > 0.01) {
ibRoundingAdjustment = ibValidation.roundingAdjustment
// Produce a descriptive warning explaining the source of the imbalance
if (Math.abs(ibValidation.excludedAccountsTotal) > 0.01 && ibValidation.fileImbalance <= 1.00) {
// File-level IB is balanced — imbalance is entirely from excluded system accounts
result.warnings.push(
`Exkluderade systemkonton har IB-saldon på totalt ${ibValidation.excludedAccountsTotal} SEK. ` +
`Differensen (${ibValidation.roundingAdjustment} SEK) bokförs på konto 2099.`
)
} else if (ibValidation.fileImbalance > 1.00) {
// File-level IB doesn't balance — likely unallocated årets resultat from previous year
result.warnings.push(
`Ingående balanser obalanserade med ${ibValidation.roundingAdjustment} SEK ` +
`(troligen ej allokerat årets resultat från föregående räkenskapsår). ` +
`Differensen bokförs på konto 2099 (Årets resultat).`
)
} else {
// Small rounding
result.warnings.push(
`Avrundningsdifferens vid SIE-import: ${ibValidation.roundingAdjustment} SEK bokförd på konto 2099`
)
}
}
result.openingBalanceEntryId = await createOpeningBalanceEntry(
supabase,
userId,
result.fiscalPeriodId,
parsed,
accountMap,
ibRoundingAdjustment
)
if (result.openingBalanceEntryId) {
result.journalEntriesCreated++
result.journalEntryIds.push(result.openingBalanceEntryId)
}
}
}
// Import transactions (SIE4 only)
if (options.importTransactions && parsed.vouchers.length > 0 && result.fiscalPeriodId) {
// Detect partial-year export: if voucher dates don't span the full fiscal year,
// the migration adjustment will produce incorrect large deltas for the missing period.
if (parsed.vouchers.length > 0 && fiscalYearStart && fiscalYearEnd) {
const voucherDates = parsed.vouchers.map(v => v.date.getTime())
const earliestVoucher = new Date(Math.min(...voucherDates))
const latestVoucher = new Date(Math.max(...voucherDates))
// Allow 30 days margin from fiscal year start/end for partial detection
const msPerDay = 86400000
const startGap = earliestVoucher.getTime() - fiscalYearStart.getTime()
const endGap = fiscalYearEnd.getTime() - latestVoucher.getTime()
if (startGap > 60 * msPerDay || endGap > 60 * msPerDay) {
result.warnings.push(
`SIE-filen verkar innehålla ett ofullständigt räkenskapsår: verifikationer ${formatDate(earliestVoucher)}–${formatDate(latestVoucher)}, ` +
`räkenskapsår ${formatDate(fiscalYearStart)}–${formatDate(fiscalYearEnd)}. ` +
`Omföringsverifikationen kan bli felaktig om #UB/#RES avser hela året men verifikationerna bara täcker en del.`
)
}
}
// Ensure öresutjämning account 3741 exists in the user's chart
await ensureAccountExists(supabase, userId, '3741', 'Öresutjämning vid import')
const voucherResults = await importVouchers(
supabase,
userId,
result.fiscalPeriodId,
parsed,
accountMap,
options.voucherSeries || 'B'
voucherSeries
)
result.journalEntriesCreated += voucherResults.created
result.journalEntryIds.push(...voucherResults.ids)
result.errors.push(...voucherResults.errors)
voucherNumberMapping = voucherResults.voucherNumberMapping
// Update stats for documentation
voucherStats = {
total: parsed.vouchers.length,
imported: voucherResults.created,
skippedUnbalanced: voucherResults.skippedUnbalanced,
skippedUnmapped: voucherResults.skippedUnmapped,
skippedSingleLine: voucherResults.skippedSingleLine,
skippedEmpty: voucherResults.skippedEmpty,
}
// Report skipped vouchers as warnings
const totalSkipped = voucherResults.skippedEmpty + voucherResults.skippedSingleLine + voucherResults.skippedUnbalanced + voucherResults.skippedUnmapped
if (totalSkipped > 0) {
const parts: string[] = []
if (voucherResults.skippedEmpty > 0) parts.push(`${voucherResults.skippedEmpty} tomma`)
if (voucherResults.skippedUnbalanced > 0) parts.push(`${voucherResults.skippedUnbalanced} obalanserade`)
if (voucherResults.skippedUnmapped > 0) parts.push(`${voucherResults.skippedUnmapped} med ej mappade konton`)
result.warnings.push(
`${totalSkipped} verifikationer hoppades över (ofullständiga i källsystemet): ${parts.join(', ')}`
)
}
// Fix 3: Specific warning for single-line vouchers
if (voucherResults.skippedSingleLine > 0) {
const singleLineDetails = voucherResults.skippedDetails
.filter(d => d.reason === 'single_line')
.slice(0, 10)
.map(d => d.voucherId)
result.warnings.push(
`${voucherResults.skippedSingleLine} enradsverifikationer hoppades över (kan vara periodiseringar/manuella justeringar): ${singleLineDetails.join(', ')}${voucherResults.skippedSingleLine > 10 ? '...' : ''}`
)
}
// Create migration adjustment entry to reconcile against UB/RES
const totalSkippedForAdjustment = voucherResults.skippedUnbalanced + voucherResults.skippedUnmapped + voucherResults.skippedSingleLine
if (totalSkippedForAdjustment > 0 && result.fiscalPeriodId) {
try {
const adjustment = await createMigrationAdjustmentEntry(
supabase,
userId,
result.fiscalPeriodId,
parsed,
accountMap,
voucherResults.movementsByAccount,
voucherResults.skippedDetails
)
result.warnings.push(...adjustment.warnings)
if (adjustment.entryId) {
result.journalEntriesCreated++
result.journalEntryIds.push(adjustment.entryId)
result.warnings.push(
`Migreringsjustering skapad: ${adjustment.deltaAccounts} konton justerade för att matcha UB/RES från källsystemet`
)
migrationAdjustmentInfo = {
created: true,
deltaAccounts: adjustment.deltaAccounts,
entryId: adjustment.entryId,
}
}
} catch (adjustmentError) {
console.error('[sie-import] Failed to create migration adjustment entry:', adjustmentError)
result.warnings.push(
'Kunde inte skapa migreringsjustering — kontrollera saldon manuellt mot källsystemet'
)
}
}
}
// Save account mappings for future use
await saveMappings(supabase, userId, mappings)
// Record the import
// Fix 6: Generate systemdokumentation (MigrationDocumentation)
const mappingStats = getMappingStats(mappings)
const documentation: MigrationDocumentation = {
sourceSystem: parsed.header.program,
sourceVersion: parsed.header.programVersion,
sieType: parsed.header.sieType,
generatedDate: parsed.header.generatedDate ? formatDate(parsed.header.generatedDate) : null,
fiscalYear: {
start: formatDate(fiscalYearStart),
end: formatDate(fiscalYearEnd),
},
importedAt: new Date().toISOString(),
importedBy: userId,
accountMappings: {
total: mappingStats.total,
exact: mappingStats.exact,
basRange: mappingStats.basRange,
manual: mappingStats.manual,
unmapped: mappingStats.unmapped,
},
vouchers: voucherStats,
openingBalanceRounding: ibRoundingAdjustment !== 0 ? ibRoundingAdjustment : null,
migrationAdjustment: migrationAdjustmentInfo,
voucherSeriesUsed: voucherSeries,
voucherNumberRange: voucherNumberMapping.length > 0
? {
from: voucherNumberMapping[0].targetNumber,
to: voucherNumberMapping[voucherNumberMapping.length - 1].targetNumber,
}
: null,
voucherNumberMapping,
}
// Set success before recording so recordImport() sees correct status
result.success = result.errors.length === 0
// Record the import with documentation
result.importId = await recordImport(
supabase,
userId,
parsed,
options.fileContent,
options.filename,
result
result,
documentation
)
result.success = result.errors.length === 0
// Add warnings for any issues
for (const issue of parsed.issues) {
if (issue.severity === 'warning') {
+124 -29
View File
@@ -24,21 +24,43 @@ import type {
ValidationResult,
} from './types'
// CP437 to UTF-8 mapping for Swedish characters
// CP437 was the standard encoding for DOS/early Windows
// 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: 'Å', // Å
0x99: 'Ö', // Ö
0x84: 'ä', // ä
0x86: 'å', // å
0x94: 'ö', // ö
0x81: 'ü', // ü
0x9a: 'Ü', // Ü
0x92: 'Æ', // Æ (not common but in CP437)
// 0x90-0x9F
0x90: 'É', // É
0x91: 'æ', // æ
0x92: 'Æ', // Æ
0x93: 'ô', // ô
0x94: 'ö', // ö
0x95: 'ò', // ò
0x96: 'û', // û
0x97: 'ù', // ù
0x98: 'ÿ', // ÿ
0x99: 'Ö', // Ö
0x9a: 'Ü', // Ü
0x9b: 'ø', // ø (Norwegian)
0x9c: '£', // £
0x9d: 'Ø', // Ø (Norwegian)
0x9b: 'ø', // ø
0x9e: '×', // ×
0x9f: 'ƒ', // ƒ
}
// Windows-1252 bytes for Swedish characters (superset of ISO-8859-1)
@@ -53,7 +75,16 @@ const WIN1252_SWEDISH_BYTES = new Set([
])
/**
* Detect the encoding of a SIE file by looking for Swedish characters
* 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.
*/
export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
const bytes = new Uint8Array(buffer)
@@ -63,11 +94,27 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
return 'utf8'
}
// 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
// Check for #FORMAT PC8 in the first 500 bytes (ASCII-safe, works regardless of encoding)
const headerSize = Math.min(bytes.length, 500)
const FORMAT_PC8 = [0x23, 0x46, 0x4f, 0x52, 0x4d, 0x41, 0x54, 0x20, 0x50, 0x43, 0x38]
for (let i = 0; i <= headerSize - FORMAT_PC8.length; i++) {
let match = true
for (let j = 0; j < FORMAT_PC8.length; j++) {
if (bytes[i + j] !== FORMAT_PC8[j]) {
match = false
break
}
}
if (match) {
return 'cp437'
}
}
// Scan sample for encoding-specific byte ranges
const sampleSize = Math.min(bytes.length, 2000)
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 < sampleSize; i++) {
const byte = bytes[i]
@@ -88,8 +135,20 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
const nextByte = bytes[i + 1]
if ([0x84, 0x85, 0x96, 0xa4, 0xa5, 0xb6].includes(nextByte)) {
utf8Count++
i++ // Skip continuation byte to avoid false CP437 count (e.g. 0x84 = ä in CP437)
continue
}
}
// CP437 Swedish chars live in 0x80-0x9F
if (byte >= 0x80 && byte <= 0x9f && CP437_MAP[byte]) {
cp437Count++
}
// Windows-1252 Swedish chars live in 0xC0-0xFF
if (WIN1252_SWEDISH_BYTES.has(byte)) {
win1252Count++
}
}
if (utf8Count > cp437Count && utf8Count > win1252Count) return 'utf8'
@@ -277,6 +336,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
address: null,
fiscalYears: [],
currency: 'SEK',
kontoPlanType: null,
}
const accounts: SIEAccount[] = []
@@ -377,6 +437,10 @@ export function parseSIEFile(content: string): ParsedSIEFile {
header.currency = parseStringField(fields[1]) || 'SEK'
break
case 'KPTYP':
header.kontoPlanType = parseStringField(fields[1])
break
case 'RAR': {
// #RAR yearIndex start end
const yearIndex = parseInt(fields[1], 10)
@@ -516,10 +580,16 @@ export function parseSIEFile(content: string): ParsedSIEFile {
break
}
case 'TRANS': {
// #TRANS accountNumber {objectList} amount [date] [description] [quantity] [signature]
case 'TRANS':
case 'RTRANS':
case 'BTRANS': {
// #TRANS/#RTRANS/#BTRANS accountNumber {objectList} amount [date] [description] [quantity] [signature]
// BTRANS = Added/corrected transaction lines (part of the voucher)
// RTRANS = Removed/reversed transaction lines (amounts already have correct sign)
// All three must be included for vouchers to balance correctly.
// Fortnox/Bokio/Visma only emit #TRANS — this is a no-op for those providers.
if (!currentVoucher) {
addIssue(issues, 'error', lineNum, 'TRANS outside of VER block', tag)
addIssue(issues, 'error', lineNum, `${tag} outside of VER block`, tag)
break
}
@@ -534,7 +604,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
const transAmountStr = fields[fieldIndex]
if (!transAmountStr || transAmountStr.trim() === '') {
addIssue(issues, 'warning', lineNum, 'Missing amount in #TRANS, skipping line', tag)
addIssue(issues, 'warning', lineNum, `Missing amount in #${tag}, skipping line`, tag)
break
}
@@ -563,17 +633,9 @@ export function parseSIEFile(content: string): ParsedSIEFile {
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)) {
if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'DIM', 'OBJEKT', 'OIB', 'OUB', 'PBUDGET', 'PSALDO'].includes(tag)) {
addIssue(issues, 'info', lineNum, `Unknown tag: #${tag}`, tag)
}
}
@@ -588,6 +650,28 @@ export function parseSIEFile(content: string): ParsedSIEFile {
}
}
// 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)`)
}
// Calculate statistics
const currentFiscalYear = header.fiscalYears.find((fy) => fy.yearIndex === 0)
const totalTransactionLines = vouchers.reduce((sum, v) => sum + v.lines.length, 0)
@@ -637,6 +721,17 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult {
warnings.push('No accounts found (#KONTO)')
}
// Warn if non-BAS kontoplan declared — mapping logic assumes BAS number ranges
if (parsed.header.kontoPlanType) {
const planType = parsed.header.kontoPlanType.toUpperCase()
const isBAS = planType.startsWith('BAS') || planType === 'EUBAS' || planType === 'EU-BAS'
if (!isBAS) {
warnings.push(
`Kontoplanstyp "${parsed.header.kontoPlanType}" är inte BAS-baserad. Alla kontomappningar bör granskas manuellt.`
)
}
}
// Check for unbalanced vouchers
for (const voucher of parsed.vouchers) {
const total = voucher.lines.reduce((sum, l) => sum + l.amount, 0)
+57
View File
@@ -39,6 +39,7 @@ export interface SIEHeader {
// Fiscal year info
fiscalYears: FiscalYearInfo[] // #RAR
currency: string // #VALUTA (default SEK)
kontoPlanType: string | null // #KPTYP (e.g. 'BAS95', 'BAS96', 'EUBAS')
}
/**
@@ -189,6 +190,7 @@ export interface SIEImport {
fiscal_period_id: string | null
opening_balance_entry_id: string | null
imported_at: string | null
migration_documentation: MigrationDocumentation | null
created_at: string
updated_at: string
}
@@ -284,10 +286,65 @@ export interface ImportPreview {
lowConfidence: number
}
// Source-system accounts excluded from import (e.g. Fortnox 0099)
excludedSystemAccounts: { number: string; name: string }[]
// Issues to review
issues: ParseIssue[]
}
/**
* Structured systemdokumentation per BFNAR 2013:2 Chapter 9.
* Generated at the end of a SIE import and stored in sie_imports.migration_documentation.
*/
export interface MigrationDocumentation {
// Source system info
sourceSystem: string | null // from #PROGRAM
sourceVersion: string | null
sieType: number
generatedDate: string | null // from #GEN
// Import scope
fiscalYear: { start: string; end: string }
importedAt: string
importedBy: string // user_id
// Account mapping
accountMappings: {
total: number
exact: number
basRange: number
manual: number
unmapped: number
}
// Voucher statistics
vouchers: {
total: number
imported: number
skippedUnbalanced: number
skippedUnmapped: number
skippedSingleLine: number
skippedEmpty: number
}
// Adjustments
openingBalanceRounding: number | null // SEK amount if any
migrationAdjustment: {
created: boolean
deltaAccounts: number
entryId: string | null
}
// Voucher number mapping
voucherSeriesUsed: string
voucherNumberRange: { from: number; to: number } | null
voucherNumberMapping: Array<{
sourceId: string // e.g. "A1"
targetNumber: number
}>
}
/**
* Wizard step state
*/