Files
accounted/lib/import/sie-import.ts
T
Jakob Wennberg 03b569d708 refactor: consolidate extension system to general-only with manifest-driven architecture
- Remove all sector-specific extensions (construction, ecommerce, export,
  hotel, restaurant, tech) — only general-purpose extensions remain
- Move NE-bilaga and SRU export from extensions to core reports (lib/reports/)
- Move moms-box-mapping from extensions/export/shared to lib/vat/
- Replace per-extension API routes with catch-all dispatcher
  (app/api/extensions/ext/[...path]/route.ts)
- Add manifest.json for each extension with metadata, env vars, and deps
- Add api-routes.ts pattern for extension-defined API endpoints
- Add code generation scripts (generate-extension-registry, create-extension)
- Add extensions.config.json for opt-in extension loading
- Add extensions.schema.json for config validation
- Add email service interface with noop default (lib/email/service.ts)
- Add CI workflow (core-build.yml) to verify core builds with zero extensions
- Add migration 045: expand account_type CHECK for untaxed_reserves
- Update CLAUDE.md with comprehensive extension system documentation
- Update all report engines and bookkeeping services for new imports
- Clean up extensions.schema.json to only list existing extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:32:56 +01:00

691 lines
19 KiB
TypeScript

/**
* SIE Import Engine
*
* Executes the actual import of SIE data into the database.
* Creates fiscal periods, opening balance entries, and journal entries.
* All operations are wrapped to ensure atomic behavior.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import type {
ParsedSIEFile,
AccountMapping,
ImportResult,
ImportPreview,
SIEImport,
} from './types'
import type { CreateJournalEntryLineInput } from '@/types'
import { mappingsToMap, getMappingStats } from './account-mapper'
import { calculateFileHash } from './sie-parser'
/**
* Format a date to ISO date string (YYYY-MM-DD)
*/
function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
}
/**
* Generate a preview of what will be imported
*/
export function generateImportPreview(
parsed: ParsedSIEFile,
mappings: AccountMapping[]
): ImportPreview {
// Calculate opening balance totals
const currentYearBalances = parsed.openingBalances.filter((b) => b.yearIndex === 0)
let totalDebit = 0
let totalCredit = 0
for (const balance of currentYearBalances) {
if (balance.amount > 0) {
totalDebit += balance.amount
} else {
totalCredit += Math.abs(balance.amount)
}
}
const mappingStats = getMappingStats(mappings)
return {
companyName: parsed.header.companyName,
orgNumber: parsed.header.orgNumber,
fiscalYearStart: parsed.stats.fiscalYearStart,
fiscalYearEnd: parsed.stats.fiscalYearEnd,
accountCount: parsed.stats.totalAccounts,
voucherCount: parsed.stats.totalVouchers,
transactionLineCount: parsed.stats.totalTransactionLines,
openingBalanceTotal: totalDebit,
trialBalance: {
totalDebit,
totalCredit,
isBalanced: Math.abs(totalDebit - totalCredit) < 0.01,
},
mappingStatus: {
total: mappingStats.total,
mapped: mappingStats.mapped,
unmapped: mappingStats.unmapped,
lowConfidence: mappingStats.lowConfidence,
},
issues: parsed.issues,
}
}
/**
* Check if a file has already been imported
*/
export async function checkDuplicateImport(
supabase: SupabaseClient,
userId: string,
fileContent: string
): Promise<SIEImport | null> {
const fileHash = await calculateFileHash(fileContent)
const { data } = await supabase
.from('sie_imports')
.select('*')
.eq('user_id', userId)
.eq('file_hash', fileHash)
.single()
return data as SIEImport | null
}
/**
* Create a fiscal period if one doesn't exist for the date range
*/
async function ensureFiscalPeriod(
supabase: SupabaseClient,
userId: string,
startDate: Date,
endDate: Date
): Promise<string> {
// Check for an existing period that contains the SIE date range
const { data: containing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.lte('period_start', formatDate(startDate))
.gte('period_end', formatDate(endDate))
.single()
if (containing) {
return containing.id
}
// Check for any overlapping period (DB exclusion constraint would reject
// a new insert that overlaps). Use the overlapping period instead.
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.lte('period_start', formatDate(endDate))
.gte('period_end', formatDate(startDate))
.order('period_start', { ascending: false })
.limit(1)
if (overlapping && overlapping.length > 0) {
return overlapping[0].id
}
// Create new fiscal period
const startYear = startDate.getFullYear()
const endYear = endDate.getFullYear()
const name = startYear === endYear
? `Räkenskapsår ${startYear}`
: `Räkenskapsår ${startYear}/${endYear}`
const { data: newPeriod, error } = await supabase
.from('fiscal_periods')
.insert({
user_id: userId,
name,
period_start: formatDate(startDate),
period_end: formatDate(endDate),
is_closed: false,
opening_balances_set: false,
})
.select()
.single()
if (error || !newPeriod) {
throw new Error(`Failed to create fiscal period: ${error?.message}`)
}
return newPeriod.id
}
/**
* Create opening balance journal entry from IB amounts
*/
async function createOpeningBalanceEntry(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
accountMap: Map<string, string>
): Promise<string | null> {
const currentYearBalances = parsed.openingBalances.filter((b) => b.yearIndex === 0)
if (currentYearBalances.length === 0) {
return null
}
// Build journal entry lines
const lines: CreateJournalEntryLineInput[] = []
for (const balance of currentYearBalances) {
const targetAccount = accountMap.get(balance.account)
if (!targetAccount) {
continue // Skip unmapped accounts
}
// Opening balances: positive = debit, negative = credit
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}`,
})
}
}
if (lines.length === 0) {
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) {
lines.push({
account_number: '2099', // Årets resultat (or similar equity account)
debit_amount: 0,
credit_amount: adjustment,
line_description: 'Balanseringsdifferens',
})
} else {
lines.push({
account_number: '2099',
debit_amount: Math.abs(adjustment),
credit_amount: 0,
line_description: 'Balanseringsdifferens',
})
}
}
const fiscalYearStart = parsed.stats.fiscalYearStart
const entryDate = fiscalYearStart ? formatDate(fiscalYearStart) : formatDate(new Date())
const entry = await createJournalEntry(supabase, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description: 'Ingående balanser från SIE-import',
source_type: 'opening_balance',
voucher_series: 'A',
lines,
})
return entry.id
}
/**
* Create journal entries from vouchers using batch insert for performance
*/
async function importVouchers(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
accountMap: Map<string, string>,
voucherSeries: string
): Promise<{ created: number; ids: string[]; errors: string[] }> {
const results = {
created: 0,
ids: [] as string[],
errors: [] as string[],
}
// Pre-filter and prepare all valid vouchers
interface PreparedVoucher {
date: string
description: string
lines: { account_number: string; debit_amount: number; credit_amount: number; line_description: string | null }[]
}
const preparedVouchers: PreparedVoucher[] = []
for (const voucher of parsed.vouchers) {
const lines: PreparedVoucher['lines'] = []
let hasUnmappedAccount = false
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}`
)
continue
}
// In SIE, amount is positive for debit, negative for credit
if (line.amount > 0) {
lines.push({
account_number: targetAccount,
debit_amount: Math.round(line.amount * 100) / 100,
credit_amount: 0,
line_description: line.description || null,
})
} else if (line.amount < 0) {
lines.push({
account_number: targetAccount,
debit_amount: 0,
credit_amount: Math.round(Math.abs(line.amount) * 100) / 100,
line_description: line.description || null,
})
}
}
// Skip vouchers with unmapped accounts or too few lines
if (hasUnmappedAccount || lines.length < 2) {
continue
}
// Validate balance
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})`
)
continue
}
preparedVouchers.push({
date: formatDate(voucher.date),
description: voucher.description || `Import: ${voucher.series}${voucher.number}`,
lines,
})
}
if (preparedVouchers.length === 0) {
return results
}
// Get all unique account numbers used
const allAccountNumbers = new Set<string>()
for (const v of preparedVouchers) {
for (const l of v.lines) {
allAccountNumbers.add(l.account_number)
}
}
// Resolve all account IDs in one query
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('user_id', userId)
.in('account_number', [...allAccountNumbers])
const accountIdMap = new Map<string, string>()
for (const acc of accounts || []) {
accountIdMap.set(acc.account_number, acc.id)
}
// Get starting voucher number
const { data: startNumber } = await supabase.rpc('next_voucher_number', {
p_user_id: userId,
p_fiscal_period_id: fiscalPeriodId,
p_series: voucherSeries,
})
let currentVoucherNumber = (startNumber as number) || 1
// Batch insert journal entries (in chunks of 100)
const BATCH_SIZE = 100
for (let batchStart = 0; batchStart < preparedVouchers.length; batchStart += BATCH_SIZE) {
const batch = preparedVouchers.slice(batchStart, batchStart + BATCH_SIZE)
// Prepare journal entry headers
const entryInserts = batch.map((v, i) => ({
user_id: userId,
fiscal_period_id: fiscalPeriodId,
voucher_number: currentVoucherNumber + batchStart + i,
voucher_series: voucherSeries,
entry_date: v.date,
description: v.description,
source_type: 'import',
status: 'posted',
committed_at: new Date().toISOString(),
}))
// Insert headers
const { data: entries, error: entryError } = await supabase
.from('journal_entries')
.insert(entryInserts)
.select('id')
if (entryError || !entries) {
results.errors.push(`Batch ${Math.floor(batchStart / BATCH_SIZE) + 1}: ${entryError?.message || 'Failed to insert entries'}`)
continue
}
// Prepare all lines for this batch
const allLines: {
journal_entry_id: string
account_number: string
account_id: string | null
debit_amount: number
credit_amount: number
currency: string
line_description: string | null
sort_order: number
}[] = []
for (let i = 0; i < batch.length; i++) {
const entryId = entries[i]?.id
if (!entryId) continue
const voucher = batch[i]
voucher.lines.forEach((line, lineIndex) => {
allLines.push({
journal_entry_id: entryId,
account_number: line.account_number,
account_id: accountIdMap.get(line.account_number) || null,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
currency: 'SEK',
line_description: line.line_description,
sort_order: lineIndex,
})
})
results.ids.push(entryId)
results.created++
}
// Insert all lines for this batch
if (allLines.length > 0) {
const { error: linesError } = await supabase
.from('journal_entry_lines')
.insert(allLines)
if (linesError) {
results.errors.push(`Batch ${Math.floor(batchStart / BATCH_SIZE) + 1} lines: ${linesError.message}`)
}
}
}
return results
}
/**
* Record the import in the database
*/
async function recordImport(
supabase: SupabaseClient,
userId: string,
parsed: ParsedSIEFile,
fileContent: string,
filename: string,
result: ImportResult
): Promise<string> {
const fileHash = await calculateFileHash(fileContent)
const { data, error } = await supabase
.from('sie_imports')
.insert({
user_id: userId,
filename,
file_hash: fileHash,
org_number: parsed.header.orgNumber,
company_name: parsed.header.companyName,
sie_type: parsed.header.sieType,
fiscal_year_start: parsed.stats.fiscalYearStart
? formatDate(parsed.stats.fiscalYearStart)
: null,
fiscal_year_end: parsed.stats.fiscalYearEnd
? formatDate(parsed.stats.fiscalYearEnd)
: null,
accounts_count: parsed.stats.totalAccounts,
transactions_count: result.journalEntriesCreated,
status: result.success ? 'completed' : 'failed',
error_message: result.errors.length > 0 ? result.errors.join('; ') : null,
fiscal_period_id: result.fiscalPeriodId,
opening_balance_entry_id: result.openingBalanceEntryId,
imported_at: new Date().toISOString(),
})
.select('id')
.single()
if (error || !data) {
throw new Error(`Failed to record import: ${error?.message}`)
}
return data.id
}
/**
* Save account mappings to the database for future use
*/
export async function saveMappings(
supabase: SupabaseClient,
userId: string,
mappings: AccountMapping[]
): Promise<void> {
// Filter to only mapped accounts
const mappingsToSave = mappings
.filter((m) => m.targetAccount)
.map((m) => ({
user_id: userId,
source_account: m.sourceAccount,
source_name: m.sourceName,
target_account: m.targetAccount,
confidence: m.confidence,
match_type: m.matchType,
}))
if (mappingsToSave.length === 0) return
// Batch upsert in chunks of 100
const BATCH_SIZE = 100
for (let i = 0; i < mappingsToSave.length; i += BATCH_SIZE) {
const batch = mappingsToSave.slice(i, i + BATCH_SIZE)
await supabase
.from('sie_account_mappings')
.upsert(batch, {
onConflict: 'user_id,source_account',
})
}
}
/**
* Load existing account mappings for a user
*/
export async function loadMappings(supabase: SupabaseClient, userId: string): Promise<Map<string, AccountMapping>> {
const { data } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', userId)
const map = new Map<string, AccountMapping>()
for (const record of data || []) {
map.set(record.source_account, {
sourceAccount: record.source_account,
sourceName: record.source_name || '',
targetAccount: record.target_account,
targetName: '', // Will be filled in by the mapper
confidence: record.confidence,
matchType: record.match_type,
isOverride: true,
})
}
return map
}
/**
* Execute the full SIE import
*/
export async function executeSIEImport(
supabase: SupabaseClient,
userId: string,
parsed: ParsedSIEFile,
mappings: AccountMapping[],
options: {
filename: string
fileContent: string
createFiscalPeriod: boolean
importOpeningBalances: boolean
importTransactions: boolean
voucherSeries?: string
}
): Promise<ImportResult> {
const result: ImportResult = {
success: false,
importId: null,
fiscalPeriodId: null,
openingBalanceEntryId: null,
journalEntriesCreated: 0,
journalEntryIds: [],
errors: [],
warnings: [],
}
try {
// Validate all accounts are mapped
const unmapped = mappings.filter((m) => !m.targetAccount)
if (unmapped.length > 0) {
result.errors.push(
`${unmapped.length} accounts are not mapped: ${unmapped.map((m) => m.sourceAccount).join(', ')}`
)
return result
}
// Check for duplicate import
const duplicate = await checkDuplicateImport(supabase, userId, options.fileContent)
if (duplicate) {
result.errors.push(
`This file has already been imported on ${new Date(duplicate.imported_at!).toLocaleDateString('sv-SE')}`
)
return result
}
// Build account mapping lookup
const accountMap = mappingsToMap(mappings)
// Create or find fiscal period
const fiscalYearStart = parsed.stats.fiscalYearStart
const fiscalYearEnd = parsed.stats.fiscalYearEnd
if (!fiscalYearStart || !fiscalYearEnd) {
result.errors.push('No fiscal year defined in the SIE file')
return result
}
if (options.createFiscalPeriod) {
result.fiscalPeriodId = await ensureFiscalPeriod(
supabase,
userId,
fiscalYearStart,
fiscalYearEnd
)
} else {
// Find existing fiscal period
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.lte('period_start', formatDate(fiscalYearStart))
.gte('period_end', formatDate(fiscalYearEnd))
.single()
if (!existing) {
result.errors.push('No matching fiscal period found. Enable "Create fiscal period" option.')
return result
}
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
)
if (result.openingBalanceEntryId) {
result.journalEntriesCreated++
result.journalEntryIds.push(result.openingBalanceEntryId)
}
}
// Import transactions (SIE4 only)
if (options.importTransactions && parsed.vouchers.length > 0 && result.fiscalPeriodId) {
const voucherResults = await importVouchers(
supabase,
userId,
result.fiscalPeriodId,
parsed,
accountMap,
options.voucherSeries || 'B'
)
result.journalEntriesCreated += voucherResults.created
result.journalEntryIds.push(...voucherResults.ids)
result.errors.push(...voucherResults.errors)
}
// Save account mappings for future use
await saveMappings(supabase, userId, mappings)
// Record the import
result.importId = await recordImport(
supabase,
userId,
parsed,
options.fileContent,
options.filename,
result
)
result.success = result.errors.length === 0
// Add warnings for any issues
for (const issue of parsed.issues) {
if (issue.severity === 'warning') {
result.warnings.push(`Line ${issue.line}: ${issue.message}`)
}
}
} catch (error) {
result.errors.push(
`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
return result
}