diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/route.ts index 0738713a..0c5d8f42 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/route.ts @@ -31,6 +31,7 @@ const TransactionDetail = z.object({ external_id: z.string().nullable(), import_source: z.string().nullable(), reconciliation_method: z.string().nullable(), + cash_account_id: z.string().uuid().nullable(), created_at: z.string(), updated_at: z.string(), }) @@ -41,7 +42,7 @@ const TRANSACTION_DETAIL_COLUMNS = 'id, date, description, amount, currency, amount_sek, reference, merchant_name, ' + 'counterparty_account, journal_entry_id, invoice_id, supplier_invoice_id, ' + 'potential_invoice_id, is_business, category, receipt_id, document_id, ' + - 'external_id, import_source, reconciliation_method, created_at, updated_at' + 'external_id, import_source, reconciliation_method, cash_account_id, created_at, updated_at' registerEndpoint({ operation: 'transactions.get', diff --git a/app/api/v1/companies/[companyId]/transactions/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/__tests__/route.test.ts index b692668f..13dcc89b 100644 --- a/app/api/v1/companies/[companyId]/transactions/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/__tests__/route.test.ts @@ -48,13 +48,17 @@ function makeFlexibleSupabase(byTable: Record resolve(next) } } - return (..._args: unknown[]) => buildChain(table) + return (...args: unknown[]) => { + calls.push({ table, method: String(prop), args }) + return buildChain(table) + } }, } return new Proxy({}, handler) } return { from: vi.fn((table: string) => buildChain(table)) } } +const calls: Array<{ table: string; method: string; args: unknown[] }> = [] const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -69,6 +73,7 @@ function makeRequest(url: string): Request { beforeEach(() => { vi.clearAllMocks() + calls.length = 0 mockValidate.mockResolvedValue({ userId: USER_ID, companyId: COMPANY_ID, @@ -104,6 +109,47 @@ describe('GET /api/v1/companies/:companyId/transactions', () => { expect(body.meta.next_cursor).toBeUndefined() }) + it('passes cash_account_id through as a filter and returns it on each row', async () => { + const CASH_ACCOUNT_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + const supabase = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { + data: [ + { + id: TX_ID, date: '2026-05-12', amount: -100, currency: 'SEK', description: 'ICA', + cash_account_id: CASH_ACCOUNT_ID, created_at: '2026-05-12T10:00:00Z', + }, + ], + error: null, + }, + }) + mockServiceClient.mockReturnValue(supabase) + + const res = await listTransactions( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions?cash_account_id=${CASH_ACCOUNT_ID}`), + { params: Promise.resolve({ companyId: COMPANY_ID }) }, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data[0].cash_account_id).toBe(CASH_ACCOUNT_ID) + const eqCalls = calls.filter((c) => c.table === 'transactions' && c.method === 'eq' && c.args[0] === 'cash_account_id') + expect(eqCalls.map((c) => c.args[1])).toEqual([CASH_ACCOUNT_ID]) + }) + + it('rejects a non-UUID cash_account_id filter with 400', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { data: [], error: null }, + }), + ) + const res = await listTransactions( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions?cash_account_id=1930`), + { params: Promise.resolve({ companyId: COMPANY_ID }) }, + ) + expect(res.status).toBe(400) + }) + it('rejects invalid status filter with 400', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ diff --git a/app/api/v1/companies/[companyId]/transactions/route.ts b/app/api/v1/companies/[companyId]/transactions/route.ts index 9b267381..e83c2dfa 100644 --- a/app/api/v1/companies/[companyId]/transactions/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/route.ts @@ -2,8 +2,9 @@ * GET /api/v1/companies/{companyId}/transactions * * Cursor-paginated transaction list. Filters: status (booked/unbooked), - * date range, currency, search (description ilike). Default sort: - * (date DESC, id ASC): newest first, deterministic tie-break. + * date range, currency, cash_account_id (one bank account), search + * (description ilike). Default sort: (date DESC, id ASC): newest first, + * deterministic tie-break. */ import { z } from 'zod' import { paginated } from '@/lib/api/v1/response' @@ -30,6 +31,7 @@ const TransactionSummary = z.object({ is_business: z.boolean().nullable(), category: z.string().nullable(), import_source: z.string().nullable(), + cash_account_id: z.string().uuid().nullable(), created_at: z.string(), }) @@ -40,7 +42,7 @@ const TransactionListResponse = listEnvelope(TransactionSummary) const TRANSACTION_SUMMARY_COLUMNS = 'id, date, description, amount, currency, reference, merchant_name, ' + 'journal_entry_id, invoice_id, supplier_invoice_id, is_business, category, ' + - 'import_source, created_at' + 'import_source, cash_account_id, created_at' registerEndpoint({ operation: 'transactions.list', @@ -103,6 +105,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), search: z.string().min(1).max(200).optional(), + cash_account_id: z.string().uuid().optional(), }) const filtersResult = FiltersSchema.safeParse({ status: url.searchParams.get('status') ?? undefined, @@ -110,6 +113,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( date_from: url.searchParams.get('date_from') ?? undefined, date_to: url.searchParams.get('date_to') ?? undefined, search: url.searchParams.get('search') ?? undefined, + cash_account_id: url.searchParams.get('cash_account_id') ?? undefined, }) if (!filtersResult.success) { return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { @@ -142,6 +146,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( if (f.status === 'booked') query = query.not('journal_entry_id', 'is', null) else if (f.status === 'unbooked') query = query.is('journal_entry_id', null) if (f.currency) query = query.eq('currency', f.currency) + if (f.cash_account_id) query = query.eq('cash_account_id', f.cash_account_id) if (f.date_from) query = query.gte('date', f.date_from) if (f.date_to) query = query.lte('date', f.date_to) if (f.search) { diff --git a/extensions/general/mcp-server/__tests__/list-uncategorized-transactions.test.ts b/extensions/general/mcp-server/__tests__/list-uncategorized-transactions.test.ts index 3850a53a..6c82fb48 100644 --- a/extensions/general/mcp-server/__tests__/list-uncategorized-transactions.test.ts +++ b/extensions/general/mcp-server/__tests__/list-uncategorized-transactions.test.ts @@ -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/) + }) }) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index b7ec9779..403e2f11 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -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) }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index bd0e26c8..7c0d8a1d 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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() + 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: { diff --git a/skills/accounted-api/references/banking.md b/skills/accounted-api/references/banking.md index 315c802e..711acd4b 100644 --- a/skills/accounted-api/references/banking.md +++ b/skills/accounted-api/references/banking.md @@ -205,7 +205,7 @@ Cursor-paginated transaction list ordered by created_at DESC, id ASC (newest-imp Response `200`: ```ts { - data: { id: string, date: string, description: string, amount: number, currency: string, reference: string, merchant_name: string, journal_entry_id: string, invoice_id: string, supplier_invoice_id: string, is_business: boolean, category: string, import_source: string, created_at: string }[], + data: { id: string, date: string, description: string, amount: number, currency: string, reference: string, merchant_name: string, journal_entry_id: string, invoice_id: string, supplier_invoice_id: string, is_business: boolean, category: string, import_source: string, cash_account_id: string, created_at: string }[], meta: { request_id: string, api_version: string, @@ -261,6 +261,7 @@ Response `200`: external_id: string, import_source: string, reconciliation_method: string, + cash_account_id: string, created_at: string, updated_at: string }, diff --git a/supabase/migrations/20260823001000_transactions_without_documents_cash_account.sql b/supabase/migrations/20260823001000_transactions_without_documents_cash_account.sql new file mode 100644 index 00000000..e1dc0c6f --- /dev/null +++ b/supabase/migrations/20260823001000_transactions_without_documents_cash_account.sql @@ -0,0 +1,141 @@ +-- Migration: transactions_without_documents exposes the bank account +-- +-- Customer report (A4): neither MCP transaction listing nor v1 REST says +-- which bank account a transaction belongs to, so per-account reconciliation +-- cannot be done from outside. The predicate, ordering and paging of this +-- RPC are unchanged; every row now also carries cash_account_id and the +-- cash account's BAS ledger (cash_account_ledger), LEFT JOINed so rows +-- without a backfilled cash_account_id still appear with nulls. +-- +-- Same signature as 20260724090000 (uuid, date, integer, integer): CREATE OR +-- REPLACE keeps the existing grants; they are restated for clarity. + +CREATE OR REPLACE FUNCTION public.transactions_without_documents( + p_company_id uuid, + p_since date DEFAULT NULL, + p_limit integer DEFAULT 20, + p_offset integer DEFAULT 0 +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_limit integer := least(greatest(coalesce(p_limit, 20), 1), 100); + v_offset integer := greatest(coalesce(p_offset, 0), 0); + v_result jsonb; +BEGIN + IF v_jwt_role IN ('anon', 'authenticated') THEN + IF p_company_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM public.user_company_ids() AS c(id) WHERE c.id = p_company_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'TRANSACTIONS_WITHOUT_DOCUMENTS_FORBIDDEN'); + END IF; + END IF; + + WITH candidates AS ( + SELECT + t.id, + t.date, + t.description, + t.amount, + t.currency, + t.merchant_name, + t.reference, + t.is_business, + t.category, + t.journal_entry_id, + t.cash_account_id, + ca.ledger_account AS cash_account_ledger + FROM transactions t + JOIN journal_entries je ON je.id = t.journal_entry_id + LEFT JOIN cash_accounts ca + ON ca.id = t.cash_account_id + AND ca.company_id = t.company_id + WHERE t.company_id = p_company_id + AND je.status = 'posted' + -- Same predicate as verifikat_without_documents: this surface is the + -- bank-driven subset, keyed on the SAME document truth + -- (document_attachments), never transactions.document_id. + AND je.source_type IN ( + 'manual', + 'bank_transaction', + 'supplier_invoice_registered', + 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', + 'import' + ) + AND NOT EXISTS ( + SELECT 1 FROM document_attachments d + WHERE d.journal_entry_id = je.id AND d.is_current_version = true + ) + AND NOT EXISTS ( + SELECT 1 FROM journal_entry_no_doc_required x + WHERE x.journal_entry_id = je.id + ) + -- BFL 5 kap 7 § hänvisning till underlag (anchored docs only); see + -- verifikat_without_documents. + AND NOT EXISTS ( + SELECT 1 + FROM supplier_invoices si + JOIN document_attachments sd ON sd.id = si.document_id + WHERE si.company_id = p_company_id + AND sd.journal_entry_id IS NOT NULL + AND (si.registration_journal_entry_id = je.id + OR si.payment_journal_entry_id = je.id) + ) + AND NOT EXISTS ( + SELECT 1 + FROM supplier_invoice_payments sip + JOIN supplier_invoices sip_si ON sip_si.id = sip.supplier_invoice_id + JOIN document_attachments sipd ON sipd.id = sip_si.document_id + WHERE sip.journal_entry_id = je.id + AND sip_si.company_id = p_company_id + AND sipd.journal_entry_id IS NOT NULL + ) + AND (p_since IS NULL OR t.date >= p_since) + ), + total AS ( + SELECT count(*) AS n FROM candidates + ), + page AS ( + SELECT * FROM candidates + ORDER BY date DESC, id DESC + LIMIT v_limit OFFSET v_offset + ) + SELECT jsonb_build_object( + 'ok', true, + 'total_count', (SELECT n FROM total), + 'transactions', coalesce( + (SELECT jsonb_agg( + jsonb_build_object( + 'id', p.id, + 'transaction_id', p.id, + 'date', p.date, + 'description', p.description, + 'amount', p.amount, + 'currency', p.currency, + 'merchant_name', p.merchant_name, + 'reference', p.reference, + 'is_business', p.is_business, + 'category', p.category, + 'journal_entry_id', p.journal_entry_id, + 'cash_account_id', p.cash_account_id, + 'cash_account_ledger', p.cash_account_ledger + ) + ORDER BY p.date DESC, p.id DESC + ) FROM page p), + '[]'::jsonb + ) + ) + INTO v_result; + + RETURN v_result; +END; +$$; + +REVOKE ALL ON FUNCTION public.transactions_without_documents(uuid, date, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.transactions_without_documents(uuid, date, integer, integer) TO authenticated, service_role; diff --git a/tests/pg/document-surfaces-unification.pg.test.ts b/tests/pg/document-surfaces-unification.pg.test.ts index bbcd1219..5f010f73 100644 --- a/tests/pg/document-surfaces-unification.pg.test.ts +++ b/tests/pg/document-surfaces-unification.pg.test.ts @@ -4,6 +4,7 @@ import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories' import { getPool } from './setup' import { seedCompany, + insertCashAccount, insertDraftJournalEntry, insertPostedJournalEntry, insertBalancedLines, @@ -697,3 +698,47 @@ describe('floating supplier-invoice document backfill (migration 20260727180000) expect(await anchorOf(doc)).toBeNull() }) }) + +describe('transactions_without_documents: bank account on each row (A4)', () => { + it('returns cash_account_id and the cash account ledger, null when unbackfilled', async () => { + const s = await seedCompany() + const cashAccountId = await insertCashAccount({ companyId: s.companyId, ledgerAccount: '1940' }) + const mkJe = (n: number) => + insertPostedJournalEntry({ + userId: s.userId, + companyId: s.companyId, + fiscalPeriodId: s.fiscalPeriodId, + voucherNumber: n, + entryDate: `2026-06-${String(n).padStart(2, '0')}`, + description: `bank ${n}`, + sourceType: 'bank_transaction', + lines: [ + { accountNumber: '1940', debitAmount: 100, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 100 }, + ], + }) + const jeWithAccount = await mkJe(1) + const jeWithoutAccount = await mkJe(2) + const txWith = await insertTransaction({ + companyId: s.companyId, + userId: s.userId, + journalEntryId: jeWithAccount, + cashAccountId, + date: '2026-06-01', + }) + const txWithout = await insertTransaction({ + companyId: s.companyId, + userId: s.userId, + journalEntryId: jeWithoutAccount, + cashAccountId: null, + date: '2026-06-02', + }) + + const { rows } = await getPool().query<{ + r: { ok: boolean; transactions: Array<{ id: string; cash_account_id: string | null; cash_account_ledger: string | null }> } + }>(`SELECT public.transactions_without_documents($1, NULL, 100, 0) AS r`, [s.companyId]) + const byId = new Map(rows[0].r.transactions.map((t) => [t.id, t])) + expect(byId.get(txWith)).toMatchObject({ cash_account_id: cashAccountId, cash_account_ledger: '1940' }) + expect(byId.get(txWithout)).toMatchObject({ cash_account_id: null, cash_account_ledger: null }) + }) +})