diff --git a/app/(dashboard)/dimensions/page.tsx b/app/(dashboard)/dimensions/page.tsx new file mode 100644 index 00000000..29e29860 --- /dev/null +++ b/app/(dashboard)/dimensions/page.tsx @@ -0,0 +1,21 @@ +import { getTranslations } from 'next-intl/server' +import { PageHeader } from '@/components/ui/page-header' +import DimensionsManager from '@/components/dimensions/DimensionsManager' + +/** + * Kostnadsställen & projekt (dimension registry) — a Redovisning-group + * register peer to Kontoplan. Reference/configuration surface: manage the + * dimension values (#OBJEKT) that voucher lines are tagged with. Reachable + * only via the nav row when company_settings.dimensions_enabled is on, but + * the page itself never gates — the toggle is UI visibility, not correctness + * (dimensions plan §2). + */ +export default async function DimensionsPage() { + const t = await getTranslations('nav') + return ( +
+ + +
+ ) +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 45348691..955b59e9 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -188,7 +188,7 @@ export default async function DashboardLayout({ ] = await Promise.all([ supabase .from('company_settings') - .select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox') + .select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled') .eq('company_id', companyId) .single(), // Shared worklist predicates (lib/worklist) — the badge must show the @@ -226,6 +226,10 @@ export default async function DashboardLayout({ (companyRow.entity_type as EntityType) || 'enskild_firma' const paysSalaries = settings?.pays_salaries ?? false + // Dimensions register visibility (Kostnadsställen & projekt nav row). Same + // mechanism as paysSalaries: UI gate only, never load-bearing for + // correctness (dimensions plan §2). + const dimensionsEnabled = settings?.dimensions_enabled ?? false const companyWithName = { ...companyRow, name: displayName, @@ -292,6 +296,7 @@ export default async function DashboardLayout({ companyName={settings?.company_name || 'Min verksamhet'} entityType={entityType} paysSalaries={paysSalaries} + dimensionsEnabled={dimensionsEnabled} uncategorizedTransactionCount={uncategorizedCount} pendingOperationsCount={pendingOpsCount} isSandbox={isSandbox} diff --git a/app/api/dimensions/[id]/route.ts b/app/api/dimensions/[id]/route.ts new file mode 100644 index 00000000..447df499 --- /dev/null +++ b/app/api/dimensions/[id]/route.ts @@ -0,0 +1,78 @@ +/** + * PATCH /api/dimensions/[id] — update a dimension (name / is_active / sort_order). + * + * Guard rails: + * - Renaming an is_system dimension (1 = Kostnadsställe, 6 = Projekt) is + * rejected with 400 DIMENSION_SYSTEM_RENAME ("Systemdimensioner kan inte + * döpas om"). Archiving (is_active=false) and reordering remain allowed. + * - sie_dim_no / is_system are immutable at the DB level + * (enforce_dimension_registry_guards) and not accepted here at all. + */ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { UpdateDimensionSchema } from '@/lib/api/schemas' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' + +ensureInitialized() + +export const PATCH = withRouteContext( + 'dimension.update', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ dimensionId: id }) + + const result = await validateBody(request, UpdateDimensionSchema, { + log: opLog, + operation: 'dimension.update', + }) + if (!result.success) return result.response + const body = result.data + + const { data: existing, error: fetchError } = await supabase + .from('dimensions') + .select('id, name, is_system') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (fetchError) { + opLog.error('dimension fetch failed', fetchError) + return errorResponse(fetchError, opLog, { requestId }) + } + if (!existing) { + return errorResponseFromCode('DIMENSION_NOT_FOUND', opLog, { requestId }) + } + + if (existing.is_system && body.name !== undefined && body.name !== existing.name) { + return errorResponseFromCode('DIMENSION_SYSTEM_RENAME', opLog, { requestId }) + } + + // Sparse update — only the fields the caller actually sent. + const updateData: Record = {} + for (const key of ['name', 'is_active', 'sort_order'] as const) { + if (body[key] !== undefined) updateData[key] = body[key] + } + + const { data, error } = await supabase + .from('dimensions') + .update(updateData) + .eq('id', id) + .eq('company_id', companyId) + .select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order') + .single() + + if (error) { + opLog.error('dimension update failed', error) + return errorResponseFromCode('DIMENSION_UPDATE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/dimensions/[id]/values/[valueId]/route.ts b/app/api/dimensions/[id]/values/[valueId]/route.ts new file mode 100644 index 00000000..97161d5f --- /dev/null +++ b/app/api/dimensions/[id]/values/[valueId]/route.ts @@ -0,0 +1,132 @@ +/** + * PATCH /api/dimensions/[id]/values/[valueId] — update a dimension value + * (name / is_active / start_date / end_date; `code` is immutable in v1). + * DELETE /api/dimensions/[id]/values/[valueId] — delete an UNREFERENCED value. + * + * Deleting a value referenced by posted/reversed lines is blocked by the DB + * retention trigger (enforce_dimension_value_retention, BFL 7-year + * philosophy). Its Swedish message ("Värdet "X" används på bokförda verifikat + * och kan inte tas bort — arkivera det istället.") is surfaced verbatim as a + * 409 DIMENSION_VALUE_REFERENCED so the register UI can toast it and offer + * archive instead. + */ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { UpdateDimensionValueSchema } from '@/lib/api/schemas' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' + +ensureInitialized() + +type ValueParams = { params: Promise<{ id: string; valueId: string }> } + +export const PATCH = withRouteContext( + 'dimension.value.update', + async (request, ctx, { params }: ValueParams) => { + const { id, valueId } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ dimensionId: id, valueId }) + + const result = await validateBody(request, UpdateDimensionValueSchema, { + log: opLog, + operation: 'dimension.value.update', + }) + if (!result.success) return result.response + const body = result.data + + // Value dates only make sense on accumulating dimensions (projekt-style + // ranges). When the request carries an actual date (explicit null = clear, + // always allowed), check the parent dimension's resets_annually flag. + if (body.start_date != null || body.end_date != null) { + const { data: dimension, error: dimError } = await supabase + .from('dimensions') + .select('id, resets_annually') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (dimError) { + opLog.error('dimension fetch failed', dimError) + return errorResponse(dimError, opLog, { requestId }) + } + if (!dimension) { + return errorResponseFromCode('DIMENSION_NOT_FOUND', opLog, { requestId }) + } + if (dimension.resets_annually) { + return errorResponseFromCode('DIMENSION_VALUE_DATES_NOT_ALLOWED', opLog, { requestId }) + } + } + + // Sparse update — only the fields the caller actually sent. `code` is + // deliberately absent from the schema: renaming a code would silently + // orphan every line tagged with it. + const updateData: Record = {} + for (const key of ['name', 'is_active', 'start_date', 'end_date'] as const) { + if (body[key] !== undefined) updateData[key] = body[key] + } + + const { data, error } = await supabase + .from('dimension_values') + .update(updateData) + .eq('id', valueId) + .eq('dimension_id', id) + .eq('company_id', companyId) + .select('id, dimension_id, code, name, is_active, start_date, end_date') + .single() + + if (error) { + if (error.code === 'PGRST116') { + return errorResponseFromCode('DIMENSION_VALUE_NOT_FOUND', opLog, { requestId }) + } + opLog.error('dimension value update failed', error) + return errorResponseFromCode('DIMENSION_VALUE_UPDATE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) + +export const DELETE = withRouteContext( + 'dimension.value.delete', + async (_request, ctx, { params }: ValueParams) => { + const { id, valueId } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ dimensionId: id, valueId }) + + const { data, error } = await supabase + .from('dimension_values') + .delete() + .eq('id', valueId) + .eq('dimension_id', id) + .eq('company_id', companyId) + .select('id') + + if (error) { + // P0001 = plpgsql RAISE EXCEPTION — the retention trigger refusing the + // delete. Surface its Swedish message verbatim (it names the code). + if (error.code === 'P0001') { + return errorResponseFromCode('DIMENSION_VALUE_REFERENCED', opLog, { + requestId, + messageSv: error.message, + }) + } + opLog.error('dimension value delete failed', error) + return errorResponseFromCode('DIMENSION_VALUE_DELETE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } + + if (!data || data.length === 0) { + return errorResponseFromCode('DIMENSION_VALUE_NOT_FOUND', opLog, { requestId }) + } + + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/dimensions/[id]/values/route.ts b/app/api/dimensions/[id]/values/route.ts new file mode 100644 index 00000000..ed4cf50b --- /dev/null +++ b/app/api/dimensions/[id]/values/route.ts @@ -0,0 +1,91 @@ +/** + * POST /api/dimensions/[id]/values — create a dimension value (SIE #OBJEKT). + * + * Codes are validated against the strict Fortnox format + * (^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$) for user-created values — the DB CHECK is + * looser by design so legacy free-text codes survive the backfill/SIE import, + * but new registry codes minted here stay portable. Duplicate codes within the + * dimension return 409 DIMENSION_VALUE_DUPLICATE_CODE with a Swedish message. + * `code` is immutable after creation (v1: no rename — retag instead). + */ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { CreateDimensionValueSchema } from '@/lib/api/schemas' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' + +ensureInitialized() + +export const POST = withRouteContext( + 'dimension.value.create', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ dimensionId: id }) + + const result = await validateBody(request, CreateDimensionValueSchema, { + log: opLog, + operation: 'dimension.value.create', + }) + if (!result.success) return result.response + const body = result.data + + // The dimension must exist and belong to the active company (defense in + // depth alongside the composite FK — a foreign dimension id 404s here). + const { data: dimension, error: dimError } = await supabase + .from('dimensions') + .select('id, resets_annually') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (dimError) { + opLog.error('dimension fetch failed', dimError) + return errorResponse(dimError, opLog, { requestId }) + } + if (!dimension) { + return errorResponseFromCode('DIMENSION_NOT_FOUND', opLog, { requestId }) + } + + // Value dates only make sense on accumulating dimensions (projekt-style + // ranges). A resets-annually dimension (e.g. kostnadsställe) rejects any + // request carrying an actual date (explicit null is a harmless no-op). + if (dimension.resets_annually && (body.start_date != null || body.end_date != null)) { + return errorResponseFromCode('DIMENSION_VALUE_DATES_NOT_ALLOWED', opLog, { requestId }) + } + + const { data, error } = await supabase + .from('dimension_values') + .insert({ + company_id: companyId, + dimension_id: id, + code: body.code, + name: body.name, + // Default true; is_active=false makes "create as archived" atomic + // (no follow-up PATCH from the register UI). + is_active: body.is_active ?? true, + start_date: body.start_date ?? null, + end_date: body.end_date ?? null, + }) + .select('id, dimension_id, code, name, is_active, start_date, end_date') + .single() + + if (error) { + if (error.code === '23505') { + return errorResponseFromCode('DIMENSION_VALUE_DUPLICATE_CODE', opLog, { + requestId, + details: { code: body.code }, + }) + } + opLog.error('dimension value insert failed', error) + return errorResponseFromCode('DIMENSION_VALUE_CREATE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/dimensions/__tests__/id.test.ts b/app/api/dimensions/__tests__/id.test.ts new file mode 100644 index 00000000..364a1303 --- /dev/null +++ b/app/api/dimensions/__tests__/id.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for PATCH /api/dimensions/[id] (update dimension). + * + * Covers: 401, empty-body validation (400), 404, the is_system name-lock + * ("Systemdimensioner kan inte döpas om" — 400 DIMENSION_SYSTEM_RENAME), + * archiving a system dimension (allowed), and the happy rename of a custom + * dimension. + */ +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'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { PATCH } from '../[id]/route' + +const params = () => createMockRouteParams({ id: 'dim-1' }) + +describe('PATCH /api/dimensions/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/dimensions/dim-1', { + method: 'PATCH', + body: { name: 'Avdelning' }, + }) + const response = await PATCH(request, params()) + + expect(response.status).toBe(401) + }) + + it('rejects an empty body with 400', async () => { + const request = createMockRequest('/api/dimensions/dim-1', { method: 'PATCH', body: {} }) + const response = await PATCH(request, params()) + + expect(response.status).toBe(400) + }) + + it('returns 404 when the dimension does not belong to the company', async () => { + enqueue({ data: null }) // maybeSingle fetch + + const request = createMockRequest('/api/dimensions/dim-1', { + method: 'PATCH', + body: { name: 'Avdelning' }, + }) + const response = await PATCH(request, params()) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('DIMENSION_NOT_FOUND') + }) + + it('rejects renaming a system dimension with 400 DIMENSION_SYSTEM_RENAME', async () => { + enqueue({ data: { id: 'dim-1', name: 'Kostnadsställe', is_system: true } }) + + const request = createMockRequest('/api/dimensions/dim-1', { + method: 'PATCH', + body: { name: 'Avdelning' }, + }) + const response = await PATCH(request, params()) + const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('DIMENSION_SYSTEM_RENAME') + expect(body.error.message).toBe('Systemdimensioner kan inte döpas om.') + }) + + it('allows archiving a system dimension (is_active only, no rename)', async () => { + enqueue({ data: { id: 'dim-1', name: 'Kostnadsställe', is_system: true } }) + enqueue({ + data: { + id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', + resets_annually: true, is_system: true, is_active: false, sort_order: 10, + }, + }) + + const request = createMockRequest('/api/dimensions/dim-1', { + method: 'PATCH', + body: { is_active: false }, + }) + const response = await PATCH(request, params()) + const { status, body } = await parseJsonResponse<{ data: { is_active: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.is_active).toBe(false) + }) + + it('renames a non-system dimension (happy path)', async () => { + enqueue({ data: { id: 'dim-1', name: 'Dimension 7', is_system: false } }) + enqueue({ + data: { + id: 'dim-1', sie_dim_no: 7, name: 'Avdelning', + resets_annually: true, is_system: false, is_active: true, sort_order: 30, + }, + }) + + const request = createMockRequest('/api/dimensions/dim-1', { + method: 'PATCH', + body: { name: 'Avdelning', sort_order: 30 }, + }) + const response = await PATCH(request, params()) + const { status, body } = await parseJsonResponse<{ data: { name: string; sort_order: number } }>(response) + + expect(status).toBe(200) + expect(body.data.name).toBe('Avdelning') + expect(body.data.sort_order).toBe(30) + }) +}) diff --git a/app/api/dimensions/__tests__/import-existing.test.ts b/app/api/dimensions/__tests__/import-existing.test.ts new file mode 100644 index 00000000..43288ea8 --- /dev/null +++ b/app/api/dimensions/__tests__/import-existing.test.ts @@ -0,0 +1,172 @@ +/** + * Tests for POST /api/dimensions/import-existing (backfill scan). + * + * Covers: 401, the empty-company early exit, the happy path where codes + * found on journal lines but missing from the registry are created as + * inactive placeholder values ({ created: n }), code sanitization (PR1 + * backfill parity), and duplicate-tolerant upserts. + */ +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 '../import-existing/route' + +const request = () => createMockRequest('/api/dimensions/import-existing', { method: 'POST' }) +const noParams = { params: Promise.resolve({}) } + +describe('POST /api/dimensions/import-existing', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(request(), noParams) + + expect(response.status).toBe(401) + }) + + it('returns { created: 0 } when no line carries dimensions', async () => { + enqueue({ data: null }) // ensure RPC + enqueue({ data: [] }) // journal_entry_lines scan + + const response = await POST(request(), noParams) + const { status, body } = await parseJsonResponse<{ created: number }>(response) + + expect(status).toBe(200) + expect(body.created).toBe(0) + }) + + it('creates inactive placeholder values for codes missing from the registry', async () => { + enqueue({ data: null }) // ensure RPC + // Lines: KS01 (dim 1) appears twice, P001 (dim 6) once, BUTIK already registered. + enqueue({ + data: [ + { id: 'l1', dimensions: { '1': 'KS01' } }, + { id: 'l2', dimensions: { '1': 'KS01', '6': 'P001' } }, + { id: 'l3', dimensions: { '1': 'BUTIK' } }, + ], + }) + // Registry dims 1 & 6 exist (system dims). + enqueue({ + data: [ + { id: 'dim-1', sie_dim_no: 1 }, + { id: 'dim-6', sie_dim_no: 6 }, + ], + }) + // Existing values: BUTIK is already registered under dim 1. + enqueue({ data: [{ dimension_id: 'dim-1', code: 'BUTIK' }] }) + // Upsert of the two missing values succeeds — created counts returned rows. + enqueue({ data: [{ id: 'nv1' }, { id: 'nv2' }] }) + + const response = await POST(request(), noParams) + const { status, body } = await parseJsonResponse<{ created: number }>(response) + + expect(status).toBe(200) + expect(body.created).toBe(2) // KS01 + P001; BUTIK skipped (already registered) + }) + + it('sanitizes candidate codes like the PR1 backfill and de-duplicates after sanitization', async () => { + enqueue({ data: null }) // ensure RPC + // 'KS"01"' and 'KS{01}' both sanitize to KS01 (one candidate); a 50-char + // code is capped at 40; '"{}"' sanitizes to empty and is dropped entirely. + enqueue({ + data: [ + { id: 'l1', dimensions: { '1': 'KS"01"' } }, + { id: 'l2', dimensions: { '1': 'KS{01}' } }, + { id: 'l3', dimensions: { '1': 'X'.repeat(50) } }, + { id: 'l4', dimensions: { '1': '"{}"' } }, + ], + }) + enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims + enqueue({ data: [] }) // no existing values + // Upsert returns the two surviving sanitized codes (KS01 + the capped one). + enqueue({ data: [{ id: 'nv1' }, { id: 'nv2' }] }) + + const response = await POST(request(), noParams) + const { status, body } = await parseJsonResponse<{ created: number }>(response) + + expect(status).toBe(200) + expect(body.created).toBe(2) + }) + + it('tolerates duplicates in the batch — created counts only the rows the upsert returned', async () => { + enqueue({ data: null }) // ensure RPC + enqueue({ + data: [ + { id: 'l1', dimensions: { '1': 'KS01' } }, + { id: 'l2', dimensions: { '1': 'KS02' } }, + ], + }) + enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims + enqueue({ data: [] }) // existing-values snapshot missed a raced KS02 + // ignoreDuplicates upsert skips the conflicting row instead of aborting + // the batch — only KS01 comes back. + enqueue({ data: [{ id: 'nv1' }] }) + + const response = await POST(request(), noParams) + const { status, body } = await parseJsonResponse<{ created: number }>(response) + + expect(status).toBe(200) + expect(body.created).toBe(1) + }) + + it('creates a registry dimension for an unregistered dim number found on lines', async () => { + enqueue({ data: null }) // ensure RPC + enqueue({ data: [{ id: 'l1', dimensions: { '7': 'AVD-A' } }] }) // lines + // Registry only has the system dims — dim 7 is missing. + enqueue({ + data: [ + { id: 'dim-1', sie_dim_no: 1 }, + { id: 'dim-6', sie_dim_no: 6 }, + ], + }) + // Upsert of the missing dimension row returns its id. + enqueue({ data: [{ id: 'dim-7', sie_dim_no: 7 }] }) + // No existing values. + enqueue({ data: [] }) + // Value upsert succeeds. + enqueue({ data: [{ id: 'nv1' }] }) + + const response = await POST(request(), noParams) + const { status, body } = await parseJsonResponse<{ created: number }>(response) + + expect(status).toBe(200) + expect(body.created).toBe(1) + }) + + it('returns 500 DIMENSION_IMPORT_FAILED when the scan blows up', async () => { + enqueue({ data: null }) // ensure RPC + enqueue({ error: { message: 'relation missing' } }) // fetchAllRows throws + + const response = await POST(request(), noParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(500) + expect(body.error.code).toBe('DIMENSION_IMPORT_FAILED') + }) +}) diff --git a/app/api/dimensions/__tests__/route.test.ts b/app/api/dimensions/__tests__/route.test.ts new file mode 100644 index 00000000..003a175b --- /dev/null +++ b/app/api/dimensions/__tests__/route.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for GET /api/dimensions (dimension registry list). + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, the ensure-RPC failure path, and the contract + * shape ({ dimensions: [...] } with values nested per dimension). + */ +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 { GET } from '../route' + +interface DimensionsBody { + dimensions: Array<{ + id: string + sie_dim_no: number + name: string + resets_annually: boolean + is_system: boolean + is_active: boolean + sort_order: number + values: Array<{ + id: string + code: string + name: string + is_active: boolean + start_date: string | null + end_date: string | null + }> + }> +} + +describe('GET /api/dimensions', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await GET(createMockRequest('/api/dimensions'), { params: Promise.resolve({}) }) + + expect(response.status).toBe(401) + }) + + it('ensures system dims exist, then returns dimensions with nested values', async () => { + // 1st DB hit: ensure_company_dimensions RPC. + enqueue({ data: null }) + // 2nd: dimensions list (sorted by sort_order server-side). + enqueue({ + data: [ + { id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 }, + { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 }, + ], + }) + // 3rd: dimension_values list (sorted by code server-side). + enqueue({ + data: [ + { id: 'v1', dimension_id: 'dim-1', code: 'BUTIK', name: 'Butiken', is_active: true, start_date: null, end_date: null }, + { id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Projekt Björk', is_active: false, start_date: '2026-01-01', end_date: '2026-06-30' }, + ], + }) + + const response = await GET(createMockRequest('/api/dimensions'), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(supabase.rpc).toHaveBeenCalledWith('ensure_company_dimensions', { + p_company_id: 'company-1', + }) + expect(body.dimensions).toHaveLength(2) + expect(body.dimensions[0]).toMatchObject({ + id: 'dim-1', + sie_dim_no: 1, + name: 'Kostnadsställe', + resets_annually: true, + is_system: true, + values: [ + { id: 'v1', code: 'BUTIK', name: 'Butiken', is_active: true, start_date: null, end_date: null }, + ], + }) + expect(body.dimensions[1].values).toEqual([ + { id: 'v2', code: 'P001', name: 'Projekt Björk', is_active: false, start_date: '2026-01-01', end_date: '2026-06-30' }, + ]) + }) + + it('returns a dimension with an empty values array when it has no values', async () => { + enqueue({ data: null }) // ensure RPC + enqueue({ + data: [ + { id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 }, + ], + }) + enqueue({ data: [] }) + + const response = await GET(createMockRequest('/api/dimensions'), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.dimensions[0].values).toEqual([]) + }) + + it('returns 500 when the ensure RPC fails', async () => { + enqueue({ error: { code: 'XX000', message: 'boom' } }) + + const response = await GET(createMockRequest('/api/dimensions'), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(500) + expect(body.error.code).toBeTruthy() + }) +}) diff --git a/app/api/dimensions/__tests__/value-id.test.ts b/app/api/dimensions/__tests__/value-id.test.ts new file mode 100644 index 00000000..5ed9e3b1 --- /dev/null +++ b/app/api/dimensions/__tests__/value-id.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for PATCH/DELETE /api/dimensions/[id]/values/[valueId]. + * + * The DELETE suite pins the retention-trigger contract: a P0001 raise from + * enforce_dimension_value_retention surfaces as 409 DIMENSION_VALUE_REFERENCED + * with the trigger's own Swedish message ("…arkivera det istället") so the UI + * can toast it verbatim. + */ +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 { PATCH, DELETE } from '../[id]/values/[valueId]/route' + +const params = () => createMockRouteParams({ id: 'dim-1', valueId: 'v1' }) +const URL_PATH = '/api/dimensions/dim-1/values/v1' + +describe('PATCH /api/dimensions/[id]/values/[valueId]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest(URL_PATH, { method: 'PATCH', body: { name: 'Nytt namn' } }) + const response = await PATCH(request, params()) + + expect(response.status).toBe(401) + }) + + it('rejects an empty body with 400', async () => { + const request = createMockRequest(URL_PATH, { method: 'PATCH', body: {} }) + const response = await PATCH(request, params()) + + expect(response.status).toBe(400) + }) + + it('returns 404 when the value does not exist in the company', async () => { + enqueue({ error: { code: 'PGRST116', message: 'No rows found' } }) + + const request = createMockRequest(URL_PATH, { method: 'PATCH', body: { name: 'Nytt namn' } }) + const response = await PATCH(request, params()) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('DIMENSION_VALUE_NOT_FOUND') + }) + + it('archives a value via is_active=false (happy path)', async () => { + // The request carries end_date, so the route first checks the parent + // dimension's resets_annually flag (accumulating → dates allowed). + enqueue({ data: { id: 'dim-1', resets_annually: false } }) + enqueue({ + data: { + id: 'v1', dimension_id: 'dim-1', code: 'BUTIK', name: 'Butiken', + is_active: false, start_date: null, end_date: '2026-06-30', + }, + }) + + const request = createMockRequest(URL_PATH, { + method: 'PATCH', + body: { is_active: false, end_date: '2026-06-30' }, + }) + const response = await PATCH(request, params()) + const { status, body } = await parseJsonResponse<{ data: { is_active: boolean; code: string } }>(response) + + expect(status).toBe(200) + expect(body.data.is_active).toBe(false) + // Code untouched — immutable in v1. + expect(body.data.code).toBe('BUTIK') + }) + + it('rejects start/end dates on a resets-annually dimension with 400', async () => { + enqueue({ data: { id: 'dim-1', resets_annually: true } }) + + const request = createMockRequest(URL_PATH, { + method: 'PATCH', + body: { start_date: '2026-01-01' }, + }) + const response = await PATCH(request, params()) + const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('DIMENSION_VALUE_DATES_NOT_ALLOWED') + expect(body.error.message).toBe( + 'Datum kan bara sättas på ackumulerande dimensioner (t.ex. projekt).', + ) + }) + + it('allows clearing dates (explicit null) without checking the dimension', async () => { + // start_date: null clears the field — a harmless no-op on any dimension, + // so the route must not spend a dimension fetch on it. The single queued + // result feeds the update itself. + enqueue({ + data: { + id: 'v1', dimension_id: 'dim-1', code: 'BUTIK', name: 'Butiken', + is_active: true, start_date: null, end_date: null, + }, + }) + + const request = createMockRequest(URL_PATH, { + method: 'PATCH', + body: { start_date: null, end_date: null }, + }) + const response = await PATCH(request, params()) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + }) +}) + +describe('DELETE /api/dimensions/[id]/values/[valueId]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest(URL_PATH, { method: 'DELETE' }) + const response = await DELETE(request, params()) + + expect(response.status).toBe(401) + }) + + it('surfaces the retention trigger as 409 with the trigger\'s Swedish message', async () => { + const triggerMessage = + 'Värdet "BUTIK" används på bokförda verifikat och kan inte tas bort — arkivera det istället.' + enqueue({ error: { code: 'P0001', message: triggerMessage } }) + + const request = createMockRequest(URL_PATH, { method: 'DELETE' }) + const response = await DELETE(request, params()) + const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('DIMENSION_VALUE_REFERENCED') + // The trigger's message (naming the code) is surfaced verbatim for the toast. + expect(body.error.message).toBe(triggerMessage) + expect(body.error.message).toContain('arkivera det istället') + }) + + it('returns 404 when nothing was deleted', async () => { + enqueue({ data: [] }) + + const request = createMockRequest(URL_PATH, { method: 'DELETE' }) + const response = await DELETE(request, params()) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('DIMENSION_VALUE_NOT_FOUND') + }) + + it('deletes an unreferenced value (happy path)', async () => { + enqueue({ data: [{ id: 'v1' }] }) + + const request = createMockRequest(URL_PATH, { method: 'DELETE' }) + const response = await DELETE(request, params()) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) +}) diff --git a/app/api/dimensions/__tests__/values.test.ts b/app/api/dimensions/__tests__/values.test.ts new file mode 100644 index 00000000..959c81fb --- /dev/null +++ b/app/api/dimensions/__tests__/values.test.ts @@ -0,0 +1,175 @@ +/** + * Tests for POST /api/dimensions/[id]/values (create dimension value). + * + * Covers: 401, the strict Fortnox code format (400), 404 on a foreign + * dimension, the duplicate-code conflict (409 with Swedish message), the + * dates-on-resets-annually rejection (400), atomic create-as-archived + * (is_active=false), and the happy path. + */ +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 { POST } from '../[id]/values/route' + +const params = () => createMockRouteParams({ id: 'dim-1' }) + +describe('POST /api/dimensions/[id]/values', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/dimensions/dim-1/values', { + method: 'POST', + body: { code: 'P001', name: 'Projekt Björk' }, + }) + const response = await POST(request, params()) + + expect(response.status).toBe(401) + }) + + it.each([ + ['space in code', 'P 001'], + ['too long (>20 chars)', 'A'.repeat(21)], + ['SIE-breaking char', 'P{1}'], + ])('rejects an invalid code (%s) with 400', async (_label, code) => { + const request = createMockRequest('/api/dimensions/dim-1/values', { + method: 'POST', + body: { code, name: 'Projekt' }, + }) + const response = await POST(request, params()) + + expect(response.status).toBe(400) + }) + + it('rejects end_date before start_date with 400', async () => { + const request = createMockRequest('/api/dimensions/dim-1/values', { + method: 'POST', + body: { code: 'P001', name: 'Projekt', start_date: '2026-06-01', end_date: '2026-01-01' }, + }) + const response = await POST(request, params()) + + expect(response.status).toBe(400) + }) + + it('returns 404 when the dimension does not belong to the company', async () => { + enqueue({ data: null }) // dimension maybeSingle + + const request = createMockRequest('/api/dimensions/dim-1/values', { + method: 'POST', + body: { code: 'P001', name: 'Projekt Björk' }, + }) + const response = await POST(request, params()) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('DIMENSION_NOT_FOUND') + }) + + it('rejects start/end dates on a resets-annually dimension with 400', async () => { + // Kostnadsställe-style dims reset annually — value date ranges are only + // meaningful on accumulating dims (projekt). + enqueue({ data: { id: 'dim-1', resets_annually: true } }) + + const request = createMockRequest('/api/dimensions/dim-1/values', { + method: 'POST', + body: { code: 'KS01', name: 'Kontoret', start_date: '2026-01-01' }, + }) + const response = await POST(request, params()) + const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('DIMENSION_VALUE_DATES_NOT_ALLOWED') + expect(body.error.message).toBe( + 'Datum kan bara sättas på ackumulerande dimensioner (t.ex. projekt).', + ) + }) + + it('creates a value as archived when is_active=false (atomic, no follow-up PATCH)', async () => { + enqueue({ data: { id: 'dim-1', resets_annually: false } }) + enqueue({ + data: { + id: 'v2', dimension_id: 'dim-1', code: 'NEDLAGD', name: 'Nedlagd avdelning', + is_active: false, start_date: null, end_date: null, + }, + }) + + const request = createMockRequest('/api/dimensions/dim-1/values', { + method: 'POST', + body: { code: 'NEDLAGD', name: 'Nedlagd avdelning', is_active: false }, + }) + const response = await POST(request, params()) + const { status, body } = await parseJsonResponse<{ data: { id: string; is_active: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.id).toBe('v2') + expect(body.data.is_active).toBe(false) + }) + + it('returns 409 with a Swedish message on a duplicate code', async () => { + enqueue({ data: { id: 'dim-1' } }) + enqueue({ error: { code: '23505', message: 'duplicate key value' } }) + + const request = createMockRequest('/api/dimensions/dim-1/values', { + method: 'POST', + body: { code: 'P001', name: 'Projekt Björk' }, + }) + const response = await POST(request, params()) + const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('DIMENSION_VALUE_DUPLICATE_CODE') + expect(body.error.message).toBe('Ett värde med samma kod finns redan i dimensionen.') + }) + + it('creates a value (happy path, Swedish chars allowed in code)', async () => { + enqueue({ data: { id: 'dim-1' } }) + enqueue({ + data: { + id: 'v1', dimension_id: 'dim-1', code: 'GÖTEBORG', name: 'Göteborgskontoret', + is_active: true, start_date: null, end_date: null, + }, + }) + + const request = createMockRequest('/api/dimensions/dim-1/values', { + method: 'POST', + body: { code: 'GÖTEBORG', name: 'Göteborgskontoret' }, + }) + const response = await POST(request, params()) + const { status, body } = await parseJsonResponse<{ data: { id: string; code: string } }>(response) + + expect(status).toBe(200) + expect(body.data.id).toBe('v1') + expect(body.data.code).toBe('GÖTEBORG') + }) +}) diff --git a/app/api/dimensions/import-existing/route.ts b/app/api/dimensions/import-existing/route.ts new file mode 100644 index 00000000..a6a8f024 Binary files /dev/null and b/app/api/dimensions/import-existing/route.ts differ diff --git a/app/api/dimensions/route.ts b/app/api/dimensions/route.ts new file mode 100644 index 00000000..5897b089 --- /dev/null +++ b/app/api/dimensions/route.ts @@ -0,0 +1,103 @@ +/** + * GET /api/dimensions — the dimension registry (kostnadsställe/projekt + custom + * dims) with nested values, for the register page and pickers. + * + * Calls ensure_company_dimensions first so the system dims (1 = Kostnadsställe, + * 6 = Projekt) always exist — lazy seeding keeps core zero-config for companies + * that never touch dimensions (dev_docs/dimensions_implementation_plan.md §6). + * + * Response contract (PR2 — the register UI builds against this exactly): + * 200 { dimensions: [{ id, sie_dim_no, name, resets_annually, is_system, + * is_active, sort_order, values: [{ id, code, name, is_active, + * start_date, end_date }] }] } + * Dimensions sorted by sort_order, values by code. + */ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse } from '@/lib/errors/get-structured-error' + +ensureInitialized() + +interface DimensionValueRow { + id: string + dimension_id: string + code: string + name: string + is_active: boolean + start_date: string | null + end_date: string | null +} + +interface DimensionRow { + id: string + sie_dim_no: number + name: string + resets_annually: boolean + is_system: boolean + is_active: boolean + sort_order: number +} + +export const GET = withRouteContext( + 'dimension.list', + async (_request, ctx) => { + // dimensions_enabled is deliberately NOT enforced here — it is a + // UI-visibility flag only (dev_docs/dimensions_implementation_plan.md §2). + // Agents/MCP and SIE import must operate on the registry regardless of the + // toggle; the security boundary is company scoping (withRouteContext + RLS). + const { supabase, companyId, log, requestId } = ctx + + 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: dims, error: dimsError } = await supabase + .from('dimensions') + .select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order') + .eq('company_id', companyId) + .order('sort_order', { ascending: true }) + .order('sie_dim_no', { ascending: true }) + + if (dimsError) { + log.error('dimension list failed', dimsError) + return errorResponse(dimsError, log, { requestId }) + } + + const { data: values, error: valuesError } = await supabase + .from('dimension_values') + .select('id, dimension_id, code, name, is_active, start_date, end_date') + .eq('company_id', companyId) + .order('code', { ascending: true }) + + if (valuesError) { + log.error('dimension value list failed', valuesError) + return errorResponse(valuesError, log, { requestId }) + } + + const valuesByDimension = new Map[]>() + for (const v of (values ?? []) as DimensionValueRow[]) { + const bucket = valuesByDimension.get(v.dimension_id) ?? [] + bucket.push({ + id: v.id, + code: v.code, + name: v.name, + is_active: v.is_active, + start_date: v.start_date, + end_date: v.end_date, + }) + valuesByDimension.set(v.dimension_id, bucket) + } + + const dimensions = ((dims ?? []) as DimensionRow[]).map((d) => ({ + ...d, + values: valuesByDimension.get(d.id) ?? [], + })) + + return NextResponse.json({ dimensions }) + }, +) diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index ff1ceb34..079112a6 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -7,6 +7,7 @@ import { createLogger } from '@/lib/logger' import { checkRateLimit } from '@/lib/auth/rate-limit-http' import { truncateIp } from '@/lib/api/v1/with-api-v1' import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent' +import { lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver' // Anonymous sign-in is enabled in all environments so visitors can try the // product; a per-/24 cap on the seed endpoint keeps a single network from @@ -146,6 +147,8 @@ export async function POST(request: Request) { onboarding_step: 6, onboarding_complete: true, is_sandbox: true, + // Dimensions demo — the register/pickers render out of the box. + dimensions_enabled: true, }) if (settingsError) throw settingsError @@ -157,6 +160,39 @@ export async function POST(request: Request) { }) if (coaError) throw coaError + // 3b. Seed demo dimensions (kostnadsställe/projekt). ensure_company_dimensions + // lazily creates the system dims 1/6; two values per dim give the register, + // pickers, and the dimension-tagged journal lines below something to show. + const { error: dimsRpcError } = await supabase.rpc('ensure_company_dimensions', { + p_company_id: companyId, + }) + if (dimsRpcError) throw dimsRpcError + + const { data: demoDims, error: demoDimsError } = await supabase + .from('dimensions') + .select('id, sie_dim_no') + .eq('company_id', companyId) + .in('sie_dim_no', [1, 6]) + if (demoDimsError) throw demoDimsError + + const dimIdByNo = Object.fromEntries( + (demoDims ?? []).map(d => [d.sie_dim_no as number, d.id as string]) + ) as Record + + if (dimIdByNo[1] && dimIdByNo[6]) { + const seededDimensionCodes = ['BUTIK', 'WEBB', 'P001', 'P002'] + const { error: dimValuesError } = await supabase + .from('dimension_values') + .insert([ + { company_id: companyId, dimension_id: dimIdByNo[1], code: 'BUTIK', name: 'Butiken' }, + { company_id: companyId, dimension_id: dimIdByNo[1], code: 'WEBB', name: 'Webbshoppen' }, + { company_id: companyId, dimension_id: dimIdByNo[6], code: 'P001', name: 'Projekt Björk' }, + { company_id: companyId, dimension_id: dimIdByNo[6], code: 'P002', name: 'Projekt Alm' }, + ]) + if (dimValuesError) throw dimValuesError + log.info('seeded sandbox dimension values', { companyId, codes: seededDimensionCodes }) + } + // 4. Create fiscal period (current year) const currentYear = new Date().getFullYear() const { data: fiscalPeriod, error: fpError } = await supabase @@ -424,11 +460,20 @@ export async function POST(request: Request) { if (je2Error) throw je2Error - // 10. Create journal entry lines + // 10. Create journal entry lines. The P&L line carries demo dimensions + // ({"1":"BUTIK","6":"P001"}) so the register's "antal taggade rader", + // voucher-detail badges, and the dimension P&L report light up in the + // sandbox. Mirror columns derived via lineDimensionColumns — never set + // independently of the dimensions map. + const revenueDims = { '1': 'BUTIK', '6': 'P001' } const { error: jelError } = await supabase .from('journal_entry_lines') .insert([ // JE1: Invoice creation — Debit AR, Credit Revenue + VAT + // NB: `dimensions` must be set explicitly on EVERY row — same PostgREST + // bulk-insert normalization as paid_amount below: omitting it on some + // rows while one row sets it sends null (violating NOT NULL) instead + // of falling through to the schema default '{}'. { journal_entry_id: je1.id, account_number: '1510', @@ -436,6 +481,7 @@ export async function POST(request: Request) { debit_amount: 18750, credit_amount: 0, sort_order: 0, + dimensions: {}, }, { journal_entry_id: je1.id, @@ -444,6 +490,8 @@ export async function POST(request: Request) { debit_amount: 0, credit_amount: 15000, sort_order: 1, + dimensions: revenueDims, + ...lineDimensionColumns(revenueDims), }, { journal_entry_id: je1.id, @@ -452,6 +500,7 @@ export async function POST(request: Request) { debit_amount: 0, credit_amount: 3750, sort_order: 2, + dimensions: {}, }, // JE2: Invoice payment — Debit Bank, Credit AR { @@ -461,6 +510,7 @@ export async function POST(request: Request) { debit_amount: 18750, credit_amount: 0, sort_order: 0, + dimensions: {}, }, { journal_entry_id: je2.id, @@ -469,6 +519,7 @@ export async function POST(request: Request) { debit_amount: 0, credit_amount: 18750, sort_order: 1, + dimensions: {}, }, ]) diff --git a/app/api/v1/companies/[companyId]/dimensions/[id]/values/route.ts b/app/api/v1/companies/[companyId]/dimensions/[id]/values/route.ts new file mode 100644 index 00000000..5f9bef39 --- /dev/null +++ b/app/api/v1/companies/[companyId]/dimensions/[id]/values/route.ts @@ -0,0 +1,166 @@ +/** + * POST /api/v1/companies/{companyId}/dimensions/{id}/values + * + * Create a dimension value (SIE #OBJEKT). Idempotent (mandatory + * Idempotency-Key), dry-runnable. Codes follow the strict Fortnox format + * (^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$) for user-created values; the DB CHECK is + * looser by design so legacy free-text codes survive imports. `code` is + * immutable after creation. + */ +import { z } from 'zod' +import { created } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { CreateDimensionValueSchema } from '@/lib/api/schemas' + +const DimensionValueCreated = z.object({ + id: z.string().uuid().nullable(), + dimension_id: z.string().uuid(), + code: z.string(), + name: z.string(), + is_active: z.boolean(), + start_date: z.string().nullable(), + end_date: z.string().nullable(), + created_at: z.string().nullable(), +}) + +registerEndpoint({ + operation: 'dimensions.values.create', + method: 'POST', + path: '/api/v1/companies/:companyId/dimensions/:id/values', + summary: 'Create a dimension value (kostnadsställe/projekt code).', + description: + 'Registers a new value (SIE #OBJEKT) under a dimension — e.g. a new project code under dimension 6. Requires Idempotency-Key (UUID). Supports ?dry_run=true to validate the code format without committing. The `:id` path segment is the dimension row id (from GET …/dimensions), not the sie_dim_no. Duplicate codes within the dimension return 409 DIMENSION_VALUE_DUPLICATE_CODE.', + useWhen: + 'A voucher or invoice references a cost centre / project code that does not exist yet and the user has confirmed it should be created.', + doNotUseFor: + 'Renaming or archiving an existing value (dashboard register in v1). Tagging lines — pass the dimensions map on the journal-entry line instead.', + pitfalls: [ + 'Idempotency-Key is mandatory — calls without it return 400 VALIDATION_ERROR.', + 'The :id segment is the dimension UUID, not the SIE dimension number.', + 'Codes are limited to the strict Fortnox charset (A–Ö, digits, _, +, -; max 20 chars) even though historical imported codes may be looser.', + 'code is immutable after creation — there is no rename in v1; create the correct code and archive the wrong one.', + ], + example: { + request: { code: 'P001', name: 'Villa Almgren tak' }, + response: { + data: { + id: '0e9c…', + dimension_id: 'a8f1…', + code: 'P001', + name: 'Villa Almgren tak', + is_active: true, + start_date: null, + end_date: null, + created_at: '2026-07-02T12:00:00Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: CreateDimensionValueSchema }, + response: { success: dataEnvelope(DimensionValueCreated) }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'dimensions.values.create', + async (request, ctx, { params }) => { + const { id } = await params + + if (!z.string().uuid().safeParse(id).success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Dimension id must be a UUID.' }, + }) + } + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + const parsed = CreateDimensionValueSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const body = parsed.data + + // The dimension must exist and belong to the company in the URL. + const { data: dimension, error: dimError } = await ctx.supabase + .from('dimensions') + .select('id') + .eq('id', id) + .eq('company_id', ctx.companyId!) + .maybeSingle() + if (dimError) { + return v1ErrorResponse(dimError, ctx.log, { requestId: ctx.requestId }) + } + if (!dimension) { + return v1ErrorResponseFromCode('DIMENSION_NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { dimension_id: id }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { + id: null, + dimension_id: id, + code: body.code, + name: body.name, + is_active: true, + start_date: body.start_date ?? null, + end_date: body.end_date ?? null, + created_at: null, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const { data, error } = await ctx.supabase + .from('dimension_values') + .insert({ + company_id: ctx.companyId!, + dimension_id: id, + code: body.code, + name: body.name, + start_date: body.start_date ?? null, + end_date: body.end_date ?? null, + }) + .select('id, dimension_id, code, name, is_active, start_date, end_date, created_at') + .single() + + if (error) { + if (error.code === '23505') { + return v1ErrorResponseFromCode('DIMENSION_VALUE_DUPLICATE_CODE', ctx.log, { + requestId: ctx.requestId, + details: { code: body.code }, + }) + } + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + return created(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/dimensions/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/dimensions/__tests__/route.test.ts new file mode 100644 index 00000000..52353b0d --- /dev/null +++ b/app/api/v1/companies/[companyId]/dimensions/__tests__/route.test.ts @@ -0,0 +1,327 @@ +/** + * Integration tests for the v1 dimensions surface (dimensions PR2): + * GET /api/v1/companies/:companyId/dimensions + * POST /api/v1/companies/:companyId/dimensions/:id/values + * + * Mirrors the suppliers/accounts test pattern: a Proxy-backed Supabase mock + * returns whatever the route awaits, keyed by table name (plus an `rpc` key + * for ensure_company_dimensions). + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required') + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as listDimensions } from '../route' +import { POST as createValue } from '../[id]/values/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +interface TableResp { + data?: unknown + error?: unknown +} + +/** + * Per-table queue mock: TableResp[] consumes one entry per await, then sticks + * on the last entry. The special key `rpc` answers `supabase.rpc(...)` calls. + */ +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (key: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(key) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (..._args: unknown[]) => buildChain(key) + }, + } + return new Proxy({}, handler) + } + return { + from: vi.fn((table: string) => buildChain(table)), + rpc: vi.fn(() => buildChain('rpc')), + } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const DIMENSION_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + +function makeRequest(url: string, init?: RequestInit): Request { + return new Request(url, { + ...init, + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Idempotency-Key': 'c1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ...(init?.headers ?? {}), + }, + }) +} + +const SAMPLE_DIMS = [ + { + id: DIMENSION_ID, + sie_dim_no: 1, + name: 'Kostnadsställe', + resets_annually: true, + is_system: true, + is_active: true, + sort_order: 10, + }, + { + id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + sie_dim_no: 6, + name: 'Projekt', + resets_annually: false, + is_system: true, + is_active: true, + sort_order: 20, + }, +] + +const SAMPLE_VALUE = { + id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + dimension_id: DIMENSION_ID, + code: 'BUTIK', + name: 'Butiken', + is_active: true, + start_date: null, + end_date: null, +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['reports:read', 'bookkeeping:write'], + mode: 'live', + }) +}) + +describe('GET /api/v1/companies/:companyId/dimensions', () => { + it('ensures system dims and returns the registry with nested values', async () => { + const client = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + rpc: { data: null, error: null }, + dimensions: { data: SAMPLE_DIMS, error: null }, + dimension_values: { data: [SAMPLE_VALUE], error: null }, + }) + mockServiceClient.mockReturnValue(client) + + const res = await listDimensions( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/dimensions`), + { params: Promise.resolve({ companyId: COMPANY_ID }) }, + ) + + expect(res.status).toBe(200) + expect(client.rpc).toHaveBeenCalledWith('ensure_company_dimensions', { + p_company_id: COMPANY_ID, + }) + const body = await res.json() + expect(body.data.dimensions).toHaveLength(2) + expect(body.data.dimensions[0].sie_dim_no).toBe(1) + expect(body.data.dimensions[0].values).toEqual([ + { + id: SAMPLE_VALUE.id, + code: 'BUTIK', + name: 'Butiken', + is_active: true, + start_date: null, + end_date: null, + }, + ]) + expect(body.data.dimensions[1].values).toEqual([]) + }) + + it('rejects keys without reports:read scope (403)', async () => { + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + scopes: ['invoices:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await listDimensions( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/dimensions`), + { params: Promise.resolve({ companyId: COMPANY_ID }) }, + ) + + expect(res.status).toBe(403) + }) +}) + +describe('POST /api/v1/companies/:companyId/dimensions/:id/values', () => { + const url = `https://x.test/api/v1/companies/${COMPANY_ID}/dimensions/${DIMENSION_ID}/values` + const detailParams = { params: Promise.resolve({ companyId: COMPANY_ID, id: DIMENSION_ID }) } + + it('creates a value (201, happy path)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimensions: { data: { id: DIMENSION_ID }, error: null }, + dimension_values: { data: { ...SAMPLE_VALUE, created_at: '2026-07-02T12:00:00Z' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createValue( + makeRequest(url, { + method: 'POST', + body: JSON.stringify({ code: 'BUTIK', name: 'Butiken' }), + }), + detailParams, + ) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.code).toBe('BUTIK') + }) + + it('returns 400 when Idempotency-Key is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const req = new Request(url, { + method: 'POST', + headers: { Authorization: 'Bearer test' }, + body: JSON.stringify({ code: 'BUTIK', name: 'Butiken' }), + }) + const res = await createValue(req, detailParams) + + expect(res.status).toBe(400) + }) + + it('rejects a non-Fortnox code with 400 VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createValue( + makeRequest(url, { + method: 'POST', + body: JSON.stringify({ code: 'BAD CODE!', name: 'x' }), + }), + detailParams, + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 404 DIMENSION_NOT_FOUND for a dimension outside the company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimensions: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createValue( + makeRequest(url, { + method: 'POST', + body: JSON.stringify({ code: 'BUTIK', name: 'Butiken' }), + }), + detailParams, + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('DIMENSION_NOT_FOUND') + }) + + it('returns 409 DIMENSION_VALUE_DUPLICATE_CODE on a 23505', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimensions: { data: { id: DIMENSION_ID }, error: null }, + dimension_values: { data: null, error: { code: '23505', message: 'duplicate' } }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createValue( + makeRequest(url, { + method: 'POST', + body: JSON.stringify({ code: 'BUTIK', name: 'Butiken' }), + }), + detailParams, + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('DIMENSION_VALUE_DUPLICATE_CODE') + }) + + it('returns a dry-run preview without inserting when ?dry_run=true', async () => { + const fromSpy = vi.fn() + mockServiceClient.mockReturnValue({ + from: (table: string) => { + fromSpy(table) + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + const data = + table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : table === 'dimensions' + ? { id: DIMENSION_ID } + : null + return (resolve: (v: unknown) => void) => resolve({ data, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + }, + rpc: vi.fn(), + }) + + const res = await createValue( + makeRequest(`${url}?dry_run=true`, { + method: 'POST', + body: JSON.stringify({ code: 'P001', name: 'Villa Almgren tak' }), + }), + detailParams, + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + expect(fromSpy).not.toHaveBeenCalledWith('dimension_values') + const body = await res.json() + expect(body.data.dry_run).toBe(true) + expect(body.data.preview.code).toBe('P001') + }) +}) diff --git a/app/api/v1/companies/[companyId]/dimensions/route.ts b/app/api/v1/companies/[companyId]/dimensions/route.ts new file mode 100644 index 00000000..ad29abdb --- /dev/null +++ b/app/api/v1/companies/[companyId]/dimensions/route.ts @@ -0,0 +1,149 @@ +/** + * GET /api/v1/companies/{companyId}/dimensions + * + * List the dimension registry (SIE #DIM) with nested values (#OBJEKT). + * Ensures the system dims (1 = Kostnadsställe, 6 = Projekt) exist via the + * ensure_company_dimensions RPC before reading — lazy seeding, so the list is + * never empty even for companies that have not touched dimensions. + */ +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse } from '@/lib/api/v1/errors' + +const DimensionValue = z.object({ + id: z.string().uuid(), + code: z.string(), + name: z.string(), + is_active: z.boolean(), + start_date: z.string().nullable(), + end_date: z.string().nullable(), +}) + +const Dimension = z.object({ + id: z.string().uuid(), + sie_dim_no: z.number().int().min(1), + name: z.string(), + resets_annually: z.boolean(), + is_system: z.boolean(), + is_active: z.boolean(), + sort_order: z.number().int(), + values: z.array(DimensionValue), +}) + +const DimensionsResponse = dataEnvelope(z.object({ dimensions: z.array(Dimension) })) + +registerEndpoint({ + operation: 'dimensions.list', + method: 'GET', + path: '/api/v1/companies/:companyId/dimensions', + summary: 'List dimensions (kostnadsställe/projekt) with their values.', + description: + 'Returns the company\'s dimension registry — SIE #DIM entries keyed by sie_dim_no (1 = Kostnadsställe, 6 = Projekt; both always exist) — with the registered values (#OBJEKT) nested under each dimension. Dimensions are ordered by sort_order, values by code. Line-level tags on journal entries reference these values as {"":""} in the `dimensions` map.', + useWhen: + 'You need the valid dimension value codes before tagging journal-entry lines with a cost centre or project, or you are rendering a dimension picker.', + doNotUseFor: + 'Filtering reports (pass the dimension filter to the report endpoints once available) or reading which lines carry a tag (read the journal entries themselves).', + pitfalls: [ + 'Dimension value codes are STRINGS and case-sensitive — "P001", not 1.', + 'sie_dim_no is the key used in journal_entry_lines.dimensions, NOT the dimension row id.', + 'is_active=false values are historical (archived) — do not tag new lines with them.', + 'resets_annually=true (dim 1) means balances reset each fiscal year; dim 6 (projekt) accumulates across years.', + ], + example: { + response: { + data: { + dimensions: [ + { + id: '0e9c…', + sie_dim_no: 1, + name: 'Kostnadsställe', + resets_annually: true, + is_system: true, + is_active: true, + sort_order: 10, + values: [ + { + id: 'a8f1…', + code: 'BUTIK', + name: 'Butiken', + is_active: true, + start_date: null, + end_date: null, + }, + ], + }, + ], + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'reports:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: DimensionsResponse }, +}) + +interface DimensionValueRow { + id: string + dimension_id: string + code: string + name: string + is_active: boolean + start_date: string | null + end_date: string | null +} + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'dimensions.list', + async (_request, ctx) => { + const { error: ensureError } = await ctx.supabase.rpc('ensure_company_dimensions', { + p_company_id: ctx.companyId!, + }) + if (ensureError) { + return v1ErrorResponse(ensureError, ctx.log, { requestId: ctx.requestId }) + } + + const { data: dims, error: dimsError } = await ctx.supabase + .from('dimensions') + .select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order') + .eq('company_id', ctx.companyId!) + .order('sort_order', { ascending: true }) + .order('sie_dim_no', { ascending: true }) + if (dimsError) { + return v1ErrorResponse(dimsError, ctx.log, { requestId: ctx.requestId }) + } + + const { data: values, error: valuesError } = await ctx.supabase + .from('dimension_values') + .select('id, dimension_id, code, name, is_active, start_date, end_date') + .eq('company_id', ctx.companyId!) + .order('code', { ascending: true }) + if (valuesError) { + return v1ErrorResponse(valuesError, ctx.log, { requestId: ctx.requestId }) + } + + const valuesByDimension = new Map[]>() + for (const v of ((values ?? []) as DimensionValueRow[])) { + const bucket = valuesByDimension.get(v.dimension_id) ?? [] + bucket.push({ + id: v.id, + code: v.code, + name: v.name, + is_active: v.is_active, + start_date: v.start_date, + end_date: v.end_date, + }) + valuesByDimension.set(v.dimension_id, bucket) + } + + const dimensions = ((dims ?? []) as Array & { id: string }>).map( + (d) => ({ ...d, values: valuesByDimension.get(d.id) ?? [] }), + ) + + return ok({ dimensions }, { requestId: ctx.requestId }) + }, +) diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 2d5f1781..6d54a1bd 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -30,6 +30,7 @@ import { HandCoins, Package, Tag, + Tags, ChevronsUpDown, Sparkles, } from 'lucide-react' @@ -68,6 +69,10 @@ interface DashboardNavProps { // pays_salaries). Drives visibility of the payroll (Personal) section for // non-aktiebolag — notably an enskild firma that hires staff. See #782. paysSalaries?: boolean + // Whether the dimensions register (company_settings.dimensions_enabled) is + // switched on. Drives visibility of the Kostnadsställen & projekt row — + // same mechanism as paysSalaries: fetched by the dashboard layout. + dimensionsEnabled?: boolean uncategorizedTransactionCount?: number pendingOperationsCount?: number isSandbox?: boolean @@ -94,6 +99,7 @@ type NavLabelKey = | 'transactions' | 'bookkeeping' | 'chart_of_accounts' + | 'dimensions' | 'assets' | 'reports' | 'import' @@ -123,6 +129,10 @@ interface NavItem { // behaviour) plus any company that has registered as an employer via // company_settings.pays_salaries (e.g. an enskild firma with staff). #782 employerOnly?: boolean + // Dimension surfaces — visible only when the company has opted in via + // company_settings.dimensions_enabled (UI-visibility gate only; the pages + // and APIs work regardless — dimensions plan §2). + requiresDimensions?: boolean hidden?: boolean comingSoon?: boolean devBadge?: boolean @@ -147,6 +157,7 @@ const navItems: NavItem[] = [ { href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'redovisning' }, { href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' }, { href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'redovisning' }, + { href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'redovisning', requiresDimensions: true }, { href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' }, { href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' }, { href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' }, @@ -185,7 +196,7 @@ function accountInitial(name: string | null, email: string | null): string { return '?' } -export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) { +export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) { const pathname = usePathname() const router = useRouter() const supabase = useRealtimeSupabase() @@ -351,6 +362,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa // Payroll (employerOnly) is hidden until the company is an employer — an // aktiebolag, or any entity that has flagged pays_salaries. #782 if (item.employerOnly && !isEmployer) return false + // Dimension surfaces are hidden until the company opts in via the + // bookkeeping settings toggle (company_settings.dimensions_enabled). + if (item.requiresDimensions && !dimensionsEnabled) return false // Hide the Assistent (/chat) tab until the agent is built — mirrors the // floating AgentTrigger and avoids a nav entry that only bounces to the // home checklist (chat/layout redirects unverified users to /). diff --git a/components/dimensions/DimensionCombobox.tsx b/components/dimensions/DimensionCombobox.tsx new file mode 100644 index 00000000..9e21e047 --- /dev/null +++ b/components/dimensions/DimensionCombobox.tsx @@ -0,0 +1,342 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Loader2, Plus } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { + DIMENSION_CODE_PATTERN, + fetchDimensions, + type DimensionValueDto, +} from '@/components/dimensions/types' + +interface DimensionComboboxProps { + /** SIE dimension number as a string ('1' = kostnadsställe, '6' = projekt). */ + sieDimNo: string + /** Selected object code, or null when the line carries no value for this dim. */ + value: string | null + onChange: (code: string | null) => void + disabled?: boolean + /** Extra classes merged into the trigger Input (callers pass `h-8` for dense rows). */ + className?: string +} + +/** + * Searchable picker for dimension values — sibling of + * components/bookkeeping/AccountCombobox.tsx and deliberately mirrors its + * interaction model: plain Input trigger, keyboard-first dropdown + * (arrows/Enter/Escape), close-on-outside-click, and an inline "Skapa ny…" + * affordance that creates the value via POST /api/dimensions/[id]/values and + * selects it. + * + * Fetches the registry lazily on first open and filters client-side (value + * lists are small). Only active values are offered — archived codes stay + * pickable in history but are never suggested. + * + * Strings are hardcoded Swedish per the AccountCombobox convention: the + * component mounts on the voucher editor (PR3), a stays-Swedish surface per + * .claude/rules/i18n.md. + */ +export default function DimensionCombobox({ + sieDimNo, + value, + onChange, + disabled, + className, +}: DimensionComboboxProps) { + const [search, setSearch] = useState(value ?? '') + const [isOpen, setIsOpen] = useState(false) + const [highlightedIndex, setHighlightedIndex] = useState(0) + const [loadState, setLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle') + const [dimensionId, setDimensionId] = useState(null) + const [values, setValues] = useState([]) + const [isCreating, setIsCreating] = useState(false) + const [createError, setCreateError] = useState(null) + const containerRef = useRef(null) + const listRef = useRef(null) + + // Refs mirroring the committed `value` prop and the fetched values. The + // blur timeout below runs 150ms after render, so reading `value`/`values` + // directly would act on a stale snapshot — a selection landing during that + // window (updating the prop via onChange) must win over the revert. + const committedRef = useRef(value) + const valuesRef = useRef(values) + + // Sync external value changes into the search field + useEffect(() => { + committedRef.current = value + setSearch(value ?? '') + }, [value]) + + useEffect(() => { + valuesRef.current = values + }, [values]) + + const loadValues = useCallback(async () => { + setLoadState('loading') + try { + const dims = await fetchDimensions() + const dim = dims.find((d) => String(d.sie_dim_no) === sieDimNo) + setDimensionId(dim?.id ?? null) + setValues(dim?.values.filter((v) => v.is_active) ?? []) + setLoadState('loaded') + } catch { + setLoadState('error') + } + }, [sieDimNo]) + + const openDropdown = useCallback(() => { + setIsOpen(true) + setCreateError(null) + if (loadState === 'idle') void loadValues() + }, [loadState, loadValues]) + + const filteredValues = useMemo(() => { + const term = search.trim().toLowerCase() + if (!term) return values + return values.filter( + (v) => + v.code.toLowerCase().includes(term) || v.name.toLowerCase().includes(term), + ) + }, [values, search]) + + // Inline create is offered when the typed text is a valid new code. + const createCandidate = useMemo(() => { + const term = search.trim() + if (!term || !dimensionId) return null + if (!DIMENSION_CODE_PATTERN.test(term)) return null + if (values.some((v) => v.code.toLowerCase() === term.toLowerCase())) return null + return term + }, [search, values, dimensionId]) + + // Keyboard list: matching values first, the create affordance last. + const optionCount = filteredValues.length + (createCandidate ? 1 : 0) + + useEffect(() => { + setHighlightedIndex(0) + }, [filteredValues, createCandidate]) + + useEffect(() => { + if (!isOpen || !listRef.current) return + const highlighted = listRef.current.querySelector('[data-highlighted="true"]') + if (highlighted) highlighted.scrollIntoView({ block: 'nearest' }) + }, [highlightedIndex, isOpen]) + + useEffect(() => { + function handleClickOutside(e: MouseEvent | TouchEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('touchstart', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('touchstart', handleClickOutside) + } + }, []) + + const selectValue = useCallback( + (code: string) => { + onChange(code) + setSearch(code) + setIsOpen(false) + }, + [onChange], + ) + + const createValue = useCallback( + async (code: string) => { + if (!dimensionId || isCreating) return + setIsCreating(true) + setCreateError(null) + try { + const res = await fetch(`/api/dimensions/${dimensionId}/values`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, name: code }), + }) + const json = await res.json().catch(() => null) + if (!res.ok) { + setCreateError(getErrorMessage(json, { locale: 'sv' })) + return + } + const created: DimensionValueDto = json?.data ?? { + id: code, + code, + name: code, + is_active: true, + start_date: null, + end_date: null, + } + setValues((prev) => [...prev, created].sort((a, b) => a.code.localeCompare(b.code, 'sv'))) + selectValue(created.code) + } finally { + setIsCreating(false) + } + }, + [dimensionId, isCreating, selectValue], + ) + + const activateOption = useCallback( + (index: number) => { + if (index < filteredValues.length) { + selectValue(filteredValues[index].code) + } else if (createCandidate) { + void createValue(createCandidate) + } + }, + [filteredValues, createCandidate, selectValue, createValue], + ) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + openDropdown() + e.preventDefault() + } + return + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setHighlightedIndex((prev) => Math.min(prev + 1, optionCount - 1)) + break + case 'ArrowUp': + e.preventDefault() + setHighlightedIndex((prev) => Math.max(prev - 1, 0)) + break + case 'Enter': + e.preventDefault() + if (optionCount > 0) activateOption(highlightedIndex) + break + case 'Escape': + e.preventDefault() + setIsOpen(false) + break + } + } + + const handleInputChange = (e: React.ChangeEvent) => { + setSearch(e.target.value) + setCreateError(null) + if (!isOpen) openDropdown() + } + + const handleBlur = () => { + setIsOpen(false) + // Small delay so a dropdown mousedown fires first (same trick as + // AccountCombobox — option clicks preventDefault, so they never blur). + // An emptied field clears the dimension; anything that isn't a known + // code reverts to the committed value. Committed value and values are + // read through refs so a selection that lands during the 150ms window + // wins over the revert (the closure's render snapshot would be stale). + const snapshot = search + setTimeout(() => { + const committed = committedRef.current + const trimmed = snapshot.trim() + if (!trimmed) { + setSearch('') + if (committed !== null) onChange(null) + return + } + if (trimmed !== committed && !valuesRef.current.some((v) => v.code === trimmed)) { + setSearch(committed ?? '') + } + }, 150) + } + + return ( +
+ + + {/* Dropdown */} + {isOpen && !disabled && ( +
+ {loadState === 'loading' && ( +
+ + Laddar… +
+ )} + {loadState === 'error' && ( +

+ Kunde inte hämta värden. +

+ )} + {loadState === 'loaded' && optionCount === 0 && ( +

+ Hittade inget värde som matchar. +

+ )} + {loadState === 'loaded' && + filteredValues.map((item, index) => { + const isHighlighted = index === highlightedIndex + return ( + + ) + })} + {loadState === 'loaded' && createCandidate && ( + + )} + {createError && ( +

+ {createError} +

+ )} +
+ )} +
+ ) +} diff --git a/components/dimensions/DimensionValueForm.tsx b/components/dimensions/DimensionValueForm.tsx new file mode 100644 index 00000000..7c4ba8ea --- /dev/null +++ b/components/dimensions/DimensionValueForm.tsx @@ -0,0 +1,223 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog' +import { Loader2, Trash2 } from 'lucide-react' +import { + DIMENSION_CODE_PATTERN, + PROJECT_DIM_NO, + type DimensionDto, + type DimensionValueDto, +} from '@/components/dimensions/types' + +export interface DimensionValueFormInput { + code: string + name: string + is_active: boolean + start_date: string | null + end_date: string | null +} + +interface DimensionValueFormProps { + /** The dimension the value belongs to (drives the Projekt date fields). */ + dimension: DimensionDto + /** When set, the form edits this value (code becomes immutable). */ + value?: DimensionValueDto | null + isSaving?: boolean + onSubmit: (input: DimensionValueFormInput) => void | Promise + /** Rendered only when editing. The caller performs the DELETE and surfaces + * the retention-trigger error ("…arkivera det istället") as a toast. */ + onDelete?: () => void | Promise +} + +/** + * Create/edit form for a dimension value (#OBJEKT), hosted in the + * DimensionsManager dialog. Code is immutable in v1 — the field is disabled + * when editing. Start/end dates appear only for Projekt (dim 6), matching the + * SIE model where projects span a date range while cost centres do not. + */ +export default function DimensionValueForm({ + dimension, + value, + isSaving = false, + onSubmit, + onDelete, +}: DimensionValueFormProps) { + const t = useTranslations('dimensions') + const isEditing = Boolean(value) + const isProject = dimension.sie_dim_no === PROJECT_DIM_NO + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false) + + const schema = useMemo( + () => + z + .object({ + // Legacy/backfilled codes can be looser than the strict format, so + // the pattern is only enforced when the code is user-typed (create). + code: isEditing + ? z.string() + : z.string().regex(DIMENSION_CODE_PATTERN, t('form_code_invalid')), + name: z.string().min(1, t('form_name_invalid')).max(120, t('form_name_invalid')), + is_active: z.boolean(), + start_date: z.string().optional(), + end_date: z.string().optional(), + }) + .refine( + (data) => + !data.start_date || !data.end_date || data.end_date >= data.start_date, + { message: t('form_date_order_invalid'), path: ['end_date'] }, + ), + [isEditing, t], + ) + + type FormData = z.infer + + const { + register, + handleSubmit, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + code: value?.code ?? '', + name: value?.name ?? '', + is_active: value?.is_active ?? true, + start_date: value?.start_date ?? '', + end_date: value?.end_date ?? '', + }, + }) + + function submit(data: FormData) { + return onSubmit({ + code: data.code.trim(), + name: data.name.trim(), + is_active: data.is_active, + start_date: isProject && data.start_date ? data.start_date : null, + end_date: isProject && data.end_date ? data.end_date : null, + }) + } + + return ( +
+
+
+ + +

+ {isEditing ? t('form_code_immutable_help') : t('form_code_help')} +

+ {errors.code && ( +

{errors.code.message}

+ )} +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+
+ + {isProject && ( +
+
+ + +
+
+ + + {errors.end_date && ( +

{errors.end_date.message}

+ )} +
+
+ )} + +
+
+ +

+ {t('form_active_help')} +

+
+ ( + + )} + /> +
+ +
+ {isEditing && onDelete ? ( + + ) : ( + + )} + +
+ + {onDelete && ( + + )} + + ) +} diff --git a/components/dimensions/DimensionsManager.tsx b/components/dimensions/DimensionsManager.tsx new file mode 100644 index 00000000..638604a3 --- /dev/null +++ b/components/dimensions/DimensionsManager.tsx @@ -0,0 +1,484 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useLocale, useTranslations } from 'next-intl' +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 { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { EmptyState } from '@/components/ui/empty-state' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { formatDate } from '@/lib/utils' +import { + Plus, + Search, + Lock, + Tags, + ChevronUp, + ChevronDown, + ChevronsUpDown, +} from 'lucide-react' +import DimensionValueForm, { + type DimensionValueFormInput, +} from '@/components/dimensions/DimensionValueForm' +import { + fetchDimensions, + PROJECT_DIM_NO, + type DimensionDto, + type DimensionValueDto, +} from '@/components/dimensions/types' + +type SortColumn = 'code' | 'name' | 'status' | 'start_date' | 'end_date' +type SortDir = 'asc' | 'desc' + +type DialogState = + | { mode: 'create' } + | { mode: 'edit'; value: DimensionValueDto } + | null + +function compareStrings(a: string, b: string): number { + return a.localeCompare(b, 'sv', { sensitivity: 'base' }) +} + +/** + * Register for dimension values (kostnadsställen & projekt) — the + * customers-page register recipe hosted behind one segmented tab per registry + * dimension. Archive rides the edit form's aktiv switch (PATCH is_active); + * delete lives only in the edit dialog and surfaces the DB retention + * trigger's Swedish "…arkivera det istället" message when the value is + * referenced by posted lines. + */ +export default function DimensionsManager() { + const t = useTranslations('dimensions') + const errorLocale = useLocale() as ErrorLocale + const { toast } = useToast() + const { canWrite } = useCanWrite() + + const [dimensions, setDimensions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [loadFailed, setLoadFailed] = useState(false) + const [activeDimId, setActiveDimId] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const [sortColumn, setSortColumn] = useState('code') + const [sortDir, setSortDir] = useState('asc') + const [dialog, setDialog] = useState(null) + const [isSaving, setIsSaving] = useState(false) + + const loadDimensions = useCallback( + async (showSpinner: boolean) => { + if (showSpinner) setIsLoading(true) + try { + const dims = await fetchDimensions() + const sorted = [...dims].sort( + (a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no, + ) + setDimensions(sorted) + setLoadFailed(false) + setActiveDimId((prev) => + prev && sorted.some((d) => d.id === prev) ? prev : (sorted[0]?.id ?? null), + ) + } catch (err) { + setLoadFailed(true) + toast({ + title: t('load_failed_title'), + description: getErrorMessage(err, { locale: errorLocale }), + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + }, + [toast, t, errorLocale], + ) + + useEffect(() => { + void loadDimensions(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const activeDim = useMemo( + () => dimensions.find((d) => d.id === activeDimId) ?? null, + [dimensions, activeDimId], + ) + const isProjectTab = activeDim?.sie_dim_no === PROJECT_DIM_NO + + const filteredValues = useMemo(() => { + if (!activeDim) return [] + const term = searchTerm.trim().toLowerCase() + if (!term) return activeDim.values + return activeDim.values.filter( + (v) => + v.code.toLowerCase().includes(term) || v.name.toLowerCase().includes(term), + ) + }, [activeDim, searchTerm]) + + const sortedValues = useMemo(() => { + const arr = [...filteredValues] + arr.sort((a, b) => { + let cmp = 0 + switch (sortColumn) { + case 'code': + cmp = compareStrings(a.code, b.code) + break + case 'name': + cmp = compareStrings(a.name, b.name) + break + case 'status': + cmp = Number(b.is_active) - Number(a.is_active) || compareStrings(a.code, b.code) + break + case 'start_date': + cmp = compareStrings(a.start_date ?? '', b.start_date ?? '') + break + case 'end_date': + cmp = compareStrings(a.end_date ?? '', b.end_date ?? '') + break + } + return sortDir === 'asc' ? cmp : -cmp + }) + return arr + }, [filteredValues, sortColumn, sortDir]) + + const updateSort = useCallback( + (column: SortColumn) => { + if (column === sortColumn) { + setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')) + } else { + setSortColumn(column) + setSortDir('asc') + } + }, + [sortColumn], + ) + + async function handleSubmitValue(input: DimensionValueFormInput) { + if (!activeDim || !dialog) return + setIsSaving(true) + try { + if (dialog.mode === 'edit') { + const res = await fetch( + `/api/dimensions/${activeDim.id}/values/${dialog.value.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: input.name, + is_active: input.is_active, + start_date: input.start_date, + end_date: input.end_date, + }), + }, + ) + const json = await res.json().catch(() => null) + if (!res.ok) throw json ?? new Error() + toast({ title: t('updated_title') }) + } else { + // "Create as archived" rides the create contract's is_active field — + // one atomic POST, no follow-up PATCH. + const body: Record = { + code: input.code, + name: input.name, + is_active: input.is_active, + } + if (input.start_date) body.start_date = input.start_date + if (input.end_date) body.end_date = input.end_date + const res = await fetch(`/api/dimensions/${activeDim.id}/values`, { + 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() + toast({ + title: t('created_title'), + description: t('created_description', { code: input.code }), + }) + } + setDialog(null) + await loadDimensions(false) + } catch (err) { + toast({ + title: t('save_failed_title'), + description: getErrorMessage(err, { locale: errorLocale }), + variant: 'destructive', + }) + } finally { + setIsSaving(false) + } + } + + async function handleDeleteValue() { + if (!activeDim || dialog?.mode !== 'edit') return + setIsSaving(true) + try { + const res = await fetch( + `/api/dimensions/${activeDim.id}/values/${dialog.value.id}`, + { method: 'DELETE' }, + ) + if (!res.ok) { + const json = await res.json().catch(() => null) + // Values referenced by posted lines cannot be deleted — the DB + // retention trigger's Swedish message ("…arkivera det istället") + // rides the error envelope; surface it verbatim. + toast({ + title: t('delete_failed_title'), + description: getErrorMessage(json, { locale: errorLocale }), + variant: 'destructive', + }) + return + } + toast({ title: t('deleted_title') }) + setDialog(null) + await loadDimensions(false) + } finally { + setIsSaving(false) + } + } + + function SortableHeader({ + column, + label, + className, + }: { + column: SortColumn + label: string + className?: string + }) { + const isActive = sortColumn === column + const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown + return ( + + + + ) + } + + function renderStatusBadge(value: DimensionValueDto) { + return value.is_active ? ( + {t('status_active')} + ) : ( + {t('status_archived')} + ) + } + + if (isLoading) { + return ( +
+ + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + +
+ ) + } + + if (loadFailed && dimensions.length === 0) { + return ( + + + void loadDimensions(true)} + /> + + + ) + } + + return ( +
+ {/* Segmented tabs — one per registry dimension (1 Kostnadsställe, 6 Projekt) */} +
+ { + setActiveDimId(id) + setSearchTerm('') + }} + > + + {dimensions.map((dim) => ( + + {dim.name} + + ))} + + + +
+ + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Value list */} + {sortedValues.length === 0 ? ( + + + {searchTerm ? ( + + ) : ( + setDialog({ mode: 'create' }) : undefined} + /> + )} + + + ) : ( + <> + {/* Desktop table */} + + + + + + + + + {isProjectTab && ( + <> + + + + )} + + + + {sortedValues.map((value) => ( + setDialog({ mode: 'edit', value })} + > + {value.code} + {value.name} + {renderStatusBadge(value)} + {isProjectTab && ( + <> + + {value.start_date ? formatDate(value.start_date) : '—'} + + + {value.end_date ? formatDate(value.end_date) : '—'} + + + )} + + ))} + +
+
+
+ + {/* Mobile card list */} +
+ {sortedValues.map((value) => ( + setDialog({ mode: 'edit', value })} + > + +
+
+

{value.code}

+

+ {value.name} +

+
+ {renderStatusBadge(value)} +
+
+ {isProjectTab && (value.start_date || value.end_date) && ( + +

+ {value.start_date ? formatDate(value.start_date) : '—'} + {' – '} + {value.end_date ? formatDate(value.end_date) : '—'} +

+
+ )} +
+ ))} +
+ + )} + + {/* Create/edit dialog */} + !open && setDialog(null)}> + + + + {dialog?.mode === 'edit' + ? t('edit_value_title') + : t('new_value_title', { dimension: activeDim?.name ?? '' })} + + + {activeDim && dialog && ( + + )} + + +
+ ) +} diff --git a/components/dimensions/types.ts b/components/dimensions/types.ts new file mode 100644 index 00000000..72ed43e5 --- /dev/null +++ b/components/dimensions/types.ts @@ -0,0 +1,56 @@ +/** + * Client-side contract types for the dimensions registry API (PR2 of + * dev_docs/dimensions_implementation_plan.md). + * + * The routes live under /api/dimensions and are built against the same locked + * contract — this module codes against the contract, not the route files, so + * the register UI (DimensionsManager) and the shared picker (DimensionCombobox) + * can ship independently of the API package. + */ + +export interface DimensionValueDto { + id: string + code: string + name: string + is_active: boolean + start_date: string | null + end_date: string | null +} + +export interface DimensionDto { + id: string + /** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt). */ + sie_dim_no: number + name: string + resets_annually: boolean + is_system: boolean + is_active: boolean + sort_order: number + /** Sorted by code by the API. */ + values: DimensionValueDto[] +} + +/** SIE dimension number whose values carry start/end dates (Projekt). */ +export const PROJECT_DIM_NO = 6 + +/** + * Strict Fortnox-compatible code format enforced by the API for user-created + * codes (the DB CHECK is deliberately looser so legacy free-text survives the + * backfill). Mirrored client-side for inline validation before POST. + */ +export const DIMENSION_CODE_PATTERN = /^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/ + +/** + * Load the company's dimension registry. The handler lazily seeds system dims + * 1/6 via ensure_company_dimensions, so the result always contains at least + * Kostnadsställe + Projekt. Throws the parsed error envelope on failure so + * callers can hand it straight to getErrorMessage(). + */ +export async function fetchDimensions(): Promise { + const res = await fetch('/api/dimensions') + const json = await res.json().catch(() => null) + if (!res.ok) { + throw json ?? new Error('Failed to load dimensions') + } + return (json?.dimensions ?? []) as DimensionDto[] +} diff --git a/components/settings/DimensionsToggle.tsx b/components/settings/DimensionsToggle.tsx new file mode 100644 index 00000000..a5e98228 --- /dev/null +++ b/components/settings/DimensionsToggle.tsx @@ -0,0 +1,123 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { useLocale, useTranslations } from 'next-intl' +import { ExternalLink } from 'lucide-react' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import { useSettings } from '@/components/settings/useSettings' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' + +/** + * Company-level toggle for the dimensions register (kostnadsställen & + * projekt). Persists company_settings.dimensions_enabled through the standard + * settings PUT — the flag gates UI visibility only (nav row + register), + * never correctness (dimensions plan §2). + * + * Toggling ON runs the "Importera befintliga koder" scan + * (POST /api/dimensions/import-existing): codes already present on + * journal_entry_lines.dimensions but missing from the registry are created as + * archived placeholder values, and the user is told how many were found. + */ +export function DimensionsToggle() { + const t = useTranslations('dimensions') + const errorLocale = useLocale() as ErrorLocale + const { settings, updateSettings } = useSettings() + const { canWrite } = useCanWrite() + const { toast } = useToast() + const [isSaving, setIsSaving] = useState(false) + + const enabled = settings?.dimensions_enabled ?? false + + async function handleChange(next: boolean) { + setIsSaving(true) + try { + const res = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dimensions_enabled: next }), + }) + const json = await res.json().catch(() => null) + if (!res.ok) { + toast({ + title: t('settings_save_failed_title'), + description: getErrorMessage(json, { locale: errorLocale }), + variant: 'destructive', + }) + return + } + updateSettings({ dimensions_enabled: next }) + + if (next) { + // Import scan: registry rows for codes already used on lines. Failure + // is non-fatal — the toggle stays on and the scan can be re-run by + // toggling again. + try { + const importRes = await fetch('/api/dimensions/import-existing', { + method: 'POST', + }) + const importJson = await importRes.json().catch(() => null) + if (importRes.ok) { + const created: number = + importJson?.created ?? importJson?.data?.created ?? 0 + if (created > 0) { + toast({ + title: t('settings_imported_toast_title'), + description: t('settings_imported_toast', { count: created }), + }) + } + } else { + toast({ + title: t('settings_import_failed_title'), + description: getErrorMessage(importJson, { locale: errorLocale }), + variant: 'destructive', + }) + } + } catch { + toast({ + title: t('settings_import_failed_title'), + variant: 'destructive', + }) + } + } + } finally { + setIsSaving(false) + } + } + + return ( +
+

+ {t('settings_heading')} +

+
+
+ +

+ {t('settings_toggle_help')} +

+
+ void handleChange(next)} + disabled={isSaving || !canWrite} + /> +
+ {enabled && ( + + + {t('settings_open_register')} + + )} +
+ ) +} diff --git a/components/settings/sections/BookkeepingSettingsContent.tsx b/components/settings/sections/BookkeepingSettingsContent.tsx index 7d84dc0a..6521dc65 100644 --- a/components/settings/sections/BookkeepingSettingsContent.tsx +++ b/components/settings/sections/BookkeepingSettingsContent.tsx @@ -12,6 +12,7 @@ import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm' import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver' import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle' +import { DimensionsToggle } from '@/components/settings/DimensionsToggle' import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm' import { useSettings } from '@/components/settings/useSettings' import { useCompany } from '@/contexts/CompanyContext' @@ -165,6 +166,11 @@ export function BookkeepingSettingsContent() { + {/* Kostnadsställen & projekt (dimensions) toggle */} +
+ +
+ {/* Cross-links */}

diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 571627b8..7685e2e7 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -713,6 +713,67 @@ export const CorrectJournalEntrySchema = z.object({ lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'), }) +// ============================================================ +// Dimension registry schemas (kostnadsställe/projekt) +// ============================================================ +// dev_docs/dimensions_implementation_plan.md §6. The registry tables +// (dimensions/dimension_values) shipped in 20260702084500_dimensions_substrate. + +/** + * Object code for USER-CREATED dimension values: strict Fortnox format. + * Deliberately tighter than both the DB CHECK (1..40 chars, no `"{}`') and + * DimensionsBagSchema (line-level values) — legacy free-text codes from the + * backfill/SIE import must survive on lines, but new registry codes minted + * through the API stay portable to Fortnox/Visma. + */ +const dimensionValueCode = z + .string() + .regex( + /^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/, + 'Koden får bara innehålla bokstäver (A–Ö), siffror, _, + och - (max 20 tecken)', + ) + +const dimensionValueDates = { + start_date: isoDate.nullable().optional(), + end_date: isoDate.nullable().optional(), +} + +/** PATCH /api/dimensions/[id] — name is rejected route-side for is_system dims. */ +export const UpdateDimensionSchema = z + .object({ + name: z.string().min(1).max(80).optional(), + is_active: z.boolean().optional(), + sort_order: z.number().int().min(0).optional(), + }) + .refine((body) => Object.values(body).some((v) => v !== undefined), { + message: 'Minst ett fält måste anges', + }) + +/** POST /api/dimensions/[id]/values — code is immutable after creation (v1: no rename). */ +export const CreateDimensionValueSchema = z + .object({ + code: dimensionValueCode, + name: z.string().min(1).max(120), + /** Omitted → true. Lets "create as archived" be a single atomic POST. */ + is_active: z.boolean().optional(), + ...dimensionValueDates, + }) + .refine( + (body) => !body.start_date || !body.end_date || body.end_date >= body.start_date, + { message: 'Slutdatum får inte vara före startdatum', path: ['end_date'] }, + ) + +/** PATCH /api/dimensions/[id]/values/[valueId] — no `code` field by design. */ +export const UpdateDimensionValueSchema = z + .object({ + name: z.string().min(1).max(120).optional(), + is_active: z.boolean().optional(), + ...dimensionValueDates, + }) + .refine((body) => Object.values(body).some((v) => v !== undefined), { + message: 'Minst ett fält måste anges', + }) + /** * Move a posted verifikation to a different date (and thereby fiscal period) * without changing its lines — fixes a booking entered with the wrong @@ -1172,6 +1233,9 @@ export const UpdateSettingsSchema = z.object({ .optional(), // AI agent flow ai_flow_enabled: z.boolean().optional(), + // Dimensions (kostnadsställe/projekt) — UI-visibility toggle only, never + // load-bearing for correctness (dev_docs/dimensions_implementation_plan.md §2). + dimensions_enabled: z.boolean().optional(), // Salary payment file preferred_payment_format: z.enum(['bg_lb', 'pain001']).optional(), }).refine( diff --git a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap index 20d961d8..97e00029 100644 --- a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `102`; +exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `104`; exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = ` [ @@ -14,6 +14,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key "GET /api/v1/companies/:companyId/compliance/check", "GET /api/v1/companies/:companyId/customers", "GET /api/v1/companies/:companyId/customers/:id", + "GET /api/v1/companies/:companyId/dimensions", "GET /api/v1/companies/:companyId/documents/:id/download", "GET /api/v1/companies/:companyId/employees", "GET /api/v1/companies/:companyId/employees/:id", @@ -60,6 +61,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key "PATCH /api/v1/companies/:companyId/webhooks/:id", "POST /api/v1/companies/:companyId/customers", "POST /api/v1/companies/:companyId/customers/bulk-create", + "POST /api/v1/companies/:companyId/dimensions/:id/values", "POST /api/v1/companies/:companyId/documents", "POST /api/v1/companies/:companyId/documents/:id/link", "POST /api/v1/companies/:companyId/employees", diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 84b3109d..454d7900 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -132,4 +132,8 @@ import '@/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route' // Inbox item stamp. import '@/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route' +// Dimensions PR2 — registry list + value creation (kostnadsställe/projekt). +import '@/app/api/v1/companies/[companyId]/dimensions/route' +import '@/app/api/v1/companies/[companyId]/dimensions/[id]/values/route' + export {} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index c3699a3c..825334df 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -181,6 +181,12 @@ export const V1_ENDPOINT_SCOPES: Record = { 'POST /api/v1/companies/:companyId/salary-runs/:id/book': 'payroll:write', 'POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi': 'payroll:write', + // Dimensions (kostnadsställe/projekt) — dimensions PR2. Reads ride + // reports:read (registry data feeds report filters/pickers); value creation + // is bookkeeping:write (it mints codes that journal lines reference). + 'GET /api/v1/companies/:companyId/dimensions': 'reports:read', + 'POST /api/v1/companies/:companyId/dimensions/:id/values': 'bookkeeping:write', + // Webhooks (Phase 6 PR-1) 'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage', 'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage', diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 4c7eb555..5d0a9c7b 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2462,6 +2462,75 @@ const ASSETS: Record = { }, } +// Dimensions registry (kostnadsställe/projekt) — dev_docs/dimensions_implementation_plan.md §6 +const DIMENSION: Record = { + DIMENSION_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Dimensionen kunde inte hittas.', + message_en: 'Dimension not found.', + }, + DIMENSION_SYSTEM_RENAME: { + httpStatus: 400, + message_sv: 'Systemdimensioner kan inte döpas om.', + message_en: 'System dimensions (kostnadsställe/projekt) cannot be renamed.', + }, + DIMENSION_UPDATE_FAILED: { + httpStatus: 500, + message_sv: 'Dimensionen kunde inte uppdateras.', + message_en: 'Failed to update dimension.', + }, + DIMENSION_VALUE_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Dimensionsvärdet kunde inte hittas.', + message_en: 'Dimension value not found.', + }, + DIMENSION_VALUE_DUPLICATE_CODE: { + httpStatus: 409, + message_sv: 'Ett värde med samma kod finns redan i dimensionen.', + message_en: 'A value with that code already exists in the dimension.', + }, + DIMENSION_VALUE_DATES_NOT_ALLOWED: { + httpStatus: 400, + message_sv: 'Datum kan bara sättas på ackumulerande dimensioner (t.ex. projekt).', + message_en: + 'Start/end dates can only be set on accumulating dimensions (e.g. projects) — this dimension resets annually.', + }, + DIMENSION_VALUE_CREATE_FAILED: { + httpStatus: 500, + message_sv: 'Dimensionsvärdet kunde inte skapas.', + message_en: 'Failed to create dimension value.', + }, + DIMENSION_VALUE_UPDATE_FAILED: { + httpStatus: 500, + message_sv: 'Dimensionsvärdet kunde inte uppdateras.', + message_en: 'Failed to update dimension value.', + }, + // The DB retention trigger (enforce_dimension_value_retention) raises when a + // code is referenced by posted/reversed lines. Routes surface the trigger's + // own Swedish message via `messageSv` so the code + kod appear in the toast. + DIMENSION_VALUE_REFERENCED: { + httpStatus: 409, + message_sv: + 'Värdet används på bokförda verifikat och kan inte tas bort — arkivera det istället.', + message_en: + 'The value is referenced by posted vouchers and cannot be deleted — archive (inactivate) it instead.', + remediation: { + description: + 'Archive the value instead: PATCH the dimension value with { "is_active": false }. Codes referenced by posted lines are retained for the BFL 7-year period.', + }, + }, + DIMENSION_VALUE_DELETE_FAILED: { + httpStatus: 500, + message_sv: 'Dimensionsvärdet kunde inte tas bort.', + message_en: 'Failed to delete dimension value.', + }, + DIMENSION_IMPORT_FAILED: { + httpStatus: 500, + message_sv: 'Import av befintliga dimensionskoder misslyckades.', + message_en: 'Failed to import existing dimension codes from journal lines.', + }, +} + // ───────────────────────────────────────────────────────────────── // Combined registry // ───────────────────────────────────────────────────────────────── @@ -2505,6 +2574,7 @@ const REGISTRY: Record = { ...SKATTEVERKET, ...BOLAGSVERKET, ...ASSETS, + ...DIMENSION, } export function getErrorEntry(code: string): StructuredErrorEntry | undefined { diff --git a/lib/reports/__tests__/sie-export.test.ts b/lib/reports/__tests__/sie-export.test.ts index d88d6fc7..4fa97fa1 100644 --- a/lib/reports/__tests__/sie-export.test.ts +++ b/lib/reports/__tests__/sie-export.test.ts @@ -6,12 +6,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' let resultIdx: number let results: Array<{ data?: unknown; error?: unknown }> +// `.eq()` args recorded per table so tests can assert on query shape (e.g. +// that the dimension registry fetch does NOT filter is_active — the latent +// undeclared-#OBJEKT bug was exactly such a filter). +let eqCallsByTable: Record> -function makeBuilder() { +function makeBuilder(table: string) { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq']) { + for (const m of ['select', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq']) { b[m] = vi.fn().mockReturnValue(b) } + b.eq = vi.fn().mockImplementation((column: string, value: unknown) => { + ;(eqCallsByTable[table] ??= []).push([column, value]) + return b + }) b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null }) return b @@ -19,7 +27,7 @@ function makeBuilder() { function makeClient() { return { - from: vi.fn().mockImplementation(() => makeBuilder()), + from: vi.fn().mockImplementation((table: string) => makeBuilder(table)), // `rpc` drains the same queue so tests can intersperse RPC + table fetches. // SIE export calls `compute_prior_opening_balances` via getOpeningBalances // whenever `opening_balance_entry_id` is null (the multi-year-import path). @@ -36,6 +44,7 @@ beforeEach(() => { vi.clearAllMocks() resultIdx = 0 results = [] + eqCallsByTable = {} supabase = makeClient() }) @@ -46,16 +55,20 @@ const baseOptions = { program_name: 'ERPBase', } -// Queue consumption order after the pagination fix: +// Queue consumption order (dimensions registry replaced cost_centers/projects): // 0: fiscal_periods.single() // 1: previous fiscal period .single() (#RAR -1) // 2: chart_of_accounts (fetchAllRows) // 3: journal_entries (fetchAllRows) // 4: journal_entry_lines (fetchAllRows) ← split out from the entries query -// 5: cost_centers -// 6: projects +// 5: dimensions (registry #DIM/#UNDERDIM rows) +// 6: dimension_values (registry #OBJEKT rows) // 7: opening balances (RPC fallback or journal_entry_lines page) +// Registry fixtures for the system dims (seeded by ensure_company_dimensions). +const dimKostnadsstalle = { id: 'dim-1', sie_dim_no: 1, parent_sie_dim_no: null, name: 'Kostnadsställe' } +const dimProjekt = { id: 'dim-6', sie_dim_no: 6, parent_sie_dim_no: null, name: 'Projekt' } + describe('generateSIEExport', () => { it('throws when fiscal period not found', async () => { results = [ @@ -74,8 +87,8 @@ describe('generateSIEExport', () => { { data: [], error: null }, // accounts { data: [], error: null }, // journal_entries { data: [], error: null }, // journal_entry_lines - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values { data: [], error: null }, // opening balances RPC ] @@ -99,8 +112,8 @@ describe('generateSIEExport', () => { { data: [], error: null }, // accounts { data: [], error: null }, // journal_entries { data: [], error: null }, // journal_entry_lines - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback ] @@ -125,8 +138,8 @@ describe('generateSIEExport', () => { }, { data: [], error: null }, // journal_entries { data: [], error: null }, // journal_entry_lines - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback ] @@ -154,14 +167,14 @@ describe('generateSIEExport', () => { { // journal_entry_lines — each carries journal_entry_id for grouping data: [ - { journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null }, - { journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', cost_center: null, project: null }, - { journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null }, + { journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, dimensions: {} }, + { journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', dimensions: {} }, + { journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, dimensions: {} }, ], error: null, }, - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback ] @@ -175,22 +188,18 @@ describe('generateSIEExport', () => { expect(output).toContain('}') }) - it('generates #DIM and #OBJEKT for dimensions', async () => { + it('generates #DIM and #OBJEKT for registry dimensions', async () => { results = [ { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts { data: [], error: null }, // journal_entries { data: [], error: null }, // journal_entry_lines + { data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions { data: [ - { code: 'CC1', name: 'Avdelning 1', is_active: true }, - ], - error: null, - }, - { - data: [ - { code: 'P001', name: 'Projekt Alpha', is_active: true }, + { dimension_id: 'dim-1', code: 'CC1', name: 'Avdelning 1' }, + { dimension_id: 'dim-6', code: 'P001', name: 'Projekt Alpha' }, ], error: null, }, @@ -205,7 +214,7 @@ describe('generateSIEExport', () => { expect(output).toContain('#OBJEKT 6 "P001" "Projekt Alpha"') }) - it('includes dimension objects in #TRANS lines', async () => { + it('includes dimension objects in #TRANS lines from the jsonb map', async () => { results = [ { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod @@ -218,13 +227,19 @@ describe('generateSIEExport', () => { }, { data: [ - { journal_entry_id: 'e1', account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, cost_center: 'CC1', project: 'P001' }, - { journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, cost_center: null, project: null }, + { journal_entry_id: 'e1', account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, dimensions: { '1': 'CC1', '6': 'P001' } }, + { journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, dimensions: {} }, + ], + error: null, + }, + { data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions + { + data: [ + { dimension_id: 'dim-1', code: 'CC1', name: 'Avdelning 1' }, + { dimension_id: 'dim-6', code: 'P001', name: 'Projekt Alpha' }, ], error: null, }, - { data: [{ code: 'CC1', name: 'Avdelning 1', is_active: true }], error: null }, - { data: [{ code: 'P001', name: 'Projekt Alpha', is_active: true }], error: null }, { data: [], error: null }, // RPC fallback ] @@ -234,6 +249,203 @@ describe('generateSIEExport', () => { expect(output).toContain('\t#TRANS 1930 {} -8000.00 20240315') }) + it('declares INACTIVE registry values as #OBJEKT (undeclared-object regression)', async () => { + // Latent bug in the legacy read path: the registry fetch filtered + // is_active=true, so a line referencing an archived code serialized into + // #TRANS while its #OBJEKT declaration was missing — Visma rejects such + // files. The registry fetch must NOT filter on is_active. + results = [ + { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, + { data: null, error: null }, // prevPeriod + { data: [], error: null }, // accounts + { + data: [ + { id: 'e1', entry_date: '2024-05-02', voucher_number: 1, voucher_series: 'A', description: 'Archived code', status: 'posted' }, + ], + error: null, + }, + { + data: [ + { journal_entry_id: 'e1', account_number: '5010', debit_amount: 500, credit_amount: 0, line_description: null, dimensions: { '1': 'CC9' } }, + { journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 500, line_description: null, dimensions: {} }, + ], + error: null, + }, + { data: [dimKostnadsstalle], error: null }, // dimensions + // The archived (is_active=false) value row IS returned by the query + // because the export must not filter it out. + { data: [{ dimension_id: 'dim-1', code: 'CC9', name: 'Nedlagd avdelning', is_active: false }], error: null }, + { data: [], error: null }, // RPC fallback + ] + + const output = await generateSIEExport(supabase, 'company-1', baseOptions) + + // Declared with its registry name (not synthesized code-as-name) + expect(output).toContain('#DIM 1 "Kostnadsställe"') + expect(output).toContain('#OBJEKT 1 "CC9" "Nedlagd avdelning"') + expect(output).toContain('\t#TRANS 5010 {1 "CC9"} 500.00 20240502') + // Query-shape guard: neither registry fetch may filter on is_active — + // that is the exact filter that caused the undeclared-#OBJEKT bug. + expect(eqCallsByTable['dimensions'] ?? []).not.toContainEqual(['is_active', true]) + expect(eqCallsByTable['dimension_values'] ?? []).not.toContainEqual(['is_active', true]) + }) + + it('synthesizes #DIM and #OBJEKT for orphan line codes with no registry rows', async () => { + // Free-text writers can still mint dimension numbers/codes until the + // write-path PR — every referenced (dimNo, code) pair must be declared, + // never silently dropped. Dim 6 resolves from the SIE reserved-number + // seed; dim 13 falls back to "Dimension n"; both codes get name = code. + results = [ + { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, + { data: null, error: null }, // prevPeriod + { data: [], error: null }, // accounts + { + data: [ + { id: 'e1', entry_date: '2024-02-01', voucher_number: 1, voucher_series: 'A', description: 'Orphans', status: 'posted' }, + ], + error: null, + }, + { + data: [ + { journal_entry_id: 'e1', account_number: '4010', debit_amount: 900, credit_amount: 0, line_description: null, dimensions: { '6': 'GHOST', '13': 'X1' } }, + { journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 900, line_description: null, dimensions: {} }, + ], + error: null, + }, + { data: [], error: null }, // dimensions — registry is empty + { data: [], error: null }, // dimension_values + { data: [], error: null }, // RPC fallback + ] + + const output = await generateSIEExport(supabase, 'company-1', baseOptions) + + expect(output).toContain('#DIM 6 "Projekt"') + expect(output).toContain('#DIM 13 "Dimension 13"') + expect(output).toContain('#OBJEKT 6 "GHOST" "GHOST"') + expect(output).toContain('#OBJEKT 13 "X1" "X1"') + expect(output).toContain('\t#TRANS 4010 {6 "GHOST" 13 "X1"} 900.00 20240201') + }) + + it('serializes multi-dimension lines sorted by numeric dimension number', async () => { + results = [ + { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, + { data: null, error: null }, // prevPeriod + { data: [], error: null }, // accounts + { + data: [ + { id: 'e1', entry_date: '2024-04-10', voucher_number: 1, voucher_series: 'A', description: 'Multi-dim', status: 'posted' }, + ], + error: null, + }, + { + // Keys deliberately out of order — output must sort 1 < 6 < 7 + data: [ + { journal_entry_id: 'e1', account_number: '7010', debit_amount: 30000, credit_amount: 0, line_description: null, dimensions: { '7': 'EMP1', '1': 'KS01', '6': 'P001' } }, + { journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 30000, line_description: null, dimensions: {} }, + ], + error: null, + }, + { data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions + { + data: [ + { dimension_id: 'dim-1', code: 'KS01', name: 'Kontoret' }, + { dimension_id: 'dim-6', code: 'P001', name: 'Projekt Alpha' }, + ], + error: null, + }, + { data: [], error: null }, // RPC fallback + ] + + const output = await generateSIEExport(supabase, 'company-1', baseOptions) + + expect(output).toContain('\t#TRANS 7010 {1 "KS01" 6 "P001" 7 "EMP1"} 30000.00 20240410') + // Dim 7 has no registry row → synthesized from the SIE reserved seed, + // with the orphan employee code declared. + expect(output).toContain('#DIM 7 "Anställd"') + expect(output).toContain('#OBJEKT 7 "EMP1" "EMP1"') + }) + + it('emits #UNDERDIM for child dimensions and declares the parent', async () => { + results = [ + { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, + { data: null, error: null }, // prevPeriod + { data: [], error: null }, // accounts + { data: [], error: null }, // journal_entries + { data: [], error: null }, // journal_entry_lines + { + // Kostnadsbärare (2) is a sub-dimension of Kostnadsställe (1); the + // parent has NO values of its own — it must still be declared because + // an #UNDERDIM referencing an undeclared parent is invalid. + data: [ + dimKostnadsstalle, + { id: 'dim-2', sie_dim_no: 2, parent_sie_dim_no: 1, name: 'Kostnadsbärare' }, + ], + error: null, + }, + { data: [{ dimension_id: 'dim-2', code: 'KB1', name: 'Bärare 1' }], error: null }, + { data: [], error: null }, // RPC fallback + ] + + const output = await generateSIEExport(supabase, 'company-1', baseOptions) + + expect(output).toContain('#DIM 1 "Kostnadsställe"') + expect(output).toContain('#UNDERDIM 2 "Kostnadsbärare" 1') + expect(output).toContain('#OBJEKT 2 "KB1" "Bärare 1"') + // The parent was pulled in as a declaration only — no #UNDERDIM for it + expect(output).not.toContain('#UNDERDIM 1') + }) + + it('declares a parent #DIM before an #UNDERDIM child with a LOWER number', async () => { + // SIE4 requires the parent to be declared before any #UNDERDIM that + // references it. A registry can hold a child whose sie_dim_no is LOWER + // than its parent's (dim 3 under dim 7 here), so a single numeric sort + // would emit the #UNDERDIM first — the two-pass emit (#DIM roots first, + // then #UNDERDIM) must keep the parent ahead. + results = [ + { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, + { data: null, error: null }, // prevPeriod + { data: [], error: null }, // accounts + { + data: [ + { id: 'e1', entry_date: '2024-03-01', voucher_number: 1, voucher_series: 'A', description: 'Child below parent', status: 'posted' }, + ], + error: null, + }, + { + data: [ + { journal_entry_id: 'e1', account_number: '5010', debit_amount: 700, credit_amount: 0, line_description: null, dimensions: { '3': 'UND1', '7': 'EMP1' } }, + { journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 700, line_description: null, dimensions: {} }, + ], + error: null, + }, + { + // dim 3 is a child of dim 7 — numerically BEFORE its parent. + data: [ + { id: 'dim-3', sie_dim_no: 3, parent_sie_dim_no: 7, name: 'Underavdelning' }, + { id: 'dim-7', sie_dim_no: 7, parent_sie_dim_no: null, name: 'Anställd' }, + ], + error: null, + }, + { + data: [ + { dimension_id: 'dim-3', code: 'UND1', name: 'Under 1' }, + { dimension_id: 'dim-7', code: 'EMP1', name: 'Anna' }, + ], + error: null, + }, + { data: [], error: null }, // RPC fallback + ] + + const output = await generateSIEExport(supabase, 'company-1', baseOptions) + + expect(output).toContain('#DIM 7 "Anställd"') + expect(output).toContain('#UNDERDIM 3 "Underavdelning" 7') + // The parent #DIM must precede the child #UNDERDIM in the file. + expect(output.indexOf('#DIM 7 "Anställd"')).toBeLessThan( + output.indexOf('#UNDERDIM 3 "Underavdelning" 7'), + ) + }) + it('generates #UB for class 1-2 and #RES for class 3-8', async () => { results = [ { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, @@ -247,14 +459,14 @@ describe('generateSIEExport', () => { }, { data: [ - { journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null }, - { journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, cost_center: null, project: null }, - { journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null }, + { journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, dimensions: {} }, + { journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, dimensions: {} }, + { journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, dimensions: {} }, ], error: null, }, - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback ] @@ -281,13 +493,13 @@ describe('generateSIEExport', () => { }, { data: [ - { journal_entry_id: 'e1', account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null }, - { journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null }, + { journal_entry_id: 'e1', account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, dimensions: {} }, + { journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, dimensions: {} }, ], error: null, }, - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback ] @@ -303,8 +515,8 @@ describe('generateSIEExport', () => { { data: [], error: null }, // accounts { data: [], error: null }, // journal_entries { data: [], error: null }, // journal_entry_lines - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback ] @@ -328,8 +540,8 @@ describe('generateSIEExport', () => { { data: [], error: null }, // accounts { data: [], error: null }, // journal_entries { data: [], error: null }, // journal_entry_lines - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback ] @@ -346,8 +558,29 @@ describe('generateSIEExport', () => { { data: [], error: null }, // accounts { data: [], error: null }, // journal_entries { data: [], error: null }, // journal_entry_lines - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values + { data: [], error: null }, // RPC fallback + ] + + const output = await generateSIEExport(supabase, 'company-1', baseOptions) + + expect(output).not.toContain('#DIM') + expect(output).not.toContain('#OBJEKT') + }) + + it('keeps seeded-but-unused system dimensions silent (no #DIM without values or tagged lines)', async () => { + // ensure_company_dimensions lazily seeds dims 1/6 for any company that + // touches the dimensions UI — a company that merely visited the register + // page must still get a dimension-free file. + results = [ + { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, + { data: null, error: null }, // prevPeriod + { data: [], error: null }, // accounts + { data: [], error: null }, // journal_entries + { data: [], error: null }, // journal_entry_lines + { data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions — seeded, valueless + { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback ] @@ -375,8 +608,8 @@ describe('generateSIEExport', () => { })) const lines = entries.flatMap((e, i) => [ - { id: `l${i * 2 + 1}`, journal_entry_id: e.id, account_number: '1510', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null }, - { id: `l${i * 2 + 2}`, journal_entry_id: e.id, account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null }, + { id: `l${i * 2 + 1}`, journal_entry_id: e.id, account_number: '1510', debit_amount: 100, credit_amount: 0, line_description: null, dimensions: {} }, + { id: `l${i * 2 + 2}`, journal_entry_id: e.id, account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, dimensions: {} }, ]) // fetchAllRows paginates at PAGE_SIZE = 1000; chunk the mock data so the @@ -399,8 +632,8 @@ describe('generateSIEExport', () => { { data: [], error: null }, // accounts ...paginate(entries), // journal_entries — 3 pages (1000 + 1000 + 500) ...paginate(lines), // journal_entry_lines — 5000 rows → 5 pages - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback ] @@ -433,8 +666,8 @@ describe('generateSIEExport', () => { { data: [], error: null }, // accounts { data: [], error: null }, // journal_entries (no movements this period) { data: [], error: null }, // journal_entry_lines - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values // RPC fallback returns prior IBs derived from historical journal lines { data: [ @@ -464,8 +697,8 @@ describe('generateSIEExport', () => { { data: [], error: null }, // accounts { data: [], error: null }, // journal_entries { data: [], error: null }, // journal_entry_lines - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values // fetchAllRows page 1 — explicit OB entry lines { data: [ @@ -526,15 +759,15 @@ describe('generateSIEExport', () => { // movement via obEntryId) and the real transfer's lines both flow through here. { data: [ - { id: 'l1', journal_entry_id: 'ob-entry-1', account_number: '1933', debit_amount: 96466.59, credit_amount: 0, line_description: 'IB 1933', cost_center: null, project: null }, - { id: 'l2', journal_entry_id: 'ob-entry-1', account_number: '2019', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null }, - { id: 'l3', journal_entry_id: 'e2', account_number: '1930', debit_amount: 96466.59, credit_amount: 0, line_description: null, cost_center: null, project: null }, - { id: 'l4', journal_entry_id: 'e2', account_number: '1933', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null }, + { id: 'l1', journal_entry_id: 'ob-entry-1', account_number: '1933', debit_amount: 96466.59, credit_amount: 0, line_description: 'IB 1933', dimensions: {} }, + { id: 'l2', journal_entry_id: 'ob-entry-1', account_number: '2019', debit_amount: 0, credit_amount: 96466.59, line_description: null, dimensions: {} }, + { id: 'l3', journal_entry_id: 'e2', account_number: '1930', debit_amount: 96466.59, credit_amount: 0, line_description: null, dimensions: {} }, + { id: 'l4', journal_entry_id: 'e2', account_number: '1933', debit_amount: 0, credit_amount: 96466.59, line_description: null, dimensions: {} }, ], error: null, }, - { data: [], error: null }, // cost_centers - { data: [], error: null }, // projects + { data: [], error: null }, // dimensions + { data: [], error: null }, // dimension_values // fetchAllRows for OB entry lines (opening_balance_entry_id is set) { data: [ diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts index 6f7fae38..56957e00 100644 --- a/lib/reports/sie-export.ts +++ b/lib/reports/sie-export.ts @@ -1,9 +1,12 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { getBranding } from '@/lib/branding/service' +import { createLogger } from '@/lib/logger' import { getOpeningBalances } from './opening-balances' import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types' +const log = createLogger('reports:sie-export') + function sanitizeProgramName(str: string): string { return str.replace(/"/g, '').replace(/[\r\n]/g, ' ').substring(0, 60) } @@ -142,19 +145,21 @@ export async function generateSIEExport( entry.lines = linesByEntryId.get(entry.id) || [] } - // Fetch cost centers and projects for dimension records - const { data: costCenters } = await supabase - .from('cost_centers') - .select('*') + // Fetch the dimension registry (#DIM/#UNDERDIM + #OBJEKT source). + // Deliberately NO is_active filter: lines referencing archived codes still + // serialize into #TRANS object lists, and Visma rejects files whose #TRANS + // references an undeclared #OBJEKT (plan §5 latent bug #2 — the legacy + // cost_centers/projects read filtered is_active=true and dropped them). + const { data: registryDimensions } = await supabase + .from('dimensions') + .select('id, sie_dim_no, parent_sie_dim_no, name') .eq('company_id', companyId) - .eq('is_active', true) - .order('code') + .order('sie_dim_no') - const { data: projects } = await supabase - .from('projects') - .select('*') + const { data: registryValues } = await supabase + .from('dimension_values') + .select('dimension_id, code, name') .eq('company_id', companyId) - .eq('is_active', true) .order('code') const lines: string[] = [] @@ -183,25 +188,10 @@ export async function generateSIEExport( lines.push(`#RAR -1 ${dateStringToSIE(prevPeriod.period_start)} ${dateStringToSIE(prevPeriod.period_end)}`) } - // === Dimension definitions === - // SIE standard: dimension 1 = kostnadsställe, dimension 6 = projekt - const hasCostCenters = costCenters && costCenters.length > 0 - const hasProjects = projects && projects.length > 0 - - if (hasCostCenters) { - lines.push('#DIM 1 "Kostnadsställe"') - } - if (hasProjects) { - lines.push('#DIM 6 "Projekt"') - } - - // === Dimension objects (#OBJEKT) === - for (const cc of costCenters || []) { - lines.push(`#OBJEKT 1 "${escapeQuotes(cc.code)}" "${escapeQuotes(cc.name)}"`) - } - for (const proj of projects || []) { - lines.push(`#OBJEKT 6 "${escapeQuotes(proj.code)}" "${escapeQuotes(proj.name)}"`) - } + // === Dimension definitions (#DIM / #UNDERDIM) + objects (#OBJEKT) === + lines.push( + ...buildDimensionSection(registryDimensions ?? [], registryValues ?? [], allLines) + ) // === Chart of accounts === for (const account of (accounts as BASAccount[]) || []) { @@ -257,14 +247,12 @@ export async function generateSIEExport( ? ` "${escapeQuotes(line.line_description)}"` : '' - // Build dimension object list for #TRANS line - const dimParts: string[] = [] - if (line.cost_center) { - dimParts.push(`1 "${escapeQuotes(line.cost_center)}"`) - } - if (line.project) { - dimParts.push(`6 "${escapeQuotes(line.project)}"`) - } + // Build dimension object list for #TRANS from the jsonb map — the + // single source of truth. The legacy cost_center/project columns are + // derived mirrors of keys '1'/'6' and are no longer read here. + const dimParts = lineDimensionEntries(line.dimensions).map( + ([dimNo, code]) => `${dimNo} "${escapeQuotes(code)}"` + ) const objList = dimParts.length > 0 ? `{${dimParts.join(' ')}}` : '{}' lines.push(`\t#TRANS ${line.account_number} ${objList} ${formatAmount(amount)} ${entryDate}${lineDesc}`) @@ -334,6 +322,202 @@ function escapeQuotes(str: string): string { return str.replace(/"/g, '\\"') } +// ── Dimensions (#DIM / #UNDERDIM / #OBJEKT) ───────────────────────────────── + +interface RegistryDimension { + id: string + sie_dim_no: number + parent_sie_dim_no: number | null + name: string +} + +interface RegistryValue { + dimension_id: string + code: string + name: string +} + +/** + * SIE reserved dimension numbers. Used to synthesize a #DIM declaration for + * dimension numbers referenced by exported lines but absent from the registry + * — free-text writers can still mint arbitrary numbers until the write-path + * PR lands, and an undeclared dimension would make importers reject the file. + * Dim 2 (kostnadsbärare) is a reserved sub-dimension of 1 → #UNDERDIM. + */ +const SIE_RESERVED_DIMENSIONS: Record = { + 1: { name: 'Kostnadsställe' }, + 2: { name: 'Kostnadsbärare', parent: 1 }, + 6: { name: 'Projekt' }, + 7: { name: 'Anställd' }, + 8: { name: 'Kund' }, + 9: { name: 'Leverantör' }, + 10: { name: 'Faktura' }, +} + +/** + * Normalize a line's jsonb dimensions map ({"1":"KS01","6":"P001"}) into + * [dimNo, code] entries sorted by numeric dimension number. Defensive on + * shape: non-numeric keys and blank codes are skipped, and duplicate keys + * ('01' vs '1') collapse onto the canonical number with last-write-wins — + * mirroring normalizeLineDimensions in lib/bookkeeping/dimension-resolver.ts. + */ +function lineDimensionEntries(dimensions: unknown): Array<[number, string]> { + if (!dimensions || typeof dimensions !== 'object' || Array.isArray(dimensions)) { + return [] + } + const byDimNo = new Map() + for (const [key, value] of Object.entries(dimensions as Record)) { + if (!/^\d+$/.test(key)) continue + const dimNo = Number(key) + if (dimNo < 1) continue + const code = typeof value === 'string' ? value.trim() : '' + if (!code) continue + byDimNo.set(dimNo, code) + } + return [...byDimNo.entries()].sort((a, b) => a[0] - b[0]) +} + +/** + * Build the #DIM/#UNDERDIM + #OBJEKT section from the dimension registry and + * the exported journal lines. + * + * Completeness guarantee: every (dimNo, code) pair referenced by any exported + * line gets an #OBJEKT record — registry rows contribute their name, orphan + * codes (no registry row) synthesize name = code, and dimension numbers with + * no registry row synthesize a #DIM from the SIE reserved-number seed. A file + * whose #TRANS references an undeclared object is rejected by Visma et al. + * + * Silence guarantee: registry dimensions with no values and no line + * references (e.g. lazily seeded system dims 1/6 that were never used) emit + * nothing, so companies that never touch dimensions keep dimension-free files. + */ +function buildDimensionSection( + registryDimensions: RegistryDimension[], + registryValues: RegistryValue[], + journalLines: JournalEntryLine[] +): string[] { + // Defence in depth: a value row whose dimension_id doesn't resolve in + // dimNoById is skipped by construction (the `continue` below). Both fetches + // are scoped to the same company_id (query filter + RLS), so every + // dimension_id in registryValues should resolve; a miss can only mean the + // dimension row vanished mid-export — skipping just omits its #OBJEKT, + // never leaks a foreign company's data into the file. + const dimsByNo = new Map() + const dimNoById = new Map() + for (const dim of registryDimensions) { + dimsByNo.set(dim.sie_dim_no, dim) + dimNoById.set(dim.id, dim.sie_dim_no) + } + + // Registry values grouped by dimension number: dimNo → (code → name) + const valuesByDimNo = new Map>() + for (const value of registryValues) { + const dimNo = dimNoById.get(value.dimension_id) + if (dimNo === undefined) continue + let codeMap = valuesByDimNo.get(dimNo) + if (!codeMap) { + codeMap = new Map() + valuesByDimNo.set(dimNo, codeMap) + } + if (!codeMap.has(value.code)) codeMap.set(value.code, value.name) + } + + // (dimNo, code) pairs referenced by exported lines + const referencedByDimNo = new Map>() + for (const line of journalLines) { + for (const [dimNo, code] of lineDimensionEntries(line.dimensions)) { + let codes = referencedByDimNo.get(dimNo) + if (!codes) { + codes = new Set() + referencedByDimNo.set(dimNo, codes) + } + codes.add(code) + } + } + + const emitDimNos = new Set([ + ...valuesByDimNo.keys(), + ...referencedByDimNo.keys(), + ]) + + const declarationFor = (dimNo: number): { name: string; parent: number | null } => { + const dim = dimsByNo.get(dimNo) + if (dim) return { name: dim.name, parent: dim.parent_sie_dim_no ?? null } + const reserved = SIE_RESERVED_DIMENSIONS[dimNo] + return { name: reserved?.name ?? `Dimension ${dimNo}`, parent: reserved?.parent ?? null } + } + + // An #UNDERDIM must not reference an undeclared parent — pull parents into + // the emit set transitively (the has-guard also breaks registry cycles). + const pending = [...emitDimNos] + while (pending.length > 0) { + const { parent } = declarationFor(pending.pop()!) + if (parent !== null && !emitDimNos.has(parent)) { + emitDimNos.add(parent) + pending.push(parent) + } + } + + const sortedDimNos = [...emitDimNos].sort((a, b) => a - b) + const out: string[] = [] + + // Synthesized placeholders collected for the operator warning below: + // #DIM "Dimension n" fallbacks and #OBJEKT rows with name = code. + const synthesizedDimNos: number[] = [] + const orphanObjects: Array<{ dimNo: number; code: string }> = [] + + // Two passes: every root #DIM first (sorted by sie_dim_no), then every + // #UNDERDIM (sorted by sie_dim_no). SIE4 requires a parent to be declared + // before any #UNDERDIM referencing it, and a child may carry a LOWER + // number than its parent — so a single numeric sort is not enough. + for (const dimNo of sortedDimNos) { + const { name, parent } = declarationFor(dimNo) + if (parent !== null) continue + if (!dimsByNo.has(dimNo) && !SIE_RESERVED_DIMENSIONS[dimNo]) { + synthesizedDimNos.push(dimNo) + } + out.push(`#DIM ${dimNo} "${escapeQuotes(name)}"`) + } + + // "Dimension n" fallbacks never carry a parent (declarationFor only + // assigns parents from the registry or the reserved seed), so #UNDERDIM + // lines are never synthesized placeholders. + for (const dimNo of sortedDimNos) { + const { name, parent } = declarationFor(dimNo) + if (parent === null) continue + out.push(`#UNDERDIM ${dimNo} "${escapeQuotes(name)}" ${parent}`) + } + + for (const dimNo of sortedDimNos) { + const registry = valuesByDimNo.get(dimNo) ?? new Map() + const codes = new Set([ + ...registry.keys(), + ...(referencedByDimNo.get(dimNo) ?? []), + ]) + for (const code of [...codes].sort((a, b) => a.localeCompare(b, 'sv'))) { + const registryName = registry.get(code) + if (registryName === undefined) { + orphanObjects.push({ dimNo, code }) + } + out.push( + `#OBJEKT ${dimNo} "${escapeQuotes(code)}" "${escapeQuotes(registryName ?? code)}"` + ) + } + } + + // Operators must know the file contains synthesized placeholder names + // (BFNAR 2013:2 behandlingshistorik) — one structured warning listing the + // pairs; silent when the registry covered everything. + if (orphanObjects.length > 0 || synthesizedDimNos.length > 0) { + log.warn('SIE export synthesized placeholder dimension declarations', { + orphanObjects, + synthesizedDimNos, + }) + } + + return out +} + /** * Calculate net balances per account from journal entries */ diff --git a/messages/en.json b/messages/en.json index 036d504c..8ceec931 100644 --- a/messages/en.json +++ b/messages/en.json @@ -81,6 +81,7 @@ "transactions": "Transactions", "bookkeeping": "Bookkeeping", "chart_of_accounts": "Chart of accounts", + "dimensions": "Cost centres & projects", "assets": "Fixed assets", "reports": "Reports", "budgets": "Budgets", @@ -3394,6 +3395,59 @@ "toast_activated_title": "Account activated", "toast_activated_description": "Account {number} has been added to your chart of accounts" }, + "dimensions": { + "new_value": "New value", + "new_value_title": "New value – {dimension}", + "edit_value_title": "Edit value", + "search_placeholder": "Search by code or name…", + "col_code": "Code", + "col_name": "Name", + "col_status": "Status", + "col_start": "Start", + "col_end": "End", + "status_active": "Active", + "status_archived": "Archived", + "load_failed_title": "Could not load the register", + "load_failed_description": "Try again in a moment.", + "retry": "Try again", + "empty_title": "No values yet", + "empty_description": "Create values for {dimension} to tag journal lines and follow up results.", + "no_search_results_title": "No matches", + "no_search_results_description": "No value matches “{term}”.", + "viewer_disabled_tooltip": "You have read-only access and cannot create values", + "created_title": "Value created", + "created_description": "{code} has been added to the register.", + "updated_title": "Value updated", + "deleted_title": "Value deleted", + "save_failed_title": "Could not save", + "delete_failed_title": "Could not delete", + "form_code_label": "Code", + "form_code_help": "1–20 characters: letters, digits and _ + -", + "form_code_immutable_help": "The code cannot be changed after the value has been created.", + "form_code_invalid": "Invalid code: 1–20 characters, only letters, digits and _ + -", + "form_name_label": "Name", + "form_name_invalid": "Enter a name (1–120 characters).", + "form_active_label": "Active", + "form_active_help": "Archived values are hidden in pickers but remain on posted vouchers.", + "form_start_label": "Start date", + "form_end_label": "End date", + "form_date_order_invalid": "The end date cannot be before the start date.", + "form_create": "Create", + "form_save": "Save", + "form_delete": "Delete", + "delete_confirm_title": "Delete this value?", + "delete_confirm_description": "“{code}” will be permanently deleted. Values used on posted vouchers cannot be deleted — archive them instead.", + "delete_confirm_label": "Delete", + "delete_cancel_label": "Cancel", + "settings_heading": "Cost centres & projects", + "settings_toggle_label": "Enable cost centres & projects", + "settings_toggle_help": "Shows the register in the menu and lets you tag journal lines with cost centre and project.", + "settings_imported_toast_title": "Existing codes imported", + "settings_imported_toast": "{count, plural, one {1 existing code was imported} other {# existing codes were imported}}", + "settings_import_failed_title": "Could not import existing codes", + "settings_save_failed_title": "Could not save the setting", + "settings_open_register": "Open cost centres & projects" + }, "tic_workspace": { "toast_settings_failed": "Could not fetch settings", "toast_profile_failed": "Could not fetch company profile", diff --git a/messages/sv.json b/messages/sv.json index 3f0899e5..4c314065 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -81,6 +81,7 @@ "transactions": "Transaktioner", "bookkeeping": "Bokföring", "chart_of_accounts": "Kontoplan", + "dimensions": "Kostnadsställen & projekt", "assets": "Anläggningstillgångar", "reports": "Rapporter", "budgets": "Budgetar", @@ -3394,6 +3395,59 @@ "toast_activated_title": "Konto aktiverat", "toast_activated_description": "Konto {number} har lagts till i din kontoplan" }, + "dimensions": { + "new_value": "Nytt värde", + "new_value_title": "Nytt värde – {dimension}", + "edit_value_title": "Redigera värde", + "search_placeholder": "Sök på kod eller namn…", + "col_code": "Kod", + "col_name": "Namn", + "col_status": "Status", + "col_start": "Start", + "col_end": "Slut", + "status_active": "Aktiv", + "status_archived": "Arkiverad", + "load_failed_title": "Kunde inte hämta registret", + "load_failed_description": "Försök igen om en stund.", + "retry": "Försök igen", + "empty_title": "Inga värden ännu", + "empty_description": "Skapa värden för {dimension} för att kunna tagga verifikatrader och följa upp resultatet.", + "no_search_results_title": "Inga träffar", + "no_search_results_description": "Inget värde matchar ”{term}”.", + "viewer_disabled_tooltip": "Du har läsbehörighet och kan inte skapa värden", + "created_title": "Värde skapat", + "created_description": "{code} har lagts till i registret.", + "updated_title": "Värde uppdaterat", + "deleted_title": "Värde borttaget", + "save_failed_title": "Kunde inte spara", + "delete_failed_title": "Kunde inte ta bort", + "form_code_label": "Kod", + "form_code_help": "1–20 tecken: bokstäver, siffror samt _ + -", + "form_code_immutable_help": "Koden kan inte ändras efter att värdet har skapats.", + "form_code_invalid": "Ogiltig kod: 1–20 tecken, endast bokstäver, siffror samt _ + -", + "form_name_label": "Namn", + "form_name_invalid": "Ange ett namn (1–120 tecken).", + "form_active_label": "Aktivt", + "form_active_help": "Arkiverade värden döljs i väljare men finns kvar på bokförda verifikat.", + "form_start_label": "Startdatum", + "form_end_label": "Slutdatum", + "form_date_order_invalid": "Slutdatum kan inte vara före startdatum.", + "form_create": "Skapa", + "form_save": "Spara", + "form_delete": "Ta bort", + "delete_confirm_title": "Ta bort värdet?", + "delete_confirm_description": "”{code}” tas bort permanent. Värden som används på bokförda verifikat kan inte tas bort — arkivera dem istället.", + "delete_confirm_label": "Ta bort", + "delete_cancel_label": "Avbryt", + "settings_heading": "Kostnadsställen & projekt", + "settings_toggle_label": "Aktivera kostnadsställen & projekt", + "settings_toggle_help": "Visar registret i menyn och gör det möjligt att tagga verifikatrader med kostnadsställe och projekt.", + "settings_imported_toast_title": "Befintliga koder importerade", + "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" + }, "tic_workspace": { "toast_settings_failed": "Kunde inte hämta inställningar", "toast_profile_failed": "Kunde inte hämta företagsprofil", diff --git a/supabase/migrations/20260702100000_company_settings_dimensions_enabled.sql b/supabase/migrations/20260702100000_company_settings_dimensions_enabled.sql new file mode 100644 index 00000000..c4a330f0 --- /dev/null +++ b/supabase/migrations/20260702100000_company_settings_dimensions_enabled.sql @@ -0,0 +1,17 @@ +-- Dimensions registry (PR2 of the dimensions plan — dev_docs/dimensions_implementation_plan.md §4) +-- +-- Adds the per-company UI toggle for the kostnadsställe/projekt registry. +-- UI-visibility only, NEVER load-bearing for correctness: dimension data +-- written via API/MCP/SIE is always validated regardless of this flag, and +-- reports never consult it. Free tier by founder decision 2026-07-02 — no +-- entitlement gating anywhere in the dimensions feature. +-- +-- pg-test: skip (plain column addition, no trigger/RPC/RLS) + +ALTER TABLE public.company_settings + ADD COLUMN dimensions_enabled boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN public.company_settings.dimensions_enabled IS + 'UI-visibility toggle for kostnadsställen & projekt (dimensions registry, pickers, report filters). Never load-bearing for correctness — data written via API/MCP/SIE import is validated regardless. SIE import that finds dimensions may flip this on with a notice.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index eedcc691..95bee57a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -587,6 +587,7 @@ export function makeCompanySettings( reminder_fee_enabled: true, reminder_fee_amount: 60, reminder_interest_rate_override: null, + dimensions_enabled: false, logo_url: null, onboarding_step: 6, onboarding_complete: true, diff --git a/types/index.ts b/types/index.ts index 9fad5b40..9a9c9400 100644 --- a/types/index.ts +++ b/types/index.ts @@ -306,6 +306,10 @@ export interface CompanySettings { // Sector sector_slug: string | null + // Dimensions (kostnadsställe/projekt) — UI-visibility toggle only, never + // load-bearing for correctness. Free tier (founder decision 2026-07-02). + dimensions_enabled: boolean + // Sandbox is_sandbox: boolean