diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index aea16e04..15f334ba 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -27,6 +27,8 @@ import EditDraftEntryDialog from '@/components/bookkeeping/EditDraftEntryDialog' import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog' import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import CorrectionChain from '@/components/bookkeeping/CorrectionChain' +import RetagLineDialog, { type RetagLine } from '@/components/dimensions/RetagLineDialog' +import { useCompanySettings } from '@/components/settings/useSettings' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' @@ -65,6 +67,15 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i // used to resolve display names for the line badges ('KS: Butik'); badges // fall back to raw codes when the fetch fails or a code is unregistered. const [registryDims, setRegistryDims] = useState(null) + // Tier-2 retro-tagging (dimensions plan PR6): pencil on posted lines opens + // the audited retag dialog; the log renders as a history disclosure below. + // Both render only when dimensions are enabled for the company. + const { settings } = useCompanySettings() + const dimensionsEnabled = settings?.dimensions_enabled === true + const [retagLine, setRetagLine] = useState(null) + const [retagLog, setRetagLog] = useState< + { id: string; line_id: string; old_dimensions: Record; new_dimensions: Record; reason: string; created_at: string }[] + >([]) useEffect(() => { if (registryDims !== null) return @@ -85,10 +96,15 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i setIsLoading(true) setError(null) try { - const [chainRes, refsRes] = await Promise.all([ + const [chainRes, refsRes, retagRes] = await Promise.all([ fetch(`/api/bookkeeping/journal-entries/${id}/chain`), fetch(`/api/bookkeeping/journal-entries/${id}/references`), + fetch(`/api/bookkeeping/journal-entries/${id}/retag-log`), ]) + if (retagRes.ok) { + const retagPayload = await retagRes.json() + setRetagLog(Array.isArray(retagPayload.data) ? retagPayload.data : []) + } if (!chainRes.ok) { const { error: msg } = await chainRes.json() setError(msg || t('error_load_failed')) @@ -633,7 +649,20 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i - {line.line_description || ''} + + {line.line_description || ''} + {dimensionsEnabled && canWrite && entry.status === 'posted' && ( + + )} + {renderDimensionBadges(line)} @@ -685,7 +714,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i return (
-
+
+ + {dimensionsEnabled && canWrite && entry.status === 'posted' && ( + + )} +
{line.line_description && (

{line.line_description}

)} @@ -777,6 +818,49 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i )} + {/* Dimension retag history (dimensions plan PR6) — the immutable + before/after trail. Stays Swedish (voucher detail surface). */} + {dimensionsEnabled && retagLog.length > 0 && ( + + + Ändringshistorik för dimensioner + + + {retagLog.map((row) => { + const lineForRow = lines.find((l) => l.id === row.line_id) + const fmt = (dims: Record) => { + const entries = Object.entries(dims ?? {}).sort(([a], [b]) => Number(a) - Number(b)) + return entries.length > 0 ? entries.map(([no, code]) => `${no}: ${code}`).join(', ') : '—' + } + return ( +
+
+ {formatDate(row.created_at)} + {lineForRow && } +
+

+ {fmt(row.old_dimensions)} + {' → '} + {fmt(row.new_dimensions)} +

+

{row.reason}

+
+ ) + })} +
+
+ )} + + {/* Retag dialog (Tier-2 retro-tagging) */} + { + if (!open) setRetagLine(null) + }} + line={retagLine} + onRetagged={fetchData} + /> + {/* Correction dialog */} {showCorrection && entry && ( - + {/* "Tagga historik" stays Swedish like the workbench it opens (PR6). */} + + + + Tagga historik + + + } + />
) diff --git a/app/(dashboard)/dimensions/tagging/page.tsx b/app/(dashboard)/dimensions/tagging/page.tsx new file mode 100644 index 00000000..b0e43ebc --- /dev/null +++ b/app/(dashboard)/dimensions/tagging/page.tsx @@ -0,0 +1,19 @@ +import { PageHeader } from '@/components/ui/page-header' +import BulkTagWorkbench from '@/components/dimensions/BulkTagWorkbench' + +/** + * Bulk retro-tagging workbench (dimensions plan PR6 §3) — tag or retag + * dimensions on already-posted verifikat lines through the audited + * retag_line_dimensions path. Thin shell; the workbench is client-side. + * + * Hardcoded Swedish like the rest of the dimensions/verifikat surface + * (.claude/rules/i18n.md — operates directly on posted vouchers). + */ +export default function DimensionTaggingPage() { + return ( +
+ + +
+ ) +} diff --git a/app/api/bookkeeping/journal-entries/[id]/retag-log/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/retag-log/__tests__/route.test.ts new file mode 100644 index 00000000..e2ace248 --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/retag-log/__tests__/route.test.ts @@ -0,0 +1,82 @@ +/** + * Tests for GET /api/bookkeeping/journal-entries/[id]/retag-log + * (dimensions plan PR6 — the immutable retag history). + */ +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/init', () => ({ ensureInitialized: vi.fn() })) + +import { GET } from '../route' + +const params = () => createMockRouteParams({ id: 'entry-1' }) +const makeGet = () => + createMockRequest('/api/bookkeeping/journal-entries/entry-1/retag-log', { method: 'GET' }) + +describe('GET /api/bookkeeping/journal-entries/[id]/retag-log', () => { + 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(makeGet(), params()) + expect(response.status).toBe(401) + }) + + it('returns the log rows newest first', async () => { + enqueue({ + data: [ + { + id: 'log-2', + line_id: 'line-1', + old_dimensions: { '6': 'P001' }, + new_dimensions: { '6': 'P002' }, + actor: 'user-1', + reason: 'Bytt projekt', + created_at: '2026-07-02T12:00:00Z', + }, + ], + error: null, + }) + + const response = await GET(makeGet(), params()) + const { body } = await parseJsonResponse<{ data: { id: string }[] }>(response) + + expect(response.status).toBe(200) + expect(body.data).toHaveLength(1) + expect(body.data[0].id).toBe('log-2') + }) + + it('returns 500 with a Swedish message when the query fails', async () => { + enqueue({ data: null, error: { message: 'boom' } }) + + const response = await GET(makeGet(), params()) + const { body } = await parseJsonResponse<{ error: string }>(response) + + expect(response.status).toBe(500) + expect(body.error).toContain('historik') + }) +}) diff --git a/app/api/bookkeeping/journal-entries/[id]/retag-log/route.ts b/app/api/bookkeeping/journal-entries/[id]/retag-log/route.ts new file mode 100644 index 00000000..ab53144f --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/retag-log/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' + +/** + * GET /api/bookkeeping/journal-entries/[id]/retag-log + * + * The entry's dimension retag history (dimensions plan PR6) — the immutable + * before/after trail behind every Tier-2 retag, newest first. + */ +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'bookkeeping.journal_entry.retag_log', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params + + const { data, error } = await supabase + .from('dimension_retag_log') + .select('id, line_id, old_dimensions, new_dimensions, actor, reason, created_at') + .eq('company_id', companyId) + .eq('journal_entry_id', id) + .order('created_at', { ascending: false }) + + if (error) { + return NextResponse.json({ error: 'Kunde inte hämta ändringshistorik' }, { status: 500 }) + } + + return NextResponse.json({ data: data ?? [] }) + }, +) diff --git a/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/__tests__/route.test.ts b/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/__tests__/route.test.ts new file mode 100644 index 00000000..4ba32e9e --- /dev/null +++ b/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/__tests__/route.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for POST /api/bookkeeping/journal-entry-lines/[lineId]/retag + * (dimensions plan PR6 — Tier-2 retro-tagging via the audited RPC). + * + * Covers: 401, validation 400 (bad bag / short reason), the rule-violation + * 409 passthrough (Swedish RPC errors surface verbatim), unexpected RPC + * failure 500, the happy path and the untag ({}) path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + createMockRouteParams, + parseJsonResponse, +} from '@/tests/helpers' + +const { supabase, reset } = createQueuedMockSupabase() + +const rpcMock = vi.fn() +;(supabase as { rpc?: unknown }).rpc = rpcMock + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = () => createMockRouteParams({ lineId: 'line-1' }) + +function makeRetagRequest(body: unknown) { + return createMockRequest('/api/bookkeeping/journal-entry-lines/line-1/retag', { + method: 'POST', + body, + }) +} + +describe('POST /api/bookkeeping/journal-entry-lines/[lineId]/retag', () => { + 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( + makeRetagRequest({ dimensions: { '6': 'P001' }, reason: 'Rätt projekt' }), + params(), + ) + expect(response.status).toBe(401) + expect(rpcMock).not.toHaveBeenCalled() + }) + + it.each([ + ['missing reason', { dimensions: { '6': 'P001' } }], + ['short reason', { dimensions: { '6': 'P001' }, reason: 'ab' }], + ['SIE-breaking code', { dimensions: { '6': 'P{1}' }, reason: 'Testar' }], + ['non-numeric dim key', { dimensions: { projekt: 'P001' }, reason: 'Testar' }], + ])('rejects invalid body (%s) with 400', async (_label, body) => { + const response = await POST(makeRetagRequest(body), params()) + expect(response.status).toBe(400) + expect(rpcMock).not.toHaveBeenCalled() + }) + + it('passes rule violations through as 409 with the Swedish message', async () => { + rpcMock.mockResolvedValue({ + data: null, + error: { code: 'P0001', message: 'Perioden är stängd — använd rättelseverifikat (storno) för att ändra dimensioner.' }, + }) + + const response = await POST( + makeRetagRequest({ dimensions: { '6': 'P001' }, reason: 'Rätt projekt' }), + params(), + ) + const { body } = await parseJsonResponse<{ error: string }>(response) + + expect(response.status).toBe(409) + expect(body.error).toContain('stängd') + }) + + it('returns 500 on unexpected RPC failure', async () => { + rpcMock.mockResolvedValue({ data: null, error: { code: '57P01', message: 'connection refused' } }) + + const response = await POST( + makeRetagRequest({ dimensions: { '6': 'P001' }, reason: 'Rätt projekt' }), + params(), + ) + expect(response.status).toBe(500) + }) + + it('retags via the RPC with the caller as explicit actor (happy path)', async () => { + rpcMock.mockResolvedValue({ + data: { changed: true, log_id: 'log-1', old_dimensions: {}, new_dimensions: { '6': 'P001' } }, + error: null, + }) + + const response = await POST( + makeRetagRequest({ dimensions: { '6': 'P001' }, reason: 'Rätt projekt' }), + params(), + ) + const { body } = await parseJsonResponse<{ data: { changed: boolean; log_id: string } }>(response) + + expect(response.status).toBe(200) + expect(body.data.changed).toBe(true) + expect(rpcMock).toHaveBeenCalledWith('retag_line_dimensions', { + p_company_id: 'company-1', + p_line_id: 'line-1', + p_dimensions: { '6': 'P001' }, + p_reason: 'Rätt projekt', + p_user_id: 'user-1', + }) + }) + + it('accepts an empty bag (untag)', async () => { + rpcMock.mockResolvedValue({ data: { changed: true, log_id: 'log-2' }, error: null }) + + const response = await POST(makeRetagRequest({ dimensions: {}, reason: 'Feltaggad rad' }), params()) + + expect(response.status).toBe(200) + expect(rpcMock).toHaveBeenCalledWith( + 'retag_line_dimensions', + expect.objectContaining({ p_dimensions: {} }), + ) + }) +}) diff --git a/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/route.ts b/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/route.ts new file mode 100644 index 00000000..40bc5c1b --- /dev/null +++ b/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { RetagLineDimensionsSchema } from '@/lib/api/schemas' + +/** + * POST /api/bookkeeping/journal-entry-lines/[lineId]/retag + * + * Tier-2 retro-tagging (dimensions plan PR6): change ONLY the dimension tags + * on a posted line, through the audited retag_line_dimensions RPC. The RPC + * enforces everything — posted status, open period, company lock date, + * active registry values, writer role — and writes the immutable + * dimension_retag_log row before the carve-out UPDATE. Affects + * internredovisning only, never the verifikat itself. + */ +export const POST = withRouteContext<{ params: Promise<{ lineId: string }> }>( + 'bookkeeping.journal_entry_line.retag', + async (request, { supabase, companyId, user, log }, { params }) => { + const { lineId } = await params + + const validation = await validateBody(request, RetagLineDimensionsSchema) + if (!validation.success) return validation.response + + const { dimensions, reason } = validation.data + + const { data, error } = await supabase.rpc('retag_line_dimensions', { + p_company_id: companyId, + p_line_id: lineId, + p_dimensions: dimensions, + p_reason: reason, + p_user_id: user.id, + }) + + if (error) { + // Classify by SQLSTATE, not message text (#867 review): every rule + // violation in the RPC is a plain RAISE EXCEPTION (P0001) with a + // human-readable Swedish message — surface those verbatim as 409 so + // the dialog shows the specific rule. The tenant guard raises 42501. + // Anything else is unexpected infrastructure failure → 500 + log. + const message = error.message ?? 'Kunde inte ändra dimensioner' + if (error.code === 'P0001') { + return NextResponse.json({ error: message }, { status: 409 }) + } + if (error.code === '42501') { + return NextResponse.json({ error: message }, { status: 403 }) + } + log.error('retag_line_dimensions failed', new Error(message), { lineId }) + return NextResponse.json({ error: 'Kunde inte ändra dimensioner' }, { status: 500 }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/dimensions/tagging/__tests__/apply.test.ts b/app/api/dimensions/tagging/__tests__/apply.test.ts new file mode 100644 index 00000000..46f786c7 --- /dev/null +++ b/app/api/dimensions/tagging/__tests__/apply.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for POST /api/dimensions/tagging/apply (bulk retag via the + * retag_line_dimensions RPC). + * + * Covers: 401, body validation (400 for empty line_ids / short reason / bad + * dimensions bag), the happy path (per-line RPC fan-out with p_user_id and + * changed/unchanged aggregation) and partial failure — the route returns 200 + * with the raw Swedish RPC message per failed line. + */ +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'), +})) + +const requireWritePermissionMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../apply/route' + +const noParams = { params: Promise.resolve({}) } + +const LINE_A = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' +const LINE_B = '9b2b6c9e-8c7d-4e5f-8a1b-2c3d4e5f6a7b' + +const validBody = { + line_ids: [LINE_A, LINE_B], + dimensions: { '1': 'KS01', '6': 'P001' }, + reason: 'Rättelse av projektkod', +} + +const request = (body: unknown) => + createMockRequest('/api/dimensions/tagging/apply', { method: 'POST', body }) + +type ApplyBody = { + data: { + retagged: number + unchanged: number + failed: { line_id: string; error: string }[] + } +} + +describe('POST /api/dimensions/tagging/apply', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWritePermissionMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(request(validBody), noParams) + + expect(response.status).toBe(401) + }) + + it('rejects viewers via requireWrite', async () => { + requireWritePermissionMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(request(validBody), noParams) + + expect(response.status).toBe(403) + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('returns 400 when line_ids is empty', async () => { + const response = await POST(request({ ...validBody, line_ids: [] }), noParams) + + expect(response.status).toBe(400) + }) + + it('returns 400 when the reason is shorter than 3 chars', async () => { + const response = await POST(request({ ...validBody, reason: 'ab' }), noParams) + + expect(response.status).toBe(400) + }) + + it('returns 400 for a malformed dimensions bag', async () => { + const response = await POST( + request({ ...validBody, dimensions: { '0': 'KS01' } }), + noParams, + ) + + expect(response.status).toBe(400) + }) + + it('calls the RPC once per line and aggregates changed/unchanged', async () => { + enqueue({ data: { changed: true, log_id: 'log-1' } }) + enqueue({ data: { changed: false, log_id: null } }) + + const response = await POST(request(validBody), noParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data).toEqual({ retagged: 1, unchanged: 1, failed: [] }) + + expect(supabase.rpc).toHaveBeenCalledTimes(2) + expect(supabase.rpc).toHaveBeenNthCalledWith(1, 'retag_line_dimensions', { + p_company_id: 'company-1', + p_line_id: LINE_A, + p_dimensions: { '1': 'KS01', '6': 'P001' }, + p_reason: 'Rättelse av projektkod', + p_user_id: 'user-1', + }) + expect(supabase.rpc).toHaveBeenNthCalledWith(2, 'retag_line_dimensions', { + p_company_id: 'company-1', + p_line_id: LINE_B, + p_dimensions: { '1': 'KS01', '6': 'P001' }, + p_reason: 'Rättelse av projektkod', + p_user_id: 'user-1', + }) + }) + + it('accepts an empty dimensions bag (replace mode clears the tags)', async () => { + enqueue({ data: { changed: true, log_id: 'log-1' } }) + + const response = await POST( + request({ line_ids: [LINE_A], dimensions: {}, reason: 'Tar bort felaktig tagg' }), + noParams, + ) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.retagged).toBe(1) + expect(supabase.rpc).toHaveBeenCalledWith('retag_line_dimensions', { + p_company_id: 'company-1', + p_line_id: LINE_A, + p_dimensions: {}, + p_reason: 'Tar bort felaktig tagg', + p_user_id: 'user-1', + }) + }) + + it('returns 200 with per-line errors on partial failure', async () => { + enqueue({ data: { changed: true, log_id: 'log-1' } }) + enqueue({ + error: { + message: + 'Perioden är låst — använd rättelseverifikat (storno) för att ändra dimensioner.', + }, + }) + + const response = await POST(request(validBody), noParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.retagged).toBe(1) + expect(body.data.unchanged).toBe(0) + expect(body.data.failed).toEqual([ + { + line_id: LINE_B, + // Raw Swedish RPC message passes through untouched. + error: + 'Perioden är låst — använd rättelseverifikat (storno) för att ändra dimensioner.', + }, + ]) + }) + + it('keeps processing after a failure (failure first, success second)', async () => { + enqueue({ error: { message: 'Verifikationsraden hittades inte.' } }) + enqueue({ data: { changed: true, log_id: 'log-2' } }) + + const response = await POST(request(validBody), noParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.retagged).toBe(1) + expect(body.data.failed).toHaveLength(1) + expect(body.data.failed[0].line_id).toBe(LINE_A) + expect(supabase.rpc).toHaveBeenCalledTimes(2) + }) +}) diff --git a/app/api/dimensions/tagging/__tests__/lines.test.ts b/app/api/dimensions/tagging/__tests__/lines.test.ts new file mode 100644 index 00000000..aff60462 --- /dev/null +++ b/app/api/dimensions/tagging/__tests__/lines.test.ts @@ -0,0 +1,200 @@ +/** + * Tests for GET /api/dimensions/tagging/lines (bulk retro-tagging browser). + * + * Covers: 401, query validation (400), the happy path (flattened DTO, + * date-sorted, total_capped false), the hard-cap contract (limit+1 fetch → + * total_capped true), and the DB error path. + */ +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 '../lines/route' + +const noParams = { params: Promise.resolve({}) } +const request = (searchParams?: Record) => + createMockRequest('/api/dimensions/tagging/lines', { searchParams }) + +interface FlatLine { + id: string + account_number: string + debit_amount: number + credit_amount: number + dimensions: Record + journal_entry_id: string + entry_date: string + voucher_number: number | null + voucher_series: string | null + description: string + reversed_by_id: string | null + reverses_id: string | null + fiscal_period_id: string +} + +type LinesBody = { data: { lines: FlatLine[]; total_capped: boolean } } + +/** Raw row as the Supabase select returns it (nested journal_entries). */ +function makeRawLine(overrides: Record = {}) { + return { + id: 'line-1', + account_number: '4010', + debit_amount: 100, + credit_amount: 0, + dimensions: { '1': 'KS01' }, + journal_entry_id: 'entry-1', + journal_entries: { + entry_date: '2026-03-10', + voucher_number: 42, + voucher_series: 'A', + description: 'Inköp material', + reversed_by_id: null, + reverses_id: null, + fiscal_period_id: 'period-1', + }, + ...overrides, + } +} + +describe('GET /api/dimensions/tagging/lines', () => { + 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(request(), noParams) + + expect(response.status).toBe(401) + }) + + it('returns 400 for an out-of-range limit', async () => { + const response = await GET(request({ limit: '9999' }), noParams) + + expect(response.status).toBe(400) + }) + + it('returns 400 for a malformed account filter', async () => { + const response = await GET(request({ account_from: '30' }), noParams) + + expect(response.status).toBe(400) + }) + + it('returns 400 for a malformed date filter', async () => { + const response = await GET(request({ date_from: '2026-13-45' }), noParams) + + expect(response.status).toBe(400) + }) + + it('returns flattened lines sorted by entry date, total_capped false', async () => { + enqueue({ + data: [ + makeRawLine({ + id: 'line-2', + journal_entries: { + entry_date: '2026-04-01', + voucher_number: 50, + voucher_series: 'A', + description: 'Senare verifikat', + reversed_by_id: 'entry-9', + reverses_id: null, + fiscal_period_id: 'period-1', + }, + }), + makeRawLine({ id: 'line-1', dimensions: {} }), + ], + }) + + const response = await GET(request(), noParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.total_capped).toBe(false) + expect(body.data.lines).toHaveLength(2) + // Sorted by entry_date: line-1 (2026-03-10) before line-2 (2026-04-01). + expect(body.data.lines[0]).toMatchObject({ + id: 'line-1', + account_number: '4010', + debit_amount: 100, + credit_amount: 0, + dimensions: {}, + journal_entry_id: 'entry-1', + entry_date: '2026-03-10', + voucher_number: 42, + voucher_series: 'A', + description: 'Inköp material', + fiscal_period_id: 'period-1', + }) + // Reversal linkage rides along for the storno-pair warning. + expect(body.data.lines[1].reversed_by_id).toBe('entry-9') + }) + + it('normalizes a null dimensions map to {}', async () => { + enqueue({ data: [makeRawLine({ dimensions: null })] }) + + const response = await GET(request(), noParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.lines[0].dimensions).toEqual({}) + }) + + it('caps the result at limit and reports total_capped', async () => { + // limit=2 → route fetches 3; a third row means "there is more". + enqueue({ + data: [ + makeRawLine({ id: 'line-1' }), + makeRawLine({ id: 'line-2' }), + makeRawLine({ id: 'line-3' }), + ], + }) + + const response = await GET(request({ limit: '2' }), noParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.lines).toHaveLength(2) + expect(body.data.total_capped).toBe(true) + }) + + it('returns an empty list when nothing matches', async () => { + enqueue({ data: [] }) + + const response = await GET(request({ only_untagged: '1' }), noParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.lines).toEqual([]) + expect(body.data.total_capped).toBe(false) + }) + + it('returns 500 when the query fails', async () => { + enqueue({ error: { message: 'relation missing' } }) + + const response = await GET(request(), noParams) + + expect(response.status).toBe(500) + }) +}) diff --git a/app/api/dimensions/tagging/apply/route.ts b/app/api/dimensions/tagging/apply/route.ts new file mode 100644 index 00000000..a24b8bd6 --- /dev/null +++ b/app/api/dimensions/tagging/apply/route.ts @@ -0,0 +1,75 @@ +/** + * POST /api/dimensions/tagging/apply — bulk retag of posted lines through the + * ONE audited write path, the retag_line_dimensions RPC (dimensions plan PR6 + * §3, migration 20260702170000). + * + * The body carries ONE dimensions object for ALL listed lines — the workbench + * groups selected lines by their computed resulting map client-side and issues + * one POST per distinct map. The RPC is called per line (it locks, validates + * tier boundaries, writes the immutable before/after log and performs the + * carve-out UPDATE per line); failures are aggregated instead of aborting the + * batch, and the response is 200 even on partial failure so the UI can present + * per-line errors: + * + * 200 { data: { retagged, unchanged, failed: [{ line_id, error }] } } + * + * RPC error messages pass through as-is — they are already Swedish domain + * errors (closed/locked period, lock date, archived/unknown codes, drafts). + */ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { DimensionTaggingApplySchema } from '@/lib/api/schemas' + +ensureInitialized() + +export const POST = withRouteContext( + 'dimensions.tagging.apply', + async (request, ctx) => { + const { supabase, companyId, user, log } = ctx + + const validation = await validateBody(request, DimensionTaggingApplySchema, { + log, + operation: 'dimensions.tagging.apply', + }) + if (!validation.success) return validation.response + const { line_ids, dimensions, reason } = validation.data + + let retagged = 0 + let unchanged = 0 + const failed: { line_id: string; error: string }[] = [] + + // Sequential on purpose: each RPC call takes a row lock and writes an + // audit row; hammering hundreds of concurrent transactions buys nothing + // and risks lock contention with live bookkeeping. + for (const lineId of line_ids) { + const { data, error } = await supabase.rpc('retag_line_dimensions', { + p_company_id: companyId, + p_line_id: lineId, + p_dimensions: dimensions, + p_reason: reason, + p_user_id: user.id, + }) + + if (error) { + failed.push({ line_id: lineId, error: error.message }) + continue + } + + const changed = (data as { changed?: boolean } | null)?.changed === true + if (changed) retagged++ + else unchanged++ + } + + log.info('bulk retag applied', { + requested: line_ids.length, + retagged, + unchanged, + failedCount: failed.length, + }) + + return NextResponse.json({ data: { retagged, unchanged, failed } }) + }, + { requireWrite: true }, +) diff --git a/app/api/dimensions/tagging/lines/route.ts b/app/api/dimensions/tagging/lines/route.ts new file mode 100644 index 00000000..e9c5828f --- /dev/null +++ b/app/api/dimensions/tagging/lines/route.ts @@ -0,0 +1,129 @@ +/** + * GET /api/dimensions/tagging/lines — posted journal-entry lines for the bulk + * retro-tagging workbench (dimensions plan PR6 §3). + * + * Read-only line browser: filter by period, entry-date range, account range, + * free text (ilike on the entry description) and "only untagged" (empty + * dimensions map). Hard cap instead of pagination for v1 — the route fetches + * limit+1 rows and reports `total_capped: true` so the UI can show a + * "narrow your filter" notice. + * + * Response: 200 { data: { lines: [...], total_capped: boolean } } where each + * line is flattened ({ id, account_number, debit_amount, credit_amount, + * dimensions, journal_entry_id, entry_date, voucher_number, voucher_series, + * description, reversed_by_id, reverses_id, fiscal_period_id }). The reversal + * linkage rides along so the workbench can warn about storno pairs. + */ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' +import { DimensionTaggingLinesQuerySchema } from '@/lib/api/schemas' +import { errorResponse } from '@/lib/errors/get-structured-error' +import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' + +ensureInitialized() + +interface RawTaggingLine { + id: string + account_number: string + debit_amount: number + credit_amount: number + dimensions: Record | null + journal_entry_id: string + // Supabase types !inner joins as arrays; for many-to-one (line → entry) it + // returns a single object at runtime (same caveat as lib/reports/general-ledger.ts). + journal_entries: { + entry_date: string + voucher_number: number | null + voucher_series: string | null + description: string + reversed_by_id: string | null + reverses_id: string | null + fiscal_period_id: string + } +} + +export const GET = withRouteContext( + 'dimensions.tagging.lines', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const validation = validateQuery(request, DimensionTaggingLinesQuerySchema, { + log, + operation: 'dimensions.tagging.lines', + }) + if (!validation.success) return validation.response + const q = validation.data + + let query = supabase + .from('journal_entry_lines') + .select( + 'id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id, company_id, status)', + ) + .eq('journal_entries.company_id', companyId) + // Posted only — drafts are edited directly in the voucher editor and the + // retag RPC rejects them anyway. + .eq('journal_entries.status', 'posted') + + if (q.period_id) query = query.eq('journal_entries.fiscal_period_id', q.period_id) + if (q.date_from) query = query.gte('journal_entries.entry_date', q.date_from) + if (q.date_to) query = query.lte('journal_entries.entry_date', q.date_to) + if (q.account_from) query = query.gte('account_number', q.account_from) + if (q.account_to) query = query.lte('account_number', q.account_to) + if (q.text) { + // Escape LIKE wildcards (\ % _) so they match literally — same posture + // as the journal-entries list route. + query = query.ilike('journal_entries.description', `%${escapeLikePattern(q.text)}%`) + } + if (q.only_untagged === '1') { + // dimensions is NOT NULL DEFAULT '{}' (substrate migration), so the + // empty-map equality is the complete "untagged" predicate. + query = query.eq('dimensions', '{}') + } + + // Deterministic order on the line PK; fetch one row past the cap so the + // response can say "there is more" without a count query. + const { data, error } = await query + .order('id', { ascending: true }) + .limit(q.limit + 1) + + if (error) { + log.error('tagging line browse failed', error) + return errorResponse(error, log, { requestId }) + } + + const raw = (data ?? []) as unknown as RawTaggingLine[] + const totalCapped = raw.length > q.limit + const page = totalCapped ? raw.slice(0, q.limit) : raw + + const lines = page + .map((l) => ({ + id: l.id, + account_number: l.account_number, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + dimensions: l.dimensions ?? {}, + journal_entry_id: l.journal_entry_id, + entry_date: l.journal_entries.entry_date, + voucher_number: l.journal_entries.voucher_number, + voucher_series: l.journal_entries.voucher_series, + description: l.journal_entries.description, + reversed_by_id: l.journal_entries.reversed_by_id, + reverses_id: l.journal_entries.reverses_id, + fiscal_period_id: l.journal_entries.fiscal_period_id, + })) + // Presentation order: date, then voucher, then line id. Sorting happens + // after the cap (the cap follows insertion-ordered PKs) — acceptable for + // the v1 hard-cap contract; the UI shows a narrow-your-filter notice. + .sort( + (a, b) => + a.entry_date.localeCompare(b.entry_date) || + (a.voucher_series ?? '').localeCompare(b.voucher_series ?? '') || + (a.voucher_number ?? 0) - (b.voucher_number ?? 0) || + a.id.localeCompare(b.id), + ) + + return NextResponse.json({ data: { lines, total_capped: totalCapped } }) + }, +) diff --git a/components/dimensions/BulkTagWorkbench.tsx b/components/dimensions/BulkTagWorkbench.tsx new file mode 100644 index 00000000..ff60e769 --- /dev/null +++ b/components/dimensions/BulkTagWorkbench.tsx @@ -0,0 +1,656 @@ +'use client' + +import { useCallback, useMemo, useRef, useState } from 'react' +import { AlertTriangle, Loader2, Search, Tags, Undo2, X } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { + DataList, + DataListEmpty, + DataListHeader, + DataListLoading, + DataListMeta, + DataListMetaSeparator, + DataListPrimary, + DataListRow, +} from '@/components/ui/data-list' +import { useToast } from '@/components/ui/use-toast' +import { + DestructiveConfirmDialog, + useDestructiveConfirm, +} from '@/components/ui/destructive-confirm-dialog' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { formatCurrency, formatDate } from '@/lib/utils' +import LineDimensionFields from '@/components/dimensions/LineDimensionFields' + +/** Flattened line DTO from GET /api/dimensions/tagging/lines. */ +interface TaggingLine { + id: string + account_number: string + debit_amount: number + credit_amount: number + dimensions: Record + journal_entry_id: string + entry_date: string + voucher_number: number | null + voucher_series: string | null + description: string + reversed_by_id: string | null + reverses_id: string | null + fiscal_period_id: string +} + +interface ApplyResult { + retagged: number + unchanged: number + failed: { line_id: string; error: string }[] +} + +const ACCOUNT_RE = /^\d{4}$/ + +function dimensionLabel(sieDimNo: string): string { + if (sieDimNo === '1') return 'KS' + if (sieDimNo === '6') return 'Proj' + return `Dim ${sieDimNo}` +} + +/** Stable grouping key for a dimensions map (sorted entries). */ +function mapKey(dims: Record): string { + return JSON.stringify( + Object.keys(dims) + .sort() + .map((k) => [k, dims[k]]), + ) +} + +/** + * Bulk retro-tagging workbench (dimensions plan PR6 §3, Retrofit UX): browse + * posted lines, select (shift-click ranges supported), pick KS/Projekt values + * and apply them through the audited retag RPC. Merge mode (default) layers + * the picked values onto each line's existing map; "Ersätt tagg" replaces the + * whole map — used to consolidate typo/phantom codes. + * + * Strings are hardcoded Swedish per the dimensions-surface convention + * (DimensionCombobox/LineDimensionFields): this operates directly on + * verifikat, a stays-Swedish surface per .claude/rules/i18n.md. + */ +export default function BulkTagWorkbench() { + const { toast } = useToast() + const { canWrite } = useCanWrite() + + // Filter bar + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + const [accountFrom, setAccountFrom] = useState('') + const [accountTo, setAccountTo] = useState('') + const [text, setText] = useState('') + const [onlyUntagged, setOnlyUntagged] = useState(false) + + // Result set (null = never fetched) + const [lines, setLines] = useState(null) + const [totalCapped, setTotalCapped] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + // Selection + apply panel + const [selected, setSelected] = useState>(new Set()) + const anchorIndexRef = useRef(null) + const [picked, setPicked] = useState>({}) + const [replaceMode, setReplaceMode] = useState(false) + const [reason, setReason] = useState('') + const [isApplying, setIsApplying] = useState(false) + const [rowErrors, setRowErrors] = useState>({}) + + const loadLines = useCallback(async () => { + for (const [label, value] of [ + ['Konto från', accountFrom], + ['Konto till', accountTo], + ] as const) { + if (value && !ACCOUNT_RE.test(value)) { + toast({ + title: 'Ogiltigt kontonummer', + description: `${label} måste vara exakt 4 siffror.`, + variant: 'destructive', + }) + return + } + } + + setIsLoading(true) + try { + const params = new URLSearchParams() + if (dateFrom) params.set('date_from', dateFrom) + if (dateTo) params.set('date_to', dateTo) + if (accountFrom) params.set('account_from', accountFrom) + if (accountTo) params.set('account_to', accountTo) + if (text.trim()) params.set('text', text.trim()) + if (onlyUntagged) params.set('only_untagged', '1') + + const res = await fetch(`/api/dimensions/tagging/lines?${params.toString()}`) + const json = await res.json().catch(() => null) + if (!res.ok) throw json ?? new Error() + + setLines((json?.data?.lines ?? []) as TaggingLine[]) + setTotalCapped(Boolean(json?.data?.total_capped)) + setSelected(new Set()) + setRowErrors({}) + anchorIndexRef.current = null + } catch (err) { + toast({ + title: 'Kunde inte hämta rader', + description: getErrorMessage(err, { locale: 'sv' }), + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + }, [accountFrom, accountTo, dateFrom, dateTo, text, onlyUntagged, toast]) + + const toggleRow = useCallback( + (index: number, shiftKey: boolean) => { + if (!lines) return + setSelected((prev) => { + const next = new Set(prev) + const anchor = anchorIndexRef.current + if (shiftKey && anchor !== null && anchor !== index) { + // Range selection: the whole range takes the clicked row's NEW state. + const target = !prev.has(lines[index].id) + const [lo, hi] = anchor < index ? [anchor, index] : [index, anchor] + for (let i = lo; i <= hi; i++) { + if (target) next.add(lines[i].id) + else next.delete(lines[i].id) + } + } else if (next.has(lines[index].id)) { + next.delete(lines[index].id) + } else { + next.add(lines[index].id) + } + return next + }) + anchorIndexRef.current = index + }, + [lines], + ) + + const allSelected = + lines !== null && lines.length > 0 && lines.every((l) => selected.has(l.id)) + const someSelected = lines !== null && lines.some((l) => selected.has(l.id)) + + const toggleAll = useCallback(() => { + if (!lines) return + setSelected(allSelected ? new Set() : new Set(lines.map((l) => l.id))) + anchorIndexRef.current = null + }, [lines, allSelected]) + + // Reversal-pair warning: a selected line whose entry is half of a storno + // pair, where the paired entry's lines are loaded but not (all) selected. + const missingPairLineIds = useMemo(() => { + if (!lines || selected.size === 0) return [] as string[] + const pairEntryIds = new Set() + for (const line of lines) { + if (!selected.has(line.id)) continue + if (line.reversed_by_id) pairEntryIds.add(line.reversed_by_id) + if (line.reverses_id) pairEntryIds.add(line.reverses_id) + } + if (pairEntryIds.size === 0) return [] as string[] + return lines + .filter((l) => pairEntryIds.has(l.journal_entry_id) && !selected.has(l.id)) + .map((l) => l.id) + }, [lines, selected]) + + // Voucher labels of the unselected counter-vouchers — the blocking + // confirmation names them so the skew risk is concrete (#867 review: + // Srf U 14 gross reporting; an asymmetric storno pair silently skews + // project P&L, so the advisory alone is not enough). + const missingPairVouchers = useMemo(() => { + if (!lines || missingPairLineIds.length === 0) return [] as string[] + const ids = new Set(missingPairLineIds) + const labels = new Set() + for (const line of lines) { + if (ids.has(line.id)) { + labels.add(`${line.voucher_series ?? ''}${line.voucher_number ?? ''}`) + } + } + return [...labels] + }, [lines, missingPairLineIds]) + + const includeCounterVouchers = useCallback(() => { + setSelected((prev) => { + const next = new Set(prev) + for (const id of missingPairLineIds) next.add(id) + return next + }) + }, [missingPairLineIds]) + + const handlePick = useCallback((sieDimNo: string, code: string | null) => { + setPicked((prev) => { + const next = { ...prev } + if (code) next[sieDimNo] = code + else delete next[sieDimNo] + return next + }) + }, []) + + const pickedCount = Object.keys(picked).length + const reasonValid = reason.trim().length >= 3 + const canApply = + canWrite && + !isApplying && + selected.size > 0 && + reasonValid && + (replaceMode || pickedCount > 0) + + const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm() + + const handleApply = useCallback(async () => { + if (!lines || !canApply) return + + // Storno-pair guard: tagging one leg of a reversal pair without the + // other skews project P&L. Blocking confirmation, not just the banner. + if (missingPairLineIds.length > 0) { + const ok = await confirm({ + title: 'Motverifikat är inte valda', + description: `Du taggar verifikat utan deras motverifikat (${missingPairVouchers.join(', ')}). Projektresultatet blir skevt tills båda sidorna bär samma dimensioner. Vill du tagga ändå?`, + confirmLabel: 'Tagga ändå', + }) + if (!ok) return + } + const selectedLines = lines.filter((l) => selected.has(l.id)) + + // Per-line resulting map, grouped so each distinct map is one POST + // (the API takes ONE dimensions object per call). Usually 1 group; more + // when merge mode meets heterogeneous existing tags. + const groups = new Map; ids: string[] }>() + for (const line of selectedLines) { + const dims = replaceMode ? { ...picked } : { ...line.dimensions, ...picked } + const key = mapKey(dims) + const group = groups.get(key) ?? { dimensions: dims, ids: [] } + group.ids.push(line.id) + groups.set(key, group) + } + + setIsApplying(true) + let retagged = 0 + let unchanged = 0 + const failed: { line_id: string; error: string }[] = [] + const newDimsByLine = new Map>() + + try { + for (const group of groups.values()) { + const res = await fetch('/api/dimensions/tagging/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + line_ids: group.ids, + dimensions: group.dimensions, + reason: reason.trim(), + }), + }) + const json = await res.json().catch(() => null) + if (!res.ok) { + const message = getErrorMessage(json, { locale: 'sv' }) + for (const id of group.ids) failed.push({ line_id: id, error: message }) + continue + } + const result = (json?.data ?? {}) as Partial + retagged += result.retagged ?? 0 + unchanged += result.unchanged ?? 0 + const failedIds = new Set() + for (const f of result.failed ?? []) { + failed.push(f) + failedIds.add(f.line_id) + } + for (const id of group.ids) { + if (!failedIds.has(id)) newDimsByLine.set(id, group.dimensions) + } + } + } finally { + setIsApplying(false) + } + + // Succeeded rows get their new map locally (no refetch); failed rows stay + // selected with their Swedish RPC error shown inline. + setLines((prev) => + prev + ? prev.map((l) => + newDimsByLine.has(l.id) + ? { ...l, dimensions: newDimsByLine.get(l.id) as Record } + : l, + ) + : prev, + ) + setSelected(new Set(failed.map((f) => f.line_id))) + setRowErrors(Object.fromEntries(failed.map((f) => [f.line_id, f.error]))) + + toast({ + title: failed.length > 0 ? 'Omtaggningen slutfördes delvis' : 'Rader omtaggade', + description: `${retagged} ändrade, ${unchanged} oförändrade${ + failed.length > 0 ? `, ${failed.length} misslyckades` : '' + }.`, + variant: failed.length > 0 ? 'destructive' : undefined, + }) + + if (failed.length === 0) { + setPicked({}) + setReason('') + } + }, [lines, canApply, selected, replaceMode, picked, reason, toast, missingPairLineIds, missingPairVouchers, confirm]) + + const headerChecked: boolean | 'indeterminate' = allSelected + ? true + : someSelected + ? 'indeterminate' + : false + + return ( +
+ {/* Filter bar */} + + +
+
+ + setDateFrom(e.target.value)} + /> +
+
+ + setDateTo(e.target.value)} + /> +
+
+ + setAccountFrom(e.target.value.replace(/\D/g, ''))} + /> +
+
+ + setAccountTo(e.target.value.replace(/\D/g, ''))} + /> +
+
+
+
+ + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void loadLines() + }} + /> +
+
+ setOnlyUntagged(checked === true)} + /> + +
+ +
+
+
+ + {/* Result list */} + {lines === null && !isLoading ? ( + + + } + title="Hämta rader att tagga" + description="Välj filter ovan och klicka på Hämta rader för att bläddra bland bokförda verifikatrader." + /> + + + ) : ( + + + { + e.preventDefault() + toggleAll() + }} + aria-label="Markera alla rader" + disabled={!lines || lines.length === 0} + /> + + {lines ? `${lines.length} rader` : ''} + + {totalCapped && ( + + Visar de första {lines?.length ?? 0} raderna — förfina filtren för att se + fler. + + )} + + {isLoading ? ( + + ) : lines && lines.length === 0 ? ( + } + title="Inga rader matchade filtren" + description="Justera datum, kontointervall eller söktext och försök igen." + /> + ) : ( + (lines ?? []).map((line, index) => { + const isSelected = selected.has(line.id) + const inStornoPair = Boolean(line.reversed_by_id || line.reverses_id) + const dimEntries = Object.entries(line.dimensions) + const isDebit = line.debit_amount > 0 + return ( + toggleRow(index, e.shiftKey)} + leading={ + { + e.preventDefault() + e.stopPropagation() + toggleRow(index, e.shiftKey) + }} + aria-label={`Markera rad ${line.voucher_series ?? ''}${line.voucher_number ?? ''} ${line.account_number}`} + /> + } + trailing={ +
+

+ {formatCurrency(isDebit ? line.debit_amount : line.credit_amount)} +

+

+ {isDebit ? 'Debet' : 'Kredit'} +

+
+ } + > + + + {line.voucher_series ?? ''} + {line.voucher_number ?? ''} + + {line.description} + {inStornoPair && ( + + + )} + + + {formatDate(line.entry_date)} + + {line.account_number} + {dimEntries.length > 0 && } + {dimEntries.map(([dimNo, code]) => ( + + {dimensionLabel(dimNo)}{' '} + {code} + + ))} + + {rowErrors[line.id] && ( +

{rowErrors[line.id]}

+ )} +
+ ) + }) + )} +
+ )} + + {/* Spacer so the fixed apply panel never covers the last rows */} + {selected.size > 0 && + ) +} diff --git a/components/dimensions/RetagLineDialog.tsx b/components/dimensions/RetagLineDialog.tsx new file mode 100644 index 00000000..cc70df5f --- /dev/null +++ b/components/dimensions/RetagLineDialog.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import LineDimensionFields from '@/components/dimensions/LineDimensionFields' +import { AccountNumber } from '@/components/ui/account-number' + +export interface RetagLine { + id: string + account_number: string + line_description: string | null + debit_amount: number + credit_amount: number + dimensions?: Record | null +} + +interface Props { + open: boolean + onOpenChange: (open: boolean) => void + line: RetagLine | null + /** Fired after a successful retag so the host refetches the entry. */ + onRetagged: () => void +} + +/** + * Tier-2 retro-tagging on a posted voucher line (dimensions plan PR6). + * Edits ONLY the dimension tags via the audited retag RPC; the verifikat + * itself is untouchable. Dims other than 1/6 pass through unedited (same + * merge semantics as the voucher editor). Hardcoded Swedish — voucher + * detail is a stays-Swedish surface. + */ +export default function RetagLineDialog({ open, onOpenChange, line, onRetagged }: Props) { + const { toast } = useToast() + const [dims, setDims] = useState>({}) + const [reason, setReason] = useState('') + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (open && line) { + setDims({ ...(line.dimensions ?? {}) }) + setReason('') + setError(null) + } + }, [open, line]) + + if (!line) return null + + const amount = Number(line.debit_amount) > 0 ? Number(line.debit_amount) : Number(line.credit_amount) + const side = Number(line.debit_amount) > 0 ? 'Debet' : 'Kredit' + + const handleChange = (dimNo: string, code: string | null) => { + setDims((prev) => { + const next = { ...prev } + if (code) next[dimNo] = code + else delete next[dimNo] + return next + }) + } + + const handleSave = async () => { + setIsSaving(true) + setError(null) + try { + const res = await fetch(`/api/bookkeeping/journal-entry-lines/${line.id}/retag`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dimensions: dims, reason }), + }) + const payload = await res.json() + if (!res.ok) { + setError(typeof payload.error === 'string' ? payload.error : 'Kunde inte ändra dimensioner') + return + } + if (payload.data?.changed === false) { + toast({ title: 'Inga ändringar', description: 'Dimensionerna var redan de valda.' }) + } else { + toast({ title: 'Dimensioner ändrade', description: 'Ändringen är loggad i ändringshistoriken.' }) + } + onOpenChange(false) + onRetagged() + } catch { + setError('Kunde inte ändra dimensioner') + } finally { + setIsSaving(false) + } + } + + const reasonValid = reason.trim().length >= 3 + + return ( + + + + Ändra dimensioner + + Påverkar endast internredovisningen, inte verifikatet. Ändringen + loggas med före/efter och anledning. + + + +
+ +
+ {line.line_description || '—'} + + {amount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr {side} + +
+
+ + + +
+ + setReason(e.target.value)} + placeholder="t.ex. Raden hörde till projekt P002" + maxLength={500} + /> +
+ + {error &&

{error}

} + + + + + +
+
+ ) +} diff --git a/extensions/general/mcp-server/__tests__/tag-journal-lines.test.ts b/extensions/general/mcp-server/__tests__/tag-journal-lines.test.ts new file mode 100644 index 00000000..e9bc09c9 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/tag-journal-lines.test.ts @@ -0,0 +1,282 @@ +/** + * Dimensions PR6 — gnubok_tag_journal_lines (bulk retag staging) tests. + * + * Covers registration (scope map, strict schema, staged-operation output + * contract via deriveToolMeta), the filter gates (no filters / 0 matches / + * >500 matches), and the staging happy paths (free-text passthrough + + * registry name resolution). Executor-side coverage + * (commitRetagLineDimensions incl. partial failure) lives in + * lib/pending-operations/__tests__/retag-line-dimensions-executor.test.ts. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' +import { tools, deriveToolMeta } from '../server' + +const tagJournalLines = tools.find((t) => t.name === 'gnubok_tag_journal_lines')! + +beforeEach(() => { + vi.clearAllMocks() +}) + +function makeLineRow(i: number, overrides: Record = {}) { + return { + // Real UUID shape — the staged params are re-validated against + // RetagLineDimensionsParamsSchema (line_ids must be UUIDs) before insert. + id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`, + account_number: '4010', + debit_amount: 250, + credit_amount: 0, + sort_order: 1, + journal_entries: { + id: `je-${i}`, + entry_date: '2024-03-01', + voucher_number: i, + voucher_series: 'A', + status: 'posted', + company_id: 'company-1', + }, + ...overrides, + } +} + +// ── Registration + contracts ───────────────────────────────────────────────── + +describe('gnubok_tag_journal_lines registration', () => { + it('exists, is scoped bookkeeping:write, and keeps a strict input schema', () => { + expect(tagJournalLines).toBeDefined() + expect(TOOL_SCOPE_MAP.gnubok_tag_journal_lines).toBe('bookkeeping:write') + expect((tagJournalLines.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe(false) + expect(tagJournalLines.description.length).toBeLessThanOrEqual(280) + expect(tagJournalLines.description).toMatch(/stag(e|ing)/i) + }) + + it('uses the staged-operation output schema so the _meta staging contract derives', () => { + // deriveToolMeta keys off reference identity with STAGED_OPERATION_SCHEMA — + // a defined meta proves the tool shares THE schema, not a lookalike copy. + const meta = deriveToolMeta(tagJournalLines) + expect(meta).toBeDefined() + expect(meta?.requires_approval).toBe(true) + expect(meta?.approve_tool).toBe('gnubok_approve_pending_operation') + const schema = tagJournalLines.outputSchema as { required?: string[] } + expect(schema?.required).toContain('staged') + }) +}) + +// ── Filter gates ───────────────────────────────────────────────────────────── + +describe('gnubok_tag_journal_lines — filter gates', () => { + it('rejects an empty filter block before any DB work', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + tagJournalLines.execute( + { dimensions: { '6': 'P01' }, reason: 'Retro-taggning', filters: {} }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/minst ett filter/) + expect(supabase.from).not.toHaveBeenCalled() + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('rejects an invalid dimensions bag before any DB work', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + tagJournalLines.execute( + { dimensions: { '0': 'X' }, reason: 'Retro-taggning', filters: { accounts: ['4010'] } }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/Invalid dimensions/) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('throws a helpful error when no posted lines match', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { dimensions_enabled: false }, error: null }) // resolveDimensionBags passthrough + enqueue({ data: [], error: null }) // line match query → empty + + await expect( + tagJournalLines.execute( + { dimensions: { '6': 'P01' }, reason: 'Retro-taggning', filters: { accounts: ['4010'] } }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/Inga bokförda rader matchade filtret[\s\S]*gnubok_query_journal/) + }) + + it('throws asking to narrow the filter when more than 500 lines match', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { dimensions_enabled: false }, error: null }) + enqueue({ data: Array.from({ length: 501 }, (_, i) => makeLineRow(i)), error: null }) + + await expect( + tagJournalLines.execute( + { dimensions: { '6': 'P01' }, reason: 'Retro-taggning', filters: { account_from: '4000', account_to: '4999' } }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/fler än 500 rader/) + + const insertCalls = (supabase.from as ReturnType).mock.calls + expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false) + }) +}) + +// ── Staging ────────────────────────────────────────────────────────────────── + +describe('gnubok_tag_journal_lines — staging', () => { + it('stages a retag_line_dimensions op with matched line_ids + preview sample', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { dimensions_enabled: false }, error: null }) // passthrough (free-text era) + enqueue({ data: [makeLineRow(1), makeLineRow(2)], error: null }) // line match query + enqueue({ data: { id: 'op-retag-1' }, error: null }) // pending_operations insert + + const result = (await tagJournalLines.execute( + { + dimensions: { '6': 'P01' }, + reason: 'Retro-taggning av Bygg AB-projektet', + filters: { accounts: ['4010'], date_from: '2024-01-01', date_to: '2024-12-31', text: 'Bygg AB' }, + }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' }, + )) as { + staged: boolean + operation_id?: string + risk_level: string + preview: { + matched_lines: number + dimensions: Record + filter_summary: string + sample: Array<{ account: string; date: string; debit: number; credit: number }> + } + } + + expect(result.staged).toBe(true) + expect(result.operation_id).toBe('op-retag-1') + expect(result.risk_level).toBe('medium') + expect(result.preview.matched_lines).toBe(2) + expect(result.preview.dimensions).toEqual({ '6': 'P01' }) + expect(result.preview.filter_summary).toMatch(/konto 4010/) + expect(result.preview.filter_summary).toMatch(/datum 2024-01-01–2024-12-31/) + expect(result.preview.filter_summary).toMatch(/text "Bygg AB"/) + expect(result.preview.sample).toEqual([ + { account: '4010', date: '2024-03-01', debit: 250, credit: 0 }, + { account: '4010', date: '2024-03-01', debit: 250, credit: 0 }, + ]) + + const insertCalls = (supabase.from as ReturnType).mock.calls + expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(true) + }) + + it('resolves dimension names to registry codes and echoes the resolution', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + // resolveDimensionBags (enabled): settings → ensure rpc → dimensions → values + enqueue({ data: { dimensions_enabled: true }, error: null }) + enqueue({ data: null, error: null }) // ensure_company_dimensions rpc + enqueue({ + data: [ + { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 }, + ], + error: null, + }) + enqueue({ + data: [ + { id: 'v1', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null }, + ], + error: null, + }) + enqueue({ data: [makeLineRow(1)], error: null }) // line match query + enqueue({ data: { id: 'op-retag-2' }, error: null }) // pending_operations insert + + const result = (await tagJournalLines.execute( + { + dimensions: { '6': 'villa almgren tak' }, + reason: 'Retro-taggning', + filters: { accounts: ['4010'], only_untagged: true }, + }, + 'company-1', + 'user-1', + supabase as never, + )) as { + staged: boolean + preview: { + dimensions: Record + filter_summary: string + dimension_resolutions?: Array<{ dimension: number; input: string; resolved_code: string }> + } + } + + expect(result.staged).toBe(true) + // The staged bag carries the resolved registry CODE, never the raw name. + expect(result.preview.dimensions).toEqual({ '6': 'P001' }) + expect(result.preview.filter_summary).toMatch(/endast otaggade rader/) + expect(result.preview.dimension_resolutions).toHaveLength(1) + expect(result.preview.dimension_resolutions![0]).toMatchObject({ + dimension: 6, + input: 'villa almgren tak', + resolved_code: 'P001', + }) + expect((supabase.rpc as ReturnType).mock.calls[0][0]).toBe('ensure_company_dimensions') + }) + + it('rejects before staging when a name has no registry match (no auto-create)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { dimensions_enabled: true }, error: null }) + enqueue({ data: null, error: null }) // ensure rpc + enqueue({ + data: [ + { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 }, + ], + error: null, + }) + enqueue({ + data: [ + { id: 'v1', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null }, + ], + error: null, + }) + + await expect( + tagJournalLines.execute( + { dimensions: { '6': 'Bryggeriet ombyggnad' }, reason: 'Retro-taggning', filters: { accounts: ['4010'] } }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/Okänt projekt[\s\S]*gnubok_create_dimension_value/) + + const insertCalls = (supabase.from as ReturnType).mock.calls + expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false) + }) + + it('dry_run previews the match without inserting a pending operation', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { dimensions_enabled: false }, error: null }) + enqueue({ data: [makeLineRow(1)], error: null }) // line match query + + const result = (await tagJournalLines.execute( + { + dimensions: { '6': 'P01' }, + reason: 'Retro-taggning', + filters: { accounts: ['4010'] }, + dry_run: true, + }, + 'company-1', + 'user-1', + supabase as never, + )) as { staged: boolean; dry_run?: boolean; preview: { matched_lines: number } } + + expect(result.staged).toBe(false) + expect(result.dry_run).toBe(true) + expect(result.preview.matched_lines).toBe(1) + const insertCalls = (supabase.from as ReturnType).mock.calls + expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 3a4e90eb..3fe69d05 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -37,6 +37,7 @@ import type { SkillTier } from './skills' import { getRiskLevel } from '@/lib/pending-operations/risk-tiers' import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier' import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value' +import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions' import { ensureCompanyDimensions, fetchDimensionRegistry, @@ -4831,6 +4832,218 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_tag_journal_lines', + title: 'Tag Journal Lines (Bulk Retag)', + description: "Bulk-tag POSTED journal lines with dimensions (kostnadsställe/projekt) selected by a filter block — e.g. all 4010 lines with 'Bygg AB' in 2024 → P01. Stages for approval; max 500 lines. Retags internal reporting only — the verifikat stays immutable, every change logged.", + outputSchema: STAGED_OPERATION_SCHEMA, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + dimensions: { + type: 'object', + additionalProperties: { type: 'string' }, + description: 'Dimensions bag applied to every matched line, REPLACING its current bag: {"":""}, e.g. {"6":"P01"}. Values may be registry codes or names — resolved server-side (resolve-don\'t-select).', + }, + reason: { + type: 'string', + minLength: 3, + maxLength: 500, + description: 'Why the lines are retagged — stored per line in the immutable dimension_retag_log.', + }, + filters: { + type: 'object', + additionalProperties: false, + description: 'Line selection — at least one filter required. Preview the match set with gnubok_query_journal (same filter fields) first.', + properties: { + account_from: { type: 'string', description: 'Lowest account number (inclusive), e.g. "4010".' }, + account_to: { type: 'string', description: 'Highest account number (inclusive).' }, + accounts: { type: 'array', items: { type: 'string' }, description: 'Specific account numbers (overrides account_from/account_to). Up to 50.' }, + date_from: { type: 'string', description: 'Earliest entry date (YYYY-MM-DD, inclusive).' }, + date_to: { type: 'string', description: 'Latest entry date (YYYY-MM-DD, inclusive).' }, + text: { type: 'string', maxLength: 200, description: 'Case-insensitive substring match on the ENTRY description (verifikattext) — line descriptions are not searched.' }, + only_untagged: { type: 'boolean', description: 'Only lines whose dimensions bag is exactly empty ({}). Lines already carrying ANY dimension are excluded — partially tagged lines do not match.' }, + }, + }, + dry_run: { + type: 'boolean', + description: 'If true, validate inputs and return the would-be preview without staging. No DB writes, no side-effects.', + }, + idempotency_key: { + type: 'string', + description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.', + }, + }, + required: ['dimensions', 'reason', 'filters'], + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const { dry_run, idempotency_key } = args + + const reason = typeof args.reason === 'string' ? args.reason.trim() : '' + if (reason.length < 3 || reason.length > 500) { + throw new Error('reason must be 3–500 characters — it is stored in the immutable dimension_retag_log.') + } + + const inputBag = parseDimensionsArg(args.dimensions, 'dimensions') + if (!inputBag) { + throw new Error('dimensions must contain at least one {"":""} pair, e.g. {"6":"P01"}.') + } + + // ── Filters — validated before any DB work so bad input fails fast. + const filters = (args.filters && typeof args.filters === 'object' ? args.filters : {}) as Record + const accounts = Array.isArray(filters.accounts) ? (filters.accounts as string[]) : undefined + if (accounts && accounts.length > 50) { + throw new Error('filters.accounts is capped at 50 — use account_from/account_to for ranges') + } + const accountFrom = typeof filters.account_from === 'string' ? filters.account_from : undefined + const accountTo = typeof filters.account_to === 'string' ? filters.account_to : undefined + const dateFrom = typeof filters.date_from === 'string' ? filters.date_from : undefined + const dateTo = typeof filters.date_to === 'string' ? filters.date_to : undefined + const text = typeof filters.text === 'string' ? filters.text.trim() : '' + if (text.length > 200) { + throw new Error('filters.text must be 200 characters or shorter') + } + const onlyUntagged = filters.only_untagged === true + + const hasFilter = Boolean( + (accounts && accounts.length > 0) || accountFrom || accountTo || dateFrom || dateTo || text || onlyUntagged, + ) + if (!hasFilter) { + throw new Error( + 'Ange minst ett filter (konto, datum, text eller only_untagged) — en företagsbred omtaggning måste avgränsas. ' + + 'Förhandsgranska träffmängden med gnubok_query_journal.', + ) + } + + // ── Resolve the bag (names → registry codes; resolve-don't-select). + // DimensionResolutionError propagates with candidates/create-first + // guidance — nothing unresolved is ever staged. + const { bags, resolutions } = await resolveDimensionBags(supabase, companyId, [inputBag]) + const resolvedBag = bags[0] as Record + + // ── Match the lines. POSTED entries only — drafts are edited directly + // (the retag RPC rejects them too). Fetch cap+1 to detect overflow. + let q = supabase + .from('journal_entry_lines') + .select('id, account_number, debit_amount, credit_amount, sort_order, journal_entries!inner(id, entry_date, voucher_number, voucher_series, status, company_id)') + .eq('journal_entries.company_id', companyId) + .eq('journal_entries.status', 'posted') + + if (accounts && accounts.length > 0) { + q = q.in('account_number', accounts) + } else { + if (accountFrom) q = q.gte('account_number', accountFrom) + if (accountTo) q = q.lte('account_number', accountTo) + } + if (dateFrom) q = q.gte('journal_entries.entry_date', dateFrom) + if (dateTo) q = q.lte('journal_entries.entry_date', dateTo) + if (text) { + // LIKE wildcards escaped so the filter matches literal % / _ — same + // treatment as gnubok_query_journal's text legs. v1 searches the + // ENTRY description only (documented in the schema); the two-leg + // line+entry union query_journal runs is overkill for a write filter. + const escaped = text.replace(/[%]/g, '\\%').replace(/_/g, '\\_') + q = q.ilike('journal_entries.description', `%${escaped}%`) + } + // Pragmatic v1 (documented in the schema): only-untagged means the bag + // is EXACTLY '{}' (column is NOT NULL DEFAULT '{}'). Partially tagged + // lines (e.g. only dim 1 set) do not match. + if (onlyUntagged) q = q.filter('dimensions', 'eq', '{}') + + const res = await q + .order('entry_date', { foreignTable: 'journal_entries', ascending: false }) + .order('voucher_number', { foreignTable: 'journal_entries', ascending: false }) + .order('sort_order', { ascending: true }) + .limit(RETAG_MAX_LINES + 1) + + if (res.error) { + log.warn('tag_journal_lines match query failed', { companyId, userId, error: res.error.message }) + throw new Error('Database error while matching journal lines') + } + + type MatchedRow = { + id: string + account_number: string + debit_amount: number + credit_amount: number + sort_order: number + journal_entries: { id: string; entry_date: string; voucher_number: number; voucher_series: string } + } + const rows = (res.data ?? []) as unknown as MatchedRow[] + + if (rows.length === 0) { + throw new Error( + 'Inga bokförda rader matchade filtret. Kontrollera konto/datum/text — förhandsgranska med gnubok_query_journal (samma filterfält).', + ) + } + if (rows.length > RETAG_MAX_LINES) { + throw new Error( + `Filtret matchar fler än ${RETAG_MAX_LINES} rader — snäva av det (kortare datumintervall, färre konton) och kör i omgångar om högst ${RETAG_MAX_LINES}.`, + ) + } + + // Human description of the selection, carried on the op for the + // approval preview (the executor acts on line_ids verbatim). + const summaryParts: string[] = [] + if (accounts && accounts.length > 0) summaryParts.push(`konto ${accounts.join(', ')}`) + else if (accountFrom || accountTo) summaryParts.push(`konto ${accountFrom ?? '…'}–${accountTo ?? '…'}`) + if (dateFrom || dateTo) summaryParts.push(`datum ${dateFrom ?? '…'}–${dateTo ?? '…'}`) + if (text) summaryParts.push(`text "${text}"`) + if (onlyUntagged) summaryParts.push('endast otaggade rader') + const filterSummary = summaryParts.join(', ').slice(0, 500) + + const bagLabel = Object.entries(resolvedBag) + .map(([dim, code]) => `${dim}=${code}`) + .join(', ') + + // Same Zod schema the commit executor re-validates with — the staged + // params can never drift from what commitRetagLineDimensions accepts. + const params = RetagLineDimensionsParamsSchema.parse({ + line_ids: rows.map((r) => r.id), + dimensions: resolvedBag, + reason, + filter_summary: filterSummary, + }) + + // No dateForPeriodCheck: the matched lines span dates; the retag RPC + // enforces open-period + lock-date per line at commit time. + return stagePendingOperation(supabase, companyId, userId, 'retag_line_dimensions', + `Tagga om ${rows.length} verifikationsrader: ${bagLabel}`, + params as unknown as Record, + { + matched_lines: rows.length, + dimensions: resolvedBag, + filter_summary: filterSummary, + sample: rows.slice(0, 10).map((r) => ({ + account: r.account_number, + date: r.journal_entries.entry_date, + debit: r.debit_amount, + credit: r.credit_amount, + })), + ...(resolutions.length > 0 ? { dimension_resolutions: resolutions } : {}), + will: 'replace the dimensions bag on every matched POSTED line via the audited retag RPC — internal reporting only, the verifikat itself is untouched', + }, + actor, + { + description: 'After approval, verify the retag with gnubok_query_journal (group_by_dimension) or gnubok_get_dimension_pnl.', + tool: 'gnubok_query_journal', + args: { group_by_dimension: Object.keys(resolvedBag)[0] }, + }, + { + dryRun: Boolean(dry_run), + idempotencyKey: typeof idempotency_key === 'string' ? idempotency_key : undefined, + } + ) + }, + }, + { name: 'gnubok_get_dimension_pnl', title: 'P&L per Dimension (Resultat per projekt)', diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 7685e2e7..97396a7d 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -763,6 +763,23 @@ export const CreateDimensionValueSchema = z { message: 'Slutdatum får inte vara före startdatum', path: ['end_date'] }, ) +/** + * POST /api/bookkeeping/journal-entry-lines/[lineId]/retag — Tier-2 retro- + * tagging (dimensions plan PR6). The RPC enforces every rule (posted only, + * open period, lock date, active registry values); this schema only shapes + * the request. An empty bag {} untags the line. + */ +export const RetagLineDimensionsSchema = z.object({ + // {} passes (no entries to validate) = UNTAG. Intentional divergence from + // the MCP staged path (RetagLineDimensionsParamsSchema), which rejects an + // empty bag: a human clearing phantom tags via the dialog/workbench is a + // deliberate act with a logged reason; an agent bulk-clearing history is + // not something we allow to be staged. The retag log records {} as the + // new value either way (#867 review). + dimensions: DimensionsBagSchema, + reason: z.string().min(3).max(500), +}) + /** PATCH /api/dimensions/[id]/values/[valueId] — no `code` field by design. */ export const UpdateDimensionValueSchema = z .object({ @@ -2138,3 +2155,41 @@ export const SalaryEmployeeOverrideSchema = z }, ) + +// ============================================================ +// Dimensions PR6 — bulk retro-tagging workbench (appended at end +// of file by PR6 to avoid conflicts; keep new schemas below). +// ============================================================ + +/** + * Query filters for GET /api/dimensions/tagging/lines (the BulkTagWorkbench + * line browser). All filters optional; `limit` is a hard cap (default 200, + * max 500) — the route fetches limit+1 and reports `total_capped` instead of + * paginating (dimensions plan §3, v1 scope). + */ +export const DimensionTaggingLinesQuerySchema = z.object({ + period_id: uuid.optional(), + date_from: saneIsoDate.optional(), + date_to: saneIsoDate.optional(), + account_from: accountNumber.optional(), + account_to: accountNumber.optional(), + /** Free-text ilike filter on journal_entries.description. */ + text: z.string().trim().max(200).optional(), + /** '1' → only lines whose dimensions map is empty ({}). */ + only_untagged: z.enum(['0', '1']).optional(), + limit: z.coerce.number().int().min(1).max(500).default(200), +}) + +/** + * Body for POST /api/dimensions/tagging/apply. One dimensions object applied + * to every listed line via the retag_line_dimensions RPC (the UI groups + * selected lines by their computed resulting map and issues one POST per + * distinct map). `dimensions` reuses THE bag schema so validation cannot + * drift from the engine/API layers; an empty bag is allowed — replace mode + * uses it to clear phantom tags. `reason` mirrors the RPC's >= 3 chars CHECK. + */ +export const DimensionTaggingApplySchema = z.object({ + line_ids: z.array(uuid).min(1).max(500), + dimensions: DimensionsBagSchema, + reason: z.string().trim().min(3).max(500), +}) diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index e502206f..aeb8f58a 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -210,6 +210,8 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_list_dimension_values: 'reports:read', gnubok_create_dimension_value: 'bookkeeping:write', gnubok_get_dimension_pnl: 'reports:read', + // Staged bulk retag of posted-line dimensions (dimensions PR6). + gnubok_tag_journal_lines: 'bookkeeping:write', // Document inbox gnubok_upload_document: 'transactions:write', gnubok_list_inbox_items: 'transactions:read', diff --git a/lib/pending-operations/__tests__/retag-line-dimensions-executor.test.ts b/lib/pending-operations/__tests__/retag-line-dimensions-executor.test.ts new file mode 100644 index 00000000..e659fa6b --- /dev/null +++ b/lib/pending-operations/__tests__/retag-line-dimensions-executor.test.ts @@ -0,0 +1,233 @@ +/** + * commitRetagLineDimensions — executor tests (dimensions PR6). + * + * The executor is private to lib/pending-operations/commit.ts and reached + * through commitPendingOperation, same pattern as + * dimension-value-executor.test.ts. Staging-side coverage (the MCP tool's + * filter matching + cap gates) lives in + * extensions/general/mcp-server/__tests__/tag-journal-lines.test.ts. The RPC + * itself (period/lock/registry/role enforcement) is covered by + * tests/pg/dimension-retag.pg.test.ts. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { eventBus } from '@/lib/events' +import type { PendingOperation } from '@/types' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), + createServiceClient: vi.fn(), +})) + +import { commitPendingOperation } from '../commit' + +const uuidAt = (i: number) => `00000000-0000-4000-8000-${String(i).padStart(12, '0')}` + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'retag_line_dimensions', + status: 'pending', + title: 'test', + params: {}, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'medium', + created_at: '2026-07-02T00:00:00Z', + resolved_at: null, + updated_at: '2026-07-02T00:00:00Z', + ...overrides, + } as PendingOperation +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe('commitPendingOperation: retag_line_dimensions — schema validation', () => { + it('rejects a non-UUID line id at the commit boundary (tampered params)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher reject update + + const op = makePendingOp({ + params: { line_ids: ['not-a-uuid'], dimensions: { '6': 'P01' }, reason: 'Rätt projekt' }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/Invalid line_ids/) + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('rejects more than 500 line_ids', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher reject update + + const op = makePendingOp({ + params: { + line_ids: Array.from({ length: 501 }, (_, i) => uuidAt(i)), + dimensions: { '6': 'P01' }, + reason: 'Rätt projekt', + }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/Invalid line_ids.*capped at 500/) + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('rejects a missing reason', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher reject update + + const op = makePendingOp({ + params: { line_ids: [uuidAt(1)], dimensions: { '6': 'P01' } }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/Invalid reason/) + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('rejects an empty dimensions bag (retag never bulk-clears)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher reject update + + const op = makePendingOp({ + params: { line_ids: [uuidAt(1)], dimensions: {}, reason: 'Rensa allt' }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/Invalid dimensions/) + expect(supabase.rpc).not.toHaveBeenCalled() + }) +}) + +describe('commitPendingOperation: retag_line_dimensions — execution', () => { + it('happy path: one RPC call per line, aggregates changed/unchanged', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { changed: true, log_id: 'log-1' }, error: null }) // line 1 rpc + enqueue({ data: { changed: false, log_id: null }, error: null }) // line 2 rpc (already tagged) + enqueue({ data: null, error: null }) // finalize update + + const op = makePendingOp({ + params: { + line_ids: [uuidAt(1), uuidAt(2)], + dimensions: { '1': 'KS01', '6': 'P01' }, + reason: 'Retro-taggning av projektet', + filter_summary: 'konto 4010, datum 2024-01-01–2024-12-31', + }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + retagged: 1, + unchanged: 1, + failed_count: 0, + failed: [], + dimensions: { '1': 'KS01', '6': 'P01' }, + filter_summary: 'konto 4010, datum 2024-01-01–2024-12-31', + }) + + const rpc = supabase.rpc as ReturnType + expect(rpc).toHaveBeenCalledTimes(2) + expect(rpc.mock.calls[0][0]).toBe('retag_line_dimensions') + expect(rpc.mock.calls[0][1]).toEqual({ + p_company_id: 'company-1', + p_line_id: uuidAt(1), + p_dimensions: { '1': 'KS01', '6': 'P01' }, + p_reason: 'Retro-taggning av projektet', + p_user_id: 'user-1', + }) + expect(rpc.mock.calls[1][1]).toMatchObject({ p_line_id: uuidAt(2) }) + }) + + it('partial failure: continues past a failing line and reports it', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { changed: true, log_id: 'log-1' }, error: null }) // line 1 ok + enqueue({ data: null, error: { message: 'Perioden är låst — använd rättelseverifikat (storno).' } }) // line 2 fails + enqueue({ data: { changed: true, log_id: 'log-3' }, error: null }) // line 3 ok + enqueue({ data: null, error: null }) // finalize update + + const op = makePendingOp({ + params: { + line_ids: [uuidAt(1), uuidAt(2), uuidAt(3)], + dimensions: { '6': 'P01' }, + reason: 'Retro-taggning', + }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ retagged: 2, unchanged: 0, failed_count: 1 }) + expect(result.data?.failed).toEqual([ + { line_id: uuidAt(2), error: 'Perioden är låst — använd rättelseverifikat (storno).' }, + ]) + expect(supabase.rpc).toHaveBeenCalledTimes(3) + }) + + it('caps the echoed failures at 20 but counts them all', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { changed: true, log_id: 'log-1' }, error: null }) // line 1 ok + for (let i = 0; i < 22; i++) { + enqueue({ data: null, error: { message: `fel ${i}` } }) + } + enqueue({ data: null, error: null }) // finalize update + + const op = makePendingOp({ + params: { + line_ids: Array.from({ length: 23 }, (_, i) => uuidAt(i)), + dimensions: { '6': 'P01' }, + reason: 'Retro-taggning', + }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ retagged: 1, failed_count: 22 }) + expect((result.data?.failed as unknown[]).length).toBe(20) + }) + + it('fails the operation when EVERY line fails', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: { message: 'Verifikationsraden hittades inte.' } }) + enqueue({ data: null, error: { message: 'Verifikationsraden hittades inte.' } }) + enqueue({ data: null, error: null }) // dispatcher reject update + + const op = makePendingOp({ + params: { + line_ids: [uuidAt(1), uuidAt(2)], + dimensions: { '6': 'P01' }, + reason: 'Retro-taggning', + }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/Ingen rad kunde taggas om \(2 rader misslyckades\)/) + expect(result.error).toMatch(/Verifikationsraden hittades inte/) + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 06051677..99515d0c 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -75,6 +75,7 @@ import { appendProcessingHistory } from '@/lib/processing-history/append' import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier' import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article' import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value' +import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions' import { BulkBookInboxSchema } from '@/lib/api/schemas' import { ensureArticleNumber } from '@/lib/articles/ensure-article-number' import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account' @@ -557,6 +558,83 @@ async function commitCreateDimensionValue( } } +/** + * Executor for the staged retag_line_dimensions operation + * (gnubok_tag_journal_lines — dimensions PR6). Loops the staged line_ids + * through the retag_line_dimensions RPC — the ONE audited write path for + * changing dimension tags on posted lines. The RPC enforces everything per + * line at commit time (open period, company lock date, active registry + * values, writer role, posted status) and writes an immutable + * dimension_retag_log row before touching the line. + * + * Partial-success semantics: one line failing (e.g. its period was locked + * between staging and approval) must not roll back the lines already + * retagged — each RPC call is its own transaction. Failures are collected + * and echoed (capped at 20) so the caller can re-stage just the failed set. + * Only when EVERY line fails does the operation as a whole fail. + */ +async function commitRetagLineDimensions( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + // Defense in depth: re-validate the staged params at the commit boundary so + // a tampered pending_operations row cannot inject arbitrary ids or a + // malformed bag (ASVS V4.5) — mirrors commitCreateDimensionValue. + let validated + try { + validated = RetagLineDimensionsParamsSchema.parse(params) + } catch (err) { + if (err instanceof z.ZodError) { + const issue = err.issues[0] + const path = issue?.path?.join('.') ?? 'params' + return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 } + } + throw err + } + + let retagged = 0 + let unchanged = 0 + const failed: Array<{ line_id: string; error: string }> = [] + + for (const lineId of validated.line_ids) { + const { data, error } = await supabase.rpc('retag_line_dimensions', { + p_company_id: companyId, + p_line_id: lineId, + p_dimensions: validated.dimensions, + p_reason: validated.reason, + p_user_id: userId, + }) + if (error) { + failed.push({ line_id: lineId, error: error.message }) + continue + } + if ((data as { changed?: boolean } | null)?.changed) retagged++ + else unchanged++ + } + + if (failed.length > 0 && retagged === 0 && unchanged === 0) { + return { + error: `Ingen rad kunde taggas om (${failed.length} rader misslyckades). Första felet: ${failed[0].error}`, + status: 400, + } + } + + return { + data: { + retagged, + unchanged, + failed_count: failed.length, + // Echo at most 20 failures — enough to act on without bloating + // result_data on a pathological 500-line all-but-one failure. + failed: failed.slice(0, 20), + dimensions: validated.dimensions, + ...(validated.filter_summary ? { filter_summary: validated.filter_summary } : {}), + }, + } +} + async function commitCreateTransaction( supabase: SupabaseClient, userId: string, @@ -3654,6 +3732,9 @@ async function commitPendingOperationInner( case 'create_dimension_value': result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params) break + case 'retag_line_dimensions': + result = await commitRetagLineDimensions(supabase, userId, companyId, pendingOp.params) + break case 'create_invoice': result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params) break diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index 811923ff..7cc8cfe7 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -63,6 +63,10 @@ export const OPERATION_RISK_TIERS: Record = { // (BFL 5 kap 6 §) and becomes immutable once the JE is posted. Medium so a // human confirms the doc-to-verifikat pairing before it locks. link_document_to_voucher: 'medium', + // Dimension-only diff on posted lines (verifikat stays immutable), fully + // audited via dimension_retag_log — but it rewrites reporting history, so + // it crosses a human at medium. + retag_line_dimensions: 'medium', // ── High: irreversible, compliance-critical, or external side-effects send_invoice: 'high', // emails the customer diff --git a/lib/pending-operations/schemas/retag-line-dimensions.ts b/lib/pending-operations/schemas/retag-line-dimensions.ts new file mode 100644 index 00000000..e56b1a18 --- /dev/null +++ b/lib/pending-operations/schemas/retag-line-dimensions.ts @@ -0,0 +1,60 @@ +/** + * Authoritative server-side validation for the retag_line_dimensions staged + * operation (dimensions PR6 — retro-tagging). Used by: + * - The MCP tool gnubok_tag_journal_lines execute() before staging + * (extensions/general/mcp-server/server.ts) + * - commitRetagLineDimensions() before looping the retag_line_dimensions + * RPC (lib/pending-operations/commit.ts) + * + * Defense in depth: validating at the commit boundary protects the DB even if + * a caller writes directly to pending_operations.params bypassing the MCP + * tool (ASVS V4.5 / ISO A.8.28), mirroring CreateDimensionValueParamsSchema. + * The RPC itself re-enforces everything per line (open period, lock date, + * active registry values, writer role) — this schema is the shape gate. + * + * The dimensions bag delegates to DimensionsBagSchema — THE bag schema shared + * with the API layer and the voucher staging path — so the retag write path + * cannot drift from how dimensions are validated everywhere else. An empty + * bag is rejected: this operation tags lines, it never bulk-clears them. + */ +import { z } from 'zod' +import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver' + +/** + * 500-line cap per staged retag (dev_docs plan §3): keeps the approval + * preview reviewable by a human and bounds the per-line RPC loop at commit. + */ +export const RETAG_MAX_LINES = 500 + +export const RetagLineDimensionsParamsSchema = z + .object({ + line_ids: z + .array(z.string().uuid('line_ids must contain journal_entry_lines UUIDs')) + .min(1, 'line_ids must contain at least one line') + .max(RETAG_MAX_LINES, `line_ids is capped at ${RETAG_MAX_LINES} lines per operation`), + // Non-empty by design — and deliberately STRICTER than the direct API + // path (RetagLineDimensionsSchema in lib/api/schemas.ts), which allows + // {} so a human can untag phantom codes via the dialog/workbench. An + // agent bulk-clearing dimension history is not a stageable operation + // (#867 review documented the divergence). + dimensions: DimensionsBagSchema.refine( + (bag) => Object.keys(bag).length > 0, + 'dimensions must contain at least one {sie_dim_no: code} pair', + ), + reason: z.preprocess( + (v) => (typeof v === 'string' ? v.trim() : v), + z + .string() + .min(3, 'Ange en anledning till ändringen (minst 3 tecken)') + .max(500, 'reason is capped at 500 characters'), + ), + /** + * Human description of how the lines were selected (the tool's filter + * block), carried only for the approval preview / audit context — the + * executor never re-runs the filter, it acts on line_ids verbatim. + */ + filter_summary: z.string().max(500).optional(), + }) + .strict() + +export type RetagLineDimensionsParams = z.infer diff --git a/supabase/migrations/20260702170000_dimension_retag_log_and_rpc.sql b/supabase/migrations/20260702170000_dimension_retag_log_and_rpc.sql new file mode 100644 index 00000000..471d753e --- /dev/null +++ b/supabase/migrations/20260702170000_dimension_retag_log_and_rpc.sql @@ -0,0 +1,284 @@ +-- Dimensions plan PR6 (retro-tagging Tier 2) — founder decision №1 APPROVED +-- 2026-07-02 (dev_docs/dimensions_implementation_plan.md §3, §8). +-- +-- Posted entries in OPEN periods may have their dimension tags changed +-- through ONE audited path: the retag_line_dimensions RPC. Everything about +-- the verifikat itself (accounts, amounts, description, currency, linkage) +-- stays absolutely immutable — the line-immutability trigger gains a single +-- narrow carve-out that permits an UPDATE iff every non-dimension column is +-- unchanged, and only while the transaction-local GUC set by the RPC is +-- active. +-- +-- Legal position (recorded in the plan): BFL 5 kap 7§'s mandatory verifikat +-- content does not include kontering/dimension coding — dimensions are +-- internredovisning metadata. Fortnox and Visma both permit editing +-- KS/projekt on posted vouchers in open periods without a +-- rättelseverifikation. This design is strictly more conservative than both: +-- dimension-only diffs, open periods only, company lock date honored, +-- immutable before/after log, storno past locks (Tier 3 — no exceptions). +-- +-- The carve-out follows the sanctioned precedent of +-- 20260613120000_mark_entry_as_opening_balance.sql (entries-trigger +-- source_type retag: GUC + whole-row to_jsonb diff). The mirror columns +-- cost_center/project are included in the changeable set because they are +-- derived views of dimensions['1']/['6'] (dual-write invariant from the +-- substrate migration) — leaving them stale would split every report. +-- +-- pg-test: tests/pg/dimension-retag.pg.test.ts + +-- ============================================================================= +-- 1. dimension_retag_log — immutable before/after audit trail +-- ============================================================================= +-- Audit-trail semantics like audit_log: no FKs to lines/entries, so the log +-- survives hard-deletes (undo_sie_import) — behandlingshistorik must not +-- vanish with its subject. company FK keeps tenant lifecycle. + +CREATE TABLE public.dimension_retag_log ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + journal_entry_id uuid NOT NULL, + line_id uuid NOT NULL, + old_dimensions jsonb NOT NULL, + new_dimensions jsonb NOT NULL, + actor uuid, + reason text NOT NULL CHECK (length(btrim(reason)) >= 3), + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.dimension_retag_log ENABLE ROW LEVEL SECURITY; + +-- Read-only for members; INSERT happens exclusively inside the SECURITY +-- DEFINER RPC (no INSERT/UPDATE/DELETE policies on purpose). +CREATE POLICY "view own-company dimension_retag_log" + ON public.dimension_retag_log FOR SELECT + USING (company_id IN (SELECT user_company_ids())); + +CREATE INDEX idx_dimension_retag_log_entry + ON public.dimension_retag_log (company_id, journal_entry_id); +CREATE INDEX idx_dimension_retag_log_line + ON public.dimension_retag_log (line_id); + +-- INSERT-only: the log is itself räkenskapsinformation-adjacent audit trail. +CREATE OR REPLACE FUNCTION public.dimension_retag_log_immutable() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +BEGIN + RAISE EXCEPTION 'dimension_retag_log är oföränderlig — rader kan inte ändras eller tas bort.'; +END; +$$; + +CREATE TRIGGER dimension_retag_log_immutable + BEFORE UPDATE OR DELETE ON public.dimension_retag_log + FOR EACH ROW EXECUTE FUNCTION public.dimension_retag_log_immutable(); + +-- ============================================================================= +-- 2. Line-immutability carve-out (append-only replacement; precedent: +-- the function has been replaced three times, see 20260415000000 §4d) +-- ============================================================================= +-- The whole-row to_jsonb diff makes the protection exhaustive BY +-- CONSTRUCTION: any column added to journal_entry_lines in the future is +-- automatically immutable under the GUC until explicitly exempted here. +-- (journal_entry_lines has no updated_at column, so no timestamp exemption.) + +CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ +DECLARE v_status text; +BEGIN + IF current_setting('gnubok.allow_delete', true) = 'true' THEN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; + END IF; + + SELECT status INTO v_status FROM public.journal_entries + WHERE id = COALESCE(OLD.journal_entry_id, NEW.journal_entry_id); + + -- Dimension retag carve-out (dimensions plan PR6, founder-approved): + -- while the transaction-local GUC set by retag_line_dimensions is active, + -- permit UPDATE of a POSTED line iff ONLY the dimension columns change — + -- dimensions (source of truth) and its derived mirrors cost_center/project. + -- Account, amounts, description, currency fields, sort order and entry + -- linkage remain absolutely immutable. + IF TG_OP = 'UPDATE' + AND v_status = 'posted' + AND current_setting('gnubok.allow_dimension_retag', true) = 'true' + AND (to_jsonb(NEW) - 'dimensions' - 'cost_center' - 'project') + = (to_jsonb(OLD) - 'dimensions' - 'cost_center' - 'project') THEN + RETURN NEW; + END IF; + + IF v_status = 'draft' THEN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; + END IF; + + IF v_status = 'cancelled' THEN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RAISE EXCEPTION 'Cannot % lines of a cancelled journal entry.', TG_OP; + END IF; + + RAISE EXCEPTION 'Cannot % lines of a % journal entry.', TG_OP, v_status; +END; $function$; + +-- Restore the hardening applied by 20260304191528 (CREATE OR REPLACE would +-- otherwise leave the new definition without a pinned search_path). +ALTER FUNCTION public.enforce_journal_entry_line_immutability() SET search_path = public; + +-- ============================================================================= +-- 3. retag_line_dimensions — the ONE write path +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.retag_line_dimensions( + p_company_id uuid, + p_line_id uuid, + p_dimensions jsonb, + p_reason text, + p_user_id uuid DEFAULT NULL +) + RETURNS jsonb + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_actor uuid := COALESCE(p_user_id, auth.uid()); + v_caller_role text; + v_line record; + v_is_closed boolean; + v_locked_at timestamptz; + v_lock_date date; + v_key text; + v_value text; + v_log_id uuid; +BEGIN + -- Tenant guard (20260619130100 pattern): anon/authenticated JWTs must be + -- members; service_role/no-JWT callers are scoped by the application layer. + IF v_jwt_role IN ('anon', 'authenticated') + AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN + RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id + USING ERRCODE = '42501'; + END IF; + + -- Writer gate: any member except viewers (Fortnox parity — retag is + -- ordinary bookkeeping work, not an admin operation). + SELECT cm.role INTO v_caller_role + FROM company_members cm + WHERE cm.company_id = p_company_id + AND cm.user_id = v_actor; + + IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin', 'member') THEN + RAISE EXCEPTION 'Endast användare med skrivbehörighet kan ändra dimensioner.'; + END IF; + + IF p_reason IS NULL OR length(btrim(p_reason)) < 3 THEN + RAISE EXCEPTION 'Ange en anledning till ändringen (minst 3 tecken).'; + END IF; + + IF p_dimensions IS NULL OR jsonb_typeof(p_dimensions) <> 'object' THEN + RAISE EXCEPTION 'Dimensionerna måste vara ett objekt ({"1":"KS01","6":"P001"}).'; + END IF; + + -- Lock the line + parent entry state. + SELECT jel.id, jel.dimensions, je.id AS entry_id, je.status, je.entry_date, + je.fiscal_period_id, je.company_id AS entry_company_id + INTO v_line + FROM public.journal_entry_lines jel + JOIN public.journal_entries je ON je.id = jel.journal_entry_id + WHERE jel.id = p_line_id + FOR UPDATE OF jel; + + IF NOT FOUND OR v_line.entry_company_id <> p_company_id THEN + RAISE EXCEPTION 'Verifikationsraden hittades inte.'; + END IF; + + IF v_line.status <> 'posted' THEN + RAISE EXCEPTION 'Endast rader på bokförda verifikat kan taggas om (utkast redigeras direkt).'; + END IF; + + -- Tier boundaries: open periods only, company lock date honored. + SELECT fp.is_closed, fp.locked_at INTO v_is_closed, v_locked_at + FROM public.fiscal_periods fp + WHERE fp.id = v_line.fiscal_period_id; + + IF v_is_closed THEN + RAISE EXCEPTION 'Perioden är stängd — använd rättelseverifikat (storno) för att ändra dimensioner.'; + END IF; + IF v_locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Perioden är låst — använd rättelseverifikat (storno) för att ändra dimensioner.'; + END IF; + + SELECT cs.bookkeeping_locked_through INTO v_lock_date + FROM public.company_settings cs + WHERE cs.company_id = p_company_id; + + IF v_lock_date IS NOT NULL AND v_line.entry_date <= v_lock_date THEN + RAISE EXCEPTION 'Bokföringen är låst t.o.m. % — använd rättelseverifikat (storno).', v_lock_date; + END IF; + + -- Validate every (dimension, code) pair against the ACTIVE registry. + -- Retag is a deliberate act on history — unlike import passthrough it + -- must reference real, active registry values (same posture as the + -- engine's soft validation for NEW entries). + FOR v_key, v_value IN SELECT key, value FROM jsonb_each_text(p_dimensions) + LOOP + IF v_key !~ '^[1-9][0-9]{0,3}$' THEN + RAISE EXCEPTION 'Ogiltigt dimensionsnummer: %.', v_key; + END IF; + IF v_value IS NULL OR length(btrim(v_value)) = 0 THEN + RAISE EXCEPTION 'Dimension % saknar kod.', v_key; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM public.dimensions d + JOIN public.dimension_values dv + ON dv.dimension_id = d.id AND dv.company_id = d.company_id + WHERE d.company_id = p_company_id + AND d.sie_dim_no = v_key::int + AND d.is_active + AND dv.code = v_value + AND dv.is_active + ) THEN + RAISE EXCEPTION 'Värdet "%" finns inte som aktivt värde för dimension % — registrera eller återaktivera det först.', v_value, v_key; + END IF; + END LOOP; + + -- Idempotent no-op: nothing to log, nothing to write. + IF v_line.dimensions = p_dimensions THEN + RETURN jsonb_build_object('changed', false, 'log_id', NULL); + END IF; + + -- Immutable before/after audit row FIRST — the trigger carve-out is only + -- ever exercised in a transaction that has already recorded the change. + INSERT INTO public.dimension_retag_log + (company_id, journal_entry_id, line_id, old_dimensions, new_dimensions, actor, reason) + VALUES + (p_company_id, v_line.entry_id, p_line_id, v_line.dimensions, p_dimensions, v_actor, btrim(p_reason)) + RETURNING id INTO v_log_id; + + -- Transaction-local GUC → the carve-out admits exactly this UPDATE. + PERFORM set_config('gnubok.allow_dimension_retag', 'true', true); + + UPDATE public.journal_entry_lines + SET dimensions = p_dimensions, + cost_center = NULLIF(p_dimensions ->> '1', ''), + project = NULLIF(p_dimensions ->> '6', '') + WHERE id = p_line_id; + + RETURN jsonb_build_object( + 'changed', true, + 'log_id', v_log_id, + 'old_dimensions', v_line.dimensions, + 'new_dimensions', p_dimensions + ); +END; +$function$; + +REVOKE ALL ON FUNCTION public.retag_line_dimensions(uuid, uuid, jsonb, text, uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.retag_line_dimensions(uuid, uuid, jsonb, text, uuid) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260702171000_pending_operations_add_retag_line_dimensions.sql b/supabase/migrations/20260702171000_pending_operations_add_retag_line_dimensions.sql new file mode 100644 index 00000000..d12a6c47 --- /dev/null +++ b/supabase/migrations/20260702171000_pending_operations_add_retag_line_dimensions.sql @@ -0,0 +1,77 @@ +-- Add 'retag_line_dimensions' to the pending_operations operation_type CHECK +-- constraint. +-- +-- The MCP tool gnubok_tag_journal_lines (dimensions PR6 — retro-tagging, +-- dev_docs/dimensions_implementation_plan.md §3) stages a pending operation +-- that, on approval, dispatches into commitRetagLineDimensions. That executor +-- loops the staged line_ids through the retag_line_dimensions RPC +-- (20260702170000) — the ONE audited write path for changing dimension tags +-- on posted lines. The RPC enforces everything per line at commit time: open +-- period, company lock date, active registry values, writer role, and it +-- writes an immutable dimension_retag_log row before touching the line. +-- Without this expansion the staged INSERT would be rejected by the +-- constraint before the commit-side code ever runs, blocking the +-- staged-operation review flow — mirrors create_dimension_value. +-- +-- Risk tier (lib/pending-operations/risk-tiers.ts): 'medium' — dimension-only +-- diff on posted lines (accounts, amounts, description stay immutable), fully +-- audited via dimension_retag_log, no external side-effects. But it rewrites +-- reporting history on up to 500 lines at once, so it always crosses a human. +-- +-- pg-test: covered-by — CHECK-list expansion only (no trigger/RPC/RLS/ +-- DEFERRABLE change), so no *.pg.test.ts is required. Mirrors +-- 20260702130000_pending_operations_add_create_dimension_value.sql. The RPC +-- itself is covered by tests/pg/dimension-retag.pg.test.ts (20260702170000). + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', + 'generate_agi', + 'link_transaction_journal_entry', + 'link_supplier_invoice_voucher', + 'submit_vat_declaration', + 'submit_agi', + 'create_article', + 'update_article', + 'bulk_book_inbox_items', + 'create_dimension_value', -- dimensions registry: stage a new kostnadsställe/projekt value (SIE #OBJEKT) + 'retag_line_dimensions' -- dimensions retag: bulk-tag posted lines via the audited retag RPC (PR6) + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/dimension-retag.pg.test.ts b/tests/pg/dimension-retag.pg.test.ts new file mode 100644 index 00000000..eab3be27 --- /dev/null +++ b/tests/pg/dimension-retag.pg.test.ts @@ -0,0 +1,369 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { + seedCompany, + insertAuthUser, + insertCompanyMember, + insertDraftJournalEntry, +} from '@/tests/pg/fixtures' + +// Migration 20260702170000_dimension_retag_log_and_rpc.sql — the founder- +// approved Tier-2 retro-tagging carve-out (dimensions plan PR6, §3). +// +// The mandatory suite from the plan: +// 1. a GUC-less UPDATE of a posted line's dimensions is still blocked +// 2. retag is blocked in closed/locked periods and behind the lock date +// 3. amounts (or any non-dimension column) can never change under the GUC +// 4. the RPC never updates without an audit row +// 5. the delete_last_voucher/undo GUC path (gnubok.allow_delete) is +// unaffected +// plus registry validation, role gates, log immutability, mirror sync and +// the untag path. + +async function insertPostedTaggedEntry(params: { + companyId: string + userId: string + fiscalPeriodId: string + dimensions?: Record + entryDate?: string + voucherNumber?: number +}): Promise<{ entryId: string; lineId: string }> { + const entryId = await insertDraftJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + sourceType: 'manual', + status: 'draft', + voucherNumber: params.voucherNumber ?? 1, + entryDate: params.entryDate, + }) + const dims = params.dimensions ?? {} + const { rows } = await getPool().query<{ id: string }>( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount, dimensions, cost_center, project) + VALUES ($1, '5010', 1000, 0, $2::jsonb, $3, $4) + RETURNING id`, + [entryId, JSON.stringify(dims), dims['1'] ?? null, dims['6'] ?? null], + ) + await getPool().query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', 0, 1000)`, + [entryId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId]) + return { entryId, lineId: rows[0].id } +} + +async function insertRegistryValue(params: { + companyId: string + sieDimNo: number + code: string + isActive?: boolean + dimIsActive?: boolean +}): Promise { + const { rows } = await getPool().query<{ id: string }>( + `INSERT INTO public.dimensions (company_id, sie_dim_no, name, resets_annually, is_system, is_active) + VALUES ($1, $2::int, 'Dim ' || $2::text, true, false, $3) + ON CONFLICT (company_id, sie_dim_no) DO UPDATE SET is_active = EXCLUDED.is_active + RETURNING id`, + [params.companyId, params.sieDimNo, params.dimIsActive ?? true], + ) + await getPool().query( + `INSERT INTO public.dimension_values (company_id, dimension_id, code, name, is_active) + VALUES ($1, $2, $3, $3, $4) + ON CONFLICT (company_id, dimension_id, code) DO UPDATE SET is_active = EXCLUDED.is_active`, + [params.companyId, rows[0].id, params.code, params.isActive ?? true], + ) +} + +async function callRetag( + companyId: string, + lineId: string, + dimensions: Record, + reason: string, + actor: string, +) { + return getPool().query<{ result: { changed: boolean; log_id: string | null } }>( + `SELECT public.retag_line_dimensions($1::uuid, $2::uuid, $3::jsonb, $4, $5::uuid) AS result`, + [companyId, lineId, JSON.stringify(dimensions), reason, actor], + ) +} + +async function lineState(lineId: string) { + const { rows } = await getPool().query( + `SELECT dimensions, cost_center, project, debit_amount FROM public.journal_entry_lines WHERE id = $1`, + [lineId], + ) + return rows[0] +} + +describe('dimension retag carve-out (PR6)', () => { + it('still blocks a GUC-less dimensions UPDATE on a posted line', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + const { lineId } = await insertPostedTaggedEntry({ companyId, userId, fiscalPeriodId }) + + await expect( + getPool().query( + `UPDATE public.journal_entry_lines SET dimensions = '{"6":"P001"}'::jsonb WHERE id = $1`, + [lineId], + ), + ).rejects.toThrow(/Cannot UPDATE lines of a posted journal entry/) + }) + + it('retags happy path: dimensions + mirrors updated, immutable log row written', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + await insertRegistryValue({ companyId, sieDimNo: 1, code: 'KS01' }) + await insertRegistryValue({ companyId, sieDimNo: 6, code: 'P001' }) + const { entryId, lineId } = await insertPostedTaggedEntry({ + companyId, userId, fiscalPeriodId, + dimensions: { '6': 'GAMMAL' }, + }) + // GAMMAL never registered — old values need no registry presence. + + const res = await callRetag(companyId, lineId, { '1': 'KS01', '6': 'P001' }, 'Rätt projekt', userId) + expect(res.rows[0].result.changed).toBe(true) + expect(res.rows[0].result.log_id).toBeTruthy() + + const line = await lineState(lineId) + expect(line.dimensions).toEqual({ '1': 'KS01', '6': 'P001' }) + expect(line.cost_center).toBe('KS01') + expect(line.project).toBe('P001') + + const { rows: log } = await getPool().query( + `SELECT old_dimensions, new_dimensions, actor, reason, journal_entry_id + FROM public.dimension_retag_log WHERE line_id = $1`, + [lineId], + ) + expect(log).toHaveLength(1) + expect(log[0].old_dimensions).toEqual({ '6': 'GAMMAL' }) + expect(log[0].new_dimensions).toEqual({ '1': 'KS01', '6': 'P001' }) + expect(log[0].actor).toBe(userId) + expect(log[0].reason).toBe('Rätt projekt') + expect(log[0].journal_entry_id).toBe(entryId) + }) + + it('supports untagging with {} and NULLs the mirrors', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + const { lineId } = await insertPostedTaggedEntry({ + companyId, userId, fiscalPeriodId, + dimensions: { '1': 'KS01', '6': 'P001' }, + }) + + const res = await callRetag(companyId, lineId, {}, 'Feltaggad rad', userId) + expect(res.rows[0].result.changed).toBe(true) + + const line = await lineState(lineId) + expect(line.dimensions).toEqual({}) + expect(line.cost_center).toBeNull() + expect(line.project).toBeNull() + }) + + it('is an idempotent no-op (no log row) when the dimensions are unchanged', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + await insertRegistryValue({ companyId, sieDimNo: 6, code: 'P001' }) + const { lineId } = await insertPostedTaggedEntry({ + companyId, userId, fiscalPeriodId, + dimensions: { '6': 'P001' }, + }) + + const res = await callRetag(companyId, lineId, { '6': 'P001' }, 'Ingen ändring', userId) + expect(res.rows[0].result.changed).toBe(false) + + const { rows: log } = await getPool().query( + `SELECT 1 FROM public.dimension_retag_log WHERE line_id = $1`, + [lineId], + ) + expect(log).toHaveLength(0) + }) + + it('never lets amounts (or any non-dimension column) change under the GUC', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + const { lineId } = await insertPostedTaggedEntry({ companyId, userId, fiscalPeriodId }) + + const client = await getPool().connect() + try { + await client.query('BEGIN') + await client.query(`SELECT set_config('gnubok.allow_dimension_retag', 'true', true)`) + // Amount edit smuggled alongside a dimension change → carve-out must NOT admit it. + await expect( + client.query( + `UPDATE public.journal_entry_lines + SET dimensions = '{"6":"P001"}'::jsonb, debit_amount = 999999 + WHERE id = $1`, + [lineId], + ), + ).rejects.toThrow(/Cannot UPDATE lines of a posted journal entry/) + await client.query('ROLLBACK') + + await client.query('BEGIN') + await client.query(`SELECT set_config('gnubok.allow_dimension_retag', 'true', true)`) + await expect( + client.query( + `UPDATE public.journal_entry_lines SET line_description = 'hacked' WHERE id = $1`, + [lineId], + ), + ).rejects.toThrow(/Cannot UPDATE lines of a posted journal entry/) + await client.query('ROLLBACK') + + // A pure dimension diff IS admitted under the GUC (the RPC's write shape). + await client.query('BEGIN') + await client.query(`SELECT set_config('gnubok.allow_dimension_retag', 'true', true)`) + await client.query( + `UPDATE public.journal_entry_lines + SET dimensions = '{"6":"P001"}'::jsonb, project = 'P001' + WHERE id = $1`, + [lineId], + ) + await client.query('ROLLBACK') + } finally { + client.release() + } + + // The GUC was transaction-local: outside it, updates are blocked again. + await expect( + getPool().query( + `UPDATE public.journal_entry_lines SET dimensions = '{"6":"P001"}'::jsonb WHERE id = $1`, + [lineId], + ), + ).rejects.toThrow(/Cannot UPDATE lines of a posted journal entry/) + }) + + it('rejects retag in closed and locked periods', async () => { + // Post first, then close — the period-lock trigger (correctly) refuses + // inserts into an already-closed period. + const closed = await seedCompany() + const { lineId: closedLine } = await insertPostedTaggedEntry({ + companyId: closed.companyId, userId: closed.userId, fiscalPeriodId: closed.fiscalPeriodId, + }) + await getPool().query( + `UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`, + [closed.fiscalPeriodId], + ) + await expect( + callRetag(closed.companyId, closedLine, {}, 'Testar stängd', closed.userId), + ).rejects.toThrow(/stängd/) + + const locked = await seedCompany() + const { lineId: lockedLine } = await insertPostedTaggedEntry({ + companyId: locked.companyId, userId: locked.userId, fiscalPeriodId: locked.fiscalPeriodId, + }) + await getPool().query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [ + locked.fiscalPeriodId, + ]) + await expect( + callRetag(locked.companyId, lockedLine, {}, 'Testar låst', locked.userId), + ).rejects.toThrow(/låst/) + }) + + it('rejects retag behind the company lock date', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + const { lineId } = await insertPostedTaggedEntry({ + companyId, userId, fiscalPeriodId, entryDate: '2026-03-15', + }) + await getPool().query( + `INSERT INTO public.company_settings (user_id, company_id, bookkeeping_locked_through) + VALUES ($1, $2, '2026-06-30') + ON CONFLICT (company_id) DO UPDATE SET bookkeeping_locked_through = '2026-06-30'`, + [userId, companyId], + ) + + await expect(callRetag(companyId, lineId, {}, 'Bakom låsdatum', userId)).rejects.toThrow( + /låst t\.o\.m/, + ) + }) + + it('rejects viewers, non-members and drafts; requires a reason and active registry values', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + await insertRegistryValue({ companyId, sieDimNo: 6, code: 'P001' }) + await insertRegistryValue({ companyId, sieDimNo: 6, code: 'ARKIV', isActive: false }) + const { lineId } = await insertPostedTaggedEntry({ companyId, userId, fiscalPeriodId }) + + // Viewer + const viewerId = await insertAuthUser() + await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' }) + await expect(callRetag(companyId, lineId, { '6': 'P001' }, 'Som viewer', viewerId)).rejects.toThrow( + /skrivbehörighet/, + ) + + // Complete stranger + await expect( + callRetag(companyId, lineId, { '6': 'P001' }, 'Som främling', randomUUID()), + ).rejects.toThrow(/skrivbehörighet/) + + // Reason required + await expect(callRetag(companyId, lineId, { '6': 'P001' }, ' ', userId)).rejects.toThrow( + /anledning/i, + ) + + // Unknown + archived codes rejected + await expect(callRetag(companyId, lineId, { '6': 'FINNSEJ' }, 'Okänd kod', userId)).rejects.toThrow( + /finns inte som aktivt värde/, + ) + await expect(callRetag(companyId, lineId, { '6': 'ARKIV' }, 'Arkiverad kod', userId)).rejects.toThrow( + /finns inte som aktivt värde/, + ) + + // Draft lines are out of scope (edited directly instead) + const draftId = await insertDraftJournalEntry({ + userId, companyId, fiscalPeriodId, status: 'draft', voucherNumber: 99, + }) + const { rows } = await getPool().query<{ id: string }>( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '5010', 100, 0) RETURNING id`, + [draftId], + ) + await expect(callRetag(companyId, rows[0].id, { '6': 'P001' }, 'Utkast', userId)).rejects.toThrow( + /bokförda verifikat/, + ) + }) + + it("cannot reach another company's lines", async () => { + const a = await seedCompany() + const b = await seedCompany() + const { lineId } = await insertPostedTaggedEntry({ + companyId: a.companyId, userId: a.userId, fiscalPeriodId: a.fiscalPeriodId, + }) + + // b's owner passes b's company id but a's line id → not found. + await expect( + callRetag(b.companyId, lineId, {}, 'Cross-tenant', b.userId), + ).rejects.toThrow(/hittades inte/) + }) + + it('keeps the dimension_retag_log immutable', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + await insertRegistryValue({ companyId, sieDimNo: 6, code: 'P001' }) + const { lineId } = await insertPostedTaggedEntry({ companyId, userId, fiscalPeriodId }) + await callRetag(companyId, lineId, { '6': 'P001' }, 'Skapa loggrad', userId) + + const { rows } = await getPool().query<{ id: string }>( + `SELECT id FROM public.dimension_retag_log WHERE line_id = $1`, + [lineId], + ) + await expect( + getPool().query(`UPDATE public.dimension_retag_log SET reason = 'x' WHERE id = $1`, [rows[0].id]), + ).rejects.toThrow(/oföränderlig/) + await expect( + getPool().query(`DELETE FROM public.dimension_retag_log WHERE id = $1`, [rows[0].id]), + ).rejects.toThrow(/oföränderlig/) + }) + + it('leaves the gnubok.allow_delete bulk-delete path unaffected', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + const { entryId, lineId } = await insertPostedTaggedEntry({ companyId, userId, fiscalPeriodId }) + + const client = await getPool().connect() + try { + await client.query('BEGIN') + await client.query(`SELECT set_config('gnubok.allow_delete', 'true', true)`) + // Full bypass still deletes posted lines + entries (undo/replace flows). + await client.query(`DELETE FROM public.journal_entry_lines WHERE id = $1`, [lineId]) + await client.query(`DELETE FROM public.journal_entries WHERE id = $1`, [entryId]) + await client.query('ROLLBACK') + } finally { + client.release() + } + }) +}) diff --git a/types/index.ts b/types/index.ts index 09287264..da3821ff 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1836,6 +1836,9 @@ export type PendingOperationType = // Dimensions PR3: stage a new dimension value (kostnadsställe/projekt object // code, SIE #OBJEKT) — agents never silently mint reporting values. | 'create_dimension_value' + // Dimensions PR6: bulk retag of posted-line dimensions via the audited + // retag_line_dimensions RPC (gnubok_tag_journal_lines). + | 'retag_line_dimensions' export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected' // 'agent_chat' = the in-app AI chat (DB CHECK widened in migration