diff --git a/app/api/dimensions/__tests__/create-dimension.test.ts b/app/api/dimensions/__tests__/create-dimension.test.ts new file mode 100644 index 00000000..76ffc3a1 --- /dev/null +++ b/app/api/dimensions/__tests__/create-dimension.test.ts @@ -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) => + 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 { + 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(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(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(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(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(response) + + expect(status).toBe(201) + expect(body.data.dimension).toEqual(row) + }) +}) diff --git a/app/api/dimensions/__tests__/rules.test.ts b/app/api/dimensions/__tests__/rules.test.ts new file mode 100644 index 00000000..2897c1ad --- /dev/null +++ b/app/api/dimensions/__tests__/rules.test.ts @@ -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 = {}) { + 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(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) => + 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(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(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(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('DIMENSION_RULE_EXISTS') + }) +}) + +describe('PATCH /api/dimensions/rules/[id]', () => { + const patchRequest = (body: Record) => + 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(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(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(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(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('DIMENSION_RULE_NOT_FOUND') + }) +}) diff --git a/app/api/dimensions/route.ts b/app/api/dimensions/route.ts index 5897b089..16dac498 100644 --- a/app/api/dimensions/route.ts +++ b/app/api/dimensions/route.ts @@ -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 }, +) diff --git a/app/api/dimensions/rules/[id]/route.ts b/app/api/dimensions/rules/[id]/route.ts new file mode 100644 index 00000000..e6d490fd --- /dev/null +++ b/app/api/dimensions/rules/[id]/route.ts @@ -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 = {} + 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 }, +) diff --git a/app/api/dimensions/rules/dto.ts b/app/api/dimensions/rules/dto.ts new file mode 100644 index 00000000..58c420f8 --- /dev/null +++ b/app/api/dimensions/rules/dto.ts @@ -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, + } +} diff --git a/app/api/dimensions/rules/route.ts b/app/api/dimensions/rules/route.ts new file mode 100644 index 00000000..d310cd19 --- /dev/null +++ b/app/api/dimensions/rules/route.ts @@ -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 }, +) diff --git a/app/api/transactions/bulk-book/__tests__/route.test.ts b/app/api/transactions/bulk-book/__tests__/route.test.ts index 1f1a1e3f..afa4815d 100644 --- a/app/api/transactions/bulk-book/__tests__/route.test.ts +++ b/app/api/transactions/bulk-book/__tests__/route.test.ts @@ -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: { diff --git a/app/api/transactions/bulk-book/route.ts b/app/api/transactions/bulk-book/route.ts index 8296f854..626f8aa6 100644 --- a/app/api/transactions/bulk-book/route.ts +++ b/app/api/transactions/bulk-book/route.ts @@ -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', { diff --git a/components/bookkeeping/EditAccountDialog.tsx b/components/bookkeeping/EditAccountDialog.tsx index b4aac9ff..f21a7043 100644 --- a/components/bookkeeping/EditAccountDialog.tsx +++ b/components/bookkeeping/EditAccountDialog.tsx @@ -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 = { + required: 'Krävs', + default: 'Förval', + fixed: 'Låst', +} + +const RULE_TYPE_HELP: Record = { + 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([]) + const [rules, setRules] = useState([]) + const [rulesLoading, setRulesLoading] = useState(false) + const [addRuleOpen, setAddRuleOpen] = useState(false) + const [newRuleDimensionId, setNewRuleDimensionId] = useState('') + const [newRuleType, setNewRuleType] = useState('required') + const [newRuleValueCode, setNewRuleValueCode] = useState(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 = { + 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 ( - + Redigera konto {account.account_number} @@ -107,6 +317,173 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit + {dimensionsEnabled && ( +
+

Dimensionsregler

+ + {rulesLoading ? ( + + ) : ( + <> + {rules.length === 0 && !addRuleOpen && ( +
+

+ Inga dimensionsregler för det här kontot. +

+ +
+ )} + + {rules.map((rule) => ( +
+
+

+ {rule.dimension_name} +

+

+ {RULE_TYPE_LABELS[rule.rule_type]} + {rule.value_code && ( + <> + {' · '} + {rule.value_code} + {rule.value_name && rule.value_name !== rule.value_code + ? ` ${rule.value_name}` + : ''} + + )} +

+
+ handleToggleRule(rule, checked)} + aria-label={`Regel för ${rule.dimension_name} aktiv`} + /> + +
+ ))} + + {addRuleOpen ? ( +
+
+
+ + +
+
+ + +
+
+

+ {RULE_TYPE_HELP[newRuleType]} +

+ {newRuleNeedsValue && newRuleDim && ( +
+ + +
+ )} +
+ + +
+
+ ) : ( + rules.length > 0 && ( + + ) + )} + + )} +
+ )} +

Aktivt konto

diff --git a/components/dimensions/DimensionsManager.tsx b/components/dimensions/DimensionsManager.tsx index 638604a3..e98c1d1d 100644 --- a/components/dimensions/DimensionsManager.tsx +++ b/components/dimensions/DimensionsManager.tsx @@ -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('asc') const [dialog, setDialog] = useState(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 = { + 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 (
- {/* Segmented tabs — one per registry dimension (1 Kostnadsställe, 6 Projekt) */} -
- { - setActiveDimId(id) - setSearchTerm('') - }} - > - - {dimensions.map((dim) => ( - - {dim.name} - - ))} - - - + {/* Segmented tabs — one per registry dimension (1 Kostnadsställe, + 6 Projekt, plus any custom 20+ dims) */} +
+
+ { + setActiveDimId(id) + setSearchTerm('') + }} + > + + {dimensions.map((dim) => ( + + {dim.name} + + ))} + + +
+ + +
+
+ {parentDimName && ( +

+ {t('subdimension_of', { parent: parentDimName })} +

+ )}
{/* Search */} @@ -479,6 +558,181 @@ export default function DimensionsManager() { )}
+ + {/* New dimension dialog — the content (and thus the form state) + unmounts on close, so each open starts from a blank form. */} + !open && setNewDimDialogOpen(false)} + > + + + {t('new_dimension_title')} + + + + ) } + +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 +}) { + 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(null) + const [numberError, setNumberError] = useState(null) + + const parentOptions = dimensions.filter((d) => d.is_active) + + function handleSubmit(e: React.FormEvent) { + 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 ( +
+
+
+ + setName(e.target.value)} + autoComplete="off" + autoFocus + /> + {nameError &&

{nameError}

} +
+
+ + setNumberStr(e.target.value)} + className="tabular-nums" + /> +

{t('dim_form_number_help')}

+ {numberError &&

{numberError}

} +
+
+ +
+
+ +

+ {t('dim_form_resets_help')} +

+
+ +
+ +
+ + {showAdvanced && ( +
+ + +

{t('dim_form_parent_help')}

+
+ )} +
+ +
+ +
+
+ ) +} diff --git a/components/dimensions/LineDimensionFields.tsx b/components/dimensions/LineDimensionFields.tsx index 9e0b697f..20ef6d43 100644 --- a/components/dimensions/LineDimensionFields.tsx +++ b/components/dimensions/LineDimensionFields.tsx @@ -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(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 (
-
- -
- onChange('1', code)} - disabled={disabled} - className={inputClassName} - /> + {fields.map((field) => ( +
+ +
+ onChange(field.sieDimNo, code)} + disabled={disabled} + className={inputClassName} + /> +
-
-
- -
- onChange('6', code)} - disabled={disabled} - className={inputClassName} - /> -
-
+ ))}
) } diff --git a/components/dimensions/types.ts b/components/dimensions/types.ts index 72ed43e5..51f203f6 100644 --- a/components/dimensions/types.ts +++ b/components/dimensions/types.ts @@ -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 { } return (json?.dimensions ?? []) as DimensionDto[] } + +let cachedDimensionsPromise: Promise | 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 { + if (!cachedDimensionsPromise) { + cachedDimensionsPromise = fetchDimensions().catch((err) => { + cachedDimensionsPromise = null + throw err + }) + } + return cachedDimensionsPromise +} diff --git a/extensions/general/mcp-server/__tests__/agent-briefing.test.ts b/extensions/general/mcp-server/__tests__/agent-briefing.test.ts index 503b874c..b492c558 100644 --- a/extensions/general/mcp-server/__tests__/agent-briefing.test.ts +++ b/extensions/general/mcp-server/__tests__/agent-briefing.test.ts @@ -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}`) }), } diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 17563f88..4af1089c 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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() + const defaultByDimension = new Map() + 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), } }), diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index f693cee9..cf008075 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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 diff --git a/lib/bookkeeping/__tests__/dimension-rules.test.ts b/lib/bookkeeping/__tests__/dimension-rules.test.ts new file mode 100644 index 00000000..a0ed740c --- /dev/null +++ b/lib/bookkeeping/__tests__/dimension-rules.test.ts @@ -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 | null + cost_center?: string | null + project?: string | null +} + +function makeRule(overrides: Partial = {}): 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() + }) +}) diff --git a/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts b/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts index 31573bfb..98175b34 100644 --- a/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts +++ b/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts @@ -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 = {}) { + 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> + // 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> + // 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') + }) +}) diff --git a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts index c502e988..63c3f71b 100644 --- a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts +++ b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts @@ -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 () => { diff --git a/lib/bookkeeping/dimension-errors.ts b/lib/bookkeeping/dimension-errors.ts index 3858cc78..ad19460b 100644 --- a/lib/bookkeeping/dimension-errors.ts +++ b/lib/bookkeeping/dimension-errors.ts @@ -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 +} diff --git a/lib/bookkeeping/dimension-rules.ts b/lib/bookkeeping/dimension-rules.ts new file mode 100644 index 00000000..65101ac0 --- /dev/null +++ b/lib/bookkeeping/dimension-rules.ts @@ -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 { + // 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() + 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, + rules: AccountDimensionRule[] +): void { + const required = rules.filter((r) => r.rule_type === 'required') + if (required.length === 0) return + + const byAccount = new Map() + 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() + 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()]) + } +} diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index c45c684a..c8e2641a 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -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 { 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 }>, + 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', { diff --git a/lib/bookkeeping/errors.ts b/lib/bookkeeping/errors.ts index 00d5cb8e..ce0e0f26 100644 --- a/lib/bookkeeping/errors.ts +++ b/lib/bookkeeping/errors.ts @@ -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( { diff --git a/messages/en.json b/messages/en.json index 057bd766..abb46cf4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index d2da8108..e8759bf9 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/supabase/migrations/20260703200000_account_dimension_rules.sql b/supabase/migrations/20260703200000_account_dimension_rules.sql new file mode 100644 index 00000000..0162a32a --- /dev/null +++ b/supabase/migrations/20260703200000_account_dimension_rules.sql @@ -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'; diff --git a/tests/pg/account-dimension-rules.pg.test.ts b/tests/pg/account-dimension-rules.pg.test.ts new file mode 100644 index 00000000..c2d9fd27 --- /dev/null +++ b/tests/pg/account-dimension-rules.pg.test.ts @@ -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 { + 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 { + 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 { + 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/) + }) +})