fix(mcp): treat created_journal_entry_id as a terminal link in gnubok_list_unmatched_documents (#891)

Documents booked directly as a journal entry (e.g. via SIE import) no longer appear as unmatched. Aligns the tool with the terminal-link predicate already used by gnubok_list_inbox_items and lib/worklist, preventing agents from double-processing already-booked documents. Adds a regression test suite.
This commit is contained in:
Jonas Flodén
2026-07-06 09:27:51 +02:00
committed by GitHub
parent c43c4a076c
commit f58b7a3602
2 changed files with 139 additions and 3 deletions
@@ -0,0 +1,133 @@
import { describe, it, expect, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
/**
* Regression guard for a reported bug: documents booked directly as a
* journal entry (created_journal_entry_id set, e.g. via SIE import) kept
* showing up in gnubok_list_unmatched_documents even though nothing was left
* to do with them, while gnubok_list_inbox_items(unprocessed_only) already
* considered them terminal-linked and correctly omitted them. The two tools
* disagreed because this query only excluded created_supplier_invoice_id and
* a transactions.document_id match, never created_journal_entry_id.
*
* lib/pending-operations/__tests__/inbox-link-status.pg.test.ts already
* documents the intended contract ("the link column alone drops the row out
* of the 'needs action' filter (the UI and list_unmatched_documents read
* it)"); this test locks the query actually doing that.
*/
const tool = tools.find((t) => t.name === 'gnubok_list_unmatched_documents')!
function makeRecordingChain(result: { data: unknown; error: unknown }) {
const calls: Array<{ method: string; args: unknown[] }> = []
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
return (...args: unknown[]) => {
calls.push({ method: String(prop), args })
return proxy
}
},
}
const proxy = new Proxy({}, handler)
return { proxy, calls }
}
describe('gnubok_list_unmatched_documents', () => {
it('is registered as a read-only paginated tool', () => {
expect(tool).toBeDefined()
expect(tool.annotations?.readOnlyHint).toBe(true)
const schema = tool.outputSchema as { properties: Record<string, unknown> }
expect(schema.properties.items).toBeDefined()
expect(schema.properties.count).toBeDefined()
})
it('describes journal entries as a terminal link, not just bank transactions and supplier invoices', () => {
expect(tool.description).toMatch(/journal entry/)
})
it('excludes inbox rows already booked as a journal entry, not just supplier invoices', async () => {
const inboxChain = makeRecordingChain({ data: [], error: null })
const fromMock = vi.fn().mockReturnValue(inboxChain.proxy)
const supabase = { from: fromMock }
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never)) as {
items: unknown[]
count: number
}
expect(fromMock).toHaveBeenCalledWith('invoice_inbox_items')
const isCalls = inboxChain.calls.filter((c) => c.method === 'is')
expect(isCalls).toContainEqual({ method: 'is', args: ['created_supplier_invoice_id', null] })
expect(isCalls).toContainEqual({ method: 'is', args: ['created_journal_entry_id', null] })
expect(result).toEqual({ items: [], count: 0 })
})
it('maps extracted_data fields and drops documents already pinned to a transaction', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{
id: 'inbox-1',
document_id: 'doc-1',
source: 'upload',
email_from: null,
email_subject: null,
email_received_at: null,
created_at: '2026-06-01T00:00:00Z',
extracted_data: {
supplier: { name: 'DNB Finans', orgNumber: '5164060161' },
invoice: { currency: 'SEK', invoiceDate: '2025-08-05', paymentReference: '9581810307' },
totals: { total: 13428 },
},
},
{
id: 'inbox-2',
document_id: 'doc-2',
source: 'upload',
email_from: null,
email_subject: null,
email_received_at: null,
created_at: '2026-05-01T00:00:00Z',
extracted_data: null,
},
],
error: null,
})
// doc-2 is already pinned to a bank transaction; doc-1 is not.
enqueue({ data: [{ document_id: 'doc-2' }], error: null })
const result = (await tool.execute({ limit: 20 }, 'company-1', 'user-1', supabase as never)) as {
items: Array<{ inbox_item_id: string; vendor_name: string | null; amount: number | null }>
count: number
}
expect(result.count).toBe(1)
expect(result.items[0].inbox_item_id).toBe('inbox-1')
expect(result.items[0].vendor_name).toBe('DNB Finans')
expect(result.items[0].amount).toBe(13428)
})
it('returns an empty result when the inbox query has nothing pending', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [], error: null })
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never)) as {
items: unknown[]
count: number
}
expect(result).toEqual({ items: [], count: 0 })
})
it('throws on a database error', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'connection refused' } })
await expect(tool.execute({}, 'company-1', 'user-1', supabase as never)).rejects.toThrow(
/connection refused/,
)
})
})
+6 -3
View File
@@ -7890,7 +7890,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_list_unmatched_documents',
title: 'List Unmatched Documents',
description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. Amount is in the invoice currency; FX-normalise before comparing to transactions.amount.',
description: 'List inbox documents not yet attached to any bank transaction, supplier invoice, or journal entry. Returns vendor/amount/currency/date hints. Amount is in the invoice currency; FX-normalise before comparing to transactions.amount.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -7933,8 +7933,10 @@ export const tools: McpTool[] = [
}
}
// Pull recent inbox items with a document, no supplier invoice yet, then
// filter out those whose document is already pinned to a transaction.
// Pull recent inbox items with a document, no supplier invoice and no
// direct journal entry yet (both are terminal links per the same
// "processed" semantics gnubok_list_inbox_items uses), then filter out
// those whose document is already pinned to a transaction.
// Two-step query because PostgREST doesn't expose anti-joins.
const fetchSize = limit * 2
let inboxQuery = supabase
@@ -7943,6 +7945,7 @@ export const tools: McpTool[] = [
.eq('company_id', companyId)
.not('document_id', 'is', null)
.is('created_supplier_invoice_id', null)
.is('created_journal_entry_id', null)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(fetchSize)