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:
co-authored by
Claude Fable 5
parent
6be6510d73
commit
11126d6d56
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user