diff --git a/extensions/general/mcp-server/__tests__/bulk-book-inbox-dimensions.test.ts b/extensions/general/mcp-server/__tests__/bulk-book-inbox-dimensions.test.ts new file mode 100644 index 00000000..afe8e727 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/bulk-book-inbox-dimensions.test.ts @@ -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>) { + const queues = new Map>() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const inserts: Record = {} + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + 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 } + + expect(result.staged).toBe(true) + expect(result.preview.dimensions).toEqual({ '6': 'P001' }) + const inserted = supabaseMock.inserts.pending_operations?.[0] as { + params: Record + } + 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 } + + expect(result.staged).toBe(true) + expect(result.preview).not.toHaveProperty('dimensions') + const inserted = supabaseMock.inserts.pending_operations?.[0] as { + params: Record + } + // BulkBookInboxSchema normalizes the persisted null back to undefined at + // commit time. + expect(inserted.params.dimensions).toBeNull() + }) +}) diff --git a/extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts b/extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts index b0932893..b4231b91 100644 --- a/extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts @@ -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 + } + 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 } + } + expect(inserted.params.patch.default_dimensions).toEqual({}) + }) + it('rejects personnummer changes at the tool boundary', async () => { const { supabase } = createQueuedMockSupabase() await expect( diff --git a/extensions/general/mcp-server/__tests__/query-journal.test.ts b/extensions/general/mcp-server/__tests__/query-journal.test.ts index a69b5fd7..118f91b6 100644 --- a/extensions/general/mcp-server/__tests__/query-journal.test.ts +++ b/extensions/general/mcp-server/__tests__/query-journal.test.ts @@ -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 }> } + + 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> } + + 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 + applied_filters: { dimensions: Record | 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) diff --git a/extensions/general/mcp-server/recommended-tools.ts b/extensions/general/mcp-server/recommended-tools.ts index 123a1272..83b0bef0 100644 --- a/extensions/general/mcp-server/recommended-tools.ts +++ b/extensions/general/mcp-server/recommended-tools.ts @@ -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', diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index a7814c87..041021a1 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 = {} 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, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 2e93d5cc..cbf8d7d8 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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 diff --git a/lib/transactions/__tests__/categorize-core.bulk.test.ts b/lib/transactions/__tests__/categorize-core.bulk.test.ts index 907ec67e..ab69f231 100644 --- a/lib/transactions/__tests__/categorize-core.bulk.test.ts +++ b/lib/transactions/__tests__/categorize-core.bulk.test.ts @@ -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()) diff --git a/lib/transactions/categorize-core.ts b/lib/transactions/categorize-core.ts index 156fd882..fd25f092 100644 --- a/lib/transactions/categorize-core.ts +++ b/lib/transactions/categorize-core.ts @@ -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 } export interface BulkBookInboxResult { @@ -471,7 +476,7 @@ export async function bulkBookMatchedInboxItems( companyId: string, input: BulkBookInboxInput, ): Promise { - 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] }, )