feat(dimensions): PR5 SIE round-trip — lossless dimension import + undo lockstep (#866)

* feat(dimensions): PR5 SIE round-trip — lossless dimension import, registry upsert, undo lockstep

SIE import previously parsed and silently DISCARDED all dimension data
(object lists at sie-parser.ts:651-654, #DIM/#OBJEKT in the ignore list at
:689). Import is now lossless — the dimensions plan PR5 milestone.

Parser: #TRANS object lists ({1 "KS01" 6 "P001"}) land on the line as an
SIE-dim-no → code map (canonical numeric keys, quoted codes, malformed
pairs warn); #DIM/#UNDERDIM/#OBJEKT parse into registry records. OIB/OUB
stay ignored (dimension reporting is P&L-only in v1).

Importer (lib/import/sie-dimensions.ts): upserts missing dimensions/
dimension_values rows — never renames existing ones (ON CONFLICT DO
NOTHING); undeclared reserved numbers synthesize their SIE-standard names
(mirroring the export's orphan synthesis); codes violating the registry
CHECK are skipped with a warning but survive verbatim on lines (documented
legacy-free-text exception). Bulk voucher insert now writes the dimensions
jsonb + cost_center/project mirrors via the sanctioned dual-write helpers
(no trigger suppression needed — the immutability trigger guards
UPDATE/DELETE, not INSERT). Import auto-enables dimensions_enabled with a
result-card notice (pre-authorized by the column comment). arcim-migration
provider syncs inherit all of it via the shared parser/importer.

Undo lockstep (migration 20260702154500): created_by_import_id provenance
on both registry tables (ON DELETE SET NULL); undo_sie_import deletes the
values/dimensions the undone import introduced when no remaining
posted/reversed line references them — user-created rows and rows other
bookkeeping references are untouched. The registry guard triggers act as
backstop. replace_sie_import deliberately skips the lockstep (re-import
re-upserts the same codes). Six pg-real tests cover the lockstep.

Round-trip pinned by test: parse → import state → export → parse preserves
declarations (#UNDERDIM parent links included), values, and per-line object
lists — including synthesis of referenced-but-undeclared values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): restate function-local statement_timeout on undo_sie_import

CREATE OR REPLACE resets proconfig, so the 290s timeout from 20260629160100
was silently dropped — regressing service-client bulk deletes to the
authenticator role's 8s limit. Caught by sie-import.replace.pg.test.ts in CI.
Full pg-real suite green (483/483, TZ=UTC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): surface OIB/OUB drops and dimension presence as parse-level info (#866 review)

Dropping object-balance records must never be silent — one info issue counts
the skipped #OIB/#OUB rows (object-level balances are P&L-out-of-scope in
v1), and a second announces dimension data before the user executes the
import (the preview step renders parse issues), so the auto-enable notice is
no longer purely post-hoc.

Triage notes for the remaining findings: the RPC's opening SELECT is the
company-ownership check the swarm asked for; registry writes are RLS-bound;
line-verbatim codes are the documented legacy-free-text exception; export
emits no #KSUMMA so there is nothing to recompute; SIE dims 3–5 are
"reserved for future use" with no standard names, so generic synthesis is
spec-correct; ON DELETE SET NULL is deliberate — provenance is operational
metadata for undo, not räkenskapsinformation (the guarded journal lines
are), and RESTRICT would block legitimate post-retention housekeeping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 16:05:13 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c7f0db00c8
commit fb3fe82a56
15 changed files with 1219 additions and 7 deletions
+2
View File
@@ -498,6 +498,8 @@ function SIEImportWizard() {
closingBalances: [],
resultBalances: [],
vouchers: [],
dimensions: [],
dimensionValues: [],
issues: data.parsed.issues,
stats: data.parsed.stats,
})
+27
View File
@@ -107,6 +107,33 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
</Card>
)}
{/* Dimensions detected (lossless SIE round-trip, dimensions plan PR5) */}
{result.success && result.dimensionsImported && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Info className="h-5 w-5 text-muted-foreground" />
Dimensioner följde med importen
</CardTitle>
<CardDescription>
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{' '}
<Link href="/dimensions" className="underline underline-offset-4">
Kostnadsställen &amp; projekt
</Link>
.</>
)}
</CardDescription>
</CardHeader>
</Card>
)}
{/* Statistics */}
{result.success && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
@@ -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<string, unknown> = {}
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)
})
})
@@ -42,6 +42,8 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
openingBalances: [{ yearIndex: 0, account: '1930', amount: 50000 }],
closingBalances: [],
resultBalances: [],
dimensions: [],
dimensionValues: [],
vouchers: [
{
series: 'A',
@@ -99,6 +99,8 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
{ yearIndex: 0, account: '2010', amount: -160406.0 },
],
resultBalances: [],
dimensions: [],
dimensionValues: [],
vouchers: [],
issues: [],
stats: {
@@ -46,6 +46,8 @@ function makeParsedFile(): ParsedSIEFile {
openingBalances: [],
closingBalances: [],
resultBalances: [],
dimensions: [],
dimensionValues: [],
vouchers: [
{
series: 'A',
+2
View File
@@ -43,6 +43,8 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
],
closingBalances: [],
resultBalances: [],
dimensions: [],
dimensionValues: [],
vouchers: [
{
series: 'A',
+92 -1
View File
@@ -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)', () => {
@@ -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<string> {
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<string, string>
}): Promise<string> {
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<string> {
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<string> {
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<number> {
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()
})
})
+216
View File
@@ -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<number, { name: string; parent?: number }>
values: Map<string, { sieDimNo: number; code: string; name: string }>
taggedLines: number
invalidCodes: Set<string>
} {
const dims = new Map<number, { name: string; parent?: number }>()
const values = new Map<string, { sieDimNo: number; code: string; name: string }>()
const invalidCodes = new Set<string>()
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<DimensionImportSummary | null> {
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 }
}
+42 -1
View File
@@ -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<string, string>
}[]
}
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<string, string>
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)
+126 -4
View File
@@ -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<string, string> | 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<string, string> = {}
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,
+35
View File
@@ -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<string, string>
}
/**
* 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
}
/**
+1 -1
View File
@@ -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<number, { name: string; parent?: number }> = {
export const SIE_RESERVED_DIMENSIONS: Record<number, { name: string; parent?: number }> = {
1: { name: 'Kostnadsställe' },
2: { name: 'Kostnadsbärare', parent: 1 },
6: { name: 'Projekt' },
@@ -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';