feat(transactions): add tool to list transactions without documents and update scope map (#403)
This commit is contained in:
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user