feat(mcp): bulk-link documents to vouchers in one staged approval (#1411)

gnubok_link_documents_to_vouchers stages up to 300 document-to-verifikat
links as a single pending operation, addressed by voucher_series /
voucher_number / fiscal_year instead of journal_entry_id UUIDs, for bulk
receipt-migration jobs where N separate tools mean N separate approvals.

Staging resolves every row server-side and returns a per-row hit or miss,
so a systematic offset such as a wrong fiscal_year is visible before
anything is approved rather than after N approvals. Only resolved rows
enter the staged operation.

The WORM precondition and the document lookup are shared with the
single-document executor through precheckDocumentLink: a bulk call must
enforce exactly the invariants N single calls would, and a second copy of
a BFL 5 kap 6 § guard is a copy that keeps the old behaviour when the
first is hardened.

A batch that links nothing returns 409 instead of a committed no-op.
Partial skips stay committed, but an approval-gated operation on
räkenskapsinformation must not leave an audit record asserting a run that
changed nothing.

The tool is search-only: a one-off migration tool does not belong in the
default catalog every session pays for in context, and keeping it there
pushed the tools/list projection past the 58.5K token ceiling that
payload-size.bench.test.ts guards.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bjornbergenheim
2026-08-17 14:52:58 +02:00
committed by GitHub
parent 4300cb711b
commit 3841ab9f54
12 changed files with 682 additions and 32 deletions
+1
View File
@@ -796,6 +796,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-03] whatsapp-inbox M11 ('stopp'): copy changed to say PAUSED rather than disconnected; the keyword only sets muted_at. Muting also stops persisting content (body_text/raw_payload null on muted senders, matching the unknown-sender discipline) so the promise is true in the data too. Actually revoking on 'stopp' was rejected: 'start' must be able to re-open the same binding, and the settings panel already owns real disconnection ("Koppla från").
[2026-08-03] whatsapp-inbox raw_payload: the sender's plaintext E.164 number is stripped before persisting (redactRawPayload) and replies resolve the recipient by decrypting the link's phone_enc. Storing the number verbatim on every message row defeated the point of the AES-256-GCM column and contradicted the RoPA claim that it is never in the clear. Legacy rows still holding `from` keep working via a fallback read.
[2026-08-05] Representation clarifying question has NO amount floor (removed the 150 kr gate after the Swedish compliance review on PR #1340): documenting deltagare + syfte is what makes a representation expense deductible at all (BFL 5 kap 6-7 §) and that duty is not conditioned on any sum; the 300 kr/person figure is the VAT-deduction base cap, an unrelated rule. Noise is bounded by the triggers instead (receipt-shaped + restaurant/cafe/hotel merchant, <=1 question per receipt, <=2 per burst, <=6 per sender per day, one "nej" dismisses).
[2026-08-05] gnubok_link_documents_to_vouchers is search-only, shares the single tool's WORM precheck, and rejects an all-skipped batch. Search-only because a one-off bulk migration tool does not belong in the catalog every session pays for in context, and keeping it default pushed the tools/list projection past the 58.5K ceiling, whose own guidance prefers opt-in search over raising the budget. The WORM guard and the document lookup moved into a shared precheckDocumentLink rather than being copied into the bulk executor: two copies of a BFL 5 kap 6 § guard drift, and a bulk call must be exactly N single calls. Staging now also previews already_linked and duplicate_in_batch instead of resolving them as matched and skipping them silently after approval. A batch that links nothing returns 409 rather than a committed no-op: partial skips stay committed, but an approval-gated operation on räkenskapsinformation must not leave an audit record asserting a run that changed nothing.
[2026-08-05] Trap-closing batch (G2/G3/G4/G10/G12/G14 from the aktivering/K3/projekt analysis) shipped as five separate PRs, so each entry below names the PR that carries it; nothing here describes code that lives only in this diff. Three non-obvious calls: (a) legal wording about WHEN switching frameworks is allowed was omitted from the K3-to-K2 dialog (this PR) because BFNAR 2012:4 is not covered by any repo skill, so the dialog states system consequences only; an interim K3_REQUIRED_FOR_UPPSKJUTEN_SKATT registry error was written and then deleted within this same PR when the founder decision removed the feature it gated, so it never shipped. (b) PR "assets K2 guard": account 1081 flagged k2_excluded in the TS BAS reference + data migration 20260805112000 for existing chart rows: BFNAR 2016:10 punkt 10.4 substance (skill k2-vs-k3.md line 67) outranks the incomplete 20260225103139 backfill list; 1260/1269 stay unflagged in this pass because no asset-register category range reaches 1260-1279, a full k2_excluded-list reconciliation against official BAS marks is a separate follow-up. (c) PR "year-end preflight blocker links": validateYearEndReadiness gained a REQUIRED blockers: {code,message}[] field with errors: string[] kept as a derived mirror, so the public v1 compliance route stays byte-identical while PreflightStep gets stable codes; the voucher-gap and trial-balance link branches in BlockerRow were deleted as dead code rather than given a new page.
[2026-08-05] K3 uppskjuten skatt on obeskattade reserver ESCALATED, not changed: the dispositions builder books Dr 8940 / Cr 2240 = 20.6 % of gross 21xx in juridisk person, but swedish-asset-accounting (depreciation.md:88) states K3 29.37 keeps obeskattade reserver at gross in juridisk person with the 79.4/20.6 split reserved for koncernredovisning, so the entry double-counts the tax portion (result charged twice, 2240 overstated). Prod blast radius verified read-only 2026-08-05: 0 companies on K3, 2 companies with any posted 2240/8940 lines (3 entries, SIE-import shaped), and INK2 field 7651 adds the whole 8900-8989 range back so the error is tax-neutral. Per CLAUDE.md "stop and ask" on Swedish tax law this goes to the founder before any engine change; the K3 principle note was meanwhile reworded to describe what the engine actually books.
[2026-08-05] K3 29.37 escalation RESOLVED same day, founder chose removal: the uppskjuten_skatt disposition (Dr 8940 / Cr 2240 = 20.6 % of gross 21xx) is deleted end to end in juridisk person: proposal step, POST kind, DispositionsStep case, K2-to-K3 account seeding, and the K3_REQUIRED_FOR_UPPSKJUTEN_SKATT error registered earlier the same day (the gate died with the feature it gated). latent-tax-calculator.ts keeps only LATENT_TAX_DEFAULT_RATE for analytical presentation (soliditet/justerat EK in build-data.ts); the 2240/8940 chart rows of already-switched companies are deliberately left in place (audit history precedent from the original seeding comment). Old clients sending the kind now get schema 400, chosen over a bespoke 410-style error because the kind no longer exists in the contract and the only known clients are our own UI and MCP, neither of which sends it. Refinement after the review round: 29.37 makes gross vs split an ELECTION for the legal entity rather than a prohibition, so what shipped is a product default (gross), not a claim that the split is unlawful; the deleted code implemented NEITHER election, since it credited 2240 while the BR still carried 21xx gross, and no company can be on the split method today (0 K3 companies on prod). Offering the split as a disclosed policy option is a possible future feature, and would have to ship together with the matching redovisningsprincip note and a BR that presents the reserves net.
@@ -41,6 +41,7 @@ export const OPERATION_LABEL_KEYS: Record<string, string> = {
// Documents & links
attach_document_to_transaction: 'type_attach_document_to_transaction',
link_document_to_voucher: 'type_link_document_to_voucher',
link_documents_to_vouchers: 'type_link_documents_to_vouchers',
link_invoice_voucher: 'type_link_invoice_voucher',
link_supplier_invoice_voucher: 'type_link_supplier_invoice_voucher',
link_transaction_journal_entry: 'type_link_transaction_journal_entry',
+267
View File
@@ -10742,6 +10742,273 @@ export const tools: McpTool[] = [
)
},
},
{
name: 'gnubok_link_documents_to_vouchers',
title: 'Bulk-Link Documents to Vouchers',
description: 'Bulk receipt migration: stage up to 300 document-to-verifikat links as ONE approval, addressed by voucher_series/voucher_number/fiscal_year (server resolves the UUID). Returns per-row hit/miss, so a wrong fiscal_year shows before approval. Only resolved rows are staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
links: {
type: 'array',
minItems: 1,
maxItems: 300,
items: {
type: 'object',
additionalProperties: false,
properties: {
document_id: { type: 'string', description: 'UUID of the document_attachments row' },
voucher_series: { type: 'string', minLength: 1, maxLength: 10, description: 'Voucher series letter, e.g. "A"' },
voucher_number: { type: 'integer', minimum: 1, description: 'Voucher number within the series and fiscal year' },
fiscal_year: { type: 'integer', minimum: 2000, maximum: 2100, description: 'Calendar year of the fiscal period (matched against fiscal_periods.period_start), e.g. 2025' },
journal_entry_line_id: { type: 'string', description: 'Optional UUID to pin the doc to a specific debit/credit line' },
},
required: ['document_id', 'voucher_series', 'voucher_number', 'fiscal_year'],
},
},
idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries' },
dry_run: { type: 'boolean', description: 'Preview without staging' },
},
required: ['links'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
// Search-only: a one-off bulk migration tool does not belong in the default
// catalog, which every session pays for in context. The everyday
// gnubok_link_document_to_voucher stays default; this one is discovered via
// gnubok_search_tools when a migration job actually needs it. Keeping it
// default pushed the tools/list projection past the 58.5K token ceiling
// (payload-size.bench.test.ts), whose own guidance is to prefer opt-in
// search over raising the budget.
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const rawLinks = args.links as Array<{
document_id: string
voucher_series: string
voucher_number: number
fiscal_year: number
journal_entry_line_id?: string
}>
if (!Array.isArray(rawLinks) || rawLinks.length === 0) throw new Error('links is required (non-empty)')
if (rawLinks.length > 300) throw new Error('links: max 300 per call')
for (const [i, l] of rawLinks.entries()) {
if (!l.document_id) throw new Error(`links[${i}]: document_id is required`)
if (!l.voucher_series) throw new Error(`links[${i}]: voucher_series is required`)
if (!Number.isInteger(l.voucher_number) || l.voucher_number < 1) {
throw new Error(`links[${i}]: voucher_number must be a positive integer`)
}
if (!Number.isInteger(l.fiscal_year)) throw new Error(`links[${i}]: fiscal_year must be an integer`)
}
// ── Resolve fiscal_year → fiscal_period_id. A "fiscal_year" here means
// the calendar year the period STARTS in; broken (non-calendar)
// fiscal years or a company with >1 period starting the same year
// are surfaced as a per-row miss rather than guessed at.
const { data: periods, error: periodsError } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
if (periodsError) throw new Error(`Database error: ${periodsError.message}`)
const periodsByYear = new Map<number, Array<{ id: string; period_start: string; period_end: string }>>()
for (const p of periods ?? []) {
const year = new Date(p.period_start as string).getUTCFullYear()
const bucket = periodsByYear.get(year) ?? []
bucket.push(p as { id: string; period_start: string; period_end: string })
periodsByYear.set(year, bucket)
}
// ── Batch-fetch documents.
const documentIds = [...new Set(rawLinks.map((l) => l.document_id))]
const { data: docs, error: docsError } = await supabase
.from('document_attachments')
.select('id, file_name, mime_type, journal_entry_id')
.in('id', documentIds)
.eq('company_id', companyId)
if (docsError) throw new Error(`Database error: ${docsError.message}`)
const docsById = new Map((docs ?? []).map((d) => [d.id as string, d as {
id: string; file_name: string; mime_type: string; journal_entry_id: string | null
}]))
// ── Batch-fetch candidate journal entries: every resolved period id ×
// every series named in the payload, then match tuples in JS (the
// unique key is (company_id, fiscal_period_id, voucher_series,
// voucher_number); see migration 20260402100000).
const resolvedPeriodIds = [...new Set(
rawLinks.flatMap((l) => (periodsByYear.get(l.fiscal_year) ?? []).map((p) => p.id))
)]
const seriesList = [...new Set(rawLinks.map((l) => l.voucher_series))]
const voucherNumbers = [...new Set(rawLinks.map((l) => l.voucher_number))]
type CandidateJe = {
id: string; entry_date: string; description: string
voucher_series: string | null; voucher_number: number | null; status: string; fiscal_period_id: string
}
let jesByKey = new Map<string, CandidateJe>()
if (resolvedPeriodIds.length > 0) {
// Bounded by voucher_number (at most 300 distinct per call) and paged
// through fetchAllRows. Without both, a migration into a fiscal year
// that already holds more than 1000 vouchers in the series would hit
// PostgREST's silent 1000-row cap: the missing entries resolve as
// voucher_not_found and vanish from the staged batch, so the caller
// approves fewer links than requested with nothing explaining why.
const jes = await fetchAllRows<CandidateJe>(
({ from, to }) =>
supabase
.from('journal_entries')
.select('id, entry_date, description, voucher_series, voucher_number, status, fiscal_period_id')
.eq('company_id', companyId)
.in('fiscal_period_id', resolvedPeriodIds)
.in('voucher_series', seriesList)
.in('voucher_number', voucherNumbers)
.order('id', { ascending: true })
.range(from, to),
)
jesByKey = new Map(jes.map((je) => [
`${je.fiscal_period_id}::${je.voucher_series}::${je.voucher_number}`,
je,
]))
}
// ── Resolve each row to a hit or a miss. Only hits are carried into the
// staged params; misses are returned in the preview so the caller
// sees them without having to approve anything first.
type RowResult = {
document_id: string
voucher_series: string
voucher_number: number
fiscal_year: number
status: 'matched' | 'ambiguous_fiscal_year' | 'unknown_fiscal_year' | 'document_not_found'
| 'voucher_not_found' | 'already_linked' | 'duplicate_in_batch'
document_file_name?: string
journal_entry_id?: string
voucher_label?: string
voucher_date?: string
}
// Identifying fields only: spreading the raw row would leak
// journal_entry_line_id into a response shape that does not declare it.
const rowKey = (l: typeof rawLinks[number]) => ({
document_id: l.document_id,
voucher_series: l.voucher_series,
voucher_number: l.voucher_number,
fiscal_year: l.fiscal_year,
})
// ── Statuses of the verifikat these documents are ALREADY attached to.
// The WORM guard in the executor refuses to move a document off a
// POSTED verifikat, and without this lookup such a row previews as
// `matched` and is silently skipped after approval: the user approves
// 42 and gets 38. Those entries can sit outside the periods fetched
// above, so they need their own lookup.
const existingJeIds = [...new Set(
(docs ?? [])
.map((d) => d.journal_entry_id as string | null)
.filter((id): id is string => !!id)
)]
const postedExistingJeIds = new Set<string>()
if (existingJeIds.length > 0) {
const { data: existingJes, error: existingErr } = await supabase
.from('journal_entries')
.select('id, status')
.eq('company_id', companyId)
.in('id', existingJeIds)
if (existingErr) throw new Error(`Database error: ${existingErr.message}`)
for (const je of existingJes ?? []) {
if ((je as { status: string }).status === 'posted') postedExistingJeIds.add(je.id as string)
}
}
const results: RowResult[] = []
const matchedLinks: Array<{
document_id: string; journal_entry_id: string; journal_entry_line_id: string | null
}> = []
// Same document twice in one payload: the executor applies rows in order,
// so against a DRAFT verifikat the second row would silently overwrite
// the first (the WORM guard only blocks posted targets). Reject the
// repeat here instead of staging a batch whose outcome depends on order.
const seenDocumentIds = new Set<string>()
for (const l of rawLinks) {
if (seenDocumentIds.has(l.document_id)) {
results.push({ ...rowKey(l), status: 'duplicate_in_batch' })
continue
}
seenDocumentIds.add(l.document_id)
const yearPeriods = periodsByYear.get(l.fiscal_year) ?? []
if (yearPeriods.length === 0) {
results.push({ ...rowKey(l), status: 'unknown_fiscal_year' })
continue
}
if (yearPeriods.length > 1) {
results.push({ ...rowKey(l), status: 'ambiguous_fiscal_year' })
continue
}
const doc = docsById.get(l.document_id)
if (!doc) {
results.push({ ...rowKey(l), status: 'document_not_found' })
continue
}
const period = yearPeriods[0]!
const je = jesByKey.get(`${period.id}::${l.voucher_series}::${l.voucher_number}`)
if (!je) {
results.push({ ...rowKey(l), status: 'voucher_not_found', document_file_name: doc.file_name })
continue
}
const existingJeId = doc.journal_entry_id
if (existingJeId && existingJeId !== je.id && postedExistingJeIds.has(existingJeId)) {
results.push({ ...rowKey(l), status: 'already_linked', document_file_name: doc.file_name })
continue
}
results.push({
...rowKey(l),
status: 'matched',
document_file_name: doc.file_name,
journal_entry_id: je.id,
voucher_label: `${je.voucher_series ?? l.voucher_series}${je.voucher_number ?? l.voucher_number}`,
voucher_date: je.entry_date,
})
matchedLinks.push({
document_id: l.document_id,
journal_entry_id: je.id,
journal_entry_line_id: l.journal_entry_line_id ?? null,
})
}
const matchedCount = matchedLinks.length
const missedCount = results.length - matchedCount
if (matchedCount === 0) {
throw new Error(
`No links resolved: 0/${rawLinks.length} matched a real document + voucher. ` +
`First miss: ${JSON.stringify(results[0])}`
)
}
return stagePendingOperation(
supabase, companyId, userId, 'link_documents_to_vouchers',
`Koppla ${matchedCount} bilagor till verifikat${missedCount > 0 ? ` (${missedCount} utan träff)` : ''}`,
{ links: matchedLinks },
{
total: rawLinks.length,
matched_count: matchedCount,
missed_count: missedCount,
results,
},
actor,
undefined,
{
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
dryRun: args.dry_run === true,
}
)
},
},
// ── Payroll (Lönehantering) ──────────────────────────────────
{
name: 'gnubok_list_mileage_trips',
+1
View File
@@ -241,6 +241,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_get_document_content: 'transactions:read',
gnubok_attach_document_to_transaction: 'transactions:write',
gnubok_link_document_to_voucher: 'bookkeeping:write',
gnubok_link_documents_to_vouchers: 'bookkeeping:write',
// Körjournal (mileage): trip log reads/writes are payroll surface
// (milersättning, 7331); booking the verifikat is a journal write.
gnubok_list_mileage_trips: 'payroll:read',
@@ -1285,6 +1285,148 @@ describe('commitPendingOperation: link_document_to_voucher', () => {
})
})
// ─── link_documents_to_vouchers (bulk) ─────────────────────────────
describe('commitPendingOperation: link_documents_to_vouchers', () => {
const baseOp: Partial<PendingOperation> = {
operation_type: 'link_documents_to_vouchers',
params: {
links: [
{ document_id: 'doc-1', journal_entry_id: 'je-1', journal_entry_line_id: null },
{ document_id: 'doc-2', journal_entry_id: 'je-2', journal_entry_line_id: null },
],
},
}
it('happy path: links every row and reports zero skipped', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// row 1: doc-1 -> je-1
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: 'kvitto1.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null },
error: null,
}) // linkToJournalEntry: doc update
// row 2: doc-2 -> je-2
enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership
enqueue({
data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', 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({
linked_count: 2,
skipped_count: 0,
})
})
it('mixed batch: a missing document is skipped without blocking the other rows', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// row 1: doc-1 not found in this company
enqueue({ data: null, error: null }) // doc fetch: not found
// row 2: doc-2 -> je-2 succeeds
enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership
enqueue({
data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', 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),
)
// Non-transactional bulk op: overall status is 'committed' even with
// partial skips (mirrors bulk_book_inbox_items's booked/skipped split),
// since no irreversible side-effect was posted for the skipped row.
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ linked_count: 1, skipped_count: 1 })
expect((result.data as { skipped: Array<{ document_id: string; reason: string }> }).skipped[0]).toMatchObject({
document_id: 'doc-1',
reason: 'Bilagan hittades inte.',
})
})
it('WORM guard skips a row whose doc is already linked to a different posted JE', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// row 1: doc-1 already linked to je-OTHER (posted) -> skipped
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status
// row 2: doc-2 -> je-2 succeeds
enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership
enqueue({
data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', 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({ linked_count: 1, skipped_count: 1 })
})
it('auto-rejects 409 when every row is skipped instead of recording a committed no-op', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// row 1: doc-1 already linked to a posted JE -> WORM skip
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status
// row 2: doc-2 not found -> skip
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),
)
// Partial skips stay 'committed' (see the mixed-batch case), but a batch
// that linked nothing must not leave an audit record asserting a run that
// changed nothing: the single-document executor returns 409 for the same
// conditions, and the bulk path must not be the weaker one.
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
it('fails 400 when the staged params carry an empty links array', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher failure update
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1',
makePendingOp({ ...baseOp, params: { links: [] } }),
)
// 400 is not auto-reject territory (only 404 and 409 are), so the operation
// is recorded as failed rather than rejected.
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
})
it('fails 400 when links is missing entirely', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher failure update
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1',
makePendingOp({ ...baseOp, params: {} }),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
})
})
// ─── categorize_transaction: dimensions propagation (PR7) ──────────
describe('commitPendingOperation: categorize_transaction: dimensions propagation (PR7)', () => {
+157 -32
View File
@@ -23,6 +23,7 @@ import {
supplierInvoiceSekAmounts,
} from '@/lib/currency/supplier-invoice-rate'
import { roundOre } from '@/lib/money'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { validateVatNumber } from '@/lib/vat/vies-client'
import {
normalizeVatRateToDecimal,
@@ -3181,6 +3182,74 @@ async function commitAttachDocumentToTransaction(
}
}
/**
* Shared precondition for linking a document to a verifikat, used by BOTH the
* single and the bulk executor. It must live in one place: a bulk call has to
* be exactly N single calls, and a second copy of a legally motivated guard is
* a copy that silently keeps the old behaviour when the first one is hardened.
*
* Two checks: the document exists in THIS company, and the WORM rule that a
* document already belonging to a POSTED verifikat cannot be moved to another
* one (it is räkenskapsinformation under BFL 5 kap 6 § once the verifikat is
* posted). Re-linking to the SAME verifikat is allowed: that is a no-op or a
* line-level refinement, not a move.
*/
type DocumentLinkPrecheck =
| { ok: true }
| { ok: false; reason: string; status: number }
async function precheckDocumentLink(
supabase: SupabaseClient,
companyId: string,
documentId: string,
journalEntryId: string
): Promise<DocumentLinkPrecheck> {
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 { ok: false, reason: 'Bilagan hittades inte.', status: 404 }
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 {
ok: false,
reason:
'Bilagan är kopplad till en bokförd verifikation och kan inte länkas om. Ladda upp ett nytt dokument.',
status: 409,
}
}
}
return { ok: true }
}
/**
* Shared failure mapping for both link executors. A locked period is the one
* case worth its own sentence: it is recoverable by unlocking, unlike the rest,
* and the raw trigger text is English database prose. Everything else goes
* through getErrorMessage so nothing reaches the Granskning panel untranslated.
*/
function documentLinkFailureMessage(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err ?? '')
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(msg)) {
return 'Verifikationens period är låst: bilagan kan inte länkas.'
}
// No ErrorContext value covers document attachments, and journal_entry would
// phrase the fallback as a verifikat problem when the failure is the link.
// Omitting it keeps the generic Swedish fallback, which is accurate here.
return getErrorMessage(err)
}
async function commitLinkDocumentToVoucher(
supabase: SupabaseClient,
_userId: string,
@@ -3194,31 +3263,8 @@ async function commitLinkDocumentToVoucher(
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,
}
}
}
const precheck = await precheckDocumentLink(supabase, companyId, documentId, journalEntryId)
if (!precheck.ok) return { error: precheck.reason, status: precheck.status }
try {
const updated = await linkToJournalEntry(
@@ -3237,14 +3283,90 @@ async function commitLinkDocumentToVoucher(
},
}
} 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,
}
const msg = err instanceof Error ? err.message : String(err ?? '')
const isLocked = /locked\/closed fiscal period|Bokföringen är låst/i.test(msg)
return { error: documentLinkFailureMessage(err), status: isLocked ? 409 : 500 }
}
}
async function commitLinkDocumentsToVouchers(
supabase: SupabaseClient,
_userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const links = params.links as Array<{
document_id: string; journal_entry_id: string; journal_entry_line_id: string | null
}> | undefined
if (!Array.isArray(links) || links.length === 0) {
return { error: 'links is required (non-empty)', status: 400 }
}
const linked: Array<{ document_id: string; journal_entry_id: string }> = []
const skipped: Array<{ document_id: string; journal_entry_id: string; reason: string }> = []
// Sequential, not Promise.all: each row re-validates against the current DB
// state (a prior row in the SAME batch can change a doc's journal_entry_id,
// and the WORM guard must see that), and goes through the SAME
// precheckDocumentLink + linkToJournalEntry pair as the single-document
// executor, so a bulk call enforces identical invariants to N individual
// calls rather than a parallel reimplementation of them.
for (const link of links) {
const documentId = link.document_id
const journalEntryId = link.journal_entry_id
if (!documentId || !journalEntryId) {
skipped.push({ document_id: documentId ?? '(saknas)', journal_entry_id: journalEntryId ?? '(saknas)', reason: 'Raden saknar dokument-id eller verifikations-id.' })
continue
}
return { error: `Failed to link document: ${msg}`, status: 500 }
const precheck = await precheckDocumentLink(supabase, companyId, documentId, journalEntryId)
if (!precheck.ok) {
skipped.push({ document_id: documentId, journal_entry_id: journalEntryId, reason: precheck.reason })
continue
}
try {
const updated = await linkToJournalEntry(
supabase, companyId, documentId, journalEntryId, link.journal_entry_line_id ?? undefined,
)
linked.push({ document_id: updated.id, journal_entry_id: updated.journal_entry_id as string })
} catch (err) {
// Same mapping as the single executor, including its locked-period
// sentence: skipped reasons land in result_data and are rendered in the
// Granskning panel, so none of them may reach the user as raw English
// database prose.
skipped.push({ document_id: documentId, journal_entry_id: journalEntryId, reason: documentLinkFailureMessage(err) })
}
}
// Nothing linked is a failure, not a partial success. Recording it as
// `committed` would leave an approval-gated operation on
// räkenskapsinformation sitting in the audit trail asserting a run that
// changed nothing, and the single-document executor returns 409 for exactly
// these conditions (locked period, WORM guard, missing document). A batch
// must not be the weaker path.
if (linked.length === 0) {
return {
error: `Inga bilagor kunde länkas (${skipped.length} hoppades över). Första orsak: ${skipped[0]?.reason ?? 'okänd'}`,
status: 409,
}
}
log.info('link_documents_to_vouchers committed', {
companyId,
operationType: 'link_documents_to_vouchers',
requested: links.length,
linkedCount: linked.length,
skippedCount: skipped.length,
})
return {
data: {
linked_count: linked.length,
skipped_count: skipped.length,
linked,
skipped,
},
}
}
@@ -5896,6 +6018,9 @@ async function commitPendingOperationInner(
case 'link_document_to_voucher':
result = await commitLinkDocumentToVoucher(supabase, userId, companyId, pendingOp.params)
break
case 'link_documents_to_vouchers':
result = await commitLinkDocumentsToVouchers(supabase, userId, companyId, pendingOp.params)
break
case 'run_year_end':
result = await commitRunYearEnd(supabase, userId, companyId, pendingOp.params)
break
+2
View File
@@ -94,6 +94,8 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// (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',
// Same rationale as link_document_to_voucher, N rows in one staged op.
link_documents_to_vouchers: 'medium',
// Dimension-only diff on posted lines (verifikat stays immutable), fully
// audited via dimension_retag_log, but it rewrites reporting history, so
// it crosses a human at medium.
+1
View File
@@ -674,6 +674,7 @@
"type_convert_invoice": "Convert invoice",
"type_attach_document_to_transaction": "Attach document",
"type_link_document_to_voucher": "Link document",
"type_link_documents_to_vouchers": "Link documents (bulk)",
"type_link_invoice_voucher": "Link invoice",
"type_link_supplier_invoice_voucher": "Link supplier invoice",
"type_link_transaction_journal_entry": "Link transaction",
+1
View File
@@ -674,6 +674,7 @@
"type_convert_invoice": "Konvertera faktura",
"type_attach_document_to_transaction": "Bifoga underlag",
"type_link_document_to_voucher": "Länka underlag",
"type_link_documents_to_vouchers": "Länka underlag (bulk)",
"type_link_invoice_voucher": "Länka faktura",
"type_link_supplier_invoice_voucher": "Länka leverantörsfaktura",
"type_link_transaction_journal_entry": "Länka transaktion",
@@ -0,0 +1,99 @@
-- Add 'link_documents_to_vouchers' to the pending_operations operation_type
-- CHECK constraint.
--
-- New bulk counterpart to 'link_document_to_voucher': addressed by
-- voucher_series/voucher_number/fiscal_year instead of journal_entry_id
-- UUIDs (the MCP tool resolves the UUID server-side), and stages up to 300
-- rows as ONE pending_operation instead of one per document. Built for bulk
-- receipt-migration jobs where staging N separate operations would mean N
-- separate approvals.
--
-- Risk tier: 'medium', same rationale as the single-document tool: linking a
-- doc to a posted verifikation becomes part of räkenskapsinformation (BFL 5
-- kap 6 §) once approved, so a human confirms the batch before it locks.
--
-- NOTE on the value list: this constraint is re-created wholesale (the
-- established pattern here; see 20260727110000's own note), so the list
-- below is every value from 20260813124507 PLUS the one new value. Dropping
-- any existing value here would silently revoke it.
--
-- NOT VALID + separate VALIDATE migration (paired file, same pattern as
-- 20260727110000 / 20260727110001): avoids a full-table scan under the
-- stronger lock this ALTER already holds.
--
-- pg-test: tests/pg/pending-operations-op-type-audit.pg.test.ts asserts every
-- op type staged in server.ts or tiered in risk-tiers.ts is accepted here.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'post_kontantmetod_cutoff',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher',
'submit_vat_declaration',
'submit_agi',
'create_article',
'update_article',
'bulk_book_inbox_items',
'create_dimension_value',
'retag_line_dimensions',
'link_document_to_voucher',
'update_payslip_line',
'register_absence',
'create_employee',
'update_employee',
'set_employee_opening_balances',
'vacation_year_close',
'create_account',
'update_account',
'set_voucher_note',
'book_salary_run',
'delete_absence',
'update_company_settings',
'update_customer',
'update_invoice',
'create_recurring_schedule',
'update_recurring_schedule',
'log_mileage_trip',
'book_mileage_period',
'link_documents_to_vouchers' -- bulk: N documents to posted verifikat in 1 staged op, addressed by series/number/fiscal_year
)) NOT VALID;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,6 @@
-- Validate the operation type CHECK re-added in 20260817130000.
-- This separate transaction avoids a full-table scan while the preceding
-- migration holds its stronger table lock.
ALTER TABLE public.pending_operations
VALIDATE CONSTRAINT pending_operations_operation_type_check;
+4
View File
@@ -2340,6 +2340,10 @@ export type PendingOperationType =
// Link a document directly to a journal entry (verifikation): for imported/
// manual vouchers that have no bank-transaction row.
| 'link_document_to_voucher'
// Bulk counterpart: N docs linked to N posted verifikationer in one staged
// op, addressed by voucher_series/voucher_number/fiscal_year instead of
// journal_entry_id UUIDs (resolved server-side).
| 'link_documents_to_vouchers'
// Manual transaction ingestion (uncategorized row, reversible by delete)
| 'create_transaction'
// Stream 1 Phase 1: supplier invoice lifecycle