Files
accounted/extensions/general/mcp-server/__tests__/dimension-tools.test.ts
T
Mattsson 8299ee9fb4 fix(bookkeeping): resolve settlement account in all categorization flows and ship mis-booking audit (#1383)
Completes the #985/#986/#987 caller sweep: categorize-core, v1
batch-categorize, pending-operation edits and the MCP categorize path now
resolve the settlement leg from the transaction's cash account instead of
inheriting a hardcoded or stale account. Extends the correct_entry preview
with currency, tax and dimension line metadata so staged corrections
preserve full line fidelity. Adds a read-only audit query and a runbook for
reviewing and correcting historical mis-bookings via staged storno with
explicit approval; no automated bulk mutation.

Fixes #1001

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:45:38 +02:00

923 lines
38 KiB
TypeScript

/**
* Dimensions PR3: MCP surface tests.
*
* Covers the three new tools (gnubok_list_dimensions,
* gnubok_list_dimension_values, staged gnubok_create_dimension_value), the
* resolve-don't-select helper (exact / fuzzy / ambiguous / none / archived),
* and the dims bag flowing from gnubok_create_voucher params into the staged
* lines. Executor-side coverage (commitCreateDimensionValue incl. duplicate
* idempotency) lives in lib/pending-operations/__tests__/.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase, makeTransaction } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { tools } from '../server'
import {
resolveValueInDimension,
resolveDimensionBags,
DimensionResolutionError,
mergeLineDimensions,
parseDimensionsArg,
type DimensionRegistryEntry,
} from '../dimensions'
const listDimensions = tools.find((t) => t.name === 'gnubok_list_dimensions')!
const listDimensionValues = tools.find((t) => t.name === 'gnubok_list_dimension_values')!
const createDimensionValue = tools.find((t) => t.name === 'gnubok_create_dimension_value')!
const createVoucher = tools.find((t) => t.name === 'gnubok_create_voucher')!
const createInvoice = tools.find((t) => t.name === 'gnubok_create_invoice')!
const categorizeTransaction = tools.find((t) => t.name === 'gnubok_categorize_transaction')!
const bulkBookTransactions = tools.find((t) => t.name === 'gnubok_bulk_book_transactions')!
beforeEach(() => {
vi.clearAllMocks()
})
/**
* Wrap a queued supabase mock so every `.insert(payload)` is recorded with its
* table name: lets tests assert the exact staged pending_operations params
* (the contract the parallel-built executors consume), not just the preview.
*/
function captureInserts(
supabase: ReturnType<typeof createQueuedMockSupabase>['supabase'],
): Array<{ table: string; payload: Record<string, unknown> }> {
const inserts: Array<{ table: string; payload: Record<string, unknown> }> = []
const fromMock = supabase.from as ReturnType<typeof vi.fn>
const original = fromMock.getMockImplementation() as (table: string) => object
fromMock.mockImplementation((table: string) => {
const chain = original(table)
return new Proxy(chain, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver)
if (prop === 'insert' && typeof value === 'function') {
return (...insertArgs: unknown[]) => {
inserts.push({ table, payload: insertArgs[0] as Record<string, unknown> })
return (value as (...a: unknown[]) => unknown)(...insertArgs)
}
}
return value
},
})
})
return inserts
}
/** Registry fixture shared by the producer-tool dims tests below. */
const REGISTRY_ROWS = [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
]
const VALUE_ROWS = [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
]
function makeDim(overrides: Partial<DimensionRegistryEntry> = {}): DimensionRegistryEntry {
return {
id: 'dim-6',
sie_dim_no: 6,
name: 'Projekt',
resets_annually: false,
is_system: true,
is_active: true,
sort_order: 20,
values: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Kontorsflytt Solna', is_active: true, start_date: null, end_date: null },
{ id: 'v3', code: 'P099', name: 'Gammalt projekt', is_active: false, start_date: null, end_date: null },
],
...overrides,
}
}
// ── Registration + scopes ────────────────────────────────────────────────────
describe('dimension tools registration', () => {
it('all three tools exist and are mapped in TOOL_SCOPE_MAP (unmapped = any key)', () => {
expect(listDimensions).toBeDefined()
expect(listDimensionValues).toBeDefined()
expect(createDimensionValue).toBeDefined()
expect(TOOL_SCOPE_MAP.gnubok_list_dimensions).toBe('reports:read')
expect(TOOL_SCOPE_MAP.gnubok_list_dimension_values).toBe('reports:read')
expect(TOOL_SCOPE_MAP.gnubok_create_dimension_value).toBe('bookkeeping:write')
})
it('gnubok_create_dimension_value stages (staged-operation output contract)', () => {
const schema = createDimensionValue.outputSchema as {
properties?: Record<string, unknown>
required?: string[]
}
expect(schema?.properties?.staged).toBeDefined()
expect(schema?.required).toContain('staged')
expect(createDimensionValue.description).toMatch(/stag(e|ing)/i)
})
})
// ── gnubok_list_dimensions ───────────────────────────────────────────────────
describe('gnubok_list_dimensions', () => {
it('seeds system dims then returns the registry with nested values (dashboard GET shape)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure_company_dimensions rpc
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
error: null,
})
enqueue({
data: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = (await listDimensions.execute({}, 'company-1', 'user-1', supabase as never)) as {
dimensions: Array<{ sie_dim_no: number; values: Array<{ code: string }> }>
}
expect(result.dimensions).toHaveLength(2)
expect(result.dimensions[0].sie_dim_no).toBe(1)
expect(result.dimensions[0].values.map((v) => v.code)).toEqual(['KS01'])
expect(result.dimensions[1].values.map((v) => v.code)).toEqual(['P001'])
expect((supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('ensure_company_dimensions')
})
it('surfaces the ensure RPC failure', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'rls denied' } })
await expect(
listDimensions.execute({}, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/rls denied/)
})
})
// ── gnubok_list_dimension_values ─────────────────────────────────────────────
describe('gnubok_list_dimension_values', () => {
it('rejects an unregistered dimension number', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: null, error: null }) // dimensions lookup → not found
await expect(
listDimensionValues.execute({ sie_dim_no: 12 }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/Dimension 12 finns inte/)
})
it('lists values alphabetically without a query', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_active: true }, error: null })
enqueue({
data: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Kontorsflytt', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = (await listDimensionValues.execute(
{ sie_dim_no: 6 },
'company-1',
'user-1',
supabase as never,
)) as { dimension: { sie_dim_no: number }; values: Array<{ code: string; confidence?: number }>; count: number }
expect(result.dimension.sie_dim_no).toBe(6)
expect(result.count).toBe(2)
expect(result.values[0].confidence).toBeUndefined()
})
it('ranks by fuzzy confidence when a query is given', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_active: true }, error: null })
enqueue({
data: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Kontorsflytt Solna', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = (await listDimensionValues.execute(
{ sie_dim_no: 6, query: 'villa almgren' },
'company-1',
'user-1',
supabase as never,
)) as { values: Array<{ code: string; confidence?: number }> }
expect(result.values[0].code).toBe('P001')
expect(result.values[0].confidence).toBeGreaterThan(0.5)
})
})
// ── gnubok_create_dimension_value (staging) ──────────────────────────────────
describe('gnubok_create_dimension_value: staging', () => {
it('rejects a non-Fortnox code before any staging', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P 001"', name: 'Bad code' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Koden får bara innehålla/)
})
it('rejects when the code already exists as an active value', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
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: { id: 'v1', code: 'P001', name: 'Villa Almgren', is_active: true }, error: null })
await expect(
createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P001', name: 'Duplicate' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/finns redan/)
})
it('rejects an archived code with the reactivation hint', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
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: { id: 'v1', code: 'P099', name: 'Gammalt', is_active: false }, error: null })
await expect(
createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P099', name: 'Revive attempt' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/arkiverat: återaktivera/)
})
it('rejects value dates on a resets-annually dimension (kostnadsställe)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true }, error: null })
await expect(
createDimensionValue.execute(
{ sie_dim_no: 1, code: 'KS01', name: 'Stockholm', start_date: '2026-01-01' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Start-\/slutdatum är inte tillåtna/)
})
it('happy path: stages a create_dimension_value pending operation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
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: null }) // duplicate lookup → none
enqueue({ data: { id: 'op-dim-1' }, error: null }) // pending_operations insert
const result = (await createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P010', name: 'Villa Almgren etapp 2' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
)) as { staged: boolean; operation_id?: string; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-dim-1')
expect(result.risk_level).toBe('low')
expect(result.preview.code).toBe('P010')
expect(result.preview.dimension_name).toBe('Projekt')
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(true)
})
it('dry_run validates and previews without staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
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: null }) // duplicate lookup → none
const result = (await createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P010', name: 'Etapp 2', dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; dry_run?: boolean }
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
})
// ── Resolve-don't-select helper ──────────────────────────────────────────────
describe('resolveValueInDimension', () => {
it('resolves an exact code match without echo (exact=true)', () => {
const r = resolveValueInDimension(makeDim(), 'P001')
expect(r).toMatchObject({ code: 'P001', exact: true, confidence: 1 })
})
it('resolves an exact name match with confidence 1 but exact=false (echoed)', () => {
const r = resolveValueInDimension(makeDim(), 'Kontorsflytt Solna')
expect(r).toMatchObject({ code: 'P002', exact: false, confidence: 1 })
})
it('resolves a unique high-confidence fuzzy hit', () => {
const r = resolveValueInDimension(makeDim(), 'villa almgren tak')
expect(r.code).toBe('P001')
expect(r.exact).toBe(false)
expect(r.confidence).toBeGreaterThan(0.7)
})
it('rejects an archived code with the Swedish reactivation message', () => {
expect(() => resolveValueInDimension(makeDim(), 'P099')).toThrow(/arkiverat: återaktivera/)
})
it('rejects an unknown value with the create-first instruction (never auto-creates)', () => {
try {
resolveValueInDimension(makeDim(), 'Bryggeriet ombyggnad')
expect.unreachable('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(DimensionResolutionError)
expect((err as Error).message).toMatch(/Okänt projekt: "Bryggeriet ombyggnad" \(dimension 6\)/)
expect((err as Error).message).toMatch(/gnubok_create_dimension_value/)
}
})
it('rejects ambiguous near-tied candidates with a ranked list', () => {
const dim = makeDim({
values: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren etapp 1', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Villa Almgren etapp 2', is_active: true, start_date: null, end_date: null },
],
})
try {
resolveValueInDimension(dim, 'Villa Almgren etapp')
expect.unreachable('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(DimensionResolutionError)
const e = err as DimensionResolutionError
expect(e.candidates.length).toBeGreaterThanOrEqual(2)
expect(e.message).toMatch(/Kandidater/)
expect(e.message).toMatch(/gnubok_create_dimension_value/)
}
})
})
describe('resolveDimensionBags', () => {
it('costs zero queries when no line carries a bag', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await resolveDimensionBags(supabase as never, 'company-1', [undefined, undefined])
expect(result.resolutions).toEqual([])
expect(supabase.from).not.toHaveBeenCalled()
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('passes bags through verbatim when dimensions_enabled is false (backward compatible)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: false }, error: null }) // company_settings
const result = await resolveDimensionBags(supabase as never, 'company-1', [{ '6': 'fritext-projekt' }])
expect(result.bags).toEqual([{ '6': 'fritext-projekt' }])
expect(result.resolutions).toEqual([])
expect(supabase.rpc).not.toHaveBeenCalled() // no ensure, no registry reads
})
it('resolves names to codes and echoes non-exact resolutions when enabled', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true }, error: null }) // company_settings
enqueue({ data: null, error: null }) // ensure rpc
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
error: null,
})
enqueue({
data: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = await resolveDimensionBags(supabase as never, 'company-1', [
{ '1': 'KS01', '6': 'villa almgren tak' },
{ '6': 'villa almgren tak' },
])
expect(result.bags).toEqual([
{ '1': 'KS01', '6': 'P001' },
{ '6': 'P001' },
])
// Exact code KS01 is not echoed; the fuzzy name is echoed ONCE (cached).
expect(result.resolutions).toHaveLength(1)
expect(result.resolutions[0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
expect(result.resolutions[0].confidence).toBeGreaterThan(0.7)
})
it('rejects a dim number with no registry row when enabled', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null }) // ensure rpc
enqueue({
data: [{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 }],
error: null,
})
enqueue({ data: [], error: null })
await expect(
resolveDimensionBags(supabase as never, 'company-1', [{ '12': 'X' }]),
).rejects.toThrow(/Okänd dimension 12/)
})
})
describe('mergeLineDimensions / parseDimensionsArg', () => {
it('line bag wins per key over defaults and legacy aliases fill unset keys', () => {
const merged = mergeLineDimensions(
{ dimensions: { '6': 'P002' }, cost_center: 'KS01' },
{ '6': 'P001', '1': 'KS99' },
)
// Line's own bag beats the default for dim 6; the line's cost_center alias
// beats the default for dim 1 (per-line explicit > voucher default).
expect(merged).toEqual({ '1': 'KS01', '6': 'P002' })
})
it('returns undefined when nothing is tagged', () => {
expect(mergeLineDimensions({}, undefined)).toBeUndefined()
})
it('parseDimensionsArg throws loudly on an invalid bag shape', () => {
expect(() => parseDimensionsArg({ '0': 'X' }, 'default_dimensions')).toThrow(/Invalid default_dimensions/)
expect(() => parseDimensionsArg({ '6': 'har"citat' }, 'lines[0].dimensions')).toThrow(/Invalid lines\[0\]\.dimensions/)
})
})
// ── Dims bag flowing through gnubok_create_voucher ───────────────────────────
describe('gnubok_create_voucher: dimensions bag', () => {
it('resolves and stages per-line dims + default_dimensions onto the staged lines', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
error: null,
})
enqueue({
data: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
],
error: null,
})
// fiscal period (explicit id path)
enqueue({
data: { id: 'fp-1', is_closed: false, period_start: '2026-01-01', period_end: '2026-12-31', name: '2026' },
error: null,
})
// chart_of_accounts
enqueue({
data: [
{ account_number: '4010', account_name: 'Inköp material', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
// resolvePeriodStatusForDate: 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
// pending_operations insert
enqueue({ data: { id: 'op-dims' }, error: null })
const result = (await createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Material Villa Almgren',
fiscal_period_id: 'fp-1',
default_dimensions: { '6': 'villa almgren tak' },
lines: [
{ account_number: '4010', debit_amount: 250, credit_amount: 0, dimensions: { '1': 'KS01' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
lines: Array<{ dimensions: Record<string, string> | null }>
dimension_resolutions?: Array<{ dimension: number; input: string; resolved_code: string; resolved_name: string; confidence: number }>
}
}
expect(result.staged).toBe(true)
// Expense line: own dim 1 + the voucher default dim 6, resolved to codes.
expect(result.preview.lines[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// Bank line inherits the voucher default too (it lacks the key itself).
expect(result.preview.lines[1].dimensions).toEqual({ '6': 'P001' })
// Non-exact resolution is echoed with the full contract shape.
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
})
it('rejects before staging when a dims value has no registry match (no auto-create)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null }) // ensure rpc
enqueue({
data: [{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 }],
error: null,
})
enqueue({
data: [{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null }],
error: null,
})
await expect(
createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Unknown project',
fiscal_period_id: 'fp-1',
lines: [
{ account_number: '4010', debit_amount: 100, credit_amount: 0, dimensions: { '6': 'Bryggeriet ombyggnad' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Okänt projekt[\s\S]*gnubok_create_dimension_value/)
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
it('keeps the untagged path query-free (legacy tests unchanged): no settings read without dims', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { id: 'fp-1', is_closed: false, period_start: '2026-01-01', period_end: '2026-12-31', name: '2026' },
error: null,
})
enqueue({
data: [
{ account_number: '1010', account_name: 'Balanserade utgifter', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
enqueue({ data: null, error: null }) // period status layer 1
enqueue({ data: null, error: null }) // period status layer 2
enqueue({ data: { id: 'op-plain' }, error: null }) // pending_operations insert
const result = (await createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Ingen dimension',
fiscal_period_id: 'fp-1',
lines: [
{ account_number: '1010', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { dimension_resolutions?: unknown } }
expect(result.staged).toBe(true)
expect(result.preview.dimension_resolutions).toBeUndefined()
expect(supabase.rpc).not.toHaveBeenCalled()
})
})
// ── Dims bags on the PR7 producer tools ──────────────────────────────────────
describe('gnubok_create_invoice: dimensions bag', () => {
it('stages resolved default_dimensions top-level and per-item bags (default NOT merged into items)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: REGISTRY_ROWS, error: null })
enqueue({ data: VALUE_ROWS, error: null })
// customers fetch
enqueue({
data: { id: 'cust-1', name: 'Acme AB', customer_type: 'swedish_business', vat_number_validated: false, default_payment_terms: 30 },
error: null,
})
// resolvePeriodStatusForDate (auto-extracted from invoice_date): 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
// pending_operations insert
enqueue({ data: { id: 'op-inv-dims' }, error: null })
const result = (await createInvoice.execute(
{
customer_id: 'cust-1',
invoice_date: '2026-05-12',
default_dimensions: { '6': 'villa almgren tak' },
items: [
{ description: 'Takarbete', quantity: 10, unit: 'tim', unit_price: 1000, dimensions: { '1': 'KS01' } },
{ description: 'Material', quantity: 1, unit: 'st', unit_price: 500 },
],
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
items: Array<{ dimensions?: Record<string, string> }>
dimension_resolutions?: Array<Record<string, unknown>>
}
}
expect(result.staged).toBe(true)
// Contract: staged params carry `default_dimensions` top-level (resolved to
// codes) and each item its OWN resolved bag: the executor merges.
const op = inserts.find((i) => i.table === 'pending_operations')!
expect(op).toBeDefined()
const params = op.payload.params as {
default_dimensions?: Record<string, string>
items: Array<{ dimensions?: Record<string, string> }>
}
expect(params.default_dimensions).toEqual({ '6': 'P001' })
expect(params.items[0].dimensions).toEqual({ '1': 'KS01' })
expect(params.items[1].dimensions).toBeUndefined()
// (d) Non-exact name resolution is echoed in the result preview.
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
// Preview items mirror the staged (resolved) bags.
expect(result.preview.items[0].dimensions).toEqual({ '1': 'KS01' })
})
it('stages no dims keys at all when nothing is tagged (zero dim queries)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
enqueue({
data: { id: 'cust-1', name: 'Acme AB', customer_type: 'swedish_business', vat_number_validated: false, default_payment_terms: 30 },
error: null,
})
enqueue({ data: null, error: null }) // period status layer 1
enqueue({ data: null, error: null }) // period status layer 2
enqueue({ data: { id: 'op-inv-plain' }, error: null })
const result = (await createInvoice.execute(
{
customer_id: 'cust-1',
invoice_date: '2026-05-12',
items: [{ description: 'Arbete', quantity: 1, unit: 'st', unit_price: 100 }],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { dimension_resolutions?: unknown } }
expect(result.staged).toBe(true)
expect(result.preview.dimension_resolutions).toBeUndefined()
expect(supabase.rpc).not.toHaveBeenCalled()
const params = inserts.find((i) => i.table === 'pending_operations')!.payload.params as Record<string, unknown>
expect(params.default_dimensions).toBeUndefined()
expect((params.items as Array<Record<string, unknown>>)[0].dimensions).toBeUndefined()
})
})
describe('gnubok_categorize_transaction: dimensions bag', () => {
it('shows the linked cash account in the staged preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
cash_account_id: 'cash-1',
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: { ledger_account: '1931' }, error: null })
enqueue({ data: tx, error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { id: 'op-cat-settlement' }, error: null })
const result = (await categorizeTransaction.execute(
{ transaction_id: 'tx-1', category: 'expense_office', allow_duplicate: true },
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
credit_account: string
lines: Array<{ account_number: string; credit_amount: number }>
}
}
expect(result.staged).toBe(true)
expect(result.preview.credit_account).toBe('1931')
expect(result.preview.lines).toContainEqual(
expect.objectContaining({ account_number: '1931', credit_amount: 500 }),
)
})
it('resolves the bag and stages it as params.dimensions with the echo in the preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
const tx = makeTransaction({ id: 'tx-1', amount: -500 })
// categorizeTransactionCore: transaction fetch + company_settings
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
// transaction fetch for the title
enqueue({ data: tx, error: null })
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: REGISTRY_ROWS, error: null })
enqueue({ data: VALUE_ROWS, error: null })
// resolvePeriodStatusForDate: 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
// pending_operations insert
enqueue({ data: { id: 'op-cat-dims' }, error: null })
const result = (await categorizeTransaction.execute(
{
transaction_id: 'tx-1',
category: 'expense_office',
dimensions: { '1': 'KS01', '6': 'villa almgren tak' },
// Skip the booking-duplicate guard so its queries don't consume the queue.
allow_duplicate: true,
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
dimensions?: Record<string, string>
dimension_resolutions?: Array<Record<string, unknown>>
}
}
expect(result.staged).toBe(true)
// Contract: staged param name is `dimensions`, resolved to registry codes.
const params = inserts.find((i) => i.table === 'pending_operations')!.payload.params as {
dimensions?: Record<string, string>
}
expect(params.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// Resolved bag + non-exact echo surface in the approval preview.
expect(result.preview.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
})
})
it('omits the dimensions key entirely when untagged (zero dim queries)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
const tx = makeTransaction({ id: 'tx-1', amount: -500 })
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: tx, error: null })
enqueue({ data: null, error: null }) // period status layer 1
enqueue({ data: null, error: null }) // period status layer 2
enqueue({ data: { id: 'op-cat-plain' }, error: null })
const result = (await categorizeTransaction.execute(
{ transaction_id: 'tx-1', category: 'expense_office', allow_duplicate: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.dimension_resolutions).toBeUndefined()
expect(supabase.rpc).not.toHaveBeenCalled()
const params = inserts.find((i) => i.table === 'pending_operations')!.payload.params as Record<string, unknown>
expect('dimensions' in params).toBe(false)
})
})
describe('gnubok_bulk_book_transactions: dimensions bag', () => {
it('merges default_dimensions into per-line bags and drops the default from staged params', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: REGISTRY_ROWS, error: null })
enqueue({ data: VALUE_ROWS, error: null })
// transactions fetch
enqueue({
data: [{ id: 'tx-1', amount: -400, currency: 'SEK', date: '2026-05-12', journal_entry_id: null }],
error: null,
})
// resolvePeriodStatusForDate: 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
// pending_operations insert
enqueue({ data: { id: 'op-bulk-dims' }, error: null })
const result = (await bulkBookTransactions.execute(
{
tx_ids: ['tx-1'],
default_dimensions: { '6': 'villa almgren tak' },
new_entry: {
description: 'Samlingsverifikation material',
lines: [
{ account_number: '4010', debit_amount: 400, credit_amount: 0, currency: 'SEK', dimensions: { '1': 'KS01' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 400, currency: 'SEK' },
],
},
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: { dimension_resolutions?: Array<Record<string, unknown>> }
}
expect(result.staged).toBe(true)
// Contract: per-line `new_entry.lines[].dimensions` carries the MERGED
// (line-over-default) resolved bags; the top-level default is dropped:
// the executor's RPC reads per-line dims only.
const params = inserts.find((i) => i.table === 'pending_operations')!.payload.params as {
default_dimensions?: Record<string, string>
new_entry: { lines: Array<{ dimensions?: Record<string, string> }> }
}
expect(params.default_dimensions).toBeUndefined()
expect(params.new_entry.lines[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(params.new_entry.lines[1].dimensions).toEqual({ '6': 'P001' })
// (d) Non-exact name resolution echoed once in the preview.
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
})
it('rejects default_dimensions in link-existing mode (posted verifikat is immutable)', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
bulkBookTransactions.execute(
{
tx_ids: ['tx-1'],
existing_journal_entry_id: 'je-1',
default_dimensions: { '6': 'P001' },
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/default_dimensions only applies/i)
expect(supabase.from).not.toHaveBeenCalled()
})
})