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:
@@ -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 { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateDimensionSchema } from '@/lib/api/schemas'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -33,6 +35,7 @@ interface DimensionRow {
|
||||
id: string
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
parent_sie_dim_no: number | null
|
||||
resets_annually: boolean
|
||||
is_system: boolean
|
||||
is_active: boolean
|
||||
@@ -58,7 +61,7 @@ export const GET = withRouteContext(
|
||||
|
||||
const { data: dims, error: dimsError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order')
|
||||
.select('id, sie_dim_no, name, parent_sie_dim_no, resets_annually, is_system, is_active, sort_order')
|
||||
.eq('company_id', companyId)
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('sie_dim_no', { ascending: true })
|
||||
@@ -101,3 +104,141 @@ export const GET = withRouteContext(
|
||||
return NextResponse.json({ dimensions })
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/dimensions — create a custom dimension (dimensions PR10).
|
||||
*
|
||||
* SIE reserves numbers 1-19 for standardized meanings (1 kostnadsställe,
|
||||
* 6 projekt, 7 anställd, …) and leaves 20+ free — when sie_dim_no is
|
||||
* omitted the server picks the next free number >= 20. Explicit numbers are
|
||||
* allowed across the whole 1-9999 range (SIE import already creates
|
||||
* reserved-number dims like 7 Anställd; manual creation of one you know is
|
||||
* the same operation), uniqueness enforced per company.
|
||||
*
|
||||
* parent_sie_dim_no (optional) declares an #UNDERDIM hierarchy — it must
|
||||
* reference an existing dimension in the company registry; SIE export emits
|
||||
* the declaration parent-before-child (lib/reports/sie-export.ts).
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'dimension.create',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const validation = await validateBody(request, CreateDimensionSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const { error: ensureError } = await supabase.rpc('ensure_company_dimensions', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
if (ensureError) {
|
||||
log.error('ensure_company_dimensions failed', ensureError)
|
||||
return errorResponse(ensureError, log, { requestId })
|
||||
}
|
||||
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('sie_dim_no')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (existingError) {
|
||||
log.error('dimension number lookup failed', existingError)
|
||||
return errorResponse(existingError, log, { requestId })
|
||||
}
|
||||
const taken = new Set(
|
||||
((existing ?? []) as { sie_dim_no: number }[]).map((d) => d.sie_dim_no),
|
||||
)
|
||||
|
||||
let sieDimNo = body.sie_dim_no
|
||||
if (sieDimNo === undefined) {
|
||||
// Next free custom number — SIE leaves 20+ unreserved.
|
||||
sieDimNo = 20
|
||||
while (taken.has(sieDimNo)) sieDimNo++
|
||||
} else if (taken.has(sieDimNo)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'DIMENSION_NUMBER_TAKEN',
|
||||
message: `Dimension ${sieDimNo} finns redan i registret.`,
|
||||
},
|
||||
},
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
if (body.parent_sie_dim_no != null) {
|
||||
if (body.parent_sie_dim_no === sieDimNo) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'DIMENSION_PARENT_INVALID',
|
||||
message: 'En dimension kan inte vara sin egen överordnade dimension.',
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (!taken.has(body.parent_sie_dim_no)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'DIMENSION_PARENT_INVALID',
|
||||
message: `Överordnad dimension ${body.parent_sie_dim_no} finns inte i registret.`,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const autoPicked = body.sie_dim_no === undefined
|
||||
const insertDimension = (dimNo: number) =>
|
||||
supabase
|
||||
.from('dimensions')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
sie_dim_no: dimNo,
|
||||
name: body.name,
|
||||
parent_sie_dim_no: body.parent_sie_dim_no ?? null,
|
||||
resets_annually: body.resets_annually ?? true,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
// System dims 1/6 sit at sort_order 10/20 (substrate seeding); custom
|
||||
// dims trail them by default.
|
||||
sort_order: 100,
|
||||
})
|
||||
.select('id, sie_dim_no, name, parent_sie_dim_no, resets_annually, is_system, is_active, sort_order')
|
||||
.single()
|
||||
|
||||
let { data: dimension, error: insertError } = await insertDimension(sieDimNo)
|
||||
|
||||
// Auto-picked numbers can race a concurrent create/SIE import between the
|
||||
// read and the insert — the UNIQUE is the arbiter; retry once past the
|
||||
// loser instead of surfacing a spurious "finns redan" for a number the
|
||||
// user never chose. Explicitly chosen numbers still 409.
|
||||
if (insertError?.code === '23505' && autoPicked) {
|
||||
sieDimNo++
|
||||
while (taken.has(sieDimNo)) sieDimNo++
|
||||
;({ data: dimension, error: insertError } = await insertDimension(sieDimNo))
|
||||
}
|
||||
|
||||
if (insertError) {
|
||||
if (insertError.code === '23505') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'DIMENSION_NUMBER_TAKEN',
|
||||
message: `Dimension ${sieDimNo} finns redan i registret.`,
|
||||
},
|
||||
},
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
log.error('dimension create failed', insertError)
|
||||
return errorResponse(insertError, log, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { dimension } }, { status: 201 })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -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 dimension rules pre-check (PR10) — none configured.
|
||||
enqueue({ data: [], error: null })
|
||||
// RPC returns happy path.
|
||||
enqueue({
|
||||
data: {
|
||||
|
||||
@@ -5,6 +5,12 @@ import { BulkBookSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { mergeDimensionBags } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import {
|
||||
applyDimensionRules,
|
||||
assertMandatoryDimensions,
|
||||
fetchActiveDimensionRules,
|
||||
} from '@/lib/bookkeeping/dimension-rules'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { BookingTemplateLibraryLine, Transaction } from '@/types'
|
||||
@@ -242,6 +248,28 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// Account dimension rules (dimensions PR10): the bulk-book RPC bypasses
|
||||
// the TS engine, so the policy layer runs here — defaults/fixed applied
|
||||
// to the computed lines, then 'required' asserted. Zero rules (the
|
||||
// default) or a failed fetch changes nothing (fail-open, same posture as
|
||||
// the engine).
|
||||
if (newEntryPayload) {
|
||||
const rules = await fetchActiveDimensionRules(supabase, companyId!)
|
||||
if (rules === null) {
|
||||
opLog.warn('dimension rule fetch failed — policy skipped (fail-open)')
|
||||
}
|
||||
if (rules && rules.length > 0) {
|
||||
newEntryPayload.lines = applyDimensionRules(newEntryPayload.lines, rules)
|
||||
try {
|
||||
assertMandatoryDimensions(newEntryPayload.lines, rules)
|
||||
} catch (err) {
|
||||
const mapped = bookkeepingErrorResponse(err)
|
||||
if (mapped) return mapped
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// p_user_id removed in PR #608 (round-3 hardening pattern applied
|
||||
// consistently). RPC resolves the caller via auth.uid().
|
||||
const { data, error } = await supabase.rpc('bulk_book_transactions', {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,12 +8,29 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { Loader2, Plus, X } from 'lucide-react'
|
||||
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
|
||||
import {
|
||||
fetchDimensions,
|
||||
type AccountDimensionRuleDto,
|
||||
type DimensionDto,
|
||||
type DimensionRuleType,
|
||||
} from '@/components/dimensions/types'
|
||||
import type { BASAccount } from '@/types'
|
||||
|
||||
interface EditAccountDialogProps {
|
||||
@@ -23,7 +40,23 @@ interface EditAccountDialogProps {
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
// Hardcoded Swedish per the file's convention (chart-of-accounts editing is a
|
||||
// bookkeeping surface). Labels mirror the rule semantics enforced by the
|
||||
// engine at commit time.
|
||||
const RULE_TYPE_LABELS: Record<DimensionRuleType, string> = {
|
||||
required: 'Krävs',
|
||||
default: 'Förval',
|
||||
fixed: 'Låst',
|
||||
}
|
||||
|
||||
const RULE_TYPE_HELP: Record<DimensionRuleType, string> = {
|
||||
required: 'Krävs — verifikat på kontot kan inte bokföras utan värde',
|
||||
default: 'Förval — värdet föreslås men kan ändras',
|
||||
fixed: 'Låst — värdet sätts alltid automatiskt',
|
||||
}
|
||||
|
||||
export function EditAccountDialog({ open, onOpenChange, account, onSaved }: EditAccountDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const [accountName, setAccountName] = useState(account.account_name)
|
||||
const [description, setDescription] = useState(account.description || '')
|
||||
const [defaultVatCode, setDefaultVatCode] = useState(account.default_vat_code || '')
|
||||
@@ -31,6 +64,183 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
const [isActive, setIsActive] = useState(account.is_active)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Dimension rules ("Dimensionsregler") — visible only when the company has
|
||||
// dimensions enabled (same /api/settings gate as JournalEntryForm). Rule
|
||||
// mutations apply immediately via their own fetches + toasts; they are
|
||||
// deliberately independent of the account PUT below.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const [dims, setDims] = useState<DimensionDto[]>([])
|
||||
const [rules, setRules] = useState<AccountDimensionRuleDto[]>([])
|
||||
const [rulesLoading, setRulesLoading] = useState(false)
|
||||
const [addRuleOpen, setAddRuleOpen] = useState(false)
|
||||
const [newRuleDimensionId, setNewRuleDimensionId] = useState('')
|
||||
const [newRuleType, setNewRuleType] = useState<DimensionRuleType>('required')
|
||||
const [newRuleValueCode, setNewRuleValueCode] = useState<string | null>(null)
|
||||
const [isAddingRule, setIsAddingRule] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => {
|
||||
if (!cancelled && data?.dimensions_enabled === true) setDimensionsEnabled(true)
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep the section hidden */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dimensionsEnabled) return
|
||||
let cancelled = false
|
||||
setRulesLoading(true)
|
||||
Promise.all([
|
||||
fetchDimensions().catch(() => [] as DimensionDto[]),
|
||||
fetch(`/api/dimensions/rules?account_number=${account.account_number}`)
|
||||
.then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) }))
|
||||
.catch(() => ({ ok: false, json: null })),
|
||||
]).then(([fetchedDims, rulesRes]) => {
|
||||
if (cancelled) return
|
||||
setDims(fetchedDims)
|
||||
if (rulesRes.ok) {
|
||||
setRules((rulesRes.json?.data?.rules ?? []) as AccountDimensionRuleDto[])
|
||||
}
|
||||
setRulesLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [dimensionsEnabled, account.account_number])
|
||||
|
||||
const activeDims = dims.filter((d) => d.is_active)
|
||||
const newRuleDim = activeDims.find((d) => d.id === newRuleDimensionId) ?? null
|
||||
const newRuleNeedsValue = newRuleType === 'default' || newRuleType === 'fixed'
|
||||
|
||||
function resetAddRuleForm() {
|
||||
setAddRuleOpen(false)
|
||||
setNewRuleDimensionId('')
|
||||
setNewRuleType('required')
|
||||
setNewRuleValueCode(null)
|
||||
}
|
||||
|
||||
async function handleAddRule() {
|
||||
if (!newRuleDim) return
|
||||
setIsAddingRule(true)
|
||||
try {
|
||||
// Resolve the picked code to a value id. The combobox can create values
|
||||
// inline, so a code missing from the mount-time registry snapshot means
|
||||
// we refetch once before giving up.
|
||||
let valueId: string | null = null
|
||||
if (newRuleNeedsValue) {
|
||||
const code = newRuleValueCode
|
||||
if (!code) return
|
||||
const findValueId = (list: DimensionDto[]) =>
|
||||
list
|
||||
.find((d) => d.id === newRuleDim.id)
|
||||
?.values.find((v) => v.code === code)?.id ?? null
|
||||
valueId = findValueId(dims)
|
||||
if (!valueId) {
|
||||
const refreshed = await fetchDimensions().catch(() => null)
|
||||
if (refreshed) {
|
||||
setDims(refreshed)
|
||||
valueId = findValueId(refreshed)
|
||||
}
|
||||
}
|
||||
if (!valueId) {
|
||||
toast({
|
||||
title: 'Kunde inte lägga till regeln',
|
||||
description: `Värdet ${code} hittades inte i registret.`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
account_number: account.account_number,
|
||||
dimension_id: newRuleDim.id,
|
||||
rule_type: newRuleType,
|
||||
}
|
||||
if (valueId) body.value_id = valueId
|
||||
const res = await fetch('/api/dimensions/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: 'Kunde inte lägga till regeln',
|
||||
description: getErrorMessage(json, { locale: 'sv' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const created = json?.data?.rule as AccountDimensionRuleDto | undefined
|
||||
if (created) setRules((prev) => [...prev, created])
|
||||
toast({ title: 'Regel tillagd' })
|
||||
resetAddRuleForm()
|
||||
} finally {
|
||||
setIsAddingRule(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRule(rule: AccountDimensionRuleDto, checked: boolean) {
|
||||
const ruleId = rule.account_dimension_rule_id
|
||||
// Optimistic — the switch flips immediately and reverts on failure.
|
||||
setRules((prev) =>
|
||||
prev.map((r) =>
|
||||
r.account_dimension_rule_id === ruleId ? { ...r, is_active: checked } : r,
|
||||
),
|
||||
)
|
||||
const res = await fetch(`/api/dimensions/rules/${ruleId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: checked }),
|
||||
}).catch(() => null)
|
||||
const json = await res?.json().catch(() => null)
|
||||
if (!res?.ok) {
|
||||
setRules((prev) =>
|
||||
prev.map((r) =>
|
||||
r.account_dimension_rule_id === ruleId ? { ...r, is_active: rule.is_active } : r,
|
||||
),
|
||||
)
|
||||
toast({
|
||||
title: 'Kunde inte uppdatera regeln',
|
||||
description: getErrorMessage(json, { locale: 'sv' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const updated = json?.data?.rule as AccountDimensionRuleDto | undefined
|
||||
if (updated) {
|
||||
setRules((prev) =>
|
||||
prev.map((r) => (r.account_dimension_rule_id === ruleId ? updated : r)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRule(rule: AccountDimensionRuleDto) {
|
||||
const ruleId = rule.account_dimension_rule_id
|
||||
const res = await fetch(`/api/dimensions/rules/${ruleId}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => null)
|
||||
if (!res?.ok) {
|
||||
const json = await res?.json().catch(() => null)
|
||||
toast({
|
||||
title: 'Kunde inte ta bort regeln',
|
||||
description: getErrorMessage(json, { locale: 'sv' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setRules((prev) => prev.filter((r) => r.account_dimension_rule_id !== ruleId))
|
||||
toast({ title: 'Regel borttagen' })
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
@@ -62,7 +272,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Redigera konto {account.account_number}
|
||||
@@ -107,6 +317,173 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dimensionsEnabled && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Dimensionsregler</p>
|
||||
|
||||
{rulesLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<>
|
||||
{rules.length === 0 && !addRuleOpen && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Inga dimensionsregler för det här kontot.
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAddRuleOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Lägg till regel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rules.map((rule) => (
|
||||
<div
|
||||
key={rule.account_dimension_rule_id}
|
||||
className="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{rule.dimension_name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{RULE_TYPE_LABELS[rule.rule_type]}
|
||||
{rule.value_code && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="font-mono">{rule.value_code}</span>
|
||||
{rule.value_name && rule.value_name !== rule.value_code
|
||||
? ` ${rule.value_name}`
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={rule.is_active}
|
||||
onCheckedChange={(checked) => handleToggleRule(rule, checked)}
|
||||
aria-label={`Regel för ${rule.dimension_name} aktiv`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Ta bort regel för ${rule.dimension_name}`}
|
||||
onClick={() => handleDeleteRule(rule)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{addRuleOpen ? (
|
||||
<div className="space-y-3 rounded-lg border border-dashed p-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Dimension
|
||||
</Label>
|
||||
<Select
|
||||
value={newRuleDimensionId || undefined}
|
||||
onValueChange={(id) => {
|
||||
setNewRuleDimensionId(id)
|
||||
setNewRuleValueCode(null)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj dimension" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeDims.map((dim) => (
|
||||
<SelectItem key={dim.id} value={dim.id}>
|
||||
{dim.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Typ</Label>
|
||||
<Select
|
||||
value={newRuleType}
|
||||
onValueChange={(v) => {
|
||||
setNewRuleType(v as DimensionRuleType)
|
||||
if (v === 'required') setNewRuleValueCode(null)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(RULE_TYPE_LABELS) as DimensionRuleType[]).map(
|
||||
(type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{RULE_TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{RULE_TYPE_HELP[newRuleType]}
|
||||
</p>
|
||||
{newRuleNeedsValue && newRuleDim && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Värde</Label>
|
||||
<DimensionCombobox
|
||||
sieDimNo={String(newRuleDim.sie_dim_no)}
|
||||
value={newRuleValueCode}
|
||||
onChange={setNewRuleValueCode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isAddingRule}
|
||||
onClick={resetAddRuleForm}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
isAddingRule ||
|
||||
!newRuleDim ||
|
||||
(newRuleNeedsValue && !newRuleValueCode)
|
||||
}
|
||||
onClick={handleAddRule}
|
||||
>
|
||||
{isAddingRule && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Lägg till
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
rules.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAddRuleOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Lägg till regel
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Aktivt konto</p>
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,6 +16,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -34,7 +43,9 @@ import {
|
||||
Tags,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import DimensionValueForm, {
|
||||
type DimensionValueFormInput,
|
||||
@@ -81,6 +92,8 @@ export default function DimensionsManager() {
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
const [dialog, setDialog] = useState<DialogState>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [newDimDialogOpen, setNewDimDialogOpen] = useState(false)
|
||||
const [isCreatingDimension, setIsCreatingDimension] = useState(false)
|
||||
|
||||
const loadDimensions = useCallback(
|
||||
async (showSpinner: boolean) => {
|
||||
@@ -120,6 +133,15 @@ export default function DimensionsManager() {
|
||||
)
|
||||
const isProjectTab = activeDim?.sie_dim_no === PROJECT_DIM_NO
|
||||
|
||||
// Parent (#UNDERDIM) of the active tab's dimension, when it has one.
|
||||
const parentDimName = useMemo(() => {
|
||||
if (!activeDim || activeDim.parent_sie_dim_no == null) return null
|
||||
const parent = dimensions.find(
|
||||
(d) => d.sie_dim_no === activeDim.parent_sie_dim_no,
|
||||
)
|
||||
return parent?.name ?? `#${activeDim.parent_sie_dim_no}`
|
||||
}, [dimensions, activeDim])
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
if (!activeDim) return []
|
||||
const term = searchTerm.trim().toLowerCase()
|
||||
@@ -224,6 +246,45 @@ export default function DimensionsManager() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateDimension(input: NewDimensionFormInput) {
|
||||
setIsCreatingDimension(true)
|
||||
try {
|
||||
// Contract: sie_dim_no omitted → the server picks the next free ≥20;
|
||||
// parent_sie_dim_no omitted → no #UNDERDIM hierarchy.
|
||||
const body: Record<string, unknown> = {
|
||||
name: input.name,
|
||||
resets_annually: input.resets_annually,
|
||||
}
|
||||
if (input.sie_dim_no !== null) body.sie_dim_no = input.sie_dim_no
|
||||
if (input.parent_sie_dim_no !== null) {
|
||||
body.parent_sie_dim_no = input.parent_sie_dim_no
|
||||
}
|
||||
const res = await fetch('/api/dimensions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw json ?? new Error()
|
||||
const createdId = (json?.data?.dimension as { id?: string } | undefined)?.id
|
||||
toast({ title: t('dim_created_title') })
|
||||
setNewDimDialogOpen(false)
|
||||
await loadDimensions(false)
|
||||
if (createdId) {
|
||||
setActiveDimId(createdId)
|
||||
setSearchTerm('')
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('save_failed_title'),
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsCreatingDimension(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteValue() {
|
||||
if (!activeDim || dialog?.mode !== 'edit') return
|
||||
setIsSaving(true)
|
||||
@@ -318,31 +379,49 @@ export default function DimensionsManager() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe, 6 Projekt) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Tabs
|
||||
value={activeDimId ?? undefined}
|
||||
onValueChange={(id) => {
|
||||
setActiveDimId(id)
|
||||
setSearchTerm('')
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
{dimensions.map((dim) => (
|
||||
<TabsTrigger key={dim.id} value={dim.id}>
|
||||
{dim.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
onClick={() => setDialog({ mode: 'create' })}
|
||||
>
|
||||
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('new_value')}
|
||||
</Button>
|
||||
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe,
|
||||
6 Projekt, plus any custom 20+ dims) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Tabs
|
||||
value={activeDimId ?? undefined}
|
||||
onValueChange={(id) => {
|
||||
setActiveDimId(id)
|
||||
setSearchTerm('')
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
{dimensions.map((dim) => (
|
||||
<TabsTrigger key={dim.id} value={dim.id}>
|
||||
{dim.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
onClick={() => setNewDimDialogOpen(true)}
|
||||
>
|
||||
{t('new_dimension')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
onClick={() => setDialog({ mode: 'create' })}
|
||||
>
|
||||
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('new_value')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{parentDimName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('subdimension_of', { parent: parentDimName })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
@@ -479,6 +558,181 @@ export default function DimensionsManager() {
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* New dimension dialog — the content (and thus the form state)
|
||||
unmounts on close, so each open starts from a blank form. */}
|
||||
<Dialog
|
||||
open={newDimDialogOpen}
|
||||
onOpenChange={(open) => !open && setNewDimDialogOpen(false)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('new_dimension_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewDimensionForm
|
||||
dimensions={dimensions}
|
||||
isSaving={isCreatingDimension}
|
||||
onSubmit={handleCreateDimension}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NewDimensionFormInput {
|
||||
name: string
|
||||
/** null → omit from the POST; the server picks the next free number ≥20. */
|
||||
sie_dim_no: number | null
|
||||
resets_annually: boolean
|
||||
/** null → omit from the POST; no #UNDERDIM hierarchy. */
|
||||
parent_sie_dim_no: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create form for a custom registry dimension (#DIM 20+). The parent
|
||||
* (#UNDERDIM) select is an advanced, rarely-used SIE concept — kept behind a
|
||||
* quiet disclosure so the common path stays name + number.
|
||||
*/
|
||||
function NewDimensionForm({
|
||||
dimensions,
|
||||
isSaving,
|
||||
onSubmit,
|
||||
}: {
|
||||
/** Existing dims — active ones populate the parent select. */
|
||||
dimensions: DimensionDto[]
|
||||
isSaving: boolean
|
||||
onSubmit: (input: NewDimensionFormInput) => void | Promise<void>
|
||||
}) {
|
||||
const t = useTranslations('dimensions')
|
||||
const [name, setName] = useState('')
|
||||
const [numberStr, setNumberStr] = useState('')
|
||||
const [resetsAnnually, setResetsAnnually] = useState(true)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [parent, setParent] = useState('none')
|
||||
const [nameError, setNameError] = useState<string | null>(null)
|
||||
const [numberError, setNumberError] = useState<string | null>(null)
|
||||
|
||||
const parentOptions = dimensions.filter((d) => d.is_active)
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const trimmedName = name.trim()
|
||||
const trimmedNumber = numberStr.trim()
|
||||
let valid = true
|
||||
if (!trimmedName || trimmedName.length > 60) {
|
||||
setNameError(t('dim_form_name_invalid'))
|
||||
valid = false
|
||||
} else {
|
||||
setNameError(null)
|
||||
}
|
||||
let sieDimNo: number | null = null
|
||||
if (trimmedNumber) {
|
||||
const parsed = Number(trimmedNumber)
|
||||
if (!Number.isInteger(parsed) || parsed < 20) {
|
||||
setNumberError(t('dim_form_number_invalid'))
|
||||
valid = false
|
||||
} else {
|
||||
setNumberError(null)
|
||||
sieDimNo = parsed
|
||||
}
|
||||
} else {
|
||||
setNumberError(null)
|
||||
}
|
||||
if (!valid) return
|
||||
void onSubmit({
|
||||
name: trimmedName,
|
||||
sie_dim_no: sieDimNo,
|
||||
resets_annually: resetsAnnually,
|
||||
parent_sie_dim_no: parent === 'none' ? null : Number(parent),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-dimension-name">{t('form_name_label')}</Label>
|
||||
<Input
|
||||
id="new-dimension-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{nameError && <p className="text-xs text-destructive">{nameError}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-dimension-number">{t('dim_form_number_label')}</Label>
|
||||
<Input
|
||||
id="new-dimension-number"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={20}
|
||||
step={1}
|
||||
value={numberStr}
|
||||
onChange={(e) => setNumberStr(e.target.value)}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('dim_form_number_help')}</p>
|
||||
{numberError && <p className="text-xs text-destructive">{numberError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="new-dimension-resets">{t('dim_form_resets_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
{t('dim_form_resets_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="new-dimension-resets"
|
||||
checked={resetsAnnually}
|
||||
onCheckedChange={setResetsAnnually}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-expanded={showAdvanced}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('dim_form_advanced')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('dim_form_parent_label')}</Label>
|
||||
<Select value={parent} onValueChange={setParent}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('dim_form_parent_none')}</SelectItem>
|
||||
{parentOptions.map((dim) => (
|
||||
<SelectItem key={dim.id} value={String(dim.sie_dim_no)}>
|
||||
{dim.name} ({dim.sie_dim_no})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t('dim_form_parent_help')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('form_create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
|
||||
import {
|
||||
fetchDimensionsCached,
|
||||
type DimensionDto,
|
||||
} from '@/components/dimensions/types'
|
||||
|
||||
interface LineDimensionFieldsProps {
|
||||
/** Current dimensions map ({sie_dim_no: object_code}) — a line's map or the header default. */
|
||||
@@ -16,11 +21,23 @@ interface LineDimensionFieldsProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Kostnadsställe + Projekt combobox pair (SIE dims 1/6) used by the
|
||||
* voucher form's header default, the per-row tag popover, and the mobile line
|
||||
* cards. Labels are the seeded system-dimension names and hardcoded Swedish —
|
||||
* the component mounts on the voucher editor, a stays-Swedish surface per
|
||||
* .claude/rules/i18n.md (same convention as DimensionCombobox).
|
||||
* While the registry loads (or if the fetch fails) we render the seeded
|
||||
* system pair (SIE dims 1/6) so the tagging affordance never disappears —
|
||||
* the registry always contains at least these two.
|
||||
*/
|
||||
const FALLBACK_FIELDS: { sieDimNo: string; label: string }[] = [
|
||||
{ sieDimNo: '1', label: 'Kostnadsställe' },
|
||||
{ sieDimNo: '6', label: 'Projekt' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Registry-driven dimension comboboxes used by the voucher form's header
|
||||
* default, the per-row tag popover, and the mobile line cards. One combobox
|
||||
* per active registry dimension, ordered by sort_order then sie_dim_no (the
|
||||
* seeded 1/6 pair sorts first). Labels are the registry dimension names and
|
||||
* hardcoded-Swedish fallbacks — the component mounts on the voucher editor,
|
||||
* a stays-Swedish surface per .claude/rules/i18n.md (same convention as
|
||||
* DimensionCombobox).
|
||||
*/
|
||||
export default function LineDimensionFields({
|
||||
dimensions,
|
||||
@@ -29,32 +46,46 @@ export default function LineDimensionFields({
|
||||
stacked,
|
||||
inputClassName,
|
||||
}: LineDimensionFieldsProps) {
|
||||
const [registry, setRegistry] = useState<DimensionDto[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchDimensionsCached()
|
||||
.then((dims) => {
|
||||
if (!cancelled) setRegistry(dims)
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep the hardcoded 1/6 fallback */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fields = useMemo(() => {
|
||||
const active = registry?.filter((d) => d.is_active) ?? []
|
||||
if (active.length === 0) return FALLBACK_FIELDS
|
||||
return [...active]
|
||||
.sort((a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no)
|
||||
.map((d) => ({ sieDimNo: String(d.sie_dim_no), label: d.name }))
|
||||
}, [registry])
|
||||
|
||||
return (
|
||||
<div className={stacked ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Kostnadsställe</Label>
|
||||
<div className="mt-1">
|
||||
<DimensionCombobox
|
||||
sieDimNo="1"
|
||||
value={dimensions?.['1'] ?? null}
|
||||
onChange={(code) => onChange('1', code)}
|
||||
disabled={disabled}
|
||||
className={inputClassName}
|
||||
/>
|
||||
{fields.map((field) => (
|
||||
<div key={field.sieDimNo}>
|
||||
<Label className="text-xs text-muted-foreground">{field.label}</Label>
|
||||
<div className="mt-1">
|
||||
<DimensionCombobox
|
||||
sieDimNo={field.sieDimNo}
|
||||
value={dimensions?.[field.sieDimNo] ?? null}
|
||||
onChange={(code) => onChange(field.sieDimNo, code)}
|
||||
disabled={disabled}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Projekt</Label>
|
||||
<div className="mt-1">
|
||||
<DimensionCombobox
|
||||
sieDimNo="6"
|
||||
value={dimensions?.['6'] ?? null}
|
||||
onChange={(code) => onChange('6', code)}
|
||||
disabled={disabled}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,17 +19,38 @@ export interface DimensionValueDto {
|
||||
|
||||
export interface DimensionDto {
|
||||
id: string
|
||||
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt). */
|
||||
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt, 20+ = custom). */
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
resets_annually: boolean
|
||||
is_system: boolean
|
||||
is_active: boolean
|
||||
sort_order: number
|
||||
/** SIE #UNDERDIM parent — the sie_dim_no of the parent dimension, or null. */
|
||||
parent_sie_dim_no: number | null
|
||||
/** Sorted by code by the API. */
|
||||
values: DimensionValueDto[]
|
||||
}
|
||||
|
||||
export type DimensionRuleType = 'required' | 'default' | 'fixed'
|
||||
|
||||
/**
|
||||
* Per-account dimension rule as served by GET /api/dimensions/rules —
|
||||
* a flattened join row (rule + dimension + optional pinned value).
|
||||
*/
|
||||
export interface AccountDimensionRuleDto {
|
||||
account_dimension_rule_id: string
|
||||
account_number: string
|
||||
dimension_id: string
|
||||
sie_dim_no: number
|
||||
dimension_name: string
|
||||
rule_type: DimensionRuleType
|
||||
value_id: string | null
|
||||
value_code: string | null
|
||||
value_name: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
/** SIE dimension number whose values carry start/end dates (Projekt). */
|
||||
export const PROJECT_DIM_NO = 6
|
||||
|
||||
@@ -54,3 +75,22 @@ export async function fetchDimensions(): Promise<DimensionDto[]> {
|
||||
}
|
||||
return (json?.dimensions ?? []) as DimensionDto[]
|
||||
}
|
||||
|
||||
let cachedDimensionsPromise: Promise<DimensionDto[]> | null = null
|
||||
|
||||
/**
|
||||
* Module-level cached variant of fetchDimensions for high-mount-count
|
||||
* consumers (one registry fetch per page load instead of one per line
|
||||
* picker). A failed fetch clears the cache so the next mount retries.
|
||||
* Registry mutations are rare enough that staleness within a page visit
|
||||
* is acceptable — the register UI uses the uncached fetch.
|
||||
*/
|
||||
export function fetchDimensionsCached(): Promise<DimensionDto[]> {
|
||||
if (!cachedDimensionsPromise) {
|
||||
cachedDimensionsPromise = fetchDimensions().catch((err) => {
|
||||
cachedDimensionsPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return cachedDimensionsPromise
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ function mockSupabase(opts: {
|
||||
// Dimension registry (dimensions PR3). Default: empty → block omitted.
|
||||
dimensionRows?: Array<{ id: string; sie_dim_no: number; name: string }>
|
||||
dimensionValueRows?: Array<{ dimension_id: string; code: string; name: string }>
|
||||
dimensionRuleRows?: Array<{ account_number: string; rule_type: string; dimension_id: string }>
|
||||
dimensionsEnabled?: boolean
|
||||
errors?: { profile?: string; memory?: string; atoms?: string }
|
||||
}) {
|
||||
@@ -164,6 +165,10 @@ function mockSupabase(opts: {
|
||||
if (table === 'dimension_values') {
|
||||
return chainResolving(opts.dimensionValueRows ?? [])
|
||||
}
|
||||
if (table === 'account_dimension_rules') {
|
||||
// PR10: the briefing surfaces active rules; default none.
|
||||
return chainResolving(opts.dimensionRuleRows ?? [])
|
||||
}
|
||||
throw new Error(`Unexpected table in test mock: ${table}`)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -2253,6 +2253,16 @@ export const tools: McpTool[] = [
|
||||
sie_dim_no: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
active_value_count: { type: 'number' },
|
||||
required_on_accounts: {
|
||||
type: 'array',
|
||||
description: 'BAS accounts with an active required-rule: postings there are refused without a value for this dimension.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
default_on_accounts: {
|
||||
type: 'array',
|
||||
description: 'BAS accounts where a default/fixed rule auto-applies a value at draft creation.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
top_values: {
|
||||
type: 'array',
|
||||
description: 'Up to 10 active values; full list via gnubok_list_dimension_values.',
|
||||
@@ -2267,7 +2277,7 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['sie_dim_no', 'name', 'active_value_count', 'top_values'],
|
||||
required: ['sie_dim_no', 'name', 'active_value_count', 'required_on_accounts', 'default_on_accounts', 'top_values'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2402,6 +2412,8 @@ export const tools: McpTool[] = [
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
active_value_count: number
|
||||
required_on_accounts: string[]
|
||||
default_on_accounts: string[]
|
||||
top_values: Array<{ code: string; name: string }>
|
||||
}>
|
||||
}
|
||||
@@ -2421,6 +2433,25 @@ export const tools: McpTool[] = [
|
||||
bucket.push({ code: v.code, name: v.name })
|
||||
byDimension.set(v.dimension_id, bucket)
|
||||
}
|
||||
// Account dimension rules (PR10): tell the agent up front which
|
||||
// accounts refuse postings without a value (required) and which
|
||||
// auto-apply one (default/fixed) — so gnubok_create_voucher calls
|
||||
// self-correct instead of bouncing off MANDATORY_DIMENSION_MISSING.
|
||||
const requiredByDimension = new Map<string, string[]>()
|
||||
const defaultByDimension = new Map<string, string[]>()
|
||||
const { data: ruleRows, error: ruleErr } = await supabase
|
||||
.from('account_dimension_rules')
|
||||
.select('account_number, rule_type, dimension_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
if (!ruleErr) {
|
||||
for (const r of (ruleRows ?? []) as Array<{ account_number: string; rule_type: string; dimension_id: string }>) {
|
||||
const target = r.rule_type === 'required' ? requiredByDimension : defaultByDimension
|
||||
const bucket = target.get(r.dimension_id) ?? []
|
||||
bucket.push(r.account_number)
|
||||
target.set(r.dimension_id, bucket)
|
||||
}
|
||||
}
|
||||
dimensionsBlock = {
|
||||
enabled: settingsRow?.dimensions_enabled === true,
|
||||
dimensions: dimensionRows.map((d) => {
|
||||
@@ -2429,6 +2460,8 @@ export const tools: McpTool[] = [
|
||||
sie_dim_no: d.sie_dim_no,
|
||||
name: d.name,
|
||||
active_value_count: values.length,
|
||||
required_on_accounts: (requiredByDimension.get(d.id) ?? []).sort(),
|
||||
default_on_accounts: (defaultByDimension.get(d.id) ?? []).sort(),
|
||||
top_values: values.slice(0, 10),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -841,6 +841,62 @@ export const CreateDimensionValueSchema = z
|
||||
* open period, lock date, active registry values); this schema only shapes
|
||||
* the request. An empty bag {} untags the line.
|
||||
*/
|
||||
/**
|
||||
* POST /api/dimensions — create a custom dimension (dimensions PR10).
|
||||
* sie_dim_no omitted → server picks the next free number >= 20 (SIE leaves
|
||||
* 20+ unreserved). parent_sie_dim_no declares an #UNDERDIM hierarchy.
|
||||
*/
|
||||
export const CreateDimensionSchema = z.object({
|
||||
name: z.string().trim().min(1).max(60),
|
||||
sie_dim_no: z.coerce.number().int().min(1).max(9999).optional(),
|
||||
resets_annually: z.boolean().optional(),
|
||||
parent_sie_dim_no: z.coerce.number().int().min(1).max(9999).nullable().optional(),
|
||||
})
|
||||
|
||||
const AccountDimensionRuleTypeSchema = z.enum(['required', 'default', 'fixed'])
|
||||
|
||||
/** GET /api/dimensions/rules query — optional exact-account filter. */
|
||||
export const ListDimensionRulesQuerySchema = z.object({
|
||||
account_number: accountNumber.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/dimensions/rules — per-account dimension policy (dimensions
|
||||
* PR10). 'required' carries no value; 'default'/'fixed' must carry the value
|
||||
* to apply. One rule per (account, dimension) — enforced by the DB UNIQUE.
|
||||
*/
|
||||
export const CreateAccountDimensionRuleSchema = z
|
||||
.object({
|
||||
account_number: accountNumber,
|
||||
dimension_id: uuid,
|
||||
rule_type: AccountDimensionRuleTypeSchema,
|
||||
value_id: uuid.optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((rule, ctx) => {
|
||||
if (rule.rule_type === 'required' && rule.value_id) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['value_id'],
|
||||
message: 'En obligatorisk regel har inget värde — värden hör till Förval/Låst.',
|
||||
})
|
||||
}
|
||||
if (rule.rule_type !== 'required' && !rule.value_id) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['value_id'],
|
||||
message: 'Välj vilket värde regeln ska använda.',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** PATCH /api/dimensions/rules/[id] — the value-presence rule re-checks in the route (partial update). */
|
||||
export const UpdateAccountDimensionRuleSchema = z.object({
|
||||
rule_type: AccountDimensionRuleTypeSchema.optional(),
|
||||
value_id: uuid.nullable().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const RetagLineDimensionsSchema = z.object({
|
||||
// {} passes (no entries to validate) = UNTAG. Intentional divergence from
|
||||
// the MCP staged path (RetagLineDimensionsParamsSchema), which rejects an
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Pure-function tests for the account dimension rule layer (dimensions PR10).
|
||||
*
|
||||
* applyDimensionRules: 'default' fills only absent bag keys, 'fixed' always
|
||||
* overwrites (including alias-sourced values, with the deprecated aliases
|
||||
* cleared on changed lines), and the zero-effect paths preserve array/line
|
||||
* identity so the common rule-less booking allocates nothing.
|
||||
*
|
||||
* assertMandatoryDimensions: throws MandatoryDimensionMissingError with one
|
||||
* violation per (account, dimension) regardless of line count, treats
|
||||
* alias-sourced values as satisfying (normalize folds them into the bag),
|
||||
* and no-ops when no 'required' rule exists.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
applyDimensionRules,
|
||||
assertMandatoryDimensions,
|
||||
type AccountDimensionRule,
|
||||
} from '../dimension-rules'
|
||||
import {
|
||||
MANDATORY_DIMENSION_MISSING,
|
||||
MandatoryDimensionMissingError,
|
||||
} from '../dimension-errors'
|
||||
|
||||
interface TestLine {
|
||||
account_number: string
|
||||
dimensions?: Record<string, string> | null
|
||||
cost_center?: string | null
|
||||
project?: string | null
|
||||
}
|
||||
|
||||
function makeRule(overrides: Partial<AccountDimensionRule> = {}): AccountDimensionRule {
|
||||
return {
|
||||
account_number: '4010',
|
||||
rule_type: 'default',
|
||||
sie_dim_no: '6',
|
||||
dimension_name: 'Projekt',
|
||||
value_code: 'P001',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyDimensionRules', () => {
|
||||
it('default fills only absent keys — caller-set keys win', () => {
|
||||
const lines: TestLine[] = [
|
||||
{ account_number: '4010', dimensions: { '6': 'CALLER' } },
|
||||
]
|
||||
const rules = [
|
||||
makeRule({ rule_type: 'default', sie_dim_no: '6', value_code: 'PDEF' }),
|
||||
makeRule({
|
||||
rule_type: 'default',
|
||||
sie_dim_no: '1',
|
||||
dimension_name: 'Kostnadsställe',
|
||||
value_code: 'KS01',
|
||||
}),
|
||||
]
|
||||
|
||||
const out = applyDimensionRules(lines, rules)
|
||||
|
||||
// Absent key '1' filled; present key '6' untouched.
|
||||
expect(out[0].dimensions).toEqual({ '6': 'CALLER', '1': 'KS01' })
|
||||
// The input line object was not mutated.
|
||||
expect(lines[0].dimensions).toEqual({ '6': 'CALLER' })
|
||||
})
|
||||
|
||||
it('default does not override an alias-sourced value (line identity preserved)', () => {
|
||||
const lines: TestLine[] = [{ account_number: '4010', cost_center: 'KS-ALIAS' }]
|
||||
const rules = [
|
||||
makeRule({
|
||||
rule_type: 'default',
|
||||
sie_dim_no: '1',
|
||||
dimension_name: 'Kostnadsställe',
|
||||
value_code: 'KS99',
|
||||
}),
|
||||
]
|
||||
|
||||
// normalize folds cost_center into key '1', so the default has nothing to
|
||||
// fill — nothing applies and the SAME array comes back.
|
||||
expect(applyDimensionRules(lines, rules)).toBe(lines)
|
||||
expect(lines[0].cost_center).toBe('KS-ALIAS')
|
||||
})
|
||||
|
||||
it('fixed overwrites an alias-sourced value and clears the aliases', () => {
|
||||
const lines: TestLine[] = [{ account_number: '4010', cost_center: 'OLD' }]
|
||||
const rules = [
|
||||
makeRule({
|
||||
rule_type: 'fixed',
|
||||
sie_dim_no: '1',
|
||||
dimension_name: 'Kostnadsställe',
|
||||
value_code: 'KS99',
|
||||
}),
|
||||
]
|
||||
|
||||
const out = applyDimensionRules(lines, rules)
|
||||
|
||||
expect(out[0].dimensions).toEqual({ '1': 'KS99' })
|
||||
// Aliases nulled so downstream normalization cannot resurrect 'OLD'.
|
||||
expect(out[0].cost_center).toBeNull()
|
||||
expect(out[0].project).toBeNull()
|
||||
})
|
||||
|
||||
it('fixed overwrites a caller-supplied bag value', () => {
|
||||
const lines: TestLine[] = [{ account_number: '4010', dimensions: { '6': 'CALLER' } }]
|
||||
const rules = [makeRule({ rule_type: 'fixed', sie_dim_no: '6', value_code: 'PLOCK' })]
|
||||
|
||||
const out = applyDimensionRules(lines, rules)
|
||||
|
||||
expect(out[0].dimensions).toEqual({ '6': 'PLOCK' })
|
||||
})
|
||||
|
||||
it('a fixed rule already satisfied is a no-op — same array identity', () => {
|
||||
const lines: TestLine[] = [{ account_number: '4010', dimensions: { '6': 'P001' } }]
|
||||
const rules = [makeRule({ rule_type: 'fixed', sie_dim_no: '6', value_code: 'P001' })]
|
||||
|
||||
expect(applyDimensionRules(lines, rules)).toBe(lines)
|
||||
})
|
||||
|
||||
it('untouched lines keep identity while changed lines are copied', () => {
|
||||
const lines: TestLine[] = [
|
||||
{ account_number: '4010' },
|
||||
{ account_number: '1930', dimensions: { '1': 'KS01' } },
|
||||
]
|
||||
const rules = [makeRule({ rule_type: 'fixed', sie_dim_no: '6', value_code: 'P001' })]
|
||||
|
||||
const out = applyDimensionRules(lines, rules)
|
||||
|
||||
expect(out).not.toBe(lines)
|
||||
expect(out[0]).not.toBe(lines[0])
|
||||
expect(out[0].dimensions).toEqual({ '6': 'P001' })
|
||||
// The 1930 line has no rule — the exact same object rides through.
|
||||
expect(out[1]).toBe(lines[1])
|
||||
})
|
||||
|
||||
it('returns the same array for zero rules and for required-only rules', () => {
|
||||
const lines: TestLine[] = [{ account_number: '4010' }]
|
||||
|
||||
expect(applyDimensionRules(lines, [])).toBe(lines)
|
||||
// 'required' rules carry no value — they never apply at draft time.
|
||||
expect(
|
||||
applyDimensionRules(lines, [
|
||||
makeRule({ rule_type: 'required', value_code: null }),
|
||||
]),
|
||||
).toBe(lines)
|
||||
})
|
||||
|
||||
it('rules for other accounts do not leak onto unrelated lines', () => {
|
||||
const lines: TestLine[] = [{ account_number: '4010' }]
|
||||
const rules = [
|
||||
makeRule({ account_number: '5010', rule_type: 'fixed', value_code: 'P001' }),
|
||||
makeRule({ account_number: '5010', rule_type: 'default', value_code: 'P002' }),
|
||||
]
|
||||
|
||||
expect(applyDimensionRules(lines, rules)).toBe(lines)
|
||||
expect(lines[0].dimensions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertMandatoryDimensions', () => {
|
||||
const requiredProjekt = makeRule({ rule_type: 'required', value_code: null })
|
||||
|
||||
it('throws with one deduped violation across multiple missing lines', () => {
|
||||
const lines: TestLine[] = [
|
||||
{ account_number: '4010', dimensions: {} },
|
||||
{ account_number: '4010' },
|
||||
{ account_number: '1930' },
|
||||
]
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
assertMandatoryDimensions(lines, [requiredProjekt])
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(MandatoryDimensionMissingError)
|
||||
const error = caught as MandatoryDimensionMissingError
|
||||
expect(error.code).toBe(MANDATORY_DIMENSION_MISSING)
|
||||
// Two 4010 lines miss the same rule → ONE violation, not two.
|
||||
expect(error.violations).toEqual([
|
||||
{ account_number: '4010', sie_dim_no: '6', dimension_name: 'Projekt' },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports one violation per (account, dimension) pair', () => {
|
||||
const lines: TestLine[] = [
|
||||
{ account_number: '4010' },
|
||||
{ account_number: '5010' },
|
||||
]
|
||||
const rules = [
|
||||
requiredProjekt,
|
||||
makeRule({
|
||||
account_number: '5010',
|
||||
rule_type: 'required',
|
||||
sie_dim_no: '1',
|
||||
dimension_name: 'Kostnadsställe',
|
||||
value_code: null,
|
||||
}),
|
||||
]
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
assertMandatoryDimensions(lines, rules)
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
|
||||
const error = caught as MandatoryDimensionMissingError
|
||||
expect(error.violations).toEqual([
|
||||
{ account_number: '4010', sie_dim_no: '6', dimension_name: 'Projekt' },
|
||||
{ account_number: '5010', sie_dim_no: '1', dimension_name: 'Kostnadsställe' },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the Swedish message format naming account and dimension', () => {
|
||||
expect(() =>
|
||||
assertMandatoryDimensions([{ account_number: '4010' }], [requiredProjekt]),
|
||||
).toThrow('Konto 4010 kräver Projekt — välj ett värde innan bokföring.')
|
||||
})
|
||||
|
||||
it('is satisfied via the deprecated cost_center alias through normalize', () => {
|
||||
const requiredKostnadsstalle = makeRule({
|
||||
rule_type: 'required',
|
||||
sie_dim_no: '1',
|
||||
dimension_name: 'Kostnadsställe',
|
||||
value_code: null,
|
||||
})
|
||||
const lines: TestLine[] = [{ account_number: '4010', cost_center: 'KS01' }]
|
||||
|
||||
expect(() => assertMandatoryDimensions(lines, [requiredKostnadsstalle])).not.toThrow()
|
||||
})
|
||||
|
||||
it('is satisfied by a bag value on the required key', () => {
|
||||
const lines: TestLine[] = [{ account_number: '4010', dimensions: { '6': 'P001' } }]
|
||||
|
||||
expect(() => assertMandatoryDimensions(lines, [requiredProjekt])).not.toThrow()
|
||||
})
|
||||
|
||||
it('never throws when no required rule exists (default/fixed only)', () => {
|
||||
const lines: TestLine[] = [{ account_number: '4010' }]
|
||||
const rules = [
|
||||
makeRule({ rule_type: 'default' }),
|
||||
makeRule({ rule_type: 'fixed', sie_dim_no: '1', value_code: 'KS01' }),
|
||||
]
|
||||
|
||||
expect(() => assertMandatoryDimensions(lines, rules)).not.toThrow()
|
||||
expect(() => assertMandatoryDimensions(lines, [])).not.toThrow()
|
||||
})
|
||||
|
||||
it('required rules on other accounts do not fire', () => {
|
||||
const lines: TestLine[] = [{ account_number: '4010' }]
|
||||
const rules = [makeRule({ account_number: '5010', rule_type: 'required', value_code: null })]
|
||||
|
||||
expect(() => assertMandatoryDimensions(lines, rules)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,20 @@
|
||||
/**
|
||||
* Engine wiring of validateEntryDimensions (dimensions plan PR3).
|
||||
* Engine wiring of validateEntryDimensions (dimensions plan PR3) and of the
|
||||
* account dimension rules (dimensions PR10).
|
||||
*
|
||||
* createDraftEntry and updateDraftEntry must run the soft dimension
|
||||
* validation AFTER balance validation and BEFORE any insert/update, so a
|
||||
* rejection leaves no orphan rows. Untagged entries must not even fetch
|
||||
* company_settings; companies without the toggle keep free-text passthrough.
|
||||
*
|
||||
* PR10: createDraftEntry applies default/fixed rules onto the line bags
|
||||
* before validation + insert; commitEntry asserts 'required' rules against
|
||||
* the entry's stored lines BEFORE the commit_journal_entry RPC, and skips
|
||||
* the line fetch entirely when no required rule exists.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createDraftEntry, updateDraftEntry } from '../engine'
|
||||
import { DimensionValidationError } from '../errors'
|
||||
import { commitEntry, createDraftEntry, updateDraftEntry } from '../engine'
|
||||
import { DimensionValidationError, MandatoryDimensionMissingError } from '../errors'
|
||||
import type { CreateJournalEntryInput } from '@/types'
|
||||
|
||||
vi.mock('@/lib/events', () => ({
|
||||
@@ -252,3 +258,136 @@ describe('updateDraftEntry — dimension validation wiring', () => {
|
||||
expect(lineRows[0].dimensions).toEqual({ '6': 'P001' })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Raw account_dimension_rules row exactly as fetchActiveDimensionRules
|
||||
* selects it: joined registry rows ride along nested (dimensions,
|
||||
* dimension_values).
|
||||
*/
|
||||
function makeRuleRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
account_number: '4010',
|
||||
rule_type: 'default',
|
||||
dimensions: { sie_dim_no: 6, name: 'Projekt' },
|
||||
dimension_values: { code: 'P001' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createDraftEntry — account dimension rules (PR10)', () => {
|
||||
it('applies a default rule onto the inserted line bag when the key is absent', async () => {
|
||||
const { supabase, inserts } = buildSupabase({
|
||||
...BASE_TABLES,
|
||||
account_dimension_rules: { data: [makeRuleRow()] },
|
||||
})
|
||||
|
||||
const entry = await createDraftEntry(supabase as never, 'company-1', 'user-1', makeInput())
|
||||
|
||||
expect(entry.id).toBe('entry-1')
|
||||
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
|
||||
// The 4010 line got the default; the 1930 line has no rule and stays bare.
|
||||
expect(lineRows[0].dimensions).toEqual({ '6': 'P001' })
|
||||
expect(lineRows[1].dimensions).toEqual({})
|
||||
// PR9 cutover: generated mirror columns must never appear in the payload.
|
||||
expect('cost_center' in lineRows[0]).toBe(false)
|
||||
expect('project' in lineRows[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('fixed rule overwrites the caller-supplied bag value', async () => {
|
||||
const { supabase, inserts } = buildSupabase({
|
||||
...BASE_TABLES,
|
||||
account_dimension_rules: {
|
||||
data: [makeRuleRow({ rule_type: 'fixed', dimension_values: { code: 'PLOCK' } })],
|
||||
},
|
||||
})
|
||||
|
||||
const entry = await createDraftEntry(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
makeInput({ '6': 'CALLER' })
|
||||
)
|
||||
|
||||
expect(entry.id).toBe('entry-1')
|
||||
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
|
||||
// Rule pinned the 4010 line; the rule-less 1930 line keeps the caller tag.
|
||||
expect(lineRows[0].dimensions).toEqual({ '6': 'PLOCK' })
|
||||
expect(lineRows[1].dimensions).toEqual({ '6': 'CALLER' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitEntry — mandatory dimension enforcement (PR10)', () => {
|
||||
const requiredRule = makeRuleRow({ rule_type: 'required', dimension_values: null })
|
||||
|
||||
it('rejects an untagged line BEFORE the commit_journal_entry RPC fires', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
...BASE_TABLES,
|
||||
account_dimension_rules: { data: [requiredRule] },
|
||||
journal_entry_lines: {
|
||||
data: [
|
||||
{ account_number: '4010', dimensions: {} },
|
||||
{ account_number: '1930', dimensions: {} },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
).rejects.toBeInstanceOf(MandatoryDimensionMissingError)
|
||||
await expect(
|
||||
commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
).rejects.toThrow('Konto 4010 kräver Projekt — välj ett värde innan bokföring.')
|
||||
|
||||
// The verifikat must never have been posted.
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits when every required dimension is satisfied on the stored lines', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
...BASE_TABLES,
|
||||
account_dimension_rules: { data: [requiredRule] },
|
||||
journal_entry_lines: {
|
||||
data: [
|
||||
{ account_number: '4010', dimensions: { '6': 'P001' } },
|
||||
{ account_number: '1930', dimensions: {} },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const entry = await commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
|
||||
expect(entry.id).toBe('entry-1')
|
||||
expect(supabase.rpc).toHaveBeenCalledWith(
|
||||
'commit_journal_entry',
|
||||
expect.objectContaining({ p_company_id: 'company-1', p_entry_id: 'entry-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips the line fetch entirely when the company has zero rules', async () => {
|
||||
const { supabase, queriedTables } = buildSupabase(BASE_TABLES)
|
||||
|
||||
const entry = await commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
|
||||
expect(entry.id).toBe('entry-1')
|
||||
// Rules were checked, but no required rule exists → no line fetch.
|
||||
expect(queriedTables()).toContain('account_dimension_rules')
|
||||
expect(queriedTables()).not.toContain('journal_entry_lines')
|
||||
expect(supabase.rpc).toHaveBeenCalledWith(
|
||||
'commit_journal_entry',
|
||||
expect.objectContaining({ p_entry_id: 'entry-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips the line fetch when the only rules are default/fixed', async () => {
|
||||
const { supabase, queriedTables } = buildSupabase({
|
||||
...BASE_TABLES,
|
||||
account_dimension_rules: {
|
||||
data: [makeRuleRow(), makeRuleRow({ rule_type: 'fixed', account_number: '5010' })],
|
||||
},
|
||||
})
|
||||
|
||||
await commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
|
||||
expect(queriedTables()).not.toContain('journal_entry_lines')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,8 +83,11 @@ describe('voucher number atomicity', () => {
|
||||
p_actor_label: null,
|
||||
})
|
||||
|
||||
// from() was never called — the RPC handles everything atomically
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
// No line/entry fetch happened — the RPC handles everything atomically.
|
||||
// (PR10: commitEntry now also probes account_dimension_rules first; the
|
||||
// bare mock makes that probe fail open, which is exactly the posture.)
|
||||
expect(supabase.from).not.toHaveBeenCalledWith('journal_entries')
|
||||
expect(supabase.from).not.toHaveBeenCalledWith('journal_entry_lines')
|
||||
})
|
||||
|
||||
it('commitEntry succeeds via atomic RPC and returns posted entry', async () => {
|
||||
|
||||
@@ -97,3 +97,44 @@ export class DimensionValidationError extends Error {
|
||||
export function isDimensionValidationError(err: unknown): err is DimensionValidationError {
|
||||
return err instanceof DimensionValidationError
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mandatory dimension enforcement (dimensions PR10)
|
||||
// ============================================================================
|
||||
|
||||
export const MANDATORY_DIMENSION_MISSING = 'MANDATORY_DIMENSION_MISSING' as const
|
||||
|
||||
export interface MandatoryDimensionViolation {
|
||||
account_number: string
|
||||
/** SIE dimension number the rule requires, e.g. '6'. */
|
||||
sie_dim_no: string
|
||||
/** Registry display name for the dimension, e.g. 'Projekt'. */
|
||||
dimension_name: string
|
||||
}
|
||||
|
||||
/** Swedish user-facing sentence for a single missing-dimension violation. */
|
||||
export function formatMandatoryDimensionViolation(v: MandatoryDimensionViolation): string {
|
||||
return `Konto ${v.account_number} kräver ${v.dimension_name} — välj ett värde innan bokföring.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised at COMMIT time (commitEntry / the bulk-book pre-check) when an
|
||||
* active 'required' rule in account_dimension_rules is unsatisfied by a
|
||||
* line's dimensions bag. Drafts may be incomplete by design — the rule bites
|
||||
* when the verifikat is about to become immutable. Companies without rules
|
||||
* (every company by default) never reach this error.
|
||||
*/
|
||||
export class MandatoryDimensionMissingError extends Error {
|
||||
readonly code = MANDATORY_DIMENSION_MISSING
|
||||
|
||||
constructor(public readonly violations: MandatoryDimensionViolation[]) {
|
||||
super(violations.map(formatMandatoryDimensionViolation).join(' '))
|
||||
this.name = 'MandatoryDimensionMissingError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isMandatoryDimensionMissingError(
|
||||
err: unknown,
|
||||
): err is MandatoryDimensionMissingError {
|
||||
return err instanceof MandatoryDimensionMissingError
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
validateEntryDimensions,
|
||||
} from '@/lib/bookkeeping/dimension-resolver'
|
||||
import {
|
||||
applyDimensionRules,
|
||||
assertMandatoryDimensions,
|
||||
fetchActiveDimensionRules,
|
||||
} from '@/lib/bookkeeping/dimension-rules'
|
||||
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
|
||||
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
|
||||
import { getActor } from '@/lib/bookkeeping/actor-context'
|
||||
@@ -227,12 +232,22 @@ export async function createDraftEntry(
|
||||
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
|
||||
}
|
||||
|
||||
// Account dimension rules (dimensions PR10): apply 'default'/'fixed'
|
||||
// values onto the line bags before validation + insert. Zero rules —
|
||||
// every company by default — returns the input untouched; a failed rule
|
||||
// fetch fails open like the soft validation below.
|
||||
const rules = await fetchActiveDimensionRules(supabase, companyId)
|
||||
if (rules === null) {
|
||||
log.warn('dimension rule fetch failed — defaults/fixed skipped (fail-open)', { companyId })
|
||||
}
|
||||
const lines = rules ? applyDimensionRules(input.lines, rules) : input.lines
|
||||
|
||||
// Soft dimension validation (dimensions plan PR3): free for untagged
|
||||
// entries; free-text passthrough unless company_settings.dimensions_enabled;
|
||||
// enabled companies get registry validation with a typed Swedish rejection.
|
||||
// Runs before any insert so a rejection leaves no orphan rows. Reversal/
|
||||
// storno/correction paths bypass this — they copy posted data verbatim.
|
||||
await validateEntryDimensions(supabase, companyId, input.lines)
|
||||
await validateEntryDimensions(supabase, companyId, lines)
|
||||
|
||||
// Validate that entry_date falls within the selected fiscal period
|
||||
const { data: period, error: periodError } = await supabase
|
||||
@@ -319,7 +334,7 @@ export async function createDraftEntry(
|
||||
}
|
||||
|
||||
// Insert journal entry lines with dimensions
|
||||
const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
|
||||
const lineInserts = buildLineInserts(entry.id, lines, accountIdMap)
|
||||
|
||||
const { error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
@@ -409,7 +424,13 @@ export async function updateDraftEntry(
|
||||
|
||||
// Same soft dimension validation as createDraftEntry — before any write, so
|
||||
// a rejection leaves both the header and the existing lines untouched.
|
||||
await validateEntryDimensions(supabase, companyId, input.lines)
|
||||
// Account dimension rules (PR10) apply first — same as create.
|
||||
const rules = await fetchActiveDimensionRules(supabase, companyId)
|
||||
if (rules === null) {
|
||||
log.warn('dimension rule fetch failed — defaults/fixed skipped (fail-open)', { companyId })
|
||||
}
|
||||
const lines = rules ? applyDimensionRules(input.lines, rules) : input.lines
|
||||
await validateEntryDimensions(supabase, companyId, lines)
|
||||
|
||||
// Entry date must fall within the selected fiscal period.
|
||||
const { data: period, error: periodError } = await supabase
|
||||
@@ -479,7 +500,7 @@ export async function updateDraftEntry(
|
||||
throw new BookkeepingDatabaseError('create_entry_lines', deleteError.message)
|
||||
}
|
||||
|
||||
const lineInserts = buildLineInserts(entryId, input.lines, accountIdMap)
|
||||
const lineInserts = buildLineInserts(entryId, lines, accountIdMap)
|
||||
const { error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(lineInserts)
|
||||
@@ -528,6 +549,39 @@ export async function commitEntry(
|
||||
): Promise<JournalEntry> {
|
||||
const actor = getActor()
|
||||
|
||||
// Mandatory dimension rules (dimensions PR10): 'required' rules bite when
|
||||
// the verifikat is about to become immutable — drafts may be incomplete,
|
||||
// posting may not. Zero active rules (the default) skips the line fetch
|
||||
// entirely; a failed rule fetch fails open (transient DB errors must not
|
||||
// block bookkeeping). Reversal/correction paths never pass through
|
||||
// commitEntry, so history always reverses regardless of policy.
|
||||
const rules = await fetchActiveDimensionRules(supabase, companyId)
|
||||
if (rules === null) {
|
||||
// Deliberate fail-open, but LOUD: a transient policy-table error must not
|
||||
// block month-end bookings company-wide, yet a silently skipped control
|
||||
// is invisible — the warning makes the degradation observable.
|
||||
log.warn('dimension rule fetch failed — mandatory enforcement skipped (fail-open)', {
|
||||
companyId,
|
||||
entityId: entryId,
|
||||
})
|
||||
} else if (rules.some((r) => r.rule_type === 'required')) {
|
||||
const { data: ruleLines, error: ruleLinesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, dimensions')
|
||||
.eq('journal_entry_id', entryId)
|
||||
if (ruleLinesError || !ruleLines) {
|
||||
log.warn('line fetch for mandatory dimension check failed — enforcement skipped (fail-open)', {
|
||||
companyId,
|
||||
entityId: entryId,
|
||||
})
|
||||
} else {
|
||||
assertMandatoryDimensions(
|
||||
ruleLines as Array<{ account_number: string; dimensions: Record<string, string> }>,
|
||||
rules
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic: increment voucher sequence + update status in one transaction.
|
||||
// Rolls back the sequence if the balance trigger or any constraint fails.
|
||||
const { data: rpcResult, error: commitError } = await supabase.rpc('commit_journal_entry', {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { DimensionValidationError } from './dimension-errors'
|
||||
import { DimensionValidationError, MandatoryDimensionMissingError } from './dimension-errors'
|
||||
|
||||
// ============================================================================
|
||||
// Dimension validation error — class lives in ./dimension-errors.ts (pure
|
||||
@@ -14,10 +14,15 @@ export {
|
||||
formatDimensionValidationIssue,
|
||||
formatDimensionValidationIssues,
|
||||
isDimensionValidationError,
|
||||
MANDATORY_DIMENSION_MISSING,
|
||||
MandatoryDimensionMissingError,
|
||||
formatMandatoryDimensionViolation,
|
||||
isMandatoryDimensionMissingError,
|
||||
} from './dimension-errors'
|
||||
export type {
|
||||
DimensionValidationIssue,
|
||||
DimensionValidationReason,
|
||||
MandatoryDimensionViolation,
|
||||
} from './dimension-errors'
|
||||
|
||||
// ============================================================================
|
||||
@@ -331,7 +336,8 @@ export function isBookkeepingError(err: unknown): boolean {
|
||||
err instanceof NoOpenPeriodForDateError ||
|
||||
err instanceof TargetPeriodClosedError ||
|
||||
err instanceof TargetPeriodLockedError ||
|
||||
err instanceof DimensionValidationError
|
||||
err instanceof DimensionValidationError ||
|
||||
err instanceof MandatoryDimensionMissingError
|
||||
)
|
||||
}
|
||||
|
||||
@@ -574,6 +580,22 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof MandatoryDimensionMissingError) {
|
||||
// Policy rejection at commit (dimensions PR10): the message names every
|
||||
// account + required dimension so a user or agent self-corrects in one
|
||||
// pass; details.violations is the machine-readable list.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: { violations: err.violations },
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof BookkeepingDatabaseError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
+15
-1
@@ -3477,7 +3477,21 @@
|
||||
"settings_imported_toast": "{count, plural, one {1 existing code was imported} other {# existing codes were imported}}",
|
||||
"settings_import_failed_title": "Could not import existing codes",
|
||||
"settings_save_failed_title": "Could not save the setting",
|
||||
"settings_open_register": "Open cost centres & projects"
|
||||
"settings_open_register": "Open cost centres & projects",
|
||||
"new_dimension": "New dimension",
|
||||
"new_dimension_title": "New dimension",
|
||||
"dim_form_name_invalid": "Enter a name (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": {
|
||||
"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_import_failed_title": "Kunde inte importera befintliga koder",
|
||||
"settings_save_failed_title": "Kunde inte spara inställningen",
|
||||
"settings_open_register": "Öppna kostnadsställen & projekt"
|
||||
"settings_open_register": "Öppna kostnadsställen & projekt",
|
||||
"new_dimension": "Ny dimension",
|
||||
"new_dimension_title": "Ny dimension",
|
||||
"dim_form_name_invalid": "Ange ett namn (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": {
|
||||
"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