feat(dimensions): PR3 tagging — voucher-form pickers, MCP dimension tools with resolve-don't-select, engine soft validation (#859)

Phase 3 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false see zero change; existing free-text API writers keep
working (validation is toggle-governed).

Engine (soft validation):
- validateEntryDimensions() in dimension-resolver: zero queries for untagged
  entries; toggle off → passthrough; toggle on → one settings fetch + two
  registry queries, rejects unknown dims/codes and archived values with
  Swedish per-code messages (DimensionValidationError, 400, details.issues).
  Wired into createDraftEntry + updateDraftEntry before any insert; reversal/
  storno paths untouched (verbatim copies). Fails open on transient registry
  errors — soft validation must never block bookkeeping.

MCP (agent write path):
- New tools: gnubok_list_dimensions, gnubok_list_dimension_values (fuse.js
  fuzzy), gnubok_create_dimension_value (STAGED via pending_operations —
  agents never silently mint reporting values; new op type + CHECK migration
  + executor with duplicate-idempotency).
- create_voucher/correct_entry: per-line dimensions bag + default_dimensions,
  resolve-don't-select server-side (code OR natural-language name; exact →
  fuzzy ≤0.30 with ≥0.15 runner-up margin; non-exact resolutions echoed with
  confidence; ambiguous → ranked candidates, no auto-create).
- gnubok_get_agent_briefing gains a dimensions block (enabled, dims, top
  values) — omitted when registry empty.
- TOOL_SCOPE_MAP entries; risk tier low for staged value creation.

UI:
- JournalEntryForm (manual voucher + TransactionBookingDialog embed): header
  "+ Kostnadsställe/Projekt" progressive disclosure (gäller alla rader with
  documented inheritance rule) + per-row tag popover + compact KS·PR badges;
  gated on dimensions_enabled.
- Voucher detail: display-only dimension badges with registry-name resolution.
- EditDraftEntryDialog carries line dimensions so editing a draft no longer
  strips tags.

categorize/bulk_book dims deferred to PR7 (needs the bulk_book RPC migration).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 13:36:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6be6510d73
commit 11126d6d56
28 changed files with 3152 additions and 43 deletions
+5
View File
@@ -204,6 +204,11 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_list_fiscal_periods: 'reports:read',
gnubok_get_reconciliation_status: 'reports:read',
gnubok_list_accrual_schedules: 'reports:read',
// Dimensions (kostnadsställe/projekt) registry — reads next to the report
// tools; the staged value-create is a bookkeeping write (dimensions PR3).
gnubok_list_dimensions: 'reports:read',
gnubok_list_dimension_values: 'reports:read',
gnubok_create_dimension_value: 'bookkeeping:write',
// Document inbox
gnubok_upload_document: 'transactions:write',
gnubok_list_inbox_items: 'transactions:read',
@@ -3,9 +3,15 @@ import {
normalizeLineDimensions,
lineDimensionColumns,
coerceDimensionsBag,
validateEntryDimensions,
DIM_COST_CENTER,
DIM_PROJECT,
} from '@/lib/bookkeeping/dimension-resolver'
// Imported from errors.ts on purpose: proves the re-export surface every
// server consumer uses (the class itself lives in dimension-errors.ts).
import { DimensionValidationError } from '@/lib/bookkeeping/errors'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
describe('normalizeLineDimensions', () => {
it('returns empty map for a line with no dimension data', () => {
@@ -132,3 +138,160 @@ describe('lineDimensionColumns', () => {
expect(lineDimensionColumns(dims)).toEqual({ cost_center: 'KS01', project: 'P001' })
})
})
describe('validateEntryDimensions (soft registry validation, PR3)', () => {
const enabledSettings = { data: { dimensions_enabled: true } }
const registry = {
data: [
{ id: 'dim-ks', sie_dim_no: 1 },
{ id: 'dim-proj', sie_dim_no: 6 },
],
}
function queriedTables(q: ReturnType<typeof createQueuedMockSupabase>): string[] {
return q.supabase.from.mock.calls.map((call) => call[0] as string)
}
function run(
q: ReturnType<typeof createQueuedMockSupabase>,
lines: Parameters<typeof validateEntryDimensions>[2]
) {
return validateEntryDimensions(q.supabase as unknown as SupabaseClient, 'company-1', lines)
}
it('makes ZERO queries when no line carries a dimension', async () => {
const q = createQueuedMockSupabase()
await expect(
run(q, [
{ dimensions: {} },
{ cost_center: null, project: null },
{},
])
).resolves.toBeUndefined()
expect(q.supabase.from).not.toHaveBeenCalled()
})
it('passes through without registry queries when dimensions_enabled is false', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: { dimensions_enabled: false } })
await expect(run(q, [{ dimensions: { '6': 'HELT-OKÄND' } }])).resolves.toBeUndefined()
expect(queriedTables(q)).toEqual(['company_settings'])
})
it('passes through when the company has no settings row (backward compatible)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: null })
await expect(run(q, [{ dimensions: { '6': 'P001' } }])).resolves.toBeUndefined()
expect(queriedTables(q)).toEqual(['company_settings'])
})
it('fails open when the settings query errors (soft validation never blocks)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: null, error: { message: 'column does not exist' } })
await expect(run(q, [{ dimensions: { '6': 'P001' } }])).resolves.toBeUndefined()
})
it('rejects an unknown dimension number when enabled', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue({ data: [] }) // no registry row for dim 9
const promise = run(q, [{ dimensions: { '9': 'X' } }])
await expect(promise).rejects.toBeInstanceOf(DimensionValidationError)
await expect(promise).rejects.toMatchObject({
code: 'DIMENSION_VALIDATION_FAILED',
issues: [{ sie_dim_no: '9', code: null, reason: 'unknown_dimension' }],
})
await expect(promise).rejects.toThrow(
'Okänd dimension 9. Skapa dimensionen i registret först.'
)
// All dims unknown → the values query is skipped entirely.
expect(queriedTables(q)).toEqual(['company_settings', 'dimensions'])
})
it('rejects a code with no dimension_values row', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({ data: [] })
const promise = run(q, [{ dimensions: { '6': 'X' } }])
await expect(promise).rejects.toMatchObject({
issues: [{ sie_dim_no: '6', code: 'X', reason: 'unknown_value' }],
})
await expect(promise).rejects.toThrow(
'Okänt kostnadsställe/projekt: "X" (dimension 6). Skapa värdet i registret först.'
)
})
it('rejects an archived (is_active = false) value', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({ data: [{ dimension_id: 'dim-proj', code: 'X', is_active: false }] })
const promise = run(q, [{ dimensions: { '6': 'X' } }])
await expect(promise).rejects.toMatchObject({
issues: [{ sie_dim_no: '6', code: 'X', reason: 'archived_value' }],
})
await expect(promise).rejects.toThrow(
'"X" är arkiverat — återaktivera värdet för att använda det.'
)
})
it('accepts valid active codes — three queries total, never per line', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({
data: [
{ dimension_id: 'dim-ks', code: 'KS01', is_active: true },
{ dimension_id: 'dim-proj', code: 'P001', is_active: true },
],
})
await expect(
run(q, [
{ dimensions: { '1': 'KS01', '6': 'P001' } },
{ dimensions: { '6': 'P001' } },
{ cost_center: 'KS01' }, // deprecated alias participates too
{}, // untagged line adds nothing
])
).resolves.toBeUndefined()
expect(queriedTables(q)).toEqual(['company_settings', 'dimensions', 'dimension_values'])
})
it('does not false-pass when the same code exists under a different dimension', async () => {
// "100" is a registered kostnadsställe but the line tags it as projekt —
// lookups key on (dimension_id, code), so this must still reject.
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({ data: [{ dimension_id: 'dim-ks', code: '100', is_active: true }] })
await expect(run(q, [{ dimensions: { '6': '100' } }])).rejects.toMatchObject({
issues: [{ sie_dim_no: '6', code: '100', reason: 'unknown_value' }],
})
})
it('collects every offending code into one rejection', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({ data: [{ dimension_id: 'dim-ks', code: 'KS-GAMMAL', is_active: false }] })
const promise = run(q, [
{ dimensions: { '9': 'X' } },
{ dimensions: { '1': 'KS-GAMMAL' } },
{ dimensions: { '6': 'P999' } },
])
await expect(promise).rejects.toMatchObject({
issues: [
{ sie_dim_no: '9', code: null, reason: 'unknown_dimension' },
{ sie_dim_no: '1', code: 'KS-GAMMAL', reason: 'archived_value' },
{ sie_dim_no: '6', code: 'P999', reason: 'unknown_value' },
],
})
})
it('fails open when a registry query errors', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue({ data: null, error: { message: 'transient' } })
await expect(run(q, [{ dimensions: { '6': 'P001' } }])).resolves.toBeUndefined()
})
})
@@ -0,0 +1,251 @@
/**
* Engine wiring of validateEntryDimensions (dimensions plan PR3).
*
* createDraftEntry and updateDraftEntry must run the soft dimension
* validation AFTER balance validation and BEFORE any insert/update, so a
* rejection leaves no orphan rows. Untagged entries must not even fetch
* company_settings; companies without the toggle keep free-text passthrough.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createDraftEntry, updateDraftEntry } from '../engine'
import { DimensionValidationError } from '../errors'
import type { CreateJournalEntryInput } from '@/types'
vi.mock('@/lib/events', () => ({
eventBus: { emit: vi.fn().mockResolvedValue([]) },
}))
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
backfillStandardBASAccounts: vi.fn().mockResolvedValue([]),
}))
interface TableResult {
data?: unknown
error?: unknown
}
/**
* Table-keyed Supabase mock: every from(table) call returns a chain resolving
* to the configured result for that table. insert payloads are captured per
* table so tests can assert what would have been written.
*/
function buildSupabase(tables: Record<string, TableResult>) {
const inserts: Record<string, unknown[]> = {}
const updates: Record<string, unknown[]> = {}
const from = vi.fn().mockImplementation((table: string) => {
const result = tables[table] ?? {}
const resolved = { data: result.data ?? null, error: result.error ?? null }
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'delete', 'order', 'limit', 'lte', 'gte']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.insert = vi.fn().mockImplementation((payload: unknown) => {
;(inserts[table] ??= []).push(payload)
return chain
})
chain.update = vi.fn().mockImplementation((payload: unknown) => {
;(updates[table] ??= []).push(payload)
return chain
})
chain.single = vi.fn().mockResolvedValue(resolved)
chain.maybeSingle = vi.fn().mockResolvedValue(resolved)
chain.then = (resolve: (v: unknown) => void) => resolve(resolved)
return chain
})
const supabase = {
from,
rpc: vi.fn().mockResolvedValue({ data: null, error: null }),
}
const queriedTables = () => from.mock.calls.map((call) => call[0] as string)
return { supabase, inserts, updates, queriedTables }
}
const BASE_TABLES: Record<string, TableResult> = {
fiscal_periods: {
data: { name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' },
},
chart_of_accounts: {
data: [
{ account_number: '1930', id: 'acc-1930' },
{ account_number: '4010', id: 'acc-4010' },
],
},
journal_entries: {
data: { id: 'entry-1', status: 'draft', voucher_series: 'A', lines: [] },
},
journal_entry_lines: { data: null },
}
const DIMENSION_TABLES: Record<string, TableResult> = {
company_settings: { data: { dimensions_enabled: true } },
dimensions: {
data: [
{ id: 'dim-ks', sie_dim_no: 1 },
{ id: 'dim-proj', sie_dim_no: 6 },
],
},
dimension_values: {
data: [
{ dimension_id: 'dim-ks', code: 'KS01', is_active: true },
{ dimension_id: 'dim-proj', code: 'P001', is_active: true },
],
},
}
function makeInput(dimensions?: Record<string, string>): CreateJournalEntryInput {
return {
fiscal_period_id: 'period-1',
entry_date: '2026-06-15',
description: 'Materialinköp',
source_type: 'manual',
// Explicit series keeps resolveSeriesFromSettings from also querying
// company_settings, so the assertions below isolate the validation fetch.
voucher_series: 'A',
lines: [
{ account_number: '4010', debit_amount: 100, credit_amount: 0, dimensions },
{ account_number: '1930', debit_amount: 0, credit_amount: 100, dimensions },
],
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('createDraftEntry — dimension validation wiring', () => {
it('never fetches company_settings for an untagged entry', async () => {
const { supabase, queriedTables } = buildSupabase(BASE_TABLES)
const entry = await createDraftEntry(supabase as never, 'company-1', 'user-1', makeInput())
expect(entry.id).toBe('entry-1')
expect(queriedTables()).not.toContain('company_settings')
expect(queriedTables()).not.toContain('dimensions')
expect(queriedTables()).not.toContain('dimension_values')
})
it('passes tagged lines through untouched when dimensions_enabled is false', async () => {
const { supabase, inserts, queriedTables } = buildSupabase({
...BASE_TABLES,
company_settings: { data: { dimensions_enabled: false } },
})
const entry = await createDraftEntry(
supabase as never,
'company-1',
'user-1',
makeInput({ '6': 'FRITEXT-PROJEKT' })
)
expect(entry.id).toBe('entry-1')
// Toggle checked once, registry never consulted (free-text passthrough).
expect(queriedTables().filter((t) => t === 'company_settings')).toHaveLength(1)
expect(queriedTables()).not.toContain('dimensions')
// The free-text tag still lands on the inserted lines (bag + mirror).
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '6': 'FRITEXT-PROJEKT' })
expect(lineRows[0].project).toBe('FRITEXT-PROJEKT')
})
it('rejects an unknown code before ANY row is inserted (toggle on)', async () => {
const { supabase, inserts, queriedTables } = buildSupabase({
...BASE_TABLES,
...DIMENSION_TABLES,
dimension_values: { data: [] },
})
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', makeInput({ '6': 'P999' }))
).rejects.toBeInstanceOf(DimensionValidationError)
expect(queriedTables()).not.toContain('journal_entries')
expect(inserts.journal_entries).toBeUndefined()
expect(inserts.journal_entry_lines).toBeUndefined()
})
it('rejects an archived value with the Swedish reactivation message', async () => {
const { supabase } = buildSupabase({
...BASE_TABLES,
...DIMENSION_TABLES,
dimension_values: {
data: [{ dimension_id: 'dim-proj', code: 'P001', is_active: false }],
},
})
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', makeInput({ '6': 'P001' }))
).rejects.toThrow('"P001" är arkiverat — återaktivera värdet för att använda det.')
})
it('creates the draft when every tagged code is registered and active', async () => {
const { supabase, inserts } = buildSupabase({ ...BASE_TABLES, ...DIMENSION_TABLES })
const entry = await createDraftEntry(
supabase as never,
'company-1',
'user-1',
makeInput({ '1': 'KS01', '6': 'P001' })
)
expect(entry.id).toBe('entry-1')
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(lineRows[0].cost_center).toBe('KS01')
expect(lineRows[0].project).toBe('P001')
})
})
describe('updateDraftEntry — dimension validation wiring', () => {
it('rejects an unknown code before the header or lines are touched', async () => {
const { supabase, updates, queriedTables } = buildSupabase({
...BASE_TABLES,
...DIMENSION_TABLES,
dimension_values: { data: [] },
})
await expect(
updateDraftEntry(supabase as never, 'company-1', 'user-1', 'entry-1', makeInput({ '6': 'P999' }))
).rejects.toBeInstanceOf(DimensionValidationError)
// journal_entries is hit exactly once: the draft-status load. The header
// update and the delete/insert of lines must never run.
expect(queriedTables().filter((t) => t === 'journal_entries')).toHaveLength(1)
expect(updates.journal_entries).toBeUndefined()
expect(queriedTables()).not.toContain('journal_entry_lines')
})
it('updates an untagged draft without ever fetching company_settings', async () => {
const { supabase, queriedTables } = buildSupabase(BASE_TABLES)
const entry = await updateDraftEntry(
supabase as never,
'company-1',
'user-1',
'entry-1',
makeInput()
)
expect(entry.id).toBe('entry-1')
expect(queriedTables()).not.toContain('company_settings')
})
it('updates a draft with valid registered codes', async () => {
const { supabase, inserts } = buildSupabase({ ...BASE_TABLES, ...DIMENSION_TABLES })
const entry = await updateDraftEntry(
supabase as never,
'company-1',
'user-1',
'entry-1',
makeInput({ '6': 'P001' })
)
expect(entry.id).toBe('entry-1')
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '6': 'P001' })
})
})
+40
View File
@@ -5,6 +5,7 @@ import {
CannotCorrectNonPostedError,
CannotReverseNonPostedError,
CurrencyRevaluationAlreadyExistsError,
DimensionValidationError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
@@ -152,6 +153,25 @@ describe('Typed bookkeeping errors', () => {
expect(err.message).toContain('commit_entry')
expect(err.message).not.toContain('undefined')
})
it('DimensionValidationError carries issues and a Swedish message naming every code', () => {
const err = new DimensionValidationError([
{ sie_dim_no: '6', code: 'P999', reason: 'unknown_value' },
{ sie_dim_no: '1', code: 'KS-GAMMAL', reason: 'archived_value' },
{ sie_dim_no: '9', code: null, reason: 'unknown_dimension' },
])
expect(err.code).toBe('DIMENSION_VALIDATION_FAILED')
expect(err.name).toBe('DimensionValidationError')
expect(err).toBeInstanceOf(Error)
expect(err.issues).toHaveLength(3)
expect(err.message).toContain(
'Okänt kostnadsställe/projekt: "P999" (dimension 6). Skapa värdet i registret först.'
)
expect(err.message).toContain(
'"KS-GAMMAL" är arkiverat — återaktivera värdet för att använda det.'
)
expect(err.message).toContain('Okänd dimension 9. Skapa dimensionen i registret först.')
})
})
describe('isAccountsNotInChartError', () => {
@@ -180,6 +200,11 @@ describe('isBookkeepingError', () => {
expect(isBookkeepingError(new CurrencyRevaluationAlreadyExistsError())).toBe(true)
expect(isBookkeepingError(new InvalidMappingResultError('1930', '3001'))).toBe(true)
expect(isBookkeepingError(new BookkeepingDatabaseError('commit_entry', 'x'))).toBe(true)
expect(
isBookkeepingError(
new DimensionValidationError([{ sie_dim_no: '6', code: 'X', reason: 'unknown_value' }])
)
).toBe(true)
})
it('returns false for plain Error', () => {
@@ -292,6 +317,21 @@ describe('bookkeepingErrorResponse', () => {
expect(body.error.details).toEqual({ debitAccount: null, creditAccount: '3001' })
})
it('returns 400 for DimensionValidationError with issue details and Swedish message', async () => {
const response = bookkeepingErrorResponse(
new DimensionValidationError([{ sie_dim_no: '6', code: 'P999', reason: 'unknown_value' }])
)!
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error.code).toBe('DIMENSION_VALIDATION_FAILED')
expect(body.error.message).toBe(
'Okänt kostnadsställe/projekt: "P999" (dimension 6). Skapa värdet i registret först.'
)
expect(body.error.details).toEqual({
issues: [{ sie_dim_no: '6', code: 'P999', reason: 'unknown_value' }],
})
})
it('returns 500 for BookkeepingDatabaseError with operation tag', async () => {
const response = bookkeepingErrorResponse(
new BookkeepingDatabaseError('commit_entry', 'constraint violation')
+99
View File
@@ -0,0 +1,99 @@
/**
* DimensionValidationError — the typed rejection of validateEntryDimensions()
* (lib/bookkeeping/dimension-resolver.ts).
*
* Lives in its own module instead of ./errors.ts for one reason only:
* dimension-resolver.ts is reachable from client bundles (lib/api/schemas.ts
* imports DimensionsBagSchema and is itself imported by "use client"
* components such as InvoiceEditor), while ./errors.ts imports next/server —
* a server-only module graph (AsyncLocalStorage internals) that must never
* enter a client bundle. This module stays dependency-free.
*
* ./errors.ts re-exports everything here, wires the class into
* isBookkeepingError() and bookkeepingErrorResponse(), and remains the single
* import surface for server code:
*
* import { DimensionValidationError } from '@/lib/bookkeeping/errors'
*
* The class follows the ./errors.ts conventions: stable `code` const, `name`
* set to the class name, structured data on public readonly fields so the
* HTTP layer can attach machine-readable details. The message is user-facing
* Swedish (stays-Swedish bookkeeping surface, mirroring
* accountsNotInChartResponse) and names every offending code so a user or
* agent can self-correct in one pass.
*/
export const DIMENSION_VALIDATION_FAILED = 'DIMENSION_VALIDATION_FAILED' as const
export type DimensionValidationReason =
/** The line references a SIE dimension number with no registry row. */
| 'unknown_dimension'
/** The dimension exists but the code has no dimension_values row. */
| 'unknown_value'
/** The value exists but is archived (is_active = false). */
| 'archived_value'
export interface DimensionValidationIssue {
/** SIE dimension number as keyed in the line bag, e.g. '1' or '6'. */
sie_dim_no: string
/** Offending object code; null when the dimension number itself is unknown. */
code: string | null
reason: DimensionValidationReason
}
/** Swedish user-facing sentence for a single validation issue. */
export function formatDimensionValidationIssue(issue: DimensionValidationIssue): string {
switch (issue.reason) {
case 'unknown_dimension':
return `Okänd dimension ${issue.sie_dim_no}. Skapa dimensionen i registret först.`
case 'archived_value':
return `"${issue.code}" är arkiverat — återaktivera värdet för att använda det.`
case 'unknown_value':
return `Okänt kostnadsställe/projekt: "${issue.code}" (dimension ${issue.sie_dim_no}). Skapa värdet i registret först.`
}
}
function isDimensionValidationIssue(value: unknown): value is DimensionValidationIssue {
if (typeof value !== 'object' || value === null) return false
const v = value as Record<string, unknown>
if (typeof v.sie_dim_no !== 'string') return false
if (v.reason === 'unknown_dimension') return true
return (
(v.reason === 'unknown_value' || v.reason === 'archived_value') && typeof v.code === 'string'
)
}
/**
* Format an untyped issues array (e.g. `details.issues` from a serialized API
* error envelope) into the Swedish message. Returns null unless `raw` is a
* non-empty array of well-formed issues — callers fall back to their generic
* message. Used by lib/errors/get-error-message.ts so the toast reconstructs
* the exact per-code sentences instead of the static registry fallback.
*/
export function formatDimensionValidationIssues(raw: unknown): string | null {
if (!Array.isArray(raw) || raw.length === 0) return null
const issues = raw.filter(isDimensionValidationIssue)
if (issues.length === 0) return null
return issues.map(formatDimensionValidationIssue).join(' ')
}
/**
* Raised by validateEntryDimensions() when a company with
* company_settings.dimensions_enabled = true tags a line with a dimension
* number that has no registry row, a code with no dimension_values row, or an
* archived value. Companies without the toggle keep free-text passthrough
* (backward compatible with every existing API/MCP writer), and untagged
* entries never reach this validation at all.
*/
export class DimensionValidationError extends Error {
readonly code = DIMENSION_VALIDATION_FAILED
constructor(public readonly issues: DimensionValidationIssue[]) {
super(issues.map(formatDimensionValidationIssue).join(' '))
this.name = 'DimensionValidationError'
}
}
export function isDimensionValidationError(err: unknown): err is DimensionValidationError {
return err instanceof DimensionValidationError
}
+128 -1
View File
@@ -11,6 +11,11 @@
*/
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
DimensionValidationError,
type DimensionValidationIssue,
} from '@/lib/bookkeeping/dimension-errors'
/** SIE dimension numbers with first-class mirror columns. */
export const DIM_COST_CENTER = '1'
@@ -31,7 +36,7 @@ export const DimensionsBagSchema = z.record(
z.string().min(1).max(40).regex(/^[^"{}]+$/, 'Dimensionskod får inte innehålla ", { eller }')
)
interface DimensionAliasInput {
export interface DimensionAliasInput {
dimensions?: LineDimensions | null
cost_center?: string | null
project?: string | null
@@ -106,3 +111,125 @@ export function lineDimensionColumns(dimensions: LineDimensions): {
project: dimensions[DIM_PROJECT] ?? null,
}
}
/**
* Soft registry validation of the dimensions referenced by a set of entry
* lines (dev_docs/dimensions_implementation_plan.md, PR3). Called from
* createDraftEntry/updateDraftEntry after balance validation and before any
* insert, so a rejection leaves no orphan rows.
*
* Semantics:
* 1. Untagged entries are free: if no line carries a dimension, the function
* returns without touching the database at all.
* 2. Companies without company_settings.dimensions_enabled keep the historic
* free-text passthrough — existing API/MCP writers are unaffected. This is
* the ONE place the toggle is load-bearing beyond UI visibility.
* 3. Enabled companies get referential validation against the registry: a
* dimension number with no `dimensions` row, a code with no
* `dimension_values` row, or an archived (is_active = false) value rejects
* the whole entry with a DimensionValidationError whose Swedish message
* names every offending code.
*
* Cost: at most three queries per entry (settings, dimensions,
* dimension_values) regardless of line count — never per-line lookups.
*
* Failure posture: query errors fail OPEN (validation is skipped). This is
* soft validation — a transient DB error must not block bookkeeping, and the
* write that follows hits the same database anyway. Reversal/storno/correction
* paths intentionally bypass this function: they copy posted data verbatim
* (BFL 5 kap 5§ requires the storno to mirror the original even if a value
* has since been archived).
*/
export async function validateEntryDimensions(
supabase: SupabaseClient,
companyId: string,
lines: DimensionAliasInput[]
): Promise<void> {
// 1. Union of normalized dimension maps across all lines.
const union = new Map<string, Set<string>>()
for (const line of lines) {
for (const [dimNo, code] of Object.entries(normalizeLineDimensions(line))) {
const codes = union.get(dimNo) ?? new Set<string>()
codes.add(code)
union.set(dimNo, codes)
}
}
if (union.size === 0) return
// 2. Toggle gate — fetched once. Missing row/column or a query error means
// passthrough (fail-open, same posture as resolveSeriesFromSettings).
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('dimensions_enabled')
.eq('company_id', companyId)
.maybeSingle()
const enabled = (settings as { dimensions_enabled?: boolean } | null)?.dimensions_enabled
if (settingsError || !enabled) return
// 3a. Registry rows for every referenced dimension number — one query.
const { data: dimRows, error: dimError } = await supabase
.from('dimensions')
.select('id, sie_dim_no')
.eq('company_id', companyId)
.in('sie_dim_no', [...union.keys()].map(Number))
if (dimError) return
const dimIdByNo = new Map<string, string>()
for (const row of (dimRows ?? []) as { id: string; sie_dim_no: number }[]) {
dimIdByNo.set(String(row.sie_dim_no), row.id)
}
const issues: DimensionValidationIssue[] = []
const knownDimIds: string[] = []
for (const dimNo of union.keys()) {
const dimId = dimIdByNo.get(dimNo)
if (dimId) knownDimIds.push(dimId)
else issues.push({ sie_dim_no: dimNo, code: null, reason: 'unknown_dimension' })
}
// 3b. Value rows for every referenced (dimension, code) pair — one query.
// Filtering by the code union may return a same-named code under another
// referenced dimension; lookups below key on (dimension_id, code) so
// that cannot cause a false pass.
if (knownDimIds.length > 0) {
const allCodes = [...new Set([...union.values()].flatMap((codes) => [...codes]))]
const { data: valueRows, error: valueError } = await supabase
.from('dimension_values')
.select('dimension_id, code, is_active')
.eq('company_id', companyId)
.in('dimension_id', knownDimIds)
.in('code', allCodes)
if (valueError) return
// NUL-escape-separated composite key: a NUL can never occur in a Postgres
// text value, so (dimension_id, code) pairs stay unambiguous for any code.
const activeByKey = new Map<string, boolean>()
for (const row of (valueRows ?? []) as {
dimension_id: string
code: string
is_active: boolean
}[]) {
activeByKey.set(`${row.dimension_id}\u0000${row.code}`, row.is_active)
}
for (const [dimNo, codes] of union) {
const dimId = dimIdByNo.get(dimNo)
if (!dimId) continue
for (const code of codes) {
const isActive = activeByKey.get(`${dimId}\u0000${code}`)
if (isActive === undefined) {
issues.push({ sie_dim_no: dimNo, code, reason: 'unknown_value' })
} else if (!isActive) {
issues.push({ sie_dim_no: dimNo, code, reason: 'archived_value' })
}
}
}
}
if (issues.length > 0) {
throw new DimensionValidationError(issues)
}
}
+16 -1
View File
@@ -14,7 +14,11 @@ import {
JournalEntryNotFoundError,
} from '@/lib/bookkeeping/errors'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
import {
normalizeLineDimensions,
lineDimensionColumns,
validateEntryDimensions,
} 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'
@@ -224,6 +228,13 @@ export async function createDraftEntry(
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
}
// Soft dimension validation (dimensions plan PR3): free for untagged
// entries; free-text passthrough unless company_settings.dimensions_enabled;
// enabled companies get registry validation with a typed Swedish rejection.
// Runs before any insert so a rejection leaves no orphan rows. Reversal/
// storno/correction paths bypass this — they copy posted data verbatim.
await validateEntryDimensions(supabase, companyId, input.lines)
// Validate that entry_date falls within the selected fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
@@ -397,6 +408,10 @@ export async function updateDraftEntry(
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
}
// Same soft dimension validation as createDraftEntry — before any write, so
// a rejection leaves both the header and the existing lines untouched.
await validateEntryDimensions(supabase, companyId, input.lines)
// Entry date must fall within the selected fiscal period.
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
+37 -1
View File
@@ -1,4 +1,24 @@
import { NextResponse } from 'next/server'
import { DimensionValidationError } from './dimension-errors'
// ============================================================================
// Dimension validation error — class lives in ./dimension-errors.ts (pure
// module, no next/server) because dimension-resolver.ts is reachable from
// client bundles via lib/api/schemas.ts. Re-exported here so this file stays
// the single import surface for all typed bookkeeping errors.
// ============================================================================
export {
DIMENSION_VALIDATION_FAILED,
DimensionValidationError,
formatDimensionValidationIssue,
formatDimensionValidationIssues,
isDimensionValidationError,
} from './dimension-errors'
export type {
DimensionValidationIssue,
DimensionValidationReason,
} from './dimension-errors'
// ============================================================================
// Error codes
@@ -310,7 +330,8 @@ export function isBookkeepingError(err: unknown): boolean {
err instanceof MeaninglessCorrectionError ||
err instanceof NoOpenPeriodForDateError ||
err instanceof TargetPeriodClosedError ||
err instanceof TargetPeriodLockedError
err instanceof TargetPeriodLockedError ||
err instanceof DimensionValidationError
)
}
@@ -538,6 +559,21 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
)
}
if (err instanceof DimensionValidationError) {
// err.message is already the user-facing Swedish sentence(s) naming the
// offending code(s); details.issues carries the machine-readable list.
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { issues: err.issues },
},
},
{ status: 400 }
)
}
if (err instanceof BookkeepingDatabaseError) {
return NextResponse.json(
{
+16
View File
@@ -18,6 +18,8 @@
*/
import { formatCurrency } from '@/lib/utils'
// Pure module (no next/server) — safe for the client bundles this file lives in.
import { formatDimensionValidationIssues } from '@/lib/bookkeeping/dimension-errors'
import { getErrorEntry } from './structured-errors'
type ErrorContext =
@@ -341,6 +343,20 @@ export function getErrorMessage(
return 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.'
}
if (structured.code === 'DIMENSION_VALIDATION_FAILED') {
// Prefer reconstructing the per-code Swedish sentences from the
// machine-readable issue list (present on both the dashboard and the
// v1/registry error envelopes); fall back to the message, which the
// engine already emits in Swedish naming the offending codes.
const details = structured.details as { issues?: unknown } | undefined
const formatted = formatDimensionValidationIssues(details?.issues)
if (formatted) return formatted
if (typeof structured.message === 'string' && structured.message.trim()) {
return structured.message
}
return 'Ett angivet kostnadsställe/projekt finns inte i dimensionsregistret eller är arkiverat. Skapa värdet i registret först.'
}
if (structured.code === 'NO_OPEN_PERIOD_FOR_DATE') {
return 'Det finns ingen räkenskapsperiod som täcker det valda datumet. Skapa eller öppna räkenskapsåret först.'
}
+4
View File
@@ -28,6 +28,7 @@ import {
CannotCorrectNonPostedError,
CannotReverseNonPostedError,
CannotReverseStornoError,
DimensionValidationError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
@@ -366,6 +367,9 @@ function extractBookkeepingDetails(err: unknown): { code: string; details?: unkn
if (err instanceof MeaninglessCorrectionError) {
return { code: err.code, details: { reason: err.reason } }
}
if (err instanceof DimensionValidationError) {
return { code: err.code, details: { issues: err.issues } }
}
if (err instanceof NoOpenPeriodForDateError) {
return { code: err.code, details: { date: err.date } }
}
+11
View File
@@ -206,6 +206,17 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
message_sv: 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.',
message_en: 'Mapping rules produced an invalid debit/credit account pair.',
},
DIMENSION_VALIDATION_FAILED: {
httpStatus: 400,
message_sv:
'Ett angivet kostnadsställe/projekt finns inte i dimensionsregistret eller är arkiverat. Skapa värdet i registret först.',
message_en:
'One or more dimension codes on the entry lines are missing from the dimension registry or archived. details.issues lists each offending sie_dim_no/code.',
remediation: {
description:
'Create the missing dimension value in the register (or re-activate the archived value), then retry. Only companies with dimensions enabled are validated; each issue in details.issues carries sie_dim_no, code and reason (unknown_dimension | unknown_value | archived_value).',
},
},
BOOKKEEPING_DATABASE_ERROR: {
httpStatus: 500,
message_sv: 'Verifikationen kunde inte sparas. Försök igen.',
@@ -0,0 +1,145 @@
/**
* commitCreateDimensionValue — executor tests (dimensions PR3).
*
* The executor is private to lib/pending-operations/commit.ts and reached
* through commitPendingOperation, same pattern as executors.test.ts. Staging-
* side coverage (the MCP tool's pre-flight gates) lives in
* extensions/general/mcp-server/__tests__/dimension-tools.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { commitPendingOperation } from '../commit'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'create_dimension_value',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'low',
created_at: '2026-07-02T00:00:00Z',
resolved_at: null,
updated_at: '2026-07-02T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: create_dimension_value', () => {
it('happy path: seeds system dims, inserts the value, returns committed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // ensure_company_dimensions rpc (dim 6)
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: { id: 'val-1', code: 'P010', name: 'Etapp 2', is_active: true }, error: null }) // insert
enqueue({ data: null, error: null }) // finalize update
const op = makePendingOp({
params: { sie_dim_no: 6, code: 'P010', name: 'Etapp 2' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
dimension_value_id: 'val-1',
sie_dim_no: 6,
dimension_name: 'Projekt',
code: 'P010',
name: 'Etapp 2',
already_existed: false,
})
expect((supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('ensure_company_dimensions')
})
it('duplicate code (23505) is idempotent: re-reads the existing row and reports success', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value' } }) // insert conflict
enqueue({ data: { id: 'val-existing', code: 'P010', name: 'Etapp 2', is_active: true }, error: null }) // re-read
enqueue({ data: null, error: null }) // finalize update
const op = makePendingOp({
params: { sie_dim_no: 6, code: 'P010', name: 'Etapp 2' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
dimension_value_id: 'val-existing',
already_existed: true,
})
})
it('rejects an unknown custom dimension (no ensure RPC for non-system dims)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dimensions lookup → not found
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: { sie_dim_no: 12, code: 'X1', name: 'Custom' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Okänd dimension 12/)
// ensure_company_dimensions must NOT run for non-system dims — agents may
// stage new VALUES, never new dimensions.
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('re-validates staged params at the commit boundary (tampered code rejected)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: { sie_dim_no: 6, code: 'has "quotes" and spaces', name: 'Tampered' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Invalid code/)
})
it('rejects value dates on a resets-annually dimension', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // ensure rpc (dim 1)
enqueue({ data: { id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true }, error: null })
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: { sie_dim_no: 1, code: 'KS01', name: 'Stockholm', start_date: '2026-01-01' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Start-\/slutdatum är inte tillåtna/)
})
})
+127
View File
@@ -74,6 +74,7 @@ import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import { BulkBookInboxSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
@@ -433,6 +434,129 @@ async function commitCreateSupplier(
return { data: { supplier_id: data.id } }
}
/**
* Executor for the staged create_dimension_value operation
* (gnubok_create_dimension_value — dimensions PR3). Inserts a dimension value
* (SIE #OBJEKT) into the registry. Agents never silently mint reporting
* values: this always arrives via a human-approved pending_operation.
*
* Idempotent on duplicate code: a 23505 on (company_id, dimension_id, code)
* re-reads the existing row and reports success with already_existed=true, so
* a raced or re-committed approval never fails on "already there".
*/
async function commitCreateDimensionValue(
supabase: SupabaseClient,
_userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
// Defense in depth: re-validate the staged params at the commit boundary so
// a tampered pending_operations row cannot inject a non-portable code or
// malformed dates into the registry (ASVS V4.5) — mirrors commitCreateSupplier.
let validated
try {
validated = CreateDimensionValueParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
const path = issue?.path?.join('.') ?? 'params'
return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
// Get-or-create the system dims (1 = kostnadsställe, 6 = projekt) —
// idempotent lazy seeding. Custom dims must already exist in the registry:
// agents may stage new VALUES, never new dimensions.
if (validated.sie_dim_no === 1 || validated.sie_dim_no === 6) {
const { error: ensureError } = await supabase.rpc('ensure_company_dimensions', {
p_company_id: companyId,
})
if (ensureError) {
return { error: `Kunde inte skapa systemdimensionerna: ${ensureError.message}`, status: 500 }
}
}
const { data: dimension, error: dimError } = await supabase
.from('dimensions')
.select('id, sie_dim_no, name, resets_annually')
.eq('company_id', companyId)
.eq('sie_dim_no', validated.sie_dim_no)
.maybeSingle()
if (dimError) return { error: dimError.message, status: 500 }
if (!dimension) {
return {
error:
`Okänd dimension ${validated.sie_dim_no}. Endast registrerade dimensioner kan få nya värden ` +
'(1 = kostnadsställe och 6 = projekt skapas automatiskt; övriga skapas i registret).',
status: 400,
}
}
// Value dates only make sense on accumulating dimensions (projekt-style
// ranges) — mirrors POST /api/dimensions/[id]/values.
if (dimension.resets_annually && (validated.start_date || validated.end_date)) {
return {
error: `Start-/slutdatum är inte tillåtna på dimensionen "${dimension.name}" (nollställs årligen).`,
status: 400,
}
}
const { data: created, error: insertError } = await supabase
.from('dimension_values')
.insert({
company_id: companyId,
dimension_id: dimension.id,
code: validated.code,
name: validated.name,
start_date: validated.start_date ?? null,
end_date: validated.end_date ?? null,
})
.select('id, code, name, is_active')
.single()
if (insertError) {
if (insertError.code === '23505') {
// Duplicate code — treat the existing value as success (idempotency).
const { data: existing, error: existingError } = await supabase
.from('dimension_values')
.select('id, code, name, is_active')
.eq('company_id', companyId)
.eq('dimension_id', dimension.id)
.eq('code', validated.code)
.maybeSingle()
if (existingError || !existing) {
return { error: insertError.message, status: 500 }
}
return {
data: {
dimension_value_id: existing.id,
sie_dim_no: dimension.sie_dim_no,
dimension_name: dimension.name,
code: existing.code,
name: existing.name,
is_active: existing.is_active,
already_existed: true,
},
}
}
return { error: insertError.message, status: 500 }
}
return {
data: {
dimension_value_id: created.id,
sie_dim_no: dimension.sie_dim_no,
dimension_name: dimension.name,
code: created.code,
name: created.name,
is_active: created.is_active,
already_existed: false,
},
}
}
async function commitCreateTransaction(
supabase: SupabaseClient,
userId: string,
@@ -3527,6 +3651,9 @@ async function commitPendingOperationInner(
case 'create_supplier':
result = await commitCreateSupplier(supabase, userId, companyId, pendingOp.params)
break
case 'create_dimension_value':
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
break
case 'create_invoice':
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
break
+5
View File
@@ -27,6 +27,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// update sit at the lowest tier next to create_customer.
create_article: 'low',
update_article: 'low',
// Dimension values (kostnadsställe/projekt object codes, SIE #OBJEKT) are
// reporting master data — no journal impact, no external side-effect, no
// payment-routing surface. Staged (agents never silently mint reporting
// values) but at the lowest tier next to create_customer/create_article.
create_dimension_value: 'low',
// ── Medium: reversible booking ─────────────────────────────────────
categorize_transaction: 'medium',
@@ -0,0 +1,73 @@
/**
* Authoritative server-side validation for the create_dimension_value staged
* operation. Used by:
* - The MCP tool execute() before staging (extensions/general/mcp-server/server.ts)
* - commitCreateDimensionValue() before the dimension_values INSERT
* (lib/pending-operations/commit.ts)
*
* Defense in depth: validating at the commit boundary protects the DB even if
* a caller writes directly to pending_operations.params bypassing the MCP
* tool (ASVS V4.5 / ISO A.8.28), mirroring CreateSupplierParamsSchema.
*
* The code format is the strict Fortnox charset — deliberately tighter than
* both the DB CHECK (1..40 chars, no `"{}`) and DimensionsBagSchema: legacy
* free-text codes from the backfill/SIE import must survive on lines, but new
* registry codes minted by agents stay portable to Fortnox/Visma. Identical
* to `dimensionValueCode` in lib/api/schemas.ts (the dashboard POST route) so
* the two write paths cannot drift.
*/
import { z } from 'zod'
const FORTNOX_CODE_RE = /^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
/** Trim strings; normalise empty/null to undefined before the inner schema. */
function optString(inner: z.ZodTypeAny) {
return z.preprocess(
(v) => {
if (v == null) return undefined
if (typeof v !== 'string') return v
const t = v.trim()
return t === '' ? undefined : t
},
inner.optional(),
)
}
export const CreateDimensionValueParamsSchema = z
.object({
sie_dim_no: z.preprocess(
(v) => (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : v),
z
.number()
.int('sie_dim_no must be an integer SIE dimension number')
.min(1, 'sie_dim_no must be ≥ 1 (1 = kostnadsställe, 6 = projekt)'),
),
code: z.preprocess(
(v) => (typeof v === 'string' ? v.trim() : v),
z
.string()
.regex(
FORTNOX_CODE_RE,
'Koden får bara innehålla bokstäver (A–Ö), siffror, _, + och - (max 20 tecken)',
),
),
name: z.preprocess(
(v) => (typeof v === 'string' ? v.trim() : v),
z.string().min(1, 'name is required').max(120),
),
start_date: optString(z.string().regex(ISO_DATE_RE, 'start_date must be ISO yyyy-MM-dd')),
end_date: optString(z.string().regex(ISO_DATE_RE, 'end_date must be ISO yyyy-MM-dd')),
})
.strict()
.superRefine((val, ctx) => {
if (val.start_date && val.end_date && val.end_date < val.start_date) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['end_date'],
message: 'Slutdatum får inte vara före startdatum',
})
}
})
export type CreateDimensionValueParams = z.infer<typeof CreateDimensionValueParamsSchema>