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>
1016 lines
42 KiB
TypeScript
1016 lines
42 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)
|
||
// 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)')
|
||
})
|
||
})
|