feat(dimensions): PR6 retro-tagging — audited retag carve-out, BulkTagWorkbench, staged MCP tool (#867)

* feat(dimensions): PR6 retro-tagging — audited retag carve-out, workbench, staged MCP tool

Tier-2 retro-tagging (founder decision №1, approved 2026-07-02): posted
entries in OPEN periods can have their dimension tags changed through ONE
audited path — everything about the verifikat itself stays immutable.

Carve-out (migration 20260702170000): the line-immutability trigger gains a
single narrow branch — while the transaction-local GUC set by the RPC is
active, an UPDATE of a posted line is admitted iff every non-dimension
column is unchanged, enforced by a whole-row to_jsonb diff (any future
column is protected by construction; mirrors cost_center/project are in the
changeable set because they are derived views of dimensions['1']/['6']).
Precedent: mark_entry_as_opening_balance (20260613120000).

retag_line_dimensions RPC: tenant guard (20260619130100 pattern), writer
gate (viewers rejected), posted-only, open period + company lock date
enforced, every code validated against the ACTIVE registry, immutable
dimension_retag_log row (before/after/actor/reason, INSERT-only via its own
trigger, no FKs so the trail survives hard-deletes) written BEFORE the
carve-out UPDATE. Idempotent no-op without a log row. Untag ({}) supported.
Legal position per the plan: dimensions are internredovisning metadata, not
BFL 5 kap 7§ verifikat content — this is strictly more conservative than
Fortnox/Visma (dimension-only diffs, open periods only, immutable log,
storno past locks — Tier 3 has no exceptions).

Mandatory pg suite (11 tests): GUC-less updates still blocked; amounts/
description can never change even under the GUC (transaction-local);
closed/locked/lock-date, role, registry, draft and cross-tenant rejections;
log immutability; gnubok.allow_delete bulk path unaffected.

UX (all writes through the ONE RPC): pencil on posted-voucher lines in
bookkeeping/[id] ("Påverkar endast internredovisningen, inte verifikatet")
+ retag-history card; BulkTagWorkbench at /dimensions/tagging (filters,
shift-select, merge vs "Ersätt tagg" replace mode, reversal-pair warning
with "Inkludera motverifikat" auto-selection, per-line failure display).

MCP: gnubok_tag_journal_lines (bookkeeping:write) — filter block resolved
via resolve-don't-select, ≤500 lines, staged via pending_operations (new
op type migration 20260702171000, medium risk tier, shared Zod validation
boundary between staging and commit; executor loops the RPC per line with
partial-success aggregation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): address #867 review — SQLSTATE classification, blocking storno confirm, documented divergence

- Retag route classifies RPC errors by SQLSTATE instead of message-regex:
  P0001 (every rule violation in the RPC) → 409 verbatim, 42501 (tenant
  guard) → 403, anything else → logged 500 with a generic message. No more
  substring sniffing.
- The workbench's storno-pair warning escalates to a BLOCKING confirmation
  naming the unselected counter-vouchers before apply (Srf U 14 gross
  reporting — one-legged retags silently skew project P&L; the banner alone
  was advisory).
- The empty-bag divergence is now documented on both schemas as intentional:
  the direct dialog/workbench path allows {} (human untags phantom codes,
  logged with reason), the MCP staged path rejects it (agents never
  bulk-clear history).

Triage notes: the log's missing FKs are the point (behandlingshistorik must
survive undo_sie_import hard-deletes — a cascade would erase the trail);
SIE exports are generated fresh on demand, never cached, so post-retag
exports carry the new object lists automatically; date-scoped registry
values are deliberately not enforced at retag because entry creation does
not enforce them either — enforcing in one path only would be incoherent
(both belong to the PR10 rules engine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 17:02:34 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fb3fe82a56
commit 816b1769c8
25 changed files with 3493 additions and 4 deletions
@@ -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')
})
})
@@ -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 ?? [] })
},
)
@@ -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: {} }),
)
})
})
@@ -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 },
)