f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
3047 lines
121 KiB
TypeScript
3047 lines
121 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 { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
|
||
import { importDimensionRegistry } from './sie-dimensions'
|
||
import { createJournalEntry, replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine'
|
||
import type {
|
||
ParsedSIEFile,
|
||
AccountMapping,
|
||
ImportResult,
|
||
ImportPreview,
|
||
SIEImport,
|
||
MigrationDocumentation,
|
||
} from './types'
|
||
import type { CreateJournalEntryLineInput } from '@/types'
|
||
import { roundOre } from '@/lib/money'
|
||
import { mappingsToMap, getMappingStats } from './account-mapper'
|
||
import { syncMappedAccounts } from './account-sync'
|
||
import { defaultOpeningBalanceSeries } from './opening-balance-defaults'
|
||
import {
|
||
calculateFileHash,
|
||
getEffectiveOpeningBalances,
|
||
isBalanceSheetAccount,
|
||
OPENING_BALANCE_DESCRIPTION_RE,
|
||
SHARE_CAPITAL_DESCRIPTION_RE,
|
||
} from './sie-parser'
|
||
|
||
// Re-export from the parser (moved there to avoid an import cycle:
|
||
// getEffectiveOpeningBalances needs it) so existing importers keep working.
|
||
export { isBalanceSheetAccount } from './sie-parser'
|
||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
|
||
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
|
||
import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates'
|
||
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
|
||
import { monthsBetween, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||
import { findUntransferredResults } from '@/lib/reports/imbalance-diagnosis'
|
||
import { formatCurrency } from '@/lib/utils'
|
||
|
||
/**
|
||
* Format a date to ISO date string (YYYY-MM-DD)
|
||
*/
|
||
function formatDate(date: Date): string {
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
return `${year}-${month}-${day}`
|
||
}
|
||
|
||
/**
|
||
* Generate a preview of what will be imported
|
||
*/
|
||
export function generateImportPreview(
|
||
parsed: ParsedSIEFile,
|
||
mappings: AccountMapping[]
|
||
): ImportPreview {
|
||
// Calculate opening balance totals from the effective set: for files
|
||
// without #IB 0 this is the IB derived from #UB -1 (issue #675), so the
|
||
// preview (and the IB toggle in ImportReviewStep, keyed off
|
||
// openingBalanceTotal > 0) reflects what the import will actually book.
|
||
const { balances: currentYearBalances, derivedFromPriorYearUB } =
|
||
getEffectiveOpeningBalances(parsed)
|
||
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,
|
||
},
|
||
excludedSystemAccounts: [],
|
||
voucherSeriesInFile: [
|
||
...new Set(
|
||
parsed.vouchers
|
||
.map((v) => (v.series ?? '').trim())
|
||
.filter((s) => s.length > 0)
|
||
),
|
||
].sort(),
|
||
issues: derivedFromPriorYearUB
|
||
? [
|
||
...parsed.issues,
|
||
{
|
||
severity: 'info',
|
||
line: 0,
|
||
message:
|
||
'Ingående balanser härleds från föregående års utgående balans (#UB -1): filen saknar #IB-poster för aktuellt räkenskapsår.',
|
||
},
|
||
]
|
||
: parsed.issues,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if a file has already been imported
|
||
*/
|
||
export async function checkDuplicateImport(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
fileContent: string
|
||
): Promise<SIEImport | null> {
|
||
const fileHash = await calculateFileHash(fileContent)
|
||
|
||
const { data } = await supabase
|
||
.from('sie_imports')
|
||
.select('*')
|
||
.eq('company_id', companyId)
|
||
.eq('file_hash', fileHash)
|
||
.eq('status', 'completed')
|
||
.single()
|
||
|
||
return data as SIEImport | null
|
||
}
|
||
|
||
/**
|
||
* Every completed SIE import whose fiscal year overlaps the given range,
|
||
* newest first (imported_at desc, created_at as tiebreak).
|
||
*
|
||
* More than one row can overlap the same räkenskapsår: a manual upload plus
|
||
* a provider sync, or residue from partially deleted data. The old
|
||
* `.limit(1).maybeSingle()` shape picked an arbitrary row in that case, so
|
||
* the replace flow resolved one watermark and left the others standing,
|
||
* permanently blocking a re-sync of that year (issue #1667). Callers that
|
||
* resolve prior imports must handle every returned row.
|
||
*/
|
||
export async function findOverlappingPeriodImports(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
fiscalYearStart: string,
|
||
fiscalYearEnd: string
|
||
): Promise<SIEImport[]> {
|
||
// Range overlap check: start <= other_end AND end >= other_start.
|
||
// Two imports whose räkenskapsår overlap would produce duplicate
|
||
// verifikationer, violating BFL 4:1 (löpande bokföring).
|
||
//
|
||
// fetchAllRows throws on any query error: a swallowed error here would
|
||
// return [] and let replace mode import fresh on top of rows it never
|
||
// resolved. It also pages past PostgREST's row cap, and the id tiebreak
|
||
// keeps the pagination order total.
|
||
const rows = await fetchAllRows<SIEImport>(
|
||
({ from, to }) =>
|
||
supabase
|
||
.from('sie_imports')
|
||
.select('*')
|
||
.eq('company_id', companyId)
|
||
.eq('status', 'completed')
|
||
.lte('fiscal_year_start', fiscalYearEnd)
|
||
.gte('fiscal_year_end', fiscalYearStart)
|
||
.order('imported_at', { ascending: false, nullsFirst: false })
|
||
.order('created_at', { ascending: false })
|
||
.order('id', { ascending: false })
|
||
.range(from, to),
|
||
{ dedupeBy: (r) => r.id }
|
||
)
|
||
return rows
|
||
}
|
||
|
||
/**
|
||
* Check if a completed SIE import already exists for the same fiscal year period.
|
||
* Prevents importing two different SIE files that cover the same accounting period,
|
||
* which would create duplicate verifikationer violating BFL 4:1 (löpande bokföring).
|
||
* Only blocks on status='completed': failed/pending imports don't prevent retries.
|
||
* When several rows overlap, the newest is returned (deterministic, see
|
||
* findOverlappingPeriodImports).
|
||
*/
|
||
export async function checkDuplicatePeriodImport(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
fiscalYearStart: string,
|
||
fiscalYearEnd: string
|
||
): Promise<SIEImport | null> {
|
||
const overlapping = await findOverlappingPeriodImports(
|
||
supabase, companyId, fiscalYearStart, fiscalYearEnd
|
||
)
|
||
return overlapping[0] ?? null
|
||
}
|
||
|
||
/**
|
||
* Client for the bulk hard-delete RPCs (replace_sie_import / undo_sie_import /
|
||
* undo_bank_file_import).
|
||
*
|
||
* The authenticated role carries statement_timeout=8s on hosted Supabase,
|
||
* and deleting a large import (thousands of journal_entries, each firing
|
||
* write_audit_log with a JSONB old_state snapshot, plus cascading lines)
|
||
* does not finish inside that budget: the RPC dies with "canceling
|
||
* statement due to statement timeout" and rolls back. The service role has
|
||
* no statement_timeout, so the RPC runs on it instead.
|
||
*
|
||
* Safe escalation: callers validate company ownership against the
|
||
* RLS-scoped session client BEFORE the RPC, and the RPC itself (SECURITY
|
||
* DEFINER) re-filters every statement on p_company_id.
|
||
*
|
||
* Falls back to the caller's client when the service key is absent
|
||
* (unit tests, misconfigured self-hosted), same behavior as before.
|
||
*
|
||
* Exported for lib/import/bank-file/undo.ts, which needs the exact same
|
||
* escalation shape for undo_bank_file_import.
|
||
*/
|
||
export async function rpcClientForBulkDelete(fallback: SupabaseClient): Promise<SupabaseClient> {
|
||
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) return fallback
|
||
const { createServiceClient } = await import('@/lib/supabase/server')
|
||
return createServiceClient()
|
||
}
|
||
|
||
/**
|
||
* Replace a completed SIE import so the user can re-import corrected data
|
||
* for the same fiscal period.
|
||
*
|
||
* The RPC hard-deletes every source_type='import' entry the original import
|
||
* created (plus stragglers from any prior soft-replace), detaches user-
|
||
* attached documents from those entries (PDFs stay in storage as unlinked
|
||
* documents), clears the fiscal-period opening-balance pointer if it came
|
||
* from this import, and resets voucher_sequences so the next re-import
|
||
* restarts the series at 1 (or at MAX of remaining non-import entries).
|
||
*
|
||
* Audit trail lives in the sie_imports row (status='replaced',
|
||
* replaced_at, filename, file_hash, transactions_count, fiscal_year_*)
|
||
* plus per-row audit_log entries written by the write_audit_log trigger
|
||
* on each journal_entries DELETE (old_state JSONB snapshot).
|
||
*
|
||
* The whole cleanup is atomic via the replace_sie_import DB RPC.
|
||
*
|
||
* `userId` is the authorising user and is required. Since migration
|
||
* 20260727120000 the RPC gates on owner/admin membership resolved from
|
||
* COALESCE(p_user_id, auth.uid()), and it usually runs on the service client
|
||
* (see rpcClientForBulkDelete) where auth.uid() is NULL: without an explicit
|
||
* actor the gate can never match and always raises. Making the parameter
|
||
* required means a caller that has no authenticated user fails to compile
|
||
* rather than discovering the closed gate at runtime.
|
||
*
|
||
* On failure `code` classifies why, so callers can tell a stale watermark
|
||
* (the row vanished or already left 'completed': nothing left to replace,
|
||
* safe to proceed as a fresh import) from a real refusal (locked period,
|
||
* authorization, RPC failure) that must abort.
|
||
*/
|
||
export type ReplaceSIEImportResult = {
|
||
success: boolean
|
||
deletedEntries: number
|
||
error?: string
|
||
code?: 'not_found' | 'not_completed' | 'period_locked' | 'rpc_error'
|
||
}
|
||
|
||
export async function replaceSIEImport(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
importId: string,
|
||
userId: string
|
||
): Promise<ReplaceSIEImportResult> {
|
||
// 1. Fetch and validate the import record. Only PGRST116 (zero rows from
|
||
// .single()) means the row is genuinely absent; any other error (statement
|
||
// timeout, network failure, 5xx surfaced as a PostgREST error) tells us
|
||
// nothing about whether the prior import's verifikationer are still in the
|
||
// ledger. Classifying such a failure as not_found would let the replace
|
||
// loop in executeSIEImport treat it as a stale watermark and import the
|
||
// year fresh on top of the old entries (silent duplicates, BFL 4:1), so it
|
||
// must fail closed as rpc_error and abort instead.
|
||
const { data: importRecord, error: fetchError } = await supabase
|
||
.from('sie_imports')
|
||
.select('status, fiscal_period_id')
|
||
.eq('id', importId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||
return {
|
||
success: false,
|
||
deletedEntries: 0,
|
||
error: `Kunde inte läsa tidigare import: ${fetchError.message}`,
|
||
code: 'rpc_error',
|
||
}
|
||
}
|
||
|
||
if (!importRecord) {
|
||
return { success: false, deletedEntries: 0, error: 'Import hittades inte', code: 'not_found' }
|
||
}
|
||
|
||
if (importRecord.status !== 'completed') {
|
||
return { success: false, deletedEntries: 0, error: `Kan bara ersätta slutförda importer (status: ${importRecord.status})`, code: 'not_completed' }
|
||
}
|
||
|
||
// 2. Check that the fiscal period is not closed or locked
|
||
if (importRecord.fiscal_period_id) {
|
||
const { data: period } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('is_closed, locked_at')
|
||
.eq('id', importRecord.fiscal_period_id)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (period?.is_closed || period?.locked_at) {
|
||
return { success: false, deletedEntries: 0, error: 'Kan inte ersätta import i ett låst eller stängt räkenskapsår. Lås upp eller öppna räkenskapsåret först under Inställningar > Bokföring > Räkenskapsår.', code: 'period_locked' }
|
||
}
|
||
}
|
||
|
||
// 3. Atomically delete entries and mark import as replaced via DB RPC.
|
||
// Runs on the service client: see rpcClientForBulkDelete. Pass the
|
||
// authorising user explicitly: on the service client auth.uid() is NULL,
|
||
// so the RPC's owner/admin gate resolves against p_user_id instead.
|
||
const actorId = userId
|
||
const rpcClient = await rpcClientForBulkDelete(supabase)
|
||
const { data: deletedCount, error: rpcError } = await rpcClient.rpc('replace_sie_import', {
|
||
p_company_id: companyId,
|
||
p_import_id: importId,
|
||
p_user_id: actorId,
|
||
})
|
||
|
||
if (rpcError) {
|
||
// The RPC re-checks status inside its transaction; a row that lost the
|
||
// race between our pre-check and the RPC surfaces here as "not found or
|
||
// not in completed status": classify it as the stale watermark it is.
|
||
// CONTRACT: this regex must match the RAISE EXCEPTION wording in
|
||
// replace_sie_import (supabase/migrations/*replace_sie_import*.sql).
|
||
// If that wording changes without this regex, a genuine stale race is
|
||
// reclassified as rpc_error — which fails closed (the import aborts),
|
||
// never open.
|
||
const staleRace = /not found or not in completed status/i.test(rpcError.message)
|
||
return {
|
||
success: false,
|
||
deletedEntries: 0,
|
||
error: `Kunde inte ersätta import: ${rpcError.message}`,
|
||
code: staleRace ? 'not_completed' : 'rpc_error',
|
||
}
|
||
}
|
||
|
||
return { success: true, deletedEntries: deletedCount as number }
|
||
}
|
||
|
||
/**
|
||
* Undo a completed SIE import by hard-deleting its entries (transaction
|
||
* vouchers + opening_balance) and resetting voucher_sequences, without
|
||
* requiring a replacement file. Marks sie_imports.status='undone'.
|
||
*
|
||
* Pre-flight checks mirror replaceSIEImport so the user gets a Swedish
|
||
* error message before the RPC raises. The RPC itself is idempotent on
|
||
* status: calling twice surfaces the "not in completed status" error.
|
||
*
|
||
* `userId` is the authorising user. It is passed to the RPC as p_user_id
|
||
* because the RPC may run on the service client (see rpcClientForBulkDelete),
|
||
* where auth.uid() is NULL: without it the RPC's owner/admin gate can never
|
||
* match and always raises. The RPC enforces owner/admin against this id.
|
||
*/
|
||
export async function undoSIEImport(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
importId: string,
|
||
userId: string
|
||
): Promise<{ success: boolean; deletedEntries: number; error?: string }> {
|
||
const { data: importRecord } = await supabase
|
||
.from('sie_imports')
|
||
.select('status, fiscal_period_id')
|
||
.eq('id', importId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (!importRecord) {
|
||
return { success: false, deletedEntries: 0, error: 'Import hittades inte' }
|
||
}
|
||
|
||
if (importRecord.status !== 'completed') {
|
||
return { success: false, deletedEntries: 0, error: `Kan bara ångra slutförda importer (status: ${importRecord.status})` }
|
||
}
|
||
|
||
if (importRecord.fiscal_period_id) {
|
||
const { data: period } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('is_closed, locked_at')
|
||
.eq('id', importRecord.fiscal_period_id)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (period?.is_closed || period?.locked_at) {
|
||
return { success: false, deletedEntries: 0, error: 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Lås upp eller öppna räkenskapsåret först under Inställningar > Bokföring > Räkenskapsår.' }
|
||
}
|
||
}
|
||
|
||
// Runs on the service client: see rpcClientForBulkDelete. Pass the
|
||
// authorising user explicitly: on the service client auth.uid() is NULL,
|
||
// so the RPC's owner/admin gate resolves against p_user_id instead.
|
||
const rpcClient = await rpcClientForBulkDelete(supabase)
|
||
const { data: deletedCount, error: rpcError } = await rpcClient.rpc('undo_sie_import', {
|
||
p_company_id: companyId,
|
||
p_import_id: importId,
|
||
p_user_id: userId,
|
||
})
|
||
|
||
if (rpcError) {
|
||
return { success: false, deletedEntries: 0, error: `Kunde inte ångra import: ${rpcError.message}` }
|
||
}
|
||
|
||
return { success: true, deletedEntries: deletedCount as number }
|
||
}
|
||
|
||
/**
|
||
* Clean up orphan in-flight import records for a given file hash.
|
||
*
|
||
* Targets rows in status='pending', left behind when a prior import
|
||
* crashed (or short-circuited at checkDuplicatePeriodImport) before
|
||
* reaching finalizeImportRecord. They hold the slot in the partial
|
||
* unique index `sie_imports_company_id_file_hash_active_idx`, so a
|
||
* retry would fail with a constraint violation.
|
||
*
|
||
* Five-minute age gate protects an in-flight import in another tab/
|
||
* session: createPendingImportRecord → ... → finalizeImportRecord can
|
||
* take tens of seconds for large SIE files. Without the gate, a
|
||
* concurrent retry of the same file would delete the live pending row
|
||
* mid-flight and the original session's finalize would silently no-op.
|
||
* Five minutes is long enough for any normal interactive import yet
|
||
* short enough that legitimate retries after a crash succeed.
|
||
*
|
||
* The 'mapped' status is defined in the type but never written by any
|
||
* code path, so we don't include it. 'failed' and 'replaced' rows are
|
||
* allowed by the partial index (excluded from its predicate), so they
|
||
* stay in place for the audit trail.
|
||
*/
|
||
async function cleanupStaleImportRecords(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
fileHash: string
|
||
): Promise<void> {
|
||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||
|
||
await supabase
|
||
.from('sie_imports')
|
||
.delete()
|
||
.eq('company_id', companyId)
|
||
.eq('file_hash', fileHash)
|
||
.eq('status', 'pending')
|
||
.lt('created_at', fiveMinutesAgo)
|
||
}
|
||
|
||
/**
|
||
* Create a fiscal period if one doesn't exist for the date range.
|
||
* Dates are ISO strings "YYYY-MM-DD" to avoid timezone issues.
|
||
*
|
||
* Exported for unit testing of the pre-validation that mirrors the
|
||
* `enforce_period_start_day` DB trigger.
|
||
*/
|
||
export async function ensureFiscalPeriod(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
startDate: string,
|
||
endDate: string
|
||
): Promise<string> {
|
||
// Check for an existing period that contains the SIE date range
|
||
const { data: containing } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.lte('period_start', startDate)
|
||
.gte('period_end', endDate)
|
||
.single()
|
||
|
||
if (containing) {
|
||
return containing.id
|
||
}
|
||
|
||
// An overlapping-but-not-containing period needs to be split into two cases:
|
||
// - The period has any real content (posted entries, opening balances set,
|
||
// closed, or locked): refuse. Silently reusing it would stamp imported
|
||
// vouchers with a fiscal_period_id whose date window doesn't match the
|
||
// voucher's own date: breaking the SIE invariant that #VER dates fall
|
||
// inside #RAR and BFL 5 kap. (verifikationsnummer per räkenskapsår).
|
||
// - The period is empty (onboarding-seeded with the default calendar year
|
||
// but never used): replace it. The user has a förlängt räkenskapsår per
|
||
// BFL 3 kap. that doesn't match the seeded period, and the seeded period
|
||
// carries no data to preserve.
|
||
const { data: overlapping } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id, period_start, period_end, name, is_closed, locked_at, opening_balances_set')
|
||
.eq('company_id', companyId)
|
||
.lte('period_start', endDate)
|
||
.gte('period_end', startDate)
|
||
.order('period_start', { ascending: false })
|
||
.limit(1)
|
||
|
||
let periodToReplaceId: string | null = null
|
||
|
||
if (overlapping && overlapping.length > 0) {
|
||
const existing = overlapping[0]
|
||
|
||
const replaceableGateOpen =
|
||
!existing.is_closed && !existing.locked_at && !existing.opening_balances_set
|
||
|
||
let hasEntries = true
|
||
if (replaceableGateOpen) {
|
||
const { data: existingEntries } = await supabase
|
||
.from('journal_entries')
|
||
.select('id')
|
||
.eq('fiscal_period_id', existing.id)
|
||
.eq('company_id', companyId)
|
||
.limit(1)
|
||
hasEntries = (existingEntries?.length ?? 0) > 0
|
||
}
|
||
|
||
if (!replaceableGateOpen || hasEntries) {
|
||
throw new Error(
|
||
`SIE-filens räkenskapsår (${startDate} till ${endDate}) överlappar men matchar inte ett befintligt räkenskapsår i Accounted ` +
|
||
`(${existing.name}: ${existing.period_start} till ${existing.period_end}). ` +
|
||
`Justera räkenskapsåret i Inställningar → Företag så att det matchar SIE-filen exakt, eller importera en SIE-fil som täcker exakt samma period.`
|
||
)
|
||
}
|
||
|
||
periodToReplaceId = existing.id
|
||
}
|
||
|
||
const startParts = parseDateParts(startDate)
|
||
const endParts = parseDateParts(endDate)
|
||
|
||
// Max 18 months per BFL 3 kap. Reuses monthsBetween() from
|
||
// lib/bookkeeping/validate-period-duration.ts, the same arithmetic and the
|
||
// same threshold validatePeriodDuration() applies on every other
|
||
// period-creation path (fiscal-periods POST/PATCH, period-service's
|
||
// createNextPeriod, onboarding's computeFiscalPeriod),
|
||
// so an 18-month förlängt räkenskapsår imports exactly as it does there and
|
||
// a 19-month one does not. 18 is the ceiling for an extended or re-laid
|
||
// year; ongoing years are 12. BFL sets no minimum, so there is no floor here.
|
||
//
|
||
// Refuse rather than warn: no reading of BFL 3 kap. permits a räkenskapsår
|
||
// longer than 18 months, so this is a malformed or merged #RAR, not
|
||
// historical data booked under different rules. Continuing would stamp every
|
||
// imported voucher with an illegal period that no UI path can repair
|
||
// afterwards (the fiscal-period editor rejects the very same span), leaving
|
||
// undo_sie_import as the only way out. Checked before the destructive delete
|
||
// below so a refused import leaves the company untouched.
|
||
const months = monthsBetween(startDate, endDate)
|
||
if (months > 18) {
|
||
throw new Error(
|
||
`SIE-filens räkenskapsår (${startDate} till ${endDate}) omfattar ${months} månader: ett räkenskapsår får vara högst 18 månader (BFL 3 kap.). ` +
|
||
`Kontrollera #RAR-raden i filen och exportera om från källsystemet med ett räkenskapsår per fil.`
|
||
)
|
||
}
|
||
|
||
// Pre-validate against the DB-side enforce_period_start_day trigger so the
|
||
// user gets an actionable Swedish error instead of a raw Postgres message.
|
||
// Per BFL 3 kap., only the company's chronologically FIRST fiscal year may
|
||
// start mid-month (förlängt första räkenskapsår). Any period that comes
|
||
// after an earlier one must start on day 1. We check "is there a period
|
||
// that starts earlier?" rather than "does any period exist?" so a user can
|
||
// retroactively import an old first fiscal year via SIE even after an
|
||
// onboarding-created period already exists later in time.
|
||
if (startParts.day !== 1) {
|
||
const { data: earlier } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.lt('period_start', startDate)
|
||
.limit(1)
|
||
|
||
if (earlier && earlier.length > 0) {
|
||
throw new Error(
|
||
`SIE-filens räkenskapsår börjar ${startDate}: endast företagets kronologiskt första räkenskapsår får börja mitt i månaden. Efterföljande räkenskapsår måste börja den 1:a i en månad (BFL 3 kap.). Kontrollera datumen i #RAR-raden.`
|
||
)
|
||
}
|
||
}
|
||
|
||
// Matches the fiscal_period_end_last_of_month CHECK constraint on prod;
|
||
// surface it as a clean message instead of a DB error.
|
||
const lastDayOfEndMonth = new Date(endParts.year, endParts.month, 0).getDate()
|
||
if (endParts.day !== lastDayOfEndMonth) {
|
||
throw new Error(
|
||
`SIE-filens räkenskapsår slutar ${endDate}: räkenskapsår måste sluta på månadens sista dag (BFL 3 kap.). Kontrollera datumen i #RAR-raden.`
|
||
)
|
||
}
|
||
|
||
// All date validation passed. If we identified an empty seeded period above,
|
||
// delete it now, deferring the destructive step until after every check
|
||
// keeps the seeded period intact when an SIE has malformed dates.
|
||
// FK cascades: account_balances, voucher_sequences, voucher_gap_explanations
|
||
// are ON DELETE CASCADE (all empty for a seeded period); sie_imports is
|
||
// ON DELETE SET NULL; journal_entries is ON DELETE RESTRICT but we already
|
||
// verified zero rows above.
|
||
if (periodToReplaceId) {
|
||
const { error: deleteError } = await supabase
|
||
.from('fiscal_periods')
|
||
.delete()
|
||
.eq('id', periodToReplaceId)
|
||
.eq('company_id', companyId)
|
||
|
||
if (deleteError) {
|
||
throw new Error(`Kunde inte ersätta automatiskt skapat räkenskapsår: ${deleteError.message}`)
|
||
}
|
||
}
|
||
|
||
// Create new fiscal period
|
||
const startYear = startParts.year
|
||
const endYear = endParts.year
|
||
const name = startYear === endYear
|
||
? `Räkenskapsår ${startYear}`
|
||
: `Räkenskapsår ${startYear}/${endYear}`
|
||
|
||
// Link the BFNAR 2013:2 continuity chain so the resultatrapport can find the
|
||
// prior year for its comparison column. Mirrors the manual fiscal-periods
|
||
// route: point this period at its closest predecessor, then relink the
|
||
// immediate successor (if any) to follow this one, so multi-year SIE files
|
||
// chain correctly regardless of the order #RAR years are processed in.
|
||
//
|
||
// Adjacency is required on both sides. previous_period_id means "the
|
||
// räkenskapsår immediately before", and year-end seeds opening balances
|
||
// into whatever follows the chain: linking the NEAREST period across a gap
|
||
// of missing years once sent a company's IB two years forward (feedback
|
||
// seq 249297). A gap stays unlinked until the missing year is imported.
|
||
const { data: predecessors } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id, period_end')
|
||
.eq('company_id', companyId)
|
||
.lt('period_end', startDate)
|
||
.order('period_end', { ascending: false })
|
||
.limit(1)
|
||
const nearestPredecessor = predecessors && predecessors.length > 0 ? predecessors[0] : null
|
||
const previousPeriodId =
|
||
nearestPredecessor && nearestPredecessor.period_end === shiftIsoDate(startDate, -1)
|
||
? nearestPredecessor.id
|
||
: null
|
||
|
||
const { data: newPeriod, error } = await supabase
|
||
.from('fiscal_periods')
|
||
.insert({
|
||
company_id: companyId,
|
||
name,
|
||
period_start: startDate,
|
||
period_end: endDate,
|
||
is_closed: false,
|
||
opening_balances_set: false,
|
||
previous_period_id: previousPeriodId,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (error || !newPeriod) {
|
||
throw new Error(`Failed to create fiscal period: ${error?.message}`)
|
||
}
|
||
|
||
// Relink the immediate successor (e.g. when an earlier year is imported after
|
||
// a later one) so the chain holds in both directions. Only a successor that
|
||
// starts the day after this period qualifies (see above).
|
||
const { data: successors } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id, period_start')
|
||
.eq('company_id', companyId)
|
||
.gt('period_start', endDate)
|
||
.neq('id', newPeriod.id)
|
||
.order('period_start', { ascending: true })
|
||
.limit(1)
|
||
if (
|
||
successors &&
|
||
successors.length > 0 &&
|
||
successors[0].period_start === shiftIsoDate(endDate, 1)
|
||
) {
|
||
await supabase
|
||
.from('fiscal_periods')
|
||
.update({ previous_period_id: newPeriod.id })
|
||
.eq('id', successors[0].id)
|
||
.eq('company_id', companyId)
|
||
}
|
||
|
||
return newPeriod.id
|
||
}
|
||
|
||
/** Shift a YYYY-MM-DD string by `days` in pure UTC (no local DST drift). */
|
||
function shiftIsoDate(isoDate: string, days: number): string {
|
||
const d = new Date(isoDate + 'T00:00:00Z')
|
||
d.setUTCDate(d.getUTCDate() + days)
|
||
return d.toISOString().slice(0, 10)
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
} {
|
||
// Effective set: explicit #IB 0, or IB derived from #UB -1 (issue #675).
|
||
const currentYearBalances = getEffectiveOpeningBalances(parsed).balances
|
||
|
||
// 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,
|
||
companyId: string,
|
||
userId: string,
|
||
fiscalPeriodId: string,
|
||
parsed: ParsedSIEFile,
|
||
accountMap: Map<string, string>,
|
||
roundingAdjustment: number,
|
||
voucherSeries: string
|
||
): Promise<string | null> {
|
||
// Effective set: explicit #IB 0, or IB derived from #UB -1 (issue #675).
|
||
const { balances: currentYearBalances, derivedFromPriorYearUB } =
|
||
getEffectiveOpeningBalances(parsed)
|
||
|
||
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
|
||
|
||
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
|
||
}
|
||
|
||
// 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',
|
||
debit_amount: 0,
|
||
credit_amount: roundingAdjustment,
|
||
line_description: `Avrundningsdifferens vid SIE-import, ${roundingAdjustment} SEK`,
|
||
})
|
||
} else {
|
||
lines.push({
|
||
account_number: '2099',
|
||
debit_amount: Math.abs(roundingAdjustment),
|
||
credit_amount: 0,
|
||
line_description: `Avrundningsdifferens vid SIE-import, ${roundingAdjustment} SEK`,
|
||
})
|
||
}
|
||
}
|
||
|
||
const entryDate = parsed.stats.fiscalYearStart ?? formatDate(new Date())
|
||
|
||
const entry = await createJournalEntry(supabase, companyId, userId, {
|
||
fiscal_period_id: fiscalPeriodId,
|
||
entry_date: entryDate,
|
||
// When derived, say so on the voucher itself: permanent documentation
|
||
// of where the amounts came from (BFNAR 2013:2 behandlingshistorik).
|
||
description: derivedFromPriorYearUB
|
||
? 'Ingående balanser från SIE-import (härledda från föregående års utgående balans)'
|
||
: 'Ingående balanser från SIE-import',
|
||
source_type: 'opening_balance',
|
||
// Never hardcoded to 'A': the IB entry is created BEFORE the file's
|
||
// vouchers, so booking it in a series the file uses would consume that
|
||
// series' next number and shift every imported voucher one number
|
||
// higher than in the source system (issue #1882).
|
||
voucher_series: voucherSeries,
|
||
lines,
|
||
})
|
||
|
||
return entry.id
|
||
}
|
||
|
||
/**
|
||
* Returns true when the company already has at least one posted non-IB
|
||
* journal entry dated no later than the target fiscal period, i.e. this is a
|
||
* continuation import rather than the first SIE upload for that point in the
|
||
* company's chronology.
|
||
*
|
||
* Used to gate IB-entry creation: when a company is already live, each year's
|
||
* #IB equals the prior year's UB, which is the sum of already-imported
|
||
* journal lines. Creating a new IB entry would double-count one year's
|
||
* movements against every balance-sheet account.
|
||
*/
|
||
export async function companyHasPriorActivity(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
targetPeriodEnd: string,
|
||
): Promise<boolean> {
|
||
// Only count currently-effective real activity. Excluding 'reversed' drops
|
||
// cancelled originals; excluding source_type 'storno' drops their matching
|
||
// reversal entries so a fully-cancelled pair contributes nothing. Without
|
||
// this, repair scripts that storno duplicate IB entries would leave storno
|
||
// artifacts that trip the guard on a freshly-repaired company.
|
||
const { count } = await supabase
|
||
.from('journal_entries')
|
||
.select('id', { count: 'exact', head: true })
|
||
.eq('company_id', companyId)
|
||
.neq('source_type', 'opening_balance')
|
||
.neq('source_type', 'storno')
|
||
.eq('status', 'posted')
|
||
// Keep same-period activity in the continuation guard, but ignore a
|
||
// later year that happened to be imported first.
|
||
.lte('entry_date', targetPeriodEnd)
|
||
|
||
return (count ?? 0) > 0
|
||
}
|
||
|
||
/**
|
||
* Link an opening-balance journal entry to its fiscal period so balance-sheet
|
||
* reports use the explicit IB path in getOpeningBalances() (reads only that
|
||
* entry's lines for IB) instead of falling through to summing all prior
|
||
* journal lines, which inflates multi-year imports, because each year's IB
|
||
* is double-counted against the prior year's UB.
|
||
*
|
||
* Mirrors the pattern used by the Excel-based OB import at
|
||
* app/api/import/opening-balance/execute/route.ts:224-231.
|
||
*/
|
||
export async function linkOpeningBalanceEntryToPeriod(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
fiscalPeriodId: string,
|
||
openingBalanceEntryId: string
|
||
): Promise<void> {
|
||
const { error } = await supabase
|
||
.from('fiscal_periods')
|
||
.update({
|
||
opening_balance_entry_id: openingBalanceEntryId,
|
||
opening_balances_set: true,
|
||
})
|
||
.eq('id', fiscalPeriodId)
|
||
.eq('company_id', companyId)
|
||
|
||
if (error) {
|
||
throw new Error(`Failed to link opening balance entry to fiscal period: ${error.message}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Pragmatic IB resync.
|
||
*
|
||
* Backfill scenario: user already imported 2026 (or set its IB manually),
|
||
* then later imports 2025. The previously-set 2026 IB no longer matches
|
||
* the 2025 UB we just computed: resync it by stornoing the old IB and
|
||
* creating a fresh one from the just-imported #UB.
|
||
*
|
||
* Returns:
|
||
* - { resynced: true, ...details } when storno + new IB succeeded
|
||
* - { resynced: false, reason } when there's no next period, no existing
|
||
* IB to replace, or the next period is locked/closed
|
||
*
|
||
* Caller is responsible for surfacing the result in ImportResult.
|
||
*/
|
||
export async function resyncNextPeriodOpeningBalance(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
justImportedPeriodEnd: string,
|
||
parsed: ParsedSIEFile,
|
||
accountMap: Map<string, string>
|
||
): Promise<
|
||
| {
|
||
resynced: true
|
||
nextPeriodId: string
|
||
nextPeriodName: string
|
||
stornoEntryId: string
|
||
newOpeningBalanceEntryId: string
|
||
}
|
||
| { resynced: false; reason: string; nextPeriodName?: string }
|
||
> {
|
||
const { data: nextPeriod } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id, name, period_start, period_end, is_closed, locked_at, opening_balance_entry_id, opening_balances_set')
|
||
.eq('company_id', companyId)
|
||
.gt('period_start', justImportedPeriodEnd)
|
||
.order('period_start', { ascending: true })
|
||
.limit(1)
|
||
.maybeSingle()
|
||
|
||
if (!nextPeriod) {
|
||
return { resynced: false, reason: 'no_next_period' }
|
||
}
|
||
|
||
const importedPeriodEnd = new Date(`${justImportedPeriodEnd}T00:00:00Z`)
|
||
importedPeriodEnd.setUTCDate(importedPeriodEnd.getUTCDate() + 1)
|
||
const expectedNextPeriodStart = importedPeriodEnd.toISOString().slice(0, 10)
|
||
|
||
if (nextPeriod.period_start !== expectedNextPeriodStart) {
|
||
// A later period separated by a gap has its own authoritative IB. It must
|
||
// not be replaced with a non-adjacent year's UB while the middle year is
|
||
// still missing.
|
||
return {
|
||
resynced: false,
|
||
reason: 'next_period_not_adjacent',
|
||
nextPeriodName: nextPeriod.name,
|
||
}
|
||
}
|
||
|
||
if (!nextPeriod.opening_balance_entry_id) {
|
||
// No existing IB on the next period: caller has nothing to resync; the
|
||
// user's first IB for the next period will be derived from the import
|
||
// we just completed via getOpeningBalances() fallback.
|
||
return { resynced: false, reason: 'next_period_has_no_ib', nextPeriodName: nextPeriod.name }
|
||
}
|
||
|
||
if (nextPeriod.is_closed || nextPeriod.locked_at) {
|
||
return {
|
||
resynced: false,
|
||
reason: 'next_period_locked',
|
||
nextPeriodName: nextPeriod.name,
|
||
}
|
||
}
|
||
|
||
// Build the new IB lines from the just-imported year's #UB (yearIndex=0
|
||
// closing balances). Each balance carries the source account number; map
|
||
// through accountMap so chart renames in the target company are honored.
|
||
const currentYearUB = parsed.closingBalances.filter((b) => b.yearIndex === 0)
|
||
if (currentYearUB.length === 0) {
|
||
return { resynced: false, reason: 'no_closing_balances', nextPeriodName: nextPeriod.name }
|
||
}
|
||
|
||
const newLines: CreateJournalEntryLineInput[] = []
|
||
for (const balance of currentYearUB) {
|
||
const targetAccount = accountMap.get(balance.account) ?? balance.account
|
||
if (balance.amount > 0) {
|
||
newLines.push({
|
||
account_number: targetAccount,
|
||
debit_amount: balance.amount,
|
||
credit_amount: 0,
|
||
line_description: `IB ${balance.account} (resynk efter import)`,
|
||
})
|
||
} else if (balance.amount < 0) {
|
||
newLines.push({
|
||
account_number: targetAccount,
|
||
debit_amount: 0,
|
||
credit_amount: Math.abs(balance.amount),
|
||
line_description: `IB ${balance.account} (resynk efter import)`,
|
||
})
|
||
}
|
||
}
|
||
|
||
if (newLines.length === 0) {
|
||
return { resynced: false, reason: 'empty_new_ib', nextPeriodName: nextPeriod.name }
|
||
}
|
||
|
||
// Balance check: if the new IB doesn't balance (excluded accounts, etc.),
|
||
// book the difference to 2099 the same way createOpeningBalanceEntry does.
|
||
const totalDebit = newLines.reduce((s, l) => s + l.debit_amount, 0)
|
||
const totalCredit = newLines.reduce((s, l) => s + l.credit_amount, 0)
|
||
const diff = Math.round((totalDebit - totalCredit) * 100) / 100
|
||
if (Math.abs(diff) > 0.01) {
|
||
if (diff > 0) {
|
||
newLines.push({
|
||
account_number: '2099',
|
||
debit_amount: 0,
|
||
credit_amount: diff,
|
||
line_description: 'Avrundningsdifferens vid IB-resynk',
|
||
})
|
||
} else {
|
||
newLines.push({
|
||
account_number: '2099',
|
||
debit_amount: Math.abs(diff),
|
||
credit_amount: 0,
|
||
line_description: 'Avrundningsdifferens vid IB-resynk',
|
||
})
|
||
}
|
||
}
|
||
|
||
const replacement = await replaceOpeningBalanceEntry(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
nextPeriod.opening_balance_entry_id,
|
||
{
|
||
fiscal_period_id: nextPeriod.id,
|
||
entry_date: nextPeriod.period_start as string,
|
||
description: 'Ingående balanser (resynk efter prior-year SIE-import)',
|
||
source_type: 'opening_balance',
|
||
lines: newLines,
|
||
},
|
||
)
|
||
|
||
return {
|
||
resynced: true,
|
||
nextPeriodId: nextPeriod.id,
|
||
nextPeriodName: nextPeriod.name,
|
||
stornoEntryId: replacement.stornoEntryId,
|
||
newOpeningBalanceEntryId: replacement.newEntryId,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create journal entries from vouchers using batch insert for performance.
|
||
*
|
||
* Preserves per-voucher series from the source SIE file so customers migrating
|
||
* from systems like Fortnox (which uses B=kundfakturor, C=inbetalningar, etc.)
|
||
* retain traceability back to their original bookkeeping. Source voucher
|
||
* numbers are renumbered per target series via next_voucher_number to avoid
|
||
* collisions with existing entries; the source (series, number) is preserved
|
||
* in MigrationDocumentation.voucherNumberMapping for audit trail (BFNAR 2013:2).
|
||
*
|
||
* `defaultSeries` is used as a fallback only for vouchers that arrive with an
|
||
* empty series (e.g., SIE4I import files, per spec §5.15).
|
||
*/
|
||
export async function importVouchers(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
fiscalPeriodId: string,
|
||
parsed: ParsedSIEFile,
|
||
accountMap: Map<string, string>,
|
||
defaultSeries: string
|
||
): Promise<{
|
||
created: number
|
||
ids: string[]
|
||
// Subset of `ids` whose entries were inserted with source_type='import' (i.e.
|
||
// excludes #VER vouchers re-tagged as opening_balance). Used to scope the
|
||
// opt-in "Inget underlag krävs" auto-exemption to genuinely migrated vouchers.
|
||
importTypedIds: 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; series: string; targetNumber: number }>
|
||
seriesUsed: string[]
|
||
retriedBatches: number
|
||
failedBatches: number
|
||
}> {
|
||
const results = {
|
||
created: 0,
|
||
ids: [] as string[],
|
||
importTypedIds: [] 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; series: string; targetNumber: number }>,
|
||
seriesUsed: [] as string[],
|
||
retriedBatches: 0,
|
||
failedBatches: 0,
|
||
}
|
||
|
||
// Pre-filter and prepare all valid vouchers
|
||
interface PreparedVoucher {
|
||
sourceId: string
|
||
series: string
|
||
date: string
|
||
description: string
|
||
// Original series/number as written in the source SIE file. NULL for SIE4I
|
||
// subsystem imports where series/verno are optional. Stored per-entry for
|
||
// traceability alongside the aggregate sie_imports.migration_documentation.
|
||
sourceSeries: string | null
|
||
sourceNumber: number | null
|
||
// 'import' for ordinary migrated vouchers; 'opening_balance' for a #VER that
|
||
// is really the year's ingående balans (see isLikelyOpeningBalance below).
|
||
sourceType: 'import' | 'opening_balance'
|
||
lines: {
|
||
account_number: string
|
||
debit_amount: number
|
||
credit_amount: number
|
||
line_description: string | null
|
||
dimensions?: Record<string, string>
|
||
}[]
|
||
}
|
||
|
||
const preparedVouchers: PreparedVoucher[] = []
|
||
|
||
// A SIE file represents the opening balance either as #IB records (handled
|
||
// separately by createOpeningBalanceEntry → source_type='opening_balance'),
|
||
// as IB derived from #UB -1 when #IB 0 is missing (issue #675, also via
|
||
// createOpeningBalanceEntry) or, in some source systems, as an ordinary #VER
|
||
// dated on the fiscal-year start. When there is NO current-year IB from
|
||
// either of the first two paths, detect a clearly-labelled IB voucher and
|
||
// tag it opening_balance so bank reconciliation excludes it from the period
|
||
// movement (otherwise it lands as 'import' and surfaces as a phantom
|
||
// difference equal to the IB). Deliberately conservative: requires the IB
|
||
// wording AND a balance-sheet-only voucher on FY start, and never a
|
||
// share-capital deposit. A missed IB still falls back to the manual "Märk som
|
||
// ingående balans" action in Bankavstämning, so we never risk hiding a real
|
||
// bank movement by over-classifying.
|
||
//
|
||
// Using the effective set keeps this gate consistent with the helper's
|
||
// precedence: when an OB-voucher candidate exists the helper yields no
|
||
// balances (the voucher serves as IB and gets tagged here); when IB was
|
||
// derived from #UB -1 the gate is closed so the same amounts can never be
|
||
// booked twice.
|
||
const hasCurrentYearIb = getEffectiveOpeningBalances(parsed).balances.length > 0
|
||
const fyStart = parsed.stats.fiscalYearStart
|
||
|
||
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
|
||
unmappedAccountSet.add(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,
|
||
...(line.dimensions ? { dimensions: line.dimensions } : {}),
|
||
})
|
||
} 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,
|
||
...(line.dimensions ? { dimensions: line.dimensions } : {}),
|
||
})
|
||
}
|
||
// Note: lines with amount === 0 are silently dropped
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
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',
|
||
})
|
||
}
|
||
}
|
||
|
||
// Resolve per-voucher series from the parsed SIE record. Fall back to the
|
||
// caller-supplied default only when the source voucher has no series
|
||
// (e.g., SIE4I subsystem import files where series/verno are optional).
|
||
const resolvedSeries = voucher.series && voucher.series.trim()
|
||
? voucher.series.trim()
|
||
: defaultSeries
|
||
|
||
const rawSourceSeries = voucher.series && voucher.series.trim() ? voucher.series.trim() : null
|
||
const rawSourceNumber = Number.isFinite(voucher.number) ? voucher.number : null
|
||
|
||
const voucherDateStr = formatDate(voucher.date)
|
||
const isLikelyOpeningBalance =
|
||
!hasCurrentYearIb &&
|
||
!!fyStart && fyStart.slice(0, 10) === voucherDateStr &&
|
||
lines.length > 0 &&
|
||
lines.every((l) => isBalanceSheetAccount(l.account_number)) &&
|
||
OPENING_BALANCE_DESCRIPTION_RE.test(voucher.description || '') &&
|
||
!SHARE_CAPITAL_DESCRIPTION_RE.test(voucher.description || '')
|
||
|
||
preparedVouchers.push({
|
||
sourceId: voucherId,
|
||
series: resolvedSeries,
|
||
date: voucherDateStr,
|
||
description: voucher.description || `Import: ${voucher.series}${voucher.number}`,
|
||
sourceSeries: rawSourceSeries,
|
||
sourceNumber: rawSourceNumber,
|
||
sourceType: isLikelyOpeningBalance ? 'opening_balance' : 'import',
|
||
lines,
|
||
})
|
||
}
|
||
|
||
// NOTE: Per-account net movements are tracked inside the batch loop below,
|
||
// so that only SUCCESSFULLY inserted vouchers are counted. This ensures
|
||
// the migration adjustment entry correctly compensates for failed batches.
|
||
|
||
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('company_id', companyId)
|
||
.in('account_number', [...allAccountNumbers])
|
||
|
||
const accountIdMap = new Map<string, string>()
|
||
for (const acc of accounts || []) {
|
||
accountIdMap.set(acc.account_number, acc.id)
|
||
}
|
||
|
||
// Group prepared vouchers by series so each series' voucher numbers are
|
||
// reserved and assigned independently. Preserves SIE parse order within a
|
||
// series (Map maintains insertion order) so the first source voucher in
|
||
// series B becomes the first target voucher in series B.
|
||
const seriesGroups = new Map<string, PreparedVoucher[]>()
|
||
for (const v of preparedVouchers) {
|
||
const list = seriesGroups.get(v.series)
|
||
if (list) {
|
||
list.push(v)
|
||
} else {
|
||
seriesGroups.set(v.series, [v])
|
||
}
|
||
}
|
||
|
||
results.seriesUsed = [...seriesGroups.keys()]
|
||
|
||
const voucherBySourceId = new Map(preparedVouchers.map((voucher) => [voucher.sourceId, voucher]))
|
||
const rpcPayload = preparedVouchers.map((voucher) => ({
|
||
sourceId: voucher.sourceId,
|
||
series: voucher.series,
|
||
date: voucher.date,
|
||
description: voucher.description,
|
||
sourceSeries: voucher.sourceSeries,
|
||
sourceNumber: voucher.sourceNumber,
|
||
sourceType: voucher.sourceType,
|
||
lines: voucher.lines.map((line, lineIndex) => ({
|
||
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,
|
||
// dimensions jsonb is the source of truth; cost_center/project are
|
||
// GENERATED mirrors the DB derives from it. SIE object-list codes carry
|
||
// through so imported dimension data is not dropped (dimensions PR5 #866).
|
||
dimensions: normalizeLineDimensions({ dimensions: line.dimensions ?? null }),
|
||
})),
|
||
}))
|
||
|
||
type ImportSieJournalEntriesRpcResult = {
|
||
inserted_entries?: Array<{
|
||
id: string
|
||
sourceId: string
|
||
series: string
|
||
voucherNumber: number
|
||
sourceType: 'import' | 'opening_balance'
|
||
}>
|
||
skipped_duplicates?: Array<{ sourceId?: string; reason?: string }>
|
||
validation_errors?: Array<{ sourceId?: string; message?: string }>
|
||
}
|
||
|
||
const { data: rpcResult, error: rpcError } = await supabase.rpc('import_sie_journal_entries', {
|
||
p_company_id: companyId,
|
||
p_user_id: userId,
|
||
p_fiscal_period_id: fiscalPeriodId,
|
||
p_entries: rpcPayload,
|
||
})
|
||
|
||
if (rpcError) {
|
||
results.errors.push(`SIE-verifikationer kunde inte importeras atomiskt: ${rpcError.message}`)
|
||
results.failedBatches = 1
|
||
return results
|
||
}
|
||
|
||
const structuredResult = (rpcResult ?? {}) as ImportSieJournalEntriesRpcResult
|
||
for (const validationError of structuredResult.validation_errors ?? []) {
|
||
results.errors.push(
|
||
validationError.sourceId
|
||
? `${validationError.sourceId}: ${validationError.message ?? 'valideringsfel'}`
|
||
: validationError.message ?? 'Valideringsfel vid SIE-import',
|
||
)
|
||
}
|
||
for (const skippedDuplicate of structuredResult.skipped_duplicates ?? []) {
|
||
results.errors.push(
|
||
skippedDuplicate.sourceId
|
||
? `${skippedDuplicate.sourceId}: duplicerad verifikation hoppades över`
|
||
: 'Duplicerad verifikation hoppades över',
|
||
)
|
||
}
|
||
|
||
if (results.errors.length > 0) {
|
||
return results
|
||
}
|
||
|
||
for (const inserted of structuredResult.inserted_entries ?? []) {
|
||
const voucher = voucherBySourceId.get(inserted.sourceId)
|
||
if (!voucher) continue
|
||
|
||
results.voucherNumberMapping.push({
|
||
sourceId: inserted.sourceId,
|
||
series: inserted.series,
|
||
targetNumber: inserted.voucherNumber,
|
||
})
|
||
|
||
results.ids.push(inserted.id)
|
||
if (inserted.sourceType === 'import') {
|
||
results.importTypedIds.push(inserted.id)
|
||
}
|
||
results.created++
|
||
|
||
for (const line of voucher.lines) {
|
||
const net = line.debit_amount - line.credit_amount
|
||
results.movementsByAccount.set(
|
||
line.account_number,
|
||
(results.movementsByAccount.get(line.account_number) || 0) + net
|
||
)
|
||
}
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* Compute per-series voucher number ranges from the voucher number mapping.
|
||
* SIE imports can span multiple series (B, C, V, ...), each with its own
|
||
* independent target-number range, so the documentation records one range
|
||
* per series.
|
||
*/
|
||
export function computeVoucherNumberRanges(
|
||
mapping: Array<{ sourceId: string; series: string; targetNumber: number }>
|
||
): Array<{ series: string; from: number; to: number }> {
|
||
if (mapping.length === 0) return []
|
||
const bySeries = new Map<string, { from: number; to: number }>()
|
||
for (const entry of mapping) {
|
||
const existing = bySeries.get(entry.series)
|
||
if (existing) {
|
||
if (entry.targetNumber < existing.from) existing.from = entry.targetNumber
|
||
if (entry.targetNumber > existing.to) existing.to = entry.targetNumber
|
||
} else {
|
||
bySeries.set(entry.series, { from: entry.targetNumber, to: entry.targetNumber })
|
||
}
|
||
}
|
||
return [...bySeries.entries()].map(([series, range]) => ({ series, ...range }))
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
companyId: string,
|
||
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. Effective set: explicit
|
||
// #IB 0, or IB derived from #UB -1 (issue #675), so the expected BS
|
||
// movement is UB(0) − UB(-1), the correct one-year movement, instead of
|
||
// treating the whole opening balance as unexplained movement.
|
||
for (const ib of getEffectiveOpeningBalances(parsed).balances) {
|
||
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 entryDate = parsed.stats.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, companyId, 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,
|
||
companyId: string,
|
||
userId: string,
|
||
accountNumber: string,
|
||
accountName: string
|
||
): Promise<void> {
|
||
const { data } = await supabase
|
||
.from('chart_of_accounts')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.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,
|
||
company_id: companyId,
|
||
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 classified = classifyAccount(accountNumber)
|
||
|
||
await supabase.from('chart_of_accounts').insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
account_number: accountNumber,
|
||
account_name: accountName,
|
||
account_class: classNum,
|
||
account_group: group,
|
||
account_type: classified.account_type,
|
||
normal_balance: classified.normal_balance,
|
||
sru_code: computeSRUCode(accountNumber),
|
||
plan_type: 'full_bas',
|
||
is_active: true,
|
||
is_system_account: false,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Phase 1: Create a pending import record early, before any journal entries.
|
||
* This ensures the import is tracked even if later steps fail.
|
||
*/
|
||
async function createPendingImportRecord(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
parsed: ParsedSIEFile,
|
||
fileContent: string,
|
||
filename: string
|
||
): Promise<string> {
|
||
const fileHash = await calculateFileHash(fileContent)
|
||
|
||
// Clean up any stale pending/failed records for this hash to avoid UNIQUE conflicts
|
||
await cleanupStaleImportRecords(supabase, companyId, fileHash)
|
||
|
||
const { data, error } = await supabase
|
||
.from('sie_imports')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
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 ?? null,
|
||
fiscal_year_end: parsed.stats.fiscalYearEnd ?? null,
|
||
accounts_count: parsed.stats.totalAccounts,
|
||
transactions_count: 0,
|
||
status: 'pending',
|
||
imported_at: null,
|
||
})
|
||
.select('id')
|
||
.single()
|
||
|
||
if (error || !data) {
|
||
// PG error 23505 (unique_violation) on the partial index means another
|
||
// active row exists for the same (company_id, file_hash). Surface the
|
||
// recovery path in Swedish instead of leaking the raw Postgres message.
|
||
const pgCode = (error as { code?: string } | null | undefined)?.code
|
||
const pgMessage = error?.message ?? ''
|
||
const hitsActiveIdx =
|
||
pgCode === '23505' &&
|
||
pgMessage.includes('sie_imports_company_id_file_hash_active_idx')
|
||
|
||
if (hitsActiveIdx) {
|
||
throw new Error(
|
||
'En tidigare SIE-import för samma fil finns redan i gnubok. Öppna importhistoriken och välj "Ersätt import" på den befintliga raden, eller använd Fortnox-synkningen för att hämta uppdaterad data automatiskt.'
|
||
)
|
||
}
|
||
|
||
throw new Error(`Failed to create pending import record: ${pgMessage}`)
|
||
}
|
||
|
||
return data.id
|
||
}
|
||
|
||
/**
|
||
* Phase 2: Finalize the import record with results and archive the SIE file.
|
||
*/
|
||
export async function finalizeImportRecord(
|
||
supabase: SupabaseClient,
|
||
importId: string,
|
||
companyId: string,
|
||
result: ImportResult,
|
||
fileContent: string,
|
||
documentation?: MigrationDocumentation
|
||
): Promise<void> {
|
||
// Safety net: if the import ran without errors but didn't actually create
|
||
// any journal entries (no OB entry, no vouchers), refuse to mark it as
|
||
// 'completed'. A 'completed' row with transactions_count=0 would claim
|
||
// the (company_id, file_hash) slot in the partial unique index and the
|
||
// overlapping-period check would block any retry. Flipping to 'failed'
|
||
// (which the partial index already excludes) keeps the slot free so the
|
||
// caller can re-import the same file once the mapping is fixed.
|
||
//
|
||
// Exception: a file the parser found NO vouchers in (documentation
|
||
// carries the parsed count) is a legitimate no-op, not a failure.
|
||
// Fortnox exports an empty SIE file for a fiscal year with nothing
|
||
// booked yet, and failing it aborts the whole migration wizard. A later
|
||
// export with actual vouchers has a different hash, so the claimed slot
|
||
// never blocks it: the Fortnox flow replaces completed imports, and the
|
||
// manual flow offers "Ersätt import".
|
||
const noEntriesCreated =
|
||
result.success &&
|
||
result.journalEntriesCreated === 0 &&
|
||
!result.openingBalanceEntryId
|
||
// The parsed count alone can't prove the year was empty: a separator or
|
||
// encoding mismatch can swallow every #VER block without a parse error
|
||
// (the parser only warns). Cross-check the raw content; a file that
|
||
// declares #VER but parsed to 0 vouchers must keep failing, or real
|
||
// affärshändelser would silently never be bokförda (BFL 5 kap).
|
||
const rawDeclaresVouchers = /^\s*#VER\b/m.test(fileContent)
|
||
const fileHadNoVouchers =
|
||
documentation?.vouchers.total === 0 && !rawDeclaresVouchers
|
||
if (noEntriesCreated && fileHadNoVouchers) {
|
||
result.warnings.push(
|
||
'SIE-filen innehåller inga verifikationer för räkenskapsåret: inget ' +
|
||
'att importera. Räkenskapsåret är skapat och redo att bokföras i.',
|
||
)
|
||
} else if (noEntriesCreated) {
|
||
result.success = false
|
||
if (result.errors.length === 0) {
|
||
result.errors.push(
|
||
'Importen skapade 0 verifikationer: markerar som misslyckad så filen ' +
|
||
'kan importeras om utan replace/undo. Granska varningarna för att se ' +
|
||
'vilka konton som behöver mappas.',
|
||
)
|
||
}
|
||
}
|
||
|
||
const status = result.success ? 'completed' : 'failed'
|
||
|
||
await supabase
|
||
.from('sie_imports')
|
||
.update({
|
||
status,
|
||
imported_at: result.success ? new Date().toISOString() : null,
|
||
transactions_count: result.journalEntriesCreated,
|
||
error_message: result.errors.length > 0 ? result.errors.join('; ') : null,
|
||
fiscal_period_id: result.fiscalPeriodId,
|
||
opening_balance_entry_id: result.openingBalanceEntryId,
|
||
migration_documentation: documentation ?? null,
|
||
})
|
||
.eq('id', importId)
|
||
|
||
// Archive the SIE file to Supabase Storage (BFL 7 kap 1-2§ retention)
|
||
if (result.success) {
|
||
const storagePath = `${companyId}/${importId}.se`
|
||
const fileBlob = new Blob([fileContent], { type: 'text/plain' })
|
||
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', importId)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save account mappings to the database for future use.
|
||
*
|
||
* Two things this function got wrong for four months, both of which have to
|
||
* stay fixed together:
|
||
*
|
||
* 1. `user_id` must be in the payload. sie_account_mappings.user_id is NOT NULL
|
||
* with no column default and no BEFORE INSERT trigger to fill it in (the
|
||
* only trigger on the table is the BEFORE UPDATE updated_at one), and the
|
||
* multi-tenant refactor added company_id without ever adding user_id to this
|
||
* payload. PostgREST sends an upsert as INSERT ... ON CONFLICT DO UPDATE and
|
||
* Postgres checks NOT NULL on the proposed tuple before conflict resolution,
|
||
* so omitting it raises 23502 even when the row already exists: neither the
|
||
* insert nor the update path can land. company_id is the tenant key; user_id
|
||
* records who approved the mapping review, matching what the PUT handler in
|
||
* app/api/import/sie/mappings writes for a single mapping.
|
||
* 2. The upsert result must be inspected. supabase-js resolves with
|
||
* `{ data, error }` and does not throw on a Postgres error, so a bare
|
||
* `await supabase.from(...).upsert(...)` discards the failure and the caller
|
||
* reports success. That is why 834 imports since 2026-03-30 wrote zero
|
||
* mapping rows without a single complaint.
|
||
*
|
||
* Pass `userId` explicitly. It is only resolved from the session as a fallback
|
||
* for callers that cannot supply it: API-key/MCP paths run on a cookieless
|
||
* service client where auth.uid() is NULL and there is no session to read, so
|
||
* a derived id is never something this function can rely on.
|
||
*
|
||
* Throws when the mappings cannot be persisted. The account mapping is the
|
||
* user's manual review work (SIE carries no mapping and no VAT codes, so the
|
||
* source-to-BAS decision per account is made by hand): losing it silently means
|
||
* the next import re-opens the whole review. Callers that have already committed
|
||
* verifikationer must catch this and surface it as a warning rather than
|
||
* unwinding an otherwise-good import.
|
||
*/
|
||
export async function saveMappings(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
mappings: AccountMapping[],
|
||
userId?: string
|
||
): Promise<void> {
|
||
// Filter to only mapped accounts
|
||
const mapped = mappings.filter((m) => m.targetAccount)
|
||
|
||
if (mapped.length === 0) return
|
||
|
||
const resolvedUserId = userId ?? (await supabase.auth.getUser()).data.user?.id
|
||
if (!resolvedUserId) {
|
||
throw new Error(
|
||
'Kunde inte spara kontomappningar: user_id saknas. ' +
|
||
'sie_account_mappings.user_id är NOT NULL utan default, och anropet ' +
|
||
'kördes utan session (API-nyckel/MCP använder en cookie-lös ' +
|
||
'service-klient). Skicka userId till saveMappings().'
|
||
)
|
||
}
|
||
|
||
const mappingsToSave = mapped.map((m) => ({
|
||
user_id: resolvedUserId,
|
||
company_id: companyId,
|
||
source_account: m.sourceAccount,
|
||
source_name: m.sourceName,
|
||
target_account: m.targetAccount,
|
||
confidence: m.confidence,
|
||
match_type: m.matchType,
|
||
}))
|
||
|
||
// Batch upsert in chunks of 100
|
||
const BATCH_SIZE = 100
|
||
let saved = 0
|
||
for (let i = 0; i < mappingsToSave.length; i += BATCH_SIZE) {
|
||
const batch = mappingsToSave.slice(i, i + BATCH_SIZE)
|
||
const { error } = await supabase
|
||
.from('sie_account_mappings')
|
||
.upsert(batch, {
|
||
onConflict: 'company_id,source_account',
|
||
})
|
||
|
||
if (error) {
|
||
throw new Error(
|
||
`Kunde inte spara kontomappningar: ${saved} av ` +
|
||
`${mappingsToSave.length} sparades innan felet ` +
|
||
`(${error.code ?? 'okänd kod'}: ${error.message}).`
|
||
)
|
||
}
|
||
saved += batch.length
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load existing account mappings for a user
|
||
*/
|
||
export async function loadMappings(supabase: SupabaseClient, companyId: string): Promise<Map<string, AccountMapping>> {
|
||
const { data } = await supabase
|
||
.from('sie_account_mappings')
|
||
.select('*')
|
||
.eq('company_id', companyId)
|
||
|
||
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
|
||
*
|
||
* `onExistingPeriod` controls how a prior completed import that overlaps
|
||
* the new SIE's fiscal year is handled:
|
||
* - 'block' (default): refuse with a Swedish error. Used by the manual
|
||
* upload route in app/api/import/sie. Preserves prior behavior.
|
||
* - 'replace': automatically call replaceSIEImport on EVERY overlapping
|
||
* completed row (marks them 'replaced', cancels their imported journal
|
||
* entries) and proceed. A row that can no longer be resolved (deleted
|
||
* or already left 'completed') is a stale watermark: it is skipped with
|
||
* a warning and the year imports fresh (issue #1667). Used by the
|
||
* provider re-sync flow so the user can pull updated data without
|
||
* manual cleanup.
|
||
*
|
||
* Replace only cancels journal entries with source_type='import'; entries
|
||
* the user created natively in Accounted (categorized transactions, invoices,
|
||
* etc.) are left alone. See the replace_sie_import RPC.
|
||
*
|
||
* `updateAccountNames` (default true) carries the SIE file's #KONTO names
|
||
* into the chart for identity-mapped accounts: new accounts are created with
|
||
* the file's name and existing accounts whose name differs are renamed.
|
||
* When false, accounts are created with BAS default names and existing
|
||
* accounts are left untouched (the pre-2026-06 behavior).
|
||
*/
|
||
export async function executeSIEImport(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
parsed: ParsedSIEFile,
|
||
mappings: AccountMapping[],
|
||
options: {
|
||
filename: string
|
||
fileContent: string
|
||
createFiscalPeriod: boolean
|
||
importOpeningBalances: boolean
|
||
importTransactions: boolean
|
||
voucherSeries?: string
|
||
// Series for the opening-balance voucher. Defaults to a series the
|
||
// file's own vouchers do not use (issue #1882): never 'A'.
|
||
openingBalanceSeries?: string
|
||
onExistingPeriod?: 'block' | 'replace'
|
||
updateAccountNames?: boolean
|
||
// Opt-in: mark every imported (source_type='import') verifikat as "Inget
|
||
// underlag krävs" so a multi-year migration doesn't flood "Att hantera:
|
||
// saknade underlag" with thousands of items. OFF by default.
|
||
markImportedNoDocRequired?: boolean
|
||
}
|
||
): Promise<ImportResult> {
|
||
const result: ImportResult = {
|
||
success: false,
|
||
importId: null,
|
||
fiscalPeriodId: null,
|
||
openingBalanceEntryId: null,
|
||
journalEntriesCreated: 0,
|
||
journalEntryIds: [],
|
||
errors: [],
|
||
warnings: [],
|
||
replacedPriorImport: null,
|
||
}
|
||
|
||
// Collected source_type='import' entry ids (vouchers + migration adjustment),
|
||
// used only when options.markImportedNoDocRequired is set. Kept separate from
|
||
// result.journalEntryIds because that also holds opening_balance entries.
|
||
const importTypedEntryIds: string[] = []
|
||
|
||
const onExistingPeriod = options.onExistingPeriod ?? 'block'
|
||
const updateAccountNames = options.updateAccountNames ?? true
|
||
|
||
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
|
||
}
|
||
|
||
// Defense in depth: refuse to enter executeSIEImport when the mapping
|
||
// doesn't cover a single account present in the file. Without this guard
|
||
// a stale MCP client (or the HTTP execute route) could still drive
|
||
// importVouchers to silently skip every voucher and write a 0-entry
|
||
// 'completed' sie_imports row that holds the unique-index slot. Mirrors
|
||
// the stage-time check in gnubok_import_sie.
|
||
const sourceAccountsInFile = new Set<string>()
|
||
for (const v of parsed.vouchers) for (const l of v.lines) sourceAccountsInFile.add(l.account)
|
||
if (options.importOpeningBalances) {
|
||
// Effective set: also covers UB-1-only files (issue #675), whose
|
||
// derived IB accounts would otherwise bypass this guard entirely.
|
||
for (const b of getEffectiveOpeningBalances(parsed).balances) {
|
||
sourceAccountsInFile.add(b.account)
|
||
}
|
||
}
|
||
const mappedSources = new Set(
|
||
mappings.filter((m) => m.targetAccount).map((m) => m.sourceAccount),
|
||
)
|
||
const hasOverlap = [...sourceAccountsInFile].some((a) => mappedSources.has(a))
|
||
if (sourceAccountsInFile.size > 0 && !hasOverlap) {
|
||
const sample = [...sourceAccountsInFile].slice(0, 8).join(', ')
|
||
result.errors.push(
|
||
`Kontomappningarna täcker inga konton i SIE-filen. ` +
|
||
`Filen innehåller ${sourceAccountsInFile.size} unika källkonton ` +
|
||
`(t.ex. ${sample}), men inget av dem finns i mappings.sourceAccount. ` +
|
||
`Importen avbryts innan en sie_imports-rad skapas så att du kan ` +
|
||
`försöka igen med korrekta mappningar.`,
|
||
)
|
||
return result
|
||
}
|
||
|
||
// Replace mode: if prior completed imports overlap the new SIE's fiscal
|
||
// year, mark them 'replaced' (and cancel their imported entries) before we
|
||
// try to insert. Done before checkDuplicateImport / checkDuplicatePeriodImport
|
||
// since both of those would otherwise reject the replace flow.
|
||
//
|
||
// ALL overlapping rows are resolved, not just the newest: more than one
|
||
// completed row can cover the same year (manual upload + provider sync,
|
||
// or residue from partially deleted data), and leaving one standing
|
||
// permanently blocks or corrupts the next re-sync of that year
|
||
// (issue #1667).
|
||
if (onExistingPeriod === 'replace') {
|
||
const fyStart = parsed.stats.fiscalYearStart
|
||
const fyEnd = parsed.stats.fiscalYearEnd
|
||
if (fyStart && fyEnd) {
|
||
const priorPeriodImports = await findOverlappingPeriodImports(
|
||
supabase, companyId, fyStart, fyEnd
|
||
)
|
||
let replacedNewestId: string | null = null
|
||
let replacedDeletedEntries = 0
|
||
let staleSkips = 0
|
||
for (const priorPeriodImport of priorPeriodImports) {
|
||
// Pass the authorising user: this path often runs on an API-key /
|
||
// MCP client where auth.uid() is NULL, and the replace_sie_import
|
||
// owner/admin gate would otherwise fail closed.
|
||
const replaceResult = await replaceSIEImport(
|
||
supabase, companyId, priorPeriodImport.id, userId
|
||
)
|
||
|
||
if (!replaceResult.success) {
|
||
// Stale watermark: the sie_imports row is gone or no longer
|
||
// 'completed' (its data was already deleted or another actor
|
||
// resolved it). There is nothing left to replace for that row,
|
||
// so treat this year as a fresh import instead of stranding the
|
||
// user between states (issue #1667: re-sync could not re-import
|
||
// an earlier fiscal year after deletion).
|
||
if (replaceResult.code === 'not_found' || replaceResult.code === 'not_completed') {
|
||
staleSkips += 1
|
||
result.warnings.push(
|
||
`Tidigare import ${priorPeriodImport.id} kunde inte ersättas (${replaceResult.error ?? 'okänd orsak'}): dess data är redan borttagen, importen fortsätter som ny import.`
|
||
)
|
||
continue
|
||
}
|
||
// Real refusals (låst/stängd period, behörighet, RPC-fel) still
|
||
// abort the year: importing on top of entries we could not
|
||
// delete would duplicate verifikationer.
|
||
result.errors.push(
|
||
replaceResult.error ?? 'Kunde inte ersätta tidigare SIE-import'
|
||
)
|
||
return result
|
||
}
|
||
|
||
// Rows arrive newest first: report the newest replaced import's id
|
||
// (the shape consumers already render) with the total entries
|
||
// deleted across every replaced row.
|
||
replacedNewestId ??= priorPeriodImport.id
|
||
replacedDeletedEntries += replaceResult.deletedEntries
|
||
|
||
// The replace_sie_import RPC clears fiscal_periods
|
||
// opening_balance_entry_id and opening_balances_set inside its
|
||
// transaction when the prior import had an OB entry. This client-
|
||
// side UPDATE is now an idempotent safety net for pre-fix data
|
||
// (companies whose prior replace ran against the soft-cancel
|
||
// implementation and left the pointer dangling on the row).
|
||
if (priorPeriodImport.fiscal_period_id && priorPeriodImport.opening_balance_entry_id) {
|
||
await supabase
|
||
.from('fiscal_periods')
|
||
.update({
|
||
opening_balances_set: false,
|
||
opening_balance_entry_id: null,
|
||
})
|
||
.eq('id', priorPeriodImport.fiscal_period_id)
|
||
.eq('company_id', companyId)
|
||
.eq('opening_balance_entry_id', priorPeriodImport.opening_balance_entry_id)
|
||
}
|
||
}
|
||
// A stale watermark (not_found / not_completed) only proves the
|
||
// sie_imports METADATA row is gone: replace_sie_import deletes
|
||
// entries by (company, fiscal_period, source_type='import'), so
|
||
// posted entries can outlive their import row. Before trusting the
|
||
// skip, positively confirm the year holds no surviving posted
|
||
// import entries — importing on top of survivors would duplicate
|
||
// verifikationer (BFL 4:1). Fail closed on a failed check.
|
||
if (staleSkips > 0) {
|
||
const { count: survivorCount, error: survivorError } = await supabase
|
||
.from('journal_entries')
|
||
.select('id', { count: 'exact', head: true })
|
||
.eq('company_id', companyId)
|
||
.eq('source_type', 'import')
|
||
.eq('status', 'posted')
|
||
.gte('entry_date', fyStart)
|
||
.lte('entry_date', fyEnd)
|
||
if (survivorError || typeof survivorCount !== 'number') {
|
||
result.errors.push(
|
||
`Kunde inte verifiera att tidigare importdata är borttagen: ${survivorError?.message ?? 'okänt fel'}. Importen avbryts.`
|
||
)
|
||
return result
|
||
}
|
||
if (survivorCount > 0) {
|
||
result.errors.push(
|
||
`Räkenskapsåret har ${survivorCount} kvarvarande verifikationer från en tidigare import vars importpost saknas. Importen avbryts för att undvika dubbletter (BFL 4:1). Ta bort de kvarvarande verifikationerna först.`
|
||
)
|
||
return result
|
||
}
|
||
}
|
||
if (replacedNewestId) {
|
||
result.replacedPriorImport = {
|
||
importId: replacedNewestId,
|
||
deletedEntries: replacedDeletedEntries,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Block mode (default): the hash and period checks reject duplicates with
|
||
// graceful Swedish errors. Skipped in replace mode because we've already
|
||
// resolved any prior import above.
|
||
if (onExistingPeriod === 'block') {
|
||
const duplicate = await checkDuplicateImport(supabase, companyId, options.fileContent)
|
||
if (duplicate) {
|
||
// Name the way out. A completed import (including one that created
|
||
// zero verifikat, e.g. mappings that skipped everything) holds the
|
||
// (company_id, file_hash) slot until it is undone; agents reported
|
||
// being stuck here without knowing undo-then-retry is the path.
|
||
result.errors.push(
|
||
`Den här filen har redan importerats ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'vid okänt datum'} (import ${duplicate.id}, ${duplicate.transactions_count} verifikat). Ångra den importen först (Ångra import i webbappen, eller gnubok_undo_sie_import via MCP) och importera sedan igen.`
|
||
)
|
||
return result
|
||
}
|
||
}
|
||
|
||
// Create pending import record early: ensures tracking even if later steps fail
|
||
result.importId = await createPendingImportRecord(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
parsed,
|
||
options.fileContent,
|
||
options.filename
|
||
)
|
||
|
||
// Dimension registry (#DIM/#UNDERDIM/#OBJEKT + object-list references):
|
||
// upsert missing rows and auto-enable the toggle with a notice. Runs
|
||
// before vouchers so tagged lines land with their registry rows present.
|
||
// Files without dimension data return null: nothing changes.
|
||
const dimensionSummary = await importDimensionRegistry(
|
||
supabase,
|
||
companyId,
|
||
parsed,
|
||
result.importId
|
||
)
|
||
if (dimensionSummary) {
|
||
result.dimensionsImported = {
|
||
dimensions: dimensionSummary.dimensionsCreated,
|
||
values: dimensionSummary.valuesCreated,
|
||
taggedLines: dimensionSummary.taggedLines,
|
||
toggleEnabled: dimensionSummary.toggleEnabled,
|
||
}
|
||
result.warnings.push(...dimensionSummary.warnings)
|
||
}
|
||
|
||
// Build account mapping lookup
|
||
const accountMap = mappingsToMap(mappings)
|
||
|
||
// Ensure all mapped target accounts exist in chart_of_accounts and,
|
||
// unless disabled, carry the SIE file's #KONTO names into the chart:
|
||
// customized names from the source system (e.g. Fortnox) would otherwise
|
||
// be lost to the BAS defaults.
|
||
const accountSync = await syncMappedAccounts(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
mappings,
|
||
updateAccountNames
|
||
)
|
||
if (accountSync.error) {
|
||
result.errors.push(`Failed to create accounts: ${accountSync.error}`)
|
||
return result
|
||
}
|
||
if (accountSync.renamed > 0) {
|
||
result.warnings.push(
|
||
accountSync.renamed === 1
|
||
? '1 konto bytte namn till namnet från SIE-filen'
|
||
: `${accountSync.renamed} konton bytte namn till namnen från SIE-filen`
|
||
)
|
||
}
|
||
if (accountSync.renameFailed > 0) {
|
||
result.warnings.push(
|
||
`${accountSync.renameFailed} kontonamn kunde inte uppdateras från SIE-filen`
|
||
)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// Safety net: reject if a completed import already exists for this period.
|
||
// Skipped in replace mode: any overlapping prior import was already
|
||
// marked 'replaced' at the top of executeSIEImport.
|
||
if (onExistingPeriod === 'block') {
|
||
const periodDuplicate = await checkDuplicatePeriodImport(
|
||
supabase, companyId, fiscalYearStart, fiscalYearEnd
|
||
)
|
||
if (periodDuplicate) {
|
||
result.errors.push(
|
||
`En SIE-import för ett överlappande räkenskapsår (${periodDuplicate.fiscal_year_start} till ${periodDuplicate.fiscal_year_end}) finns redan (import ${periodDuplicate.id}, ${periodDuplicate.transactions_count} verifikat). Ångra den importen först (Ångra import i webbappen, eller gnubok_undo_sie_import via MCP) och importera sedan igen.`
|
||
)
|
||
return result
|
||
}
|
||
}
|
||
|
||
if (options.createFiscalPeriod) {
|
||
result.fiscalPeriodId = await ensureFiscalPeriod(
|
||
supabase,
|
||
companyId,
|
||
fiscalYearStart,
|
||
fiscalYearEnd
|
||
)
|
||
} else {
|
||
// Find existing fiscal period
|
||
const { data: existing } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.lte('period_start', fiscalYearStart)
|
||
.gte('period_end', fiscalYearEnd)
|
||
.single()
|
||
|
||
if (!existing) {
|
||
result.errors.push('No matching fiscal period found. Enable "Create fiscal period" option.')
|
||
return result
|
||
}
|
||
|
||
result.fiscalPeriodId = existing.id
|
||
}
|
||
|
||
// Track documentation data across import phases
|
||
let ibRoundingAdjustment = 0
|
||
let ibExplanation: 'unallocated_result' | 'excluded_accounts' | 'rounding' | null = null
|
||
let migrationAdjustmentInfo = { created: false, deltaAccounts: 0, entryId: null as string | null }
|
||
let voucherNumberMapping: Array<{ sourceId: string; series: string; targetNumber: number }> = []
|
||
let voucherSeriesUsed: string[] = []
|
||
let voucherRetryStats = { retriedBatches: 0, failedBatches: 0 }
|
||
let voucherStats = {
|
||
total: parsed.vouchers.length,
|
||
imported: 0,
|
||
skippedUnbalanced: 0,
|
||
skippedUnmapped: 0,
|
||
skippedSingleLine: 0,
|
||
skippedEmpty: 0,
|
||
}
|
||
// Fallback series for vouchers that arrive without one (SIE4I subsystem files).
|
||
// Source series from #VER are preserved per-voucher by importVouchers.
|
||
const defaultSeries = options.voucherSeries || 'B'
|
||
|
||
// Series for the IB voucher: caller's choice, else the first candidate
|
||
// the file's own vouchers do not use. The IB entry is created before
|
||
// the file's vouchers, so a colliding series would consume its next
|
||
// number and shift the whole series by one (issue #1882).
|
||
//
|
||
// Type-checked, not just trimmed: the web execute route and MCP accept
|
||
// untyped JSON, and a non-string here must fall back to the default,
|
||
// not crash mid-import after the fiscal period was already created.
|
||
// Uppercased before persisting: a lowercase 'a' would otherwise book a
|
||
// case-distinct parallel series next to 'A', fragmenting what BFL
|
||
// 5 kap requires to be one systematic series, and would slip past the
|
||
// collision warning below.
|
||
const requestedOpeningBalanceSeries =
|
||
typeof options.openingBalanceSeries === 'string'
|
||
? options.openingBalanceSeries.trim().toUpperCase()
|
||
: ''
|
||
const seriesUsedByFile = new Set(
|
||
parsed.vouchers
|
||
.map((v) => (v.series ?? '').trim().toUpperCase())
|
||
.filter((s) => s.length > 0)
|
||
)
|
||
// Series-less #VER records (SIE4I subsystem files) resolve to
|
||
// defaultSeries at import time, so the default picker must treat that
|
||
// series as used by the file too: otherwise the IB voucher can land in
|
||
// it and shift those vouchers' numbering, the same #1882 pattern.
|
||
if (parsed.vouchers.some((v) => !(v.series ?? '').trim())) {
|
||
seriesUsedByFile.add(defaultSeries.trim().toUpperCase())
|
||
}
|
||
const openingBalanceSeries =
|
||
requestedOpeningBalanceSeries || defaultOpeningBalanceSeries(seriesUsedByFile)
|
||
|
||
// An explicitly chosen IB series that the file's vouchers also use
|
||
// reintroduces the numbering shift this option exists to prevent.
|
||
// Honor the choice (the caller may know better) but say what it does.
|
||
if (
|
||
requestedOpeningBalanceSeries &&
|
||
options.importOpeningBalances &&
|
||
options.importTransactions &&
|
||
seriesUsedByFile.has(requestedOpeningBalanceSeries)
|
||
) {
|
||
result.warnings.push(
|
||
`Vald verifikationsserie för ingående balanser (${requestedOpeningBalanceSeries}) används även av filens verifikationer: ` +
|
||
'IB-verifikationen tar seriens nästa nummer, så filens verifikationer i den serien kan förskjutas ett nummer jämfört med källsystemet.'
|
||
)
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// Gate on the EFFECTIVE set: for files without #IB 0, the IB derived
|
||
// from #UB -1 (issue #675) must still open this block: gating on raw
|
||
// parsed.openingBalances would silently skip the derived IB entirely.
|
||
const effectiveIB = getEffectiveOpeningBalances(parsed)
|
||
if (options.importOpeningBalances && effectiveIB.balances.length > 0 && result.fiscalPeriodId) {
|
||
// Check if opening balances already exist for this period
|
||
const { data: period } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('opening_balances_set, opening_balance_entry_id')
|
||
.eq('id', result.fiscalPeriodId)
|
||
.single()
|
||
|
||
if (period?.opening_balances_set || period?.opening_balance_entry_id) {
|
||
result.warnings.push('Ingående balanser finns redan för denna period: hoppar över IB-import')
|
||
} else {
|
||
// Continuation-import guard: if the company already has any posted
|
||
// non-IB journal entries from a prior import or manual bookkeeping,
|
||
// do NOT create a new IB entry. Each year's #IB equals the prior
|
||
// year's UB, which is already the sum of the prior year's posted
|
||
// transactions, so importing another IB entry double-counts one
|
||
// year of activity against every balance-sheet account. The
|
||
// first-ever import creates the legitimate pre-system IB; subsequent
|
||
// imports must rely on the prior entries to derive opening balances
|
||
// on the fly (via getOpeningBalances() fallback).
|
||
const isContinuationImport = await companyHasPriorActivity(
|
||
supabase,
|
||
companyId,
|
||
fiscalYearEnd,
|
||
)
|
||
|
||
if (isContinuationImport) {
|
||
result.warnings.push(
|
||
'Ingående balanser hoppades över eftersom bolaget redan har bokförda verifikationer. ' +
|
||
'Ingående balans för denna period härleds från föregående periods utgående balans. ' +
|
||
'Stäm av mot SIE-filens #IB om du är osäker.'
|
||
)
|
||
} else {
|
||
// Orphan-IB guard (issue #1882): the period pointer above is not
|
||
// proof that no IB voucher exists. replace_sie_import deletes only
|
||
// source_type='import' entries and CLEARS the period's OB pointer,
|
||
// so a prior import's IB voucher (source_type='opening_balance')
|
||
// survives every replace cycle with no pointer left behind: each
|
||
// re-import then created another "Ingående balanser" verifikat
|
||
// (field report: five accumulated). Look the survivors up directly
|
||
// and skip when any exist. On a failed check, skip too (fail
|
||
// closed against duplication) and say why.
|
||
const { data: existingIbEntries, error: existingIbError } = await supabase
|
||
.from('journal_entries')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.eq('fiscal_period_id', result.fiscalPeriodId)
|
||
.eq('source_type', 'opening_balance')
|
||
.eq('status', 'posted')
|
||
|
||
if (existingIbError) {
|
||
result.warnings.push(
|
||
`Ingående balanser hoppades över: det gick inte att kontrollera om en IB-verifikation redan finns (${existingIbError.message}). ` +
|
||
'Importera om filen med enbart ingående balanser, eller skapa IB manuellt, om ingen IB-verifikation finns.'
|
||
)
|
||
} else if ((existingIbEntries?.length ?? 0) > 0) {
|
||
// Skip the duplicate, but leave a consistent state behind. With
|
||
// the pointer NULL, getOpeningBalances falls back to
|
||
// compute_prior_opening_balances, which excludes an IB entry
|
||
// dated ON period_start (reports then show IB = 0), the manual
|
||
// IB flow (gated on opening_balances_set) can double-book, and
|
||
// year-end's duplicate-IB blocker never arms. So when the
|
||
// survivor is unambiguous (exactly one), relink it as the
|
||
// period's OB entry (permitted by
|
||
// enforce_opening_balance_immutability while the pointer is
|
||
// NULL) and diff its lines against the file's IB so a stale
|
||
// orphan is called out instead of silently kept. Both steps are
|
||
// best effort: the skip alone already stops the duplication, and
|
||
// reverseEntry clears the pointer again if the user stornos the
|
||
// relinked voucher to re-import corrected balances.
|
||
const orphans = existingIbEntries ?? []
|
||
let relinked = false
|
||
let amountsDiffer = false
|
||
if (orphans.length === 1) {
|
||
try {
|
||
const expected = validateIBBalance(parsed, accountMap)
|
||
const expectedNet = new Map<string, number>()
|
||
for (const line of expected.lines) {
|
||
const prev = expectedNet.get(line.account_number) ?? 0
|
||
expectedNet.set(
|
||
line.account_number,
|
||
roundOre(prev + line.debit_amount - line.credit_amount)
|
||
)
|
||
}
|
||
if (Math.abs(expected.roundingAdjustment) > 0.01) {
|
||
// createOpeningBalanceEntry books the adjustment on 2099
|
||
// with the opposite sign of the mapped diff.
|
||
const prev = expectedNet.get('2099') ?? 0
|
||
expectedNet.set('2099', roundOre(prev - expected.roundingAdjustment))
|
||
}
|
||
|
||
const { data: orphanLines, error: orphanLinesError } = await supabase
|
||
.from('journal_entry_lines')
|
||
.select('account_number, debit_amount, credit_amount')
|
||
.eq('journal_entry_id', orphans[0].id)
|
||
if (orphanLinesError) {
|
||
throw new Error(orphanLinesError.message)
|
||
}
|
||
const orphanNet = new Map<string, number>()
|
||
for (const line of orphanLines ?? []) {
|
||
const prev = orphanNet.get(line.account_number) ?? 0
|
||
orphanNet.set(
|
||
line.account_number,
|
||
roundOre(prev + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0))
|
||
)
|
||
}
|
||
for (const account of new Set([...expectedNet.keys(), ...orphanNet.keys()])) {
|
||
const diff = (expectedNet.get(account) ?? 0) - (orphanNet.get(account) ?? 0)
|
||
if (Math.abs(diff) > 0.01) {
|
||
amountsDiffer = true
|
||
break
|
||
}
|
||
}
|
||
|
||
await linkOpeningBalanceEntryToPeriod(
|
||
supabase,
|
||
companyId,
|
||
result.fiscalPeriodId,
|
||
orphans[0].id
|
||
)
|
||
relinked = true
|
||
} catch (relinkError) {
|
||
// Best effort only: a failed comparison or relink keeps the
|
||
// pre-guard state, and the warning below still says what to
|
||
// do about the surviving IB voucher.
|
||
console.error('[sie-import] orphan-IB relink skipped (non-fatal):', relinkError)
|
||
}
|
||
}
|
||
|
||
let ibSkipWarning =
|
||
orphans.length === 1
|
||
? 'En verifikation för ingående balanser finns redan i räkenskapsåret: hoppar över IB-import för att inte skapa en dubblett.'
|
||
: `${orphans.length} verifikationer för ingående balanser finns redan i räkenskapsåret: hoppar över IB-import för att inte skapa ännu en dubblett.`
|
||
if (relinked) {
|
||
ibSkipWarning +=
|
||
' Den befintliga IB-verifikationen har kopplats som räkenskapsårets ingående balans.'
|
||
}
|
||
if (amountsDiffer) {
|
||
ibSkipWarning +=
|
||
' OBS: den befintliga IB-verifikationens belopp skiljer sig från filens ingående balanser. ' +
|
||
'Ångra (storno) den gamla IB-verifikationen och importera om filen om filens belopp är de rätta.'
|
||
} else {
|
||
ibSkipWarning +=
|
||
' Ångra eller ta bort den gamla IB-verifikationen först om du vill importera om ingående balanser.'
|
||
}
|
||
result.warnings.push(ibSkipWarning)
|
||
} else {
|
||
const ibValidation = validateIBBalance(parsed, accountMap)
|
||
|
||
if (ibValidation.lines.length > 0) {
|
||
if (effectiveIB.derivedFromPriorYearUB) {
|
||
result.warnings.push(
|
||
'SIE-filen saknar ingående balanser (#IB) för räkenskapsåret. ' +
|
||
'Ingående balanser härleddes från föregående års utgående balanser (#UB -1) enligt kontinuitetsprincipen.'
|
||
)
|
||
}
|
||
|
||
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
|
||
ibExplanation = 'excluded_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
|
||
ibExplanation = 'unallocated_result'
|
||
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
|
||
ibExplanation = 'rounding'
|
||
result.warnings.push(
|
||
`Avrundningsdifferens vid SIE-import: ${ibValidation.roundingAdjustment} SEK bokförd på konto 2099`
|
||
)
|
||
}
|
||
}
|
||
|
||
result.openingBalanceEntryId = await createOpeningBalanceEntry(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
result.fiscalPeriodId,
|
||
parsed,
|
||
accountMap,
|
||
ibRoundingAdjustment,
|
||
openingBalanceSeries
|
||
)
|
||
|
||
if (result.openingBalanceEntryId) {
|
||
result.journalEntriesCreated++
|
||
result.journalEntryIds.push(result.openingBalanceEntryId)
|
||
|
||
await linkOpeningBalanceEntryToPeriod(
|
||
supabase,
|
||
companyId,
|
||
result.fiscalPeriodId,
|
||
result.openingBalanceEntryId
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Import transactions (SIE4 only)
|
||
if (options.importTransactions && parsed.vouchers.length > 0 && result.fiscalPeriodId) {
|
||
// Reject vouchers whose date falls outside the resolved fiscal period.
|
||
// Without this guard, a SIE file whose #VER dates extend beyond #RAR (or
|
||
// a fiscal period whose shape doesn't match the file's #RAR) would
|
||
// produce journal entries stamped to a period that doesn't cover their
|
||
// own entry_date: breaking the SIE invariant and BFL 5 kap.
|
||
//
|
||
// Fail closed if the period fetch errors: a silent skip would leave the
|
||
// exact data-corruption path this guard exists to close.
|
||
const { data: resolvedPeriod, error: resolvedPeriodError } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('period_start, period_end')
|
||
.eq('id', result.fiscalPeriodId)
|
||
.single()
|
||
|
||
if (resolvedPeriodError || !resolvedPeriod) {
|
||
result.errors.push(
|
||
`Kunde inte verifiera räkenskapsårets datumintervall innan import: ${resolvedPeriodError?.message ?? 'räkenskapsåret hittades inte'}. Försök igen.`
|
||
)
|
||
return result
|
||
}
|
||
|
||
// Date-only string comparison: sidesteps any latent off-by-one if the
|
||
// SIE parser ever attaches a time component to v.date. SIE per spec is
|
||
// YYYYMMDD and our parser normalizes to midnight, but a string compare
|
||
// matches the underlying DATE columns exactly and is cheap.
|
||
const periodStart = resolvedPeriod.period_start as string
|
||
const periodEnd = resolvedPeriod.period_end as string
|
||
const outOfRange = parsed.vouchers.filter((v) => {
|
||
const d = formatDate(v.date)
|
||
return d < periodStart || d > periodEnd
|
||
})
|
||
|
||
if (outOfRange.length > 0) {
|
||
const sample = outOfRange.slice(0, 3).map(v => `${v.series}${v.number} (${formatDate(v.date)})`).join(', ')
|
||
result.errors.push(
|
||
`${outOfRange.length} verifikation${outOfRange.length === 1 ? '' : 'er'} har datum utanför räkenskapsåret ` +
|
||
`${periodStart} till ${periodEnd}. Exempel: ${sample}${outOfRange.length > 3 ? '…' : ''}. ` +
|
||
`Importera varje räkenskapsår som en egen SIE-fil: flera år i samma fil stöds inte.`
|
||
)
|
||
return result
|
||
}
|
||
|
||
// 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))
|
||
|
||
// Parse fiscal year string dates for comparison (append T00:00:00 to avoid UTC shift)
|
||
const fyStart = new Date(fiscalYearStart + 'T00:00:00')
|
||
const fyEnd = new Date(fiscalYearEnd + 'T00:00:00')
|
||
|
||
// Allow 30 days margin from fiscal year start/end for partial detection
|
||
const msPerDay = 86400000
|
||
const startGap = earliestVoucher.getTime() - fyStart.getTime()
|
||
const endGap = fyEnd.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 ${fiscalYearStart}-${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, companyId, userId, '3741', 'Öresutjämning vid import')
|
||
|
||
const voucherResults = await importVouchers(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
result.fiscalPeriodId,
|
||
parsed,
|
||
accountMap,
|
||
defaultSeries
|
||
)
|
||
|
||
result.journalEntriesCreated += voucherResults.created
|
||
result.journalEntryIds.push(...voucherResults.ids)
|
||
importTypedEntryIds.push(...voucherResults.importTypedIds)
|
||
result.errors.push(...voucherResults.errors)
|
||
voucherNumberMapping = voucherResults.voucherNumberMapping
|
||
voucherSeriesUsed = voucherResults.seriesUsed
|
||
voucherRetryStats = {
|
||
retriedBatches: voucherResults.retriedBatches,
|
||
failedBatches: voucherResults.failedBatches,
|
||
}
|
||
|
||
// 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} ${voucherResults.skippedEmpty === 1 ? 'tom' : '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} ${totalSkipped === 1 ? 'verifikation' : 'verifikationer'} hoppades över (${totalSkipped === 1 ? 'ofullständig' : '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} ${voucherResults.skippedSingleLine === 1 ? 'enradsverifikation' : '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,
|
||
companyId,
|
||
userId,
|
||
result.fiscalPeriodId,
|
||
parsed,
|
||
accountMap,
|
||
voucherResults.movementsByAccount,
|
||
voucherResults.skippedDetails
|
||
)
|
||
|
||
result.warnings.push(...adjustment.warnings)
|
||
|
||
if (adjustment.entryId) {
|
||
result.journalEntriesCreated++
|
||
result.journalEntryIds.push(adjustment.entryId)
|
||
// The omföringsverifikation is source_type='import' too.
|
||
importTypedEntryIds.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. Non-fatal by design: the
|
||
// verifikationer are already committed and unwinding them over a lookup
|
||
// table would be worse than losing the table. But it is not silent either:
|
||
// the mapping is the user's manual source-to-BAS review, so a failure has
|
||
// to reach result.warnings (rendered as "Varningar" in ImportResultStep)
|
||
// instead of being swallowed the way the missing { error } destructure
|
||
// swallowed it for four months. userId is passed explicitly: this path also
|
||
// runs on API-key/MCP service clients with no session to derive it from.
|
||
try {
|
||
await saveMappings(supabase, companyId, mappings, userId)
|
||
} catch (mappingError) {
|
||
console.error('[sie-import] Failed to save mappings (non-fatal):', mappingError)
|
||
result.warnings.push(
|
||
`Kontomappningarna kunde inte sparas: de importerade verifikationerna ` +
|
||
`påverkas inte, men mappningen mellan källkontona och BAS finns inte ` +
|
||
`kvar, så nästa import av samma källsystem måste mappas om manuellt. ` +
|
||
`(${mappingError instanceof Error ? mappingError.message : 'okänt fel'})`
|
||
)
|
||
}
|
||
|
||
// Pragmatic IB resync: if a chronologically-later fiscal period already
|
||
// exists with its own opening_balance entry, the customer is doing a
|
||
// prior-year backfill. Sync the next period's IB to match the UB we
|
||
// just imported so reports stay consistent.
|
||
// result.success is finalized below, after diagnostics and documentation.
|
||
// Use the same error condition here so this block is reachable, but require
|
||
// a target-period entry so a no-op file cannot succeed through resync alone.
|
||
if (
|
||
result.errors.length === 0 &&
|
||
result.journalEntriesCreated > 0 &&
|
||
fiscalYearEnd &&
|
||
result.fiscalPeriodId &&
|
||
parsed.closingBalances.length > 0
|
||
) {
|
||
try {
|
||
const resync = await resyncNextPeriodOpeningBalance(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
fiscalYearEnd,
|
||
parsed,
|
||
accountMap,
|
||
)
|
||
if (resync.resynced) {
|
||
result.nextPeriodIBResync = {
|
||
nextPeriodId: resync.nextPeriodId,
|
||
nextPeriodName: resync.nextPeriodName,
|
||
stornoEntryId: resync.stornoEntryId,
|
||
newOpeningBalanceEntryId: resync.newOpeningBalanceEntryId,
|
||
}
|
||
result.journalEntriesCreated += 2 // storno + new IB
|
||
result.journalEntryIds.push(resync.stornoEntryId, resync.newOpeningBalanceEntryId)
|
||
result.warnings.push(
|
||
`Ingående balanser för ${resync.nextPeriodName} synkades om mot den just importerade utgående balansen.`,
|
||
)
|
||
} else if (resync.reason === 'next_period_locked' && resync.nextPeriodName) {
|
||
result.nextPeriodIBResyncSkipped = {
|
||
reason: 'locked',
|
||
nextPeriodName: resync.nextPeriodName,
|
||
}
|
||
result.warnings.push(
|
||
`Nästa räkenskapsår (${resync.nextPeriodName}) är låst: ingående balanser kunde inte synkas om automatiskt. Lås upp perioden och importera igen för att synka.`,
|
||
)
|
||
}
|
||
} catch (resyncError) {
|
||
console.error('[sie-import] IB resync failed (non-fatal):', resyncError)
|
||
result.warnings.push(
|
||
`Ingående balanser för nästa räkenskapsår kunde inte synkas om automatiskt: ${resyncError instanceof Error ? resyncError.message : 'okänt fel'}. Kontrollera och justera manuellt.`,
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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 ?? null,
|
||
fiscalYear: {
|
||
start: fiscalYearStart,
|
||
end: fiscalYearEnd,
|
||
},
|
||
importedAt: new Date().toISOString(),
|
||
importedBy: userId,
|
||
accountMappings: {
|
||
total: mappingStats.total,
|
||
exact: mappingStats.exact,
|
||
basRange: mappingStats.basRange,
|
||
manual: mappingStats.manual,
|
||
unmapped: mappingStats.unmapped,
|
||
},
|
||
// Behandlingshistorik for #KONTO renames applied by this import
|
||
// (BFNAR 2013:2, the warnings array only carries the count).
|
||
accountRenames:
|
||
accountSync.renamedAccounts.length > 0 ? accountSync.renamedAccounts : undefined,
|
||
vouchers: voucherStats,
|
||
openingBalanceRounding: ibRoundingAdjustment !== 0 ? ibRoundingAdjustment : null,
|
||
migrationAdjustment: migrationAdjustmentInfo,
|
||
voucherSeriesUsed: voucherSeriesUsed.length > 0 ? voucherSeriesUsed : [defaultSeries],
|
||
voucherNumberRanges: computeVoucherNumberRanges(voucherNumberMapping),
|
||
voucherNumberMapping,
|
||
}
|
||
|
||
// Populate structured details for the UI
|
||
const totalSkippedForDetails = voucherStats.skippedUnbalanced + voucherStats.skippedUnmapped +
|
||
voucherStats.skippedSingleLine + voucherStats.skippedEmpty
|
||
result.details = {
|
||
fiscalYear: fiscalYearStart && fiscalYearEnd
|
||
? { start: fiscalYearStart, end: fiscalYearEnd }
|
||
: undefined,
|
||
skippedVouchers: totalSkippedForDetails > 0 ? {
|
||
unbalanced: voucherStats.skippedUnbalanced,
|
||
unmapped: voucherStats.skippedUnmapped,
|
||
singleLine: voucherStats.skippedSingleLine,
|
||
empty: voucherStats.skippedEmpty,
|
||
total: totalSkippedForDetails,
|
||
} : undefined,
|
||
openingBalance: ibRoundingAdjustment !== 0 ? {
|
||
imbalance: ibRoundingAdjustment,
|
||
explanation: ibExplanation,
|
||
bookedToAccount: '2099',
|
||
} : undefined,
|
||
migrationAdjustment: migrationAdjustmentInfo.created ? {
|
||
created: true,
|
||
accountsAdjusted: migrationAdjustmentInfo.deltaAccounts,
|
||
} : undefined,
|
||
retriedBatches: voucherRetryStats.retriedBatches,
|
||
failedBatches: voucherRetryStats.failedBatches,
|
||
}
|
||
|
||
// Untransferred prior-year results — the root cause of "balansräkningen
|
||
// balanserar inte" after multi-year migrations. Any non-latest fiscal
|
||
// year whose P&L doesn't net to zero (its omföring av årets resultat is
|
||
// missing) corrupts every later derived opening balance by exactly that
|
||
// residual. Checked against the DB (not the file) so it also catches
|
||
// gaps introduced across separate per-year imports. Non-fatal: a
|
||
// diagnosis failure never fails the import.
|
||
try {
|
||
const untransferred = await findUntransferredResults(supabase, companyId)
|
||
if (untransferred.length > 0 && result.details) {
|
||
result.details.untransferredResults = untransferred
|
||
for (const culprit of untransferred) {
|
||
result.warnings.push(
|
||
`Resultatet för ${culprit.period_name} (${formatCurrency(culprit.pl_net, 'SEK', { minimumFractionDigits: 2 })}) har inte förts om till eget kapital — ` +
|
||
'senare års balansräkning visar en differens tills omföringen bokförs i det året.'
|
||
)
|
||
}
|
||
}
|
||
} catch (diagnosisError) {
|
||
console.error('[sie-import] untransferred-results check failed (non-fatal):', diagnosisError)
|
||
}
|
||
|
||
// Set success before finalizing
|
||
result.success = result.errors.length === 0
|
||
|
||
// Finalize the import record with results and documentation
|
||
await finalizeImportRecord(
|
||
supabase,
|
||
result.importId,
|
||
companyId,
|
||
result,
|
||
options.fileContent,
|
||
documentation
|
||
)
|
||
|
||
// Populate counterparty templates from voucher patterns (non-blocking)
|
||
if (result.success && parsed.vouchers.length > 0) {
|
||
try {
|
||
const templateCount = await populateTemplatesFromSieVouchers(
|
||
supabase, companyId, parsed.vouchers
|
||
)
|
||
if (templateCount > 0) {
|
||
console.info(`[sie-import] ${templateCount} counterparty templates extracted from voucher history`)
|
||
}
|
||
} catch (templateError) {
|
||
console.error('[sie-import] Failed to populate counterparty templates:', templateError)
|
||
}
|
||
}
|
||
|
||
// Opt-in: mark imported verifikat as "Inget underlag krävs" (non-blocking).
|
||
// Migrated vouchers carry their underlag in the source system, so the user
|
||
// can choose to keep all of them out of "Att hantera: saknade underlag" in
|
||
// one go instead of clearing thousands of items by hand. Data is already
|
||
// committed at this point, so a failure here only loses the convenience.
|
||
if (result.success && options.markImportedNoDocRequired && importTypedEntryIds.length > 0) {
|
||
try {
|
||
await markEntriesNoDocRequired(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
importTypedEntryIds,
|
||
'Importerad från tidigare system (SIE)',
|
||
)
|
||
} catch (exemptError) {
|
||
console.error('[sie-import] Failed to mark imported entries no-doc-required (non-fatal):', exemptError)
|
||
result.warnings.push(
|
||
'Kunde inte markera importerade verifikat som "Inget underlag krävs": du kan markera dem manuellt i bokföringslistan.',
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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'}`
|
||
)
|
||
|
||
// Mark the pending import as failed if we created one
|
||
if (result.importId) {
|
||
try {
|
||
await finalizeImportRecord(
|
||
supabase,
|
||
result.importId,
|
||
companyId,
|
||
result,
|
||
options.fileContent
|
||
)
|
||
} catch (finalizeError) {
|
||
console.error('[sie-import] Failed to finalize import record on error:', finalizeError)
|
||
}
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|