Files
accounted/lib/bookkeeping/dimension-errors.ts
T
Jakob Wennberg 11126d6d56 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>
2026-07-02 13:36:45 +02:00

100 lines
4.4 KiB
TypeScript

/**
* 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
}