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';