feat(dimensions): PR3 tagging — voucher-form pickers, MCP dimension tools with resolve-don't-select, engine soft validation (#859)

Phase 3 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false see zero change; existing free-text API writers keep
working (validation is toggle-governed).

Engine (soft validation):
- validateEntryDimensions() in dimension-resolver: zero queries for untagged
  entries; toggle off → passthrough; toggle on → one settings fetch + two
  registry queries, rejects unknown dims/codes and archived values with
  Swedish per-code messages (DimensionValidationError, 400, details.issues).
  Wired into createDraftEntry + updateDraftEntry before any insert; reversal/
  storno paths untouched (verbatim copies). Fails open on transient registry
  errors — soft validation must never block bookkeeping.

MCP (agent write path):
- New tools: gnubok_list_dimensions, gnubok_list_dimension_values (fuse.js
  fuzzy), gnubok_create_dimension_value (STAGED via pending_operations —
  agents never silently mint reporting values; new op type + CHECK migration
  + executor with duplicate-idempotency).
- create_voucher/correct_entry: per-line dimensions bag + default_dimensions,
  resolve-don't-select server-side (code OR natural-language name; exact →
  fuzzy ≤0.30 with ≥0.15 runner-up margin; non-exact resolutions echoed with
  confidence; ambiguous → ranked candidates, no auto-create).
- gnubok_get_agent_briefing gains a dimensions block (enabled, dims, top
  values) — omitted when registry empty.
- TOOL_SCOPE_MAP entries; risk tier low for staged value creation.

UI:
- JournalEntryForm (manual voucher + TransactionBookingDialog embed): header
  "+ Kostnadsställe/Projekt" progressive disclosure (gäller alla rader with
  documented inheritance rule) + per-row tag popover + compact KS·PR badges;
  gated on dimensions_enabled.
- Voucher detail: display-only dimension badges with registry-name resolution.
- EditDraftEntryDialog carries line dimensions so editing a draft no longer
  strips tags.

categorize/bulk_book dims deferred to PR7 (needs the bulk_book RPC migration).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 13:36:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6be6510d73
commit 11126d6d56
28 changed files with 3152 additions and 43 deletions
+59 -1
View File
@@ -28,8 +28,10 @@ import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import CorrectionChain from '@/components/bookkeeping/CorrectionChain'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types'
import type { JournalEntry, JournalEntryLine } from '@/types'
import type { UnderlagReference } from '@/lib/core/bookkeeping/journal-entry-references'
@@ -59,6 +61,25 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const [editingNotes, setEditingNotes] = useState(false)
const [notesValue, setNotesValue] = useState('')
const [savingNotes, setSavingNotes] = useState(false)
// Dimension registry, fetched once when any line carries a dimensions map —
// used to resolve display names for the line badges ('KS: Butik'); badges
// fall back to raw codes when the fetch fails or a code is unregistered.
const [registryDims, setRegistryDims] = useState<DimensionDto[] | null>(null)
useEffect(() => {
if (registryDims !== null) return
const entryLines = (entry?.lines || []) as JournalEntryLine[]
if (!entryLines.some((l) => l.dimensions && Object.keys(l.dimensions).length > 0)) return
let cancelled = false
fetchDimensions()
.then((dims) => {
if (!cancelled) setRegistryDims(dims)
})
.catch(() => {/* display-only — raw codes are fine */})
return () => {
cancelled = true
}
}, [entry, registryDims])
const fetchData = useCallback(async () => {
setIsLoading(true)
@@ -251,6 +272,39 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
// Include current entry in the chain for the visualization
const fullChain = [entry, ...chain]
// SIE dimension badge prefixes — statutory Swedish abbreviations
// (kostnadsställe/projekt); stays Swedish per .claude/rules/i18n.md.
const DIM_BADGE_PREFIX: Record<string, string> = { '1': 'KS', '6': 'PR' }
// Display-only dimension badges for a line (e.g. 'KS: Butik', 'PR: P001').
// Names resolve through the registry when loaded; raw codes otherwise.
const renderDimensionBadges = (line: JournalEntryLine) => {
const entries = Object.entries(line.dimensions ?? {})
.filter(([, code]) => code)
.sort(([a], [b]) => Number(a) - Number(b))
if (entries.length === 0) return null
return (
<div className="mt-1 flex flex-wrap gap-1">
{entries.map(([dimNo, code]) => {
const dim = registryDims?.find((d) => String(d.sie_dim_no) === dimNo)
const value = dim?.values.find((v) => v.code === code)
const prefix = DIM_BADGE_PREFIX[dimNo] ?? dim?.name ?? `Dim ${dimNo}`
const hasName = !!value && value.name !== '' && value.name !== value.code
return (
<Badge
key={dimNo}
variant="outline"
className="font-mono text-[11px] font-normal"
title={`${dim?.name ?? prefix} ${code}${hasName ? ` – ${value.name}` : ''}`}
>
{prefix}: {hasName ? value.name : code}
</Badge>
)
})}
</div>
)
}
return (
<div className="space-y-8">
{/* Back link */}
@@ -575,7 +629,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
return (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
<td className="py-2 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
{renderDimensionBadges(line)}
</td>
<td className="py-2 text-right tabular-nums">
{Number(line.debit_amount) > 0 && (
<>
@@ -629,6 +686,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{line.line_description && (
<p className="text-xs text-muted-foreground truncate">{line.line_description}</p>
)}
{renderDimensionBadges(line)}
</div>
<div className="text-right shrink-0 text-sm tabular-nums">
{Number(line.debit_amount) > 0 && (
@@ -35,6 +35,10 @@ export default function EditDraftEntryDialog({ entry, open, onOpenChange, onUpda
debit_amount: Number(l.debit_amount) > 0 ? String(l.debit_amount) : '',
credit_amount: Number(l.credit_amount) > 0 ? String(l.credit_amount) : '',
line_description: l.line_description || '',
// Carry the line's dimensions into the form — the PATCH replaces all
// lines, so omitting this would silently strip existing tags.
dimensions:
l.dimensions && Object.keys(l.dimensions).length > 0 ? { ...l.dimensions } : undefined,
}))
return (
+214 -23
View File
@@ -10,12 +10,14 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser } from 'lucide-react'
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser, Tags } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
@@ -53,6 +55,8 @@ export interface FormLine {
currency?: string
amount_in_currency?: number
exchange_rate?: number
/** SIE dimension map {sie_dim_no: object_code}, e.g. {"1":"KS01","6":"P001"}. */
dimensions?: Record<string, string>
}
interface Props {
@@ -106,6 +110,20 @@ export default function JournalEntryForm({
const [description, setDescription] = useState(initialDescription ?? '')
const [notes, setNotes] = useState(initialNotes ?? '')
const [showNotes, setShowNotes] = useState(false)
// Dimension tagging (kostnadsställe/projekt). The affordances render only
// when company_settings.dimensions_enabled — a UI-visibility gate; lines
// that already carry dimensions (e.g. a draft being edited) still round-trip
// untouched when the toggle is off.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [showDims, setShowDims] = useState(false)
// Header-level default dims ("gäller alla rader"). The per-row maps on
// `lines` are the ONE source of truth — this state only drives the header
// comboboxes; setHeaderDimension writes the default through to the rows.
const [headerDims, setHeaderDims] = useState<Record<string, string>>({})
// Which row's dimension popover is open (desktop table), and its container
// for the outside-click close.
const [dimPopoverRow, setDimPopoverRow] = useState<number | null>(null)
const dimPopoverRef = useRef<HTMLDivElement | null>(null)
const [lines, setLines] = useState<FormLine[]>(
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
)
@@ -193,14 +211,16 @@ export default function JournalEntryForm({
fetchPeriods()
fetchAccounts()
loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */})
// Fetch default voucher series from company settings — prefer the
// per-source-type mapping when present; fall back to the legacy
// default_voucher_series, then to 'A'.
// In edit mode the draft's own series is pre-filled — never override it
// from the company defaults.
if (!embedded && !editEntryId) {
fetch('/api/settings').then(r => r.json()).then(({ data }) => {
if (!data) return
// Company settings power two things here: dimensions_enabled gates the
// tagging affordances (all modes, incl. the TransactionBookingDialog
// embed), and the default voucher series seeds the standalone form —
// prefer the per-source-type mapping when present; fall back to the legacy
// default_voucher_series, then to 'A'. In edit mode the draft's own series
// is pre-filled — never override it from the company defaults.
fetch('/api/settings').then(r => r.json()).then(({ data }) => {
if (!data) return
setDimensionsEnabled(data.dimensions_enabled === true)
if (!embedded && !editEntryId) {
const effectiveSourceType = sourceType ?? 'manual'
const perSource = resolveDefaultSeriesForSource(
data as { default_voucher_series_per_source_type?: Record<string, string> | null } | null,
@@ -208,8 +228,8 @@ export default function JournalEntryForm({
)
const fallback = data.default_voucher_series || 'A'
setVoucherSeries(perSource !== 'A' ? perSource : fallback)
}).catch(() => {/* keep 'A' */})
}
}
}).catch(() => {/* keep 'A' + hidden dimension affordances */})
}, [embedded, sourceType, editEntryId])
// Auto-select period when entry date changes
@@ -346,15 +366,95 @@ export default function JournalEntryForm({
}
}, [accountsKey, entryDate])
// New rows inherit the current header default (a row without a per-row
// override follows the header — see setHeaderDimension).
const makeBlankLine = useCallback(
(): FormLine =>
Object.keys(headerDims).length > 0
? { ...BLANK_LINE, dimensions: { ...headerDims } }
: { ...BLANK_LINE },
[headerDims]
)
const addLine = () => {
setLines([...lines, { ...BLANK_LINE }])
setLines([...lines, makeBlankLine()])
}
const removeLine = (index: number) => {
if (lines.length <= 2) return
setLines(lines.filter((_, i) => i !== index))
// Keep the open dimension popover attached to the same row after the splice.
setDimPopoverRow((r) => (r === null ? r : r === index ? null : r > index ? r - 1 : r))
}
/**
* Header default write-through. Inheritance rule: a row inherits dimension
* `dimNo` iff its current value equals the previous header default (unset
* counts as equal to an unset default). Inheriting rows follow the change
* (including clearing); rows whose value differs are per-row overrides and
* are left untouched. A row explicitly set to the same code as the header is
* indistinguishable from an inherited one and follows later header changes
* by design — the per-row maps stay the single source of truth.
*/
const setHeaderDimension = (dimNo: string, code: string | null) => {
const prev = headerDims[dimNo]
const next = code?.trim() || undefined
setHeaderDims((h) => {
const out = { ...h }
if (next) out[dimNo] = next
else delete out[dimNo]
return out
})
setLines((ls) =>
ls.map((l) => {
if (l.dimensions?.[dimNo] !== prev) return l // per-row override — keep
const dims = { ...(l.dimensions ?? {}) }
if (next) dims[dimNo] = next
else delete dims[dimNo]
return { ...l, dimensions: Object.keys(dims).length > 0 ? dims : undefined }
})
)
}
const updateLineDimension = (index: number, dimNo: string, code: string | null) => {
setLines((ls) =>
ls.map((l, i) => {
if (i !== index) return l
const dims = { ...(l.dimensions ?? {}) }
const trimmed = code?.trim()
if (trimmed) dims[dimNo] = trimmed
else delete dims[dimNo]
return { ...l, dimensions: Object.keys(dims).length > 0 ? dims : undefined }
})
)
}
// Compact per-row display, e.g. "KS01 · P001" (dim number order).
const compactDims = (dims: Record<string, string>) =>
Object.entries(dims)
.filter(([, v]) => v)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v)
.join(' · ')
// Close the row dimension popover on outside click (same pattern as the
// comboboxes' own dropdowns; their option clicks preventDefault so a
// selection never counts as outside).
useEffect(() => {
if (dimPopoverRow === null) return
function handlePointerDown(e: MouseEvent | TouchEvent) {
if (dimPopoverRef.current && !dimPopoverRef.current.contains(e.target as Node)) {
setDimPopoverRow(null)
}
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('touchstart', handlePointerDown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('touchstart', handlePointerDown)
}
}, [dimPopoverRow])
const updateLine = (index: number, field: keyof FormLine, value: string) => {
const updated = [...lines]
updated[index] = { ...updated[index], [field]: value }
@@ -437,9 +537,9 @@ export default function JournalEntryForm({
if (!last) return prev
const trailingBlank =
last.account_number === '' && last.debit_amount === '' && last.credit_amount === ''
return trailingBlank ? prev : [...prev, { ...BLANK_LINE }]
return trailingBlank ? prev : [...prev, makeBlankLine()]
})
}, [lines])
}, [lines, makeBlankLine])
// Inline (bare) review: move focus to the confirm button when it opens so
// Enter posts — parity with the ConfirmationDialog's autoFocusConfirm.
@@ -518,6 +618,7 @@ export default function JournalEntryForm({
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setHeaderDims({})
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
@@ -589,6 +690,13 @@ export default function JournalEntryForm({
line_description: l.line_description || undefined,
}
if (l.dimensions) {
const dims = Object.fromEntries(
Object.entries(l.dimensions).filter(([, v]) => typeof v === 'string' && v.trim() !== '')
)
if (Object.keys(dims).length > 0) base.dimensions = dims
}
if (l.currency) {
base.currency = l.currency
if (l.amount_in_currency != null) base.amount_in_currency = l.amount_in_currency
@@ -675,6 +783,7 @@ export default function JournalEntryForm({
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setHeaderDims({})
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
@@ -762,6 +871,7 @@ export default function JournalEntryForm({
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setHeaderDims({})
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
@@ -995,6 +1105,15 @@ export default function JournalEntryForm({
+ {t('internal_note')}
</button>
)}
{dimensionsEnabled && !showDims && (
<button
type="button"
onClick={() => setShowDims(true)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
+ {t('add_dimensions')}
</button>
)}
</div>
{isForeign && (
@@ -1057,6 +1176,19 @@ export default function JournalEntryForm({
</div>
)}
{/* Header default dims — writes through to all rows without a per-row
override (see setHeaderDimension for the inheritance rule). */}
{dimensionsEnabled && showDims && (
<div className="max-w-md space-y-1">
<LineDimensionFields
dimensions={headerDims}
onChange={setHeaderDimension}
inputClassName="h-8"
/>
<p className="text-xs text-muted-foreground">{t('dimensions_apply_all_hint')}</p>
</div>
)}
{periodMismatch === 'no_period' && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
@@ -1141,6 +1273,12 @@ export default function JournalEntryForm({
/>
</div>
</div>
{dimensionsEnabled && (
<LineDimensionFields
dimensions={line.dimensions}
onChange={(dimNo, code) => updateLineDimension(index, dimNo, code)}
/>
)}
{/^\d{4}$/.test(line.account_number) && (
<div className="flex justify-end text-xs text-muted-foreground tabular-nums pt-0.5">
{accountBalances[line.account_number] === null || accountBalances[line.account_number] === undefined ? (
@@ -1220,6 +1358,13 @@ export default function JournalEntryForm({
placeholder={t('line_description_placeholder')}
className="h-8"
/>
{line.dimensions &&
Object.keys(line.dimensions).length > 0 &&
(line.account_number || line.debit_amount || line.credit_amount) && (
<Badge variant="outline" className="mt-1 font-mono text-[11px] font-normal">
{compactDims(line.dimensions)}
</Badge>
)}
</td>
<td className="py-1.5 px-1">
<Input
@@ -1261,15 +1406,61 @@ export default function JournalEntryForm({
})()}
</td>
<td className="py-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px]"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div className="flex items-center justify-end">
{dimensionsEnabled && (
<div
className="relative"
ref={dimPopoverRow === index ? dimPopoverRef : undefined}
>
<Button
variant="ghost"
size="sm"
onClick={() => setDimPopoverRow(dimPopoverRow === index ? null : index)}
className={`h-8 w-8 p-0 min-h-[44px] min-w-[44px] ${
line.dimensions && Object.keys(line.dimensions).length > 0
? 'text-foreground'
: 'text-muted-foreground'
}`}
aria-label={t('row_dimensions_aria')}
aria-expanded={dimPopoverRow === index}
title={t('row_dimensions_aria')}
>
<Tags className="h-3.5 w-3.5" />
</Button>
{dimPopoverRow === index && (
<div
className="absolute right-0 top-full z-50 mt-1 w-64 rounded-md border bg-card p-3 shadow-md"
onKeyDown={(e) => {
// The comboboxes preventDefault their own Escape
// (closing their dropdown) — only an unhandled
// Escape closes the popover.
if (e.key === 'Escape' && !e.defaultPrevented) {
e.preventDefault()
e.stopPropagation()
setDimPopoverRow(null)
}
}}
>
<LineDimensionFields
stacked
dimensions={line.dimensions}
onChange={(dimNo, code) => updateLineDimension(index, dimNo, code)}
inputClassName="h-8"
/>
</div>
)}
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px]"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
@@ -0,0 +1,60 @@
'use client'
import { Label } from '@/components/ui/label'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
interface LineDimensionFieldsProps {
/** Current dimensions map ({sie_dim_no: object_code}) — a line's map or the header default. */
dimensions: Record<string, string> | undefined
/** Fired per dimension; `code === null` clears the value. */
onChange: (sieDimNo: string, code: string | null) => void
disabled?: boolean
/** Vertical layout for narrow containers (row popover); default is a 2-col grid. */
stacked?: boolean
/** Extra classes merged into the combobox inputs (pass 'h-8' for dense contexts). */
inputClassName?: string
}
/**
* The Kostnadsställe + Projekt combobox pair (SIE dims 1/6) used by the
* voucher form's header default, the per-row tag popover, and the mobile line
* cards. Labels are the seeded system-dimension names and hardcoded Swedish —
* the component mounts on the voucher editor, a stays-Swedish surface per
* .claude/rules/i18n.md (same convention as DimensionCombobox).
*/
export default function LineDimensionFields({
dimensions,
onChange,
disabled,
stacked,
inputClassName,
}: LineDimensionFieldsProps) {
return (
<div className={stacked ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
<div>
<Label className="text-xs text-muted-foreground">Kostnadsställe</Label>
<div className="mt-1">
<DimensionCombobox
sieDimNo="1"
value={dimensions?.['1'] ?? null}
onChange={(code) => onChange('1', code)}
disabled={disabled}
className={inputClassName}
/>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Projekt</Label>
<div className="mt-1">
<DimensionCombobox
sieDimNo="6"
value={dimensions?.['6'] ?? null}
onChange={(code) => onChange('6', code)}
disabled={disabled}
className={inputClassName}
/>
</div>
</div>
</div>
)
}
@@ -50,6 +50,10 @@ function mockSupabase(opts: {
company?: { name: string | null; org_number: string | null; entity_type: string | null } | null
// company_settings.accounting_method. undefined → no settings row (null data).
accountingMethod?: string | null
// Dimension registry (dimensions PR3). Default: empty → block omitted.
dimensionRows?: Array<{ id: string; sie_dim_no: number; name: string }>
dimensionValueRows?: Array<{ dimension_id: string; code: string; name: string }>
dimensionsEnabled?: boolean
errors?: { profile?: string; memory?: string; atoms?: string }
}) {
const profile = opts.profile === undefined ? null : opts.profile
@@ -57,6 +61,16 @@ function mockSupabase(opts: {
const atomRows = opts.atomRows ?? []
const errors = opts.errors ?? {}
/** Order-agnostic chainable query resolving to `data` when awaited. */
const chainResolving = (data: unknown) => {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order', 'limit']) {
chain[m] = vi.fn(() => chain)
}
chain.then = (resolve: (v: unknown) => void) => resolve({ data, error: null })
return chain
}
return {
from: vi.fn((table: string) => {
if (table === 'profiles') {
@@ -132,15 +146,24 @@ function mockSupabase(opts: {
eq: vi.fn(() => ({
maybeSingle: vi.fn().mockResolvedValue({
data:
opts.accountingMethod === undefined
opts.accountingMethod === undefined && opts.dimensionsEnabled === undefined
? null
: { accounting_method: opts.accountingMethod },
: {
accounting_method: opts.accountingMethod ?? null,
dimensions_enabled: opts.dimensionsEnabled ?? false,
},
error: null,
}),
})),
})),
}
}
if (table === 'dimensions') {
return chainResolving(opts.dimensionRows ?? [])
}
if (table === 'dimension_values') {
return chainResolving(opts.dimensionValueRows ?? [])
}
throw new Error(`Unexpected table in test mock: ${table}`)
}),
}
@@ -344,4 +367,81 @@ describe('gnubok_get_agent_briefing tool', () => {
tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
).rejects.toThrow(/Failed to load agent memory.*rls-denied/)
})
it('omits the dimensions block entirely when the registry is empty', async () => {
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
const supabase = mockSupabase({ profile: null })
const result = (await tool.execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as Record<string, unknown>
expect('dimensions' in result).toBe(false)
})
it('returns the dimensions block with enabled flag, counts, and top values capped at 10', async () => {
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
const manyValues = Array.from({ length: 12 }, (_, i) => ({
dimension_id: 'dim-6',
code: `P${String(i + 1).padStart(3, '0')}`,
name: `Projekt ${i + 1}`,
}))
const supabase = mockSupabase({
profile: null,
dimensionsEnabled: true,
dimensionRows: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe' },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt' },
],
dimensionValueRows: [
{ dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm' },
...manyValues,
],
})
const result = (await tool.execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as {
dimensions?: {
enabled: boolean
dimensions: Array<{
sie_dim_no: number
name: string
active_value_count: number
top_values: Array<{ code: string; name: string }>
}>
}
}
expect(result.dimensions).toBeDefined()
expect(result.dimensions!.enabled).toBe(true)
expect(result.dimensions!.dimensions).toHaveLength(2)
const [ks, projekt] = result.dimensions!.dimensions
expect(ks).toMatchObject({ sie_dim_no: 1, name: 'Kostnadsställe', active_value_count: 1 })
expect(ks.top_values).toEqual([{ code: 'KS01', name: 'Stockholm' }])
expect(projekt.active_value_count).toBe(12)
expect(projekt.top_values).toHaveLength(10) // capped
})
it('reports enabled=false in the dimensions block when the toggle is off but values exist', async () => {
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
const supabase = mockSupabase({
profile: null,
dimensionsEnabled: false,
dimensionRows: [{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }],
dimensionValueRows: [{ dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren' }],
})
const result = (await tool.execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as { dimensions?: { enabled: boolean } }
expect(result.dimensions?.enabled).toBe(false)
})
})
@@ -0,0 +1,580 @@
/**
* Dimensions PR3 — MCP surface tests.
*
* Covers the three new tools (gnubok_list_dimensions,
* gnubok_list_dimension_values, staged gnubok_create_dimension_value), the
* resolve-don't-select helper (exact / fuzzy / ambiguous / none / archived),
* and the dims bag flowing from gnubok_create_voucher params into the staged
* lines. Executor-side coverage (commitCreateDimensionValue incl. duplicate
* idempotency) lives in lib/pending-operations/__tests__/.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { tools } from '../server'
import {
resolveValueInDimension,
resolveDimensionBags,
DimensionResolutionError,
mergeLineDimensions,
parseDimensionsArg,
type DimensionRegistryEntry,
} from '../dimensions'
const listDimensions = tools.find((t) => t.name === 'gnubok_list_dimensions')!
const listDimensionValues = tools.find((t) => t.name === 'gnubok_list_dimension_values')!
const createDimensionValue = tools.find((t) => t.name === 'gnubok_create_dimension_value')!
const createVoucher = tools.find((t) => t.name === 'gnubok_create_voucher')!
beforeEach(() => {
vi.clearAllMocks()
})
function makeDim(overrides: Partial<DimensionRegistryEntry> = {}): DimensionRegistryEntry {
return {
id: 'dim-6',
sie_dim_no: 6,
name: 'Projekt',
resets_annually: false,
is_system: true,
is_active: true,
sort_order: 20,
values: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Kontorsflytt Solna', is_active: true, start_date: null, end_date: null },
{ id: 'v3', code: 'P099', name: 'Gammalt projekt', is_active: false, start_date: null, end_date: null },
],
...overrides,
}
}
// ── Registration + scopes ────────────────────────────────────────────────────
describe('dimension tools registration', () => {
it('all three tools exist and are mapped in TOOL_SCOPE_MAP (unmapped = any key)', () => {
expect(listDimensions).toBeDefined()
expect(listDimensionValues).toBeDefined()
expect(createDimensionValue).toBeDefined()
expect(TOOL_SCOPE_MAP.gnubok_list_dimensions).toBe('reports:read')
expect(TOOL_SCOPE_MAP.gnubok_list_dimension_values).toBe('reports:read')
expect(TOOL_SCOPE_MAP.gnubok_create_dimension_value).toBe('bookkeeping:write')
})
it('gnubok_create_dimension_value stages (staged-operation output contract)', () => {
const schema = createDimensionValue.outputSchema as {
properties?: Record<string, unknown>
required?: string[]
}
expect(schema?.properties?.staged).toBeDefined()
expect(schema?.required).toContain('staged')
expect(createDimensionValue.description).toMatch(/stag(e|ing)/i)
})
})
// ── gnubok_list_dimensions ───────────────────────────────────────────────────
describe('gnubok_list_dimensions', () => {
it('seeds system dims then returns the registry with nested values (dashboard GET shape)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure_company_dimensions rpc
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
error: null,
})
enqueue({
data: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = (await listDimensions.execute({}, 'company-1', 'user-1', supabase as never)) as {
dimensions: Array<{ sie_dim_no: number; values: Array<{ code: string }> }>
}
expect(result.dimensions).toHaveLength(2)
expect(result.dimensions[0].sie_dim_no).toBe(1)
expect(result.dimensions[0].values.map((v) => v.code)).toEqual(['KS01'])
expect(result.dimensions[1].values.map((v) => v.code)).toEqual(['P001'])
expect((supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('ensure_company_dimensions')
})
it('surfaces the ensure RPC failure', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'rls denied' } })
await expect(
listDimensions.execute({}, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/rls denied/)
})
})
// ── gnubok_list_dimension_values ─────────────────────────────────────────────
describe('gnubok_list_dimension_values', () => {
it('rejects an unregistered dimension number', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: null, error: null }) // dimensions lookup → not found
await expect(
listDimensionValues.execute({ sie_dim_no: 12 }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/Dimension 12 finns inte/)
})
it('lists values alphabetically without a query', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_active: true }, error: null })
enqueue({
data: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Kontorsflytt', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = (await listDimensionValues.execute(
{ sie_dim_no: 6 },
'company-1',
'user-1',
supabase as never,
)) as { dimension: { sie_dim_no: number }; values: Array<{ code: string; confidence?: number }>; count: number }
expect(result.dimension.sie_dim_no).toBe(6)
expect(result.count).toBe(2)
expect(result.values[0].confidence).toBeUndefined()
})
it('ranks by fuzzy confidence when a query is given', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_active: true }, error: null })
enqueue({
data: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Kontorsflytt Solna', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = (await listDimensionValues.execute(
{ sie_dim_no: 6, query: 'villa almgren' },
'company-1',
'user-1',
supabase as never,
)) as { values: Array<{ code: string; confidence?: number }> }
expect(result.values[0].code).toBe('P001')
expect(result.values[0].confidence).toBeGreaterThan(0.5)
})
})
// ── gnubok_create_dimension_value (staging) ──────────────────────────────────
describe('gnubok_create_dimension_value — staging', () => {
it('rejects a non-Fortnox code before any staging', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P 001"', name: 'Bad code' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Koden får bara innehålla/)
})
it('rejects when the code already exists as an active value', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: { id: 'v1', code: 'P001', name: 'Villa Almgren', is_active: true }, error: null })
await expect(
createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P001', name: 'Duplicate' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/finns redan/)
})
it('rejects an archived code with the reactivation hint', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: { id: 'v1', code: 'P099', name: 'Gammalt', is_active: false }, error: null })
await expect(
createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P099', name: 'Revive attempt' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/arkiverat — återaktivera/)
})
it('rejects value dates on a resets-annually dimension (kostnadsställe)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true }, error: null })
await expect(
createDimensionValue.execute(
{ sie_dim_no: 1, code: 'KS01', name: 'Stockholm', start_date: '2026-01-01' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Start-\/slutdatum är inte tillåtna/)
})
it('happy path: stages a create_dimension_value pending operation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: null, error: null }) // duplicate lookup → none
enqueue({ data: { id: 'op-dim-1' }, error: null }) // pending_operations insert
const result = (await createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P010', name: 'Villa Almgren etapp 2' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
)) as { staged: boolean; operation_id?: string; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-dim-1')
expect(result.risk_level).toBe('low')
expect(result.preview.code).toBe('P010')
expect(result.preview.dimension_name).toBe('Projekt')
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(true)
})
it('dry_run validates and previews without staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: null, error: null }) // duplicate lookup → none
const result = (await createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P010', name: 'Etapp 2', dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; dry_run?: boolean }
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
})
// ── Resolve-don't-select helper ──────────────────────────────────────────────
describe('resolveValueInDimension', () => {
it('resolves an exact code match without echo (exact=true)', () => {
const r = resolveValueInDimension(makeDim(), 'P001')
expect(r).toMatchObject({ code: 'P001', exact: true, confidence: 1 })
})
it('resolves an exact name match with confidence 1 but exact=false (echoed)', () => {
const r = resolveValueInDimension(makeDim(), 'Kontorsflytt Solna')
expect(r).toMatchObject({ code: 'P002', exact: false, confidence: 1 })
})
it('resolves a unique high-confidence fuzzy hit', () => {
const r = resolveValueInDimension(makeDim(), 'villa almgren tak')
expect(r.code).toBe('P001')
expect(r.exact).toBe(false)
expect(r.confidence).toBeGreaterThan(0.7)
})
it('rejects an archived code with the Swedish reactivation message', () => {
expect(() => resolveValueInDimension(makeDim(), 'P099')).toThrow(/arkiverat — återaktivera/)
})
it('rejects an unknown value with the create-first instruction (never auto-creates)', () => {
try {
resolveValueInDimension(makeDim(), 'Bryggeriet ombyggnad')
expect.unreachable('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(DimensionResolutionError)
expect((err as Error).message).toMatch(/Okänt projekt: "Bryggeriet ombyggnad" \(dimension 6\)/)
expect((err as Error).message).toMatch(/gnubok_create_dimension_value/)
}
})
it('rejects ambiguous near-tied candidates with a ranked list', () => {
const dim = makeDim({
values: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren etapp 1', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Villa Almgren etapp 2', is_active: true, start_date: null, end_date: null },
],
})
try {
resolveValueInDimension(dim, 'Villa Almgren etapp')
expect.unreachable('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(DimensionResolutionError)
const e = err as DimensionResolutionError
expect(e.candidates.length).toBeGreaterThanOrEqual(2)
expect(e.message).toMatch(/Kandidater/)
expect(e.message).toMatch(/gnubok_create_dimension_value/)
}
})
})
describe('resolveDimensionBags', () => {
it('costs zero queries when no line carries a bag', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await resolveDimensionBags(supabase as never, 'company-1', [undefined, undefined])
expect(result.resolutions).toEqual([])
expect(supabase.from).not.toHaveBeenCalled()
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('passes bags through verbatim when dimensions_enabled is false (backward compatible)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: false }, error: null }) // company_settings
const result = await resolveDimensionBags(supabase as never, 'company-1', [{ '6': 'fritext-projekt' }])
expect(result.bags).toEqual([{ '6': 'fritext-projekt' }])
expect(result.resolutions).toEqual([])
expect(supabase.rpc).not.toHaveBeenCalled() // no ensure, no registry reads
})
it('resolves names to codes and echoes non-exact resolutions when enabled', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true }, error: null }) // company_settings
enqueue({ data: null, error: null }) // ensure rpc
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
error: null,
})
enqueue({
data: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = await resolveDimensionBags(supabase as never, 'company-1', [
{ '1': 'KS01', '6': 'villa almgren tak' },
{ '6': 'villa almgren tak' },
])
expect(result.bags).toEqual([
{ '1': 'KS01', '6': 'P001' },
{ '6': 'P001' },
])
// Exact code KS01 is not echoed; the fuzzy name is echoed ONCE (cached).
expect(result.resolutions).toHaveLength(1)
expect(result.resolutions[0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
expect(result.resolutions[0].confidence).toBeGreaterThan(0.7)
})
it('rejects a dim number with no registry row when enabled', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null }) // ensure rpc
enqueue({
data: [{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 }],
error: null,
})
enqueue({ data: [], error: null })
await expect(
resolveDimensionBags(supabase as never, 'company-1', [{ '12': 'X' }]),
).rejects.toThrow(/Okänd dimension 12/)
})
})
describe('mergeLineDimensions / parseDimensionsArg', () => {
it('line bag wins per key over defaults and legacy aliases fill unset keys', () => {
const merged = mergeLineDimensions(
{ dimensions: { '6': 'P002' }, cost_center: 'KS01' },
{ '6': 'P001', '1': 'KS99' },
)
// Line's own bag beats the default for dim 6; the line's cost_center alias
// beats the default for dim 1 (per-line explicit > voucher default).
expect(merged).toEqual({ '1': 'KS01', '6': 'P002' })
})
it('returns undefined when nothing is tagged', () => {
expect(mergeLineDimensions({}, undefined)).toBeUndefined()
})
it('parseDimensionsArg throws loudly on an invalid bag shape', () => {
expect(() => parseDimensionsArg({ '0': 'X' }, 'default_dimensions')).toThrow(/Invalid default_dimensions/)
expect(() => parseDimensionsArg({ '6': 'har"citat' }, 'lines[0].dimensions')).toThrow(/Invalid lines\[0\]\.dimensions/)
})
})
// ── Dims bag flowing through gnubok_create_voucher ───────────────────────────
describe('gnubok_create_voucher — dimensions bag', () => {
it('resolves and stages per-line dims + default_dimensions onto the staged lines', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
error: null,
})
enqueue({
data: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
],
error: null,
})
// fiscal period (explicit id path)
enqueue({
data: { id: 'fp-1', is_closed: false, period_start: '2026-01-01', period_end: '2026-12-31', name: '2026' },
error: null,
})
// chart_of_accounts
enqueue({
data: [
{ account_number: '4010', account_name: 'Inköp material', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
// resolvePeriodStatusForDate: 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
// pending_operations insert
enqueue({ data: { id: 'op-dims' }, error: null })
const result = (await createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Material Villa Almgren',
fiscal_period_id: 'fp-1',
default_dimensions: { '6': 'villa almgren tak' },
lines: [
{ account_number: '4010', debit_amount: 250, credit_amount: 0, dimensions: { '1': 'KS01' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
lines: Array<{ dimensions: Record<string, string> | null }>
dimension_resolutions?: Array<{ dimension: number; input: string; resolved_code: string; resolved_name: string; confidence: number }>
}
}
expect(result.staged).toBe(true)
// Expense line: own dim 1 + the voucher default dim 6, resolved to codes.
expect(result.preview.lines[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// Bank line inherits the voucher default too (it lacks the key itself).
expect(result.preview.lines[1].dimensions).toEqual({ '6': 'P001' })
// Non-exact resolution is echoed with the full contract shape.
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
})
it('rejects before staging when a dims value has no registry match (no auto-create)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null }) // ensure rpc
enqueue({
data: [{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 }],
error: null,
})
enqueue({
data: [{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null }],
error: null,
})
await expect(
createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Unknown project',
fiscal_period_id: 'fp-1',
lines: [
{ account_number: '4010', debit_amount: 100, credit_amount: 0, dimensions: { '6': 'Bryggeriet ombyggnad' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Okänt projekt[\s\S]*gnubok_create_dimension_value/)
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
it('keeps the untagged path query-free (legacy tests unchanged): no settings read without dims', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { id: 'fp-1', is_closed: false, period_start: '2026-01-01', period_end: '2026-12-31', name: '2026' },
error: null,
})
enqueue({
data: [
{ account_number: '1010', account_name: 'Balanserade utgifter', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
enqueue({ data: null, error: null }) // period status layer 1
enqueue({ data: null, error: null }) // period status layer 2
enqueue({ data: { id: 'op-plain' }, error: null }) // pending_operations insert
const result = (await createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Ingen dimension',
fiscal_period_id: 'fp-1',
lines: [
{ account_number: '1010', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { dimension_resolutions?: unknown } }
expect(result.staged).toBe(true)
expect(result.preview.dimension_resolutions).toBeUndefined()
expect(supabase.rpc).not.toHaveBeenCalled()
})
})
@@ -64,9 +64,16 @@ describe('tools/list payload size guard', () => {
// plus per-line supplier-invoice overrides). Each new tool carries its
// inputSchema + staging _meta; the growth is genuine wire data, not prose,
// so descriptions are already at their trimmed floor (~180–220 chars).
// * 40K → 42K with dimensions PR3: gnubok_list_dimensions +
// gnubok_list_dimension_values (nested registry output schemas) + staged
// gnubok_create_dimension_value (STAGED_OPERATION_SCHEMA + _meta), the
// dims bag + default_dimensions on create_voucher/correct_entry, and the
// agent-briefing dimensions block. Descriptions were trimmed first
// (~200 tokens recovered); the remainder is schema structure agents
// depend on for resolve-don't-select, not trimmable prose.
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(40_000)
expect(approxTokens).toBeLessThan(42_000)
})
})
+425
View File
@@ -0,0 +1,425 @@
/**
* Dimension helpers for the MCP server (PR3 of the dimensions plan —
* dev_docs/dimensions_implementation_plan.md §6).
*
* Two responsibilities, shared by the dimension tools and the dims-bag write
* tools (gnubok_create_voucher / gnubok_correct_entry):
*
* 1. Registry access — ensure_company_dimensions + the same two-query fetch
* the dashboard GET /api/dimensions uses, returning the identical shape.
*
* 2. Resolve-don't-select — an incoming dimension value may be an object
* code ("KS01") OR a natural-language name ("Villa Almgren tak"). The
* server resolves exact code → exact name → fuse.js fuzzy over the
* dimension's ACTIVE values. A single high-confidence hit resolves (and
* is echoed back with its confidence); multiple/low-confidence candidates
* reject with a ranked list so the agent retries with a code or stages
* gnubok_create_dimension_value. NO auto-create, ever — agents must not
* silently mint reporting values.
*
* Resolution honours the validation contract: company_settings.dimensions_enabled
* is fetched ONCE; when false the bags pass through verbatim (free-text
* backward compatibility — the engine skips validation too), when true every
* value must land on an active registry value. Untagged entries cost zero
* queries.
*/
import Fuse from 'fuse.js'
import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import { z } from 'zod'
import {
DimensionsBagSchema,
normalizeLineDimensions,
type LineDimensions,
} from '@/lib/bookkeeping/dimension-resolver'
// ── Registry shapes (mirror GET /api/dimensions exactly) ─────────────────────
export interface DimensionValueEntry {
id: string
code: string
name: string
is_active: boolean
start_date: string | null
end_date: string | null
}
export interface DimensionRegistryEntry {
id: string
sie_dim_no: number
name: string
resets_annually: boolean
is_system: boolean
is_active: boolean
sort_order: number
values: DimensionValueEntry[]
}
/**
* Lazy get-or-create of the system dims (1 = Kostnadsställe, 6 = Projekt).
* Idempotent; keeps core zero-config for companies that never touch dimensions.
*/
export async function ensureCompanyDimensions(
supabase: SupabaseClient,
companyId: string,
): Promise<void> {
const { error } = await supabase.rpc('ensure_company_dimensions', {
p_company_id: companyId,
})
if (error) throw new Error(`Failed to seed system dimensions: ${error.message}`)
}
/**
* Fetch the full registry incl. values — the same two queries and the same
* nested shape as the dashboard GET /api/dimensions, so agents and the
* register UI see one consistent contract.
*/
export async function fetchDimensionRegistry(
supabase: SupabaseClient,
companyId: string,
): Promise<DimensionRegistryEntry[]> {
const { data: dims, error: dimsError } = await supabase
.from('dimensions')
.select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order')
.eq('company_id', companyId)
.order('sort_order', { ascending: true })
.order('sie_dim_no', { ascending: true })
if (dimsError) throw new Error(`Failed to list dimensions: ${dimsError.message}`)
const { data: values, error: valuesError } = await supabase
.from('dimension_values')
.select('id, dimension_id, code, name, is_active, start_date, end_date')
.eq('company_id', companyId)
.order('code', { ascending: true })
if (valuesError) throw new Error(`Failed to list dimension values: ${valuesError.message}`)
const valuesByDimension = new Map<string, DimensionValueEntry[]>()
for (const v of (values ?? []) as Array<DimensionValueEntry & { dimension_id: string }>) {
const bucket = valuesByDimension.get(v.dimension_id) ?? []
bucket.push({
id: v.id,
code: v.code,
name: v.name,
is_active: v.is_active,
start_date: v.start_date,
end_date: v.end_date,
})
valuesByDimension.set(v.dimension_id, bucket)
}
return ((dims ?? []) as Array<Omit<DimensionRegistryEntry, 'values'>>).map((d) => ({
...d,
values: valuesByDimension.get(d.id) ?? [],
}))
}
// ── Input parsing ────────────────────────────────────────────────────────────
/**
* MCP boundary schema for a dimensions bag where values may be NAMES, not just
* codes. Looser than DimensionsBagSchema on length (names go up to 120 chars,
* dimension_values.name CHECK) but keeps the SIE-framing charset ban. After
* resolution the final bags are re-validated against DimensionsBagSchema — the
* exact schema the API layer and staged commit path use — so nothing loose is
* ever staged.
*/
const DimensionsInputSchema = z.record(
z.string().regex(/^[1-9]\d*$/, 'nyckeln måste vara ett SIE-dimensionsnummer (t.ex. "1" eller "6")'),
z
.string()
.min(1)
.max(120)
.regex(/^[^"{}]+$/, 'värdet får inte innehålla ", { eller }'),
)
/**
* Parse an untyped `dimensions` / `default_dimensions` tool argument. Throws a
* loud, actionable error on invalid shape — the MCP boundary is an input gate,
* unlike coerceDimensionsBag (which silently drops on the trusted staged path).
*/
export function parseDimensionsArg(raw: unknown, field: string): LineDimensions | undefined {
if (raw === undefined || raw === null) return undefined
const parsed = DimensionsInputSchema.safeParse(raw)
if (!parsed.success) {
const issue = parsed.error.issues[0]
throw new Error(
`Invalid ${field}: ${issue?.message ?? 'ogiltig dimensionsmap'}. ` +
'Expected {"<sie_dim_no>":"<kod eller namn>"}, e.g. {"1":"KS01","6":"P001"}.',
)
}
const dims = normalizeLineDimensions({ dimensions: parsed.data })
return Object.keys(dims).length > 0 ? dims : undefined
}
// ── Resolve-don't-select ─────────────────────────────────────────────────────
/**
* Fuse score cutoff for a single high-confidence fuzzy hit. Fuse scores run
* 0 (perfect) → 1 (no relation); 0.3 matches the app-wide precedent for
* registry lookups (components/import/OpeningBalanceRowEditor.tsx uses
* threshold 0.3 for BAS account search). Anything above is "plausible but not
* safe to attach" — plan risk #5 is a confident-but-wrong project tag.
*/
export const DIMENSION_FUZZY_SCORE_MAX = 0.3
/**
* Minimum score gap to the runner-up before a fuzzy hit counts as unambiguous.
* Two near-tied candidates (e.g. "P001 Villa Alm" vs "P002 Villa Alm etapp 2")
* must be disambiguated by the agent, not guessed at.
*/
export const DIMENSION_FUZZY_AMBIGUITY_MARGIN = 0.15
/** Echoed in tool responses for every non-exact resolution. */
export interface DimensionResolution {
dimension: number
input: string
resolved_code: string
resolved_name: string
/** 1 for exact code/name matches; 1 − fuse score (2 decimals) for fuzzy. */
confidence: number
}
/** Typed rejection so tests/tools can distinguish resolution failures. */
export class DimensionResolutionError extends Error {
readonly dimension: number
readonly input: string
readonly candidates: Array<{ code: string; name: string; confidence: number }>
constructor(
message: string,
dimension: number,
input: string,
candidates: Array<{ code: string; name: string; confidence: number }> = [],
) {
super(message)
this.name = 'DimensionResolutionError'
this.dimension = dimension
this.input = input
this.candidates = candidates
}
}
function dimensionLabel(dim: Pick<DimensionRegistryEntry, 'sie_dim_no' | 'name'>): string {
if (dim.sie_dim_no === 1) return 'kostnadsställe'
if (dim.sie_dim_no === 6) return 'projekt'
return dim.name
}
interface ResolvedValue {
code: string
name: string
exact: boolean
confidence: number
}
/**
* Resolve one input (code or name) inside one dimension. Pure — takes registry
* data, so it is unit-testable without a DB. Throws DimensionResolutionError
* on archived / unknown / ambiguous inputs; never auto-creates.
*/
export function resolveValueInDimension(
dim: DimensionRegistryEntry,
input: string,
): ResolvedValue {
const active = dim.values.filter((v) => v.is_active)
const label = dimensionLabel(dim)
// 1. Exact code match among active values — the fast path, not echoed.
const exactCode = active.find((v) => v.code === input)
if (exactCode) return { code: exactCode.code, name: exactCode.name, exact: true, confidence: 1 }
// Archived guard: an exact code hit on an inactive value is a hard stop —
// same rule the engine enforces at draft time.
const archived = dim.values.find((v) => !v.is_active && v.code === input)
if (archived) {
throw new DimensionResolutionError(
`"${input}" är arkiverat — återaktivera värdet för att använda det.`,
dim.sie_dim_no,
input,
)
}
// 2. Exact (case-insensitive) code or exact name match — unambiguous but
// echoed, since the stored code differs from the raw input.
const lowered = input.trim().toLowerCase()
const exactish = active.filter(
(v) => v.code.toLowerCase() === lowered || v.name.trim().toLowerCase() === lowered,
)
if (exactish.length === 1) {
return { code: exactish[0].code, name: exactish[0].name, exact: false, confidence: 1 }
}
if (exactish.length > 1) {
throw new DimensionResolutionError(
`"${input}" matchar flera värden i ${label} (dimension ${dim.sie_dim_no}): ` +
exactish.map((v) => `"${v.code}" (${v.name})`).join(', ') +
'. Ange en exakt kod i stället.',
dim.sie_dim_no,
input,
exactish.map((v) => ({ code: v.code, name: v.name, confidence: 1 })),
)
}
// 3. Fuzzy over active values.
const fuse = new Fuse(active, {
keys: ['code', 'name'],
includeScore: true,
threshold: 0.4,
})
const hits = fuse.search(input)
if (hits.length === 0) {
throw new DimensionResolutionError(
`Okänt ${label}: "${input}" (dimension ${dim.sie_dim_no}). Skapa värdet i registret först — ` +
'stage det med gnubok_create_dimension_value, eller ange en befintlig kod ' +
'(gnubok_list_dimension_values).',
dim.sie_dim_no,
input,
)
}
const top = hits[0]
const topScore = top.score ?? 1
const runnerUpScore = hits[1]?.score ?? Number.POSITIVE_INFINITY
const unambiguous =
topScore <= DIMENSION_FUZZY_SCORE_MAX &&
runnerUpScore - topScore >= DIMENSION_FUZZY_AMBIGUITY_MARGIN
if (unambiguous) {
return {
code: top.item.code,
name: top.item.name,
exact: false,
confidence: roundOre(1 - topScore),
}
}
const candidates = hits.slice(0, 5).map((h) => ({
code: h.item.code,
name: h.item.name,
confidence: roundOre(1 - (h.score ?? 1)),
}))
throw new DimensionResolutionError(
`Kunde inte entydigt matcha "${input}" mot ett ${label} (dimension ${dim.sie_dim_no}). Kandidater: ` +
candidates.map((c) => `"${c.code}" (${c.name}, ${Math.round(c.confidence * 100)}%)`).join(', ') +
'. Ange en exakt kod, eller skapa ett nytt värde med gnubok_create_dimension_value.',
dim.sie_dim_no,
input,
candidates,
)
}
export interface ResolveBagsResult {
/** Same length/positions as the input; values rewritten to registry codes. */
bags: Array<LineDimensions | undefined>
/** One entry per distinct non-exact (dimension, input) pair. */
resolutions: DimensionResolution[]
}
/**
* Resolve the dimension bags of a whole voucher in one pass.
*
* Query budget (validation contract): zero queries when no line carries a
* bag; one company_settings read when dimensions are disabled (free-text
* passthrough); ensure-RPC + two registry reads when enabled. Never per-line.
*/
export async function resolveDimensionBags(
supabase: SupabaseClient,
companyId: string,
bags: Array<LineDimensions | undefined>,
): Promise<ResolveBagsResult> {
const hasAny = bags.some((b) => b && Object.keys(b).length > 0)
if (!hasAny) return { bags, resolutions: [] }
const { data: settings } = await supabase
.from('company_settings')
.select('dimensions_enabled')
.eq('company_id', companyId)
.maybeSingle()
const finalize = (resolved: Array<LineDimensions | undefined>): void => {
// Whatever path produced the bags, the staged result must satisfy THE bag
// schema (max 40-char codes, SIE-safe charset) — otherwise the commit-side
// coerceDimensionsBag would silently drop the whole bag at booking time.
for (const bag of resolved) {
if (!bag) continue
const check = DimensionsBagSchema.safeParse(bag)
if (!check.success) {
const issue = check.error.issues[0]
throw new Error(
`Ogiltig dimensionskod: ${issue?.message ?? 'ogiltigt värde'}. ` +
'Koder får vara max 40 tecken — registrera värdet i registret och referera det via kod eller namn.',
)
}
}
}
if (!(settings as { dimensions_enabled?: boolean } | null)?.dimensions_enabled) {
// Dimensions not enabled: free-text passthrough, exactly like the engine's
// validation rule 2 — existing API/MCP writers keep working unchanged.
finalize(bags)
return { bags, resolutions: [] }
}
await ensureCompanyDimensions(supabase, companyId)
const registry = await fetchDimensionRegistry(supabase, companyId)
const byDimNo = new Map(registry.map((d) => [d.sie_dim_no, d]))
const cache = new Map<string, ResolvedValue & { dimension: number; input: string }>()
const resolveOne = (dimNo: string, input: string) => {
const key = `${dimNo}\u0000${input}`
const cached = cache.get(key)
if (cached) return cached
const dim = byDimNo.get(Number(dimNo))
if (!dim) {
throw new DimensionResolutionError(
`Okänd dimension ${dimNo}. Registrerade dimensioner: ` +
(registry.map((d) => `${d.sie_dim_no} (${d.name})`).join(', ') || 'inga') +
'. Anropa gnubok_list_dimensions.',
Number(dimNo),
input,
)
}
const resolved = { ...resolveValueInDimension(dim, input), dimension: dim.sie_dim_no, input }
cache.set(key, resolved)
return resolved
}
const outBags = bags.map((bag) => {
if (!bag || Object.keys(bag).length === 0) return bag
const out: LineDimensions = {}
for (const [dimNo, value] of Object.entries(bag)) {
out[dimNo] = resolveOne(dimNo, value).code
}
return out
})
const resolutions: DimensionResolution[] = [...cache.values()]
.filter((r) => !r.exact)
.map((r) => ({
dimension: r.dimension,
input: r.input,
resolved_code: r.code,
resolved_name: r.name,
confidence: r.confidence,
}))
finalize(outBags)
return { bags: outBags, resolutions }
}
/**
* Per-line effective bag for a voucher tool: the line's own normalized map
* (explicit bag wins over the deprecated cost_center/project aliases) filled
* with voucher-level defaults for keys the line does not set itself.
*/
export function mergeLineDimensions(
line: { dimensions?: LineDimensions; cost_center?: string; project?: string },
defaults: LineDimensions | undefined,
): LineDimensions | undefined {
const own = normalizeLineDimensions({
dimensions: line.dimensions ?? null,
cost_center: line.cost_center ?? null,
project: line.project ?? null,
})
const merged = { ...(defaults ?? {}), ...own }
return Object.keys(merged).length > 0 ? merged : undefined
}
+494 -11
View File
@@ -36,6 +36,15 @@ import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX,
import type { SkillTier } from './skills'
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import {
ensureCompanyDimensions,
fetchDimensionRegistry,
parseDimensionsArg,
mergeLineDimensions,
resolveDimensionBags,
} from './dimensions'
import Fuse from 'fuse.js'
import { z } from 'zod'
import {
checkIdempotencyKey,
@@ -2110,7 +2119,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_get_agent_briefing',
title: 'Get Agent Briefing',
description: 'Bootstrap this company\'s accountant context in one call: user_name, profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories. Call once at session start.',
description: 'Bootstrap this company\'s accountant context in one call: user_name, profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories, dimensions snapshot (when registered). Call once at session start.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -2177,6 +2186,41 @@ export const tools: McpTool[] = [
required: ['id', 'kind', 'content'],
},
},
dimensions: {
type: 'object',
additionalProperties: false,
description: 'Dimension registry snapshot (kostnadsställe/projekt). OMITTED when the company has none registered; presence means lines can be tagged via the dims bag on gnubok_create_voucher.',
properties: {
enabled: { type: 'boolean', description: 'When true, dims-bag values are validated against the registry.' },
dimensions: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
sie_dim_no: { type: 'number' },
name: { type: 'string' },
active_value_count: { type: 'number' },
top_values: {
type: 'array',
description: 'Up to 10 active values; full list via gnubok_list_dimension_values.',
items: {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string' },
name: { type: 'string' },
},
required: ['code', 'name'],
},
},
},
required: ['sie_dim_no', 'name', 'active_value_count', 'top_values'],
},
},
},
required: ['enabled', 'dimensions'],
},
},
required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory'],
},
@@ -2187,7 +2231,25 @@ export const tools: McpTool[] = [
openWorldHint: false,
},
async execute(_args, companyId, userId, supabase) {
const [profileRes, memoryRes, userRes, companyRes, settingsRes] = await Promise.all([
// Dimension registry is best-effort and cheap: one indexed read, skipped
// output when empty (most companies never register dimensions — lazy
// seeding means zero rows until first use). Errors never block the
// briefing.
const safeDimensionsRead = (async () => {
try {
return await supabase
.from('dimensions')
.select('id, sie_dim_no, name')
.eq('company_id', companyId)
.eq('is_active', true)
.order('sort_order', { ascending: true })
.order('sie_dim_no', { ascending: true })
} catch {
return { data: null, error: new Error('dimensions read failed') }
}
})()
const [profileRes, memoryRes, userRes, companyRes, settingsRes, dimensionsRes] = await Promise.all([
supabase
.from('agent_profiles')
.select('profile_summary, horizontal_atoms, vertical_atoms, modifier_atoms')
@@ -2222,9 +2284,10 @@ export const tools: McpTool[] = [
.maybeSingle(),
supabase
.from('company_settings')
.select('accounting_method')
.select('accounting_method, dimensions_enabled')
.eq('company_id', companyId)
.maybeSingle(),
safeDimensionsRead,
])
if (profileRes.error) throw new Error(`Failed to load agent profile: ${profileRes.error.message}`)
@@ -2260,13 +2323,67 @@ export const tools: McpTool[] = [
const companyRow = companyRes.data as
| { name: string | null; org_number: string | null; entity_type: string | null }
| null
const settingsRow = settingsRes.data as
| { accounting_method: string | null; dimensions_enabled?: boolean | null }
| null
const company = {
id: companyId,
name: companyRow?.name ?? null,
org_number: companyRow?.org_number ?? null,
entity_type: companyRow?.entity_type ?? null,
accounting_method:
(settingsRes.data as { accounting_method: string | null } | null)?.accounting_method ?? null,
accounting_method: settingsRow?.accounting_method ?? null,
}
// Dimensions block — skipped entirely when the registry is empty so an
// untagged company pays nothing (and the agent isn't told about a
// feature with no data behind it).
const dimensionRows = (dimensionsRes.error ? [] : dimensionsRes.data ?? []) as Array<{
id: string
sie_dim_no: number
name: string
}>
let dimensionsBlock:
| {
enabled: boolean
dimensions: Array<{
sie_dim_no: number
name: string
active_value_count: number
top_values: Array<{ code: string; name: string }>
}>
}
| undefined
if (dimensionRows.length > 0) {
try {
const { data: valueRows, error: valueErr } = await supabase
.from('dimension_values')
.select('dimension_id, code, name')
.eq('company_id', companyId)
.eq('is_active', true)
.order('code', { ascending: true })
if (!valueErr) {
const byDimension = new Map<string, Array<{ code: string; name: string }>>()
for (const v of (valueRows ?? []) as Array<{ dimension_id: string; code: string; name: string }>) {
const bucket = byDimension.get(v.dimension_id) ?? []
bucket.push({ code: v.code, name: v.name })
byDimension.set(v.dimension_id, bucket)
}
dimensionsBlock = {
enabled: settingsRow?.dimensions_enabled === true,
dimensions: dimensionRows.map((d) => {
const values = byDimension.get(d.id) ?? []
return {
sie_dim_no: d.sie_dim_no,
name: d.name,
active_value_count: values.length,
top_values: values.slice(0, 10),
}
}),
}
}
} catch {
// Best-effort — a values-read failure just omits the block.
}
}
const atomIds = [
@@ -2309,6 +2426,7 @@ export const tools: McpTool[] = [
content: m.content,
relevance_score: m.relevance_score,
})),
...(dimensionsBlock ? { dimensions: dimensionsBlock } : {}),
}
},
},
@@ -4335,6 +4453,314 @@ export const tools: McpTool[] = [
},
},
// ── Dimensions (kostnadsställe/projekt) ──────────────────────
{
name: 'gnubok_list_dimensions',
title: 'List Dimensions (Kostnadsställe/Projekt)',
description: 'List the dimension registry with values: 1 = kostnadsställe, 6 = projekt, plus custom dims. Call before tagging voucher lines via the dimensions bag on gnubok_create_voucher. System dims are seeded on first call.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
dimensions: {
type: 'array',
description: 'Registry entries keyed by sie_dim_no (the dims-bag key), each with its values. code = what goes in the bag; is_active false = archived (unusable on new lines).',
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
sie_dim_no: { type: 'number' },
name: { type: 'string' },
resets_annually: { type: 'boolean' },
is_system: { type: 'boolean' },
is_active: { type: 'boolean' },
sort_order: { type: 'number' },
values: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
code: { type: 'string' },
name: { type: 'string' },
is_active: { type: 'boolean' },
start_date: { type: ['string', 'null'] },
end_date: { type: ['string', 'null'] },
},
required: ['id', 'code', 'name', 'is_active', 'start_date', 'end_date'],
},
},
},
required: ['id', 'sie_dim_no', 'name', 'resets_annually', 'is_system', 'is_active', 'sort_order', 'values'],
},
},
},
required: ['dimensions'],
},
annotations: {
// The lazy ensure_company_dimensions seed is an idempotent get-or-create
// of the two system registry rows — semantically a read (the dashboard
// GET /api/dimensions does the same).
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, companyId, _userId, supabase) {
await ensureCompanyDimensions(supabase, companyId)
const dimensions = await fetchDimensionRegistry(supabase, companyId)
return { dimensions }
},
},
{
name: 'gnubok_list_dimension_values',
title: 'List Dimension Values',
description: 'List values (SIE #OBJEKT codes) for one dimension, optionally fuzzy-matched by query. Use to find the right kostnadsställe/projekt code before tagging lines. sie_dim_no: 1 = kostnadsställe, 6 = projekt.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
sie_dim_no: { type: 'number', description: '1 = kostnadsställe, 6 = projekt, or a custom dim from gnubok_list_dimensions.' },
query: { type: 'string', description: 'Optional fuzzy search over code + name, ranked by confidence.' },
include_inactive: { type: 'boolean', description: 'Include archived values (default false).' },
limit: { type: 'number', description: 'Max results, 1–200 (default 50).' },
},
required: ['sie_dim_no'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
dimension: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
sie_dim_no: { type: 'number' },
name: { type: 'string' },
resets_annually: { type: 'boolean' },
is_active: { type: 'boolean' },
},
required: ['id', 'sie_dim_no', 'name', 'resets_annually', 'is_active'],
},
values: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
code: { type: 'string' },
name: { type: 'string' },
is_active: { type: 'boolean' },
start_date: { type: ['string', 'null'] },
end_date: { type: ['string', 'null'] },
confidence: { type: 'number', description: 'Fuzzy confidence 0–1; present only with query.' },
},
required: ['id', 'code', 'name', 'is_active', 'start_date', 'end_date'],
},
},
count: { type: 'number' },
},
required: ['dimension', 'values', 'count'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const sieDimNo = Number(args.sie_dim_no)
if (!Number.isInteger(sieDimNo) || sieDimNo < 1) {
throw new Error('sie_dim_no must be a positive integer SIE dimension number (1 = kostnadsställe, 6 = projekt).')
}
const includeInactive = args.include_inactive === true
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 200)
const query = typeof args.query === 'string' ? args.query.trim() : ''
await ensureCompanyDimensions(supabase, companyId)
const { data: dimension, error: dimError } = await supabase
.from('dimensions')
.select('id, sie_dim_no, name, resets_annually, is_active')
.eq('company_id', companyId)
.eq('sie_dim_no', sieDimNo)
.maybeSingle()
if (dimError) throw new Error(`Database error: ${dimError.message}`)
if (!dimension) {
throw new Error(
`Dimension ${sieDimNo} finns inte i registret. Anropa gnubok_list_dimensions för att se registrerade dimensioner.`,
)
}
let valuesQuery = supabase
.from('dimension_values')
.select('id, code, name, is_active, start_date, end_date')
.eq('company_id', companyId)
.eq('dimension_id', dimension.id)
.order('code', { ascending: true })
if (!includeInactive) valuesQuery = valuesQuery.eq('is_active', true)
const { data: rows, error: valuesError } = await valuesQuery
if (valuesError) throw new Error(`Database error: ${valuesError.message}`)
const all = (rows ?? []) as Array<{
id: string
code: string
name: string
is_active: boolean
start_date: string | null
end_date: string | null
}>
if (!query) {
const values = all.slice(0, limit)
return { dimension, values, count: values.length }
}
// Fuzzy ranking — same fuse.js setup as the resolve step so what this
// tool shows matches what a dims bag would resolve to.
const fuse = new Fuse(all, { keys: ['code', 'name'], includeScore: true, threshold: 0.4 })
const values = fuse
.search(query)
.slice(0, limit)
.map((hit) => ({
...hit.item,
confidence: roundOre(1 - (hit.score ?? 1)),
}))
return { dimension, values, count: values.length }
},
},
{
name: 'gnubok_create_dimension_value',
title: 'Create Dimension Value',
description: 'Stage a new dimension value (kostnadsställe/projekt object code, SIE #OBJEKT) for user approval — agents never silently mint reporting values. Use when a dims-bag value has no registry match. sie_dim_no: 1 = kostnadsställe, 6 = projekt.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
sie_dim_no: { type: 'number', description: '1 = kostnadsställe, 6 = projekt, or a custom dim.' },
code: {
type: 'string',
maxLength: 20,
pattern: '^[A-Za-z0-9\\u00C5\\u00C4\\u00D6\\u00E5\\u00E4\\u00F6_+\\-]{1,20}$',
description: 'Object code, strict Fortnox format: letters A–Ö, digits, _, + and -. Immutable after creation.',
},
name: { type: 'string', maxLength: 120, description: 'Human-readable name shown in registers and reports.' },
start_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Optional ISO start date; only on accumulating dims (projekt).' },
end_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Optional ISO end date ≥ start_date; only on accumulating dims.' },
dry_run: {
type: 'boolean',
description: 'If true, validate inputs and return the would-be preview without staging. No DB writes, no side-effects.',
},
idempotency_key: {
type: 'string',
description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.',
},
},
required: ['sie_dim_no', 'code', 'name'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
// Strip MCP control fields, then re-validate with the same Zod schema
// the commit executor uses (defense in depth, mirrors create_supplier).
const { dry_run, idempotency_key, ...valueArgs } = args
let params
try {
params = CreateDimensionValueParamsSchema.parse(valueArgs)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
const path = issue?.path?.join('.') ?? 'params'
throw new Error(`Invalid ${path}: ${issue?.message ?? 'validation failed'}`)
}
throw err
}
await ensureCompanyDimensions(supabase, companyId)
// Pre-flight for a tight agent feedback loop; the executor re-checks all
// of this at commit time (the staged row is never trusted).
const { data: dimension, error: dimError } = await supabase
.from('dimensions')
.select('id, sie_dim_no, name, resets_annually')
.eq('company_id', companyId)
.eq('sie_dim_no', params.sie_dim_no)
.maybeSingle()
if (dimError) throw new Error(`Database error: ${dimError.message}`)
if (!dimension) {
throw new Error(
`Okänd dimension ${params.sie_dim_no}. Endast registrerade dimensioner kan få nya värden — ` +
'anropa gnubok_list_dimensions (1 = kostnadsställe och 6 = projekt skapas automatiskt).',
)
}
if (dimension.resets_annually && (params.start_date || params.end_date)) {
throw new Error(
`Start-/slutdatum är inte tillåtna på dimensionen "${dimension.name}" (nollställs årligen).`,
)
}
const { data: existing, error: existingError } = await supabase
.from('dimension_values')
.select('id, code, name, is_active')
.eq('company_id', companyId)
.eq('dimension_id', dimension.id)
.eq('code', params.code)
.maybeSingle()
if (existingError) throw new Error(`Database error: ${existingError.message}`)
if (existing?.is_active) {
throw new Error(
`Värdet "${params.code}" (${existing.name}) finns redan i ${dimension.name} — använd koden direkt i dimensions-baggen.`,
)
}
if (existing) {
throw new Error(
`"${params.code}" är arkiverat — återaktivera värdet i registret för att använda det.`,
)
}
return stagePendingOperation(supabase, companyId, userId, 'create_dimension_value',
`Nytt värde i ${dimension.name}: ${params.code} — ${params.name}`,
params,
{
sie_dim_no: dimension.sie_dim_no,
dimension_name: dimension.name,
code: params.code,
name: params.name,
start_date: params.start_date ?? null,
end_date: params.end_date ?? null,
will: 'create the value in the dimension registry so lines can be tagged with it',
},
actor,
{
description: 'Once approved, tag voucher lines with the new code via the dimensions bag on gnubok_create_voucher, or verify it with gnubok_list_dimension_values.',
tool: 'gnubok_list_dimension_values',
args: { sie_dim_no: dimension.sie_dim_no },
},
{
dryRun: Boolean(dry_run),
idempotencyKey: typeof idempotency_key === 'string' ? idempotency_key : undefined,
}
)
},
},
// ── Reports ──────────────────────────────────────────────────
{
@@ -8808,6 +9234,11 @@ export const tools: McpTool[] = [
fiscal_period_id: { type: 'string', description: 'UUID of fiscal period. If omitted, resolved from entry_date.' },
voucher_series: { type: 'string', description: 'Single letter A–Z. Defaults to A.' },
notes: { type: 'string', description: 'Internal notes (max 2000 chars) — visible on the verifikation but not on reports.' },
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}, applied to every line not setting the key itself. Unknown values are rejected — never auto-created.',
},
is_opening_balance: { type: 'boolean', description: 'Set true ONLY for a migrated ingående balans (IB). Marks the entry source_type=opening_balance so bank reconciliation excludes it from period movement. Requires every line to be a balance-sheet account (class 1/2) and entry_date = fiscal period start, else rejected. Defaults false.' },
inbox_item_id: { type: 'string', description: 'Optional inbox item UUID to book directly. On confirm, the inbox item is linked to the new verifikat and its OCR document is attached to the journal entry. Fails if the inbox item is already booked (as voucher) or converted (to supplier invoice).' },
lines: {
@@ -8824,8 +9255,13 @@ export const tools: McpTool[] = [
amount_in_currency: { type: 'number', description: 'Original amount if currency is not SEK' },
exchange_rate: { type: 'number' },
tax_code: { type: 'string', description: 'Free-text tag — does NOT drive momsdeklaration ruta mapping. The BAS account number is what determines which ruta the line lands in (e.g. 2641 → ruta 48, 2614 → ruta 30). Pick the correct account first.' },
cost_center: { type: 'string' },
project: { type: 'string' },
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"1":"KS01","6":"P001"}. Names resolve against the registry (high-confidence only, echoed). Wins per key over default_dimensions and cost_center/project.',
},
cost_center: { type: 'string', description: 'DEPRECATED alias for dimensions["1"].' },
project: { type: 'string', description: 'DEPRECATED alias for dimensions["6"].' },
},
required: ['account_number'],
},
@@ -8848,7 +9284,7 @@ export const tools: McpTool[] = [
}
// Normalize so validateBalance + preview see consistent numeric types.
const lines = rawLines.map((l) => ({
const lines = rawLines.map((l, i) => ({
account_number: String(l.account_number ?? ''),
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
@@ -8857,6 +9293,7 @@ export const tools: McpTool[] = [
amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined,
exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined,
tax_code: l.tax_code ? String(l.tax_code) : undefined,
dimensions: parseDimensionsArg(l.dimensions, `lines[${i}].dimensions`),
cost_center: l.cost_center ? String(l.cost_center) : undefined,
project: l.project ? String(l.project) : undefined,
}))
@@ -8871,6 +9308,22 @@ export const tools: McpTool[] = [
)
}
// Resolve-don't-select: merge voucher-level default_dimensions under each
// line's own bag/aliases, then resolve codes AND natural-language names
// against the registry in ONE pass (zero queries when nothing is tagged;
// free-text passthrough while dimensions_enabled is off). Non-exact
// resolutions are echoed in the preview so the approver and the agent
// both see what "Villa Almgren tak" actually attached to.
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
const { bags: resolvedBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
supabase,
companyId,
lines.map((l) => mergeLineDimensions(l, defaultDimensions)),
)
for (const [i, line] of lines.entries()) {
line.dimensions = resolvedBags[i]
}
// Resolve fiscal period. Two paths:
// 1. Caller supplied fiscal_period_id → verify it exists and is open.
// 2. Omitted → look up the open period covering entry_date.
@@ -8953,6 +9406,7 @@ export const tools: McpTool[] = [
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
dimensions: l.dimensions ?? null,
}))
// Optional inbox-direct booking. Validate at staging so the agent gets a
@@ -9018,6 +9472,9 @@ export const tools: McpTool[] = [
total_credit: balance.totalCredit,
line_count: lines.length,
lines: previewLines,
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
inbox_item_id: inboxItemId,
document_attached: Boolean(inboxDocumentId),
will: inboxItemId
@@ -9043,6 +9500,11 @@ export const tools: McpTool[] = [
additionalProperties: false,
properties: {
entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' },
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}, applied to every replacement line not setting the key itself. Unknown values are rejected — never auto-created.',
},
lines: {
type: 'array',
description: 'Replacement lines (≥ 2, balanced). Use the same accounts as the original where unchanged.',
@@ -9057,8 +9519,13 @@ export const tools: McpTool[] = [
amount_in_currency: { type: 'number' },
exchange_rate: { type: 'number' },
tax_code: { type: 'string', description: 'Free-text tag — does NOT drive momsdeklaration ruta. Pick the correct BAS account first.' },
cost_center: { type: 'string' },
project: { type: 'string' },
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dimension tags {sie_dim_no: kod eller namn}. Names resolve against the registry (high-confidence only, echoed). Wins per key over default_dimensions and cost_center/project.',
},
cost_center: { type: 'string', description: 'DEPRECATED alias for dimensions["1"].' },
project: { type: 'string', description: 'DEPRECATED alias for dimensions["6"].' },
},
required: ['account_number'],
},
@@ -9076,7 +9543,7 @@ export const tools: McpTool[] = [
throw new Error('entry_id and at least two lines are required')
}
const lines = rawLines.map((l) => ({
const lines = rawLines.map((l, i) => ({
account_number: String(l.account_number ?? ''),
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
@@ -9085,6 +9552,7 @@ export const tools: McpTool[] = [
amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined,
exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined,
tax_code: l.tax_code ? String(l.tax_code) : undefined,
dimensions: parseDimensionsArg(l.dimensions, `lines[${i}].dimensions`),
cost_center: l.cost_center ? String(l.cost_center) : undefined,
project: l.project ? String(l.project) : undefined,
}))
@@ -9097,6 +9565,19 @@ export const tools: McpTool[] = [
)
}
// Resolve-don't-select — same one-pass registry resolution as
// gnubok_create_voucher (codes AND names; unknown/archived/ambiguous
// values reject with candidates; nothing is ever auto-created).
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
const { bags: resolvedBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
supabase,
companyId,
lines.map((l) => mergeLineDimensions(l, defaultDimensions)),
)
for (const [i, line] of lines.entries()) {
line.dimensions = resolvedBags[i]
}
const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef)
// Pre-flight: the executor checks again, but failing fast here gives the
@@ -9182,8 +9663,10 @@ export const tools: McpTool[] = [
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
dimensions: l.dimensions ?? null,
})),
},
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
will: 'post a storno that mirrors the original, then post a new corrected entry, then mark the original as reversed (BFL 5 kap 5§)',
},
actor,
+5
View File
@@ -204,6 +204,11 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_list_fiscal_periods: 'reports:read',
gnubok_get_reconciliation_status: 'reports:read',
gnubok_list_accrual_schedules: 'reports:read',
// Dimensions (kostnadsställe/projekt) registry — reads next to the report
// tools; the staged value-create is a bookkeeping write (dimensions PR3).
gnubok_list_dimensions: 'reports:read',
gnubok_list_dimension_values: 'reports:read',
gnubok_create_dimension_value: 'bookkeeping:write',
// Document inbox
gnubok_upload_document: 'transactions:write',
gnubok_list_inbox_items: 'transactions:read',
@@ -3,9 +3,15 @@ import {
normalizeLineDimensions,
lineDimensionColumns,
coerceDimensionsBag,
validateEntryDimensions,
DIM_COST_CENTER,
DIM_PROJECT,
} from '@/lib/bookkeeping/dimension-resolver'
// Imported from errors.ts on purpose: proves the re-export surface every
// server consumer uses (the class itself lives in dimension-errors.ts).
import { DimensionValidationError } from '@/lib/bookkeeping/errors'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
describe('normalizeLineDimensions', () => {
it('returns empty map for a line with no dimension data', () => {
@@ -132,3 +138,160 @@ describe('lineDimensionColumns', () => {
expect(lineDimensionColumns(dims)).toEqual({ cost_center: 'KS01', project: 'P001' })
})
})
describe('validateEntryDimensions (soft registry validation, PR3)', () => {
const enabledSettings = { data: { dimensions_enabled: true } }
const registry = {
data: [
{ id: 'dim-ks', sie_dim_no: 1 },
{ id: 'dim-proj', sie_dim_no: 6 },
],
}
function queriedTables(q: ReturnType<typeof createQueuedMockSupabase>): string[] {
return q.supabase.from.mock.calls.map((call) => call[0] as string)
}
function run(
q: ReturnType<typeof createQueuedMockSupabase>,
lines: Parameters<typeof validateEntryDimensions>[2]
) {
return validateEntryDimensions(q.supabase as unknown as SupabaseClient, 'company-1', lines)
}
it('makes ZERO queries when no line carries a dimension', async () => {
const q = createQueuedMockSupabase()
await expect(
run(q, [
{ dimensions: {} },
{ cost_center: null, project: null },
{},
])
).resolves.toBeUndefined()
expect(q.supabase.from).not.toHaveBeenCalled()
})
it('passes through without registry queries when dimensions_enabled is false', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: { dimensions_enabled: false } })
await expect(run(q, [{ dimensions: { '6': 'HELT-OKÄND' } }])).resolves.toBeUndefined()
expect(queriedTables(q)).toEqual(['company_settings'])
})
it('passes through when the company has no settings row (backward compatible)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: null })
await expect(run(q, [{ dimensions: { '6': 'P001' } }])).resolves.toBeUndefined()
expect(queriedTables(q)).toEqual(['company_settings'])
})
it('fails open when the settings query errors (soft validation never blocks)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: null, error: { message: 'column does not exist' } })
await expect(run(q, [{ dimensions: { '6': 'P001' } }])).resolves.toBeUndefined()
})
it('rejects an unknown dimension number when enabled', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue({ data: [] }) // no registry row for dim 9
const promise = run(q, [{ dimensions: { '9': 'X' } }])
await expect(promise).rejects.toBeInstanceOf(DimensionValidationError)
await expect(promise).rejects.toMatchObject({
code: 'DIMENSION_VALIDATION_FAILED',
issues: [{ sie_dim_no: '9', code: null, reason: 'unknown_dimension' }],
})
await expect(promise).rejects.toThrow(
'Okänd dimension 9. Skapa dimensionen i registret först.'
)
// All dims unknown → the values query is skipped entirely.
expect(queriedTables(q)).toEqual(['company_settings', 'dimensions'])
})
it('rejects a code with no dimension_values row', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({ data: [] })
const promise = run(q, [{ dimensions: { '6': 'X' } }])
await expect(promise).rejects.toMatchObject({
issues: [{ sie_dim_no: '6', code: 'X', reason: 'unknown_value' }],
})
await expect(promise).rejects.toThrow(
'Okänt kostnadsställe/projekt: "X" (dimension 6). Skapa värdet i registret först.'
)
})
it('rejects an archived (is_active = false) value', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({ data: [{ dimension_id: 'dim-proj', code: 'X', is_active: false }] })
const promise = run(q, [{ dimensions: { '6': 'X' } }])
await expect(promise).rejects.toMatchObject({
issues: [{ sie_dim_no: '6', code: 'X', reason: 'archived_value' }],
})
await expect(promise).rejects.toThrow(
'"X" är arkiverat — återaktivera värdet för att använda det.'
)
})
it('accepts valid active codes — three queries total, never per line', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({
data: [
{ dimension_id: 'dim-ks', code: 'KS01', is_active: true },
{ dimension_id: 'dim-proj', code: 'P001', is_active: true },
],
})
await expect(
run(q, [
{ dimensions: { '1': 'KS01', '6': 'P001' } },
{ dimensions: { '6': 'P001' } },
{ cost_center: 'KS01' }, // deprecated alias participates too
{}, // untagged line adds nothing
])
).resolves.toBeUndefined()
expect(queriedTables(q)).toEqual(['company_settings', 'dimensions', 'dimension_values'])
})
it('does not false-pass when the same code exists under a different dimension', async () => {
// "100" is a registered kostnadsställe but the line tags it as projekt —
// lookups key on (dimension_id, code), so this must still reject.
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({ data: [{ dimension_id: 'dim-ks', code: '100', is_active: true }] })
await expect(run(q, [{ dimensions: { '6': '100' } }])).rejects.toMatchObject({
issues: [{ sie_dim_no: '6', code: '100', reason: 'unknown_value' }],
})
})
it('collects every offending code into one rejection', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue(registry)
q.enqueue({ data: [{ dimension_id: 'dim-ks', code: 'KS-GAMMAL', is_active: false }] })
const promise = run(q, [
{ dimensions: { '9': 'X' } },
{ dimensions: { '1': 'KS-GAMMAL' } },
{ dimensions: { '6': 'P999' } },
])
await expect(promise).rejects.toMatchObject({
issues: [
{ sie_dim_no: '9', code: null, reason: 'unknown_dimension' },
{ sie_dim_no: '1', code: 'KS-GAMMAL', reason: 'archived_value' },
{ sie_dim_no: '6', code: 'P999', reason: 'unknown_value' },
],
})
})
it('fails open when a registry query errors', async () => {
const q = createQueuedMockSupabase()
q.enqueue(enabledSettings)
q.enqueue({ data: null, error: { message: 'transient' } })
await expect(run(q, [{ dimensions: { '6': 'P001' } }])).resolves.toBeUndefined()
})
})
@@ -0,0 +1,251 @@
/**
* Engine wiring of validateEntryDimensions (dimensions plan PR3).
*
* createDraftEntry and updateDraftEntry must run the soft dimension
* validation AFTER balance validation and BEFORE any insert/update, so a
* rejection leaves no orphan rows. Untagged entries must not even fetch
* company_settings; companies without the toggle keep free-text passthrough.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createDraftEntry, updateDraftEntry } from '../engine'
import { DimensionValidationError } from '../errors'
import type { CreateJournalEntryInput } from '@/types'
vi.mock('@/lib/events', () => ({
eventBus: { emit: vi.fn().mockResolvedValue([]) },
}))
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
backfillStandardBASAccounts: vi.fn().mockResolvedValue([]),
}))
interface TableResult {
data?: unknown
error?: unknown
}
/**
* Table-keyed Supabase mock: every from(table) call returns a chain resolving
* to the configured result for that table. insert payloads are captured per
* table so tests can assert what would have been written.
*/
function buildSupabase(tables: Record<string, TableResult>) {
const inserts: Record<string, unknown[]> = {}
const updates: Record<string, unknown[]> = {}
const from = vi.fn().mockImplementation((table: string) => {
const result = tables[table] ?? {}
const resolved = { data: result.data ?? null, error: result.error ?? null }
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'delete', 'order', 'limit', 'lte', 'gte']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.insert = vi.fn().mockImplementation((payload: unknown) => {
;(inserts[table] ??= []).push(payload)
return chain
})
chain.update = vi.fn().mockImplementation((payload: unknown) => {
;(updates[table] ??= []).push(payload)
return chain
})
chain.single = vi.fn().mockResolvedValue(resolved)
chain.maybeSingle = vi.fn().mockResolvedValue(resolved)
chain.then = (resolve: (v: unknown) => void) => resolve(resolved)
return chain
})
const supabase = {
from,
rpc: vi.fn().mockResolvedValue({ data: null, error: null }),
}
const queriedTables = () => from.mock.calls.map((call) => call[0] as string)
return { supabase, inserts, updates, queriedTables }
}
const BASE_TABLES: Record<string, TableResult> = {
fiscal_periods: {
data: { name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' },
},
chart_of_accounts: {
data: [
{ account_number: '1930', id: 'acc-1930' },
{ account_number: '4010', id: 'acc-4010' },
],
},
journal_entries: {
data: { id: 'entry-1', status: 'draft', voucher_series: 'A', lines: [] },
},
journal_entry_lines: { data: null },
}
const DIMENSION_TABLES: Record<string, TableResult> = {
company_settings: { data: { dimensions_enabled: true } },
dimensions: {
data: [
{ id: 'dim-ks', sie_dim_no: 1 },
{ id: 'dim-proj', sie_dim_no: 6 },
],
},
dimension_values: {
data: [
{ dimension_id: 'dim-ks', code: 'KS01', is_active: true },
{ dimension_id: 'dim-proj', code: 'P001', is_active: true },
],
},
}
function makeInput(dimensions?: Record<string, string>): CreateJournalEntryInput {
return {
fiscal_period_id: 'period-1',
entry_date: '2026-06-15',
description: 'Materialinköp',
source_type: 'manual',
// Explicit series keeps resolveSeriesFromSettings from also querying
// company_settings, so the assertions below isolate the validation fetch.
voucher_series: 'A',
lines: [
{ account_number: '4010', debit_amount: 100, credit_amount: 0, dimensions },
{ account_number: '1930', debit_amount: 0, credit_amount: 100, dimensions },
],
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('createDraftEntry — dimension validation wiring', () => {
it('never fetches company_settings for an untagged entry', async () => {
const { supabase, queriedTables } = buildSupabase(BASE_TABLES)
const entry = await createDraftEntry(supabase as never, 'company-1', 'user-1', makeInput())
expect(entry.id).toBe('entry-1')
expect(queriedTables()).not.toContain('company_settings')
expect(queriedTables()).not.toContain('dimensions')
expect(queriedTables()).not.toContain('dimension_values')
})
it('passes tagged lines through untouched when dimensions_enabled is false', async () => {
const { supabase, inserts, queriedTables } = buildSupabase({
...BASE_TABLES,
company_settings: { data: { dimensions_enabled: false } },
})
const entry = await createDraftEntry(
supabase as never,
'company-1',
'user-1',
makeInput({ '6': 'FRITEXT-PROJEKT' })
)
expect(entry.id).toBe('entry-1')
// Toggle checked once, registry never consulted (free-text passthrough).
expect(queriedTables().filter((t) => t === 'company_settings')).toHaveLength(1)
expect(queriedTables()).not.toContain('dimensions')
// The free-text tag still lands on the inserted lines (bag + mirror).
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '6': 'FRITEXT-PROJEKT' })
expect(lineRows[0].project).toBe('FRITEXT-PROJEKT')
})
it('rejects an unknown code before ANY row is inserted (toggle on)', async () => {
const { supabase, inserts, queriedTables } = buildSupabase({
...BASE_TABLES,
...DIMENSION_TABLES,
dimension_values: { data: [] },
})
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', makeInput({ '6': 'P999' }))
).rejects.toBeInstanceOf(DimensionValidationError)
expect(queriedTables()).not.toContain('journal_entries')
expect(inserts.journal_entries).toBeUndefined()
expect(inserts.journal_entry_lines).toBeUndefined()
})
it('rejects an archived value with the Swedish reactivation message', async () => {
const { supabase } = buildSupabase({
...BASE_TABLES,
...DIMENSION_TABLES,
dimension_values: {
data: [{ dimension_id: 'dim-proj', code: 'P001', is_active: false }],
},
})
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', makeInput({ '6': 'P001' }))
).rejects.toThrow('"P001" är arkiverat — återaktivera värdet för att använda det.')
})
it('creates the draft when every tagged code is registered and active', async () => {
const { supabase, inserts } = buildSupabase({ ...BASE_TABLES, ...DIMENSION_TABLES })
const entry = await createDraftEntry(
supabase as never,
'company-1',
'user-1',
makeInput({ '1': 'KS01', '6': 'P001' })
)
expect(entry.id).toBe('entry-1')
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(lineRows[0].cost_center).toBe('KS01')
expect(lineRows[0].project).toBe('P001')
})
})
describe('updateDraftEntry — dimension validation wiring', () => {
it('rejects an unknown code before the header or lines are touched', async () => {
const { supabase, updates, queriedTables } = buildSupabase({
...BASE_TABLES,
...DIMENSION_TABLES,
dimension_values: { data: [] },
})
await expect(
updateDraftEntry(supabase as never, 'company-1', 'user-1', 'entry-1', makeInput({ '6': 'P999' }))
).rejects.toBeInstanceOf(DimensionValidationError)
// journal_entries is hit exactly once: the draft-status load. The header
// update and the delete/insert of lines must never run.
expect(queriedTables().filter((t) => t === 'journal_entries')).toHaveLength(1)
expect(updates.journal_entries).toBeUndefined()
expect(queriedTables()).not.toContain('journal_entry_lines')
})
it('updates an untagged draft without ever fetching company_settings', async () => {
const { supabase, queriedTables } = buildSupabase(BASE_TABLES)
const entry = await updateDraftEntry(
supabase as never,
'company-1',
'user-1',
'entry-1',
makeInput()
)
expect(entry.id).toBe('entry-1')
expect(queriedTables()).not.toContain('company_settings')
})
it('updates a draft with valid registered codes', async () => {
const { supabase, inserts } = buildSupabase({ ...BASE_TABLES, ...DIMENSION_TABLES })
const entry = await updateDraftEntry(
supabase as never,
'company-1',
'user-1',
'entry-1',
makeInput({ '6': 'P001' })
)
expect(entry.id).toBe('entry-1')
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '6': 'P001' })
})
})
+40
View File
@@ -5,6 +5,7 @@ import {
CannotCorrectNonPostedError,
CannotReverseNonPostedError,
CurrencyRevaluationAlreadyExistsError,
DimensionValidationError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
@@ -152,6 +153,25 @@ describe('Typed bookkeeping errors', () => {
expect(err.message).toContain('commit_entry')
expect(err.message).not.toContain('undefined')
})
it('DimensionValidationError carries issues and a Swedish message naming every code', () => {
const err = new DimensionValidationError([
{ sie_dim_no: '6', code: 'P999', reason: 'unknown_value' },
{ sie_dim_no: '1', code: 'KS-GAMMAL', reason: 'archived_value' },
{ sie_dim_no: '9', code: null, reason: 'unknown_dimension' },
])
expect(err.code).toBe('DIMENSION_VALIDATION_FAILED')
expect(err.name).toBe('DimensionValidationError')
expect(err).toBeInstanceOf(Error)
expect(err.issues).toHaveLength(3)
expect(err.message).toContain(
'Okänt kostnadsställe/projekt: "P999" (dimension 6). Skapa värdet i registret först.'
)
expect(err.message).toContain(
'"KS-GAMMAL" är arkiverat — återaktivera värdet för att använda det.'
)
expect(err.message).toContain('Okänd dimension 9. Skapa dimensionen i registret först.')
})
})
describe('isAccountsNotInChartError', () => {
@@ -180,6 +200,11 @@ describe('isBookkeepingError', () => {
expect(isBookkeepingError(new CurrencyRevaluationAlreadyExistsError())).toBe(true)
expect(isBookkeepingError(new InvalidMappingResultError('1930', '3001'))).toBe(true)
expect(isBookkeepingError(new BookkeepingDatabaseError('commit_entry', 'x'))).toBe(true)
expect(
isBookkeepingError(
new DimensionValidationError([{ sie_dim_no: '6', code: 'X', reason: 'unknown_value' }])
)
).toBe(true)
})
it('returns false for plain Error', () => {
@@ -292,6 +317,21 @@ describe('bookkeepingErrorResponse', () => {
expect(body.error.details).toEqual({ debitAccount: null, creditAccount: '3001' })
})
it('returns 400 for DimensionValidationError with issue details and Swedish message', async () => {
const response = bookkeepingErrorResponse(
new DimensionValidationError([{ sie_dim_no: '6', code: 'P999', reason: 'unknown_value' }])
)!
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error.code).toBe('DIMENSION_VALIDATION_FAILED')
expect(body.error.message).toBe(
'Okänt kostnadsställe/projekt: "P999" (dimension 6). Skapa värdet i registret först.'
)
expect(body.error.details).toEqual({
issues: [{ sie_dim_no: '6', code: 'P999', reason: 'unknown_value' }],
})
})
it('returns 500 for BookkeepingDatabaseError with operation tag', async () => {
const response = bookkeepingErrorResponse(
new BookkeepingDatabaseError('commit_entry', 'constraint violation')
+99
View File
@@ -0,0 +1,99 @@
/**
* DimensionValidationError — the typed rejection of validateEntryDimensions()
* (lib/bookkeeping/dimension-resolver.ts).
*
* Lives in its own module instead of ./errors.ts for one reason only:
* dimension-resolver.ts is reachable from client bundles (lib/api/schemas.ts
* imports DimensionsBagSchema and is itself imported by "use client"
* components such as InvoiceEditor), while ./errors.ts imports next/server —
* a server-only module graph (AsyncLocalStorage internals) that must never
* enter a client bundle. This module stays dependency-free.
*
* ./errors.ts re-exports everything here, wires the class into
* isBookkeepingError() and bookkeepingErrorResponse(), and remains the single
* import surface for server code:
*
* import { DimensionValidationError } from '@/lib/bookkeeping/errors'
*
* The class follows the ./errors.ts conventions: stable `code` const, `name`
* set to the class name, structured data on public readonly fields so the
* HTTP layer can attach machine-readable details. The message is user-facing
* Swedish (stays-Swedish bookkeeping surface, mirroring
* accountsNotInChartResponse) and names every offending code so a user or
* agent can self-correct in one pass.
*/
export const DIMENSION_VALIDATION_FAILED = 'DIMENSION_VALIDATION_FAILED' as const
export type DimensionValidationReason =
/** The line references a SIE dimension number with no registry row. */
| 'unknown_dimension'
/** The dimension exists but the code has no dimension_values row. */
| 'unknown_value'
/** The value exists but is archived (is_active = false). */
| 'archived_value'
export interface DimensionValidationIssue {
/** SIE dimension number as keyed in the line bag, e.g. '1' or '6'. */
sie_dim_no: string
/** Offending object code; null when the dimension number itself is unknown. */
code: string | null
reason: DimensionValidationReason
}
/** Swedish user-facing sentence for a single validation issue. */
export function formatDimensionValidationIssue(issue: DimensionValidationIssue): string {
switch (issue.reason) {
case 'unknown_dimension':
return `Okänd dimension ${issue.sie_dim_no}. Skapa dimensionen i registret först.`
case 'archived_value':
return `"${issue.code}" är arkiverat — återaktivera värdet för att använda det.`
case 'unknown_value':
return `Okänt kostnadsställe/projekt: "${issue.code}" (dimension ${issue.sie_dim_no}). Skapa värdet i registret först.`
}
}
function isDimensionValidationIssue(value: unknown): value is DimensionValidationIssue {
if (typeof value !== 'object' || value === null) return false
const v = value as Record<string, unknown>
if (typeof v.sie_dim_no !== 'string') return false
if (v.reason === 'unknown_dimension') return true
return (
(v.reason === 'unknown_value' || v.reason === 'archived_value') && typeof v.code === 'string'
)
}
/**
* Format an untyped issues array (e.g. `details.issues` from a serialized API
* error envelope) into the Swedish message. Returns null unless `raw` is a
* non-empty array of well-formed issues — callers fall back to their generic
* message. Used by lib/errors/get-error-message.ts so the toast reconstructs
* the exact per-code sentences instead of the static registry fallback.
*/
export function formatDimensionValidationIssues(raw: unknown): string | null {
if (!Array.isArray(raw) || raw.length === 0) return null
const issues = raw.filter(isDimensionValidationIssue)
if (issues.length === 0) return null
return issues.map(formatDimensionValidationIssue).join(' ')
}
/**
* Raised by validateEntryDimensions() when a company with
* company_settings.dimensions_enabled = true tags a line with a dimension
* number that has no registry row, a code with no dimension_values row, or an
* archived value. Companies without the toggle keep free-text passthrough
* (backward compatible with every existing API/MCP writer), and untagged
* entries never reach this validation at all.
*/
export class DimensionValidationError extends Error {
readonly code = DIMENSION_VALIDATION_FAILED
constructor(public readonly issues: DimensionValidationIssue[]) {
super(issues.map(formatDimensionValidationIssue).join(' '))
this.name = 'DimensionValidationError'
}
}
export function isDimensionValidationError(err: unknown): err is DimensionValidationError {
return err instanceof DimensionValidationError
}
+128 -1
View File
@@ -11,6 +11,11 @@
*/
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
DimensionValidationError,
type DimensionValidationIssue,
} from '@/lib/bookkeeping/dimension-errors'
/** SIE dimension numbers with first-class mirror columns. */
export const DIM_COST_CENTER = '1'
@@ -31,7 +36,7 @@ export const DimensionsBagSchema = z.record(
z.string().min(1).max(40).regex(/^[^"{}]+$/, 'Dimensionskod får inte innehålla ", { eller }')
)
interface DimensionAliasInput {
export interface DimensionAliasInput {
dimensions?: LineDimensions | null
cost_center?: string | null
project?: string | null
@@ -106,3 +111,125 @@ export function lineDimensionColumns(dimensions: LineDimensions): {
project: dimensions[DIM_PROJECT] ?? null,
}
}
/**
* Soft registry validation of the dimensions referenced by a set of entry
* lines (dev_docs/dimensions_implementation_plan.md, PR3). Called from
* createDraftEntry/updateDraftEntry after balance validation and before any
* insert, so a rejection leaves no orphan rows.
*
* Semantics:
* 1. Untagged entries are free: if no line carries a dimension, the function
* returns without touching the database at all.
* 2. Companies without company_settings.dimensions_enabled keep the historic
* free-text passthrough — existing API/MCP writers are unaffected. This is
* the ONE place the toggle is load-bearing beyond UI visibility.
* 3. Enabled companies get referential validation against the registry: a
* dimension number with no `dimensions` row, a code with no
* `dimension_values` row, or an archived (is_active = false) value rejects
* the whole entry with a DimensionValidationError whose Swedish message
* names every offending code.
*
* Cost: at most three queries per entry (settings, dimensions,
* dimension_values) regardless of line count — never per-line lookups.
*
* Failure posture: query errors fail OPEN (validation is skipped). This is
* soft validation — a transient DB error must not block bookkeeping, and the
* write that follows hits the same database anyway. Reversal/storno/correction
* paths intentionally bypass this function: they copy posted data verbatim
* (BFL 5 kap 5§ requires the storno to mirror the original even if a value
* has since been archived).
*/
export async function validateEntryDimensions(
supabase: SupabaseClient,
companyId: string,
lines: DimensionAliasInput[]
): Promise<void> {
// 1. Union of normalized dimension maps across all lines.
const union = new Map<string, Set<string>>()
for (const line of lines) {
for (const [dimNo, code] of Object.entries(normalizeLineDimensions(line))) {
const codes = union.get(dimNo) ?? new Set<string>()
codes.add(code)
union.set(dimNo, codes)
}
}
if (union.size === 0) return
// 2. Toggle gate — fetched once. Missing row/column or a query error means
// passthrough (fail-open, same posture as resolveSeriesFromSettings).
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('dimensions_enabled')
.eq('company_id', companyId)
.maybeSingle()
const enabled = (settings as { dimensions_enabled?: boolean } | null)?.dimensions_enabled
if (settingsError || !enabled) return
// 3a. Registry rows for every referenced dimension number — one query.
const { data: dimRows, error: dimError } = await supabase
.from('dimensions')
.select('id, sie_dim_no')
.eq('company_id', companyId)
.in('sie_dim_no', [...union.keys()].map(Number))
if (dimError) return
const dimIdByNo = new Map<string, string>()
for (const row of (dimRows ?? []) as { id: string; sie_dim_no: number }[]) {
dimIdByNo.set(String(row.sie_dim_no), row.id)
}
const issues: DimensionValidationIssue[] = []
const knownDimIds: string[] = []
for (const dimNo of union.keys()) {
const dimId = dimIdByNo.get(dimNo)
if (dimId) knownDimIds.push(dimId)
else issues.push({ sie_dim_no: dimNo, code: null, reason: 'unknown_dimension' })
}
// 3b. Value rows for every referenced (dimension, code) pair — one query.
// Filtering by the code union may return a same-named code under another
// referenced dimension; lookups below key on (dimension_id, code) so
// that cannot cause a false pass.
if (knownDimIds.length > 0) {
const allCodes = [...new Set([...union.values()].flatMap((codes) => [...codes]))]
const { data: valueRows, error: valueError } = await supabase
.from('dimension_values')
.select('dimension_id, code, is_active')
.eq('company_id', companyId)
.in('dimension_id', knownDimIds)
.in('code', allCodes)
if (valueError) return
// NUL-escape-separated composite key: a NUL can never occur in a Postgres
// text value, so (dimension_id, code) pairs stay unambiguous for any code.
const activeByKey = new Map<string, boolean>()
for (const row of (valueRows ?? []) as {
dimension_id: string
code: string
is_active: boolean
}[]) {
activeByKey.set(`${row.dimension_id}\u0000${row.code}`, row.is_active)
}
for (const [dimNo, codes] of union) {
const dimId = dimIdByNo.get(dimNo)
if (!dimId) continue
for (const code of codes) {
const isActive = activeByKey.get(`${dimId}\u0000${code}`)
if (isActive === undefined) {
issues.push({ sie_dim_no: dimNo, code, reason: 'unknown_value' })
} else if (!isActive) {
issues.push({ sie_dim_no: dimNo, code, reason: 'archived_value' })
}
}
}
}
if (issues.length > 0) {
throw new DimensionValidationError(issues)
}
}
+16 -1
View File
@@ -14,7 +14,11 @@ import {
JournalEntryNotFoundError,
} from '@/lib/bookkeeping/errors'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
import {
normalizeLineDimensions,
lineDimensionColumns,
validateEntryDimensions,
} from '@/lib/bookkeeping/dimension-resolver'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import { getActor } from '@/lib/bookkeeping/actor-context'
@@ -224,6 +228,13 @@ export async function createDraftEntry(
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
}
// Soft dimension validation (dimensions plan PR3): free for untagged
// entries; free-text passthrough unless company_settings.dimensions_enabled;
// enabled companies get registry validation with a typed Swedish rejection.
// Runs before any insert so a rejection leaves no orphan rows. Reversal/
// storno/correction paths bypass this — they copy posted data verbatim.
await validateEntryDimensions(supabase, companyId, input.lines)
// Validate that entry_date falls within the selected fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
@@ -397,6 +408,10 @@ export async function updateDraftEntry(
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
}
// Same soft dimension validation as createDraftEntry — before any write, so
// a rejection leaves both the header and the existing lines untouched.
await validateEntryDimensions(supabase, companyId, input.lines)
// Entry date must fall within the selected fiscal period.
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
+37 -1
View File
@@ -1,4 +1,24 @@
import { NextResponse } from 'next/server'
import { DimensionValidationError } from './dimension-errors'
// ============================================================================
// Dimension validation error — class lives in ./dimension-errors.ts (pure
// module, no next/server) because dimension-resolver.ts is reachable from
// client bundles via lib/api/schemas.ts. Re-exported here so this file stays
// the single import surface for all typed bookkeeping errors.
// ============================================================================
export {
DIMENSION_VALIDATION_FAILED,
DimensionValidationError,
formatDimensionValidationIssue,
formatDimensionValidationIssues,
isDimensionValidationError,
} from './dimension-errors'
export type {
DimensionValidationIssue,
DimensionValidationReason,
} from './dimension-errors'
// ============================================================================
// Error codes
@@ -310,7 +330,8 @@ export function isBookkeepingError(err: unknown): boolean {
err instanceof MeaninglessCorrectionError ||
err instanceof NoOpenPeriodForDateError ||
err instanceof TargetPeriodClosedError ||
err instanceof TargetPeriodLockedError
err instanceof TargetPeriodLockedError ||
err instanceof DimensionValidationError
)
}
@@ -538,6 +559,21 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
)
}
if (err instanceof DimensionValidationError) {
// err.message is already the user-facing Swedish sentence(s) naming the
// offending code(s); details.issues carries the machine-readable list.
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { issues: err.issues },
},
},
{ status: 400 }
)
}
if (err instanceof BookkeepingDatabaseError) {
return NextResponse.json(
{
+16
View File
@@ -18,6 +18,8 @@
*/
import { formatCurrency } from '@/lib/utils'
// Pure module (no next/server) — safe for the client bundles this file lives in.
import { formatDimensionValidationIssues } from '@/lib/bookkeeping/dimension-errors'
import { getErrorEntry } from './structured-errors'
type ErrorContext =
@@ -341,6 +343,20 @@ export function getErrorMessage(
return 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.'
}
if (structured.code === 'DIMENSION_VALIDATION_FAILED') {
// Prefer reconstructing the per-code Swedish sentences from the
// machine-readable issue list (present on both the dashboard and the
// v1/registry error envelopes); fall back to the message, which the
// engine already emits in Swedish naming the offending codes.
const details = structured.details as { issues?: unknown } | undefined
const formatted = formatDimensionValidationIssues(details?.issues)
if (formatted) return formatted
if (typeof structured.message === 'string' && structured.message.trim()) {
return structured.message
}
return 'Ett angivet kostnadsställe/projekt finns inte i dimensionsregistret eller är arkiverat. Skapa värdet i registret först.'
}
if (structured.code === 'NO_OPEN_PERIOD_FOR_DATE') {
return 'Det finns ingen räkenskapsperiod som täcker det valda datumet. Skapa eller öppna räkenskapsåret först.'
}
+4
View File
@@ -28,6 +28,7 @@ import {
CannotCorrectNonPostedError,
CannotReverseNonPostedError,
CannotReverseStornoError,
DimensionValidationError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
@@ -366,6 +367,9 @@ function extractBookkeepingDetails(err: unknown): { code: string; details?: unkn
if (err instanceof MeaninglessCorrectionError) {
return { code: err.code, details: { reason: err.reason } }
}
if (err instanceof DimensionValidationError) {
return { code: err.code, details: { issues: err.issues } }
}
if (err instanceof NoOpenPeriodForDateError) {
return { code: err.code, details: { date: err.date } }
}
+11
View File
@@ -206,6 +206,17 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
message_sv: 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.',
message_en: 'Mapping rules produced an invalid debit/credit account pair.',
},
DIMENSION_VALIDATION_FAILED: {
httpStatus: 400,
message_sv:
'Ett angivet kostnadsställe/projekt finns inte i dimensionsregistret eller är arkiverat. Skapa värdet i registret först.',
message_en:
'One or more dimension codes on the entry lines are missing from the dimension registry or archived. details.issues lists each offending sie_dim_no/code.',
remediation: {
description:
'Create the missing dimension value in the register (or re-activate the archived value), then retry. Only companies with dimensions enabled are validated; each issue in details.issues carries sie_dim_no, code and reason (unknown_dimension | unknown_value | archived_value).',
},
},
BOOKKEEPING_DATABASE_ERROR: {
httpStatus: 500,
message_sv: 'Verifikationen kunde inte sparas. Försök igen.',
@@ -0,0 +1,145 @@
/**
* commitCreateDimensionValue — executor tests (dimensions PR3).
*
* The executor is private to lib/pending-operations/commit.ts and reached
* through commitPendingOperation, same pattern as executors.test.ts. Staging-
* side coverage (the MCP tool's pre-flight gates) lives in
* extensions/general/mcp-server/__tests__/dimension-tools.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'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'create_dimension_value',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'low',
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: create_dimension_value', () => {
it('happy path: seeds system dims, inserts the value, returns committed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // ensure_company_dimensions rpc (dim 6)
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: { id: 'val-1', code: 'P010', name: 'Etapp 2', is_active: true }, error: null }) // insert
enqueue({ data: null, error: null }) // finalize update
const op = makePendingOp({
params: { sie_dim_no: 6, code: 'P010', name: 'Etapp 2' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
dimension_value_id: 'val-1',
sie_dim_no: 6,
dimension_name: 'Projekt',
code: 'P010',
name: 'Etapp 2',
already_existed: false,
})
expect((supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('ensure_company_dimensions')
})
it('duplicate code (23505) is idempotent: re-reads the existing row and reports success', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value' } }) // insert conflict
enqueue({ data: { id: 'val-existing', code: 'P010', name: 'Etapp 2', is_active: true }, error: null }) // re-read
enqueue({ data: null, error: null }) // finalize update
const op = makePendingOp({
params: { sie_dim_no: 6, code: 'P010', name: 'Etapp 2' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
dimension_value_id: 'val-existing',
already_existed: true,
})
})
it('rejects an unknown custom dimension (no ensure RPC for non-system dims)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dimensions lookup → not found
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: { sie_dim_no: 12, code: 'X1', name: 'Custom' },
})
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(/Okänd dimension 12/)
// ensure_company_dimensions must NOT run for non-system dims — agents may
// stage new VALUES, never new dimensions.
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('re-validates staged params at the commit boundary (tampered code rejected)', 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: { sie_dim_no: 6, code: 'has "quotes" and spaces', name: 'Tampered' },
})
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 code/)
})
it('rejects value dates on a resets-annually dimension', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // ensure rpc (dim 1)
enqueue({ data: { id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true }, error: null })
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: { sie_dim_no: 1, code: 'KS01', name: 'Stockholm', start_date: '2026-01-01' },
})
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(/Start-\/slutdatum är inte tillåtna/)
})
})
+127
View File
@@ -74,6 +74,7 @@ import { createLogger } from '@/lib/logger'
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 { BulkBookInboxSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
@@ -433,6 +434,129 @@ async function commitCreateSupplier(
return { data: { supplier_id: data.id } }
}
/**
* Executor for the staged create_dimension_value operation
* (gnubok_create_dimension_value — dimensions PR3). Inserts a dimension value
* (SIE #OBJEKT) into the registry. Agents never silently mint reporting
* values: this always arrives via a human-approved pending_operation.
*
* Idempotent on duplicate code: a 23505 on (company_id, dimension_id, code)
* re-reads the existing row and reports success with already_existed=true, so
* a raced or re-committed approval never fails on "already there".
*/
async function commitCreateDimensionValue(
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 a non-portable code or
// malformed dates into the registry (ASVS V4.5) — mirrors commitCreateSupplier.
let validated
try {
validated = CreateDimensionValueParamsSchema.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
}
// Get-or-create the system dims (1 = kostnadsställe, 6 = projekt) —
// idempotent lazy seeding. Custom dims must already exist in the registry:
// agents may stage new VALUES, never new dimensions.
if (validated.sie_dim_no === 1 || validated.sie_dim_no === 6) {
const { error: ensureError } = await supabase.rpc('ensure_company_dimensions', {
p_company_id: companyId,
})
if (ensureError) {
return { error: `Kunde inte skapa systemdimensionerna: ${ensureError.message}`, status: 500 }
}
}
const { data: dimension, error: dimError } = await supabase
.from('dimensions')
.select('id, sie_dim_no, name, resets_annually')
.eq('company_id', companyId)
.eq('sie_dim_no', validated.sie_dim_no)
.maybeSingle()
if (dimError) return { error: dimError.message, status: 500 }
if (!dimension) {
return {
error:
`Okänd dimension ${validated.sie_dim_no}. Endast registrerade dimensioner kan få nya värden ` +
'(1 = kostnadsställe och 6 = projekt skapas automatiskt; övriga skapas i registret).',
status: 400,
}
}
// Value dates only make sense on accumulating dimensions (projekt-style
// ranges) — mirrors POST /api/dimensions/[id]/values.
if (dimension.resets_annually && (validated.start_date || validated.end_date)) {
return {
error: `Start-/slutdatum är inte tillåtna på dimensionen "${dimension.name}" (nollställs årligen).`,
status: 400,
}
}
const { data: created, error: insertError } = await supabase
.from('dimension_values')
.insert({
company_id: companyId,
dimension_id: dimension.id,
code: validated.code,
name: validated.name,
start_date: validated.start_date ?? null,
end_date: validated.end_date ?? null,
})
.select('id, code, name, is_active')
.single()
if (insertError) {
if (insertError.code === '23505') {
// Duplicate code — treat the existing value as success (idempotency).
const { data: existing, error: existingError } = await supabase
.from('dimension_values')
.select('id, code, name, is_active')
.eq('company_id', companyId)
.eq('dimension_id', dimension.id)
.eq('code', validated.code)
.maybeSingle()
if (existingError || !existing) {
return { error: insertError.message, status: 500 }
}
return {
data: {
dimension_value_id: existing.id,
sie_dim_no: dimension.sie_dim_no,
dimension_name: dimension.name,
code: existing.code,
name: existing.name,
is_active: existing.is_active,
already_existed: true,
},
}
}
return { error: insertError.message, status: 500 }
}
return {
data: {
dimension_value_id: created.id,
sie_dim_no: dimension.sie_dim_no,
dimension_name: dimension.name,
code: created.code,
name: created.name,
is_active: created.is_active,
already_existed: false,
},
}
}
async function commitCreateTransaction(
supabase: SupabaseClient,
userId: string,
@@ -3527,6 +3651,9 @@ async function commitPendingOperationInner(
case 'create_supplier':
result = await commitCreateSupplier(supabase, userId, companyId, pendingOp.params)
break
case 'create_dimension_value':
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
break
case 'create_invoice':
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
break
+5
View File
@@ -27,6 +27,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// update sit at the lowest tier next to create_customer.
create_article: 'low',
update_article: 'low',
// Dimension values (kostnadsställe/projekt object codes, SIE #OBJEKT) are
// reporting master data — no journal impact, no external side-effect, no
// payment-routing surface. Staged (agents never silently mint reporting
// values) but at the lowest tier next to create_customer/create_article.
create_dimension_value: 'low',
// ── Medium: reversible booking ─────────────────────────────────────
categorize_transaction: 'medium',
@@ -0,0 +1,73 @@
/**
* Authoritative server-side validation for the create_dimension_value staged
* operation. Used by:
* - The MCP tool execute() before staging (extensions/general/mcp-server/server.ts)
* - commitCreateDimensionValue() before the dimension_values INSERT
* (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 CreateSupplierParamsSchema.
*
* The code format is the strict Fortnox charset — deliberately tighter than
* both the DB CHECK (1..40 chars, no `"{}`) and DimensionsBagSchema: legacy
* free-text codes from the backfill/SIE import must survive on lines, but new
* registry codes minted by agents stay portable to Fortnox/Visma. Identical
* to `dimensionValueCode` in lib/api/schemas.ts (the dashboard POST route) so
* the two write paths cannot drift.
*/
import { z } from 'zod'
const FORTNOX_CODE_RE = /^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
/** Trim strings; normalise empty/null to undefined before the inner schema. */
function optString(inner: z.ZodTypeAny) {
return z.preprocess(
(v) => {
if (v == null) return undefined
if (typeof v !== 'string') return v
const t = v.trim()
return t === '' ? undefined : t
},
inner.optional(),
)
}
export const CreateDimensionValueParamsSchema = z
.object({
sie_dim_no: z.preprocess(
(v) => (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : v),
z
.number()
.int('sie_dim_no must be an integer SIE dimension number')
.min(1, 'sie_dim_no must be ≥ 1 (1 = kostnadsställe, 6 = projekt)'),
),
code: z.preprocess(
(v) => (typeof v === 'string' ? v.trim() : v),
z
.string()
.regex(
FORTNOX_CODE_RE,
'Koden får bara innehålla bokstäver (A–Ö), siffror, _, + och - (max 20 tecken)',
),
),
name: z.preprocess(
(v) => (typeof v === 'string' ? v.trim() : v),
z.string().min(1, 'name is required').max(120),
),
start_date: optString(z.string().regex(ISO_DATE_RE, 'start_date must be ISO yyyy-MM-dd')),
end_date: optString(z.string().regex(ISO_DATE_RE, 'end_date must be ISO yyyy-MM-dd')),
})
.strict()
.superRefine((val, ctx) => {
if (val.start_date && val.end_date && val.end_date < val.start_date) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['end_date'],
message: 'Slutdatum får inte vara före startdatum',
})
}
})
export type CreateDimensionValueParams = z.infer<typeof CreateDimensionValueParamsSchema>
+4 -1
View File
@@ -3351,7 +3351,10 @@
"fill_balance_tooltip": "Double-click to fill the balancing amount",
"fill_balance_hint": "Tip: double-click debit or credit to fill the remaining difference.",
"review_month_changed": "Note: different month than the previous voucher ({prev} → {current}).",
"review_period_locked": "This period is closed or locked — posting may be rejected."
"review_period_locked": "This period is closed or locked — posting may be rejected.",
"add_dimensions": "Cost centre/Project",
"dimensions_apply_all_hint": "Applies to all rows without their own tag.",
"row_dimensions_aria": "Cost centre/project for this row"
},
"chart_of_accounts": {
"class_1": "Assets",
+4 -1
View File
@@ -3351,7 +3351,10 @@
"fill_balance_tooltip": "Dubbelklicka för att fylla i balanserande belopp",
"fill_balance_hint": "Tips: dubbelklicka på debet eller kredit för att fylla i differensen.",
"review_month_changed": "Obs: annan månad än föregående verifikat ({prev} → {current}).",
"review_period_locked": "Perioden är stängd eller låst — bokföring kan nekas."
"review_period_locked": "Perioden är stängd eller låst — bokföring kan nekas.",
"add_dimensions": "Kostnadsställe/Projekt",
"dimensions_apply_all_hint": "Gäller alla rader utan egen märkning.",
"row_dimensions_aria": "Kostnadsställe/Projekt för raden"
},
"chart_of_accounts": {
"class_1": "Tillgångar",
@@ -0,0 +1,75 @@
-- Add 'create_dimension_value' to the pending_operations operation_type CHECK
-- constraint.
--
-- The MCP tool gnubok_create_dimension_value (dimensions PR3 —
-- dev_docs/dimensions_implementation_plan.md §6) stages a pending operation
-- that, on approval, dispatches into commitCreateDimensionValue. That executor
-- validates the strict Fortnox code format, get-or-creates the system
-- dimensions (1 = kostnadsställe, 6 = projekt) via ensure_company_dimensions,
-- and inserts the dimension value (idempotent on duplicate code). Agents must
-- never silently mint reporting values — resolve-don't-select on the voucher
-- tools rejects unknown codes and routes the agent here instead. Without this
-- expansion the staged INSERT would be rejected by the constraint before the
-- commit-side code ever runs, blocking the staged-operation review flow —
-- mirrors create_customer / create_article.
--
-- Risk tier (lib/pending-operations/risk-tiers.ts): 'low' — registry master
-- data with no journal impact, no external side-effects and no payment-routing
-- surface, same tier as create_customer / create_article. Still staged and
-- human-approved (no auto-commit path exists).
--
-- pg-test: covered-by — CHECK-list expansion only (no trigger/RPC/RLS/
-- DEFERRABLE change), so no *.pg.test.ts is required. Mirrors
-- 20260630120000_pending_operations_add_bulk_book_inbox_items.sql.
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',
'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' -- dimensions registry: stage a new kostnadsställe/projekt value (SIE #OBJEKT)
));
NOTIFY pgrst, 'reload schema';
+3
View File
@@ -1800,6 +1800,9 @@ export type PendingOperationType =
// (returns a signing link); the user's signature in the browser files it.
| 'submit_vat_declaration'
| 'submit_agi'
// Dimensions PR3: stage a new dimension value (kostnadsställe/projekt object
// code, SIE #OBJEKT) — agents never silently mint reporting values.
| 'create_dimension_value'
export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected'
// 'agent_chat' = the in-app AI chat (DB CHECK widened in migration