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
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', {
+380 -3
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
@@ -8,12 +8,29 @@ import {
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Loader2, Plus, X } from 'lucide-react'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
import {
fetchDimensions,
type AccountDimensionRuleDto,
type DimensionDto,
type DimensionRuleType,
} from '@/components/dimensions/types'
import type { BASAccount } from '@/types'
interface EditAccountDialogProps {
@@ -23,7 +40,23 @@ interface EditAccountDialogProps {
onSaved: () => void
}
// Hardcoded Swedish per the file's convention (chart-of-accounts editing is a
// bookkeeping surface). Labels mirror the rule semantics enforced by the
// engine at commit time.
const RULE_TYPE_LABELS: Record<DimensionRuleType, string> = {
required: 'Krävs',
default: 'Förval',
fixed: 'Låst',
}
const RULE_TYPE_HELP: Record<DimensionRuleType, string> = {
required: 'Krävs — verifikat på kontot kan inte bokföras utan värde',
default: 'Förval — värdet föreslås men kan ändras',
fixed: 'Låst — värdet sätts alltid automatiskt',
}
export function EditAccountDialog({ open, onOpenChange, account, onSaved }: EditAccountDialogProps) {
const { toast } = useToast()
const [accountName, setAccountName] = useState(account.account_name)
const [description, setDescription] = useState(account.description || '')
const [defaultVatCode, setDefaultVatCode] = useState(account.default_vat_code || '')
@@ -31,6 +64,183 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
const [isActive, setIsActive] = useState(account.is_active)
const [isSaving, setIsSaving] = useState(false)
// Dimension rules ("Dimensionsregler") — visible only when the company has
// dimensions enabled (same /api/settings gate as JournalEntryForm). Rule
// mutations apply immediately via their own fetches + toasts; they are
// deliberately independent of the account PUT below.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [dims, setDims] = useState<DimensionDto[]>([])
const [rules, setRules] = useState<AccountDimensionRuleDto[]>([])
const [rulesLoading, setRulesLoading] = useState(false)
const [addRuleOpen, setAddRuleOpen] = useState(false)
const [newRuleDimensionId, setNewRuleDimensionId] = useState('')
const [newRuleType, setNewRuleType] = useState<DimensionRuleType>('required')
const [newRuleValueCode, setNewRuleValueCode] = useState<string | null>(null)
const [isAddingRule, setIsAddingRule] = useState(false)
useEffect(() => {
let cancelled = false
fetch('/api/settings')
.then((r) => r.json())
.then(({ data }) => {
if (!cancelled && data?.dimensions_enabled === true) setDimensionsEnabled(true)
})
.catch(() => {
/* keep the section hidden */
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!dimensionsEnabled) return
let cancelled = false
setRulesLoading(true)
Promise.all([
fetchDimensions().catch(() => [] as DimensionDto[]),
fetch(`/api/dimensions/rules?account_number=${account.account_number}`)
.then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) }))
.catch(() => ({ ok: false, json: null })),
]).then(([fetchedDims, rulesRes]) => {
if (cancelled) return
setDims(fetchedDims)
if (rulesRes.ok) {
setRules((rulesRes.json?.data?.rules ?? []) as AccountDimensionRuleDto[])
}
setRulesLoading(false)
})
return () => {
cancelled = true
}
}, [dimensionsEnabled, account.account_number])
const activeDims = dims.filter((d) => d.is_active)
const newRuleDim = activeDims.find((d) => d.id === newRuleDimensionId) ?? null
const newRuleNeedsValue = newRuleType === 'default' || newRuleType === 'fixed'
function resetAddRuleForm() {
setAddRuleOpen(false)
setNewRuleDimensionId('')
setNewRuleType('required')
setNewRuleValueCode(null)
}
async function handleAddRule() {
if (!newRuleDim) return
setIsAddingRule(true)
try {
// Resolve the picked code to a value id. The combobox can create values
// inline, so a code missing from the mount-time registry snapshot means
// we refetch once before giving up.
let valueId: string | null = null
if (newRuleNeedsValue) {
const code = newRuleValueCode
if (!code) return
const findValueId = (list: DimensionDto[]) =>
list
.find((d) => d.id === newRuleDim.id)
?.values.find((v) => v.code === code)?.id ?? null
valueId = findValueId(dims)
if (!valueId) {
const refreshed = await fetchDimensions().catch(() => null)
if (refreshed) {
setDims(refreshed)
valueId = findValueId(refreshed)
}
}
if (!valueId) {
toast({
title: 'Kunde inte lägga till regeln',
description: `Värdet ${code} hittades inte i registret.`,
variant: 'destructive',
})
return
}
}
const body: Record<string, unknown> = {
account_number: account.account_number,
dimension_id: newRuleDim.id,
rule_type: newRuleType,
}
if (valueId) body.value_id = valueId
const res = await fetch('/api/dimensions/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
toast({
title: 'Kunde inte lägga till regeln',
description: getErrorMessage(json, { locale: 'sv' }),
variant: 'destructive',
})
return
}
const created = json?.data?.rule as AccountDimensionRuleDto | undefined
if (created) setRules((prev) => [...prev, created])
toast({ title: 'Regel tillagd' })
resetAddRuleForm()
} finally {
setIsAddingRule(false)
}
}
async function handleToggleRule(rule: AccountDimensionRuleDto, checked: boolean) {
const ruleId = rule.account_dimension_rule_id
// Optimistic — the switch flips immediately and reverts on failure.
setRules((prev) =>
prev.map((r) =>
r.account_dimension_rule_id === ruleId ? { ...r, is_active: checked } : r,
),
)
const res = await fetch(`/api/dimensions/rules/${ruleId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: checked }),
}).catch(() => null)
const json = await res?.json().catch(() => null)
if (!res?.ok) {
setRules((prev) =>
prev.map((r) =>
r.account_dimension_rule_id === ruleId ? { ...r, is_active: rule.is_active } : r,
),
)
toast({
title: 'Kunde inte uppdatera regeln',
description: getErrorMessage(json, { locale: 'sv' }),
variant: 'destructive',
})
return
}
const updated = json?.data?.rule as AccountDimensionRuleDto | undefined
if (updated) {
setRules((prev) =>
prev.map((r) => (r.account_dimension_rule_id === ruleId ? updated : r)),
)
}
}
async function handleDeleteRule(rule: AccountDimensionRuleDto) {
const ruleId = rule.account_dimension_rule_id
const res = await fetch(`/api/dimensions/rules/${ruleId}`, {
method: 'DELETE',
}).catch(() => null)
if (!res?.ok) {
const json = await res?.json().catch(() => null)
toast({
title: 'Kunde inte ta bort regeln',
description: getErrorMessage(json, { locale: 'sv' }),
variant: 'destructive',
})
return
}
setRules((prev) => prev.filter((r) => r.account_dimension_rule_id !== ruleId))
toast({ title: 'Regel borttagen' })
}
async function handleSave() {
setIsSaving(true)
try {
@@ -62,7 +272,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Redigera konto {account.account_number}
@@ -107,6 +317,173 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
</div>
</div>
{dimensionsEnabled && (
<div className="space-y-3">
<p className="text-sm font-medium">Dimensionsregler</p>
{rulesLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<>
{rules.length === 0 && !addRuleOpen && (
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Inga dimensionsregler för det här kontot.
</p>
<Button
variant="ghost"
size="sm"
onClick={() => setAddRuleOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Lägg till regel
</Button>
</div>
)}
{rules.map((rule) => (
<div
key={rule.account_dimension_rule_id}
className="flex items-center gap-3 rounded-lg border p-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{rule.dimension_name}
</p>
<p className="text-xs text-muted-foreground truncate">
{RULE_TYPE_LABELS[rule.rule_type]}
{rule.value_code && (
<>
{' · '}
<span className="font-mono">{rule.value_code}</span>
{rule.value_name && rule.value_name !== rule.value_code
? ` ${rule.value_name}`
: ''}
</>
)}
</p>
</div>
<Switch
checked={rule.is_active}
onCheckedChange={(checked) => handleToggleRule(rule, checked)}
aria-label={`Regel för ${rule.dimension_name} aktiv`}
/>
<Button
variant="ghost"
size="icon"
aria-label={`Ta bort regel för ${rule.dimension_name}`}
onClick={() => handleDeleteRule(rule)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
{addRuleOpen ? (
<div className="space-y-3 rounded-lg border border-dashed p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Dimension
</Label>
<Select
value={newRuleDimensionId || undefined}
onValueChange={(id) => {
setNewRuleDimensionId(id)
setNewRuleValueCode(null)
}}
>
<SelectTrigger>
<SelectValue placeholder="Välj dimension" />
</SelectTrigger>
<SelectContent>
{activeDims.map((dim) => (
<SelectItem key={dim.id} value={dim.id}>
{dim.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Typ</Label>
<Select
value={newRuleType}
onValueChange={(v) => {
setNewRuleType(v as DimensionRuleType)
if (v === 'required') setNewRuleValueCode(null)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(RULE_TYPE_LABELS) as DimensionRuleType[]).map(
(type) => (
<SelectItem key={type} value={type}>
{RULE_TYPE_LABELS[type]}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
{RULE_TYPE_HELP[newRuleType]}
</p>
{newRuleNeedsValue && newRuleDim && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Värde</Label>
<DimensionCombobox
sieDimNo={String(newRuleDim.sie_dim_no)}
value={newRuleValueCode}
onChange={setNewRuleValueCode}
/>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
disabled={isAddingRule}
onClick={resetAddRuleForm}
>
Avbryt
</Button>
<Button
size="sm"
disabled={
isAddingRule ||
!newRuleDim ||
(newRuleNeedsValue && !newRuleValueCode)
}
onClick={handleAddRule}
>
{isAddingRule && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Lägg till
</Button>
</div>
</div>
) : (
rules.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setAddRuleOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Lägg till regel
</Button>
)
)}
</>
)}
</div>
)}
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Aktivt konto</p>
+279 -25
View File
@@ -6,7 +6,9 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
@@ -14,6 +16,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
@@ -34,7 +43,9 @@ import {
Tags,
ChevronUp,
ChevronDown,
ChevronRight,
ChevronsUpDown,
Loader2,
} from 'lucide-react'
import DimensionValueForm, {
type DimensionValueFormInput,
@@ -81,6 +92,8 @@ export default function DimensionsManager() {
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [dialog, setDialog] = useState<DialogState>(null)
const [isSaving, setIsSaving] = useState(false)
const [newDimDialogOpen, setNewDimDialogOpen] = useState(false)
const [isCreatingDimension, setIsCreatingDimension] = useState(false)
const loadDimensions = useCallback(
async (showSpinner: boolean) => {
@@ -120,6 +133,15 @@ export default function DimensionsManager() {
)
const isProjectTab = activeDim?.sie_dim_no === PROJECT_DIM_NO
// Parent (#UNDERDIM) of the active tab's dimension, when it has one.
const parentDimName = useMemo(() => {
if (!activeDim || activeDim.parent_sie_dim_no == null) return null
const parent = dimensions.find(
(d) => d.sie_dim_no === activeDim.parent_sie_dim_no,
)
return parent?.name ?? `#${activeDim.parent_sie_dim_no}`
}, [dimensions, activeDim])
const filteredValues = useMemo(() => {
if (!activeDim) return []
const term = searchTerm.trim().toLowerCase()
@@ -224,6 +246,45 @@ export default function DimensionsManager() {
}
}
async function handleCreateDimension(input: NewDimensionFormInput) {
setIsCreatingDimension(true)
try {
// Contract: sie_dim_no omitted → the server picks the next free ≥20;
// parent_sie_dim_no omitted → no #UNDERDIM hierarchy.
const body: Record<string, unknown> = {
name: input.name,
resets_annually: input.resets_annually,
}
if (input.sie_dim_no !== null) body.sie_dim_no = input.sie_dim_no
if (input.parent_sie_dim_no !== null) {
body.parent_sie_dim_no = input.parent_sie_dim_no
}
const res = await fetch('/api/dimensions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok) throw json ?? new Error()
const createdId = (json?.data?.dimension as { id?: string } | undefined)?.id
toast({ title: t('dim_created_title') })
setNewDimDialogOpen(false)
await loadDimensions(false)
if (createdId) {
setActiveDimId(createdId)
setSearchTerm('')
}
} catch (err) {
toast({
title: t('save_failed_title'),
description: getErrorMessage(err, { locale: errorLocale }),
variant: 'destructive',
})
} finally {
setIsCreatingDimension(false)
}
}
async function handleDeleteValue() {
if (!activeDim || dialog?.mode !== 'edit') return
setIsSaving(true)
@@ -318,31 +379,49 @@ export default function DimensionsManager() {
return (
<div className="space-y-8">
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe, 6 Projekt) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Tabs
value={activeDimId ?? undefined}
onValueChange={(id) => {
setActiveDimId(id)
setSearchTerm('')
}}
>
<TabsList>
{dimensions.map((dim) => (
<TabsTrigger key={dim.id} value={dim.id}>
{dim.name}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Button
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
onClick={() => setDialog({ mode: 'create' })}
>
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('new_value')}
</Button>
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe,
6 Projekt, plus any custom 20+ dims) */}
<div className="space-y-2">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Tabs
value={activeDimId ?? undefined}
onValueChange={(id) => {
setActiveDimId(id)
setSearchTerm('')
}}
>
<TabsList>
{dimensions.map((dim) => (
<TabsTrigger key={dim.id} value={dim.id}>
{dim.name}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex items-center gap-3">
<Button
variant="outline"
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
onClick={() => setNewDimDialogOpen(true)}
>
{t('new_dimension')}
</Button>
<Button
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
onClick={() => setDialog({ mode: 'create' })}
>
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('new_value')}
</Button>
</div>
</div>
{parentDimName && (
<p className="text-xs text-muted-foreground">
{t('subdimension_of', { parent: parentDimName })}
</p>
)}
</div>
{/* Search */}
@@ -479,6 +558,181 @@ export default function DimensionsManager() {
)}
</DialogContent>
</Dialog>
{/* New dimension dialog — the content (and thus the form state)
unmounts on close, so each open starts from a blank form. */}
<Dialog
open={newDimDialogOpen}
onOpenChange={(open) => !open && setNewDimDialogOpen(false)}
>
<DialogContent className="sm:max-w-lg max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('new_dimension_title')}</DialogTitle>
</DialogHeader>
<NewDimensionForm
dimensions={dimensions}
isSaving={isCreatingDimension}
onSubmit={handleCreateDimension}
/>
</DialogContent>
</Dialog>
</div>
)
}
interface NewDimensionFormInput {
name: string
/** null → omit from the POST; the server picks the next free number ≥20. */
sie_dim_no: number | null
resets_annually: boolean
/** null → omit from the POST; no #UNDERDIM hierarchy. */
parent_sie_dim_no: number | null
}
/**
* Create form for a custom registry dimension (#DIM 20+). The parent
* (#UNDERDIM) select is an advanced, rarely-used SIE concept — kept behind a
* quiet disclosure so the common path stays name + number.
*/
function NewDimensionForm({
dimensions,
isSaving,
onSubmit,
}: {
/** Existing dims — active ones populate the parent select. */
dimensions: DimensionDto[]
isSaving: boolean
onSubmit: (input: NewDimensionFormInput) => void | Promise<void>
}) {
const t = useTranslations('dimensions')
const [name, setName] = useState('')
const [numberStr, setNumberStr] = useState('')
const [resetsAnnually, setResetsAnnually] = useState(true)
const [showAdvanced, setShowAdvanced] = useState(false)
const [parent, setParent] = useState('none')
const [nameError, setNameError] = useState<string | null>(null)
const [numberError, setNumberError] = useState<string | null>(null)
const parentOptions = dimensions.filter((d) => d.is_active)
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmedName = name.trim()
const trimmedNumber = numberStr.trim()
let valid = true
if (!trimmedName || trimmedName.length > 60) {
setNameError(t('dim_form_name_invalid'))
valid = false
} else {
setNameError(null)
}
let sieDimNo: number | null = null
if (trimmedNumber) {
const parsed = Number(trimmedNumber)
if (!Number.isInteger(parsed) || parsed < 20) {
setNumberError(t('dim_form_number_invalid'))
valid = false
} else {
setNumberError(null)
sieDimNo = parsed
}
} else {
setNumberError(null)
}
if (!valid) return
void onSubmit({
name: trimmedName,
sie_dim_no: sieDimNo,
resets_annually: resetsAnnually,
parent_sie_dim_no: parent === 'none' ? null : Number(parent),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="new-dimension-name">{t('form_name_label')}</Label>
<Input
id="new-dimension-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
autoFocus
/>
{nameError && <p className="text-xs text-destructive">{nameError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="new-dimension-number">{t('dim_form_number_label')}</Label>
<Input
id="new-dimension-number"
type="number"
inputMode="numeric"
min={20}
step={1}
value={numberStr}
onChange={(e) => setNumberStr(e.target.value)}
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">{t('dim_form_number_help')}</p>
{numberError && <p className="text-xs text-destructive">{numberError}</p>}
</div>
</div>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="new-dimension-resets">{t('dim_form_resets_label')}</Label>
<p className="text-xs text-muted-foreground max-w-md">
{t('dim_form_resets_help')}
</p>
</div>
<Switch
id="new-dimension-resets"
checked={resetsAnnually}
onCheckedChange={setResetsAnnually}
/>
</div>
<div className="space-y-3">
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
aria-expanded={showAdvanced}
>
<ChevronRight
className={`h-3 w-3 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}
aria-hidden="true"
/>
{t('dim_form_advanced')}
</button>
{showAdvanced && (
<div className="space-y-2">
<Label>{t('dim_form_parent_label')}</Label>
<Select value={parent} onValueChange={setParent}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('dim_form_parent_none')}</SelectItem>
{parentOptions.map((dim) => (
<SelectItem key={dim.id} value={String(dim.sie_dim_no)}>
{dim.name} ({dim.sie_dim_no})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('dim_form_parent_help')}</p>
</div>
)}
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('form_create')}
</Button>
</div>
</form>
)
}
+59 -28
View File
@@ -1,7 +1,12 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Label } from '@/components/ui/label'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
import {
fetchDimensionsCached,
type DimensionDto,
} from '@/components/dimensions/types'
interface LineDimensionFieldsProps {
/** Current dimensions map ({sie_dim_no: object_code}) — a line's map or the header default. */
@@ -16,11 +21,23 @@ interface LineDimensionFieldsProps {
}
/**
* 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).
* While the registry loads (or if the fetch fails) we render the seeded
* system pair (SIE dims 1/6) so the tagging affordance never disappears —
* the registry always contains at least these two.
*/
const FALLBACK_FIELDS: { sieDimNo: string; label: string }[] = [
{ sieDimNo: '1', label: 'Kostnadsställe' },
{ sieDimNo: '6', label: 'Projekt' },
]
/**
* Registry-driven dimension comboboxes used by the voucher form's header
* default, the per-row tag popover, and the mobile line cards. One combobox
* per active registry dimension, ordered by sort_order then sie_dim_no (the
* seeded 1/6 pair sorts first). Labels are the registry dimension names and
* hardcoded-Swedish fallbacks — 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,
@@ -29,32 +46,46 @@ export default function LineDimensionFields({
stacked,
inputClassName,
}: LineDimensionFieldsProps) {
const [registry, setRegistry] = useState<DimensionDto[] | null>(null)
useEffect(() => {
let cancelled = false
fetchDimensionsCached()
.then((dims) => {
if (!cancelled) setRegistry(dims)
})
.catch(() => {
/* keep the hardcoded 1/6 fallback */
})
return () => {
cancelled = true
}
}, [])
const fields = useMemo(() => {
const active = registry?.filter((d) => d.is_active) ?? []
if (active.length === 0) return FALLBACK_FIELDS
return [...active]
.sort((a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no)
.map((d) => ({ sieDimNo: String(d.sie_dim_no), label: d.name }))
}, [registry])
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}
/>
{fields.map((field) => (
<div key={field.sieDimNo}>
<Label className="text-xs text-muted-foreground">{field.label}</Label>
<div className="mt-1">
<DimensionCombobox
sieDimNo={field.sieDimNo}
value={dimensions?.[field.sieDimNo] ?? null}
onChange={(code) => onChange(field.sieDimNo, code)}
disabled={disabled}
className={inputClassName}
/>
</div>
</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>
)
}
+41 -1
View File
@@ -19,17 +19,38 @@ export interface DimensionValueDto {
export interface DimensionDto {
id: string
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt). */
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt, 20+ = custom). */
sie_dim_no: number
name: string
resets_annually: boolean
is_system: boolean
is_active: boolean
sort_order: number
/** SIE #UNDERDIM parent — the sie_dim_no of the parent dimension, or null. */
parent_sie_dim_no: number | null
/** Sorted by code by the API. */
values: DimensionValueDto[]
}
export type DimensionRuleType = 'required' | 'default' | 'fixed'
/**
* Per-account dimension rule as served by GET /api/dimensions/rules —
* a flattened join row (rule + dimension + optional pinned value).
*/
export interface AccountDimensionRuleDto {
account_dimension_rule_id: string
account_number: string
dimension_id: string
sie_dim_no: number
dimension_name: string
rule_type: DimensionRuleType
value_id: string | null
value_code: string | null
value_name: string | null
is_active: boolean
}
/** SIE dimension number whose values carry start/end dates (Projekt). */
export const PROJECT_DIM_NO = 6
@@ -54,3 +75,22 @@ export async function fetchDimensions(): Promise<DimensionDto[]> {
}
return (json?.dimensions ?? []) as DimensionDto[]
}
let cachedDimensionsPromise: Promise<DimensionDto[]> | null = null
/**
* Module-level cached variant of fetchDimensions for high-mount-count
* consumers (one registry fetch per page load instead of one per line
* picker). A failed fetch clears the cache so the next mount retries.
* Registry mutations are rare enough that staleness within a page visit
* is acceptable — the register UI uses the uncached fetch.
*/
export function fetchDimensionsCached(): Promise<DimensionDto[]> {
if (!cachedDimensionsPromise) {
cachedDimensionsPromise = fetchDimensions().catch((err) => {
cachedDimensionsPromise = null
throw err
})
}
return cachedDimensionsPromise
}
@@ -53,6 +53,7 @@ function mockSupabase(opts: {
// 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 }>
dimensionRuleRows?: Array<{ account_number: string; rule_type: string; dimension_id: string }>
dimensionsEnabled?: boolean
errors?: { profile?: string; memory?: string; atoms?: string }
}) {
@@ -164,6 +165,10 @@ function mockSupabase(opts: {
if (table === 'dimension_values') {
return chainResolving(opts.dimensionValueRows ?? [])
}
if (table === 'account_dimension_rules') {
// PR10: the briefing surfaces active rules; default none.
return chainResolving(opts.dimensionRuleRows ?? [])
}
throw new Error(`Unexpected table in test mock: ${table}`)
}),
}
+34 -1
View File
@@ -2253,6 +2253,16 @@ export const tools: McpTool[] = [
sie_dim_no: { type: 'number' },
name: { type: 'string' },
active_value_count: { type: 'number' },
required_on_accounts: {
type: 'array',
description: 'BAS accounts with an active required-rule: postings there are refused without a value for this dimension.',
items: { type: 'string' },
},
default_on_accounts: {
type: 'array',
description: 'BAS accounts where a default/fixed rule auto-applies a value at draft creation.',
items: { type: 'string' },
},
top_values: {
type: 'array',
description: 'Up to 10 active values; full list via gnubok_list_dimension_values.',
@@ -2267,7 +2277,7 @@ export const tools: McpTool[] = [
},
},
},
required: ['sie_dim_no', 'name', 'active_value_count', 'top_values'],
required: ['sie_dim_no', 'name', 'active_value_count', 'required_on_accounts', 'default_on_accounts', 'top_values'],
},
},
},
@@ -2402,6 +2412,8 @@ export const tools: McpTool[] = [
sie_dim_no: number
name: string
active_value_count: number
required_on_accounts: string[]
default_on_accounts: string[]
top_values: Array<{ code: string; name: string }>
}>
}
@@ -2421,6 +2433,25 @@ export const tools: McpTool[] = [
bucket.push({ code: v.code, name: v.name })
byDimension.set(v.dimension_id, bucket)
}
// Account dimension rules (PR10): tell the agent up front which
// accounts refuse postings without a value (required) and which
// auto-apply one (default/fixed) — so gnubok_create_voucher calls
// self-correct instead of bouncing off MANDATORY_DIMENSION_MISSING.
const requiredByDimension = new Map<string, string[]>()
const defaultByDimension = new Map<string, string[]>()
const { data: ruleRows, error: ruleErr } = await supabase
.from('account_dimension_rules')
.select('account_number, rule_type, dimension_id')
.eq('company_id', companyId)
.eq('is_active', true)
if (!ruleErr) {
for (const r of (ruleRows ?? []) as Array<{ account_number: string; rule_type: string; dimension_id: string }>) {
const target = r.rule_type === 'required' ? requiredByDimension : defaultByDimension
const bucket = target.get(r.dimension_id) ?? []
bucket.push(r.account_number)
target.set(r.dimension_id, bucket)
}
}
dimensionsBlock = {
enabled: settingsRow?.dimensions_enabled === true,
dimensions: dimensionRows.map((d) => {
@@ -2429,6 +2460,8 @@ export const tools: McpTool[] = [
sie_dim_no: d.sie_dim_no,
name: d.name,
active_value_count: values.length,
required_on_accounts: (requiredByDimension.get(d.id) ?? []).sort(),
default_on_accounts: (defaultByDimension.get(d.id) ?? []).sort(),
top_values: values.slice(0, 10),
}
}),
+56
View File
@@ -841,6 +841,62 @@ export const CreateDimensionValueSchema = z
* open period, lock date, active registry values); this schema only shapes
* the request. An empty bag {} untags the line.
*/
/**
* POST /api/dimensions — create a custom dimension (dimensions PR10).
* sie_dim_no omitted → server picks the next free number >= 20 (SIE leaves
* 20+ unreserved). parent_sie_dim_no declares an #UNDERDIM hierarchy.
*/
export const CreateDimensionSchema = z.object({
name: z.string().trim().min(1).max(60),
sie_dim_no: z.coerce.number().int().min(1).max(9999).optional(),
resets_annually: z.boolean().optional(),
parent_sie_dim_no: z.coerce.number().int().min(1).max(9999).nullable().optional(),
})
const AccountDimensionRuleTypeSchema = z.enum(['required', 'default', 'fixed'])
/** GET /api/dimensions/rules query — optional exact-account filter. */
export const ListDimensionRulesQuerySchema = z.object({
account_number: accountNumber.optional(),
})
/**
* POST /api/dimensions/rules — per-account dimension policy (dimensions
* PR10). 'required' carries no value; 'default'/'fixed' must carry the value
* to apply. One rule per (account, dimension) — enforced by the DB UNIQUE.
*/
export const CreateAccountDimensionRuleSchema = z
.object({
account_number: accountNumber,
dimension_id: uuid,
rule_type: AccountDimensionRuleTypeSchema,
value_id: uuid.optional(),
is_active: z.boolean().optional(),
})
.superRefine((rule, ctx) => {
if (rule.rule_type === 'required' && rule.value_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['value_id'],
message: 'En obligatorisk regel har inget värde — värden hör till Förval/Låst.',
})
}
if (rule.rule_type !== 'required' && !rule.value_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['value_id'],
message: 'Välj vilket värde regeln ska använda.',
})
}
})
/** PATCH /api/dimensions/rules/[id] — the value-presence rule re-checks in the route (partial update). */
export const UpdateAccountDimensionRuleSchema = z.object({
rule_type: AccountDimensionRuleTypeSchema.optional(),
value_id: uuid.nullable().optional(),
is_active: z.boolean().optional(),
})
export const RetagLineDimensionsSchema = z.object({
// {} passes (no entries to validate) = UNTAG. Intentional divergence from
// the MCP staged path (RetagLineDimensionsParamsSchema), which rejects an
@@ -0,0 +1,255 @@
/**
* Pure-function tests for the account dimension rule layer (dimensions PR10).
*
* applyDimensionRules: 'default' fills only absent bag keys, 'fixed' always
* overwrites (including alias-sourced values, with the deprecated aliases
* cleared on changed lines), and the zero-effect paths preserve array/line
* identity so the common rule-less booking allocates nothing.
*
* assertMandatoryDimensions: throws MandatoryDimensionMissingError with one
* violation per (account, dimension) regardless of line count, treats
* alias-sourced values as satisfying (normalize folds them into the bag),
* and no-ops when no 'required' rule exists.
*/
import { describe, it, expect } from 'vitest'
import {
applyDimensionRules,
assertMandatoryDimensions,
type AccountDimensionRule,
} from '../dimension-rules'
import {
MANDATORY_DIMENSION_MISSING,
MandatoryDimensionMissingError,
} from '../dimension-errors'
interface TestLine {
account_number: string
dimensions?: Record<string, string> | null
cost_center?: string | null
project?: string | null
}
function makeRule(overrides: Partial<AccountDimensionRule> = {}): AccountDimensionRule {
return {
account_number: '4010',
rule_type: 'default',
sie_dim_no: '6',
dimension_name: 'Projekt',
value_code: 'P001',
...overrides,
}
}
describe('applyDimensionRules', () => {
it('default fills only absent keys — caller-set keys win', () => {
const lines: TestLine[] = [
{ account_number: '4010', dimensions: { '6': 'CALLER' } },
]
const rules = [
makeRule({ rule_type: 'default', sie_dim_no: '6', value_code: 'PDEF' }),
makeRule({
rule_type: 'default',
sie_dim_no: '1',
dimension_name: 'Kostnadsställe',
value_code: 'KS01',
}),
]
const out = applyDimensionRules(lines, rules)
// Absent key '1' filled; present key '6' untouched.
expect(out[0].dimensions).toEqual({ '6': 'CALLER', '1': 'KS01' })
// The input line object was not mutated.
expect(lines[0].dimensions).toEqual({ '6': 'CALLER' })
})
it('default does not override an alias-sourced value (line identity preserved)', () => {
const lines: TestLine[] = [{ account_number: '4010', cost_center: 'KS-ALIAS' }]
const rules = [
makeRule({
rule_type: 'default',
sie_dim_no: '1',
dimension_name: 'Kostnadsställe',
value_code: 'KS99',
}),
]
// normalize folds cost_center into key '1', so the default has nothing to
// fill — nothing applies and the SAME array comes back.
expect(applyDimensionRules(lines, rules)).toBe(lines)
expect(lines[0].cost_center).toBe('KS-ALIAS')
})
it('fixed overwrites an alias-sourced value and clears the aliases', () => {
const lines: TestLine[] = [{ account_number: '4010', cost_center: 'OLD' }]
const rules = [
makeRule({
rule_type: 'fixed',
sie_dim_no: '1',
dimension_name: 'Kostnadsställe',
value_code: 'KS99',
}),
]
const out = applyDimensionRules(lines, rules)
expect(out[0].dimensions).toEqual({ '1': 'KS99' })
// Aliases nulled so downstream normalization cannot resurrect 'OLD'.
expect(out[0].cost_center).toBeNull()
expect(out[0].project).toBeNull()
})
it('fixed overwrites a caller-supplied bag value', () => {
const lines: TestLine[] = [{ account_number: '4010', dimensions: { '6': 'CALLER' } }]
const rules = [makeRule({ rule_type: 'fixed', sie_dim_no: '6', value_code: 'PLOCK' })]
const out = applyDimensionRules(lines, rules)
expect(out[0].dimensions).toEqual({ '6': 'PLOCK' })
})
it('a fixed rule already satisfied is a no-op — same array identity', () => {
const lines: TestLine[] = [{ account_number: '4010', dimensions: { '6': 'P001' } }]
const rules = [makeRule({ rule_type: 'fixed', sie_dim_no: '6', value_code: 'P001' })]
expect(applyDimensionRules(lines, rules)).toBe(lines)
})
it('untouched lines keep identity while changed lines are copied', () => {
const lines: TestLine[] = [
{ account_number: '4010' },
{ account_number: '1930', dimensions: { '1': 'KS01' } },
]
const rules = [makeRule({ rule_type: 'fixed', sie_dim_no: '6', value_code: 'P001' })]
const out = applyDimensionRules(lines, rules)
expect(out).not.toBe(lines)
expect(out[0]).not.toBe(lines[0])
expect(out[0].dimensions).toEqual({ '6': 'P001' })
// The 1930 line has no rule — the exact same object rides through.
expect(out[1]).toBe(lines[1])
})
it('returns the same array for zero rules and for required-only rules', () => {
const lines: TestLine[] = [{ account_number: '4010' }]
expect(applyDimensionRules(lines, [])).toBe(lines)
// 'required' rules carry no value — they never apply at draft time.
expect(
applyDimensionRules(lines, [
makeRule({ rule_type: 'required', value_code: null }),
]),
).toBe(lines)
})
it('rules for other accounts do not leak onto unrelated lines', () => {
const lines: TestLine[] = [{ account_number: '4010' }]
const rules = [
makeRule({ account_number: '5010', rule_type: 'fixed', value_code: 'P001' }),
makeRule({ account_number: '5010', rule_type: 'default', value_code: 'P002' }),
]
expect(applyDimensionRules(lines, rules)).toBe(lines)
expect(lines[0].dimensions).toBeUndefined()
})
})
describe('assertMandatoryDimensions', () => {
const requiredProjekt = makeRule({ rule_type: 'required', value_code: null })
it('throws with one deduped violation across multiple missing lines', () => {
const lines: TestLine[] = [
{ account_number: '4010', dimensions: {} },
{ account_number: '4010' },
{ account_number: '1930' },
]
let caught: unknown
try {
assertMandatoryDimensions(lines, [requiredProjekt])
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(MandatoryDimensionMissingError)
const error = caught as MandatoryDimensionMissingError
expect(error.code).toBe(MANDATORY_DIMENSION_MISSING)
// Two 4010 lines miss the same rule → ONE violation, not two.
expect(error.violations).toEqual([
{ account_number: '4010', sie_dim_no: '6', dimension_name: 'Projekt' },
])
})
it('reports one violation per (account, dimension) pair', () => {
const lines: TestLine[] = [
{ account_number: '4010' },
{ account_number: '5010' },
]
const rules = [
requiredProjekt,
makeRule({
account_number: '5010',
rule_type: 'required',
sie_dim_no: '1',
dimension_name: 'Kostnadsställe',
value_code: null,
}),
]
let caught: unknown
try {
assertMandatoryDimensions(lines, rules)
} catch (err) {
caught = err
}
const error = caught as MandatoryDimensionMissingError
expect(error.violations).toEqual([
{ account_number: '4010', sie_dim_no: '6', dimension_name: 'Projekt' },
{ account_number: '5010', sie_dim_no: '1', dimension_name: 'Kostnadsställe' },
])
})
it('uses the Swedish message format naming account and dimension', () => {
expect(() =>
assertMandatoryDimensions([{ account_number: '4010' }], [requiredProjekt]),
).toThrow('Konto 4010 kräver Projekt — välj ett värde innan bokföring.')
})
it('is satisfied via the deprecated cost_center alias through normalize', () => {
const requiredKostnadsstalle = makeRule({
rule_type: 'required',
sie_dim_no: '1',
dimension_name: 'Kostnadsställe',
value_code: null,
})
const lines: TestLine[] = [{ account_number: '4010', cost_center: 'KS01' }]
expect(() => assertMandatoryDimensions(lines, [requiredKostnadsstalle])).not.toThrow()
})
it('is satisfied by a bag value on the required key', () => {
const lines: TestLine[] = [{ account_number: '4010', dimensions: { '6': 'P001' } }]
expect(() => assertMandatoryDimensions(lines, [requiredProjekt])).not.toThrow()
})
it('never throws when no required rule exists (default/fixed only)', () => {
const lines: TestLine[] = [{ account_number: '4010' }]
const rules = [
makeRule({ rule_type: 'default' }),
makeRule({ rule_type: 'fixed', sie_dim_no: '1', value_code: 'KS01' }),
]
expect(() => assertMandatoryDimensions(lines, rules)).not.toThrow()
expect(() => assertMandatoryDimensions(lines, [])).not.toThrow()
})
it('required rules on other accounts do not fire', () => {
const lines: TestLine[] = [{ account_number: '4010' }]
const rules = [makeRule({ account_number: '5010', rule_type: 'required', value_code: null })]
expect(() => assertMandatoryDimensions(lines, rules)).not.toThrow()
})
})
@@ -1,14 +1,20 @@
/**
* Engine wiring of validateEntryDimensions (dimensions plan PR3).
* Engine wiring of validateEntryDimensions (dimensions plan PR3) and of the
* account dimension rules (dimensions PR10).
*
* 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.
*
* PR10: createDraftEntry applies default/fixed rules onto the line bags
* before validation + insert; commitEntry asserts 'required' rules against
* the entry's stored lines BEFORE the commit_journal_entry RPC, and skips
* the line fetch entirely when no required rule exists.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createDraftEntry, updateDraftEntry } from '../engine'
import { DimensionValidationError } from '../errors'
import { commitEntry, createDraftEntry, updateDraftEntry } from '../engine'
import { DimensionValidationError, MandatoryDimensionMissingError } from '../errors'
import type { CreateJournalEntryInput } from '@/types'
vi.mock('@/lib/events', () => ({
@@ -252,3 +258,136 @@ describe('updateDraftEntry — dimension validation wiring', () => {
expect(lineRows[0].dimensions).toEqual({ '6': 'P001' })
})
})
/**
* Raw account_dimension_rules row exactly as fetchActiveDimensionRules
* selects it: joined registry rows ride along nested (dimensions,
* dimension_values).
*/
function makeRuleRow(overrides: Record<string, unknown> = {}) {
return {
account_number: '4010',
rule_type: 'default',
dimensions: { sie_dim_no: 6, name: 'Projekt' },
dimension_values: { code: 'P001' },
...overrides,
}
}
describe('createDraftEntry — account dimension rules (PR10)', () => {
it('applies a default rule onto the inserted line bag when the key is absent', async () => {
const { supabase, inserts } = buildSupabase({
...BASE_TABLES,
account_dimension_rules: { data: [makeRuleRow()] },
})
const entry = await createDraftEntry(supabase as never, 'company-1', 'user-1', makeInput())
expect(entry.id).toBe('entry-1')
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
// The 4010 line got the default; the 1930 line has no rule and stays bare.
expect(lineRows[0].dimensions).toEqual({ '6': 'P001' })
expect(lineRows[1].dimensions).toEqual({})
// PR9 cutover: generated mirror columns must never appear in the payload.
expect('cost_center' in lineRows[0]).toBe(false)
expect('project' in lineRows[0]).toBe(false)
})
it('fixed rule overwrites the caller-supplied bag value', async () => {
const { supabase, inserts } = buildSupabase({
...BASE_TABLES,
account_dimension_rules: {
data: [makeRuleRow({ rule_type: 'fixed', dimension_values: { code: 'PLOCK' } })],
},
})
const entry = await createDraftEntry(
supabase as never,
'company-1',
'user-1',
makeInput({ '6': 'CALLER' })
)
expect(entry.id).toBe('entry-1')
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
// Rule pinned the 4010 line; the rule-less 1930 line keeps the caller tag.
expect(lineRows[0].dimensions).toEqual({ '6': 'PLOCK' })
expect(lineRows[1].dimensions).toEqual({ '6': 'CALLER' })
})
})
describe('commitEntry — mandatory dimension enforcement (PR10)', () => {
const requiredRule = makeRuleRow({ rule_type: 'required', dimension_values: null })
it('rejects an untagged line BEFORE the commit_journal_entry RPC fires', async () => {
const { supabase } = buildSupabase({
...BASE_TABLES,
account_dimension_rules: { data: [requiredRule] },
journal_entry_lines: {
data: [
{ account_number: '4010', dimensions: {} },
{ account_number: '1930', dimensions: {} },
],
},
})
await expect(
commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
).rejects.toBeInstanceOf(MandatoryDimensionMissingError)
await expect(
commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
).rejects.toThrow('Konto 4010 kräver Projekt — välj ett värde innan bokföring.')
// The verifikat must never have been posted.
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('commits when every required dimension is satisfied on the stored lines', async () => {
const { supabase } = buildSupabase({
...BASE_TABLES,
account_dimension_rules: { data: [requiredRule] },
journal_entry_lines: {
data: [
{ account_number: '4010', dimensions: { '6': 'P001' } },
{ account_number: '1930', dimensions: {} },
],
},
})
const entry = await commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(entry.id).toBe('entry-1')
expect(supabase.rpc).toHaveBeenCalledWith(
'commit_journal_entry',
expect.objectContaining({ p_company_id: 'company-1', p_entry_id: 'entry-1' })
)
})
it('skips the line fetch entirely when the company has zero rules', async () => {
const { supabase, queriedTables } = buildSupabase(BASE_TABLES)
const entry = await commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(entry.id).toBe('entry-1')
// Rules were checked, but no required rule exists → no line fetch.
expect(queriedTables()).toContain('account_dimension_rules')
expect(queriedTables()).not.toContain('journal_entry_lines')
expect(supabase.rpc).toHaveBeenCalledWith(
'commit_journal_entry',
expect.objectContaining({ p_entry_id: 'entry-1' })
)
})
it('skips the line fetch when the only rules are default/fixed', async () => {
const { supabase, queriedTables } = buildSupabase({
...BASE_TABLES,
account_dimension_rules: {
data: [makeRuleRow(), makeRuleRow({ rule_type: 'fixed', account_number: '5010' })],
},
})
await commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(queriedTables()).not.toContain('journal_entry_lines')
})
})
@@ -83,8 +83,11 @@ describe('voucher number atomicity', () => {
p_actor_label: null,
})
// from() was never called — the RPC handles everything atomically
expect(supabase.from).not.toHaveBeenCalled()
// No line/entry fetch happened — the RPC handles everything atomically.
// (PR10: commitEntry now also probes account_dimension_rules first; the
// bare mock makes that probe fail open, which is exactly the posture.)
expect(supabase.from).not.toHaveBeenCalledWith('journal_entries')
expect(supabase.from).not.toHaveBeenCalledWith('journal_entry_lines')
})
it('commitEntry succeeds via atomic RPC and returns posted entry', async () => {
+41
View File
@@ -97,3 +97,44 @@ export class DimensionValidationError extends Error {
export function isDimensionValidationError(err: unknown): err is DimensionValidationError {
return err instanceof DimensionValidationError
}
// ============================================================================
// Mandatory dimension enforcement (dimensions PR10)
// ============================================================================
export const MANDATORY_DIMENSION_MISSING = 'MANDATORY_DIMENSION_MISSING' as const
export interface MandatoryDimensionViolation {
account_number: string
/** SIE dimension number the rule requires, e.g. '6'. */
sie_dim_no: string
/** Registry display name for the dimension, e.g. 'Projekt'. */
dimension_name: string
}
/** Swedish user-facing sentence for a single missing-dimension violation. */
export function formatMandatoryDimensionViolation(v: MandatoryDimensionViolation): string {
return `Konto ${v.account_number} kräver ${v.dimension_name} — välj ett värde innan bokföring.`
}
/**
* Raised at COMMIT time (commitEntry / the bulk-book pre-check) when an
* active 'required' rule in account_dimension_rules is unsatisfied by a
* line's dimensions bag. Drafts may be incomplete by design — the rule bites
* when the verifikat is about to become immutable. Companies without rules
* (every company by default) never reach this error.
*/
export class MandatoryDimensionMissingError extends Error {
readonly code = MANDATORY_DIMENSION_MISSING
constructor(public readonly violations: MandatoryDimensionViolation[]) {
super(violations.map(formatMandatoryDimensionViolation).join(' '))
this.name = 'MandatoryDimensionMissingError'
}
}
export function isMandatoryDimensionMissingError(
err: unknown,
): err is MandatoryDimensionMissingError {
return err instanceof MandatoryDimensionMissingError
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Account dimension rules (dimensions PR10) — the policy layer over the
* dimensions substrate. Rules live in account_dimension_rules
* (20260703200000), one per (account, dimension):
*
* 'required' the account cannot be POSTED without a value → enforced by
* assertMandatoryDimensions at commitEntry and the bulk-book
* route pre-check. Drafts may be incomplete by design; storno/
* correction paths never pass through commitEntry, so history
* always reverses regardless of policy.
* 'default' pre-applied to the line bag at draft creation when the key
* is absent (user-overridable).
* 'fixed' ALWAYS applied at draft creation (overwrites the caller's
* key) — the account is pinned to one value.
*
* Zero rules (every company by default) short-circuits everything — the
* engine behaves exactly as before PR10. Rule fetches FAIL OPEN like the
* soft registry validation: a transient DB error must not block bookkeeping,
* and the write hits the same database anyway.
*
* Pure of next/server so it stays importable from anywhere the resolver is.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import {
MandatoryDimensionMissingError,
type MandatoryDimensionViolation,
} from './dimension-errors'
import {
normalizeLineDimensions,
type DimensionAliasInput,
type LineDimensions,
} from './dimension-resolver'
export interface AccountDimensionRule {
account_number: string
rule_type: 'required' | 'default' | 'fixed'
/** Canonical SIE dimension number as a string key, e.g. '6'. */
sie_dim_no: string
dimension_name: string
/** Value code for default/fixed rules; null for required. */
value_code: string | null
}
interface RawRuleRow {
account_number: string
rule_type: 'required' | 'default' | 'fixed'
dimensions: { sie_dim_no: number; name: string }
dimension_values: { code: string } | null
}
/**
* All ACTIVE rules for the company. Returns null on query failure (callers
* fail open — same posture as validateEntryDimensions). The table is tiny
* and indexed on (company_id, account_number); one fetch per booking is the
* whole cost for rule-less companies.
*/
export async function fetchActiveDimensionRules(
supabase: SupabaseClient,
companyId: string
): Promise<AccountDimensionRule[] | null> {
// try/catch on top of the error-result check: fail-open must also cover
// thrown exceptions (broken client, network throw) — policy lookups are
// never allowed to take bookkeeping down with them.
try {
const { data, error } = await supabase
.from('account_dimension_rules')
.select(
'account_number, rule_type, dimensions!account_dimension_rules_dimension_id_company_id_fkey(sie_dim_no, name), dimension_values!account_dimension_rules_value_id_fkey(code)'
)
.eq('company_id', companyId)
.eq('is_active', true)
if (error) return null
return ((data ?? []) as unknown as RawRuleRow[]).map((row) => ({
account_number: row.account_number,
rule_type: row.rule_type,
sie_dim_no: String(row.dimensions.sie_dim_no),
dimension_name: row.dimensions.name,
value_code: row.dimension_values?.code ?? null,
}))
} catch {
return null
}
}
/**
* Apply default/fixed rules onto entry lines before validation + insert.
* Returns the same array when nothing applies (zero allocation on the
* common path); otherwise a copy where affected lines carry the augmented
* bag (aliases folded in first, so the returned lines are bag-authoritative).
*/
export function applyDimensionRules<
T extends DimensionAliasInput & { account_number: string },
>(lines: T[], rules: AccountDimensionRule[]): T[] {
const applicable = rules.filter(
(r) => (r.rule_type === 'default' || r.rule_type === 'fixed') && r.value_code
)
if (applicable.length === 0) return lines
const byAccount = new Map<string, AccountDimensionRule[]>()
for (const rule of applicable) {
const bucket = byAccount.get(rule.account_number) ?? []
bucket.push(rule)
byAccount.set(rule.account_number, bucket)
}
let anyChanged = false
const result = lines.map((line) => {
const forAccount = byAccount.get(line.account_number)
if (!forAccount) return line
const bag: LineDimensions = normalizeLineDimensions(line)
let changed = false
for (const rule of forAccount) {
if (rule.rule_type === 'fixed') {
if (bag[rule.sie_dim_no] !== rule.value_code) {
bag[rule.sie_dim_no] = rule.value_code as string
changed = true
}
} else if (!(rule.sie_dim_no in bag)) {
bag[rule.sie_dim_no] = rule.value_code as string
changed = true
}
}
if (!changed) return line
anyChanged = true
// The bag now carries everything (aliases folded by normalize) — clear
// the deprecated aliases so downstream normalization can't resurrect a
// value a fixed rule just overwrote.
return { ...line, dimensions: bag, cost_center: null, project: null }
})
return anyChanged ? result : lines
}
/**
* Throw MandatoryDimensionMissingError when any ACTIVE 'required' rule is
* unsatisfied. One violation per (account, dimension) regardless of how many
* lines miss it — the Swedish message stays readable for multi-line entries.
*/
export function assertMandatoryDimensions(
lines: Array<DimensionAliasInput & { account_number: string }>,
rules: AccountDimensionRule[]
): void {
const required = rules.filter((r) => r.rule_type === 'required')
if (required.length === 0) return
const byAccount = new Map<string, AccountDimensionRule[]>()
for (const rule of required) {
const bucket = byAccount.get(rule.account_number) ?? []
bucket.push(rule)
byAccount.set(rule.account_number, bucket)
}
const violations = new Map<string, MandatoryDimensionViolation>()
for (const line of lines) {
const forAccount = byAccount.get(line.account_number)
if (!forAccount) continue
const bag = normalizeLineDimensions(line)
for (const rule of forAccount) {
if (!bag[rule.sie_dim_no]) {
violations.set(`${line.account_number} ${rule.sie_dim_no}`, {
account_number: line.account_number,
sie_dim_no: rule.sie_dim_no,
dimension_name: rule.dimension_name,
})
}
}
}
if (violations.size > 0) {
throw new MandatoryDimensionMissingError([...violations.values()])
}
}
+58 -4
View File
@@ -18,6 +18,11 @@ import {
normalizeLineDimensions,
validateEntryDimensions,
} from '@/lib/bookkeeping/dimension-resolver'
import {
applyDimensionRules,
assertMandatoryDimensions,
fetchActiveDimensionRules,
} from '@/lib/bookkeeping/dimension-rules'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import { getActor } from '@/lib/bookkeeping/actor-context'
@@ -227,12 +232,22 @@ export async function createDraftEntry(
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
}
// Account dimension rules (dimensions PR10): apply 'default'/'fixed'
// values onto the line bags before validation + insert. Zero rules —
// every company by default — returns the input untouched; a failed rule
// fetch fails open like the soft validation below.
const rules = await fetchActiveDimensionRules(supabase, companyId)
if (rules === null) {
log.warn('dimension rule fetch failed — defaults/fixed skipped (fail-open)', { companyId })
}
const lines = rules ? applyDimensionRules(input.lines, rules) : input.lines
// 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)
await validateEntryDimensions(supabase, companyId, lines)
// Validate that entry_date falls within the selected fiscal period
const { data: period, error: periodError } = await supabase
@@ -319,7 +334,7 @@ export async function createDraftEntry(
}
// Insert journal entry lines with dimensions
const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
const lineInserts = buildLineInserts(entry.id, lines, accountIdMap)
const { error: linesError } = await supabase
.from('journal_entry_lines')
@@ -409,7 +424,13 @@ export async function updateDraftEntry(
// 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)
// Account dimension rules (PR10) apply first — same as create.
const rules = await fetchActiveDimensionRules(supabase, companyId)
if (rules === null) {
log.warn('dimension rule fetch failed — defaults/fixed skipped (fail-open)', { companyId })
}
const lines = rules ? applyDimensionRules(input.lines, rules) : input.lines
await validateEntryDimensions(supabase, companyId, lines)
// Entry date must fall within the selected fiscal period.
const { data: period, error: periodError } = await supabase
@@ -479,7 +500,7 @@ export async function updateDraftEntry(
throw new BookkeepingDatabaseError('create_entry_lines', deleteError.message)
}
const lineInserts = buildLineInserts(entryId, input.lines, accountIdMap)
const lineInserts = buildLineInserts(entryId, lines, accountIdMap)
const { error: linesError } = await supabase
.from('journal_entry_lines')
.insert(lineInserts)
@@ -528,6 +549,39 @@ export async function commitEntry(
): Promise<JournalEntry> {
const actor = getActor()
// Mandatory dimension rules (dimensions PR10): 'required' rules bite when
// the verifikat is about to become immutable — drafts may be incomplete,
// posting may not. Zero active rules (the default) skips the line fetch
// entirely; a failed rule fetch fails open (transient DB errors must not
// block bookkeeping). Reversal/correction paths never pass through
// commitEntry, so history always reverses regardless of policy.
const rules = await fetchActiveDimensionRules(supabase, companyId)
if (rules === null) {
// Deliberate fail-open, but LOUD: a transient policy-table error must not
// block month-end bookings company-wide, yet a silently skipped control
// is invisible — the warning makes the degradation observable.
log.warn('dimension rule fetch failed — mandatory enforcement skipped (fail-open)', {
companyId,
entityId: entryId,
})
} else if (rules.some((r) => r.rule_type === 'required')) {
const { data: ruleLines, error: ruleLinesError } = await supabase
.from('journal_entry_lines')
.select('account_number, dimensions')
.eq('journal_entry_id', entryId)
if (ruleLinesError || !ruleLines) {
log.warn('line fetch for mandatory dimension check failed — enforcement skipped (fail-open)', {
companyId,
entityId: entryId,
})
} else {
assertMandatoryDimensions(
ruleLines as Array<{ account_number: string; dimensions: Record<string, string> }>,
rules
)
}
}
// Atomic: increment voucher sequence + update status in one transaction.
// Rolls back the sequence if the balance trigger or any constraint fails.
const { data: rpcResult, error: commitError } = await supabase.rpc('commit_journal_entry', {
+24 -2
View File
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { DimensionValidationError } from './dimension-errors'
import { DimensionValidationError, MandatoryDimensionMissingError } from './dimension-errors'
// ============================================================================
// Dimension validation error — class lives in ./dimension-errors.ts (pure
@@ -14,10 +14,15 @@ export {
formatDimensionValidationIssue,
formatDimensionValidationIssues,
isDimensionValidationError,
MANDATORY_DIMENSION_MISSING,
MandatoryDimensionMissingError,
formatMandatoryDimensionViolation,
isMandatoryDimensionMissingError,
} from './dimension-errors'
export type {
DimensionValidationIssue,
DimensionValidationReason,
MandatoryDimensionViolation,
} from './dimension-errors'
// ============================================================================
@@ -331,7 +336,8 @@ export function isBookkeepingError(err: unknown): boolean {
err instanceof NoOpenPeriodForDateError ||
err instanceof TargetPeriodClosedError ||
err instanceof TargetPeriodLockedError ||
err instanceof DimensionValidationError
err instanceof DimensionValidationError ||
err instanceof MandatoryDimensionMissingError
)
}
@@ -574,6 +580,22 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
)
}
if (err instanceof MandatoryDimensionMissingError) {
// Policy rejection at commit (dimensions PR10): the message names every
// account + required dimension so a user or agent self-corrects in one
// pass; details.violations is the machine-readable list.
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { violations: err.violations },
},
},
{ status: 400 }
)
}
if (err instanceof BookkeepingDatabaseError) {
return NextResponse.json(
{
+15 -1
View File
@@ -3477,7 +3477,21 @@
"settings_imported_toast": "{count, plural, one {1 existing code was imported} other {# existing codes were imported}}",
"settings_import_failed_title": "Could not import existing codes",
"settings_save_failed_title": "Could not save the setting",
"settings_open_register": "Open cost centres & projects"
"settings_open_register": "Open cost centres & projects",
"new_dimension": "New dimension",
"new_dimension_title": "New dimension",
"dim_form_name_invalid": "Enter a name (160 characters).",
"dim_form_number_label": "Number",
"dim_form_number_help": "Leave empty for the next free number (20+). SIE reserves 119.",
"dim_form_number_invalid": "Enter a whole number of 20 or higher.",
"dim_form_resets_label": "Resets every fiscal year",
"dim_form_resets_help": "Balances per value start over at a new fiscal year.",
"dim_form_advanced": "Advanced",
"dim_form_parent_label": "Parent dimension",
"dim_form_parent_none": "None",
"dim_form_parent_help": "Makes the new dimension a subdimension (SIE #UNDERDIM) of the selected one.",
"dim_created_title": "Dimension created",
"subdimension_of": "Subdimension of {parent}"
},
"tic_workspace": {
"toast_settings_failed": "Could not fetch settings",
+15 -1
View File
@@ -3477,7 +3477,21 @@
"settings_imported_toast": "{count, plural, one {1 befintlig kod importerades} other {# befintliga koder importerades}}",
"settings_import_failed_title": "Kunde inte importera befintliga koder",
"settings_save_failed_title": "Kunde inte spara inställningen",
"settings_open_register": "Öppna kostnadsställen & projekt"
"settings_open_register": "Öppna kostnadsställen & projekt",
"new_dimension": "Ny dimension",
"new_dimension_title": "Ny dimension",
"dim_form_name_invalid": "Ange ett namn (160 tecken).",
"dim_form_number_label": "Nummer",
"dim_form_number_help": "Lämna tomt för nästa lediga (20+). SIE reserverar 119.",
"dim_form_number_invalid": "Ange ett heltal, 20 eller högre.",
"dim_form_resets_label": "Nollställs varje räkenskapsår",
"dim_form_resets_help": "Saldon per värde börjar om vid nytt räkenskapsår.",
"dim_form_advanced": "Avancerat",
"dim_form_parent_label": "Överordnad dimension",
"dim_form_parent_none": "Ingen",
"dim_form_parent_help": "Gör den nya dimensionen till en underdimension (SIE #UNDERDIM) av den valda.",
"dim_created_title": "Dimension skapad",
"subdimension_of": "Underdimension till {parent}"
},
"tic_workspace": {
"toast_settings_failed": "Kunde inte hämta inställningar",
@@ -0,0 +1,83 @@
-- Dimensions PR10 (advanced): per-account dimension policy.
--
-- account_dimension_rules — one rule per (account, dimension):
-- 'required' the account cannot be POSTED without a value for the
-- dimension (enforced TS-side at commitEntry + the bulk-book
-- route pre-check; drafts may be incomplete)
-- 'default' the value is pre-applied to the line's bag at draft
-- creation when the key is absent (user-overridable)
-- 'fixed' the value is ALWAYS applied at draft creation (overwrites
-- whatever the caller sent for that key)
--
-- Opt-in by construction: zero rows (every company's default) = the engine
-- behaves exactly as before. There is deliberately NO settings toggle for
-- enforcement — a rule that exists but is silently ignored is worse than
-- either extreme; pausing one rule is what is_active is for.
--
-- Shape follows the dimensions registry (20260702084500): company_id-native,
-- no user_id (rules are company policy, not personal data), RLS via
-- user_company_ids(). value_id's dimension/company consistency is validated
-- at the API layer (and re-checked by engine-side registry validation at
-- booking); a composite FK is deliberately skipped — dimension_values has no
-- (id, dimension_id) unique pair and adding one for this is not worth the
-- churn.
--
-- pg-test: tests/pg/account-dimension-rules.pg.test.ts (RLS + CHECKs +
-- cascade behavior).
CREATE TABLE public.account_dimension_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- Exact BAS account; ranges can layer on later without schema change.
account_number text NOT NULL CHECK (account_number ~ '^[0-9]{4}$'),
dimension_id uuid NOT NULL,
rule_type text NOT NULL CHECK (rule_type IN ('required', 'default', 'fixed')),
-- required → no value; default/fixed → the value to apply.
value_id uuid REFERENCES public.dimension_values(id) ON DELETE CASCADE,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Composite FK: the dimension must belong to the same company (the
-- registry's UNIQUE (id, company_id) exists exactly for this pattern).
FOREIGN KEY (dimension_id, company_id)
REFERENCES public.dimensions(id, company_id) ON DELETE CASCADE,
-- One rule per (account, dimension) — 'required'+'default' combos et al.
-- are a later refinement; three clean types for v1.
UNIQUE (company_id, account_number, dimension_id),
CONSTRAINT adr_value_presence CHECK (
(rule_type = 'required' AND value_id IS NULL)
OR (rule_type IN ('default', 'fixed') AND value_id IS NOT NULL)
)
);
ALTER TABLE public.account_dimension_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company account_dimension_rules"
ON public.account_dimension_rules FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company account_dimension_rules"
ON public.account_dimension_rules FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "update own-company account_dimension_rules"
ON public.account_dimension_rules FOR UPDATE
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "delete own-company account_dimension_rules"
ON public.account_dimension_rules FOR DELETE
USING (company_id IN (SELECT user_company_ids()));
CREATE INDEX idx_adr_company_account
ON public.account_dimension_rules (company_id, account_number);
CREATE TRIGGER set_updated_at_account_dimension_rules
BEFORE UPDATE ON public.account_dimension_rules
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_account_dimension_rules
AFTER INSERT OR UPDATE OR DELETE ON public.account_dimension_rules
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
COMMENT ON TABLE public.account_dimension_rules IS
'Per-account dimension policy (dimensions PR10): required blocks posting without a value (TS-side, commitEntry), default pre-fills, fixed always applies. Zero rows = no behavior change.';
NOTIFY pgrst, 'reload schema';
+178
View File
@@ -0,0 +1,178 @@
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool, withUserContext } from './setup'
import { seedCompany } from './fixtures'
// PR10 account_dimension_rules (20260703200000_account_dimension_rules.sql):
// RLS via user_company_ids() on all four operations, the adr_value_presence
// CHECK (required ⇔ no value), UNIQUE (company_id, account_number,
// dimension_id), value_id ON DELETE CASCADE, and the composite
// (dimension_id, company_id) FK that pins a rule's dimension to the same
// company.
async function seedWithDimensions() {
const seeded = await seedCompany()
await getPool().query(`SELECT public.ensure_company_dimensions($1)`, [seeded.companyId])
return seeded
}
async function getDimensionId(companyId: string, sieDimNo: number): Promise<string> {
const { rows } = await getPool().query(
`SELECT id FROM public.dimensions WHERE company_id = $1 AND sie_dim_no = $2`,
[companyId, sieDimNo],
)
return rows[0].id
}
async function insertValue(params: {
companyId: string
dimensionId: string
code: string
name?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.dimension_values (id, company_id, dimension_id, code, name)
VALUES ($1, $2, $3, $4, $5)`,
[id, params.companyId, params.dimensionId, params.code, params.name ?? params.code],
)
return id
}
async function insertRule(params: {
companyId: string
dimensionId: string
ruleType: 'required' | 'default' | 'fixed'
accountNumber?: string
valueId?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.account_dimension_rules
(id, company_id, account_number, dimension_id, rule_type, value_id)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
id,
params.companyId,
params.accountNumber ?? '4010',
params.dimensionId,
params.ruleType,
params.valueId ?? null,
],
)
return id
}
describe('account_dimension_rules RLS', () => {
it('lets a member insert and read an own-company rule', async () => {
const { userId, companyId } = await seedWithDimensions()
const dimId = await getDimensionId(companyId, 6)
await withUserContext(userId, async (client) => {
await client.query(
`INSERT INTO public.account_dimension_rules
(company_id, account_number, dimension_id, rule_type)
VALUES ($1, '4010', $2, 'required')`,
[companyId, dimId],
)
const { rows } = await client.query(
`SELECT account_number, rule_type, value_id, is_active
FROM public.account_dimension_rules WHERE company_id = $1`,
[companyId],
)
expect(rows).toEqual([
{ account_number: '4010', rule_type: 'required', value_id: null, is_active: true },
])
})
})
it('hides other companies rules and blocks cross-company inserts', async () => {
const a = await seedWithDimensions()
const b = await seedWithDimensions()
const aDimId = await getDimensionId(a.companyId, 6)
await insertRule({ companyId: a.companyId, dimensionId: aDimId, ruleType: 'required' })
await withUserContext(b.userId, async (client) => {
// The outsider sees none of A's rules (and has none of their own).
const { rows } = await client.query(
`SELECT id FROM public.account_dimension_rules`,
)
expect(rows).toEqual([])
await expect(
client.query(
`INSERT INTO public.account_dimension_rules
(company_id, account_number, dimension_id, rule_type)
VALUES ($1, '5010', $2, 'required')`,
[a.companyId, aDimId],
),
).rejects.toThrow(/row-level security/)
})
})
})
describe('adr_value_presence CHECK', () => {
it('rejects a required rule that carries a value', async () => {
const { companyId } = await seedWithDimensions()
const dimId = await getDimensionId(companyId, 6)
const valueId = await insertValue({ companyId, dimensionId: dimId, code: 'P001' })
await expect(
insertRule({ companyId, dimensionId: dimId, ruleType: 'required', valueId }),
).rejects.toThrow(/adr_value_presence/)
})
it('rejects a default rule without a value', async () => {
const { companyId } = await seedWithDimensions()
const dimId = await getDimensionId(companyId, 6)
await expect(
insertRule({ companyId, dimensionId: dimId, ruleType: 'default', valueId: null }),
).rejects.toThrow(/adr_value_presence/)
})
})
describe('UNIQUE (company_id, account_number, dimension_id)', () => {
it('rejects a second rule for the same account and dimension', async () => {
const { companyId } = await seedWithDimensions()
const dimId = await getDimensionId(companyId, 6)
const valueId = await insertValue({ companyId, dimensionId: dimId, code: 'P001' })
await insertRule({ companyId, dimensionId: dimId, ruleType: 'default', valueId })
// Different rule_type, same (company, account, dimension) — still one slot.
await expect(
insertRule({ companyId, dimensionId: dimId, ruleType: 'required' }),
).rejects.toThrow(/duplicate|unique/)
})
})
describe('cascade behavior', () => {
it('deleting the dimension_value removes rules pinned to it (ON DELETE CASCADE)', async () => {
const { companyId } = await seedWithDimensions()
const dimId = await getDimensionId(companyId, 6)
const valueId = await insertValue({ companyId, dimensionId: dimId, code: 'P001' })
const ruleId = await insertRule({ companyId, dimensionId: dimId, ruleType: 'fixed', valueId })
// The value is unreferenced by posted lines, so the retention trigger
// allows the delete — and the rule must ride the cascade.
await getPool().query(`DELETE FROM public.dimension_values WHERE id = $1`, [valueId])
const { rows } = await getPool().query(
`SELECT id FROM public.account_dimension_rules WHERE id = $1`,
[ruleId],
)
expect(rows).toEqual([])
})
})
describe('composite (dimension_id, company_id) FK', () => {
it('rejects a rule whose dimension belongs to another company', async () => {
const a = await seedWithDimensions()
const b = await seedWithDimensions()
const aDimId = await getDimensionId(a.companyId, 6)
await expect(
insertRule({ companyId: b.companyId, dimensionId: aDimId, ruleType: 'required' }),
).rejects.toThrow(/foreign key/)
})
})