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
@@ -0,0 +1,166 @@
/**
* Tests for POST /api/dimensions (create a custom dimension — PR10).
*
* Covers: auto-picking the next free SIE number >= 20 (proved both via the
* insert result and via the self-parent guard, which names the picked
* number), 409 on an explicitly taken number, 400 on an invalid parent, and
* the 201 { data: { dimension } } contract for an explicit number.
*
* Queue order per request: ensure_company_dimensions RPC → existing-numbers
* select → insert returning the row.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../route'
const noParams = { params: Promise.resolve({}) }
const postRequest = (body: Record<string, unknown>) =>
createMockRequest('/api/dimensions', { method: 'POST', body })
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
sort_order: number
}
type DimensionBody = { data: { dimension: DimensionRow } }
type ErrorBody = { error: { code: string; message: string } }
function makeDimensionRow(overrides: Partial<DimensionRow> = {}): DimensionRow {
return {
id: 'dim-new',
sie_dim_no: 21,
name: 'Avdelning',
parent_sie_dim_no: null,
resets_annually: true,
is_system: false,
is_active: true,
sort_order: 100,
...overrides,
}
}
/** Enqueue the ensure RPC + the existing-numbers select. */
function enqueuePreamble(existingNumbers: number[]) {
enqueue({ data: null }) // ensure_company_dimensions
enqueue({ data: existingNumbers.map((n) => ({ sie_dim_no: n })) })
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
describe('POST /api/dimensions', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(postRequest({ name: 'Avdelning' }), noParams)
expect(response.status).toBe(401)
})
it('picks the next free number >= 20 when sie_dim_no is omitted ([1,6,20] → 21)', async () => {
enqueuePreamble([1, 6, 20])
enqueue({ data: makeDimensionRow({ sie_dim_no: 21 }) })
const response = await POST(postRequest({ name: 'Avdelning' }), noParams)
const { status, body } = await parseJsonResponse<DimensionBody>(response)
expect(status).toBe(201)
expect(body.data.dimension.sie_dim_no).toBe(21)
expect(body.data.dimension.is_system).toBe(false)
})
it('auto-picks exactly 21 — pinned via the self-parent guard message', async () => {
// The queued mock cannot capture insert payloads, so pin the computed
// number through an observable branch: parent 21 collides with the pick
// ONLY if the route picked 21 (any other pick yields the "finns inte"
// message instead, since 21 is not a registered number).
enqueuePreamble([1, 6, 20])
const response = await POST(
postRequest({ name: 'Avdelning', parent_sie_dim_no: 21 }),
noParams,
)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('DIMENSION_PARENT_INVALID')
expect(body.error.message).toBe(
'En dimension kan inte vara sin egen överordnade dimension.',
)
})
it('returns 409 DIMENSION_NUMBER_TAKEN for an explicitly taken number', async () => {
enqueuePreamble([1, 6])
const response = await POST(postRequest({ name: 'Projekt igen', sie_dim_no: 6 }), noParams)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('DIMENSION_NUMBER_TAKEN')
})
it('returns 400 DIMENSION_PARENT_INVALID for an unknown parent', async () => {
enqueuePreamble([1, 6])
const response = await POST(
postRequest({ name: 'Avdelning', sie_dim_no: 30, parent_sie_dim_no: 99 }),
noParams,
)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('DIMENSION_PARENT_INVALID')
expect(body.error.message).toContain('99')
})
it('creates an explicit-number dimension with the 201 { data: { dimension } } shape', async () => {
enqueuePreamble([1, 6])
const row = makeDimensionRow({
sie_dim_no: 30,
name: 'Maskin',
parent_sie_dim_no: 6,
resets_annually: false,
})
enqueue({ data: row })
const response = await POST(
postRequest({ name: 'Maskin', sie_dim_no: 30, parent_sie_dim_no: 6, resets_annually: false }),
noParams,
)
const { status, body } = await parseJsonResponse<DimensionBody>(response)
expect(status).toBe(201)
expect(body.data.dimension).toEqual(row)
})
})
+292
View File
@@ -0,0 +1,292 @@
/**
* Tests for /api/dimensions/rules (list + create) and
* /api/dimensions/rules/[id] (update + delete) — dimensions PR10.
*
* Exercises the routes through the real withRouteContext wrapper, mocking
* only its auth/company dependencies and injecting a queued Supabase mock via
* requireAuth. Covers: 401, the DTO mapping contract, the account filter
* validation, the schema's value-presence superRefine, referential 404s, the
* 23505 → 409 duplicate mapping, PATCH's effective-type validation, and
* DELETE's count-based 404.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { GET, POST } from '../rules/route'
import { PATCH, DELETE } from '../rules/[id]/route'
const DIM_ID = '11111111-1111-4111-8111-111111111111'
const VALUE_ID = '22222222-2222-4222-8222-222222222222'
const RULE_ID = '33333333-3333-4333-8333-333333333333'
const noParams = { params: Promise.resolve({}) }
const idParams = createMockRouteParams({ id: RULE_ID })
interface RuleDto {
account_dimension_rule_id: string
account_number: string
dimension_id: string
sie_dim_no: number
dimension_name: string
rule_type: string
value_id: string | null
value_code: string | null
value_name: string | null
is_active: boolean
}
type RuleBody = { data: { rule: RuleDto } }
type RulesBody = { data: { rules: RuleDto[] } }
type ErrorBody = { error: { code: string; message: string } }
/** Raw row exactly as RULE_SELECT returns it (joined registry aliases). */
function makeRawRule(overrides: Record<string, unknown> = {}) {
return {
id: RULE_ID,
account_number: '4010',
rule_type: 'default',
value_id: VALUE_ID,
is_active: true,
dimension: { id: DIM_ID, sie_dim_no: 6, name: 'Projekt' },
value: { code: 'P001', name: 'Projekt Alpha' },
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
describe('GET /api/dimensions/rules', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(createMockRequest('/api/dimensions/rules'), noParams)
expect(response.status).toBe(401)
})
it('maps rows through the DTO (account_dimension_rule_id + value fields)', async () => {
enqueue({
data: [
makeRawRule(),
makeRawRule({
id: '44444444-4444-4444-8444-444444444444',
account_number: '5010',
rule_type: 'required',
value_id: null,
value: null,
}),
],
})
const response = await GET(createMockRequest('/api/dimensions/rules'), noParams)
const { status, body } = await parseJsonResponse<RulesBody>(response)
expect(status).toBe(200)
expect(body.data.rules).toHaveLength(2)
expect(body.data.rules[0]).toEqual({
account_dimension_rule_id: RULE_ID,
account_number: '4010',
dimension_id: DIM_ID,
sie_dim_no: 6,
dimension_name: 'Projekt',
rule_type: 'default',
value_id: VALUE_ID,
value_code: 'P001',
value_name: 'Projekt Alpha',
is_active: true,
})
// A required rule has no value — the DTO carries explicit nulls.
expect(body.data.rules[1]).toMatchObject({
rule_type: 'required',
value_id: null,
value_code: null,
value_name: null,
})
})
it('rejects a malformed account_number filter with 400', async () => {
const response = await GET(
createMockRequest('/api/dimensions/rules', { searchParams: { account_number: '40' } }),
noParams,
)
const { status, body } = await parseJsonResponse<{ type: string }>(response)
expect(status).toBe(400)
// validateQuery's canonical envelope (review fix: inline regex → schema).
expect(body.type).toBe('validation_error')
})
})
describe('POST /api/dimensions/rules', () => {
const postRequest = (body: Record<string, unknown>) =>
createMockRequest('/api/dimensions/rules', { method: 'POST', body })
const validDefaultBody = {
account_number: '4010',
dimension_id: DIM_ID,
rule_type: 'default',
value_id: VALUE_ID,
}
it('creates a default rule (dimension → value → account → insert) with 201', async () => {
enqueue({ data: { id: DIM_ID, is_active: true } }) // dimension lookup
enqueue({ data: { id: VALUE_ID, is_active: true } }) // value lookup
enqueue({ data: { account_number: '4010' } }) // chart lookup
enqueue({ data: makeRawRule() }) // insert returning RULE_SELECT
const response = await POST(postRequest(validDefaultBody), noParams)
const { status, body } = await parseJsonResponse<RuleBody>(response)
expect(status).toBe(201)
expect(body.data.rule.account_dimension_rule_id).toBe(RULE_ID)
expect(body.data.rule.rule_type).toBe('default')
expect(body.data.rule.value_code).toBe('P001')
})
it('rejects a required rule that carries a value (schema superRefine)', async () => {
const response = await POST(
postRequest({
account_number: '4010',
dimension_id: DIM_ID,
rule_type: 'required',
value_id: VALUE_ID,
}),
noParams,
)
expect(response.status).toBe(400)
})
it('rejects a default rule without a value (schema superRefine)', async () => {
const response = await POST(
postRequest({ account_number: '4010', dimension_id: DIM_ID, rule_type: 'default' }),
noParams,
)
expect(response.status).toBe(400)
})
it('returns 404 for a dimension the company does not have', async () => {
enqueue({ data: null }) // dimension lookup misses
const response = await POST(postRequest(validDefaultBody), noParams)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('DIMENSION_NOT_FOUND')
})
it('maps the UNIQUE violation (23505) to 409 DIMENSION_RULE_EXISTS', async () => {
enqueue({ data: { id: DIM_ID, is_active: true } })
enqueue({ data: { id: VALUE_ID, is_active: true } })
enqueue({ data: { account_number: '4010' } })
enqueue({
error: { code: '23505', message: 'duplicate key value violates unique constraint' },
})
const response = await POST(postRequest(validDefaultBody), noParams)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('DIMENSION_RULE_EXISTS')
})
})
describe('PATCH /api/dimensions/rules/[id]', () => {
const patchRequest = (body: Record<string, unknown>) =>
createMockRequest(`/api/dimensions/rules/${RULE_ID}`, { method: 'PATCH', body })
it('pauses a rule via is_active without touching the value', async () => {
enqueue({
data: { id: RULE_ID, rule_type: 'default', value_id: VALUE_ID, dimension_id: DIM_ID },
}) // existing lookup
enqueue({ data: makeRawRule({ is_active: false }) }) // update returning RULE_SELECT
const response = await PATCH(patchRequest({ is_active: false }), idParams)
const { status, body } = await parseJsonResponse<RuleBody>(response)
expect(status).toBe(200)
expect(body.data.rule.is_active).toBe(false)
expect(body.data.rule.account_dimension_rule_id).toBe(RULE_ID)
})
it('rejects switching to required while the stored value remains (effective type)', async () => {
enqueue({
data: { id: RULE_ID, rule_type: 'default', value_id: VALUE_ID, dimension_id: DIM_ID },
})
const response = await PATCH(patchRequest({ rule_type: 'required' }), idParams)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_FAILED')
})
it('returns 404 for a rule outside the company', async () => {
enqueue({ data: null })
const response = await PATCH(patchRequest({ is_active: false }), idParams)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('DIMENSION_RULE_NOT_FOUND')
})
})
describe('DELETE /api/dimensions/rules/[id]', () => {
it('deletes the rule and confirms', async () => {
enqueue({ count: 1 })
const response = await DELETE(
createMockRequest(`/api/dimensions/rules/${RULE_ID}`, { method: 'DELETE' }),
idParams,
)
const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response)
expect(status).toBe(200)
expect(body.data.deleted).toBe(true)
})
it('returns 404 when nothing was deleted (count 0)', async () => {
enqueue({ count: 0 })
const response = await DELETE(
createMockRequest(`/api/dimensions/rules/${RULE_ID}`, { method: 'DELETE' }),
idParams,
)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('DIMENSION_RULE_NOT_FOUND')
})
})
+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 },
)
+138
View File
@@ -0,0 +1,138 @@
/**
* /api/dimensions/rules/[id] — mutate one account dimension rule (PR10).
*
* PATCH { rule_type?, value_id?, is_active? } — value presence is
* re-validated against the EFFECTIVE rule_type (required ⇔ no value).
* DELETE — removes the rule; enforcement stops immediately. Pausing without
* losing the configuration is is_active: false.
*/
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 { UpdateAccountDimensionRuleSchema } from '@/lib/api/schemas'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { RULE_SELECT, toRuleDto, type RawRule } from '../dto'
ensureInitialized()
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'dimension.rules.update',
async (request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, UpdateAccountDimensionRuleSchema)
if (!validation.success) return validation.response
const body = validation.data
const { data: existing, error: existingError } = await supabase
.from('account_dimension_rules')
.select('id, rule_type, value_id, dimension_id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (existingError) return errorResponse(existingError, log, { requestId })
if (!existing) {
return NextResponse.json(
{ error: { code: 'DIMENSION_RULE_NOT_FOUND', message: 'Regeln finns inte.' } },
{ status: 404 },
)
}
const effectiveType = body.rule_type ?? (existing.rule_type as string)
const effectiveValueId =
body.value_id !== undefined ? body.value_id : (existing.value_id as string | null)
if (effectiveType === 'required' && effectiveValueId) {
return NextResponse.json(
{ error: { code: 'VALIDATION_FAILED', message: 'En obligatorisk regel har inget värde — ta bort värdet eller byt regeltyp.' } },
{ status: 400 },
)
}
if (effectiveType !== 'required' && !effectiveValueId) {
return NextResponse.json(
{ error: { code: 'VALIDATION_FAILED', message: 'Välj vilket värde regeln ska använda.' } },
{ status: 400 },
)
}
if (body.value_id) {
const { data: value, error: valueError } = await supabase
.from('dimension_values')
.select('id, is_active')
.eq('id', body.value_id)
.eq('company_id', companyId)
.eq('dimension_id', existing.dimension_id)
.maybeSingle()
if (valueError) return errorResponse(valueError, log, { requestId })
if (!value) {
return NextResponse.json(
{ error: { code: 'DIMENSION_VALUE_NOT_FOUND', message: 'Värdet finns inte under regelns dimension.' } },
{ status: 404 },
)
}
if (!value.is_active) {
return NextResponse.json(
{ error: { code: 'DIMENSION_VALUE_ARCHIVED', message: 'Värdet är arkiverat — återaktivera det innan det används i en regel.' } },
{ status: 400 },
)
}
}
const updates: Record<string, unknown> = {}
if (body.rule_type !== undefined) updates.rule_type = body.rule_type
if (body.value_id !== undefined) updates.value_id = body.value_id
if (body.is_active !== undefined) updates.is_active = body.is_active
if (Object.keys(updates).length === 0) {
return NextResponse.json(
{ error: { code: 'VALIDATION_FAILED', message: 'Ingen ändring angiven.' } },
{ status: 400 },
)
}
const { data: rule, error: updateError } = await supabase
.from('account_dimension_rules')
.update(updates)
.eq('id', id)
.eq('company_id', companyId)
.select(RULE_SELECT)
.single()
if (updateError) {
log.error('dimension rule update failed', updateError)
return errorResponse(updateError, log, { requestId })
}
return NextResponse.json({ data: { rule: toRuleDto(rule as unknown as RawRule) } })
},
{ requireWrite: true },
)
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'dimension.rules.delete',
async (_request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const { error, count } = await supabase
.from('account_dimension_rules')
.delete({ count: 'exact' })
.eq('id', id)
.eq('company_id', companyId)
if (error) {
log.error('dimension rule delete failed', error)
return errorResponse(error, log, { requestId })
}
if (!count) {
return NextResponse.json(
{ error: { code: 'DIMENSION_RULE_NOT_FOUND', message: 'Regeln finns inte.' } },
{ status: 404 },
)
}
return NextResponse.json({ data: { deleted: true } })
},
{ requireWrite: true },
)
+29
View File
@@ -0,0 +1,29 @@
/** Shared select + DTO mapper for the account_dimension_rules routes (PR10). */
export const RULE_SELECT =
'id, account_number, rule_type, value_id, is_active, dimension:dimensions!account_dimension_rules_dimension_id_company_id_fkey(id, sie_dim_no, name), value:dimension_values!account_dimension_rules_value_id_fkey(code, name)'
export interface RawRule {
id: string
account_number: string
rule_type: 'required' | 'default' | 'fixed'
value_id: string | null
is_active: boolean
dimension: { id: string; sie_dim_no: number; name: string }
value: { code: string; name: string } | null
}
export function toRuleDto(row: RawRule) {
return {
account_dimension_rule_id: row.id,
account_number: row.account_number,
dimension_id: row.dimension.id,
sie_dim_no: row.dimension.sie_dim_no,
dimension_name: row.dimension.name,
rule_type: row.rule_type,
value_id: row.value_id,
value_code: row.value?.code ?? null,
value_name: row.value?.name ?? null,
is_active: row.is_active,
}
}
+157
View File
@@ -0,0 +1,157 @@
/**
* /api/dimensions/rules — per-account dimension policy (dimensions PR10).
*
* GET ?account_number=4010 (optional) → every rule (or the account's).
* POST → create a rule. 'required' blocks posting on the account without a
* value for the dimension (enforced at commitEntry + the bulk-book route);
* 'default' pre-fills at draft creation; 'fixed' always applies.
*
* Opt-in by construction: zero rules = the engine behaves exactly as before.
* There is deliberately NO settings toggle for enforcement — a rule that
* exists but is ignored would be worse than either extreme; pausing a single
* rule is what is_active is for.
*/
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateAccountDimensionRuleSchema, ListDimensionRulesQuerySchema } from '@/lib/api/schemas'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { RULE_SELECT, toRuleDto, type RawRule } from './dto'
ensureInitialized()
export const GET = withRouteContext(
'dimension.rules.list',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const queryValidation = validateQuery(request, ListDimensionRulesQuerySchema, {
log,
operation: 'dimension.rules.list',
})
if (!queryValidation.success) return queryValidation.response
const { account_number: accountNumber } = queryValidation.data
let query = supabase
.from('account_dimension_rules')
.select(RULE_SELECT)
.eq('company_id', companyId)
.order('account_number', { ascending: true })
if (accountNumber) {
query = query.eq('account_number', accountNumber)
}
const { data, error } = await query
if (error) {
log.error('dimension rule list failed', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({
data: { rules: ((data ?? []) as unknown as RawRule[]).map(toRuleDto) },
})
},
)
export const POST = withRouteContext(
'dimension.rules.create',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, CreateAccountDimensionRuleSchema)
if (!validation.success) return validation.response
const body = validation.data
// The dimension must belong to this company (RLS backstops; this gives a
// clean Swedish 400 instead of an FK error).
const { data: dimension, error: dimensionError } = await supabase
.from('dimensions')
.select('id, is_active')
.eq('id', body.dimension_id)
.eq('company_id', companyId)
.maybeSingle()
if (dimensionError) return errorResponse(dimensionError, log, { requestId })
if (!dimension) {
return NextResponse.json(
{ error: { code: 'DIMENSION_NOT_FOUND', message: 'Dimensionen finns inte i registret.' } },
{ status: 404 },
)
}
// default/fixed: the value must belong to the SAME dimension + company
// and be active — a rule pointing at a foreign or archived value would
// make every booking on the account fail registry validation.
if (body.value_id) {
const { data: value, error: valueError } = await supabase
.from('dimension_values')
.select('id, is_active')
.eq('id', body.value_id)
.eq('company_id', companyId)
.eq('dimension_id', body.dimension_id)
.maybeSingle()
if (valueError) return errorResponse(valueError, log, { requestId })
if (!value) {
return NextResponse.json(
{ error: { code: 'DIMENSION_VALUE_NOT_FOUND', message: 'Värdet finns inte under den valda dimensionen.' } },
{ status: 404 },
)
}
if (!value.is_active) {
return NextResponse.json(
{ error: { code: 'DIMENSION_VALUE_ARCHIVED', message: 'Värdet är arkiverat — återaktivera det innan det används i en regel.' } },
{ status: 400 },
)
}
}
// The account must exist and be active in the company chart — a rule on
// a nonexistent account can never fire and only confuses.
const { data: account, error: accountError } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.eq('account_number', body.account_number)
.eq('is_active', true)
.maybeSingle()
if (accountError) return errorResponse(accountError, log, { requestId })
if (!account) {
return NextResponse.json(
{ error: { code: 'ACCOUNT_NOT_FOUND', message: `Konto ${body.account_number} finns inte som aktivt konto i kontoplanen.` } },
{ status: 404 },
)
}
const { data: rule, error: insertError } = await supabase
.from('account_dimension_rules')
.insert({
company_id: companyId,
account_number: body.account_number,
dimension_id: body.dimension_id,
rule_type: body.rule_type,
value_id: body.value_id ?? null,
is_active: body.is_active ?? true,
})
.select(RULE_SELECT)
.single()
if (insertError) {
if (insertError.code === '23505') {
return NextResponse.json(
{ error: { code: 'DIMENSION_RULE_EXISTS', message: `Konto ${body.account_number} har redan en regel för den dimensionen.` } },
{ status: 409 },
)
}
log.error('dimension rule create failed', insertError)
return errorResponse(insertError, log, { requestId })
}
return NextResponse.json(
{ data: { rule: toRuleDto(rule as unknown as RawRule) } },
{ status: 201 },
)
},
{ requireWrite: true },
)
@@ -139,6 +139,8 @@ describe('POST /api/transactions/bulk-book', () => {
{ account_number: '2611', debit_amount: '', credit_amount: String(total * 0.2), line_description: 'Utg moms 25%' },
])
// Account dimension rules pre-check (PR10) — none configured.
enqueue({ data: [], error: null })
// RPC returns happy path.
enqueue({
data: {
+28
View File
@@ -5,6 +5,12 @@ import { BulkBookSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
import { mergeDimensionBags } from '@/lib/bookkeeping/dimension-resolver'
import {
applyDimensionRules,
assertMandatoryDimensions,
fetchActiveDimensionRules,
} from '@/lib/bookkeeping/dimension-rules'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { BookingTemplateLibraryLine, Transaction } from '@/types'
@@ -242,6 +248,28 @@ export const POST = withRouteContext(
}
}
// Account dimension rules (dimensions PR10): the bulk-book RPC bypasses
// the TS engine, so the policy layer runs here — defaults/fixed applied
// to the computed lines, then 'required' asserted. Zero rules (the
// default) or a failed fetch changes nothing (fail-open, same posture as
// the engine).
if (newEntryPayload) {
const rules = await fetchActiveDimensionRules(supabase, companyId!)
if (rules === null) {
opLog.warn('dimension rule fetch failed — policy skipped (fail-open)')
}
if (rules && rules.length > 0) {
newEntryPayload.lines = applyDimensionRules(newEntryPayload.lines, rules)
try {
assertMandatoryDimensions(newEntryPayload.lines, rules)
} catch (err) {
const mapped = bookkeepingErrorResponse(err)
if (mapped) return mapped
throw err
}
}
}
// p_user_id removed in PR #608 (round-3 hardening pattern applied
// consistently). RPC resolves the caller via auth.uid().
const { data, error } = await supabase.rpc('bulk_book_transactions', {