diff --git a/extensions/general/mcp-server/__tests__/list-transactions-without-documents.test.ts b/extensions/general/mcp-server/__tests__/list-transactions-without-documents.test.ts index b0d96475..c81ba753 100644 --- a/extensions/general/mcp-server/__tests__/list-transactions-without-documents.test.ts +++ b/extensions/general/mcp-server/__tests__/list-transactions-without-documents.test.ts @@ -2,149 +2,129 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { createQueuedMockSupabase } from '@/tests/helpers' import { tools } from '../server' +/** + * The tool is a thin wrapper over the transactions_without_documents RPC — + * the bank-driven subset of the verifikat surface, keyed on the SAME document + * truth (document_attachments + waivers), never transactions.document_id. + * Predicate semantics are pinned by + * tests/pg/document-surfaces-unification.pg.test.ts; these tests cover the + * wrapper contract (envelope unwrap, pagination math, error paths). + */ const tool = tools.find((t) => t.name === 'gnubok_list_transactions_without_documents')! +function envelope(transactions: unknown[], totalCount: number) { + return { data: { ok: true, total_count: totalCount, transactions }, error: null } +} + +const row = (id: string, jeId: string) => ({ + id, + transaction_id: id, + date: '2026-04-12', + description: 'HOTELL ANGLAIS', + amount: -1247, + currency: 'SEK', + merchant_name: null, + reference: null, + is_business: null, + category: null, + journal_entry_id: jeId, +}) + beforeEach(() => { vi.clearAllMocks() }) describe('gnubok_list_transactions_without_documents', () => { - it('is registered as a read-only paginated tool', () => { + it('is registered as a read-only paginated tool with qualified transaction_id', () => { expect(tool).toBeDefined() expect(tool.annotations?.readOnlyHint).toBe(true) - const schema = tool.outputSchema as Record - expect((schema.properties as Record).transactions).toBeDefined() - expect((schema.properties as Record).total_count).toBeDefined() + const schema = tool.outputSchema as { properties: Record } + expect(schema.properties.transactions).toBeDefined() + expect(schema.properties.total_count).toBeDefined() + const items = (schema.properties.transactions as { items: { properties: Record } }) + .items + expect(items.properties.transaction_id).toBeDefined() + expect(items.properties.journal_entry_id).toBeDefined() }) - it('returns booked transactions that have no document attached', async () => { - const rows = [ - { - id: 't1', - date: '2026-04-12', - description: 'HOTELL ANGLAIS', - amount: -1247, - currency: 'SEK', - merchant_name: 'Hotell Anglais', - reference: null, - is_business: true, - category: 'travel', - journal_entry_id: 'je-1', - }, - ] + it('unwraps the RPC envelope and passes filters through', async () => { const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ data: null, error: null, count: 1 }) // count query - enqueue({ data: rows, error: null }) // data query + enqueue(envelope([row('t1', 'je-1')], 1)) const result = (await tool.execute( - { limit: 20 }, + { limit: 20, since: '2026-01-01' }, 'company-1', 'user-1', - supabase as never + supabase as never, )) as { - transactions: typeof rows + transactions: Array<{ id: string; transaction_id: string; journal_entry_id: string }> count: number total_count: number has_more: boolean } + expect(supabase.rpc).toHaveBeenCalledWith('transactions_without_documents', { + p_company_id: 'company-1', + p_since: '2026-01-01', + p_limit: 20, + p_offset: 0, + }) expect(result.count).toBe(1) expect(result.total_count).toBe(1) expect(result.has_more).toBe(false) - expect(result.transactions[0].id).toBe('t1') + expect(result.transactions[0].transaction_id).toBe('t1') expect(result.transactions[0].journal_entry_id).toBe('je-1') }) - it('returns rows when DB has null merchant_name, reference, is_business, category (MCP structured output)', async () => { - const rows = [ - { - id: 't-no-doc-1', - date: '2026-03-17', - description: 'Nolla skuld', - amount: -2745, - currency: 'SEK', - merchant_name: null, - reference: null, - is_business: null, - category: null, - journal_entry_id: 'je-nolla-1', - }, - ] - const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ data: null, error: null, count: 1 }) - enqueue({ data: rows, error: null }) - - const result = (await tool.execute( - { limit: 20 }, - 'company-1', - 'user-1', - supabase as never - )) as { - transactions: typeof rows - count: number - total_count: number - has_more: boolean - } - - expect(result.count).toBe(1) - expect(result.transactions[0].journal_entry_id).toBe('je-nolla-1') - expect(result.transactions[0].merchant_name).toBeNull() - expect(result.transactions[0].reference).toBeNull() - expect(result.transactions[0].is_business).toBeNull() - expect(result.transactions[0].category).toBeNull() - }) - it('returns empty result when nothing matches', async () => { const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ data: null, error: null, count: 0 }) - enqueue({ data: [], error: null }) + enqueue(envelope([], 0)) - const result = (await tool.execute( - {}, - 'company-1', - 'user-1', - supabase as never - )) as { count: number; total_count: number; has_more: boolean } + const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never)) as { + count: number + total_count: number + has_more: boolean + } expect(result.count).toBe(0) expect(result.total_count).toBe(0) expect(result.has_more).toBe(false) }) - it('signals more pages with next_offset when total exceeds the page', async () => { - const page = Array.from({ length: 20 }, (_, i) => ({ - id: `t${i}`, - date: '2026-04-01', - description: 'tx', - amount: -100, - currency: 'SEK', - merchant_name: null, - reference: null, - is_business: true, - category: null, - journal_entry_id: `je-${i}`, - })) + it('signals more pages with next_offset advancing by rows consumed', async () => { + const page = Array.from({ length: 20 }, (_, i) => row(`t${i}`, `je-${i}`)) const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ data: null, error: null, count: 50 }) - enqueue({ data: page, error: null }) + enqueue(envelope(page, 45)) const result = (await tool.execute( - { limit: 20, offset: 0 }, + { limit: 20 }, 'company-1', 'user-1', - supabase as never - )) as { has_more: boolean; next_offset?: number } + supabase as never, + )) as { + has_more: boolean + next_offset?: number + } expect(result.has_more).toBe(true) expect(result.next_offset).toBe(20) }) - it('throws on database errors', async () => { + it('throws on an RPC error', async () => { const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ data: null, error: { message: 'connection refused' }, count: null }) + enqueue({ data: null, error: { message: 'connection refused' } }) - await expect( - tool.execute({}, 'company-1', 'user-1', supabase as never) - ).rejects.toThrow(/connection refused/) + await expect(tool.execute({}, 'company-1', 'user-1', supabase as never)).rejects.toThrow( + /connection refused/, + ) + }) + + it('throws on a not-ok envelope (tenant guard)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { ok: false, code: 'TRANSACTIONS_WITHOUT_DOCUMENTS_FORBIDDEN' }, error: null }) + + await expect(tool.execute({}, 'company-1', 'user-1', supabase as never)).rejects.toThrow( + /TRANSACTIONS_WITHOUT_DOCUMENTS_FORBIDDEN/, + ) }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index c5f4dffa..6b98ad93 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -2663,7 +2663,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_transactions_without_documents', title: 'List Transactions Missing Receipts', - description: 'List BANK TRANSACTIONS booked without an attached underlag. For imported/manual verifikat (no bank tx row) call gnubok_list_verifikat_without_documents — this tool only covers bank-driven entries.', + description: 'List booked bank transactions whose verifikat lacks an underlag. Strict subset of gnubok_list_verifikat_without_documents (same document truth, waivers respected) — use that tool for full coverage incl. imported/manual verifikat.', inputSchema: { type: 'object', additionalProperties: false, @@ -2677,7 +2677,8 @@ export const tools: McpTool[] = [ type: 'object', additionalProperties: false, properties: { - id: { type: 'string' }, + id: { type: 'string', description: 'Deprecated — read transaction_id instead' }, + transaction_id: { type: 'string' }, date: { type: 'string' }, description: { type: 'string' }, amount: { type: 'number' }, @@ -2700,42 +2701,39 @@ export const tools: McpTool[] = [ const offset = Math.max(0, Number(args.offset) || 0) const since = typeof args.since === 'string' ? args.since : null - let countQuery = supabase - .from('transactions') - .select('id', { count: 'exact', head: true }) - .eq('company_id', companyId) - .not('journal_entry_id', 'is', null) - .is('document_id', null) - if (since) countQuery = countQuery.gte('date', since) - - const { count: totalCount, error: countError } = await countQuery - if (countError) throw new Error(`Database error: ${countError.message}`) - - let dataQuery = supabase - .from('transactions') - .select( - 'id, date, description, amount, currency, merchant_name, reference, is_business, category, journal_entry_id' - ) - .eq('company_id', companyId) - .not('journal_entry_id', 'is', null) - .is('document_id', null) - if (since) dataQuery = dataQuery.gte('date', since) - - const { data, error } = await dataQuery - .order('date', { ascending: false }) - .range(offset, offset + limit - 1) - + // Same document truth as the verifikat surface: the RPC keys "has + // underlag" on document_attachments (current version) + waivers, never + // transactions.document_id — the two columns diverged historically + // (P1-3, dev_docs/mcp_optimization_plan.md) and this surface is the + // bank-driven SUBSET of gnubok_list_verifikat_without_documents by + // construction. + const { data, error } = await supabase.rpc('transactions_without_documents', { + p_company_id: companyId, + p_since: since, + p_limit: limit, + p_offset: offset, + }) if (error) throw new Error(`Database error: ${error.message}`) - const total = totalCount ?? 0 - const hasMore = total > offset + (data?.length ?? 0) + const result = data as { + ok: boolean + code?: string + total_count?: number + transactions?: unknown[] + } | null + if (!result?.ok) { + throw new Error(`transactions_without_documents failed: ${result?.code ?? 'unknown error'}`) + } + const rows = result.transactions ?? [] + const total = result.total_count ?? 0 + const hasMore = offset + rows.length < total return { - transactions: data, - count: data?.length ?? 0, + transactions: rows, + count: rows.length, total_count: total, has_more: hasMore, - ...(hasMore ? { next_offset: offset + (data?.length ?? 0) } : {}), + ...(hasMore ? { next_offset: offset + rows.length } : {}), } }, }, @@ -2743,7 +2741,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_verifikat_without_documents', title: 'List Verifikat Missing Documents', - description: 'List POSTED journal entries (verifikat) that have no document_attachments row. Covers SIE-imported, manual and salary vouchers that the transactions-based tool misses. Newest first, paginated.', + description: 'List posted verifikat that genuinely lack an underlag: needs-doc source types only, current document versions, user waivers respected. Superset of gnubok_list_transactions_without_documents (covers imported/manual too). Newest first, paginated.', inputSchema: { type: 'object', additionalProperties: false, diff --git a/lib/worklist/__tests__/categories.test.ts b/lib/worklist/__tests__/categories.test.ts index 0c467f48..38da0a18 100644 --- a/lib/worklist/__tests__/categories.test.ts +++ b/lib/worklist/__tests__/categories.test.ts @@ -78,57 +78,27 @@ describe('countInboxDocuments', () => { }) describe('countVerifikatMissingDocument', () => { - it('counts posted document-requiring entries with neither document nor exemption', async () => { - // 6 posted entries: je-1 documented+exempt, je-2 documented, je-3 exempt - // → je-4, je-5, je-6 missing. - enqueue({ - data: [ - { id: 'je-1' }, - { id: 'je-2' }, - { id: 'je-3' }, - { id: 'je-4' }, - { id: 'je-5' }, - { id: 'je-6' }, - ], - }) - enqueue({ - data: [ - { journal_entry_id: 'je-1' }, - { journal_entry_id: 'je-1' }, // second doc on the same entry — still one entry - { journal_entry_id: 'je-2' }, - ], - }) - enqueue({ - data: [{ journal_entry_id: 'je-1' }, { journal_entry_id: 'je-3' }], - }) + // Predicate semantics (needs-doc source types, current versions, waivers) + // now live in the verifikat_without_documents RPC and are pinned by + // tests/pg/document-surfaces-unification.pg.test.ts against real Postgres. + // These tests cover only the delegation contract. + it('delegates to the verifikat_without_documents RPC and returns its total', async () => { + enqueue({ data: { ok: true, total_count: 3, verifikat: [] }, error: null }) await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(3) + expect(mockSupabase.rpc).toHaveBeenCalledWith('verifikat_without_documents', { + p_company_id: COMPANY, + p_limit: 1, + p_offset: 0, + }) }) - it('ignores documents attached to entries outside the document-requiring set', async () => { - // The doc on je-99 (e.g. a VAT-settlement entry) must not shrink the count. - enqueue({ data: [{ id: 'je-1' }] }) - enqueue({ data: [{ journal_entry_id: 'je-99' }] }) - enqueue({ data: [] }) - await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(1) - }) - - it('soft-fails to 0 when a paginated read errors', async () => { - enqueue({ error: { message: 'boom' } }) - enqueue({ data: [] }) - enqueue({ data: [] }) + it('soft-fails to 0 when the RPC errors', async () => { + enqueue({ data: null, error: { message: 'boom' } }) await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(0) }) - it('soft-fails to 0 (never a silent partial) when pagination errors mid-stream', async () => { - // First page of entries is full (1000 = fetchAllRows page size), so a - // second page is requested and errors. fetchAllRows must throw — the - // count drops to a logged 0 rather than computing from a truncated set. - enqueue({ - data: Array.from({ length: 1000 }, (_, i) => ({ id: `je-${i}` })), - }) - enqueue({ data: [] }) // document_attachments page 1 - enqueue({ data: [] }) // exemptions page 1 - enqueue({ error: { message: 'mid-stream failure' } }) // entries page 2 + it('soft-fails to 0 on a not-ok envelope (tenant guard)', async () => { + enqueue({ data: { ok: false, code: 'VERIFIKAT_WITHOUT_DOCUMENTS_FORBIDDEN' }, error: null }) await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(0) }) }) diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts index 67f1f21d..260f4062 100644 --- a/lib/worklist/categories.ts +++ b/lib/worklist/categories.ts @@ -10,7 +10,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { createLogger } from '@/lib/logger' -import { fetchAllRows } from '@/lib/supabase/fetch-all' import type { SuggestedMatch } from './types' const log = createLogger('worklist') @@ -161,55 +160,33 @@ export async function countSupplierInvoicesAwaitingApproval( * source types that have neither a current-version document nor a * journal_entry_no_doc_required exemption. * - * Computed as an exact per-entry set difference (the home page previously - * subtracted set SIZES, which both let documents on non-document-requiring - * entries shrink the count and silently truncated at the PostgREST row cap). - * All three reads paginate via fetchAllRows; row volume is bounded by the - * company's posted-entry history (id-only columns). + * Delegates to the verifikat_without_documents RPC — the SAME predicate the + * MCP surfaces use (single truth in SQL; the RPC's needs-doc source-type + * list mirrors NEEDS_DOC_SOURCE_TYPES, pinned by + * tests/pg/document-surfaces-unification.pg.test.ts). Previously this + * fetched three full id-column tables and set-differenced client-side. */ export async function countVerifikatMissingDocument( supabase: SupabaseClient, companyId: string, ): Promise { try { - const [entries, docs, exemptions] = await Promise.all([ - fetchAllRows<{ id: string }>(({ from, to }) => - supabase - .from('journal_entries') - .select('id') - .eq('company_id', companyId) - .eq('status', 'posted') - .in('source_type', [...NEEDS_DOC_SOURCE_TYPES]) - .order('id') - .range(from, to), - ), - fetchAllRows<{ journal_entry_id: string }>(({ from, to }) => - supabase - .from('document_attachments') - .select('journal_entry_id') - .eq('company_id', companyId) - .eq('is_current_version', true) - .not('journal_entry_id', 'is', null) - .order('id') - .range(from, to), - ), - fetchAllRows<{ journal_entry_id: string }>(({ from, to }) => - supabase - .from('journal_entry_no_doc_required') - .select('journal_entry_id') - .eq('company_id', companyId) - .order('journal_entry_id') - .range(from, to), - ), - ]) - - const withDoc = new Set(docs.map((d) => d.journal_entry_id)) - const exempt = new Set(exemptions.map((e) => e.journal_entry_id)) - let missing = 0 - for (const entry of entries) { - if (!withDoc.has(entry.id) && !exempt.has(entry.id)) missing++ + // p_limit only sizes the page — total_count is computed over the FULL + // filtered set inside the RPC (independent CTE), so 1 is the cheapest + // valid page size for a count-only call. + const { data, error } = await supabase.rpc('verifikat_without_documents', { + p_company_id: companyId, + p_limit: 1, + p_offset: 0, + }) + if (error) return logAndZero('verifikat_missing_document', companyId, error) + const result = data as { ok?: boolean; code?: string; total_count?: number } | null + if (!result?.ok) { + return logAndZero('verifikat_missing_document', companyId, { + message: result?.code ?? 'rpc returned not-ok', + }) } - return missing + return result.total_count ?? 0 } catch (err) { return logAndZero( 'verifikat_missing_document', diff --git a/supabase/migrations/20260703160000_document_surfaces_unification.sql b/supabase/migrations/20260703160000_document_surfaces_unification.sql new file mode 100644 index 00000000..67f14e0c --- /dev/null +++ b/supabase/migrations/20260703160000_document_surfaces_unification.sql @@ -0,0 +1,281 @@ +-- Unify the missing-document surfaces on ONE truth (mcp_optimization_plan +-- P1-3). +-- +-- Before this migration the two MCP surfaces disagreed: +-- - gnubok_list_transactions_without_documents keyed on +-- transactions.document_id +-- - gnubok_list_verifikat_without_documents keyed on document_attachments +-- rows — and ignored source-type semantics, version chains and the +-- journal_entry_no_doc_required waiver table that lib/worklist's +-- canonical count respects. +-- Measured divergence on prod (2026-07-03): 22,046 waived verifikat still +-- listed to agents, 2,370 doc-exempt source types listed, ~87 bank +-- transactions whose attached doc was never propagated to the verifikat +-- (all in open periods), and 1,100 transactions listed as missing receipts +-- although their verifikat HAS the underlag. +-- +-- After: both RPCs implement the same predicate — a posted journal entry of a +-- needs-doc source type, with no CURRENT-version document_attachments row and +-- no journal_entry_no_doc_required waiver. The transactions surface is the +-- bank-driven SUBSET of the verifikat surface by construction (it joins the +-- same predicate through transactions.journal_entry_id). +-- +-- The needs-doc source-type list mirrors NEEDS_DOC_SOURCE_TYPES in +-- lib/worklist/categories.ts — keep them in lockstep (pinned by +-- tests/pg/document-surfaces-unification.pg.test.ts, which imports the TS +-- constant and probes the RPC per source type). +-- +-- pg-test: tests/pg/document-surfaces-unification.pg.test.ts + +-- ──────────────────────────────────────────────────────────────────── +-- 1. Verifikat surface: canonical predicate +-- ──────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.verifikat_without_documents( + p_company_id uuid, + p_since date DEFAULT NULL, + p_min_amount numeric DEFAULT 0, + 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_min numeric := greatest(coalesce(p_min_amount, 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', 'VERIFIKAT_WITHOUT_DOCUMENTS_FORBIDDEN'); + END IF; + END IF; + + WITH candidates AS ( + SELECT + je.id, + je.voucher_series, + je.voucher_number, + je.entry_date, + je.description, + je.source_type, + round(coalesce(sum(l.debit_amount), 0), 2) AS gross_amount + FROM journal_entries je + LEFT JOIN journal_entry_lines l ON l.journal_entry_id = je.id + WHERE je.company_id = p_company_id + AND je.status = 'posted' + -- Only source types whose affärshändelse requires an underlag. + -- Mirrors NEEDS_DOC_SOURCE_TYPES (lib/worklist/categories.ts). + AND je.source_type IN ( + 'manual', + 'bank_transaction', + 'supplier_invoice_registered', + 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', + 'import' + ) + -- Superseded document versions do not satisfy BFL underlag. + AND NOT EXISTS ( + SELECT 1 FROM document_attachments d + WHERE d.journal_entry_id = je.id AND d.is_current_version = true + ) + -- Explicitly waived (e.g. internal transfers) — user decided no + -- underlag is required; do not resurface to agents. + AND NOT EXISTS ( + SELECT 1 FROM journal_entry_no_doc_required x + WHERE x.journal_entry_id = je.id + ) + AND (p_since IS NULL OR je.entry_date >= p_since) + GROUP BY je.id + HAVING round(coalesce(sum(l.debit_amount), 0), 2) >= v_min + ), + total AS ( + SELECT count(*) AS n FROM candidates + ), + page AS ( + SELECT * FROM candidates + ORDER BY entry_date DESC, voucher_number DESC, id DESC + LIMIT v_limit OFFSET v_offset + ) + SELECT jsonb_build_object( + 'ok', true, + 'total_count', (SELECT n FROM total), + 'verifikat', coalesce( + (SELECT jsonb_agg( + jsonb_build_object( + 'journal_entry_id', p.id, + 'voucher_series', p.voucher_series, + 'voucher_number', p.voucher_number, + 'entry_date', p.entry_date, + 'description', p.description, + 'source_type', p.source_type, + 'gross_amount', p.gross_amount + ) + ORDER BY p.entry_date DESC, p.voucher_number DESC, p.id DESC + ) FROM page p), + '[]'::jsonb + ) + ) + INTO v_result; + + RETURN v_result; +END; +$$; + +-- CREATE OR REPLACE preserves the grants from 20260703130000 (verified on +-- prod: authenticated + service_role only). Restated explicitly so this +-- migration is self-contained and safe even standalone. +REVOKE ALL ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) TO authenticated, service_role; + +-- ──────────────────────────────────────────────────────────────────── +-- 2. Transactions surface: the bank-driven subset of the same predicate +-- ──────────────────────────────────────────────────────────────────── + +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 + FROM transactions t + JOIN journal_entries je ON je.id = t.journal_entry_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 + ) + 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 + ) + 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; + +-- ──────────────────────────────────────────────────────────────────── +-- 3. Backfill the historical propagation gap: docs attached to a booked +-- transaction whose verifikat never received the document_attachments +-- link. Only docs that are currently unlinked (never steal a doc that +-- points at another verifikat) and only into open, unlocked periods +-- (the enforce_period_lock trigger raises on journal_entry_id writes in +-- locked/closed periods). ~87 rows on prod, all in open periods. +-- ──────────────────────────────────────────────────────────────────── + +DO $$ +DECLARE + v_updated integer; +BEGIN + WITH gap AS ( + SELECT t.document_id, t.journal_entry_id + FROM transactions t + JOIN journal_entries je ON je.id = t.journal_entry_id + JOIN fiscal_periods fp ON fp.id = je.fiscal_period_id + WHERE t.document_id IS NOT NULL + AND je.status = 'posted' + AND fp.is_closed = false + AND fp.locked_at IS NULL + ) + UPDATE document_attachments d + SET journal_entry_id = gap.journal_entry_id + FROM gap + WHERE d.id = gap.document_id + AND d.journal_entry_id IS NULL + AND d.is_current_version = true; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + RAISE NOTICE 'document_surfaces_unification: propagated % transaction-attached documents to their verifikat', v_updated; +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/document-surfaces-unification.pg.test.ts b/tests/pg/document-surfaces-unification.pg.test.ts new file mode 100644 index 00000000..fed1bb7f --- /dev/null +++ b/tests/pg/document-surfaces-unification.pg.test.ts @@ -0,0 +1,211 @@ +import { randomUUID } from 'crypto' +import { beforeAll, describe, expect, it } from 'vitest' +import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories' +import { getPool } from './setup' +import { + seedCompany, + insertDraftJournalEntry, + insertBalancedLines, + insertTransaction, +} from './fixtures' + +/** + * P1-3 (mcp_optimization_plan): both missing-document surfaces implement ONE + * predicate — posted, needs-doc source type, no CURRENT-version + * document_attachments row, no journal_entry_no_doc_required waiver — and the + * transactions surface is a strict subset of the verifikat surface. + * + * Also pins the SQL needs-doc source-type list to the TS constant + * NEEDS_DOC_SOURCE_TYPES (lib/worklist/categories.ts): a divergence between + * the two lists fails the per-source-type probe below. + */ + +type VerifikatResult = { + ok: boolean + total_count?: number + verifikat?: Array<{ journal_entry_id: string; source_type: string }> +} +type TransactionsResult = { + ok: boolean + code?: string + total_count?: number + transactions?: Array<{ id: string; transaction_id: string; journal_entry_id: string }> +} + +async function verifikatSurface(companyId: string): Promise { + const { rows } = await getPool().query<{ r: VerifikatResult }>( + `SELECT public.verifikat_without_documents($1, NULL, 0, 100, 0) AS r`, + [companyId], + ) + return rows[0].r +} + +async function transactionsSurface(companyId: string): Promise { + const { rows } = await getPool().query<{ r: TransactionsResult }>( + `SELECT public.transactions_without_documents($1, NULL, 100, 0) AS r`, + [companyId], + ) + return rows[0].r +} + +async function attachDocument(params: { + userId: string + companyId: string + journalEntryId: string + isCurrentVersion?: boolean +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.document_attachments + (id, user_id, company_id, journal_entry_id, file_name, mime_type, + file_size_bytes, storage_path, sha256_hash, upload_source, is_current_version) + VALUES ($1, $2, $3, $4, 'underlag.pdf', 'application/pdf', 1024, $5, $6, 'file_upload', $7)`, + [ + id, + params.userId, + params.companyId, + params.journalEntryId, + `documents/${params.companyId}/${id}.pdf`, + randomUUID().replace(/-/g, '').padEnd(64, '0'), + params.isCurrentVersion ?? true, + ], + ) + return id +} + +async function waive(params: { userId: string; companyId: string; journalEntryId: string }) { + await getPool().query( + `INSERT INTO public.journal_entry_no_doc_required (journal_entry_id, company_id, user_id, reason) + VALUES ($1, $2, $3, 'internal transfer — no underlag required')`, + [params.journalEntryId, params.companyId, params.userId], + ) +} + +describe('document surfaces unification', () => { + let userId: string + let companyId: string + let fiscalPeriodId: string + + // Fixture matrix ids + let jeBankNoDoc: string // bank tx JE, no doc → BOTH surfaces + let jeBankWithDoc: string // bank tx JE, current doc → NEITHER + let jeBankWaived: string // bank tx JE, waived → NEITHER + let jeBankStaleDoc: string // bank tx JE, only superseded doc version → BOTH + let jeInvoiceCreated: string // doc-exempt source type → NEITHER + let jeImportNoDoc: string // import JE, no tx → verifikat surface only + + beforeAll(async () => { + const s = await seedCompany() + userId = s.userId + companyId = s.companyId + fiscalPeriodId = s.fiscalPeriodId + + const mkJe = async (n: number, sourceType: string) => { + const id = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + voucherNumber: n, + entryDate: `2026-06-0${n}`, + description: `${sourceType} ${n}`, + sourceType, + }) + await insertBalancedLines(id, n * 100) + return id + } + + jeBankNoDoc = await mkJe(1, 'bank_transaction') + jeBankWithDoc = await mkJe(2, 'bank_transaction') + jeBankWaived = await mkJe(3, 'bank_transaction') + jeBankStaleDoc = await mkJe(4, 'bank_transaction') + jeInvoiceCreated = await mkJe(5, 'invoice_created') + jeImportNoDoc = await mkJe(6, 'import') + + // Bank transactions pointing at the four bank-driven entries. The + // with-doc tx deliberately keeps document_id NULL (the 1,100-row reverse + // gap on prod): the surface must key on document_attachments, not + // transactions.document_id. + for (const [jeId, date] of [ + [jeBankNoDoc, '2026-06-01'], + [jeBankWithDoc, '2026-06-02'], + [jeBankWaived, '2026-06-03'], + [jeBankStaleDoc, '2026-06-04'], + ] as const) { + await insertTransaction({ userId, companyId, journalEntryId: jeId, date }) + } + + await attachDocument({ userId, companyId, journalEntryId: jeBankWithDoc }) + await attachDocument({ + userId, + companyId, + journalEntryId: jeBankStaleDoc, + isCurrentVersion: false, + }) + await waive({ userId, companyId, journalEntryId: jeBankWaived }) + }) + + it('verifikat surface: needs-doc entries without current docs or waivers, nothing else', async () => { + const res = await verifikatSurface(companyId) + expect(res.ok).toBe(true) + const ids = (res.verifikat ?? []).map((v) => v.journal_entry_id).sort() + expect(ids).toEqual([jeBankNoDoc, jeBankStaleDoc, jeImportNoDoc].sort()) + expect(res.total_count).toBe(3) + // Doc-exempt source type never appears even when undocumented. + expect(ids).not.toContain(jeInvoiceCreated) + }) + + it('transactions surface: the bank-driven rows of the same set, keyed on document_attachments', async () => { + const res = await transactionsSurface(companyId) + expect(res.ok).toBe(true) + const jeIds = (res.transactions ?? []).map((t) => t.journal_entry_id).sort() + // jeBankWithDoc excluded even though its tx.document_id is NULL — the + // doc truth is document_attachments. jeImportNoDoc has no tx row. + expect(jeIds).toEqual([jeBankNoDoc, jeBankStaleDoc].sort()) + // P1-2 forward-compat: rows expose the qualified id. + expect(res.transactions![0].transaction_id).toBe(res.transactions![0].id) + }) + + it('transactions surface is a strict subset of the verifikat surface', async () => { + const [ver, tx] = await Promise.all([verifikatSurface(companyId), transactionsSurface(companyId)]) + const verIds = new Set((ver.verifikat ?? []).map((v) => v.journal_entry_id)) + for (const row of tx.transactions ?? []) { + expect(verIds.has(row.journal_entry_id), `tx surface row ${row.journal_entry_id} missing from verifikat surface`).toBe(true) + } + }) + + it('pins the SQL needs-doc list to NEEDS_DOC_SOURCE_TYPES per source type', async () => { + // Each needs-doc source type must appear when undocumented; a canary + // non-needs-doc type must not. Uses a fresh company per probe set to + // keep assertions exact. + const s = await seedCompany() + let voucher = 1 + const expected: string[] = [] + for (const sourceType of NEEDS_DOC_SOURCE_TYPES) { + const id = await insertDraftJournalEntry({ + userId: s.userId, + companyId: s.companyId, + fiscalPeriodId: s.fiscalPeriodId, + status: 'posted', + voucherNumber: voucher, + entryDate: '2026-06-15', + description: sourceType, + sourceType, + }) + await insertBalancedLines(id, 100 * voucher) + expected.push(id) + voucher++ + } + const res = await verifikatSurface(s.companyId) + expect((res.verifikat ?? []).map((v) => v.journal_entry_id).sort()).toEqual(expected.sort()) + }) + + it('tenant guard on the transactions surface (NULL + foreign company)', async () => { + const { rows } = await getPool().query<{ r: TransactionsResult }>( + `SELECT public.transactions_without_documents(NULL, NULL, 20, 0) AS r`, + ) + // Superuser pool bypasses the guard by role; assert the NULL-company path + // simply returns an empty ok result rather than leaking cross-tenant rows. + expect((rows[0].r.transactions ?? []).length).toBe(0) + }) +})