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
@@ -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',
|
||||
|
||||
@@ -48,13 +48,17 @@ function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>
|
||||
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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user