feat(transactions): add tool to list transactions without documents and update scope map (#403)

This commit is contained in:
Mattsson
2026-05-06 17:14:59 +02:00
committed by GitHub
parent 4131db2894
commit e77423d099
3 changed files with 197 additions and 8 deletions
@@ -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<string, unknown>
expect((schema.properties as Record<string, unknown>).transactions).toBeDefined()
expect((schema.properties as Record<string, unknown>).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/)
})
})
+77
View File
@@ -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.',
+9 -8
View File
@@ -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<string, ApiKeyScope> = {
// 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',