diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx
index 104df581..596f9024 100644
--- a/app/(dashboard)/import/page.tsx
+++ b/app/(dashboard)/import/page.tsx
@@ -498,6 +498,8 @@ function SIEImportWizard() {
closingBalances: [],
resultBalances: [],
vouchers: [],
+ dimensions: [],
+ dimensionValues: [],
issues: data.parsed.issues,
stats: data.parsed.stats,
})
diff --git a/components/import/ImportResultStep.tsx b/components/import/ImportResultStep.tsx
index 4974ee29..e73cdd8a 100644
--- a/components/import/ImportResultStep.tsx
+++ b/components/import/ImportResultStep.tsx
@@ -107,6 +107,33 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
)}
+ {/* Dimensions detected (lossless SIE round-trip, dimensions plan PR5) */}
+ {result.success && result.dimensionsImported && (
+
+
+
+
+ Dimensioner följde med importen
+
+
+ Filen innehöll kostnadsställen/projekt: {result.dimensionsImported.taggedLines}{' '}
+ taggade rader importerades
+ {result.dimensionsImported.values > 0 && (
+ <> och {result.dimensionsImported.values} nya värden lades till i registret>
+ )}
+ .{' '}
+ {result.dimensionsImported.toggleEnabled && (
+ <>Dimensioner aktiverades automatiskt för företaget — du hittar registret under{' '}
+
+ Kostnadsställen & projekt
+
+ .>
+ )}
+
+
+
+ )}
+
{/* Statistics */}
{result.success && (
diff --git a/lib/import/__tests__/sie-dimensions-roundtrip.test.ts b/lib/import/__tests__/sie-dimensions-roundtrip.test.ts
new file mode 100644
index 00000000..488629ab
--- /dev/null
+++ b/lib/import/__tests__/sie-dimensions-roundtrip.test.ts
@@ -0,0 +1,218 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { parseSIEFile } from '../sie-parser'
+import { collectSIEDimensionUsage } from '../sie-dimensions'
+import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
+import { generateSIEExport } from '@/lib/reports/sie-export'
+
+// ============================================================
+// Dimensions plan PR5 — the lossless round-trip guarantee.
+//
+// parse(source) → what import writes (registry rows + line dimension maps)
+// → generateSIEExport over exactly that state → parse(exported) must carry
+// the same dimension surface: #DIM/#UNDERDIM declarations, #OBJEKT values,
+// and per-line object lists. The comparison is structural (both files run
+// through the same parser), so formatting/order differences don't matter.
+// ============================================================
+
+// Sequential-queue supabase mock — same consumption order as
+// sie-export.test.ts documents:
+// 0 fiscal_periods.single, 1 prev period, 2 accounts, 3 journal_entries,
+// 4 journal_entry_lines, 5 dimensions, 6 dimension_values, 7 OB fallback
+let resultIdx: number
+let results: Array<{ data?: unknown; error?: unknown }>
+
+function makeBuilder() {
+ const b: Record
= {}
+ for (const m of ['select', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq', 'eq']) {
+ b[m] = vi.fn().mockReturnValue(b)
+ }
+ b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
+ return b
+}
+
+function makeClient() {
+ return {
+ from: vi.fn().mockImplementation(() => makeBuilder()),
+ rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any
+}
+
+beforeEach(() => {
+ resultIdx = 0
+ results = []
+})
+
+const SOURCE_SIE = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#FNAMN "Roundtrip AB"',
+ '#RAR 0 20260101 20261231',
+ '#KONTO 5010 "Lokalhyra"',
+ '#KONTO 1930 "Företagskonto"',
+ '#DIM 1 "Kostnadsställe"',
+ '#DIM 6 "Projekt"',
+ '#UNDERDIM 2 "Kostnadsbärare" 1',
+ '#OBJEKT 1 "KS01" "Butiken"',
+ '#OBJEKT 2 "KB1" "Bärare ett"',
+ '#OBJEKT 6 "P001" "Villa Almgren"',
+ '#VER A 1 20260115 "Hyra januari"',
+ '{',
+ '#TRANS 5010 {1 "KS01" 2 "KB1" 6 "P001"} 15000.00',
+ '#TRANS 1930 {} -15000.00',
+ '}',
+ '#VER A 2 20260116 "Odeklarerat projekt"',
+ '{',
+ // P002 is referenced but never declared via #OBJEKT — import synthesizes
+ // it (name = code) and export must re-declare it.
+ '#TRANS 5010 {6 "P002"} 500.00',
+ '#TRANS 1930 {} -500.00',
+ '}',
+].join('\n')
+
+describe('SIE dimensions round-trip', () => {
+ it('parse → import state → export → parse preserves the dimension surface', async () => {
+ const parsedSource = parseSIEFile(SOURCE_SIE)
+ expect(parsedSource.issues.filter((i) => i.severity === 'error')).toEqual([])
+
+ // ── What the importer writes ─────────────────────────────────
+ const usage = collectSIEDimensionUsage(parsedSource)
+
+ // Registry rows exactly as importDimensionRegistry inserts them.
+ const dimensionRows = [...usage.dims.entries()].map(([sieDimNo, info], i) => ({
+ id: `dim-${sieDimNo}`,
+ sie_dim_no: sieDimNo,
+ parent_sie_dim_no: info.parent ?? null,
+ name: info.name,
+ sort_order: i,
+ }))
+ const valueRows = [...usage.values.values()].map((v) => ({
+ dimension_id: `dim-${v.sieDimNo}`,
+ code: v.code,
+ name: v.name,
+ }))
+
+ // Journal lines exactly as importVouchers inserts them (dimensions jsonb
+ // via normalizeLineDimensions; SIE amount sign → debit/credit).
+ const journalEntries = parsedSource.vouchers.map((v, i) => ({
+ id: `e${i + 1}`,
+ entry_date: '2026-01-15',
+ voucher_number: v.number,
+ voucher_series: v.series,
+ description: v.description,
+ status: 'posted',
+ }))
+ const journalLines = parsedSource.vouchers.flatMap((v, i) =>
+ v.lines.map((line) => ({
+ journal_entry_id: `e${i + 1}`,
+ account_number: line.account,
+ debit_amount: line.amount > 0 ? line.amount : 0,
+ credit_amount: line.amount < 0 ? Math.abs(line.amount) : 0,
+ line_description: line.description ?? null,
+ dimensions: normalizeLineDimensions({ dimensions: line.dimensions ?? null }),
+ }))
+ )
+
+ // ── Export over exactly that state ───────────────────────────
+ results = [
+ { data: { id: 'period-1', period_start: '2026-01-01', period_end: '2026-12-31' }, error: null },
+ { data: null, error: null }, // prev period
+ {
+ data: [
+ { account_number: '5010', account_name: 'Lokalhyra', sru_code: null, is_active: true },
+ { account_number: '1930', account_name: 'Företagskonto', sru_code: null, is_active: true },
+ ],
+ error: null,
+ },
+ { data: journalEntries, error: null },
+ { data: journalLines, error: null },
+ { data: dimensionRows, error: null },
+ { data: valueRows, error: null },
+ { data: [], error: null }, // OB fallback
+ ]
+
+ const exported = await generateSIEExport(makeClient(), 'company-1', {
+ fiscal_period_id: 'period-1',
+ company_name: 'Roundtrip AB',
+ org_number: null,
+ program_name: 'ERPBase',
+ })
+
+ // ── Compare the two dimension surfaces structurally ──────────
+ const parsedExport = parseSIEFile(exported)
+ expect(parsedExport.issues.filter((i) => i.severity === 'error')).toEqual([])
+
+ const dimSet = (dims: typeof parsedSource.dimensions) =>
+ new Set(dims.map((d) => `${d.sieDimNo}|${d.name}|${d.parentSieDimNo ?? ''}`))
+ // Every declaration that carries data survives — including the
+ // #UNDERDIM child with its parent link. (A declared dimension with no
+ // values and no tagged lines is metadata without data; export
+ // deliberately omits it, so the fixture gives every dim a value.)
+ for (const entry of dimSet(parsedSource.dimensions)) {
+ expect(dimSet(parsedExport.dimensions)).toContain(entry)
+ }
+
+ const valueSet = (values: typeof parsedSource.dimensionValues) =>
+ new Set(values.map((v) => `${v.sieDimNo}|${v.code}|${v.name}`))
+ for (const entry of valueSet(parsedSource.dimensionValues)) {
+ expect(valueSet(parsedExport.dimensionValues)).toContain(entry)
+ }
+ // The undeclared-but-referenced P002 got synthesized (name = code).
+ expect(valueSet(parsedExport.dimensionValues)).toContain('6|P002|P002')
+
+ // Per-line object lists survive verbatim.
+ const lineDims = (parsed: typeof parsedSource) =>
+ parsed.vouchers.flatMap((v) => v.lines.map((l) => l.dimensions ?? null))
+ expect(lineDims(parsedExport)).toEqual(lineDims(parsedSource))
+ })
+
+ it('collectSIEDimensionUsage synthesizes reserved names and prefers declared ones', () => {
+ const parsed = parseSIEFile(
+ [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#RAR 0 20260101 20261231',
+ // Dim 7 (reserved: Anställd) referenced without declaration; dim 6
+ // declared with a custom name that must win over "Projekt".
+ '#DIM 6 "Mina projekt"',
+ '#VER A 1 20260115 "Löner"',
+ '{',
+ '#TRANS 7010 {7 "ANNA" 6 "P001" 42 "X"} 1000.00',
+ '#TRANS 1930 {} -1000.00',
+ '}',
+ ].join('\n')
+ )
+
+ const usage = collectSIEDimensionUsage(parsed)
+ expect(usage.dims.get(7)?.name).toBe('Anställd')
+ expect(usage.dims.get(6)?.name).toBe('Mina projekt')
+ // Unknown custom number falls back to a generic label.
+ expect(usage.dims.get(42)?.name).toBe('Dimension 42')
+ expect(usage.taggedLines).toBe(1)
+ expect([...usage.values.keys()].sort()).toEqual(['42 X', '6 P001', '7 ANNA'])
+ })
+
+ it('rejects registry codes that violate the DB CHECK but keeps them on lines', () => {
+ const longCode = 'X'.repeat(41)
+ const parsed = parseSIEFile(
+ [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#RAR 0 20260101 20261231',
+ '#VER A 1 20260115 "För lång kod"',
+ '{',
+ `#TRANS 5010 {6 "${longCode}"} 100.00`,
+ '#TRANS 1930 {} -100.00',
+ '}',
+ ].join('\n')
+ )
+
+ // The line keeps the tag (legacy free-text survives on lines)…
+ expect(parsed.vouchers[0].lines[0].dimensions).toEqual({ '6': longCode })
+ // …but the registry collection skips it and reports why.
+ const usage = collectSIEDimensionUsage(parsed)
+ expect(usage.values.size).toBe(0)
+ expect(usage.invalidCodes.size).toBe(1)
+ })
+})
diff --git a/lib/import/__tests__/sie-import-coverage.test.ts b/lib/import/__tests__/sie-import-coverage.test.ts
index 2f9e2278..92fcfd34 100644
--- a/lib/import/__tests__/sie-import-coverage.test.ts
+++ b/lib/import/__tests__/sie-import-coverage.test.ts
@@ -42,6 +42,8 @@ function makeParsedFile(overrides?: Partial): ParsedSIEFile {
openingBalances: [{ yearIndex: 0, account: '1930', amount: 50000 }],
closingBalances: [],
resultBalances: [],
+ dimensions: [],
+ dimensionValues: [],
vouchers: [
{
series: 'A',
diff --git a/lib/import/__tests__/sie-import-derived-ib.test.ts b/lib/import/__tests__/sie-import-derived-ib.test.ts
index cad6292c..ff983b86 100644
--- a/lib/import/__tests__/sie-import-derived-ib.test.ts
+++ b/lib/import/__tests__/sie-import-derived-ib.test.ts
@@ -99,6 +99,8 @@ function makeParsedFile(overrides?: Partial): ParsedSIEFile {
{ yearIndex: 0, account: '2010', amount: -160406.0 },
],
resultBalances: [],
+ dimensions: [],
+ dimensionValues: [],
vouchers: [],
issues: [],
stats: {
diff --git a/lib/import/__tests__/sie-import.account-names.test.ts b/lib/import/__tests__/sie-import.account-names.test.ts
index b874b5dc..ce28983e 100644
--- a/lib/import/__tests__/sie-import.account-names.test.ts
+++ b/lib/import/__tests__/sie-import.account-names.test.ts
@@ -46,6 +46,8 @@ function makeParsedFile(): ParsedSIEFile {
openingBalances: [],
closingBalances: [],
resultBalances: [],
+ dimensions: [],
+ dimensionValues: [],
vouchers: [
{
series: 'A',
diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts
index 53026326..b55bd073 100644
--- a/lib/import/__tests__/sie-import.test.ts
+++ b/lib/import/__tests__/sie-import.test.ts
@@ -43,6 +43,8 @@ function makeParsedFile(overrides?: Partial): ParsedSIEFile {
],
closingBalances: [],
resultBalances: [],
+ dimensions: [],
+ dimensionValues: [],
vouchers: [
{
series: 'A',
diff --git a/lib/import/__tests__/sie-parser.test.ts b/lib/import/__tests__/sie-parser.test.ts
index b2589ad1..124dd8d9 100644
--- a/lib/import/__tests__/sie-parser.test.ts
+++ b/lib/import/__tests__/sie-parser.test.ts
@@ -275,14 +275,105 @@ describe('parseSIEFile', () => {
expect(v1.lines[2]).toMatchObject({ account: '2611', amount: -2500 })
})
- it('handles object lists in braces', () => {
+ it('handles object lists in braces and captures them as dimensions', () => {
const result = parseSIEFile(SIE_WITH_OBJECT_LIST)
expect(result.vouchers).toHaveLength(1)
const v = result.vouchers[0]
expect(v.lines).toHaveLength(2)
expect(v.lines[0]).toMatchObject({ account: '5010', amount: 15000 })
+ // The object list is data, not noise — lossless import (PR5).
+ expect(v.lines[0].dimensions).toEqual({ '1': 'Kontor' })
expect(v.lines[1]).toMatchObject({ account: '1930', amount: -15000 })
+ // Empty object list {} → no dimensions key at all.
+ expect(v.lines[1].dimensions).toBeUndefined()
+ })
+
+ it('parses multi-pair object lists with quoted codes and canonical keys', () => {
+ const sie = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#RAR 0 20240101 20241231',
+ '#VER A 1 20240115 "Projektköp"',
+ '{',
+ '#TRANS 5010 {"1" "KS 01" 06 "P001"} 15000.00',
+ '#TRANS 1930 {} -15000.00',
+ '}',
+ ].join('\n')
+
+ const result = parseSIEFile(sie)
+ // '06' canonicalizes to '6' (matches normalizeLineDimensions); quoted
+ // codes may contain spaces.
+ expect(result.vouchers[0].lines[0].dimensions).toEqual({ '1': 'KS 01', '6': 'P001' })
+ })
+
+ it('warns on a malformed (odd-field) object list but keeps the line', () => {
+ const sie = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#RAR 0 20240101 20241231',
+ '#VER A 1 20240115 "Trasig objektlista"',
+ '{',
+ '#TRANS 5010 {6} 100.00',
+ '#TRANS 1930 {} -100.00',
+ '}',
+ ].join('\n')
+
+ const result = parseSIEFile(sie)
+ expect(result.vouchers[0].lines[0]).toMatchObject({ account: '5010', amount: 100 })
+ expect(result.vouchers[0].lines[0].dimensions).toBeUndefined()
+ expect(result.issues.some((i) => i.severity === 'warning' && i.message.toLowerCase().includes('objektlista'))).toBe(true)
+ })
+
+ it('surfaces OIB/OUB drops and dimension presence as info issues', () => {
+ const sie = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#RAR 0 20240101 20241231',
+ '#DIM 6 "Projekt"',
+ '#OIB 0 1930 {6 "P001"} 5000.00',
+ '#OUB 0 1930 {6 "P001"} 7000.00',
+ '#VER A 1 20240115 "Taggad"',
+ '{',
+ '#TRANS 5010 {6 "P001"} 100.00',
+ '#TRANS 1930 {} -100.00',
+ '}',
+ ].join('\n')
+
+ const result = parseSIEFile(sie)
+ const infos = result.issues.filter((i) => i.severity === 'info').map((i) => i.message)
+ expect(infos.some((m) => m.includes('2 objektbalansrader'))).toBe(true)
+ expect(infos.some((m) => m.includes('dimensionsdata'))).toBe(true)
+ // Silence preserved for files without any dimension data.
+ const plain = parseSIEFile(['#FLAGGA 0', '#SIETYP 4', '#RAR 0 20240101 20241231'].join('\n'))
+ expect(plain.issues.some((i) => i.tag === 'DIM' || i.tag === 'OIB')).toBe(false)
+ })
+
+ it('parses #DIM, #UNDERDIM and #OBJEKT into the registry arrays', () => {
+ const sie = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#RAR 0 20240101 20241231',
+ '#DIM 1 "Kostnadsställe"',
+ '#DIM 6 "Projekt"',
+ '#UNDERDIM 2 "Kostnadsbärare" 1',
+ '#OBJEKT 1 "KS01" "Butiken"',
+ '#OBJEKT 6 "P001" "Villa Almgren"',
+ '#OBJEKT 6 "P002" ""',
+ ].join('\n')
+
+ const result = parseSIEFile(sie)
+ expect(result.dimensions).toEqual([
+ { sieDimNo: 1, name: 'Kostnadsställe' },
+ { sieDimNo: 6, name: 'Projekt' },
+ { sieDimNo: 2, name: 'Kostnadsbärare', parentSieDimNo: 1 },
+ ])
+ expect(result.dimensionValues).toEqual([
+ { sieDimNo: 1, code: 'KS01', name: 'Butiken' },
+ { sieDimNo: 6, code: 'P001', name: 'Villa Almgren' },
+ // Nameless objekt falls back to its code.
+ { sieDimNo: 6, code: 'P002', name: 'P002' },
+ ])
})
it('parses quoted VER fields (series, number, date)', () => {
diff --git a/lib/import/__tests__/undo-sie-import-dimensions.pg.test.ts b/lib/import/__tests__/undo-sie-import-dimensions.pg.test.ts
new file mode 100644
index 00000000..47bf8ec5
--- /dev/null
+++ b/lib/import/__tests__/undo-sie-import-dimensions.pg.test.ts
@@ -0,0 +1,232 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { getPool } from '@/tests/pg/setup'
+import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
+
+// Migration 20260702154500_dimension_import_provenance_undo_lockstep.sql:
+// SIE import creates dimensions/dimension_values rows carrying
+// created_by_import_id; undo_sie_import deletes the rows the undone import
+// introduced — but ONLY when no remaining posted/reversed line references
+// them, and NEVER user-created rows (created_by_import_id IS NULL).
+//
+// The registry guard triggers (enforce_dimension_registry_guards /
+// enforce_dimension_value_retention) fire on these deletes as a backstop, so
+// these tests also prove the lockstep deletes are trigger-compatible.
+
+async function insertCompletedImport(params: {
+ companyId: string
+ userId: string
+ fiscalPeriodId: string
+}): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.sie_imports
+ (id, user_id, company_id, filename, file_hash, sie_type,
+ fiscal_year_start, fiscal_year_end, accounts_count, transactions_count,
+ status, fiscal_period_id, imported_at)
+ VALUES ($1, $2, $3, 'undo-dims-test.se', $4, 4,
+ '2026-01-01', '2026-12-31', 0, 1,
+ 'completed', $5, now())`,
+ [id, params.userId, params.companyId, `hash-${id}`, params.fiscalPeriodId],
+ )
+ return id
+}
+
+/** Posted entry with dimension-tagged balanced lines. */
+async function insertPostedTaggedEntry(params: {
+ companyId: string
+ userId: string
+ fiscalPeriodId: string
+ sourceType: string
+ voucherNumber: number
+ dimensions: Record
+}): Promise {
+ const jeId = await insertDraftJournalEntry({
+ userId: params.userId,
+ companyId: params.companyId,
+ fiscalPeriodId: params.fiscalPeriodId,
+ sourceType: params.sourceType,
+ status: 'draft',
+ voucherNumber: params.voucherNumber,
+ })
+ await getPool().query(
+ `INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount, dimensions)
+ VALUES ($1, '5010', 1000, 0, $2::jsonb),
+ ($1, '1930', 0, 1000, '{}'::jsonb)`,
+ [jeId, JSON.stringify(params.dimensions)],
+ )
+ await getPool().query(
+ `UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`,
+ [jeId],
+ )
+ return jeId
+}
+
+async function insertDimension(params: {
+ companyId: string
+ sieDimNo: number
+ name: string
+ importId?: string | null
+ isSystem?: boolean
+}): Promise {
+ const { rows } = await getPool().query<{ id: string }>(
+ `INSERT INTO public.dimensions
+ (company_id, sie_dim_no, name, resets_annually, is_system, created_by_import_id)
+ VALUES ($1, $2, $3, true, $4, $5)
+ RETURNING id`,
+ [params.companyId, params.sieDimNo, params.name, params.isSystem ?? false, params.importId ?? null],
+ )
+ return rows[0].id
+}
+
+async function insertDimensionValue(params: {
+ companyId: string
+ dimensionId: string
+ code: string
+ importId?: string | null
+}): Promise {
+ const { rows } = await getPool().query<{ id: string }>(
+ `INSERT INTO public.dimension_values
+ (company_id, dimension_id, code, name, created_by_import_id)
+ VALUES ($1, $2, $3, $3, $4)
+ RETURNING id`,
+ [params.companyId, params.dimensionId, params.code, params.importId ?? null],
+ )
+ return rows[0].id
+}
+
+async function callUndo(companyId: string, importId: string, actor: string) {
+ return getPool().query<{ deleted: number }>(
+ `SELECT public.undo_sie_import($1::uuid, $2::uuid, $3::uuid) AS deleted`,
+ [companyId, importId, actor],
+ )
+}
+
+async function countRows(table: string, id: string): Promise {
+ const { rows } = await getPool().query(
+ `SELECT 1 FROM public.${table} WHERE id = $1`,
+ [id],
+ )
+ return rows.length
+}
+
+describe('undo_sie_import: dimension registry lockstep', () => {
+ it('deletes import-created values whose references vanish with the import', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+ const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
+
+ const dimId = await insertDimension({ companyId, sieDimNo: 6, name: 'Projekt' })
+ const valueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'P001', importId })
+ await insertPostedTaggedEntry({
+ companyId, userId, fiscalPeriodId,
+ sourceType: 'import', voucherNumber: 1,
+ dimensions: { '6': 'P001' },
+ })
+
+ const res = await callUndo(companyId, importId, userId)
+ expect(res.rows[0].deleted).toBe(1)
+
+ // The import's value is gone; the dimension itself (not import-created)
+ // survives.
+ expect(await countRows('dimension_values', valueId)).toBe(0)
+ expect(await countRows('dimensions', dimId)).toBe(1)
+ })
+
+ it('keeps import-created values that other posted bookkeeping still references', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+ const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
+
+ const dimId = await insertDimension({ companyId, sieDimNo: 6, name: 'Projekt' })
+ const valueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'P002', importId })
+ await insertPostedTaggedEntry({
+ companyId, userId, fiscalPeriodId,
+ sourceType: 'import', voucherNumber: 1,
+ dimensions: { '6': 'P002' },
+ })
+ // A MANUAL posted entry tagged with the same code — survives the undo and
+ // must keep its registry row (retention trigger would block the delete;
+ // the lockstep's own WHERE avoids even attempting it).
+ await insertPostedTaggedEntry({
+ companyId, userId, fiscalPeriodId,
+ sourceType: 'manual', voucherNumber: 2,
+ dimensions: { '6': 'P002' },
+ })
+
+ const res = await callUndo(companyId, importId, userId)
+ expect(res.rows[0].deleted).toBe(1) // only the import entry
+
+ expect(await countRows('dimension_values', valueId)).toBe(1)
+ })
+
+ it('never touches user-created values (no provenance)', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+ const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
+
+ const dimId = await insertDimension({ companyId, sieDimNo: 6, name: 'Projekt' })
+ const userValueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'EGEN', importId: null })
+ await insertPostedTaggedEntry({
+ companyId, userId, fiscalPeriodId,
+ sourceType: 'import', voucherNumber: 1,
+ dimensions: { '6': 'EGEN' },
+ })
+
+ await callUndo(companyId, importId, userId)
+
+ // Unreferenced now, but user-created → stays.
+ expect(await countRows('dimension_values', userValueId)).toBe(1)
+ })
+
+ it('deletes an import-created custom dimension once empty and unreferenced', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+ const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
+
+ const dimId = await insertDimension({ companyId, sieDimNo: 7, name: 'Anställd', importId })
+ const valueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'ANNA', importId })
+ await insertPostedTaggedEntry({
+ companyId, userId, fiscalPeriodId,
+ sourceType: 'import', voucherNumber: 1,
+ dimensions: { '7': 'ANNA' },
+ })
+
+ await callUndo(companyId, importId, userId)
+
+ expect(await countRows('dimension_values', valueId)).toBe(0)
+ expect(await countRows('dimensions', dimId)).toBe(0)
+ })
+
+ it('keeps an import-created dimension that still has user-created values', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+ const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
+
+ const dimId = await insertDimension({ companyId, sieDimNo: 8, name: 'Kund', importId })
+ const userValueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'KUND1', importId: null })
+ await insertPostedTaggedEntry({
+ companyId, userId, fiscalPeriodId,
+ sourceType: 'import', voucherNumber: 1,
+ dimensions: { '8': 'KUND1' },
+ })
+
+ await callUndo(companyId, importId, userId)
+
+ // The user's value anchors the dimension.
+ expect(await countRows('dimension_values', userValueId)).toBe(1)
+ expect(await countRows('dimensions', dimId)).toBe(1)
+ })
+
+ it('deleting an old sie_imports row nulls provenance instead of cascading', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+ const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
+ const dimId = await insertDimension({ companyId, sieDimNo: 6, name: 'Projekt' })
+ const valueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'P009', importId })
+
+ await getPool().query(`DELETE FROM public.sie_imports WHERE id = $1`, [importId])
+
+ const { rows } = await getPool().query<{ created_by_import_id: string | null }>(
+ `SELECT created_by_import_id FROM public.dimension_values WHERE id = $1`,
+ [valueId],
+ )
+ expect(rows).toHaveLength(1)
+ expect(rows[0].created_by_import_id).toBeNull()
+ })
+})
diff --git a/lib/import/sie-dimensions.ts b/lib/import/sie-dimensions.ts
new file mode 100644
index 00000000..5409e0dd
--- /dev/null
+++ b/lib/import/sie-dimensions.ts
@@ -0,0 +1,216 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { SIE_RESERVED_DIMENSIONS } from '@/lib/reports/sie-export'
+import type { ParsedSIEFile } from './types'
+
+/**
+ * Registry-side of lossless SIE dimension import (dimensions plan PR5).
+ *
+ * Collects every dimension the file mentions — declared (#DIM/#UNDERDIM),
+ * valued (#OBJEKT), or merely referenced by a #TRANS object list — and
+ * inserts the missing `dimensions`/`dimension_values` rows. Existing rows
+ * are NEVER touched (ON CONFLICT DO NOTHING): an import must not rename a
+ * user's dimensions or values. Undeclared reserved numbers synthesize their
+ * SIE-standard names (1 Kostnadsställe, 2→1 Kostnadsbärare, 6 Projekt, 7–10);
+ * unknown customs fall back to "Dimension N", exactly mirroring the export's
+ * orphan synthesis so a parse→import→re-export round-trip is lossless.
+ *
+ * Rows created here carry `created_by_import_id` so `undo_sie_import` can
+ * remove registry values that the undone import introduced (and that nothing
+ * else references) — the lockstep the plan requires.
+ */
+
+export interface DimensionImportSummary {
+ /** dimensions rows actually inserted (not pre-existing). */
+ dimensionsCreated: number
+ /** dimension_values rows actually inserted (not pre-existing). */
+ valuesCreated: number
+ /** #TRANS lines in the file carrying an object list. */
+ taggedLines: number
+ /** True when this import flipped company_settings.dimensions_enabled on. */
+ toggleEnabled: boolean
+ warnings: string[]
+}
+
+/** dimension_values.code DB CHECK: 1–40 chars, none of `"{}`. */
+function isValidRegistryCode(code: string): boolean {
+ return code.length >= 1 && code.length <= 40 && !/["{}]/.test(code)
+}
+
+export function collectSIEDimensionUsage(parsed: ParsedSIEFile): {
+ dims: Map
+ values: Map
+ taggedLines: number
+ invalidCodes: Set
+} {
+ const dims = new Map()
+ const values = new Map()
+ const invalidCodes = new Set()
+ let taggedLines = 0
+
+ const ensureDim = (dimNo: number, name?: string, parent?: number) => {
+ const existing = dims.get(dimNo)
+ if (existing) {
+ // A declared name/parent wins over a synthesized placeholder.
+ if (name) existing.name = name
+ if (parent !== undefined) existing.parent = parent
+ return
+ }
+ const reserved = SIE_RESERVED_DIMENSIONS[dimNo]
+ dims.set(dimNo, {
+ name: name || reserved?.name || `Dimension ${dimNo}`,
+ parent: parent ?? reserved?.parent,
+ })
+ }
+
+ const ensureValue = (dimNo: number, code: string, name?: string) => {
+ if (!isValidRegistryCode(code)) {
+ invalidCodes.add(`${dimNo}:${code}`)
+ return
+ }
+ ensureDim(dimNo)
+ const key = `${dimNo} ${code}`
+ const existing = values.get(key)
+ if (existing) {
+ if (name && existing.name === existing.code) existing.name = name
+ return
+ }
+ values.set(key, { sieDimNo: dimNo, code, name: name || code })
+ }
+
+ // Nullish guards: ParsedSIEFile-shaped objects predating PR5 (serialized
+ // previews, hand-built test fixtures) may lack the dimension arrays.
+ for (const dim of parsed.dimensions ?? []) {
+ ensureDim(dim.sieDimNo, dim.name || undefined, dim.parentSieDimNo)
+ }
+ for (const value of parsed.dimensionValues ?? []) {
+ ensureValue(value.sieDimNo, value.code, value.name)
+ }
+ for (const voucher of parsed.vouchers ?? []) {
+ for (const line of voucher.lines) {
+ if (!line.dimensions) continue
+ taggedLines++
+ for (const [dimNoRaw, code] of Object.entries(line.dimensions)) {
+ const dimNo = Number(dimNoRaw)
+ if (!Number.isInteger(dimNo) || dimNo < 1) continue
+ ensureValue(dimNo, code)
+ }
+ }
+ }
+
+ return { dims, values, taggedLines, invalidCodes }
+}
+
+/**
+ * Upsert the registry rows the file needs and flip dimensions_enabled on.
+ * Returns null when the file carries no dimension data at all — companies
+ * without dimensions see literally nothing changed.
+ */
+export async function importDimensionRegistry(
+ supabase: SupabaseClient,
+ companyId: string,
+ parsed: ParsedSIEFile,
+ importId: string | null
+): Promise {
+ const { dims, values, taggedLines, invalidCodes } = collectSIEDimensionUsage(parsed)
+ if (dims.size === 0 && values.size === 0 && taggedLines === 0) {
+ return null
+ }
+
+ const warnings: string[] = []
+ if (invalidCodes.size > 0) {
+ warnings.push(
+ `${invalidCodes.size} dimensionskoder kunde inte registreras (ogiltig längd eller tecken): ` +
+ [...invalidCodes].slice(0, 5).join(', ') +
+ (invalidCodes.size > 5 ? '…' : '')
+ )
+ }
+
+ // Seed system dims 1/6 (idempotent) before touching the registry.
+ await supabase.rpc('ensure_company_dimensions', { p_company_id: companyId })
+
+ // ── dimensions rows — insert missing, never rename existing ────
+ let dimensionsCreated = 0
+ if (dims.size > 0) {
+ const dimInserts = [...dims.entries()].map(([sieDimNo, info]) => ({
+ company_id: companyId,
+ sie_dim_no: sieDimNo,
+ parent_sie_dim_no: info.parent ?? null,
+ name: info.name,
+ // Dim 1 resets annually per SIE convention; projekt (6) accumulates.
+ // ensure_company_dimensions already seeded 1/6 with the right flags,
+ // so this only matters for custom dims — default to resetting.
+ resets_annually: sieDimNo !== 6,
+ is_system: false,
+ created_by_import_id: importId,
+ }))
+ const { data: insertedDims, error: dimError } = await supabase
+ .from('dimensions')
+ .upsert(dimInserts, { onConflict: 'company_id,sie_dim_no', ignoreDuplicates: true })
+ .select('id')
+ if (dimError) {
+ warnings.push(`Dimensionsregistret kunde inte uppdateras: ${dimError.message}`)
+ return { dimensionsCreated: 0, valuesCreated: 0, taggedLines, toggleEnabled: false, warnings }
+ }
+ dimensionsCreated = insertedDims?.length ?? 0
+ }
+
+ // ── dimension_values rows ───────────────────────────────────────
+ let valuesCreated = 0
+ if (values.size > 0) {
+ const { data: dimRows, error: readError } = await supabase
+ .from('dimensions')
+ .select('id, sie_dim_no')
+ .eq('company_id', companyId)
+ .in('sie_dim_no', [...new Set([...values.values()].map((v) => v.sieDimNo))])
+ if (readError || !dimRows) {
+ warnings.push(`Dimensionsvärden kunde inte registreras: ${readError?.message ?? 'okänt fel'}`)
+ return { dimensionsCreated, valuesCreated: 0, taggedLines, toggleEnabled: false, warnings }
+ }
+ const dimIdByNo = new Map(dimRows.map((d) => [Number(d.sie_dim_no), d.id as string]))
+
+ const valueInserts = [...values.values()]
+ .filter((v) => dimIdByNo.has(v.sieDimNo))
+ .map((v) => ({
+ company_id: companyId,
+ dimension_id: dimIdByNo.get(v.sieDimNo)!,
+ code: v.code,
+ name: v.name,
+ created_by_import_id: importId,
+ }))
+
+ if (valueInserts.length > 0) {
+ const { data: insertedValues, error: valueError } = await supabase
+ .from('dimension_values')
+ .upsert(valueInserts, {
+ onConflict: 'company_id,dimension_id,code',
+ ignoreDuplicates: true,
+ })
+ .select('id')
+ if (valueError) {
+ warnings.push(`Dimensionsvärden kunde inte registreras: ${valueError.message}`)
+ return { dimensionsCreated, valuesCreated: 0, taggedLines, toggleEnabled: false, warnings }
+ }
+ valuesCreated = insertedValues?.length ?? 0
+ }
+ }
+
+ // ── Auto-enable the toggle with a notice ────────────────────────
+ // The column comment pre-authorizes this: "SIE import that finds dimensions
+ // may flip this on with a notice." Idempotent; only reported as flipped
+ // when it actually changed.
+ let toggleEnabled = false
+ const { data: settings } = await supabase
+ .from('company_settings')
+ .select('dimensions_enabled')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ if (settings && settings.dimensions_enabled !== true) {
+ const { error: toggleError } = await supabase
+ .from('company_settings')
+ .update({ dimensions_enabled: true })
+ .eq('company_id', companyId)
+ if (!toggleError) toggleEnabled = true
+ }
+
+ return { dimensionsCreated, valuesCreated, taggedLines, toggleEnabled, warnings }
+}
diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts
index bd966ab0..f47ec68c 100644
--- a/lib/import/sie-import.ts
+++ b/lib/import/sie-import.ts
@@ -7,6 +7,8 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
+import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
+import { importDimensionRegistry } from './sie-dimensions'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import type {
ParsedSIEFile,
@@ -1012,7 +1014,13 @@ export async function importVouchers(
// '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 }[]
+ lines: {
+ account_number: string
+ debit_amount: number
+ credit_amount: number
+ line_description: string | null
+ dimensions?: Record
+ }[]
}
const preparedVouchers: PreparedVoucher[] = []
@@ -1060,6 +1068,7 @@ export async function importVouchers(
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({
@@ -1067,6 +1076,7 @@ export async function importVouchers(
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
@@ -1334,6 +1344,9 @@ export async function importVouchers(
currency: string
line_description: string | null
sort_order: number
+ dimensions: Record
+ cost_center: string | null
+ project: string | null
}[] = []
for (let i = 0; i < batch.length; i++) {
@@ -1343,6 +1356,12 @@ export async function importVouchers(
const voucher = batch[i]
const assignedNumber = currentVoucherNumber + batchStart + i
voucher.lines.forEach((line, lineIndex) => {
+ // dimensions jsonb is the source of truth; cost_center/project are
+ // derived mirrors — the same dual-write every sanctioned writer uses
+ // (see lib/bookkeeping/dimension-resolver.ts). SIE object-list codes
+ // survive verbatim on lines (legacy free-text is a documented
+ // exception to the registry format rules).
+ const dims = normalizeLineDimensions({ dimensions: line.dimensions ?? null })
allLines.push({
journal_entry_id: entryId,
account_number: line.account_number,
@@ -1352,6 +1371,8 @@ export async function importVouchers(
currency: 'SEK',
line_description: line.line_description,
sort_order: lineIndex,
+ dimensions: dims,
+ ...lineDimensionColumns(dims),
})
})
@@ -2081,6 +2102,26 @@ export async function executeSIEImport(
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)
diff --git a/lib/import/sie-parser.ts b/lib/import/sie-parser.ts
index 895219d8..44698211 100644
--- a/lib/import/sie-parser.ts
+++ b/lib/import/sie-parser.ts
@@ -18,6 +18,8 @@ import type {
SIEBalance,
SIEVoucher,
SIETransactionLine,
+ SIEDimension,
+ SIEDimensionValue,
ParsedSIEFile,
ParseIssue,
ParseIssueSeverity,
@@ -343,6 +345,41 @@ function splitSIELine(line: string): string[] {
return fields
}
+/**
+ * Parse a #TRANS object list — `{1 "KS01" 6 "P001"}` — into an SIE dim
+ * number → object code map. The list arrives as ONE field thanks to the
+ * brace-aware splitter; the inner content is itself space-separated with
+ * SIE quoting, so it re-runs through splitSIELine. Returns undefined for an
+ * empty list ({}), malformed pairs are skipped with a warning issue.
+ */
+function parseObjectList(
+ raw: string,
+ issues: ParseIssue[],
+ lineNum: number
+): Record | undefined {
+ const inner = raw.replace(/^\{/, '').replace(/\}$/, '').trim()
+ if (!inner) return undefined
+
+ const parts = splitSIELine(inner)
+ if (parts.length % 2 !== 0) {
+ addIssue(issues, 'warning', lineNum, `Objektlista med udda antal fält ignoreras delvis: ${raw}`, 'TRANS')
+ }
+
+ const dims: Record = {}
+ for (let i = 0; i + 1 < parts.length; i += 2) {
+ const dimNoRaw = parseStringField(parts[i])
+ const code = parseStringField(parts[i + 1]).trim()
+ const dimNo = parseInt(dimNoRaw, 10)
+ if (isNaN(dimNo) || dimNo < 1 || !code) {
+ addIssue(issues, 'warning', lineNum, `Ogiltigt objektpar i objektlista: ${dimNoRaw} ${code}`, 'TRANS')
+ continue
+ }
+ // Canonical numeric key ('01' → '1') — matches normalizeLineDimensions.
+ dims[String(dimNo)] = code
+ }
+ return Object.keys(dims).length > 0 ? dims : undefined
+}
+
/**
* Add an issue to the issues list
*/
@@ -385,6 +422,9 @@ export function parseSIEFile(content: string): ParsedSIEFile {
const closingBalances: SIEBalance[] = []
const resultBalances: SIEBalance[] = []
const vouchers: SIEVoucher[] = []
+ const dimensions: SIEDimension[] = []
+ const dimensionValues: SIEDimensionValue[] = []
+ let objectBalanceCount = 0
// Track current voucher being parsed (inside #VER { ... })
let currentVoucher: SIEVoucher | null = null
@@ -644,12 +684,16 @@ export function parseSIEFile(content: string): ParsedSIEFile {
break
}
- // Parse account and skip object list (in braces)
+ // Parse account and capture the object list (in braces)
let fieldIndex = 1
const account = parseStringField(fields[fieldIndex++])
- // Skip object list if present (now a single field thanks to brace-aware splitting)
+ // Object list (single field thanks to brace-aware splitting) —
+ // dimension tags like {1 "KS01" 6 "P001"}. Parsed onto the line so
+ // import is lossless (dimensions plan PR5).
+ let objectListRaw: string | null = null
if (fields[fieldIndex]?.startsWith('{')) {
+ objectListRaw = fields[fieldIndex]
fieldIndex++
}
@@ -666,6 +710,13 @@ export function parseSIEFile(content: string): ParsedSIEFile {
amount,
}
+ if (objectListRaw) {
+ const dims = parseObjectList(objectListRaw, issues, lineNum)
+ if (dims) {
+ transLine.dimensions = dims
+ }
+ }
+
// Optional fields
if (fields[fieldIndex]) {
transLine.date = parseSIEDate(parseStringField(fields[fieldIndex++])) || undefined
@@ -684,9 +735,53 @@ export function parseSIEFile(content: string): ParsedSIEFile {
break
}
+ case 'DIM': {
+ // #DIM dimNo "name"
+ const dimNo = parseInt(parseStringField(fields[1]), 10)
+ const name = parseStringField(fields[2])
+ if (!isNaN(dimNo) && dimNo >= 1) {
+ dimensions.push({ sieDimNo: dimNo, name: name || '' })
+ } else {
+ addIssue(issues, 'warning', lineNum, 'Ogiltig dimensionsdefinition — numret kunde inte tolkas', tag)
+ }
+ break
+ }
+
+ case 'UNDERDIM': {
+ // #UNDERDIM dimNo "name" parentDimNo
+ const dimNo = parseInt(parseStringField(fields[1]), 10)
+ const name = parseStringField(fields[2])
+ const parent = parseInt(parseStringField(fields[3]), 10)
+ if (!isNaN(dimNo) && dimNo >= 1 && !isNaN(parent) && parent >= 1) {
+ dimensions.push({ sieDimNo: dimNo, name: name || '', parentSieDimNo: parent })
+ } else {
+ addIssue(issues, 'warning', lineNum, 'Ogiltig underdimension — nummer eller överdimension kunde inte tolkas', tag)
+ }
+ break
+ }
+
+ case 'OBJEKT': {
+ // #OBJEKT dimNo "code" "name"
+ const dimNo = parseInt(parseStringField(fields[1]), 10)
+ const code = parseStringField(fields[2]).trim()
+ const name = parseStringField(fields[3])
+ if (!isNaN(dimNo) && dimNo >= 1 && code) {
+ dimensionValues.push({ sieDimNo: dimNo, code, name: name || code })
+ } else {
+ addIssue(issues, 'warning', lineNum, 'Ogiltigt objekt — dimension eller kod kunde inte tolkas', tag)
+ }
+ break
+ }
+
default:
- // Unknown tag - add info issue for notable ones
- if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'DIM', 'OBJEKT', 'OIB', 'OUB', 'PBUDGET', 'PSALDO'].includes(tag)) {
+ // Unknown tag - add info issue for notable ones. OIB/OUB (per-object
+ // opening/closing balances) are counted and surfaced as ONE info
+ // issue below — dimension reporting is P&L-only in v1, so
+ // object-level balance records have no consumer yet, but dropping
+ // them must never be silent (#866 review).
+ if (tag === 'OIB' || tag === 'OUB') {
+ objectBalanceCount++
+ } else if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'PBUDGET', 'PSALDO'].includes(tag)) {
addIssue(issues, 'info', lineNum, `Okänd tagg: #${tag} — ignoreras`, tag)
}
}
@@ -755,6 +850,31 @@ export function parseSIEFile(content: string): ParsedSIEFile {
)
}
+ // Dimension visibility: the preview step renders parse issues, so these
+ // make dimension handling explicit BEFORE the user executes the import.
+ if (objectBalanceCount > 0) {
+ addIssue(
+ issues,
+ 'info',
+ 0,
+ `${objectBalanceCount} objektbalansrader (#OIB/#OUB) hoppades över — balanser per objekt stöds inte ännu`,
+ 'OIB'
+ )
+ }
+ const taggedLineCount = vouchers.reduce(
+ (sum, v) => sum + v.lines.filter((l) => l.dimensions).length,
+ 0
+ )
+ if (dimensions.length > 0 || dimensionValues.length > 0 || taggedLineCount > 0) {
+ addIssue(
+ issues,
+ 'info',
+ 0,
+ `Filen innehåller dimensionsdata (kostnadsställen/projekt): ${taggedLineCount} taggade rader — dimensionerna följer med importen`,
+ 'DIM'
+ )
+ }
+
// Calculate statistics
const currentFiscalYear = header.fiscalYears.find((fy) => fy.yearIndex === 0)
const totalTransactionLines = vouchers.reduce((sum, v) => sum + v.lines.length, 0)
@@ -766,6 +886,8 @@ export function parseSIEFile(content: string): ParsedSIEFile {
closingBalances,
resultBalances,
vouchers,
+ dimensions,
+ dimensionValues,
issues,
stats: {
totalAccounts: accounts.length,
diff --git a/lib/import/types.ts b/lib/import/types.ts
index 5638b423..219c7d34 100644
--- a/lib/import/types.ts
+++ b/lib/import/types.ts
@@ -84,6 +84,27 @@ export interface SIETransactionLine {
quantity?: number
signature?: string
objectId?: string
+ /** Object list ({dimNo "code" …}) as SIE dim number → object code. */
+ dimensions?: Record
+}
+
+/**
+ * Dimension declaration from #DIM or #UNDERDIM
+ */
+export interface SIEDimension {
+ sieDimNo: number
+ name: string
+ /** Set when declared via #UNDERDIM — the parent dimension number. */
+ parentSieDimNo?: number
+}
+
+/**
+ * Dimension value from #OBJEKT
+ */
+export interface SIEDimensionValue {
+ sieDimNo: number
+ code: string
+ name: string
}
/**
@@ -127,6 +148,10 @@ export interface ParsedSIEFile {
// Transactions (SIE4 only)
vouchers: SIEVoucher[]
+ // Dimension registry records (#DIM / #UNDERDIM / #OBJEKT)
+ dimensions: SIEDimension[]
+ dimensionValues: SIEDimensionValue[]
+
// Parse issues
issues: ParseIssue[]
@@ -319,6 +344,16 @@ export interface ImportResult {
// If the next period's IB needed resync but we couldn't do it (locked,
// closed, or no existing IB), the human-readable reason.
nextPeriodIBResyncSkipped?: { reason: string; nextPeriodName: string } | null
+
+ // Populated when the file carried dimension data (#DIM/#OBJEKT/object
+ // lists): what landed in the registry and whether the import flipped
+ // company_settings.dimensions_enabled on (with a UI notice).
+ dimensionsImported?: {
+ dimensions: number
+ values: number
+ taggedLines: number
+ toggleEnabled: boolean
+ } | null
}
/**
diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts
index 56957e00..44aea749 100644
--- a/lib/reports/sie-export.ts
+++ b/lib/reports/sie-export.ts
@@ -344,7 +344,7 @@ interface RegistryValue {
* PR lands, and an undeclared dimension would make importers reject the file.
* Dim 2 (kostnadsbärare) is a reserved sub-dimension of 1 → #UNDERDIM.
*/
-const SIE_RESERVED_DIMENSIONS: Record = {
+export const SIE_RESERVED_DIMENSIONS: Record = {
1: { name: 'Kostnadsställe' },
2: { name: 'Kostnadsbärare', parent: 1 },
6: { name: 'Projekt' },
diff --git a/supabase/migrations/20260702154500_dimension_import_provenance_undo_lockstep.sql b/supabase/migrations/20260702154500_dimension_import_provenance_undo_lockstep.sql
new file mode 100644
index 00000000..b3019814
--- /dev/null
+++ b/supabase/migrations/20260702154500_dimension_import_provenance_undo_lockstep.sql
@@ -0,0 +1,220 @@
+-- Dimensions plan PR5 (SIE round-trip): registry provenance + undo lockstep.
+--
+-- SIE import now creates `dimensions`/`dimension_values` rows (#DIM /
+-- #UNDERDIM / #OBJEKT / object-list references). undo_sie_import previously
+-- only deleted the import's journal entries — the registry rows it introduced
+-- would linger as orphans. Two changes:
+--
+-- 1. `created_by_import_id` provenance on both registry tables (NULL for
+-- user-created rows; ON DELETE SET NULL so deleting an old sie_imports
+-- row never cascades into the registry).
+-- 2. undo_sie_import deletes the values/dimensions the undone import
+-- created, but ONLY when no remaining posted/reversed line references
+-- them — user-created rows and rows referenced by other imports or
+-- manual bookkeeping are untouched. The registry guard triggers
+-- (enforce_dimension_registry_guards / enforce_dimension_value_retention)
+-- run on these deletes as a backstop: the WHERE clauses mirror their
+-- reference checks, so they only fire if a concurrent write tagged a
+-- line between check and delete — in which case aborting the undo is
+-- the correct outcome.
+--
+-- replace_sie_import intentionally does NOT get the lockstep: replace is
+-- undo + immediate re-import of the same fiscal year, and the re-import
+-- re-upserts the same codes — deleting them in between would only churn ids
+-- that MCP/agent flows may hold. Stale values from a replaced file remain
+-- inactivatable via the register UI.
+--
+-- pg-test: lib/import/__tests__/undo-sie-import-dimensions.pg.test.ts
+
+ALTER TABLE public.dimensions
+ ADD COLUMN created_by_import_id uuid REFERENCES public.sie_imports(id) ON DELETE SET NULL;
+
+ALTER TABLE public.dimension_values
+ ADD COLUMN created_by_import_id uuid REFERENCES public.sie_imports(id) ON DELETE SET NULL;
+
+COMMENT ON COLUMN public.dimensions.created_by_import_id IS
+ 'SIE import that introduced this dimension (NULL = user-created). Undo deletes import-created rows that ended up unreferenced.';
+COMMENT ON COLUMN public.dimension_values.created_by_import_id IS
+ 'SIE import that introduced this value (NULL = user-created). Undo deletes import-created rows that ended up unreferenced.';
+
+-- Undo looks rows up by import id; partial indexes keep the common case
+-- (user-created rows, NULL) out of the index entirely.
+CREATE INDEX idx_dimensions_created_by_import
+ ON public.dimensions (created_by_import_id)
+ WHERE created_by_import_id IS NOT NULL;
+CREATE INDEX idx_dimension_values_created_by_import
+ ON public.dimension_values (created_by_import_id)
+ WHERE created_by_import_id IS NOT NULL;
+
+CREATE OR REPLACE FUNCTION public.undo_sie_import(
+ p_company_id uuid,
+ p_import_id uuid,
+ p_user_id uuid DEFAULT NULL
+)
+ RETURNS integer
+ LANGUAGE plpgsql
+ SECURITY DEFINER
+ SET search_path TO 'public'
+ -- CREATE OR REPLACE resets proconfig, so the function-local timeout from
+ -- 20260629160100 must be restated here or the service-client bulk delete
+ -- regresses to the authenticator role's 8s limit (pinned by
+ -- sie-import.replace.pg.test.ts).
+ SET statement_timeout TO '290s'
+AS $function$
+DECLARE
+ v_fiscal_period_id uuid;
+ v_opening_balance_entry_id uuid;
+ v_is_closed boolean;
+ v_locked_at timestamptz;
+ v_deleted integer := 0;
+ v_caller_role text;
+ v_actor uuid := COALESCE(p_user_id, auth.uid());
+BEGIN
+ SELECT cm.role INTO v_caller_role
+ FROM company_members cm
+ WHERE cm.company_id = p_company_id
+ AND cm.user_id = v_actor;
+
+ IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
+ RAISE EXCEPTION 'Only company owners and admins can undo SIE imports';
+ END IF;
+
+ SELECT fiscal_period_id, opening_balance_entry_id
+ INTO v_fiscal_period_id, v_opening_balance_entry_id
+ FROM public.sie_imports
+ WHERE id = p_import_id
+ AND company_id = p_company_id
+ AND status = 'completed';
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Import % not found or not in completed status', p_import_id;
+ END IF;
+
+ IF v_fiscal_period_id IS NOT NULL THEN
+ SELECT is_closed, locked_at
+ INTO v_is_closed, v_locked_at
+ FROM public.fiscal_periods
+ WHERE id = v_fiscal_period_id;
+
+ IF v_is_closed OR v_locked_at IS NOT NULL THEN
+ RAISE EXCEPTION 'Cannot undo SIE import in a locked or closed fiscal period';
+ END IF;
+ END IF;
+
+ PERFORM set_config('gnubok.allow_delete', 'true', true);
+
+ -- Detach documents (entry- and line-level).
+ UPDATE public.document_attachments
+ SET journal_entry_id = NULL,
+ journal_entry_line_id = NULL
+ WHERE journal_entry_id IN (
+ SELECT je.id
+ FROM public.journal_entries je
+ WHERE je.company_id = p_company_id
+ AND je.fiscal_period_id = v_fiscal_period_id
+ AND je.source_type IN ('import', 'opening_balance')
+ AND je.status IN ('posted', 'cancelled')
+ )
+ OR journal_entry_line_id IN (
+ SELECT jel.id
+ FROM public.journal_entry_lines jel
+ JOIN public.journal_entries je ON je.id = jel.journal_entry_id
+ WHERE je.company_id = p_company_id
+ AND je.fiscal_period_id = v_fiscal_period_id
+ AND je.source_type IN ('import', 'opening_balance')
+ AND je.status IN ('posted', 'cancelled')
+ );
+
+ -- Clear the fiscal-period OB pointer (two-step around
+ -- enforce_opening_balance_immutability).
+ IF v_opening_balance_entry_id IS NOT NULL THEN
+ UPDATE public.fiscal_periods
+ SET opening_balances_set = false
+ WHERE id = v_fiscal_period_id
+ AND opening_balance_entry_id = v_opening_balance_entry_id;
+
+ UPDATE public.fiscal_periods
+ SET opening_balance_entry_id = NULL
+ WHERE id = v_fiscal_period_id
+ AND opening_balance_entry_id = v_opening_balance_entry_id;
+ END IF;
+
+ -- Drop the sie_imports -> opening_balance_entry FK before delete.
+ UPDATE public.sie_imports
+ SET opening_balance_entry_id = NULL
+ WHERE id = p_import_id;
+
+ -- Hard-delete the import's journal entries (both transaction vouchers
+ -- and the opening_balance entry).
+ WITH deleted AS (
+ DELETE FROM public.journal_entries
+ WHERE company_id = p_company_id
+ AND fiscal_period_id = v_fiscal_period_id
+ AND source_type IN ('import', 'opening_balance')
+ AND status IN ('posted', 'cancelled')
+ RETURNING id
+ )
+ SELECT count(*) INTO v_deleted FROM deleted;
+
+ -- Registry lockstep (dimensions plan PR5): remove dimension VALUES this
+ -- import introduced, unless a remaining posted/reversed line still
+ -- references the code (other imports, manual bookkeeping). User-created
+ -- rows have created_by_import_id NULL and are never touched.
+ DELETE FROM public.dimension_values dv
+ USING public.dimensions d
+ WHERE dv.created_by_import_id = p_import_id
+ AND dv.company_id = p_company_id
+ AND d.id = dv.dimension_id
+ AND NOT EXISTS (
+ SELECT 1
+ FROM public.journal_entries je
+ JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id
+ WHERE je.company_id = p_company_id
+ AND je.status IN ('posted', 'reversed')
+ AND jel.dimensions ->> d.sie_dim_no::text = dv.code
+ );
+
+ -- ...and custom DIMENSIONS this import introduced that are now empty and
+ -- unreferenced. System dims (1/6) are never import-created and are
+ -- trigger-protected regardless.
+ DELETE FROM public.dimensions d
+ WHERE d.created_by_import_id = p_import_id
+ AND d.company_id = p_company_id
+ AND d.is_system = false
+ AND NOT EXISTS (
+ SELECT 1 FROM public.dimension_values dv WHERE dv.dimension_id = d.id
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM public.journal_entries je
+ JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id
+ WHERE je.company_id = p_company_id
+ AND je.status IN ('posted', 'reversed')
+ AND jel.dimensions ? d.sie_dim_no::text
+ );
+
+ -- Reset voucher_sequences per series to the max remaining number.
+ UPDATE public.voucher_sequences vs
+ SET last_number = COALESCE((
+ SELECT MAX(je.voucher_number)
+ FROM public.journal_entries je
+ WHERE je.company_id = vs.company_id
+ AND je.fiscal_period_id = vs.fiscal_period_id
+ AND je.voucher_series = vs.voucher_series
+ AND je.voucher_number > 0
+ ), 0),
+ updated_at = now()
+ WHERE vs.company_id = p_company_id
+ AND vs.fiscal_period_id = v_fiscal_period_id;
+
+ UPDATE public.sie_imports
+ SET status = 'undone',
+ replaced_at = now()
+ WHERE id = p_import_id
+ AND company_id = p_company_id;
+
+ RETURN v_deleted;
+END;
+$function$;
+
+NOTIFY pgrst, 'reload schema';