From 5e9aa52dea9698530c24d0477d25321505006e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Flod=C3=A9n?= Date: Mon, 29 Jun 2026 22:14:07 +0200 Subject: [PATCH] feat(mcp): add gnubok_link_document_to_voucher tool (#804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Links an uploaded document directly to a posted verifikation (journal entry) via the staged-operation pattern. Covers imported/manual vouchers that have no bank-transaction row — the gap left by gnubok_attach_document_to_transaction. - New MCP tool gnubok_link_document_to_voucher (bookkeeping:write scope) - New pending-operation type link_document_to_voucher (medium risk) - Commit executor with WORM guard: refuses to re-link a doc already pinned to a different posted JE (BFL 5 kap 6 §); allows overwriting a draft-JE link; maps period-lock throws to 409 - 5 executor unit tests covering 404, WORM 409, draft-allow, happy path, and period-lock Signed-off-by: Jonas Flodén --- extensions/general/mcp-server/server.ts | 89 +++++++++++++++++ lib/auth/api-keys.ts | 1 + .../__tests__/executors.test.ts | 95 +++++++++++++++++++ lib/pending-operations/commit.ts | 70 ++++++++++++++ lib/pending-operations/risk-tiers.ts | 4 + types/index.ts | 3 + 6 files changed, 262 insertions(+) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 61ee43e4..a7c746b9 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -6849,6 +6849,95 @@ export const tools: McpTool[] = [ ) }, }, + { + name: 'gnubok_link_document_to_voucher', + title: 'Link Document to Voucher', + description: 'Stage linking a document to a posted verifikation. Use for imported/manual vouchers with no bank-tx row. Call gnubok_list_verifikat_without_documents first to find targets. Stages for approval.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + document_id: { type: 'string', description: 'UUID of the document_attachments row' }, + journal_entry_id: { type: 'string', description: 'UUID of the target journal entry (verifikation)' }, + journal_entry_line_id: { type: 'string', description: 'Optional UUID to pin the doc to a specific debit/credit line' }, + idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries' }, + dry_run: { type: 'boolean', description: 'Preview without staging' }, + }, + required: ['document_id', 'journal_entry_id'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const documentId = args.document_id as string + const journalEntryId = args.journal_entry_id as string + const journalEntryLineId = typeof args.journal_entry_line_id === 'string' ? args.journal_entry_line_id : undefined + if (!documentId) throw new Error('document_id is required') + if (!journalEntryId) throw new Error('journal_entry_id is required') + + const [docRes, jeRes] = await Promise.all([ + supabase + .from('document_attachments') + .select('id, file_name, mime_type, journal_entry_id') + .eq('id', documentId) + .eq('company_id', companyId) + .maybeSingle(), + supabase + .from('journal_entries') + .select('id, entry_date, description, voucher_series, voucher_number, status') + .eq('id', journalEntryId) + .eq('company_id', companyId) + .maybeSingle(), + ]) + + if (docRes.error || !docRes.data) throw new Error('Document not found') + if (jeRes.error || !jeRes.data) throw new Error('Journal entry not found') + + const doc = docRes.data as { + id: string; file_name: string; mime_type: string; journal_entry_id: string | null + } + const je = jeRes.data as { + id: string; entry_date: string; description: string + voucher_series: string | null; voucher_number: number | null; status: string + } + + const voucherLabel = je.voucher_series && je.voucher_number + ? `${je.voucher_series}${je.voucher_number}` + : je.id.slice(0, 8) + + const currentlyLinkedToSameJe = doc.journal_entry_id === journalEntryId + const currentlyLinkedToOther = !!doc.journal_entry_id && !currentlyLinkedToSameJe + + return stagePendingOperation( + supabase, companyId, userId, 'link_document_to_voucher', + `Koppla bilaga: ${doc.file_name} → verifikat ${voucherLabel}`, + { document_id: documentId, journal_entry_id: journalEntryId, journal_entry_line_id: journalEntryLineId ?? null }, + { + document_file_name: doc.file_name, + document_mime_type: doc.mime_type, + document_already_linked: currentlyLinkedToSameJe, + document_currently_linked_to_other: currentlyLinkedToOther, + document_current_journal_entry_id: doc.journal_entry_id ?? null, + voucher_label: voucherLabel, + voucher_date: je.entry_date, + voucher_description: je.description, + voucher_status: je.status, + journal_entry_line_id: journalEntryLineId ?? null, + }, + actor, + undefined, + { + idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, + dryRun: args.dry_run === true, + dateForPeriodCheck: je.entry_date, + } + ) + }, + }, // ── Payroll (Lönehantering) ────────────────────────────────── { name: 'gnubok_list_employees', diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 7fc2ae25..7c4910e4 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -210,6 +210,7 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_list_unmatched_documents: 'transactions:read', gnubok_get_document_content: 'transactions:read', gnubok_attach_document_to_transaction: 'transactions:write', + gnubok_link_document_to_voucher: 'bookkeeping:write', // Payroll gnubok_list_employees: 'payroll:read', gnubok_get_salary_run: 'payroll:read', diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index d9512178..289f8f68 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -766,3 +766,98 @@ describe('commitPendingOperation: attach_document_to_transaction', () => { expect(result.http_status).toBe(409) }) }) + +// ─── link_document_to_voucher ───────────────────────────────── + +describe('commitPendingOperation: link_document_to_voucher', () => { + const baseOp: Partial = { + operation_type: 'link_document_to_voucher', + params: { document_id: 'doc-1', journal_entry_id: 'je-1' }, + } + + it('auto-rejects 404 when document is not in the company', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // doc fetch — not found + enqueue({ data: null, error: null }) // dispatcher reject update + + const result = await commitPendingOperation( + supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), + ) + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(404) + }) + + it('auto-rejects 409 when doc is already linked to a different posted JE (WORM guard)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch + enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status + enqueue({ data: null, error: null }) // dispatcher reject update + + const result = await commitPendingOperation( + supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), + ) + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + }) + + it('allows re-linking when existing linked JE is not yet posted (draft)', async () => { + // A doc linked to a draft (uncommitted) JE can be moved — only posted + // verifikationer trigger the WORM guard. + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-DRAFT' }, error: null }) // doc fetch + enqueue({ data: { status: 'draft' }, error: null }) // WORM: existing JE — not posted + enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership + enqueue({ + data: { id: 'doc-1', file_name: 'kvitto.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null }, + error: null, + }) // linkToJournalEntry: doc update + enqueue({ data: null, error: null }) // dispatcher commit update + + const result = await commitPendingOperation( + supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), + ) + expect(result.status).toBe('committed') + }) + + it('happy path: links doc to verifikation with no prior journal_entry_id', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership + enqueue({ + data: { id: 'doc-1', file_name: 'faktura.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null }, + error: null, + }) // linkToJournalEntry: doc update + enqueue({ data: null, error: null }) // dispatcher commit update + + const result = await commitPendingOperation( + supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), + ) + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + document_id: 'doc-1', + journal_entry_id: 'je-1', + }) + }) + + it('auto-rejects 409 when linkToJournalEntry throws a period-lock error', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership + enqueue({ + data: null, + error: { message: 'cannot link document in a locked/closed fiscal period' }, + }) // linkToJournalEntry: doc update — period locked + enqueue({ data: null, error: null }) // dispatcher reject update + + const result = await commitPendingOperation( + supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), + ) + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index ab27cb19..cf843725 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -1759,6 +1759,73 @@ async function commitAttachDocumentToTransaction( } } +async function commitLinkDocumentToVoucher( + supabase: SupabaseClient, + _userId: string, + companyId: string, + params: Record +): Promise { + const documentId = params.document_id as string + const journalEntryId = params.journal_entry_id as string + const journalEntryLineId = params.journal_entry_line_id as string | undefined + if (!documentId || !journalEntryId) { + return { error: 'document_id and journal_entry_id are required', status: 400 } + } + + const { data: doc, error: docError } = await supabase + .from('document_attachments') + .select('id, file_name, journal_entry_id') + .eq('id', documentId) + .eq('company_id', companyId) + .maybeSingle() + if (docError || !doc) return { error: 'Document not found', status: 404 } + + // WORM guard: refuse to re-link a doc already linked to a DIFFERENT posted JE. + const existingJeId = (doc.journal_entry_id as string | null) ?? null + if (existingJeId && existingJeId !== journalEntryId) { + const { data: existingJe } = await supabase + .from('journal_entries') + .select('status') + .eq('id', existingJeId) + .eq('company_id', companyId) + .maybeSingle() + if (existingJe && (existingJe as { status: string }).status === 'posted') { + return { + error: + 'Bilagan är kopplad till en bokförd verifikation och kan inte länkas om. Ladda upp ett nytt dokument.', + status: 409, + } + } + } + + try { + const updated = await linkToJournalEntry( + supabase, + companyId, + documentId, + journalEntryId, + journalEntryLineId, + ) + return { + data: { + document_id: updated.id, + file_name: updated.file_name, + journal_entry_id: updated.journal_entry_id, + journal_entry_line_id: updated.journal_entry_line_id ?? null, + }, + } + } catch (err) { + const msg = (err as Error).message ?? '' + if (/locked\/closed fiscal period|Bokföringen är låst/i.test(msg)) { + return { + error: 'Verifikationens period är låst — bilagan kan inte länkas.', + status: 409, + } + } + return { error: `Failed to link document: ${msg}`, status: 500 } + } +} + async function commitRunYearEnd( supabase: SupabaseClient, userId: string, @@ -3584,6 +3651,9 @@ async function commitPendingOperationInner( case 'attach_document_to_transaction': result = await commitAttachDocumentToTransaction(supabase, userId, companyId, pendingOp.params) break + case 'link_document_to_voucher': + result = await commitLinkDocumentToVoucher(supabase, userId, companyId, pendingOp.params) + break case 'run_year_end': result = await commitRunYearEnd(supabase, userId, companyId, pendingOp.params) break diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index 19672083..91640821 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -54,6 +54,10 @@ export const OPERATION_RISK_TIERS: Record = { // propagates it. A wrong attachment requires a rättelse, so require human // approval rather than auto-commit. attach_document_to_transaction: 'medium', + // Linking a doc to a posted verifikation is part of räkenskapsinformation + // (BFL 5 kap 6 §) and becomes immutable once the JE is posted. Medium so a + // human confirms the doc-to-verifikat pairing before it locks. + link_document_to_voucher: 'medium', // ── High: irreversible, compliance-critical, or external side-effects send_invoice: 'high', // emails the customer diff --git a/types/index.ts b/types/index.ts index 5b2f486f..24d29ecb 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1744,6 +1744,9 @@ export type PendingOperationType = | 'uncategorize_transaction' // Document inbox: pin doc to bank transaction | 'attach_document_to_transaction' + // Link a document directly to a journal entry (verifikation) — for imported/ + // manual vouchers that have no bank-transaction row. + | 'link_document_to_voucher' // Manual transaction ingestion (uncategorized row, reversible by delete) | 'create_transaction' // Stream 1 Phase 1: supplier invoice lifecycle