feat(mcp): dimension parity for the write/read tool edges (#1274)
Closes the MCP dimension gaps found in the 2026-07-28 audit:
- gnubok_bulk_book_inbox_items accepts a shared dimensions bag through
all three layers (tool schema + BulkBookInboxSchema + categorize-core
BulkBookInboxInput), resolve-don't-select with echoed resolutions; the
web inbox bulk-book route and the pending-op executor inherit it via
the shared schema.
- gnubok_create_employee / gnubok_update_employee accept
default_dimensions (names resolve to codes; {} clears on update).
The command layer already persisted the field: only the MCP boundary
blocked it, leaving payroll tagging dashboard-only.
- gnubok_query_journal: dimensions bag filter (jsonb containment via
the GIN index, covers custom dims the legacy project/cost_center
filters cannot) + include_dimensions to return each line's bag.
The wide full-match fetch stays dims-free unless something needs it.
- gnubok_list_invoices / gnubok_list_supplier_invoices return
default_dimensions (agents could set invoice bags but never read
them back).
- Discoverability: create_voucher, categorize_transaction,
correct_entry, update_invoice descriptions now name dimensions;
categorize_month and invoice_run loadouts include
gnubok_list_dimensions. Trimmed new schema prose to stay under the
tools/list payload budget.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* gnubok_bulk_book_inbox_items: shared dimensions bag.
|
||||
*
|
||||
* The bag resolves via the standard resolve-don't-select pass and is staged
|
||||
* into the pending-operation params, from which the executor forwards it to
|
||||
* bulkBookMatchedInboxItems (covered in
|
||||
* lib/transactions/__tests__/categorize-core.bulk.test.ts).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
const bulkBookInbox = tools.find((t) => t.name === 'gnubok_bulk_book_inbox_items')!
|
||||
|
||||
/** Per-table mock capturing insert payloads (same pattern as the payroll
|
||||
* staged-tool tests). A single queued entry repeats for subsequent reads. */
|
||||
function makeCapturingSupabase(byTable: Record<string, { data?: unknown; error?: unknown } | Array<{ data?: unknown; error?: unknown }>>) {
|
||||
const queues = new Map<string, Array<{ data?: unknown; error?: unknown }>>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const inserts: Record<string, unknown[]> = {}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve({ count: null, ...next })
|
||||
}
|
||||
}
|
||||
return (...callArgs: unknown[]) => {
|
||||
if (prop === 'insert') {
|
||||
;(inserts[table] ??= []).push(callArgs[0])
|
||||
}
|
||||
return buildChain(table)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { inserts, from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('gnubok_bulk_book_inbox_items: dimensions', () => {
|
||||
it('stages the resolved bag in params and echoes it in the preview', async () => {
|
||||
const supabaseMock = makeCapturingSupabase({
|
||||
// Resolver: dimensions disabled → free-text passthrough. Also serves
|
||||
// the period-status read at staging time.
|
||||
company_settings: { data: { dimensions_enabled: false } },
|
||||
invoice_inbox_items: {
|
||||
data: [
|
||||
{ id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null },
|
||||
],
|
||||
},
|
||||
transactions: { data: [{ id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK' }] },
|
||||
fiscal_periods: { data: null },
|
||||
pending_operations: { data: { id: 'op-inbox-dims' }, error: null },
|
||||
})
|
||||
|
||||
const result = (await bulkBookInbox.execute(
|
||||
{
|
||||
item_ids: ['i1'],
|
||||
category: 'expense_software',
|
||||
dimensions: { '6': 'P001' },
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabaseMock as never,
|
||||
{ type: 'user' },
|
||||
)) as { staged: boolean; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.dimensions).toEqual({ '6': 'P001' })
|
||||
const inserted = supabaseMock.inserts.pending_operations?.[0] as {
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
expect(inserted.params.dimensions).toEqual({ '6': 'P001' })
|
||||
})
|
||||
|
||||
it('stages dimensions: null when no bag is supplied (persisted-optional contract)', async () => {
|
||||
const supabaseMock = makeCapturingSupabase({
|
||||
company_settings: { data: { dimensions_enabled: false } },
|
||||
invoice_inbox_items: {
|
||||
data: [
|
||||
{ id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null },
|
||||
],
|
||||
},
|
||||
transactions: { data: [{ id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK' }] },
|
||||
fiscal_periods: { data: null },
|
||||
pending_operations: { data: { id: 'op-inbox-nodims' }, error: null },
|
||||
})
|
||||
|
||||
const result = (await bulkBookInbox.execute(
|
||||
{ item_ids: ['i1'], category: 'expense_software' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabaseMock as never,
|
||||
{ type: 'user' },
|
||||
)) as { staged: boolean; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview).not.toHaveProperty('dimensions')
|
||||
const inserted = supabaseMock.inserts.pending_operations?.[0] as {
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
// BulkBookInboxSchema normalizes the persisted null back to undefined at
|
||||
// commit time.
|
||||
expect(inserted.params.dimensions).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -325,6 +325,27 @@ describe('gnubok_create_employee', () => {
|
||||
expect(inserted.title).not.toContain(SAMPLE_PERSONNUMMER)
|
||||
})
|
||||
|
||||
it('stages default_dimensions on the employee (free-text passthrough while dims are off)', async () => {
|
||||
const supabaseMock = makeCapturingSupabase({
|
||||
// Serves BOTH the dimensions resolver (dimensions_enabled undefined →
|
||||
// passthrough) and the entity-type preflight.
|
||||
company_settings: { data: { entity_type: 'ab' } },
|
||||
fiscal_periods: { data: null },
|
||||
pending_operations: { data: { id: 'op-dims-emp' }, error: null },
|
||||
})
|
||||
|
||||
const result = (await createEmployee.execute(
|
||||
{ ...validArgs, default_dimensions: { '1': 'KS1' } },
|
||||
'company-1', 'user-1', supabaseMock as never, { type: 'user' },
|
||||
)) as { staged: boolean }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
const inserted = supabaseMock.inserts.pending_operations?.[0] as {
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
expect(inserted.params.default_dimensions).toEqual({ '1': 'KS1' })
|
||||
})
|
||||
|
||||
it('rejects invalid input via CreateEmployeeSchema (missing salary)', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
@@ -377,6 +398,28 @@ describe('gnubok_update_employee', () => {
|
||||
expect(salaryChange).toEqual({ field: 'monthly_salary', from: 35000, to: 38000 })
|
||||
})
|
||||
|
||||
it('stages a default_dimensions patch, with {} as the clear-all-tags update', async () => {
|
||||
const supabaseMock = makeCapturingSupabase({
|
||||
employees: { data: { ...EXISTING, default_dimensions: { '1': 'KS1' } } },
|
||||
fiscal_periods: { data: null },
|
||||
company_settings: { data: null },
|
||||
pending_operations: { data: { id: 'op-dims-emp2' }, error: null },
|
||||
})
|
||||
|
||||
const result = (await updateEmployee.execute(
|
||||
{ employee_id: 'emp-1', default_dimensions: {} },
|
||||
'company-1', 'user-1', supabaseMock as never, { type: 'user' },
|
||||
)) as { staged: boolean; preview: { changes: Array<{ field: string; from: unknown; to: unknown }> } }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
const dimChange = result.preview.changes.find((c) => c.field === 'default_dimensions')
|
||||
expect(dimChange).toEqual({ field: 'default_dimensions', from: { '1': 'KS1' }, to: {} })
|
||||
const inserted = supabaseMock.inserts.pending_operations?.[0] as {
|
||||
params: { patch: Record<string, unknown> }
|
||||
}
|
||||
expect(inserted.params.patch.default_dimensions).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects personnummer changes at the tool boundary', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
|
||||
@@ -405,6 +405,97 @@ describe('gnubok_query_journal: execute', () => {
|
||||
expect(result.applied_filters.group_by_dimension).toBe('6')
|
||||
})
|
||||
|
||||
it('include_dimensions returns each line\'s bag with an empty-object fallback', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const rows = [
|
||||
{ ...makeLineRow({ id: 'l1', account_number: '4010', debit_amount: 100 }), dimensions: { '6': 'P001' } },
|
||||
{ ...makeLineRow({ id: 'l2', account_number: '5010', debit_amount: 50 }), dimensions: null },
|
||||
]
|
||||
const { supabase } = makeEntryLinesMock(rows)
|
||||
|
||||
const result = (await tool.execute(
|
||||
{ include_dimensions: true, limit: 100 },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase,
|
||||
)) as { lines: Array<{ line_id: string; dimensions?: Record<string, string> }> }
|
||||
|
||||
const byId = new Map(result.lines.map((l) => [l.line_id, l]))
|
||||
expect(byId.get('l1')?.dimensions).toEqual({ '6': 'P001' })
|
||||
expect(byId.get('l2')?.dimensions).toEqual({})
|
||||
})
|
||||
|
||||
it('omits the dimensions key from lines by default (width guard)', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const rows = [
|
||||
{ ...makeLineRow({ id: 'l1', account_number: '4010', debit_amount: 100 }), dimensions: { '6': 'P001' } },
|
||||
]
|
||||
const { supabase } = makeEntryLinesMock(rows)
|
||||
|
||||
const result = (await tool.execute(
|
||||
{ limit: 100 },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase,
|
||||
)) as { lines: Array<Record<string, unknown>> }
|
||||
|
||||
expect(result.lines[0]).not.toHaveProperty('dimensions')
|
||||
})
|
||||
|
||||
it('applies the dimensions bag filter via jsonb containment and echoes it', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
// Table-aware chain mock recording .contains calls: company_settings
|
||||
// (resolver, dimensions disabled → free-text passthrough), then the
|
||||
// two-step entry-lines fetch.
|
||||
const containsCalls: Array<{ column: string; value: unknown }> = []
|
||||
const row = { ...makeLineRow({ id: 'l1', debit_amount: 100 }), dimensions: { '6': 'P001' } }
|
||||
const entryParent = row.journal_entries
|
||||
const bareLine = { ...row, journal_entries: undefined, journal_entry_id: entryParent.id }
|
||||
const chain = (data: unknown): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({ data, error: null, count: Array.isArray(data) ? data.length : null })
|
||||
}
|
||||
if (prop === 'range') return () => ({ data, error: null, count: Array.isArray(data) ? data.length : null })
|
||||
if (prop === 'contains') {
|
||||
return (column: string, value: unknown) => {
|
||||
containsCalls.push({ column, value })
|
||||
return chain(data)
|
||||
}
|
||||
}
|
||||
return () => chain(data)
|
||||
},
|
||||
},
|
||||
)
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
if (table === 'company_settings') return chain({ dimensions_enabled: false })
|
||||
if (table === 'journal_entries') return chain([entryParent])
|
||||
return chain([bareLine])
|
||||
}),
|
||||
} as never
|
||||
|
||||
const result = (await tool.execute(
|
||||
{ dimensions: { '6': 'P001' }, limit: 100 },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase,
|
||||
)) as {
|
||||
dimension_filter?: Record<string, string>
|
||||
applied_filters: { dimensions: Record<string, string> | null }
|
||||
lines: Array<{ line_id: string }>
|
||||
}
|
||||
|
||||
expect(containsCalls).toContainEqual({ column: 'dimensions', value: { '6': 'P001' } })
|
||||
expect(result.dimension_filter).toEqual({ '6': 'P001' })
|
||||
expect(result.applied_filters.dimensions).toEqual({ '6': 'P001' })
|
||||
expect(result.lines).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a non-numeric group_by_dimension', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
const supabase = makeChainMock([], 0)
|
||||
|
||||
@@ -45,6 +45,9 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
|
||||
'gnubok_suggest_categories',
|
||||
'gnubok_categorize_transaction',
|
||||
'gnubok_match_transaction_to_invoice',
|
||||
// Tagging: check the registry before writing dimensions bags on
|
||||
// categorize calls (resolve-don't-select needs real codes/names).
|
||||
'gnubok_list_dimensions',
|
||||
'gnubok_load_skill',
|
||||
'gnubok_approve_pending_operation',
|
||||
],
|
||||
@@ -71,6 +74,9 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
|
||||
'gnubok_list_customers',
|
||||
'gnubok_create_customer',
|
||||
'gnubok_list_articles',
|
||||
// Tagging: invoices carry default_dimensions + per-item bags; check the
|
||||
// registry before setting them on gnubok_create_invoice.
|
||||
'gnubok_list_dimensions',
|
||||
'gnubok_create_invoice',
|
||||
'gnubok_send_invoice',
|
||||
'gnubok_mark_invoice_as_sent',
|
||||
|
||||
@@ -3869,7 +3869,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_categorize_transaction',
|
||||
title: 'Categorize Bank Transaction',
|
||||
description: 'Categorize a bank transaction. Stages the verifikat — cost line booked NET of moms, bank line gross; preview.lines shows the exact entry. Commit via gnubok_approve_pending_operation. vat_amount overrides computed moms; reverse_charge rejected when the underlag shows seller VAT.',
|
||||
description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross; dimensions bag tags the cost line. vat_amount overrides computed moms; reverse_charge rejected when the underlag shows seller VAT. Commit via gnubok_approve_pending_operation.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -4539,7 +4539,7 @@ export const tools: McpTool[] = [
|
||||
|
||||
let query = supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, customers(name)', { count: 'exact' })
|
||||
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, default_dimensions, customers(name)', { count: 'exact' })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (status) {
|
||||
@@ -4562,6 +4562,7 @@ export const tools: McpTool[] = [
|
||||
invoice_date: inv.invoice_date,
|
||||
due_date: inv.due_date,
|
||||
document_type: inv.document_type,
|
||||
default_dimensions: inv.default_dimensions ?? {},
|
||||
}))
|
||||
|
||||
return {
|
||||
@@ -5721,7 +5722,7 @@ export const tools: McpTool[] = [
|
||||
|
||||
let query = supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, supplier:suppliers(id, name)')
|
||||
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, default_dimensions, supplier:suppliers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (status !== 'all') {
|
||||
@@ -7021,7 +7022,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_query_journal',
|
||||
title: 'Query Journal Lines',
|
||||
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher series/number, source type, status, project, cost center, free-text. Optional group_by aggregation. Returns lines + totals over the full match set (see totals_scope).",
|
||||
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher, source, status, dimensions bag, free-text. group_by/group_by_dimension aggregation; include_dimensions returns each line's bag. Lines + totals over the full match set (totals_scope).",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -7039,8 +7040,17 @@ export const tools: McpTool[] = [
|
||||
voucher_number_to: { type: 'number', description: 'Highest voucher number (inclusive)' },
|
||||
source_type: { type: 'string', description: 'Filter by source: bank_transaction, invoice_created, supplier_invoice, currency_revaluation, year_end, opening_balance, etc.' },
|
||||
status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' },
|
||||
project: { type: 'string', description: 'Filter by project code' },
|
||||
cost_center: { type: 'string', description: 'Filter by cost center' },
|
||||
project: { type: 'string', description: 'Filter by project code (SIE dim 6)' },
|
||||
cost_center: { type: 'string', description: 'Filter by cost center (SIE dim 1)' },
|
||||
dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Filter: SIE dim no → value (code OR name, resolved server-side), e.g. {"6":"P001"}. Containment match; covers custom dims unlike project/cost_center.',
|
||||
},
|
||||
include_dimensions: {
|
||||
type: 'boolean',
|
||||
description: "Return each line's dimensions bag (default false).",
|
||||
},
|
||||
group_by: { type: 'string', enum: ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'], description: 'Aggregate matching lines into groups by this field. Mutually exclusive with group_by_dimension.' },
|
||||
group_by_dimension: { type: 'string', description: 'Aggregate by SIE dimension number (e.g. "6" = projekt) from each line\'s dimensions bag; untagged → "(utan dimension)". Mutually exclusive with group_by.' },
|
||||
limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1-500 (default 100). Totals/groups cover the FULL match set even when truncated, except under free-text search (see totals_scope).' },
|
||||
@@ -7084,6 +7094,7 @@ export const tools: McpTool[] = [
|
||||
description: 'Present when group_by/group_by_dimension is set; sorted by |net| desc. Scope follows totals_scope.',
|
||||
},
|
||||
applied_filters: { type: 'object' },
|
||||
...DIMENSION_FILTER_OUTPUT_PROPS,
|
||||
},
|
||||
required: ['lines', 'total_lines', 'returned_lines', 'totals', 'totals_scope'],
|
||||
},
|
||||
@@ -7112,6 +7123,10 @@ export const tools: McpTool[] = [
|
||||
const sourceType = args.source_type as string | undefined
|
||||
const project = args.project as string | undefined
|
||||
const costCenter = args.cost_center as string | undefined
|
||||
const includeDimensions = args.include_dimensions === true
|
||||
// Resolve-don't-select: value NAMES resolve to registry codes; the
|
||||
// containment filter then hits the GIN index on the jsonb bag.
|
||||
const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions)
|
||||
|
||||
const GROUP_BY_FIELDS = ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'] as const
|
||||
const groupBy = args.group_by as (typeof GROUP_BY_FIELDS)[number] | undefined
|
||||
@@ -7133,9 +7148,10 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
const wantsGroups = Boolean(groupBy || groupByDimension)
|
||||
|
||||
// The dimensions jsonb only rides along when a group needs it: it is
|
||||
// The dimensions jsonb only rides along when something needs it (a
|
||||
// dimension group, the bag filter's echo, or include_dimensions): it is
|
||||
// the widest column on the line and the aggregate pass fetches ALL rows.
|
||||
const dimsSelect = groupByDimension ? ', dimensions' : ''
|
||||
const dimsSelect = groupByDimension || includeDimensions || dimFilter.filter ? ', dimensions' : ''
|
||||
// Free-text legs only. The embed survives here on purpose: each leg is
|
||||
// capped at `legLimit` rows, and that cap (which drives legCapHit and
|
||||
// the `truncated` signal) has no equivalent in the two-step fetch,
|
||||
@@ -7183,6 +7199,7 @@ export const tools: McpTool[] = [
|
||||
|
||||
if (project) q = q.eq('project', project)
|
||||
if (costCenter) q = q.eq('cost_center', costCenter)
|
||||
if (dimFilter.filter) q = q.contains('dimensions', dimFilter.filter)
|
||||
|
||||
return q
|
||||
}
|
||||
@@ -7213,6 +7230,7 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
if (project) l = l.eq('project', project)
|
||||
if (costCenter) l = l.eq('cost_center', costCenter)
|
||||
if (dimFilter.filter) l = l.contains('dimensions', dimFilter.filter)
|
||||
return l
|
||||
}
|
||||
|
||||
@@ -7420,6 +7438,7 @@ export const tools: McpTool[] = [
|
||||
line_description: r.line_description,
|
||||
project: r.project,
|
||||
cost_center: r.cost_center,
|
||||
...(includeDimensions ? { dimensions: r.dimensions ?? {} } : {}),
|
||||
currency: r.currency,
|
||||
}
|
||||
})
|
||||
@@ -7508,9 +7527,12 @@ export const tools: McpTool[] = [
|
||||
status,
|
||||
project: project ?? null,
|
||||
cost_center: costCenter ?? null,
|
||||
dimensions: dimFilter.filter ?? null,
|
||||
group_by: groupBy ?? null,
|
||||
group_by_dimension: groupByDimension ?? null,
|
||||
},
|
||||
...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}),
|
||||
...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -8245,6 +8267,11 @@ export const tools: McpTool[] = [
|
||||
vat_amount: { type: 'number', exclusiveMinimum: 0, description: "The underlag's exact moms override; only valid with a rate-based vat_treatment. Rarely needed in bulk: all items share one value." },
|
||||
notes: { type: 'string', description: 'Audit-trail note appended to every verifikation. Keep under 200 chars.' },
|
||||
allow_duplicate: { type: 'boolean', description: 'Override the per-item duplicate-booking guard (default false). Set true only after the user confirms these bank lines are genuinely separate events.' },
|
||||
dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Shared dims bag {sie_dim_no: kod eller namn} applied to the business lines of every verifikat. Unknown values rejected: never auto-created.',
|
||||
},
|
||||
},
|
||||
required: ['item_ids', 'category'],
|
||||
},
|
||||
@@ -8265,6 +8292,15 @@ export const tools: McpTool[] = [
|
||||
? args.notes.trim()
|
||||
: undefined
|
||||
|
||||
// Resolve-don't-select: codes AND natural-language names resolve against
|
||||
// the registry; the staged params carry only resolved codes.
|
||||
const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
|
||||
supabase,
|
||||
companyId,
|
||||
[parseDimensionsArg(args.dimensions, 'dimensions')],
|
||||
)
|
||||
const resolvedDimensions = resolvedDimBags[0]
|
||||
|
||||
// Pre-flight: classify the selection so the preview (and the agent) sees
|
||||
// the real shape before staging. Tenant isolation via company_id.
|
||||
const { data: items, error } = await supabase
|
||||
@@ -8323,6 +8359,9 @@ export const tools: McpTool[] = [
|
||||
vat_amount: vatAmount ?? null,
|
||||
notes: notes ?? null,
|
||||
allow_duplicate: args.allow_duplicate === true,
|
||||
dimensions: resolvedDimensions && Object.keys(resolvedDimensions).length > 0
|
||||
? resolvedDimensions
|
||||
: null,
|
||||
},
|
||||
{
|
||||
item_count: itemIds.length,
|
||||
@@ -8334,6 +8373,12 @@ export const tools: McpTool[] = [
|
||||
total_sek: Math.round(totalSek * 100) / 100,
|
||||
category: args.category,
|
||||
vat_treatment: args.vat_treatment ?? null,
|
||||
...(resolvedDimensions && Object.keys(resolvedDimensions).length > 0
|
||||
? { dimensions: resolvedDimensions }
|
||||
: {}),
|
||||
// Echoed for every non-exact dimension resolution (resolve-don't-
|
||||
// select) so the agent can verify what a name attached to.
|
||||
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
|
||||
},
|
||||
actor,
|
||||
{
|
||||
@@ -11194,12 +11239,29 @@ export const tools: McpTool[] = [
|
||||
jamkning_percentage: { type: 'number' },
|
||||
jamkning_valid_from: { type: 'string' },
|
||||
jamkning_valid_to: { type: 'string' },
|
||||
default_dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Dims bag {sie_dim_no: kod eller namn} tagging this employee\'s salary cost lines on every run. Never auto-created.',
|
||||
},
|
||||
},
|
||||
required: ['first_name', 'last_name', 'personnummer', 'employment_start'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
// Resolve-don't-select BEFORE schema validation: the bag may carry
|
||||
// registry value NAMES, which the strict DimensionsBagSchema inside
|
||||
// CreateEmployeeSchema would reject.
|
||||
const { bags: employeeDimBags } = await resolveDimensionBags(
|
||||
supabase,
|
||||
companyId,
|
||||
[parseDimensionsArg(args.default_dimensions, 'default_dimensions')],
|
||||
)
|
||||
if (args.default_dimensions !== undefined) {
|
||||
args = { ...args, default_dimensions: employeeDimBags[0] ?? {} }
|
||||
}
|
||||
|
||||
const { CreateEmployeeSchema } = await import('@/lib/api/schemas')
|
||||
const parsed = CreateEmployeeSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
@@ -11290,6 +11352,11 @@ export const tools: McpTool[] = [
|
||||
jamkning_percentage: { type: ['number', 'null'], description: 'null clears the beslut' },
|
||||
jamkning_valid_from: { type: ['string', 'null'] },
|
||||
jamkning_valid_to: { type: ['string', 'null'] },
|
||||
default_dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Dims bag {sie_dim_no: kod eller namn} tagging salary cost lines. Replaces the whole bag; {} clears all tags. Omit to keep.',
|
||||
},
|
||||
},
|
||||
required: ['employee_id'],
|
||||
},
|
||||
@@ -11302,6 +11369,17 @@ export const tools: McpTool[] = [
|
||||
throw new Error('personnummer cannot be changed: identity is immutable post-create')
|
||||
}
|
||||
|
||||
// Resolve-don't-select: names resolve to registry codes; an explicit {}
|
||||
// stays {} (the clear-all-tags update).
|
||||
if (rest.default_dimensions !== undefined) {
|
||||
const { bags: employeeDimBags } = await resolveDimensionBags(
|
||||
supabase,
|
||||
companyId,
|
||||
[parseDimensionsArg(rest.default_dimensions, 'default_dimensions')],
|
||||
)
|
||||
rest.default_dimensions = employeeDimBags[0] ?? {}
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (value !== undefined) patch[key] = value
|
||||
@@ -12937,7 +13015,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_update_invoice',
|
||||
title: 'Update Draft Invoice',
|
||||
description: 'Stage an edit to a DRAFT invoice: header fields and/or items (items = FULL REPLACE of all lines). Only editable drafts: no verifikat, not self-billed, not a credit note. Sent/paid invoices need gnubok_credit_invoice. Find invoice_id with gnubok_list_invoices.',
|
||||
description: 'Stage an edit to a DRAFT invoice: header fields (incl. default_dimensions) and/or items (items = FULL REPLACE). Drafts only: no verifikat, not self-billed, not a credit note. Sent/paid invoices need gnubok_credit_invoice. Find invoice_id with gnubok_list_invoices.',
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -13332,7 +13410,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_create_voucher',
|
||||
title: 'Create Manual Voucher (Verifikation)',
|
||||
description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Pass inbox_item_id to book a kvitto direct. HIGH risk.',
|
||||
description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Lines accept dimensions bags {sie_dim_no: code or name}. Pass inbox_item_id to book a kvitto direct. HIGH risk.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -13602,7 +13680,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_correct_entry',
|
||||
title: 'Correct Posted Entry (Rättelse)',
|
||||
description: 'Stage a rättelse for a posted verifikation per BFL 5 kap 5§: storno + corrected entry in the original period (never in-place edit). Use for partial fixes like 2641 → 2614/2645. Account drives ruta. HIGH risk.',
|
||||
description: 'Stage a rättelse for a posted verifikation per BFL 5 kap 5§: storno + corrected entry in the original period (never in-place edit). Use for partial fixes like 2641 → 2614/2645; lines accept dimensions bags. Account drives ruta. HIGH risk.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -1376,6 +1376,10 @@ export const BulkBookInboxSchema = z.object({
|
||||
vat_amount: z.number().positive().nullish().transform((v) => v ?? undefined),
|
||||
notes: z.string().max(2000).nullish().transform((v) => v ?? undefined),
|
||||
allow_duplicate: z.boolean().nullish().transform((v) => v ?? undefined),
|
||||
// Shared dimensions bag applied to the business lines of every generated
|
||||
// verifikat (same semantics as single categorize). nullish for the same
|
||||
// staged-params reason as the fields above.
|
||||
dimensions: DimensionsBagSchema.nullish().transform((v) => v ?? undefined),
|
||||
})
|
||||
export type BulkBookInboxInput = z.infer<typeof BulkBookInboxSchema>
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('BulkBookInboxSchema', () => {
|
||||
vat_amount: null,
|
||||
notes: null,
|
||||
allow_duplicate: false,
|
||||
dimensions: null,
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) {
|
||||
@@ -108,9 +109,28 @@ describe('BulkBookInboxSchema', () => {
|
||||
expect(r.data.vat_treatment).toBeUndefined()
|
||||
expect(r.data.vat_amount).toBeUndefined()
|
||||
expect(r.data.notes).toBeUndefined()
|
||||
expect(r.data.dimensions).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a shared dimensions bag and rejects a malformed one', () => {
|
||||
const ok = BulkBookInboxSchema.safeParse({
|
||||
item_ids: ['11111111-1111-4111-8111-111111111111'],
|
||||
category: 'expense_software',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
expect(ok.success).toBe(true)
|
||||
if (ok.success) expect(ok.data.dimensions).toEqual({ '6': 'P001' })
|
||||
|
||||
const bad = BulkBookInboxSchema.safeParse({
|
||||
item_ids: ['11111111-1111-4111-8111-111111111111'],
|
||||
category: 'expense_software',
|
||||
// Key must be a SIE dim number: 'projekt' is not.
|
||||
dimensions: { projekt: 'P001' },
|
||||
})
|
||||
expect(bad.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an empty item_ids array', () => {
|
||||
const r = BulkBookInboxSchema.safeParse({ item_ids: [], category: 'expense_software' })
|
||||
expect(r.success).toBe(false)
|
||||
@@ -227,6 +247,47 @@ describe('bulkBookMatchedInboxItems: booking', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards the shared dimensions bag onto every booked mapping result', async () => {
|
||||
// Fresh mapping object per call: the core mutates it in place, and a
|
||||
// shared fixture would leak dimensions across tests.
|
||||
mockMapping.mockImplementation(() => ({
|
||||
rule: null,
|
||||
debit_account: '5420',
|
||||
credit_account: '1930',
|
||||
risk_level: 'LOW',
|
||||
confidence: 1,
|
||||
requires_review: false,
|
||||
default_private: false,
|
||||
vat_lines: [],
|
||||
description: 'Programvara',
|
||||
}))
|
||||
const supabase = queuedSupabase([
|
||||
{ data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null } },
|
||||
{ data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
|
||||
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
|
||||
{ data: [{ id: 'fp-1' }] },
|
||||
{ error: null },
|
||||
{ data: [] },
|
||||
])
|
||||
|
||||
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', {
|
||||
item_ids: ['i1'],
|
||||
category: 'expense_software',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
|
||||
expect(skipped).toEqual([])
|
||||
expect(booked).toHaveLength(1)
|
||||
expect(mockCreateJE).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'c1',
|
||||
'u1',
|
||||
expect.objectContaining({ id: 'tx-1' }),
|
||||
expect.objectContaining({ dimensions: { '6': 'P001' } }),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('books the matched item and skips the unmatched one in a mixed batch', async () => {
|
||||
const supabase = queuedSupabase([
|
||||
// item i1 → not matched (1 from())
|
||||
|
||||
@@ -447,6 +447,11 @@ export interface BulkBookInboxInput {
|
||||
vat_amount?: number
|
||||
notes?: string
|
||||
allow_duplicate?: boolean
|
||||
/**
|
||||
* Shared dimensions bag applied to the business lines of every generated
|
||||
* verifikat in the batch (same semantics as single categorize).
|
||||
*/
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface BulkBookInboxResult {
|
||||
@@ -471,7 +476,7 @@ export async function bulkBookMatchedInboxItems(
|
||||
companyId: string,
|
||||
input: BulkBookInboxInput,
|
||||
): Promise<BulkBookInboxResult> {
|
||||
const { item_ids, category, vat_treatment, vat_amount, notes, allow_duplicate } = input
|
||||
const { item_ids, category, vat_treatment, vat_amount, notes, allow_duplicate, dimensions } = input
|
||||
|
||||
const booked: BulkBookInboxResult['booked'] = []
|
||||
const skipped: BulkBookInboxResult['skipped'] = []
|
||||
@@ -516,7 +521,7 @@ export async function bulkBookMatchedInboxItems(
|
||||
userId,
|
||||
companyId,
|
||||
item.matched_transaction_id as string,
|
||||
{ category, vatTreatment: vat_treatment, vatAmount: vat_amount, notes, allowDuplicate: allow_duplicate },
|
||||
{ category, vatTreatment: vat_treatment, vatAmount: vat_amount, notes, allowDuplicate: allow_duplicate, dimensions },
|
||||
// Snapshot copies so the guard sees only the prior bookings of this batch.
|
||||
{ excludeTransactionIds: [...bookedTransactionIds], excludeJournalEntryIds: [...bookedJournalEntryIds] },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user