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:
Jakob Wennberg
2026-07-29 09:39:53 +02:00
committed by GitHub
parent 222e581476
commit 80a14ddfd2
8 changed files with 418 additions and 13 deletions
@@ -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',
+89 -11
View File
@@ -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,
+4
View File
@@ -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())
+7 -2
View File
@@ -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] },
)