feat(transactions): expose the bank account (cash_account_id + ledger) on listings, filter by it (#1809)
* feat(transactions): expose the bank account (cash_account_id + ledger) on listings, filter by it Customer report: neither the MCP transaction listings nor v1 REST said which bank account a transaction belongs to, so per-account reconciliation could not be driven from outside and a difference on one account was hunted on another. - gnubok_list_uncategorized_transactions: cash_account_id + cash_account_ledger (BAS account of the bank account, one lookup per page) on every row, and an optional cash_account_id filter applied to both count and page. - transactions_without_documents RPC (new migration, same signature): rows carry cash_account_id + cash_account_ledger via LEFT JOIN cash_accounts; gnubok_list_transactions_without_documents declares them. - v1 transactions list/detail: cash_account_id column; list accepts ?cash_account_id=<uuid> (400 on non-UUID). - tools/list budget bumped 59.85K -> 59.9K with the usual log entry; no property descriptions added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): import insertCashAccount in the pg test, regenerate banking.md, validate cash_account_id Skeptic/CI findings: the new pg-real test referenced insertCashAccount without importing it; the accounted-api agent skill (banking.md) was stale after cash_account_id joined the v1 projections (apiskill:check). Also reject a ledger number passed as cash_account_id on the MCP tool with a clear message instead of a raw uuid cast error, since the ledger now sits next to the id in every row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d4c42fd8db
commit
6e5694fd03
@@ -52,4 +52,48 @@ describe('gnubok_list_uncategorized_transactions', () => {
|
||||
expect(result.transactions[0].reference).toBeNull()
|
||||
expect(result.transactions[0].is_business).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes cash_account_id and resolves the bank account ledger per row', async () => {
|
||||
// Customer report (A4): nothing on the API said which bank account a
|
||||
// transaction belongs to, so per-account reconciliation was impossible.
|
||||
const rows = [
|
||||
{ id: 't-1', date: '2026-03-09', description: 'A', amount: -10, currency: 'SEK', merchant_name: null, reference: null, is_business: null, category: null, cash_account_id: 'ca-1930' },
|
||||
{ id: 't-2', date: '2026-03-08', description: 'B', amount: -20, currency: 'SEK', merchant_name: null, reference: null, is_business: null, category: null, cash_account_id: null },
|
||||
]
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null, count: 2 })
|
||||
enqueue({ data: rows, error: null })
|
||||
enqueue({ data: [{ id: 'ca-1930', ledger_account: '1930' }], error: null })
|
||||
|
||||
const result = (await tool.execute({ limit: 20 }, 'company-1', 'user-1', supabase as never)) as {
|
||||
transactions: Array<{ transaction_id: string; cash_account_id: string | null; cash_account_ledger: string | null }>
|
||||
}
|
||||
|
||||
expect(result.transactions[0]).toMatchObject({ transaction_id: 't-1', cash_account_id: 'ca-1930', cash_account_ledger: '1930' })
|
||||
expect(result.transactions[1]).toMatchObject({ transaction_id: 't-2', cash_account_id: null, cash_account_ledger: null })
|
||||
// The ledger lookup is scoped to the company and the ids on this page.
|
||||
const inCalls = findCalls('cash_accounts', 'in')
|
||||
expect(inCalls).toEqual([['id', ['ca-1930']]])
|
||||
})
|
||||
|
||||
it('narrows both the count and the page to one bank account when cash_account_id is given', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null, count: 0 })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const CA = '550e8400-e29b-41d4-a716-446655441940'
|
||||
await tool.execute({ limit: 20, cash_account_id: CA }, 'company-1', 'user-1', supabase as never)
|
||||
|
||||
const eqCalls = findCalls('transactions', 'eq').filter((args) => args[0] === 'cash_account_id')
|
||||
expect(eqCalls).toEqual([['cash_account_id', CA], ['cash_account_id', CA]])
|
||||
// No ledger lookup when the page is empty.
|
||||
expect(findCalls('cash_accounts', 'in')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a ledger number passed as cash_account_id with a clear message', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tool.execute({ limit: 20, cash_account_id: '1930' }, 'company-1', 'user-1', supabase as never),
|
||||
).rejects.toThrow(/cash_account_id must be a cash account UUID/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,9 +183,17 @@ describe('tools/list payload size guard', () => {
|
||||
// the contract; its description and the org_number/payment_terms
|
||||
// descriptions were trimmed to one short sentence first; headroom
|
||||
// before the change was ~11 tokens, so even the trimmed form crossed.
|
||||
// * 59.85K to 59.9K with the bank account on transaction listings
|
||||
// (customer A4): cash_account_id + cash_account_ledger on
|
||||
// gnubok_list_uncategorized_transactions and
|
||||
// gnubok_list_transactions_without_documents, plus a cash_account_id
|
||||
// filter on the former, so per-account reconciliation can be driven
|
||||
// from outside. No property descriptions (names are the contract);
|
||||
// the tool description gained six words; headroom before the change
|
||||
// was ~50 tokens, so even the bare contract crossed by ~10.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(59_850)
|
||||
expect(approxTokens).toBeLessThan(59_900)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4163,13 +4163,14 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_list_uncategorized_transactions',
|
||||
title: 'List Uncategorized Transactions',
|
||||
description: 'List bank transactions with no journal entry yet, newest first. Paginated.',
|
||||
description: 'List bank transactions with no journal entry yet, newest first. Paginated. cash_account_id narrows to one bank account.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
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)' },
|
||||
cash_account_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
outputSchema: paginatedSchema('transactions', {
|
||||
@@ -4186,6 +4187,8 @@ export const tools: McpTool[] = [
|
||||
reference: { type: ['string', 'null'] },
|
||||
is_business: { type: ['boolean', 'null'] },
|
||||
category: { type: ['string', 'null'] },
|
||||
cash_account_id: { type: ['string', 'null'] },
|
||||
cash_account_ledger: { type: ['string', 'null'] },
|
||||
},
|
||||
}),
|
||||
annotations: {
|
||||
@@ -4197,29 +4200,67 @@ export const tools: McpTool[] = [
|
||||
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 cashAccountId =
|
||||
typeof args.cash_account_id === 'string' && args.cash_account_id.trim()
|
||||
? args.cash_account_id.trim()
|
||||
: null
|
||||
// The ledger number now sits next to the id in every row, so an agent
|
||||
// may well pass "1930" here: fail with a clear message instead of a
|
||||
// raw Postgres uuid cast error.
|
||||
if (cashAccountId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cashAccountId)) {
|
||||
throw new Error('cash_account_id must be a cash account UUID (cash_accounts.id), not a ledger account number')
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const { count: totalCount, error: countError } = await supabase
|
||||
let countQuery = supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
if (cashAccountId) countQuery = countQuery.eq('cash_account_id', cashAccountId)
|
||||
const { count: totalCount, error: countError } = await countQuery
|
||||
|
||||
if (countError) throw new Error(`Database error: ${countError.message}`)
|
||||
|
||||
const { data, error } = await supabase
|
||||
let listQuery = supabase
|
||||
.from('transactions')
|
||||
.select(
|
||||
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
|
||||
'id, date, description, amount, currency, merchant_name, reference, is_business, category, cash_account_id'
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
if (cashAccountId) listQuery = listQuery.eq('cash_account_id', cashAccountId)
|
||||
const { data, error } = await listQuery
|
||||
.order('date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
|
||||
const rows = (data ?? []).map((t: { id: string }) => ({ ...t, transaction_id: t.id }))
|
||||
// Resolve the bank account's BAS ledger for the rows on this page so a
|
||||
// per-account reconciliation can be driven from outside (customer
|
||||
// report: the API never said which bank account a transaction belongs
|
||||
// to). One lookup per page, only when any row carries a cash_account_id.
|
||||
const pageRows = (data ?? []) as Array<{ id: string; cash_account_id?: string | null }>
|
||||
const cashAccountIds = [...new Set(pageRows.map((t) => t.cash_account_id).filter((v): v is string => !!v))]
|
||||
const ledgerByCashAccount = new Map<string, string>()
|
||||
if (cashAccountIds.length > 0) {
|
||||
const { data: cashRows, error: cashError } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, ledger_account')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', cashAccountIds)
|
||||
if (cashError) throw new Error(`Database error: ${cashError.message}`)
|
||||
for (const c of (cashRows ?? []) as Array<{ id: string; ledger_account: string }>) {
|
||||
ledgerByCashAccount.set(c.id, c.ledger_account)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = pageRows.map((t) => ({
|
||||
...t,
|
||||
transaction_id: t.id,
|
||||
cash_account_id: t.cash_account_id ?? null,
|
||||
cash_account_ledger: t.cash_account_id ? ledgerByCashAccount.get(t.cash_account_id) ?? null : null,
|
||||
}))
|
||||
const total = totalCount ?? 0
|
||||
const hasMore = total > offset + rows.length
|
||||
|
||||
@@ -4261,6 +4302,8 @@ export const tools: McpTool[] = [
|
||||
is_business: { type: ['boolean', 'null'] },
|
||||
category: { type: ['string', 'null'] },
|
||||
journal_entry_id: { type: 'string' },
|
||||
cash_account_id: { type: ['string', 'null'] },
|
||||
cash_account_ledger: { type: ['string', 'null'] },
|
||||
},
|
||||
}),
|
||||
annotations: {
|
||||
|
||||
Reference in New Issue
Block a user