diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 12b2a0ed..571627b8 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -3,6 +3,7 @@ import { normaliseSwish, isValidSwish } from '@/lib/payments/swish' import { normalizeVatNumber } from '@/lib/vat/vat-number' import { isSaneDateString } from '@/lib/utils' import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute' +import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver' // ============================================================ // Shared primitives @@ -686,6 +687,13 @@ export const CreateJournalEntryLineSchema = z.object({ amount_in_currency: z.number().optional(), exchange_rate: z.number().positive().optional(), tax_code: z.string().optional(), + // SIE dimension map {sie_dim_no: object_code}, e.g. {"1":"KS01","6":"P001"}. + // Single source of truth for the constraints lives in dimension-resolver so + // the staged pending-operations path validates identically. Wins per key + // over the cost_center/project aliases. + dimensions: DimensionsBagSchema.optional(), + // Deprecated aliases for dimensions['1'] / dimensions['6'] — kept forever + // for API/MCP compatibility. cost_center: z.string().optional(), project: z.string().optional(), }) diff --git a/lib/bookkeeping/__tests__/dimension-resolver.test.ts b/lib/bookkeeping/__tests__/dimension-resolver.test.ts new file mode 100644 index 00000000..2d00a7c7 --- /dev/null +++ b/lib/bookkeeping/__tests__/dimension-resolver.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest' +import { + normalizeLineDimensions, + lineDimensionColumns, + coerceDimensionsBag, + DIM_COST_CENTER, + DIM_PROJECT, +} from '@/lib/bookkeeping/dimension-resolver' + +describe('normalizeLineDimensions', () => { + it('returns empty map for a line with no dimension data', () => { + expect(normalizeLineDimensions({})).toEqual({}) + expect(normalizeLineDimensions({ cost_center: null, project: null })).toEqual({}) + }) + + it('maps the deprecated aliases to SIE keys 1 and 6', () => { + expect(normalizeLineDimensions({ cost_center: 'KS01', project: 'P001' })).toEqual({ + '1': 'KS01', + '6': 'P001', + }) + }) + + it('passes an explicit bag through', () => { + expect(normalizeLineDimensions({ dimensions: { '1': 'KS01', '7': 'ANST-4' } })).toEqual({ + '1': 'KS01', + '7': 'ANST-4', + }) + }) + + it('lets the explicit bag win over aliases per key', () => { + expect( + normalizeLineDimensions({ + dimensions: { '1': 'KS-BAG' }, + cost_center: 'KS-ALIAS', + project: 'P-ALIAS', + }) + ).toEqual({ '1': 'KS-BAG', '6': 'P-ALIAS' }) + }) + + it('treats an explicit empty string in the bag as clearing that dimension', () => { + expect( + normalizeLineDimensions({ dimensions: { '1': '' }, cost_center: 'KS-ALIAS' }) + ).toEqual({}) + }) + + it('trims whitespace and drops blank values', () => { + expect( + normalizeLineDimensions({ dimensions: { '6': ' P001 ' }, cost_center: ' ' }) + ).toEqual({ '6': 'P001' }) + }) + + it('drops non-numeric and zero/negative keys', () => { + expect( + normalizeLineDimensions({ + dimensions: { projekt: 'X', '0': 'Y', '6': 'P001' } as Record, + }) + ).toEqual({ '6': 'P001' }) + }) + + it("canonicalizes leading-zero keys ('01' -> '1') so mirrors are derived", () => { + const dims = normalizeLineDimensions({ dimensions: { '01': 'KS01', '06': 'P001' } }) + expect(dims).toEqual({ '1': 'KS01', '6': 'P001' }) + expect(lineDimensionColumns(dims)).toEqual({ cost_center: 'KS01', project: 'P001' }) + }) + + it("clearing via a leading-zero key ('01': '') also clears the alias-filled '1'", () => { + expect( + normalizeLineDimensions({ dimensions: { '01': '' }, cost_center: 'KS-ALIAS' }) + ).toEqual({}) + }) + + it('reversal parity: empty bag + populated aliases equals alias-only input', () => { + // reverseEntry passes {dimensions: {}, cost_center, project} for legacy + // rows; storno passes the row directly — both must normalize identically. + expect( + normalizeLineDimensions({ dimensions: {}, cost_center: 'KS01', project: 'P001' }) + ).toEqual(normalizeLineDimensions({ cost_center: 'KS01', project: 'P001' })) + }) +}) + +describe('coerceDimensionsBag (boundary validator for staged payloads)', () => { + it('returns undefined for non-objects', () => { + expect(coerceDimensionsBag(undefined)).toBeUndefined() + expect(coerceDimensionsBag(null)).toBeUndefined() + expect(coerceDimensionsBag('P001')).toBeUndefined() + expect(coerceDimensionsBag(['6', 'P001'])).toBeUndefined() + }) + + it('accepts a valid bag and normalizes values', () => { + expect(coerceDimensionsBag({ '6': ' P001 ', '1': 'KS01' })).toEqual({ + '6': 'P001', + '1': 'KS01', + }) + }) + + it('rejects the WHOLE bag on any invalid entry — same as the API schema', () => { + // Numeric value (no silent coercion), invalid key, leading-zero key: + // exactly what CreateJournalEntryLineSchema would reject. + expect(coerceDimensionsBag({ '6': 42 })).toBeUndefined() + expect(coerceDimensionsBag({ '6': 42, '1': 'KS01' })).toBeUndefined() + expect(coerceDimensionsBag({ projekt: 'P001', '6': 'P001' })).toBeUndefined() + expect(coerceDimensionsBag({ '06': 'P001' })).toBeUndefined() + }) + + it('enforces the same length/charset constraints as the Zod line schema', () => { + expect(coerceDimensionsBag({ '6': 'x'.repeat(41) })).toBeUndefined() + expect(coerceDimensionsBag({ '6': 'P"1' })).toBeUndefined() + expect(coerceDimensionsBag({ '6': 'P{1}' })).toBeUndefined() + expect(coerceDimensionsBag({ '6': 'x'.repeat(40) })).toEqual({ '6': 'x'.repeat(40) }) + }) + + it('returns undefined for an empty or whitespace-only bag', () => { + expect(coerceDimensionsBag({})).toBeUndefined() + }) +}) + +describe('lineDimensionColumns', () => { + it('derives both mirrors from the map', () => { + expect(lineDimensionColumns({ [DIM_COST_CENTER]: 'KS01', [DIM_PROJECT]: 'P001' })).toEqual({ + cost_center: 'KS01', + project: 'P001', + }) + }) + + it('returns nulls for missing keys', () => { + expect(lineDimensionColumns({})).toEqual({ cost_center: null, project: null }) + expect(lineDimensionColumns({ '7': 'ANST-4' })).toEqual({ cost_center: null, project: null }) + }) + + it('round-trips with normalizeLineDimensions (mirror consistency)', () => { + const dims = normalizeLineDimensions({ cost_center: 'KS01', dimensions: { '6': 'P001' } }) + expect(lineDimensionColumns(dims)).toEqual({ cost_center: 'KS01', project: 'P001' }) + }) +}) diff --git a/lib/bookkeeping/dimension-resolver.ts b/lib/bookkeeping/dimension-resolver.ts new file mode 100644 index 00000000..6a87c1cd --- /dev/null +++ b/lib/bookkeeping/dimension-resolver.ts @@ -0,0 +1,108 @@ +/** + * Dimension resolver — the single place line dimensions are normalized and + * mirrored (dev_docs/dimensions_implementation_plan.md). + * + * Storage model: journal_entry_lines.dimensions is a JSONB map keyed by SIE + * dimension number ({"1":"KS01","6":"P001"}) and is the single source of + * truth. The legacy cost_center/project TEXT columns are deterministic mirrors + * of keys '1'/'6' during the dual-write window (they become GENERATED columns + * in a later migration). Every journal_entry_lines writer MUST derive the + * mirror columns via lineDimensionColumns() — never set them independently. + */ + +import { z } from 'zod' + +/** SIE dimension numbers with first-class mirror columns. */ +export const DIM_COST_CENTER = '1' +export const DIM_PROJECT = '6' + +export type LineDimensions = Record + +/** + * THE schema for a dimensions bag ({sie_dim_no: object_code}) — the single + * source of truth for its constraints. The API layer + * (CreateJournalEntryLineSchema) and the staged pending-operations path + * (coerceDimensionsBag) both use this exact schema, so the two validation + * layers cannot drift. Keys are canonical SIE dimension numbers (no leading + * zeros); values must not contain characters that break SIE field framing. + */ +export const DimensionsBagSchema = z.record( + z.string().regex(/^[1-9]\d*$/, 'Dimensionsnyckel måste vara ett SIE-dimensionsnummer'), + z.string().min(1).max(40).regex(/^[^"{}]+$/, 'Dimensionskod får inte innehålla ", { eller }') +) + +interface DimensionAliasInput { + dimensions?: LineDimensions | null + cost_center?: string | null + project?: string | null +} + +/** + * Merge the explicit `dimensions` bag with the deprecated cost_center/project + * aliases into one canonical map. The explicit bag wins per key; aliases only + * fill keys the bag does not set. Empty/blank values and non-numeric keys are + * dropped so the stored map never carries junk entries. + */ +export function normalizeLineDimensions(line: DimensionAliasInput): LineDimensions { + const out: LineDimensions = {} + + const costCenter = line.cost_center?.trim() + if (costCenter) out[DIM_COST_CENTER] = costCenter + const project = line.project?.trim() + if (project) out[DIM_PROJECT] = project + + if (line.dimensions) { + for (const [key, value] of Object.entries(line.dimensions)) { + if (!/^\d+$/.test(key) || Number(key) < 1) continue + // Canonical numeric form: '01' and '1' must land on the same key, or + // lineDimensionColumns misses the mirror and reports split the value. + const dimNo = String(Number(key)) + const trimmed = typeof value === 'string' ? value.trim() : '' + if (!trimmed) { + // Explicit empty string in the bag means "clear this dimension" — it + // must also override a non-empty alias, so remove any alias-filled key. + delete out[dimNo] + continue + } + out[dimNo] = trimmed + } + } + + return out +} + +/** + * Boundary validator for an untyped dimensions bag (staged pending-operation + * params, tool payloads). Delegates to DimensionsBagSchema — the exact schema + * the API layer uses — so the staged path cannot drift from API validation. + * Whole-bag semantics: a bag containing ANY invalid entry is rejected + * (returns undefined) rather than partially salvaged; staged payloads were + * already schema-validated at staging time, so an invalid entry here means + * drift or tampering — booking then proceeds without dimensions, which are + * never load-bearing for validity. Interior normalization + * (normalizeLineDimensions) stays permissive on charset by design — it must + * preserve legacy DB values verbatim on reversal/correction; this function is + * the input gate. + */ +export function coerceDimensionsBag(raw: unknown): LineDimensions | undefined { + if (raw === undefined || raw === null) return undefined + const parsed = DimensionsBagSchema.safeParse(raw) + if (!parsed.success) return undefined + const dims = normalizeLineDimensions({ dimensions: parsed.data }) + return Object.keys(dims).length > 0 ? dims : undefined +} + +/** + * Derive the legacy mirror columns from the canonical map. Pure function — + * divergence between `dimensions` and cost_center/project is impossible as + * long as every writer goes through this. + */ +export function lineDimensionColumns(dimensions: LineDimensions): { + cost_center: string | null + project: string | null +} { + return { + cost_center: dimensions[DIM_COST_CENTER] ?? null, + project: dimensions[DIM_PROJECT] ?? null, + } +} diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 3a003934..ee397e2b 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -14,6 +14,7 @@ import { JournalEntryNotFoundError, } from '@/lib/bookkeeping/errors' import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver' +import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver' import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill' import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync' import { getActor } from '@/lib/bookkeeping/actor-context' @@ -185,21 +186,26 @@ function buildLineInserts( lines: CreateJournalEntryLineInput[], accountIdMap: Map ) { - return lines.map((line, index) => ({ - journal_entry_id: entryId, - account_number: line.account_number, - account_id: accountIdMap.get(line.account_number) || null, - debit_amount: Math.round((line.debit_amount || 0) * 100) / 100, - credit_amount: Math.round((line.credit_amount || 0) * 100) / 100, - currency: line.currency || 'SEK', - amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null, - exchange_rate: line.exchange_rate || null, - line_description: line.line_description || null, - tax_code: line.tax_code || null, - cost_center: line.cost_center || null, - project: line.project || null, - sort_order: index, - })) + return lines.map((line, index) => { + // dimensions JSONB is the source of truth; cost_center/project are + // derived mirrors (dual-write window — see lib/bookkeeping/dimension-resolver.ts) + const dimensions = normalizeLineDimensions(line) + return { + journal_entry_id: entryId, + account_number: line.account_number, + account_id: accountIdMap.get(line.account_number) || null, + debit_amount: Math.round((line.debit_amount || 0) * 100) / 100, + credit_amount: Math.round((line.credit_amount || 0) * 100) / 100, + currency: line.currency || 'SEK', + amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null, + exchange_rate: line.exchange_rate || null, + line_description: line.line_description || null, + tax_code: line.tax_code || null, + dimensions, + ...lineDimensionColumns(dimensions), + sort_order: index, + } + }) } /** @@ -662,6 +668,7 @@ export async function reverseEntry( : undefined, exchange_rate: line.exchange_rate || undefined, tax_code: line.tax_code || undefined, + dimensions: line.dimensions || undefined, cost_center: line.cost_center || undefined, project: line.project || undefined, })) diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index ee145cef..fde72521 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -6,6 +6,7 @@ import type { JournalEntryLine, } from '@/types' import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine' +import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver' import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill' import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' import { @@ -271,22 +272,25 @@ export async function correctEntry( throw new BookkeepingDatabaseError('create_reversal_entry', reversalError?.message) } - // Insert reversed lines (swap debit and credit) - const reversalLineInserts = originalLines.map((line, index) => ({ - journal_entry_id: reversalEntry.id, - account_number: line.account_number, - account_id: line.account_id || null, - debit_amount: Math.round((Number(line.credit_amount) || 0) * 100) / 100, - credit_amount: Math.round((Number(line.debit_amount) || 0) * 100) / 100, - currency: line.currency || 'SEK', - amount_in_currency: line.amount_in_currency ? -Number(line.amount_in_currency) : null, - exchange_rate: line.exchange_rate || null, - line_description: `Storno: ${line.line_description || ''}`, - tax_code: line.tax_code || null, - cost_center: line.cost_center || null, - project: line.project || null, - sort_order: index, - })) + // Insert reversed lines (swap debit and credit, preserve dimensions) + const reversalLineInserts = originalLines.map((line, index) => { + const dimensions = normalizeLineDimensions(line) + return { + journal_entry_id: reversalEntry.id, + account_number: line.account_number, + account_id: line.account_id || null, + debit_amount: Math.round((Number(line.credit_amount) || 0) * 100) / 100, + credit_amount: Math.round((Number(line.debit_amount) || 0) * 100) / 100, + currency: line.currency || 'SEK', + amount_in_currency: line.amount_in_currency ? -Number(line.amount_in_currency) : null, + exchange_rate: line.exchange_rate || null, + line_description: `Storno: ${line.line_description || ''}`, + tax_code: line.tax_code || null, + dimensions, + ...lineDimensionColumns(dimensions), + sort_order: index, + } + }) const { error: reversalLinesError } = await supabase .from('journal_entry_lines') @@ -352,23 +356,26 @@ export async function correctEntry( correctedEntry = newEntry // Insert corrected lines - const correctedLineInserts = correctedLines.map((line, index) => ({ - journal_entry_id: correctedEntry.id, - account_number: line.account_number, - account_id: accountIdMap.get(line.account_number) || null, - debit_amount: Math.round((line.debit_amount || 0) * 100) / 100, - credit_amount: Math.round((line.credit_amount || 0) * 100) / 100, - currency: line.currency || 'SEK', - amount_in_currency: line.amount_in_currency - ? Math.round(line.amount_in_currency * 100) / 100 - : null, - exchange_rate: line.exchange_rate || null, - line_description: line.line_description || null, - tax_code: line.tax_code || null, - cost_center: line.cost_center || null, - project: line.project || null, - sort_order: index, - })) + const correctedLineInserts = correctedLines.map((line, index) => { + const dimensions = normalizeLineDimensions(line) + return { + journal_entry_id: correctedEntry.id, + account_number: line.account_number, + account_id: accountIdMap.get(line.account_number) || null, + debit_amount: Math.round((line.debit_amount || 0) * 100) / 100, + credit_amount: Math.round((line.credit_amount || 0) * 100) / 100, + currency: line.currency || 'SEK', + amount_in_currency: line.amount_in_currency + ? Math.round(line.amount_in_currency * 100) / 100 + : null, + exchange_rate: line.exchange_rate || null, + line_description: line.line_description || null, + tax_code: line.tax_code || null, + dimensions, + ...lineDimensionColumns(dimensions), + sort_order: index, + } + }) const { error: correctedLinesError } = await supabase .from('journal_entry_lines') @@ -526,6 +533,7 @@ export async function recordateEntry( line.amount_in_currency != null ? Number(line.amount_in_currency) : undefined, exchange_rate: line.exchange_rate != null ? Number(line.exchange_rate) : undefined, tax_code: line.tax_code || undefined, + dimensions: line.dimensions || undefined, cost_center: line.cost_center || undefined, project: line.project || undefined, })) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index f8843aa0..9b34c2f3 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -26,6 +26,7 @@ import { createCreditNoteJournalEntry, } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' +import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver' import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { runWithActor } from '@/lib/bookkeeping/actor-context-node' import type { CommitActor } from '@/lib/bookkeeping/actor-context' @@ -2667,6 +2668,9 @@ function normalizeVoucherLines(raw: unknown): CreateJournalEntryLineInput[] { amount_in_currency: line.amount_in_currency !== undefined ? Number(line.amount_in_currency) : undefined, exchange_rate: line.exchange_rate !== undefined ? Number(line.exchange_rate) : undefined, tax_code: line.tax_code ? String(line.tax_code) : undefined, + // Boundary-validated with the same constraints as the Zod line schema — + // staged payloads must not bypass API-layer validation (SOC 2 PI1.1). + dimensions: coerceDimensionsBag(line.dimensions), cost_center: line.cost_center ? String(line.cost_center) : undefined, project: line.project ? String(line.project) : undefined, } diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index 6893e05f..7896d63d 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -16,6 +16,12 @@ * 2. naive-ore-round — `Math.round(x * 100) / 100`, which is subtly wrong on * exact-half values (see lib/money.ts `roundOre`). Tracked as a count. * The canonical rounding modules are excluded. + * 3. direct-jel-insert — a file that inserts into `journal_entry_lines` + * outside the sanctioned writers. During the dimensions dual-write window + * every line writer must derive cost_center/project via + * lineDimensionColumns() from the dimensions JSONB map + * (lib/bookkeeping/dimension-resolver.ts) — a new direct insert site can + * silently diverge the mirror columns. Tracked as a file-set. * * Usage: * node scripts/checks/no-new-antipatterns.mjs # check (CI) @@ -75,6 +81,37 @@ function findRawRouteAuth() { .sort() } +// Sanctioned journal_entry_lines insert sites. engine/storno write mirrors via +// dimension-resolver; sie-import and sandbox seed write neither dims nor +// mirrors (DB defaults keep them consistent). +const JEL_INSERT_SANCTIONED = new Set([ + 'lib/bookkeeping/engine.ts', + 'lib/core/bookkeeping/storno-service.ts', + 'lib/import/sie-import.ts', + 'app/api/sandbox/seed/route.ts', +]) +// Matches an insert CHAINED on the lines table (`.from('journal_entry_lines').insert(`, +// with optional whitespace/newlines in the chain) — select-only readers don't count. +const JEL_INSERT_CHAIN_RE = /\.from\(\s*['"]journal_entry_lines['"]\s*\)\s*\.\s*(insert|upsert)\(/ + +/** Files that insert into journal_entry_lines outside the sanctioned writers. */ +function findDirectJelInserts() { + const files = [ + ...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']), + ] + return files + .filter((f) => { + const r = rel(f) + if (JEL_INSERT_SANCTIONED.has(r)) return false + if (r.includes('__tests__/') || r.endsWith('.test.ts')) return false + return JEL_INSERT_CHAIN_RE.test(fs.readFileSync(f, 'utf8')) + }) + .map(rel) + .sort() +} + /** Count of naive Math.round(x*100)/100 occurrences (lines) across source. */ function countNaiveRound() { const files = [ @@ -96,6 +133,7 @@ function countNaiveRound() { const current = { rawRouteAuth: findRawRouteAuth(), naiveOreRound: countNaiveRound(), + directJelInsert: findDirectJelInserts(), } const isUpdate = process.argv.includes('--update') @@ -136,6 +174,22 @@ if (newAuthFiles.length) { console.error(' → wrap the route in withRouteContext (or call requireAuth) so MFA is enforced.') } +// 1b. direct-jel-insert: allowlist lives in this file (JEL_INSERT_SANCTIONED), +// no baseline — any unsanctioned insert site is a hard failure. +if (current.directJelInsert.length) { + failed = true + console.error( + `\n✗ direct-jel-insert: ${current.directJelInsert.length} file(s) insert into journal_entry_lines ` + + `outside the sanctioned writers:`, + ) + current.directJelInsert.forEach((f) => console.error(` ${f}`)) + console.error( + ' → route line writes through lib/bookkeeping/engine.ts, or derive cost_center/project via\n' + + ' lineDimensionColumns() (lib/bookkeeping/dimension-resolver.ts) and add the file to\n' + + ' JEL_INSERT_SANCTIONED in this script with a justification.', + ) +} + // 2. naive-ore-round: count may not increase. if (current.naiveOreRound > baseline.naiveOreRound.count) { failed = true @@ -160,5 +214,5 @@ if (failed) { process.exit(1) } console.log( - `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}).`, + `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, direct-jel-insert: 0).`, ) diff --git a/supabase/migrations/20260702084500_dimensions_substrate.sql b/supabase/migrations/20260702084500_dimensions_substrate.sql new file mode 100644 index 00000000..639d462b --- /dev/null +++ b/supabase/migrations/20260702084500_dimensions_substrate.sql @@ -0,0 +1,402 @@ +-- Dimensions substrate (PR1 of the dimensions plan — dev_docs/dimensions_implementation_plan.md) +-- +-- Adds the SIE-native dimension registry (dimensions = #DIM/#UNDERDIM, +-- dimension_values = #OBJEKT) and the single-source-of-truth `dimensions jsonb` +-- map on journal_entry_lines ({"1":"KS01","6":"P001"}, keyed by SIE dimension +-- number). The legacy free-text journal_entry_lines.cost_center / .project +-- columns become deterministic mirrors of keys '1'/'6', kept in sync by +-- lib/bookkeeping/dimension-resolver.ts during the dual-write window (they are +-- demoted to GENERATED columns in a later migration, per the plan). +-- +-- Registry tables are born company_id-native with NO user_id column. This is a +-- deliberate deviation from the legacy new-table template: it finishes the +-- user_id -> company_id lift for this domain (dev_docs/dimensions_architecture.md +-- §3.8). write_audit_log() reads user_id via to_jsonb() and tolerates its absence. +-- +-- Corrective note: the comment in 20240101000011_alter_existing_tables.sql:66-68 +-- claiming cost_center_id/project_id UUID FK columns + indexes exist was never +-- true — no such columns were ever created. This migration is the real +-- referential structure for dimensions. + +-- ============================================================================= +-- 1. dimensions (= SIE #DIM / #UNDERDIM registry) +-- ============================================================================= +CREATE TABLE public.dimensions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + -- Byrå/firm-shared taxonomy axis. Bare nullable column by design (SHAPE + -- decision: never NOT NULL); the FK to firms(id) is wired when firms lands. + firm_id uuid NULL, + sie_dim_no int NOT NULL CHECK (sie_dim_no >= 1), + -- #UNDERDIM parent (e.g. kostnadsbärare 2 -> kostnadsställe 1) + parent_sie_dim_no int NULL CHECK (parent_sie_dim_no IS NULL OR parent_sie_dim_no <> sie_dim_no), + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80), + -- SIE asymmetry as data: dim 1 balances reset each fiscal year, dim 6 accumulates + resets_annually boolean NOT NULL DEFAULT true, + -- Seeded dims 1 & 6: undeletable, number immutable (enforced by trigger below) + is_system boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + sort_order int NOT NULL DEFAULT 100, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + UNIQUE (company_id, sie_dim_no), + -- Composite key target so dimension_values can FK on (dimension_id, company_id) + -- and cross-company value rows are impossible by construction. + UNIQUE (id, company_id) +); + +ALTER TABLE public.dimensions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "view own-company dimensions" + ON public.dimensions FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "insert own-company dimensions" + ON public.dimensions FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "update own-company dimensions" + ON public.dimensions FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "delete own-company dimensions" + ON public.dimensions FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE INDEX idx_dimensions_company_id ON public.dimensions (company_id); + +COMMENT ON COLUMN public.dimensions.resets_annually IS + 'SIE4 balance semantics: true = flow-period dimension whose balances reset each fiscal year (dim 1 kostnadsställe — must NOT carry #IB/#OIB opening balances on export); false = accumulating dimension spanning years (dim 6 projekt). The SIE export/import path (PR2+) must honour this; it is data, not enforcement.'; + +CREATE TRIGGER dimensions_updated_at + BEFORE UPDATE ON public.dimensions + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER audit_dimensions + AFTER INSERT OR UPDATE OR DELETE ON public.dimensions + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +-- ============================================================================= +-- 2. dimension_values (= SIE #OBJEKT) +-- ============================================================================= +CREATE TABLE public.dimension_values ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + dimension_id uuid NOT NULL, + -- Object code as written into journal_entry_lines.dimensions and SIE #OBJEKT. + -- DB bound is deliberately loose (only chars that break SIE field framing are + -- forbidden) so legacy free-text survives the backfill; the strict Fortnox + -- format (^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$) is enforced at the API layer for + -- user-created codes. + code text NOT NULL CHECK (char_length(code) BETWEEN 1 AND 40 AND code !~ '["{}]'), + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120), + parent_value_id uuid NULL REFERENCES public.dimension_values(id) ON DELETE SET NULL, + -- Inactivate, never delete, once referenced by a posted line (trigger below). + is_active boolean NOT NULL DEFAULT true, + start_date date NULL, + end_date date NULL, + attributes jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(attributes) = 'object'), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + UNIQUE (company_id, dimension_id, code), + -- Same-company integrity by construction (see dimensions UNIQUE (id, company_id)) + FOREIGN KEY (dimension_id, company_id) + REFERENCES public.dimensions (id, company_id) ON DELETE CASCADE +); + +ALTER TABLE public.dimension_values ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "view own-company dimension_values" + ON public.dimension_values FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "insert own-company dimension_values" + ON public.dimension_values FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "update own-company dimension_values" + ON public.dimension_values FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "delete own-company dimension_values" + ON public.dimension_values FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE INDEX idx_dimension_values_company_id ON public.dimension_values (company_id); +CREATE INDEX idx_dimension_values_dimension_id ON public.dimension_values (dimension_id); + +CREATE TRIGGER dimension_values_updated_at + BEFORE UPDATE ON public.dimension_values + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER audit_dimension_values + AFTER INSERT OR UPDATE OR DELETE ON public.dimension_values + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +-- ============================================================================= +-- 3. Registry guard triggers +-- ============================================================================= + +-- 3a. dimensions: system dims are undeletable; sie_dim_no is immutable (it is +-- the key used inside journal_entry_lines.dimensions — renumbering would +-- silently orphan every tagged line); is_system cannot be flipped; a dimension +-- whose number is referenced by any posted/reversed line cannot be deleted. +CREATE OR REPLACE FUNCTION public.enforce_dimension_registry_guards() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW.sie_dim_no <> OLD.sie_dim_no THEN + RAISE EXCEPTION 'Dimensionsnumret kan inte ändras (rader är taggade med numret).'; + END IF; + IF NEW.is_system <> OLD.is_system THEN + RAISE EXCEPTION 'is_system kan inte ändras.'; + END IF; + RETURN NEW; + END IF; + + -- DELETE + IF OLD.is_system THEN + RAISE EXCEPTION 'Systemdimensionen % (%) kan inte tas bort — avaktivera den istället.', + OLD.sie_dim_no, OLD.name; + END IF; + IF 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 = OLD.company_id + AND je.status IN ('posted', 'reversed') + AND jel.dimensions ? OLD.sie_dim_no::text + ) THEN + RAISE EXCEPTION 'Dimensionen % (%) används på bokförda verifikat och kan inte tas bort — avaktivera den istället.', + OLD.sie_dim_no, OLD.name; + END IF; + RETURN OLD; +END; +$$; + +CREATE TRIGGER enforce_dimension_registry_guards + BEFORE UPDATE OR DELETE ON public.dimensions + FOR EACH ROW EXECUTE FUNCTION public.enforce_dimension_registry_guards(); + +-- 3b. dimension_values: a code referenced by any posted/reversed line cannot be +-- deleted (BFL 7-year philosophy / Fortnox "Avslutat") — inactivate instead. +-- Fires on direct DELETE and on cascade from dimensions. +CREATE OR REPLACE FUNCTION public.enforce_dimension_value_retention() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_dim_no int; +BEGIN + SELECT d.sie_dim_no INTO v_dim_no + FROM public.dimensions d + WHERE d.id = OLD.dimension_id; + + IF v_dim_no IS NULL THEN + -- Parent dimension row already gone (same-statement cascade edge) — nothing + -- left to validate against. + RETURN OLD; + END IF; + + IF 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 = OLD.company_id + AND je.status IN ('posted', 'reversed') + AND jel.dimensions ->> v_dim_no::text = OLD.code + ) THEN + RAISE EXCEPTION 'Värdet "%" används på bokförda verifikat och kan inte tas bort — arkivera det istället.', + OLD.code; + END IF; + RETURN OLD; +END; +$$; + +CREATE TRIGGER enforce_dimension_value_retention + BEFORE DELETE ON public.dimension_values + FOR EACH ROW EXECUTE FUNCTION public.enforce_dimension_value_retention(); + +-- ============================================================================= +-- 4. ensure_company_dimensions(company_id) — lazy get-or-create of system dims +-- ============================================================================= +-- No eager per-company seeding: core stays zero-config for companies that never +-- touch dimensions. Called by registry CRUD, the engine resolver and SIE import +-- on first use. Idempotent. +CREATE OR REPLACE FUNCTION public.ensure_company_dimensions(p_company_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +BEGIN + -- Tenant guard: authenticated callers must be members of the company. + -- Service-role callers (auth.uid() IS NULL) are trusted — server code scopes + -- by company_id (defense-in-depth pattern used across the codebase). + IF auth.uid() IS NOT NULL + AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN + RAISE EXCEPTION 'not a member of company %', p_company_id; + END IF; + + INSERT INTO public.dimensions (company_id, sie_dim_no, name, resets_annually, is_system, sort_order) + VALUES + (p_company_id, 1, 'Kostnadsställe', true, true, 10), + (p_company_id, 6, 'Projekt', false, true, 20) + ON CONFLICT (company_id, sie_dim_no) DO NOTHING; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.ensure_company_dimensions(uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.ensure_company_dimensions(uuid) TO authenticated, service_role; + +-- ============================================================================= +-- 5. journal_entry_lines.dimensions — the single source of truth line tag +-- ============================================================================= +-- NOT NULL DEFAULT '{}' is metadata-only on PG11+ (no table rewrite). +ALTER TABLE public.journal_entry_lines + ADD COLUMN dimensions jsonb NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE public.journal_entry_lines + ADD CONSTRAINT jel_dimensions_is_object CHECK (jsonb_typeof(dimensions) = 'object'); + +-- Containment queries (reports: dimensions @> '{"6":"P001"}') ride the GIN; +-- hot dims 1/6 get partial expression indexes for equality/grouping paths. +CREATE INDEX idx_jel_dimensions_gin + ON public.journal_entry_lines USING gin (dimensions jsonb_path_ops); +CREATE INDEX idx_jel_dimensions_dim1 + ON public.journal_entry_lines ((dimensions ->> '1')) WHERE dimensions ? '1'; +CREATE INDEX idx_jel_dimensions_dim6 + ON public.journal_entry_lines ((dimensions ->> '6')) WHERE dimensions ? '6'; + +COMMENT ON COLUMN public.journal_entry_lines.dimensions IS + 'SIE dimension map {sie_dim_no: object_code}, e.g. {"1":"KS01","6":"P001"}. Single source of truth for line dimensions; cost_center/project mirror keys 1/6 during the dual-write window (see lib/bookkeeping/dimension-resolver.ts).'; +COMMENT ON COLUMN public.journal_entry_lines.cost_center IS + 'Legacy mirror of dimensions->>''1'' (SIE #DIM 1 object code). Kept in sync by lineDimensionColumns(); will become a GENERATED column. NB: the 20240101000011 comment about cost_center_id/project_id FK columns was never true.'; +COMMENT ON COLUMN public.journal_entry_lines.project IS + 'Legacy mirror of dimensions->>''6'' (SIE #DIM 6 object code). Kept in sync by lineDimensionColumns(); will become a GENERATED column.'; + +-- ============================================================================= +-- 6. Backfill — representation copy of existing dimension data +-- ============================================================================= +-- Copies the legacy TEXT columns into the JSONB map on already-posted lines. +-- This is a pure representation change (same values, new column) with no +-- accounting content touched — accounts, amounts, dates and descriptions are +-- byte-identical. The line-immutability trigger blocks ALL updates to posted +-- lines regardless of column, so it is disabled for exactly this statement +-- (sanctioned precedent: 20260415000000_schema_sync.sql cleanup routine). +-- +-- ⚠️ REVIEWER GUIDANCE BEFORE REUSING THIS PATTERN (BFL 5 kap 5§ / BFNAR +-- 2013:2: corrections must preserve original + change visibly; overwriting +-- verifikat content is forbidden). Disabling this trigger is defensible ONLY +-- when ALL of the following hold, as they do here: +-- 1. The UPDATE writes exclusively to a column that carries NO verifikat +-- content (here: the brand-new `dimensions` column, populated from values +-- already stored on the same row — no information is created or lost). +-- 2. Accounts, amounts, dates, descriptions and linkage columns are +-- untouched (this statement's SET clause names only `dimensions`). +-- 3. The change is reviewed under the Swedish-compliance CI workflow. +-- A migration that needs to touch actual verifikat content must instead go +-- through storno/rättelse (correctEntry) — never this pattern. +-- +-- Concurrency: the DISABLE/UPDATE/ENABLE sequence runs inside ONE transaction. +-- ALTER TABLE ... DISABLE TRIGGER takes ACCESS EXCLUSIVE on the table and +-- holds it until COMMIT, so no concurrent writer can slip a line in while the +-- trigger is off — the unguarded window the pattern would otherwise open +-- during a live deployment does not exist. (If the migration runner already +-- wraps the file in a transaction, the inner BEGIN degrades to a warning.) +BEGIN; + +ALTER TABLE public.journal_entry_lines + DISABLE TRIGGER enforce_journal_entry_line_immutability; + +-- NULLIF: an empty-string legacy mirror must not mint a {"n": ""} entry — +-- normalizeLineDimensions treats empty string as "cleared" and never stores it. +UPDATE public.journal_entry_lines +SET dimensions = jsonb_strip_nulls( + jsonb_build_object('1', NULLIF(cost_center, ''), '6', NULLIF(project, '')) +) +WHERE NULLIF(cost_center, '') IS NOT NULL OR NULLIF(project, '') IS NOT NULL; + +ALTER TABLE public.journal_entry_lines + ENABLE TRIGGER enforce_journal_entry_line_immutability; + +COMMIT; + +-- ============================================================================= +-- 7. Migrate the legacy registry tables into the new registry +-- ============================================================================= +-- cost_centers/projects were company-scoped by 20260330130000 but are +-- write-dead (no code path ever INSERTed; sie-export is the only reader and is +-- switched to the new registry in PR2). Copy their rows, then seed system dims +-- for any company that has legacy registry rows or tagged lines. + +-- 7a. System dims for every company with legacy registry rows or tagged lines. +INSERT INTO public.dimensions (company_id, sie_dim_no, name, resets_annually, is_system, sort_order) +SELECT DISTINCT src.company_id, 1, 'Kostnadsställe', true, true, 10 +FROM ( + SELECT company_id FROM public.cost_centers + UNION + SELECT je.company_id + FROM public.journal_entry_lines jel + JOIN public.journal_entries je ON je.id = jel.journal_entry_id + WHERE jel.cost_center IS NOT NULL +) src +ON CONFLICT (company_id, sie_dim_no) DO NOTHING; + +INSERT INTO public.dimensions (company_id, sie_dim_no, name, resets_annually, is_system, sort_order) +SELECT DISTINCT src.company_id, 6, 'Projekt', false, true, 20 +FROM ( + SELECT company_id FROM public.projects + UNION + SELECT je.company_id + FROM public.journal_entry_lines jel + JOIN public.journal_entries je ON je.id = jel.journal_entry_id + WHERE jel.project IS NOT NULL +) src +ON CONFLICT (company_id, sie_dim_no) DO NOTHING; + +-- 7b. Copy legacy registry rows (sanitized to satisfy the code CHECK). +INSERT INTO public.dimension_values (company_id, dimension_id, code, name, is_active) +SELECT cc.company_id, d.id, + left(regexp_replace(cc.code, '["{}]', '', 'g'), 40), + left(cc.name, 120), + cc.is_active +FROM public.cost_centers cc +JOIN public.dimensions d ON d.company_id = cc.company_id AND d.sie_dim_no = 1 +WHERE char_length(regexp_replace(cc.code, '["{}]', '', 'g')) >= 1 +ON CONFLICT (company_id, dimension_id, code) DO NOTHING; + +INSERT INTO public.dimension_values (company_id, dimension_id, code, name, is_active, start_date, end_date) +SELECT p.company_id, d.id, + left(regexp_replace(p.code, '["{}]', '', 'g'), 40), + left(p.name, 120), + p.is_active, p.start_date, p.end_date +FROM public.projects p +JOIN public.dimensions d ON d.company_id = p.company_id AND d.sie_dim_no = 6 +WHERE char_length(regexp_replace(p.code, '["{}]', '', 'g')) >= 1 +ON CONFLICT (company_id, dimension_id, code) DO NOTHING; + +-- 7c. Placeholder values for orphaned free-text codes on lines (referential +-- validity holds retroactively without polluting pickers: is_active = false). +INSERT INTO public.dimension_values (company_id, dimension_id, code, name, is_active) +SELECT DISTINCT je.company_id, d.id, + left(regexp_replace(jel.cost_center, '["{}]', '', 'g'), 40), + jel.cost_center, + false +FROM public.journal_entry_lines jel +JOIN public.journal_entries je ON je.id = jel.journal_entry_id +JOIN public.dimensions d ON d.company_id = je.company_id AND d.sie_dim_no = 1 +WHERE jel.cost_center IS NOT NULL + AND char_length(regexp_replace(jel.cost_center, '["{}]', '', 'g')) >= 1 +ON CONFLICT (company_id, dimension_id, code) DO NOTHING; + +INSERT INTO public.dimension_values (company_id, dimension_id, code, name, is_active) +SELECT DISTINCT je.company_id, d.id, + left(regexp_replace(jel.project, '["{}]', '', 'g'), 40), + jel.project, + false +FROM public.journal_entry_lines jel +JOIN public.journal_entries je ON je.id = jel.journal_entry_id +JOIN public.dimensions d ON d.company_id = je.company_id AND d.sie_dim_no = 6 +WHERE jel.project IS NOT NULL + AND char_length(regexp_replace(jel.project, '["{}]', '', 'g')) >= 1 +ON CONFLICT (company_id, dimension_id, code) DO NOTHING; + +-- The legacy cost_centers/projects tables are intentionally NOT dropped here: +-- sie-export still reads them until PR2 switches it to the new registry. A +-- follow-up cleanup migration drops both tables after that release. + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/dimensions-substrate.pg.test.ts b/tests/pg/dimensions-substrate.pg.test.ts new file mode 100644 index 00000000..3d707b81 --- /dev/null +++ b/tests/pg/dimensions-substrate.pg.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool, withUserContext } from './setup' +import { seedCompany, insertDraftJournalEntry } from './fixtures' + +// PR1 dimensions substrate (20260702084500_dimensions_substrate.sql): +// registry tables + RLS, ensure_company_dimensions RPC, registry guard +// triggers, jel.dimensions column + CHECK, and — the load-bearing property — +// that the dimensions map on a POSTED line is frozen by the existing +// line-immutability trigger with zero new triggers. + +async function seedWithDimensions() { + const seeded = await seedCompany() + await getPool().query(`SELECT public.ensure_company_dimensions($1)`, [seeded.companyId]) + return seeded +} + +async function getDimensionId(companyId: string, sieDimNo: number): Promise { + const { rows } = await getPool().query( + `SELECT id FROM public.dimensions WHERE company_id = $1 AND sie_dim_no = $2`, + [companyId, sieDimNo], + ) + return rows[0].id +} + +async function insertValue(params: { + companyId: string + dimensionId: string + code: string + name?: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.dimension_values (id, company_id, dimension_id, code, name) + VALUES ($1, $2, $3, $4, $5)`, + [id, params.companyId, params.dimensionId, params.code, params.name ?? params.code], + ) + return id +} + +// Insert a balanced line pair where the debit line carries a dimensions map. +async function insertDimensionedLines( + journalEntryId: string, + dimensions: Record, + amount = 1000, +): Promise { + const lineId = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entry_lines + (id, journal_entry_id, account_number, debit_amount, credit_amount, dimensions, cost_center, project) + VALUES ($1, $2, '4010', $3, 0, $4, $5, $6), + (gen_random_uuid(), $2, '1930', 0, $3, '{}', NULL, NULL)`, + [ + lineId, + journalEntryId, + amount, + JSON.stringify(dimensions), + dimensions['1'] ?? null, + dimensions['6'] ?? null, + ], + ) + return lineId +} + +async function commitEntry(companyId: string, journalEntryId: string): Promise { + await getPool().query( + `SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`, + [companyId, journalEntryId], + ) +} + +describe('ensure_company_dimensions', () => { + it('creates system dims 1 and 6 idempotently', async () => { + const { companyId } = await seedCompany() + await getPool().query(`SELECT public.ensure_company_dimensions($1)`, [companyId]) + await getPool().query(`SELECT public.ensure_company_dimensions($1)`, [companyId]) + + const { rows } = await getPool().query( + `SELECT sie_dim_no, name, resets_annually, is_system + FROM public.dimensions WHERE company_id = $1 ORDER BY sie_dim_no`, + [companyId], + ) + expect(rows).toEqual([ + { sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true }, + { sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true }, + ]) + }) + + it('rejects an authenticated caller who is not a member of the company', async () => { + const { companyId } = await seedCompany() + const outsider = await seedCompany() + + await expect( + withUserContext(outsider.userId, (client) => + client.query(`SELECT public.ensure_company_dimensions($1)`, [companyId]), + ), + ).rejects.toThrow(/not a member/) + }) + + it('allows a member to call it through RLS context', async () => { + const { userId, companyId } = await seedCompany() + await withUserContext(userId, async (client) => { + await client.query(`SELECT public.ensure_company_dimensions($1)`, [companyId]) + const { rows } = await client.query( + `SELECT sie_dim_no FROM public.dimensions WHERE company_id = $1 ORDER BY sie_dim_no`, + [companyId], + ) + expect(rows.map((r) => r.sie_dim_no)).toEqual([1, 6]) + }) + }) +}) + +describe('registry RLS', () => { + it('hides other companies dimensions and blocks cross-company inserts', async () => { + const a = await seedWithDimensions() + const b = await seedWithDimensions() + + await withUserContext(a.userId, async (client) => { + const { rows } = await client.query(`SELECT company_id FROM public.dimensions`) + expect(rows.every((r) => r.company_id === a.companyId)).toBe(true) + + const dimId = await getDimensionId(b.companyId, 6) + await expect( + client.query( + `INSERT INTO public.dimension_values (company_id, dimension_id, code, name) + VALUES ($1, $2, 'X', 'X')`, + [b.companyId, dimId], + ), + ).rejects.toThrow(/row-level security/) + }) + }) + + it('lets a member manage values in their own company (incl. DELETE)', async () => { + const { userId, companyId } = await seedWithDimensions() + const dimId = await getDimensionId(companyId, 6) + + await withUserContext(userId, async (client) => { + await client.query( + `INSERT INTO public.dimension_values (company_id, dimension_id, code, name) + VALUES ($1, $2, 'P001', 'Projekt Alpha')`, + [companyId, dimId], + ) + await client.query( + `UPDATE public.dimension_values SET is_active = false + WHERE company_id = $1 AND code = 'P001'`, + [companyId], + ) + const del = await client.query( + `DELETE FROM public.dimension_values WHERE company_id = $1 AND code = 'P001'`, + [companyId], + ) + expect(del.rowCount).toBe(1) + }) + }) +}) + +describe('registry guard triggers', () => { + it('blocks deleting a system dimension', async () => { + const { companyId } = await seedWithDimensions() + await expect( + getPool().query( + `DELETE FROM public.dimensions WHERE company_id = $1 AND sie_dim_no = 6`, + [companyId], + ), + ).rejects.toThrow(/kan inte tas bort/) + }) + + it('blocks renumbering a dimension', async () => { + const { companyId } = await seedWithDimensions() + await expect( + getPool().query( + `UPDATE public.dimensions SET sie_dim_no = 7 WHERE company_id = $1 AND sie_dim_no = 6`, + [companyId], + ), + ).rejects.toThrow(/kan inte ändras/) + }) + + it('code CHECK forbids SIE-framing-breaking characters', async () => { + const { companyId } = await seedWithDimensions() + const dimId = await getDimensionId(companyId, 6) + await expect( + insertValue({ companyId, dimensionId: dimId, code: 'P"1' }), + ).rejects.toThrow(/check/i) + }) +}) + +describe('dimension_values retention', () => { + it('blocks deleting a value referenced by a posted line, allows unreferenced', async () => { + const { userId, companyId, fiscalPeriodId } = await seedWithDimensions() + const dimId = await getDimensionId(companyId, 6) + await insertValue({ companyId, dimensionId: dimId, code: 'P001' }) + await insertValue({ companyId, dimensionId: dimId, code: 'P002' }) + + const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }) + await insertDimensionedLines(entryId, { '6': 'P001' }) + await commitEntry(companyId, entryId) + + await expect( + getPool().query( + `DELETE FROM public.dimension_values WHERE company_id = $1 AND code = 'P001'`, + [companyId], + ), + ).rejects.toThrow(/arkivera/) + + const del = await getPool().query( + `DELETE FROM public.dimension_values WHERE company_id = $1 AND code = 'P002'`, + [companyId], + ) + expect(del.rowCount).toBe(1) + }) + + it('blocks deleting a non-system dimension whose number is on posted lines (cascade path)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedWithDimensions() + // Custom dim 20 with a tagged, posted line + await getPool().query( + `INSERT INTO public.dimensions (company_id, sie_dim_no, name) VALUES ($1, 20, 'Avdelning')`, + [companyId], + ) + const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }) + await insertDimensionedLines(entryId, { '20': 'SYD' }) + await commitEntry(companyId, entryId) + + await expect( + getPool().query( + `DELETE FROM public.dimensions WHERE company_id = $1 AND sie_dim_no = 20`, + [companyId], + ), + ).rejects.toThrow(/kan inte tas bort/) + }) +}) + +describe('journal_entry_lines.dimensions', () => { + it('rejects non-object values via CHECK', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }) + await expect( + getPool().query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, dimensions) + VALUES ($1, '1930', 100, 0, '["not","a","map"]')`, + [entryId], + ), + ).rejects.toThrow(/jel_dimensions_is_object/) + }) + + it('defaults to {} so dimension-less writers stay valid', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }) + await getPool().query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 100, 0), ($1, '3001', 0, 100)`, + [entryId], + ) + const { rows } = await getPool().query( + `SELECT dimensions FROM public.journal_entry_lines WHERE journal_entry_id = $1`, + [entryId], + ) + expect(rows.map((r) => r.dimensions)).toEqual([{}, {}]) + }) + + it('is mutable on drafts but frozen on posted lines (inherits immutability)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedWithDimensions() + const dimId = await getDimensionId(companyId, 6) + await insertValue({ companyId, dimensionId: dimId, code: 'P001' }) + + const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }) + const lineId = await insertDimensionedLines(entryId, { '6': 'P001' }) + + // Draft: retagging is allowed + await getPool().query( + `UPDATE public.journal_entry_lines SET dimensions = '{"6":"P002"}', project = 'P002' WHERE id = $1`, + [lineId], + ) + + await commitEntry(companyId, entryId) + + // Posted: ANY update of the dimensions map is blocked by the existing trigger + await expect( + getPool().query( + `UPDATE public.journal_entry_lines SET dimensions = '{"6":"P999"}' WHERE id = $1`, + [lineId], + ), + ).rejects.toThrow(/Cannot UPDATE lines of a posted journal entry/) + + // And the committed map survived intact + const { rows } = await getPool().query( + `SELECT dimensions, cost_center, project FROM public.journal_entry_lines WHERE id = $1`, + [lineId], + ) + expect(rows[0].dimensions).toEqual({ '6': 'P002' }) + expect(rows[0].project).toBe('P002') + expect(rows[0].cost_center).toBeNull() + }) +}) diff --git a/types/index.ts b/types/index.ts index 35928a22..9fad5b40 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1364,6 +1364,10 @@ export interface JournalEntryLine { exchange_rate: number | null line_description: string | null tax_code: string | null + // SIE dimension map {sie_dim_no: object_code}, e.g. {"1":"KS01","6":"P001"}. + // Source of truth; cost_center/project mirror keys '1'/'6'. Optional so + // pre-migration fixtures and partial selects stay type-valid. + dimensions?: Record cost_center: string | null project: string | null sort_order: number @@ -1705,7 +1709,12 @@ export interface CreateJournalEntryLineInput { amount_in_currency?: number exchange_rate?: number tax_code?: string + // SIE dimension map {sie_dim_no: object_code}. Wins per key over the + // deprecated cost_center/project aliases (normalizeLineDimensions). + dimensions?: Record + /** @deprecated alias for dimensions['1'] — kept for API/MCP compatibility */ cost_center?: string + /** @deprecated alias for dimensions['6'] — kept for API/MCP compatibility */ project?: string }