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) {
|
||||
|
||||
Reference in New Issue
Block a user