feat(mcp): add gnubok_link_document_to_voucher tool (#804)

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 <jonas@floden.nu>
This commit is contained in:
Jonas Flodén
2026-06-29 22:14:07 +02:00
committed by GitHub
parent 15af0cd7ea
commit 5e9aa52dea
6 changed files with 262 additions and 0 deletions
+89
View File
@@ -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',
+1
View File
@@ -210,6 +210,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
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',
@@ -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<PendingOperation> = {
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)
})
})
+70
View File
@@ -1759,6 +1759,73 @@ async function commitAttachDocumentToTransaction(
}
}
async function commitLinkDocumentToVoucher(
supabase: SupabaseClient,
_userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
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
+4
View File
@@ -54,6 +54,10 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// 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
+3
View File
@@ -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