Files
accounted/extensions/general/mcp-server/__tests__/dimension-tools.test.ts
T
2a33291c18 fix(mcp): bulk-book titles that say what is approved, reject unknown parameters (#1856)
Two reports from the 08-24 feedback sweep (seq 261545):

- The bulk-book queue title (Samlingsverifikation: 1 transaktioner
  2026-07-22) carried no amount, direction or counterparty; the CEO
  approving from a phone could not tell what he authorised. Titles now
  read: Samlingsverifikation -1 000,00 SEK 2026-05-12: NORDNET UTTAG
  (+1 till). Same per-tx text the categorize titles already carry;
  preview_data stays aggregate-only.
- gnubok_query_journal called with {query} instead of {text} silently
  returned the whole journal. tools/call now rejects unknown top-level
  parameters for every tool (all schemas declare additionalProperties:
  false) with a VALIDATION_ERROR that lists the valid keys. company_id
  stays tolerated everywhere. codedError is exported from
  company-routing for the dispatcher.

The copy fixes this branch originally carried (scope-honest
list_pending_operations, BFL 5 kap 6 § on create_voucher, bank-movement
only on categorize/bulk_book) landed independently in #1844 and were
dropped on rebase; no catalog token change remains.


Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:44:30 +02:00

1016 lines
42 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
// customers fetch (now FIRST: article-rate adoption needs the customer)
enqueue({
data: { id: 'cust-1', name: 'Acme AB', customer_type: 'swedish_business', vat_number_validated: false, default_payment_terms: 30 },
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 (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 }) // resolveSettlementAccount: explicit cash_account_id lookup
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 })
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
// 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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
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,
})
// chart_of_accounts name lookup for the preview kontering
enqueue({
data: [
{ account_number: '4010', account_name: 'Inköp material och varor' },
{ account_number: '1930', account_name: 'Företagskonto' },
],
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>>
lines?: Array<Record<string, unknown>>
currency?: string
entry_description?: string
}
}
expect(result.staged).toBe(true)
// The approval card renders the staged kontering: the exact lines the
// RPC will post, named, plus the bank rows' currency. Aggregates alone
// ("-400, 1 tx, expense") cannot tell a right booking from a wrong one.
expect(result.preview.currency).toBe('SEK')
expect(result.preview.entry_description).toBe('Samlingsverifikation material')
expect(result.preview.lines).toEqual([
expect.objectContaining({
account_number: '4010',
account_name: 'Inköp material och varor',
debit_amount: 400,
credit_amount: 0,
}),
expect.objectContaining({
account_number: '1930',
account_name: 'Företagskonto',
debit_amount: 0,
credit_amount: 400,
}),
])
// 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()
})
})
describe('gnubok_bulk_book_transactions: approval-queue title', () => {
// Feedback seq 261545: "Samlingsverifikation: 1 transaktioner 2026-07-22"
// carried no amount, direction or counterparty, so the CEO approving from a
// phone could not tell what he was authorising.
it('carries direction, amount, currency, date and the lead counterparty', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: REGISTRY_ROWS, error: null })
enqueue({ data: VALUE_ROWS, error: null })
enqueue({
data: [
{ id: 'tx-1', amount: -400, currency: 'SEK', date: '2026-05-12', journal_entry_id: null, description: 'NORDNET UTTAG', merchant_name: null },
{ id: 'tx-2', amount: -600, currency: 'SEK', date: '2026-05-12', journal_entry_id: null, description: 'NORDNET UTTAG', merchant_name: null },
],
error: null,
})
enqueue({
data: [
{ account_number: '4010', account_name: 'Inköp material och varor' },
{ account_number: '1930', account_name: 'Företagskonto' },
],
error: null,
})
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { id: 'op-bulk-title' }, error: null })
await bulkBookTransactions.execute(
{
tx_ids: ['tx-1', 'tx-2'],
default_dimensions: { '6': 'villa almgren tak' },
new_entry: {
description: 'Samlingsverifikation material',
lines: [
{ account_number: '4010', debit_amount: 1000, credit_amount: 0, currency: 'SEK', dimensions: { '1': 'KS01' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
},
},
'company-1',
'user-1',
supabase as never,
)
const staged = inserts.find((i) => i.table === 'pending_operations')
expect(staged).toBeDefined()
const title = staged!.payload.title as string
expect(title).toContain('Samlingsverifikation')
expect(title).toContain('1 000,00 SEK')
expect(title).toMatch(/^Samlingsverifikation -/)
expect(title).toContain('2026-05-12')
expect(title).toContain('NORDNET UTTAG')
expect(title).toContain('(+1 till)')
})
})