feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement (#886)

* feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement

The final rung of the dimensions ladder
(dev_docs/dimensions_implementation_plan.md §7 row 10):

- custom dimensions: POST /api/dimensions creates registry dims (next free
  SIE number >= 20 when omitted; explicit numbers allowed — SIE import
  already mints reserved ones); register gets a 'Ny dimension' dialog with
  a quiet Avancerat disclosure for the #UNDERDIM parent; GET now carries
  parent_sie_dim_no (the column + SIE round-trip existed since PR1/PR5 —
  this exposes it)
- account_dimension_rules (migration 20260703120000): one rule per
  (account, dimension) — required / default / fixed, per-rule is_active,
  company-scoped RLS, composite FK to the registry, value-presence CHECK
- enforcement, opt-in BY CONSTRUCTION (zero rules = engine byte-identical;
  deliberately NO settings toggle — a rule that exists but is ignored is
  worse than either extreme): default/fixed apply onto line bags at draft
  creation (fixed overwrites, default fills); required asserts at
  commitEntry with a Swedish MANDATORY_DIMENSION_MISSING naming every
  account + dimension; the bulk-book route runs the same policy before its
  RPC; storno/correction paths never pass through commitEntry so history
  always reverses regardless of policy; rule fetches fail open incl.
  thrown exceptions
- chart of accounts: per-account Dimensionsregler section in
  EditAccountDialog (Krävs/Förval/Låst, value picker, pause switch),
  gated on the existing dimensions toggle, quiet when empty
- pickers: LineDimensionFields is registry-driven (one combobox per active
  dimension, cached fetch, hardcoded 1/6 fallback) — every existing mount
  lights up custom dims with zero changes
- agent briefing: per-dimension required_on_accounts/default_on_accounts
  so agents self-correct instead of bouncing off the policy error
- rules CRUD API with existence/active/company validation and qualified
  DTO ids; firm_id FK deferred until the firms table lands (per plan)

39 new tests (pure-fn rules, engine enforcement, both new API surfaces,
pg-real RLS/CHECK/cascade suite); full suite 6,791 green; migration
replayed on a fresh container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: renumber migration to 20260703200000 — version collision with prod

The concurrent session shipped pending_operations_add_link_document_to_voucher
as 20260703120000 today; the Supabase preview branch (cloned from prod)
rejected the duplicate version key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: review round — auto-pick retry on collision, fail-open warnings, query schema

- POST /api/dimensions retries once past a concurrent number claim when the
  number was auto-picked (explicit choices still 409)
- every fail-open skip of the dimension-rules policy now logs a structured
  warning (engine draft/commit paths + bulk-book) — deliberate fail-open,
  but observable
- GET /api/dimensions/rules validates its query through
  ListDimensionRulesQuerySchema instead of an inline regex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 16:50:28 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 70e893b8d4
commit 764348e99c
26 changed files with 2800 additions and 72 deletions
+142 -1
View File
@@ -15,6 +15,8 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateDimensionSchema } from '@/lib/api/schemas'
import { errorResponse } from '@/lib/errors/get-structured-error'
ensureInitialized()
@@ -33,6 +35,7 @@ interface DimensionRow {
id: string
sie_dim_no: number
name: string
parent_sie_dim_no: number | null
resets_annually: boolean
is_system: boolean
is_active: boolean
@@ -58,7 +61,7 @@ export const GET = withRouteContext(
const { data: dims, error: dimsError } = await supabase
.from('dimensions')
.select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order')
.select('id, sie_dim_no, name, parent_sie_dim_no, resets_annually, is_system, is_active, sort_order')
.eq('company_id', companyId)
.order('sort_order', { ascending: true })
.order('sie_dim_no', { ascending: true })
@@ -101,3 +104,141 @@ export const GET = withRouteContext(
return NextResponse.json({ dimensions })
},
)
/**
* POST /api/dimensions — create a custom dimension (dimensions PR10).
*
* SIE reserves numbers 1-19 for standardized meanings (1 kostnadsställe,
* 6 projekt, 7 anställd, …) and leaves 20+ free — when sie_dim_no is
* omitted the server picks the next free number >= 20. Explicit numbers are
* allowed across the whole 1-9999 range (SIE import already creates
* reserved-number dims like 7 Anställd; manual creation of one you know is
* the same operation), uniqueness enforced per company.
*
* parent_sie_dim_no (optional) declares an #UNDERDIM hierarchy — it must
* reference an existing dimension in the company registry; SIE export emits
* the declaration parent-before-child (lib/reports/sie-export.ts).
*/
export const POST = withRouteContext(
'dimension.create',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, CreateDimensionSchema)
if (!validation.success) return validation.response
const body = validation.data
const { error: ensureError } = await supabase.rpc('ensure_company_dimensions', {
p_company_id: companyId,
})
if (ensureError) {
log.error('ensure_company_dimensions failed', ensureError)
return errorResponse(ensureError, log, { requestId })
}
const { data: existing, error: existingError } = await supabase
.from('dimensions')
.select('sie_dim_no')
.eq('company_id', companyId)
if (existingError) {
log.error('dimension number lookup failed', existingError)
return errorResponse(existingError, log, { requestId })
}
const taken = new Set(
((existing ?? []) as { sie_dim_no: number }[]).map((d) => d.sie_dim_no),
)
let sieDimNo = body.sie_dim_no
if (sieDimNo === undefined) {
// Next free custom number — SIE leaves 20+ unreserved.
sieDimNo = 20
while (taken.has(sieDimNo)) sieDimNo++
} else if (taken.has(sieDimNo)) {
return NextResponse.json(
{
error: {
code: 'DIMENSION_NUMBER_TAKEN',
message: `Dimension ${sieDimNo} finns redan i registret.`,
},
},
{ status: 409 },
)
}
if (body.parent_sie_dim_no != null) {
if (body.parent_sie_dim_no === sieDimNo) {
return NextResponse.json(
{
error: {
code: 'DIMENSION_PARENT_INVALID',
message: 'En dimension kan inte vara sin egen överordnade dimension.',
},
},
{ status: 400 },
)
}
if (!taken.has(body.parent_sie_dim_no)) {
return NextResponse.json(
{
error: {
code: 'DIMENSION_PARENT_INVALID',
message: `Överordnad dimension ${body.parent_sie_dim_no} finns inte i registret.`,
},
},
{ status: 400 },
)
}
}
const autoPicked = body.sie_dim_no === undefined
const insertDimension = (dimNo: number) =>
supabase
.from('dimensions')
.insert({
company_id: companyId,
sie_dim_no: dimNo,
name: body.name,
parent_sie_dim_no: body.parent_sie_dim_no ?? null,
resets_annually: body.resets_annually ?? true,
is_system: false,
is_active: true,
// System dims 1/6 sit at sort_order 10/20 (substrate seeding); custom
// dims trail them by default.
sort_order: 100,
})
.select('id, sie_dim_no, name, parent_sie_dim_no, resets_annually, is_system, is_active, sort_order')
.single()
let { data: dimension, error: insertError } = await insertDimension(sieDimNo)
// Auto-picked numbers can race a concurrent create/SIE import between the
// read and the insert — the UNIQUE is the arbiter; retry once past the
// loser instead of surfacing a spurious "finns redan" for a number the
// user never chose. Explicitly chosen numbers still 409.
if (insertError?.code === '23505' && autoPicked) {
sieDimNo++
while (taken.has(sieDimNo)) sieDimNo++
;({ data: dimension, error: insertError } = await insertDimension(sieDimNo))
}
if (insertError) {
if (insertError.code === '23505') {
return NextResponse.json(
{
error: {
code: 'DIMENSION_NUMBER_TAKEN',
message: `Dimension ${sieDimNo} finns redan i registret.`,
},
},
{ status: 409 },
)
}
log.error('dimension create failed', insertError)
return errorResponse(insertError, log, { requestId })
}
return NextResponse.json({ data: { dimension } }, { status: 201 })
},
{ requireWrite: true },
)