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:
co-authored by
Claude Fable 5
parent
70e893b8d4
commit
764348e99c
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -15,6 +15,8 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
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'
|
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
@@ -33,6 +35,7 @@ interface DimensionRow {
|
|||||||
id: string
|
id: string
|
||||||
sie_dim_no: number
|
sie_dim_no: number
|
||||||
name: string
|
name: string
|
||||||
|
parent_sie_dim_no: number | null
|
||||||
resets_annually: boolean
|
resets_annually: boolean
|
||||||
is_system: boolean
|
is_system: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
@@ -58,7 +61,7 @@ export const GET = withRouteContext(
|
|||||||
|
|
||||||
const { data: dims, error: dimsError } = await supabase
|
const { data: dims, error: dimsError } = await supabase
|
||||||
.from('dimensions')
|
.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)
|
.eq('company_id', companyId)
|
||||||
.order('sort_order', { ascending: true })
|
.order('sort_order', { ascending: true })
|
||||||
.order('sie_dim_no', { ascending: true })
|
.order('sie_dim_no', { ascending: true })
|
||||||
@@ -101,3 +104,141 @@ export const GET = withRouteContext(
|
|||||||
return NextResponse.json({ dimensions })
|
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 },
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_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.
|
// RPC returns happy path.
|
||||||
enqueue({
|
enqueue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { BulkBookSchema } from '@/lib/api/schemas'
|
|||||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||||
import { mergeDimensionBags } from '@/lib/bookkeeping/dimension-resolver'
|
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 { eventBus } from '@/lib/events/bus'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import type { BookingTemplateLibraryLine, Transaction } from '@/types'
|
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
|
// p_user_id removed in PR #608 (round-3 hardening pattern applied
|
||||||
// consistently). RPC resolves the caller via auth.uid().
|
// consistently). RPC resolves the caller via auth.uid().
|
||||||
const { data, error } = await supabase.rpc('bulk_book_transactions', {
|
const { data, error } = await supabase.rpc('bulk_book_transactions', {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -8,12 +8,29 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Switch } from '@/components/ui/switch'
|
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'
|
import type { BASAccount } from '@/types'
|
||||||
|
|
||||||
interface EditAccountDialogProps {
|
interface EditAccountDialogProps {
|
||||||
@@ -23,7 +40,23 @@ interface EditAccountDialogProps {
|
|||||||
onSaved: () => void
|
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) {
|
export function EditAccountDialog({ open, onOpenChange, account, onSaved }: EditAccountDialogProps) {
|
||||||
|
const { toast } = useToast()
|
||||||
const [accountName, setAccountName] = useState(account.account_name)
|
const [accountName, setAccountName] = useState(account.account_name)
|
||||||
const [description, setDescription] = useState(account.description || '')
|
const [description, setDescription] = useState(account.description || '')
|
||||||
const [defaultVatCode, setDefaultVatCode] = useState(account.default_vat_code || '')
|
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 [isActive, setIsActive] = useState(account.is_active)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
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() {
|
async function handleSave() {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -62,7 +272,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Redigera konto {account.account_number}
|
Redigera konto {account.account_number}
|
||||||
@@ -107,6 +317,173 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
|||||||
</div>
|
</div>
|
||||||
</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 className="flex items-center justify-between rounded-lg border p-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Aktivt konto</p>
|
<p className="text-sm font-medium">Aktivt konto</p>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -14,6 +16,13 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -34,7 +43,9 @@ import {
|
|||||||
Tags,
|
Tags,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import DimensionValueForm, {
|
import DimensionValueForm, {
|
||||||
type DimensionValueFormInput,
|
type DimensionValueFormInput,
|
||||||
@@ -81,6 +92,8 @@ export default function DimensionsManager() {
|
|||||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||||
const [dialog, setDialog] = useState<DialogState>(null)
|
const [dialog, setDialog] = useState<DialogState>(null)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [newDimDialogOpen, setNewDimDialogOpen] = useState(false)
|
||||||
|
const [isCreatingDimension, setIsCreatingDimension] = useState(false)
|
||||||
|
|
||||||
const loadDimensions = useCallback(
|
const loadDimensions = useCallback(
|
||||||
async (showSpinner: boolean) => {
|
async (showSpinner: boolean) => {
|
||||||
@@ -120,6 +133,15 @@ export default function DimensionsManager() {
|
|||||||
)
|
)
|
||||||
const isProjectTab = activeDim?.sie_dim_no === PROJECT_DIM_NO
|
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(() => {
|
const filteredValues = useMemo(() => {
|
||||||
if (!activeDim) return []
|
if (!activeDim) return []
|
||||||
const term = searchTerm.trim().toLowerCase()
|
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() {
|
async function handleDeleteValue() {
|
||||||
if (!activeDim || dialog?.mode !== 'edit') return
|
if (!activeDim || dialog?.mode !== 'edit') return
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
@@ -318,31 +379,49 @@ export default function DimensionsManager() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe, 6 Projekt) */}
|
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe,
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
6 Projekt, plus any custom 20+ dims) */}
|
||||||
<Tabs
|
<div className="space-y-2">
|
||||||
value={activeDimId ?? undefined}
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
onValueChange={(id) => {
|
<Tabs
|
||||||
setActiveDimId(id)
|
value={activeDimId ?? undefined}
|
||||||
setSearchTerm('')
|
onValueChange={(id) => {
|
||||||
}}
|
setActiveDimId(id)
|
||||||
>
|
setSearchTerm('')
|
||||||
<TabsList>
|
}}
|
||||||
{dimensions.map((dim) => (
|
>
|
||||||
<TabsTrigger key={dim.id} value={dim.id}>
|
<TabsList>
|
||||||
{dim.name}
|
{dimensions.map((dim) => (
|
||||||
</TabsTrigger>
|
<TabsTrigger key={dim.id} value={dim.id}>
|
||||||
))}
|
{dim.name}
|
||||||
</TabsList>
|
</TabsTrigger>
|
||||||
</Tabs>
|
))}
|
||||||
<Button
|
</TabsList>
|
||||||
disabled={!canWrite}
|
</Tabs>
|
||||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
<div className="flex items-center gap-3">
|
||||||
onClick={() => setDialog({ mode: 'create' })}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
disabled={!canWrite}
|
||||||
{t('new_value')}
|
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||||
</Button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
@@ -479,6 +558,181 @@ export default function DimensionsManager() {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
|
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
|
||||||
|
import {
|
||||||
|
fetchDimensionsCached,
|
||||||
|
type DimensionDto,
|
||||||
|
} from '@/components/dimensions/types'
|
||||||
|
|
||||||
interface LineDimensionFieldsProps {
|
interface LineDimensionFieldsProps {
|
||||||
/** Current dimensions map ({sie_dim_no: object_code}) — a line's map or the header default. */
|
/** 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
|
* While the registry loads (or if the fetch fails) we render the seeded
|
||||||
* voucher form's header default, the per-row tag popover, and the mobile line
|
* system pair (SIE dims 1/6) so the tagging affordance never disappears —
|
||||||
* cards. Labels are the seeded system-dimension names and hardcoded Swedish —
|
* the registry always contains at least these two.
|
||||||
* the component mounts on the voucher editor, a stays-Swedish surface per
|
*/
|
||||||
* .claude/rules/i18n.md (same convention as DimensionCombobox).
|
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({
|
export default function LineDimensionFields({
|
||||||
dimensions,
|
dimensions,
|
||||||
@@ -29,32 +46,46 @@ export default function LineDimensionFields({
|
|||||||
stacked,
|
stacked,
|
||||||
inputClassName,
|
inputClassName,
|
||||||
}: LineDimensionFieldsProps) {
|
}: 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 (
|
return (
|
||||||
<div className={stacked ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
|
<div className={stacked ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
|
||||||
<div>
|
{fields.map((field) => (
|
||||||
<Label className="text-xs text-muted-foreground">Kostnadsställe</Label>
|
<div key={field.sieDimNo}>
|
||||||
<div className="mt-1">
|
<Label className="text-xs text-muted-foreground">{field.label}</Label>
|
||||||
<DimensionCombobox
|
<div className="mt-1">
|
||||||
sieDimNo="1"
|
<DimensionCombobox
|
||||||
value={dimensions?.['1'] ?? null}
|
sieDimNo={field.sieDimNo}
|
||||||
onChange={(code) => onChange('1', code)}
|
value={dimensions?.[field.sieDimNo] ?? null}
|
||||||
disabled={disabled}
|
onChange={(code) => onChange(field.sieDimNo, code)}
|
||||||
className={inputClassName}
|
disabled={disabled}
|
||||||
/>
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,17 +19,38 @@ export interface DimensionValueDto {
|
|||||||
|
|
||||||
export interface DimensionDto {
|
export interface DimensionDto {
|
||||||
id: string
|
id: string
|
||||||
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt). */
|
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt, 20+ = custom). */
|
||||||
sie_dim_no: number
|
sie_dim_no: number
|
||||||
name: string
|
name: string
|
||||||
resets_annually: boolean
|
resets_annually: boolean
|
||||||
is_system: boolean
|
is_system: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
sort_order: number
|
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. */
|
/** Sorted by code by the API. */
|
||||||
values: DimensionValueDto[]
|
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). */
|
/** SIE dimension number whose values carry start/end dates (Projekt). */
|
||||||
export const PROJECT_DIM_NO = 6
|
export const PROJECT_DIM_NO = 6
|
||||||
|
|
||||||
@@ -54,3 +75,22 @@ export async function fetchDimensions(): Promise<DimensionDto[]> {
|
|||||||
}
|
}
|
||||||
return (json?.dimensions ?? []) as 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.
|
// Dimension registry (dimensions PR3). Default: empty → block omitted.
|
||||||
dimensionRows?: Array<{ id: string; sie_dim_no: number; name: string }>
|
dimensionRows?: Array<{ id: string; sie_dim_no: number; name: string }>
|
||||||
dimensionValueRows?: Array<{ dimension_id: string; code: string; name: string }>
|
dimensionValueRows?: Array<{ dimension_id: string; code: string; name: string }>
|
||||||
|
dimensionRuleRows?: Array<{ account_number: string; rule_type: string; dimension_id: string }>
|
||||||
dimensionsEnabled?: boolean
|
dimensionsEnabled?: boolean
|
||||||
errors?: { profile?: string; memory?: string; atoms?: string }
|
errors?: { profile?: string; memory?: string; atoms?: string }
|
||||||
}) {
|
}) {
|
||||||
@@ -164,6 +165,10 @@ function mockSupabase(opts: {
|
|||||||
if (table === 'dimension_values') {
|
if (table === 'dimension_values') {
|
||||||
return chainResolving(opts.dimensionValueRows ?? [])
|
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}`)
|
throw new Error(`Unexpected table in test mock: ${table}`)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2253,6 +2253,16 @@ export const tools: McpTool[] = [
|
|||||||
sie_dim_no: { type: 'number' },
|
sie_dim_no: { type: 'number' },
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
active_value_count: { type: 'number' },
|
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: {
|
top_values: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Up to 10 active values; full list via gnubok_list_dimension_values.',
|
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
|
sie_dim_no: number
|
||||||
name: string
|
name: string
|
||||||
active_value_count: number
|
active_value_count: number
|
||||||
|
required_on_accounts: string[]
|
||||||
|
default_on_accounts: string[]
|
||||||
top_values: Array<{ code: string; name: string }>
|
top_values: Array<{ code: string; name: string }>
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@@ -2421,6 +2433,25 @@ export const tools: McpTool[] = [
|
|||||||
bucket.push({ code: v.code, name: v.name })
|
bucket.push({ code: v.code, name: v.name })
|
||||||
byDimension.set(v.dimension_id, bucket)
|
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 = {
|
dimensionsBlock = {
|
||||||
enabled: settingsRow?.dimensions_enabled === true,
|
enabled: settingsRow?.dimensions_enabled === true,
|
||||||
dimensions: dimensionRows.map((d) => {
|
dimensions: dimensionRows.map((d) => {
|
||||||
@@ -2429,6 +2460,8 @@ export const tools: McpTool[] = [
|
|||||||
sie_dim_no: d.sie_dim_no,
|
sie_dim_no: d.sie_dim_no,
|
||||||
name: d.name,
|
name: d.name,
|
||||||
active_value_count: values.length,
|
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),
|
top_values: values.slice(0, 10),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -841,6 +841,62 @@ export const CreateDimensionValueSchema = z
|
|||||||
* open period, lock date, active registry values); this schema only shapes
|
* open period, lock date, active registry values); this schema only shapes
|
||||||
* the request. An empty bag {} untags the line.
|
* 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({
|
export const RetagLineDimensionsSchema = z.object({
|
||||||
// {} passes (no entries to validate) = UNTAG. Intentional divergence from
|
// {} passes (no entries to validate) = UNTAG. Intentional divergence from
|
||||||
// the MCP staged path (RetagLineDimensionsParamsSchema), which rejects an
|
// 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
|
* createDraftEntry and updateDraftEntry must run the soft dimension
|
||||||
* validation AFTER balance validation and BEFORE any insert/update, so a
|
* validation AFTER balance validation and BEFORE any insert/update, so a
|
||||||
* rejection leaves no orphan rows. Untagged entries must not even fetch
|
* rejection leaves no orphan rows. Untagged entries must not even fetch
|
||||||
* company_settings; companies without the toggle keep free-text passthrough.
|
* 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 { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { createDraftEntry, updateDraftEntry } from '../engine'
|
import { commitEntry, createDraftEntry, updateDraftEntry } from '../engine'
|
||||||
import { DimensionValidationError } from '../errors'
|
import { DimensionValidationError, MandatoryDimensionMissingError } from '../errors'
|
||||||
import type { CreateJournalEntryInput } from '@/types'
|
import type { CreateJournalEntryInput } from '@/types'
|
||||||
|
|
||||||
vi.mock('@/lib/events', () => ({
|
vi.mock('@/lib/events', () => ({
|
||||||
@@ -252,3 +258,136 @@ describe('updateDraftEntry — dimension validation wiring', () => {
|
|||||||
expect(lineRows[0].dimensions).toEqual({ '6': 'P001' })
|
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,
|
p_actor_label: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// from() was never called — the RPC handles everything atomically
|
// No line/entry fetch happened — the RPC handles everything atomically.
|
||||||
expect(supabase.from).not.toHaveBeenCalled()
|
// (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 () => {
|
it('commitEntry succeeds via atomic RPC and returns posted entry', async () => {
|
||||||
|
|||||||
@@ -97,3 +97,44 @@ export class DimensionValidationError extends Error {
|
|||||||
export function isDimensionValidationError(err: unknown): err is DimensionValidationError {
|
export function isDimensionValidationError(err: unknown): err is DimensionValidationError {
|
||||||
return err instanceof 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
normalizeLineDimensions,
|
normalizeLineDimensions,
|
||||||
validateEntryDimensions,
|
validateEntryDimensions,
|
||||||
} from '@/lib/bookkeeping/dimension-resolver'
|
} from '@/lib/bookkeeping/dimension-resolver'
|
||||||
|
import {
|
||||||
|
applyDimensionRules,
|
||||||
|
assertMandatoryDimensions,
|
||||||
|
fetchActiveDimensionRules,
|
||||||
|
} from '@/lib/bookkeeping/dimension-rules'
|
||||||
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
|
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
|
||||||
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
|
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
|
||||||
import { getActor } from '@/lib/bookkeeping/actor-context'
|
import { getActor } from '@/lib/bookkeeping/actor-context'
|
||||||
@@ -227,12 +232,22 @@ export async function createDraftEntry(
|
|||||||
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
|
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
|
// Soft dimension validation (dimensions plan PR3): free for untagged
|
||||||
// entries; free-text passthrough unless company_settings.dimensions_enabled;
|
// entries; free-text passthrough unless company_settings.dimensions_enabled;
|
||||||
// enabled companies get registry validation with a typed Swedish rejection.
|
// enabled companies get registry validation with a typed Swedish rejection.
|
||||||
// Runs before any insert so a rejection leaves no orphan rows. Reversal/
|
// Runs before any insert so a rejection leaves no orphan rows. Reversal/
|
||||||
// storno/correction paths bypass this — they copy posted data verbatim.
|
// 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
|
// Validate that entry_date falls within the selected fiscal period
|
||||||
const { data: period, error: periodError } = await supabase
|
const { data: period, error: periodError } = await supabase
|
||||||
@@ -319,7 +334,7 @@ export async function createDraftEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert journal entry lines with dimensions
|
// 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
|
const { error: linesError } = await supabase
|
||||||
.from('journal_entry_lines')
|
.from('journal_entry_lines')
|
||||||
@@ -409,7 +424,13 @@ export async function updateDraftEntry(
|
|||||||
|
|
||||||
// Same soft dimension validation as createDraftEntry — before any write, so
|
// Same soft dimension validation as createDraftEntry — before any write, so
|
||||||
// a rejection leaves both the header and the existing lines untouched.
|
// 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.
|
// Entry date must fall within the selected fiscal period.
|
||||||
const { data: period, error: periodError } = await supabase
|
const { data: period, error: periodError } = await supabase
|
||||||
@@ -479,7 +500,7 @@ export async function updateDraftEntry(
|
|||||||
throw new BookkeepingDatabaseError('create_entry_lines', deleteError.message)
|
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
|
const { error: linesError } = await supabase
|
||||||
.from('journal_entry_lines')
|
.from('journal_entry_lines')
|
||||||
.insert(lineInserts)
|
.insert(lineInserts)
|
||||||
@@ -528,6 +549,39 @@ export async function commitEntry(
|
|||||||
): Promise<JournalEntry> {
|
): Promise<JournalEntry> {
|
||||||
const actor = getActor()
|
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.
|
// Atomic: increment voucher sequence + update status in one transaction.
|
||||||
// Rolls back the sequence if the balance trigger or any constraint fails.
|
// Rolls back the sequence if the balance trigger or any constraint fails.
|
||||||
const { data: rpcResult, error: commitError } = await supabase.rpc('commit_journal_entry', {
|
const { data: rpcResult, error: commitError } = await supabase.rpc('commit_journal_entry', {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server'
|
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
|
// Dimension validation error — class lives in ./dimension-errors.ts (pure
|
||||||
@@ -14,10 +14,15 @@ export {
|
|||||||
formatDimensionValidationIssue,
|
formatDimensionValidationIssue,
|
||||||
formatDimensionValidationIssues,
|
formatDimensionValidationIssues,
|
||||||
isDimensionValidationError,
|
isDimensionValidationError,
|
||||||
|
MANDATORY_DIMENSION_MISSING,
|
||||||
|
MandatoryDimensionMissingError,
|
||||||
|
formatMandatoryDimensionViolation,
|
||||||
|
isMandatoryDimensionMissingError,
|
||||||
} from './dimension-errors'
|
} from './dimension-errors'
|
||||||
export type {
|
export type {
|
||||||
DimensionValidationIssue,
|
DimensionValidationIssue,
|
||||||
DimensionValidationReason,
|
DimensionValidationReason,
|
||||||
|
MandatoryDimensionViolation,
|
||||||
} from './dimension-errors'
|
} from './dimension-errors'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -331,7 +336,8 @@ export function isBookkeepingError(err: unknown): boolean {
|
|||||||
err instanceof NoOpenPeriodForDateError ||
|
err instanceof NoOpenPeriodForDateError ||
|
||||||
err instanceof TargetPeriodClosedError ||
|
err instanceof TargetPeriodClosedError ||
|
||||||
err instanceof TargetPeriodLockedError ||
|
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) {
|
if (err instanceof BookkeepingDatabaseError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
+15
-1
@@ -3477,7 +3477,21 @@
|
|||||||
"settings_imported_toast": "{count, plural, one {1 existing code was imported} other {# existing codes were imported}}",
|
"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_import_failed_title": "Could not import existing codes",
|
||||||
"settings_save_failed_title": "Could not save the setting",
|
"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 (1–60 characters).",
|
||||||
|
"dim_form_number_label": "Number",
|
||||||
|
"dim_form_number_help": "Leave empty for the next free number (20+). SIE reserves 1–19.",
|
||||||
|
"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": {
|
"tic_workspace": {
|
||||||
"toast_settings_failed": "Could not fetch settings",
|
"toast_settings_failed": "Could not fetch settings",
|
||||||
|
|||||||
+15
-1
@@ -3477,7 +3477,21 @@
|
|||||||
"settings_imported_toast": "{count, plural, one {1 befintlig kod importerades} other {# befintliga koder importerades}}",
|
"settings_imported_toast": "{count, plural, one {1 befintlig kod importerades} other {# befintliga koder importerades}}",
|
||||||
"settings_import_failed_title": "Kunde inte importera befintliga koder",
|
"settings_import_failed_title": "Kunde inte importera befintliga koder",
|
||||||
"settings_save_failed_title": "Kunde inte spara inställningen",
|
"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 (1–60 tecken).",
|
||||||
|
"dim_form_number_label": "Nummer",
|
||||||
|
"dim_form_number_help": "Lämna tomt för nästa lediga (20+). SIE reserverar 1–19.",
|
||||||
|
"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": {
|
"tic_workspace": {
|
||||||
"toast_settings_failed": "Kunde inte hämta inställningar",
|
"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';
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user