diff --git a/extensions/general/mcp-server/__tests__/list-transactions-without-documents.test.ts b/extensions/general/mcp-server/__tests__/list-transactions-without-documents.test.ts new file mode 100644 index 00000000..7af2b417 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/list-transactions-without-documents.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { tools } from '../server' + +const tool = tools.find((t) => t.name === 'gnubok_list_transactions_without_documents')! + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('gnubok_list_transactions_without_documents', () => { + it('is registered as a read-only paginated tool', () => { + expect(tool).toBeDefined() + expect(tool.annotations?.readOnlyHint).toBe(true) + const schema = tool.outputSchema as Record + expect((schema.properties as Record).transactions).toBeDefined() + expect((schema.properties as Record).total_count).toBeDefined() + }) + + it('returns booked transactions that have no document attached', async () => { + const rows = [ + { + id: 't1', + date: '2026-04-12', + description: 'HOTELL ANGLAIS', + amount: -1247, + currency: 'SEK', + merchant_name: 'Hotell Anglais', + reference: null, + is_business: true, + category: 'travel', + journal_entry_id: 'je-1', + }, + ] + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: null, count: 1 }) // count query + enqueue({ data: rows, error: null }) // data query + + const result = (await tool.execute( + { limit: 20 }, + 'company-1', + 'user-1', + supabase as never + )) as { + transactions: typeof rows + count: number + total_count: number + has_more: boolean + } + + expect(result.count).toBe(1) + expect(result.total_count).toBe(1) + expect(result.has_more).toBe(false) + expect(result.transactions[0].id).toBe('t1') + expect(result.transactions[0].journal_entry_id).toBe('je-1') + }) + + it('returns empty result when nothing matches', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: null, count: 0 }) + enqueue({ data: [], error: null }) + + const result = (await tool.execute( + {}, + 'company-1', + 'user-1', + supabase as never + )) as { count: number; total_count: number; has_more: boolean } + + expect(result.count).toBe(0) + expect(result.total_count).toBe(0) + expect(result.has_more).toBe(false) + }) + + it('signals more pages with next_offset when total exceeds the page', async () => { + const page = Array.from({ length: 20 }, (_, i) => ({ + id: `t${i}`, + date: '2026-04-01', + description: 'tx', + amount: -100, + currency: 'SEK', + merchant_name: null, + reference: null, + is_business: true, + category: null, + journal_entry_id: `je-${i}`, + })) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: null, count: 50 }) + enqueue({ data: page, error: null }) + + const result = (await tool.execute( + { limit: 20, offset: 0 }, + 'company-1', + 'user-1', + supabase as never + )) as { has_more: boolean; next_offset?: number } + + expect(result.has_more).toBe(true) + expect(result.next_offset).toBe(20) + }) + + it('throws on database errors', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'connection refused' }, count: null }) + + await expect( + tool.execute({}, 'company-1', 'user-1', supabase as never) + ).rejects.toThrow(/connection refused/) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index cb56c2cc..015ebe28 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -1480,6 +1480,83 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_list_transactions_without_documents', + description: 'List bank transactions that have a journal entry but no attached receipt/invoice document. Use to find verifikationer that need their kvitto attached for BFL compliance. Newest first, paginated.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max results to return, 1–100 (default 20)' }, + offset: { type: 'number', description: 'Number of results to skip for pagination (default 0)' }, + since: { type: 'string', description: 'Optional ISO date (YYYY-MM-DD). Only return transactions on or after this date.' }, + }, + }, + outputSchema: paginatedSchema('transactions', { + type: 'object', + properties: { + id: { type: 'string' }, + date: { type: 'string' }, + description: { type: 'string' }, + amount: { type: 'number' }, + currency: { type: 'string' }, + merchant_name: { type: 'string' }, + reference: { type: 'string' }, + is_business: { type: 'boolean' }, + category: { type: 'string' }, + journal_entry_id: { type: 'string' }, + }, + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase) { + const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100) + const offset = Math.max(0, Number(args.offset) || 0) + const since = typeof args.since === 'string' ? args.since : null + + let countQuery = supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .not('journal_entry_id', 'is', null) + .is('document_id', null) + if (since) countQuery = countQuery.gte('date', since) + + const { count: totalCount, error: countError } = await countQuery + if (countError) throw new Error(`Database error: ${countError.message}`) + + let dataQuery = supabase + .from('transactions') + .select( + 'id, date, description, amount, currency, merchant_name, reference, is_business, category, journal_entry_id' + ) + .eq('company_id', companyId) + .not('journal_entry_id', 'is', null) + .is('document_id', null) + if (since) dataQuery = dataQuery.gte('date', since) + + const { data, error } = await dataQuery + .order('date', { ascending: false }) + .range(offset, offset + limit - 1) + + if (error) throw new Error(`Database error: ${error.message}`) + + const total = totalCount ?? 0 + const hasMore = total > offset + (data?.length ?? 0) + + return { + transactions: data, + count: data?.length ?? 0, + total_count: total, + has_more: hasMore, + ...(hasMore ? { next_offset: offset + (data?.length ?? 0) } : {}), + } + }, + }, + { name: 'gnubok_categorize_transaction', description: 'Categorize a bank transaction. Stages the journal entry for the user to approve in the web app — no DB write until approval.', diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index b3554dc7..c3185643 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -48,14 +48,15 @@ export const SCOPE_GROUPS = [ /** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */ export const TOOL_SCOPE_MAP: Record = { // Transactions - gnubok_list_uncategorized_transactions: 'transactions:read', - gnubok_create_transactions: 'transactions:write', - gnubok_categorize_transaction: 'transactions:write', - gnubok_receipt_matcher: 'transactions:write', - gnubok_get_counterparty_templates: 'transactions:read', - gnubok_suggest_categories: 'transactions:read', - gnubok_match_transaction_to_invoice: 'transactions:write', - gnubok_auto_match_period: 'transactions:write', + gnubok_list_uncategorized_transactions: 'transactions:read', + gnubok_list_transactions_without_documents: 'transactions:read', + gnubok_create_transactions: 'transactions:write', + gnubok_categorize_transaction: 'transactions:write', + gnubok_receipt_matcher: 'transactions:write', + gnubok_get_counterparty_templates: 'transactions:read', + gnubok_suggest_categories: 'transactions:read', + gnubok_match_transaction_to_invoice: 'transactions:write', + gnubok_auto_match_period: 'transactions:write', // Customers gnubok_list_customers: 'customers:read', gnubok_create_customer: 'customers:write',