feat(dimensions): PR6 retro-tagging — audited retag carve-out, BulkTagWorkbench, staged MCP tool (#867)
* feat(dimensions): PR6 retro-tagging — audited retag carve-out, workbench, staged MCP tool
Tier-2 retro-tagging (founder decision №1, approved 2026-07-02): posted
entries in OPEN periods can have their dimension tags changed through ONE
audited path — everything about the verifikat itself stays immutable.
Carve-out (migration 20260702170000): the line-immutability trigger gains a
single narrow branch — while the transaction-local GUC set by the RPC is
active, an UPDATE of a posted line is admitted iff every non-dimension
column is unchanged, enforced by a whole-row to_jsonb diff (any future
column is protected by construction; mirrors cost_center/project are in the
changeable set because they are derived views of dimensions['1']/['6']).
Precedent: mark_entry_as_opening_balance (20260613120000).
retag_line_dimensions RPC: tenant guard (20260619130100 pattern), writer
gate (viewers rejected), posted-only, open period + company lock date
enforced, every code validated against the ACTIVE registry, immutable
dimension_retag_log row (before/after/actor/reason, INSERT-only via its own
trigger, no FKs so the trail survives hard-deletes) written BEFORE the
carve-out UPDATE. Idempotent no-op without a log row. Untag ({}) supported.
Legal position per the plan: dimensions are internredovisning metadata, not
BFL 5 kap 7§ verifikat content — this is strictly more conservative than
Fortnox/Visma (dimension-only diffs, open periods only, immutable log,
storno past locks — Tier 3 has no exceptions).
Mandatory pg suite (11 tests): GUC-less updates still blocked; amounts/
description can never change even under the GUC (transaction-local);
closed/locked/lock-date, role, registry, draft and cross-tenant rejections;
log immutability; gnubok.allow_delete bulk path unaffected.
UX (all writes through the ONE RPC): pencil on posted-voucher lines in
bookkeeping/[id] ("Påverkar endast internredovisningen, inte verifikatet")
+ retag-history card; BulkTagWorkbench at /dimensions/tagging (filters,
shift-select, merge vs "Ersätt tagg" replace mode, reversal-pair warning
with "Inkludera motverifikat" auto-selection, per-line failure display).
MCP: gnubok_tag_journal_lines (bookkeeping:write) — filter block resolved
via resolve-don't-select, ≤500 lines, staged via pending_operations (new
op type migration 20260702171000, medium risk tier, shared Zod validation
boundary between staging and commit; executor loops the RPC per line with
partial-success aggregation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dimensions): address #867 review — SQLSTATE classification, blocking storno confirm, documented divergence
- Retag route classifies RPC errors by SQLSTATE instead of message-regex:
P0001 (every rule violation in the RPC) → 409 verbatim, 42501 (tenant
guard) → 403, anything else → logged 500 with a generic message. No more
substring sniffing.
- The workbench's storno-pair warning escalates to a BLOCKING confirmation
naming the unselected counter-vouchers before apply (Srf U 14 gross
reporting — one-legged retags silently skew project P&L; the banner alone
was advisory).
- The empty-bag divergence is now documented on both schemas as intentional:
the direct dialog/workbench path allows {} (human untags phantom codes,
logged with reason), the MCP staged path rejects it (agents never
bulk-clear history).
Triage notes: the log's missing FKs are the point (behandlingshistorik must
survive undo_sie_import hard-deletes — a cascade would erase the trail);
SIE exports are generated fresh on demand, never cached, so post-retag
exports carry the new object lists automatically; date-scoped registry
values are deliberately not enforced at retag because entry creation does
not enforce them either — enforcing in one path only would be incoherent
(both belong to the PR10 rules engine).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fb3fe82a56
commit
816b1769c8
@@ -763,6 +763,23 @@ export const CreateDimensionValueSchema = z
|
||||
{ message: 'Slutdatum får inte vara före startdatum', path: ['end_date'] },
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag — Tier-2 retro-
|
||||
* tagging (dimensions plan PR6). The RPC enforces every rule (posted only,
|
||||
* open period, lock date, active registry values); this schema only shapes
|
||||
* the request. An empty bag {} untags the line.
|
||||
*/
|
||||
export const RetagLineDimensionsSchema = z.object({
|
||||
// {} passes (no entries to validate) = UNTAG. Intentional divergence from
|
||||
// the MCP staged path (RetagLineDimensionsParamsSchema), which rejects an
|
||||
// empty bag: a human clearing phantom tags via the dialog/workbench is a
|
||||
// deliberate act with a logged reason; an agent bulk-clearing history is
|
||||
// not something we allow to be staged. The retag log records {} as the
|
||||
// new value either way (#867 review).
|
||||
dimensions: DimensionsBagSchema,
|
||||
reason: z.string().min(3).max(500),
|
||||
})
|
||||
|
||||
/** PATCH /api/dimensions/[id]/values/[valueId] — no `code` field by design. */
|
||||
export const UpdateDimensionValueSchema = z
|
||||
.object({
|
||||
@@ -2138,3 +2155,41 @@ export const SalaryEmployeeOverrideSchema = z
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Dimensions PR6 — bulk retro-tagging workbench (appended at end
|
||||
// of file by PR6 to avoid conflicts; keep new schemas below).
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Query filters for GET /api/dimensions/tagging/lines (the BulkTagWorkbench
|
||||
* line browser). All filters optional; `limit` is a hard cap (default 200,
|
||||
* max 500) — the route fetches limit+1 and reports `total_capped` instead of
|
||||
* paginating (dimensions plan §3, v1 scope).
|
||||
*/
|
||||
export const DimensionTaggingLinesQuerySchema = z.object({
|
||||
period_id: uuid.optional(),
|
||||
date_from: saneIsoDate.optional(),
|
||||
date_to: saneIsoDate.optional(),
|
||||
account_from: accountNumber.optional(),
|
||||
account_to: accountNumber.optional(),
|
||||
/** Free-text ilike filter on journal_entries.description. */
|
||||
text: z.string().trim().max(200).optional(),
|
||||
/** '1' → only lines whose dimensions map is empty ({}). */
|
||||
only_untagged: z.enum(['0', '1']).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||
})
|
||||
|
||||
/**
|
||||
* Body for POST /api/dimensions/tagging/apply. One dimensions object applied
|
||||
* to every listed line via the retag_line_dimensions RPC (the UI groups
|
||||
* selected lines by their computed resulting map and issues one POST per
|
||||
* distinct map). `dimensions` reuses THE bag schema so validation cannot
|
||||
* drift from the engine/API layers; an empty bag is allowed — replace mode
|
||||
* uses it to clear phantom tags. `reason` mirrors the RPC's >= 3 chars CHECK.
|
||||
*/
|
||||
export const DimensionTaggingApplySchema = z.object({
|
||||
line_ids: z.array(uuid).min(1).max(500),
|
||||
dimensions: DimensionsBagSchema,
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
})
|
||||
|
||||
@@ -210,6 +210,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_list_dimension_values: 'reports:read',
|
||||
gnubok_create_dimension_value: 'bookkeeping:write',
|
||||
gnubok_get_dimension_pnl: 'reports:read',
|
||||
// Staged bulk retag of posted-line dimensions (dimensions PR6).
|
||||
gnubok_tag_journal_lines: 'bookkeeping:write',
|
||||
// Document inbox
|
||||
gnubok_upload_document: 'transactions:write',
|
||||
gnubok_list_inbox_items: 'transactions:read',
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* commitRetagLineDimensions — executor tests (dimensions PR6).
|
||||
*
|
||||
* The executor is private to lib/pending-operations/commit.ts and reached
|
||||
* through commitPendingOperation, same pattern as
|
||||
* dimension-value-executor.test.ts. Staging-side coverage (the MCP tool's
|
||||
* filter matching + cap gates) lives in
|
||||
* extensions/general/mcp-server/__tests__/tag-journal-lines.test.ts. The RPC
|
||||
* itself (period/lock/registry/role enforcement) is covered by
|
||||
* tests/pg/dimension-retag.pg.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { PendingOperation } from '@/types'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
import { commitPendingOperation } from '../commit'
|
||||
|
||||
const uuidAt = (i: number) => `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`
|
||||
|
||||
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
|
||||
return {
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'retag_line_dimensions',
|
||||
status: 'pending',
|
||||
title: 'test',
|
||||
params: {},
|
||||
preview_data: {},
|
||||
result_data: null,
|
||||
actor_type: 'user',
|
||||
actor_id: null,
|
||||
actor_label: null,
|
||||
risk_level: 'medium',
|
||||
created_at: '2026-07-02T00:00:00Z',
|
||||
resolved_at: null,
|
||||
updated_at: '2026-07-02T00:00:00Z',
|
||||
...overrides,
|
||||
} as PendingOperation
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: retag_line_dimensions — schema validation', () => {
|
||||
it('rejects a non-UUID line id at the commit boundary (tampered params)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: { line_ids: ['not-a-uuid'], dimensions: { '6': 'P01' }, reason: 'Rätt projekt' },
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/Invalid line_ids/)
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects more than 500 line_ids', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: {
|
||||
line_ids: Array.from({ length: 501 }, (_, i) => uuidAt(i)),
|
||||
dimensions: { '6': 'P01' },
|
||||
reason: 'Rätt projekt',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/Invalid line_ids.*capped at 500/)
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing reason', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: { line_ids: [uuidAt(1)], dimensions: { '6': 'P01' } },
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/Invalid reason/)
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an empty dimensions bag (retag never bulk-clears)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: { line_ids: [uuidAt(1)], dimensions: {}, reason: 'Rensa allt' },
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/Invalid dimensions/)
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: retag_line_dimensions — execution', () => {
|
||||
it('happy path: one RPC call per line, aggregates changed/unchanged', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { changed: true, log_id: 'log-1' }, error: null }) // line 1 rpc
|
||||
enqueue({ data: { changed: false, log_id: null }, error: null }) // line 2 rpc (already tagged)
|
||||
enqueue({ data: null, error: null }) // finalize update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: {
|
||||
line_ids: [uuidAt(1), uuidAt(2)],
|
||||
dimensions: { '1': 'KS01', '6': 'P01' },
|
||||
reason: 'Retro-taggning av projektet',
|
||||
filter_summary: 'konto 4010, datum 2024-01-01–2024-12-31',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({
|
||||
retagged: 1,
|
||||
unchanged: 1,
|
||||
failed_count: 0,
|
||||
failed: [],
|
||||
dimensions: { '1': 'KS01', '6': 'P01' },
|
||||
filter_summary: 'konto 4010, datum 2024-01-01–2024-12-31',
|
||||
})
|
||||
|
||||
const rpc = supabase.rpc as ReturnType<typeof vi.fn>
|
||||
expect(rpc).toHaveBeenCalledTimes(2)
|
||||
expect(rpc.mock.calls[0][0]).toBe('retag_line_dimensions')
|
||||
expect(rpc.mock.calls[0][1]).toEqual({
|
||||
p_company_id: 'company-1',
|
||||
p_line_id: uuidAt(1),
|
||||
p_dimensions: { '1': 'KS01', '6': 'P01' },
|
||||
p_reason: 'Retro-taggning av projektet',
|
||||
p_user_id: 'user-1',
|
||||
})
|
||||
expect(rpc.mock.calls[1][1]).toMatchObject({ p_line_id: uuidAt(2) })
|
||||
})
|
||||
|
||||
it('partial failure: continues past a failing line and reports it', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { changed: true, log_id: 'log-1' }, error: null }) // line 1 ok
|
||||
enqueue({ data: null, error: { message: 'Perioden är låst — använd rättelseverifikat (storno).' } }) // line 2 fails
|
||||
enqueue({ data: { changed: true, log_id: 'log-3' }, error: null }) // line 3 ok
|
||||
enqueue({ data: null, error: null }) // finalize update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: {
|
||||
line_ids: [uuidAt(1), uuidAt(2), uuidAt(3)],
|
||||
dimensions: { '6': 'P01' },
|
||||
reason: 'Retro-taggning',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ retagged: 2, unchanged: 0, failed_count: 1 })
|
||||
expect(result.data?.failed).toEqual([
|
||||
{ line_id: uuidAt(2), error: 'Perioden är låst — använd rättelseverifikat (storno).' },
|
||||
])
|
||||
expect(supabase.rpc).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('caps the echoed failures at 20 but counts them all', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { changed: true, log_id: 'log-1' }, error: null }) // line 1 ok
|
||||
for (let i = 0; i < 22; i++) {
|
||||
enqueue({ data: null, error: { message: `fel ${i}` } })
|
||||
}
|
||||
enqueue({ data: null, error: null }) // finalize update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: {
|
||||
line_ids: Array.from({ length: 23 }, (_, i) => uuidAt(i)),
|
||||
dimensions: { '6': 'P01' },
|
||||
reason: 'Retro-taggning',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ retagged: 1, failed_count: 22 })
|
||||
expect((result.data?.failed as unknown[]).length).toBe(20)
|
||||
})
|
||||
|
||||
it('fails the operation when EVERY line fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: { message: 'Verifikationsraden hittades inte.' } })
|
||||
enqueue({ data: null, error: { message: 'Verifikationsraden hittades inte.' } })
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: {
|
||||
line_ids: [uuidAt(1), uuidAt(2)],
|
||||
dimensions: { '6': 'P01' },
|
||||
reason: 'Retro-taggning',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/Ingen rad kunde taggas om \(2 rader misslyckades\)/)
|
||||
expect(result.error).toMatch(/Verifikationsraden hittades inte/)
|
||||
})
|
||||
})
|
||||
@@ -75,6 +75,7 @@ import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||||
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
|
||||
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
|
||||
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
|
||||
import { BulkBookInboxSchema } from '@/lib/api/schemas'
|
||||
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
|
||||
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
|
||||
@@ -557,6 +558,83 @@ async function commitCreateDimensionValue(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executor for the staged retag_line_dimensions operation
|
||||
* (gnubok_tag_journal_lines — dimensions PR6). Loops the staged line_ids
|
||||
* through the retag_line_dimensions RPC — the ONE audited write path for
|
||||
* changing dimension tags on posted lines. The RPC enforces everything per
|
||||
* line at commit time (open period, company lock date, active registry
|
||||
* values, writer role, posted status) and writes an immutable
|
||||
* dimension_retag_log row before touching the line.
|
||||
*
|
||||
* Partial-success semantics: one line failing (e.g. its period was locked
|
||||
* between staging and approval) must not roll back the lines already
|
||||
* retagged — each RPC call is its own transaction. Failures are collected
|
||||
* and echoed (capped at 20) so the caller can re-stage just the failed set.
|
||||
* Only when EVERY line fails does the operation as a whole fail.
|
||||
*/
|
||||
async function commitRetagLineDimensions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
// Defense in depth: re-validate the staged params at the commit boundary so
|
||||
// a tampered pending_operations row cannot inject arbitrary ids or a
|
||||
// malformed bag (ASVS V4.5) — mirrors commitCreateDimensionValue.
|
||||
let validated
|
||||
try {
|
||||
validated = RetagLineDimensionsParamsSchema.parse(params)
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const issue = err.issues[0]
|
||||
const path = issue?.path?.join('.') ?? 'params'
|
||||
return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
let retagged = 0
|
||||
let unchanged = 0
|
||||
const failed: Array<{ line_id: string; error: string }> = []
|
||||
|
||||
for (const lineId of validated.line_ids) {
|
||||
const { data, error } = await supabase.rpc('retag_line_dimensions', {
|
||||
p_company_id: companyId,
|
||||
p_line_id: lineId,
|
||||
p_dimensions: validated.dimensions,
|
||||
p_reason: validated.reason,
|
||||
p_user_id: userId,
|
||||
})
|
||||
if (error) {
|
||||
failed.push({ line_id: lineId, error: error.message })
|
||||
continue
|
||||
}
|
||||
if ((data as { changed?: boolean } | null)?.changed) retagged++
|
||||
else unchanged++
|
||||
}
|
||||
|
||||
if (failed.length > 0 && retagged === 0 && unchanged === 0) {
|
||||
return {
|
||||
error: `Ingen rad kunde taggas om (${failed.length} rader misslyckades). Första felet: ${failed[0].error}`,
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
retagged,
|
||||
unchanged,
|
||||
failed_count: failed.length,
|
||||
// Echo at most 20 failures — enough to act on without bloating
|
||||
// result_data on a pathological 500-line all-but-one failure.
|
||||
failed: failed.slice(0, 20),
|
||||
dimensions: validated.dimensions,
|
||||
...(validated.filter_summary ? { filter_summary: validated.filter_summary } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function commitCreateTransaction(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -3654,6 +3732,9 @@ async function commitPendingOperationInner(
|
||||
case 'create_dimension_value':
|
||||
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'retag_line_dimensions':
|
||||
result = await commitRetagLineDimensions(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'create_invoice':
|
||||
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
|
||||
@@ -63,6 +63,10 @@ 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',
|
||||
// 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.
|
||||
retag_line_dimensions: 'medium',
|
||||
|
||||
// ── High: irreversible, compliance-critical, or external side-effects
|
||||
send_invoice: 'high', // emails the customer
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Authoritative server-side validation for the retag_line_dimensions staged
|
||||
* operation (dimensions PR6 — retro-tagging). Used by:
|
||||
* - The MCP tool gnubok_tag_journal_lines execute() before staging
|
||||
* (extensions/general/mcp-server/server.ts)
|
||||
* - commitRetagLineDimensions() before looping the retag_line_dimensions
|
||||
* RPC (lib/pending-operations/commit.ts)
|
||||
*
|
||||
* Defense in depth: validating at the commit boundary protects the DB even if
|
||||
* a caller writes directly to pending_operations.params bypassing the MCP
|
||||
* tool (ASVS V4.5 / ISO A.8.28), mirroring CreateDimensionValueParamsSchema.
|
||||
* The RPC itself re-enforces everything per line (open period, lock date,
|
||||
* active registry values, writer role) — this schema is the shape gate.
|
||||
*
|
||||
* The dimensions bag delegates to DimensionsBagSchema — THE bag schema shared
|
||||
* with the API layer and the voucher staging path — so the retag write path
|
||||
* cannot drift from how dimensions are validated everywhere else. An empty
|
||||
* bag is rejected: this operation tags lines, it never bulk-clears them.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
|
||||
|
||||
/**
|
||||
* 500-line cap per staged retag (dev_docs plan §3): keeps the approval
|
||||
* preview reviewable by a human and bounds the per-line RPC loop at commit.
|
||||
*/
|
||||
export const RETAG_MAX_LINES = 500
|
||||
|
||||
export const RetagLineDimensionsParamsSchema = z
|
||||
.object({
|
||||
line_ids: z
|
||||
.array(z.string().uuid('line_ids must contain journal_entry_lines UUIDs'))
|
||||
.min(1, 'line_ids must contain at least one line')
|
||||
.max(RETAG_MAX_LINES, `line_ids is capped at ${RETAG_MAX_LINES} lines per operation`),
|
||||
// Non-empty by design — and deliberately STRICTER than the direct API
|
||||
// path (RetagLineDimensionsSchema in lib/api/schemas.ts), which allows
|
||||
// {} so a human can untag phantom codes via the dialog/workbench. An
|
||||
// agent bulk-clearing dimension history is not a stageable operation
|
||||
// (#867 review documented the divergence).
|
||||
dimensions: DimensionsBagSchema.refine(
|
||||
(bag) => Object.keys(bag).length > 0,
|
||||
'dimensions must contain at least one {sie_dim_no: code} pair',
|
||||
),
|
||||
reason: z.preprocess(
|
||||
(v) => (typeof v === 'string' ? v.trim() : v),
|
||||
z
|
||||
.string()
|
||||
.min(3, 'Ange en anledning till ändringen (minst 3 tecken)')
|
||||
.max(500, 'reason is capped at 500 characters'),
|
||||
),
|
||||
/**
|
||||
* Human description of how the lines were selected (the tool's filter
|
||||
* block), carried only for the approval preview / audit context — the
|
||||
* executor never re-runs the filter, it acts on line_ids verbatim.
|
||||
*/
|
||||
filter_summary: z.string().max(500).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RetagLineDimensionsParams = z.infer<typeof RetagLineDimensionsParamsSchema>
|
||||
Reference in New Issue
Block a user