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:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user