Files
accounted/lib/import/sie-import.ts
T
Jakob Wennberg 91e2c1705a feat: per-line VAT, invoice document types, ledger-based VAT declaration, bank reconciliation, and pagination
Per-line VAT rates:
- Add generatePerRateLines() to group invoice items by vat_rate with separate
  revenue + VAT lines per rate group (invoice-entries.ts)
- Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts)
- PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices
- Invoice create/review UI supports per-line rate selection
- Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput

Invoice document types (proforma, delivery note):
- Add InvoiceDocumentType, document_type and converted_from_id to Invoice type
- PDF hides prices for delivery notes, adds proforma notice
- Email templates support all document types
- mark-paid skips journal entries for non-invoice document types
- Migration 031: invoice_document_type

Accounting method support:
- Add AccountingMethod type (accrual/cash)
- Migration 032: add_accounting_method column to company_settings

VAT declaration rewrite:
- Rewrite to read directly from general ledger (26xx/3xxx account lines)
  instead of aggregating invoices/transactions/receipts
- ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances

Bank reconciliation:
- Transaction ingest now pre-fetches unlinked GL lines and attempts
  auto-reconciliation during import
- Add transaction.reconciled event type
- Add ReconciliationMethod type and reconciliation_method on Transaction
- Migration 030: bank_reconciliation
- New reconciliation engine, API routes, and BankReconciliationView component

Pagination (fetchAllRows):
- New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit
- Adopted in all report generators, SIE/SRU export, account list APIs

Fiscal period validation:
- New validate-period-duration.ts enforces max 18 months per BFL 3 kap.
- Applied in period-service.ts and fiscal-periods API

Account mapper simplification:
- Remove Levenshtein/fuzzy matching, use exact account number match only

Swedbank parser improvements:
- Support abbreviated headers (Clnr, Bokfdag, Radnr)
- Use Referens column as counterparty

Chart of accounts management:
- Add DELETE endpoint with system account and usage protection
- PUT uses partial updates
- New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager

Tax deadline corrections:
- Rewrite inkomstdeklaration_ab using Skatteverket lookup table
- Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3

Onboarding first fiscal year:
- Add first fiscal year toggle with date pickers and 18-month validation

UI terminology:
- Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout

Report column fix:
- Fix start_date/end_date to period_start/period_end in report queries

Supplier invoice input:
- CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept)

Misc:
- SIE import uses upsert for idempotent account creation
- account-descriptions.ts falls back to BAS reference data
- Add invoice_default_notes to CompanySettings
- Update CLAUDE.md to reflect current project state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:57:15 +01:00

677 lines
18 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 { createClient } from '@/lib/supabase/server'
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(
userId: string,
fileContent: string
): Promise<SIEImport | null> {
const supabase = await createClient()
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: Awaited<ReturnType<typeof createClient>>,
userId: string,
startDate: Date,
endDate: Date
): Promise<string> {
// Check for existing fiscal period
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.lte('period_start', formatDate(startDate))
.gte('period_end', formatDate(endDate))
.single()
if (existing) {
return existing.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(
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(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(
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
accountMap: Map<string, string>,
voucherSeries: string
): Promise<{ created: number; ids: string[]; errors: string[] }> {
const supabase = await createClient()
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: Awaited<ReturnType<typeof createClient>>,
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(
userId: string,
mappings: AccountMapping[]
): Promise<void> {
const supabase = await createClient()
// 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(userId: string): Promise<Map<string, AccountMapping>> {
const supabase = await createClient()
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(
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 {
const supabase = await createClient()
// 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(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(
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(
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(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
}