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
parent fb3fe82a56
commit 816b1769c8
25 changed files with 3493 additions and 4 deletions
+87 -3
View File
@@ -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<DimensionDto[] | null>(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<RetagLine | null>(null)
const [retagLog, setRetagLog] = useState<
{ id: string; line_id: string; old_dimensions: Record<string, string>; new_dimensions: Record<string, string>; 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
<tr key={line.id} className="border-b last:border-0">
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
<span className="inline-flex items-center gap-1">
{line.line_description || ''}
{dimensionsEnabled && canWrite && entry.status === 'posted' && (
<button
type="button"
onClick={() => setRetagLine(line as unknown as RetagLine)}
className="p-1 rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary/60 transition-colors"
aria-label="Ändra dimensioner"
title="Ändra dimensioner (påverkar endast internredovisningen)"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</span>
{renderDimensionBadges(line)}
</td>
<td className="py-2 text-right tabular-nums">
@@ -685,7 +714,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
return (
<div key={line.id} className="flex items-center justify-between py-2 border-b last:border-0 gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm"><AccountNumber number={line.account_number} showName /></div>
<div className="text-sm flex items-center gap-1">
<AccountNumber number={line.account_number} showName />
{dimensionsEnabled && canWrite && entry.status === 'posted' && (
<button
type="button"
onClick={() => setRetagLine(line as unknown as RetagLine)}
className="p-1 rounded text-muted-foreground/50 hover:text-foreground transition-colors"
aria-label="Ändra dimensioner"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{line.line_description && (
<p className="text-xs text-muted-foreground truncate">{line.line_description}</p>
)}
@@ -777,6 +818,49 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
</Card>
)}
{/* Dimension retag history (dimensions plan PR6) — the immutable
before/after trail. Stays Swedish (voucher detail surface). */}
{dimensionsEnabled && retagLog.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Ändringshistorik för dimensioner</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{retagLog.map((row) => {
const lineForRow = lines.find((l) => l.id === row.line_id)
const fmt = (dims: Record<string, string>) => {
const entries = Object.entries(dims ?? {}).sort(([a], [b]) => Number(a) - Number(b))
return entries.length > 0 ? entries.map(([no, code]) => `${no}: ${code}`).join(', ') : '—'
}
return (
<div key={row.id} className="text-sm border-b last:border-0 pb-3 last:pb-0">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground tabular-nums">{formatDate(row.created_at)}</span>
{lineForRow && <AccountNumber number={lineForRow.account_number} />}
</div>
<p className="tabular-nums">
<span className="text-muted-foreground line-through">{fmt(row.old_dimensions)}</span>
{' → '}
<span>{fmt(row.new_dimensions)}</span>
</p>
<p className="text-xs text-muted-foreground">{row.reason}</p>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Retag dialog (Tier-2 retro-tagging) */}
<RetagLineDialog
open={retagLine !== null}
onOpenChange={(open) => {
if (!open) setRetagLine(null)
}}
line={retagLine}
onRetagged={fetchData}
/>
{/* Correction dialog */}
{showCorrection && entry && (
<CorrectionEntryDialog
+15 -1
View File
@@ -1,4 +1,7 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { History } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { PageHeader } from '@/components/ui/page-header'
import DimensionsManager from '@/components/dimensions/DimensionsManager'
@@ -14,7 +17,18 @@ export default async function DimensionsPage() {
const t = await getTranslations('nav')
return (
<div className="space-y-8">
<PageHeader title={t('dimensions')} />
{/* "Tagga historik" stays Swedish like the workbench it opens (PR6). */}
<PageHeader
title={t('dimensions')}
action={
<Button variant="outline" asChild>
<Link href="/dimensions/tagging">
<History className="mr-2 h-4 w-4" />
Tagga historik
</Link>
</Button>
}
/>
<DimensionsManager />
</div>
)
@@ -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 (
<div className="space-y-8">
<PageHeader title="Tagga historik" />
<BulkTagWorkbench />
</div>
)
}
@@ -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 },
)
@@ -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<ApplyBody>(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<ApplyBody>(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<ApplyBody>(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<ApplyBody>(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)
})
})
@@ -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<string, string>) =>
createMockRequest('/api/dimensions/tagging/lines', { searchParams })
interface FlatLine {
id: string
account_number: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string>
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<string, unknown> = {}) {
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<LinesBody>(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<LinesBody>(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<LinesBody>(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<LinesBody>(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)
})
})
+75
View File
@@ -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 },
)
+129
View File
@@ -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<string, string> | 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 } })
},
)
+656
View File
@@ -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<string, string>
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, string>): 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<TaggingLine[] | null>(null)
const [totalCapped, setTotalCapped] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Selection + apply panel
const [selected, setSelected] = useState<Set<string>>(new Set())
const anchorIndexRef = useRef<number | null>(null)
const [picked, setPicked] = useState<Record<string, string>>({})
const [replaceMode, setReplaceMode] = useState(false)
const [reason, setReason] = useState('')
const [isApplying, setIsApplying] = useState(false)
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
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<string>()
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<string>()
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<string, { dimensions: Record<string, string>; 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<string, Record<string, string>>()
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<ApplyResult>
retagged += result.retagged ?? 0
unchanged += result.unchanged ?? 0
const failedIds = new Set<string>()
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<string, string> }
: 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 (
<div className="space-y-8">
{/* Filter bar */}
<Card>
<CardContent className="p-6 space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<Label htmlFor="tag-date-from" className="text-xs text-muted-foreground">
Från datum
</Label>
<Input
id="tag-date-from"
type="date"
className="mt-1"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div>
<Label htmlFor="tag-date-to" className="text-xs text-muted-foreground">
Till datum
</Label>
<Input
id="tag-date-to"
type="date"
className="mt-1"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
<div>
<Label htmlFor="tag-account-from" className="text-xs text-muted-foreground">
Konto från
</Label>
<Input
id="tag-account-from"
inputMode="numeric"
maxLength={4}
placeholder="3000"
className="mt-1 font-mono"
value={accountFrom}
onChange={(e) => setAccountFrom(e.target.value.replace(/\D/g, ''))}
/>
</div>
<div>
<Label htmlFor="tag-account-to" className="text-xs text-muted-foreground">
Konto till
</Label>
<Input
id="tag-account-to"
inputMode="numeric"
maxLength={4}
placeholder="7999"
className="mt-1 font-mono"
value={accountTo}
onChange={(e) => setAccountTo(e.target.value.replace(/\D/g, ''))}
/>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Sök i beskrivning…"
className="pl-10"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void loadLines()
}}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="tag-only-untagged"
checked={onlyUntagged}
onCheckedChange={(checked) => setOnlyUntagged(checked === true)}
/>
<Label htmlFor="tag-only-untagged" className="text-sm font-normal">
Endast otaggade rader
</Label>
</div>
<Button onClick={() => void loadLines()} disabled={isLoading}>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Hämta rader
</Button>
</div>
</CardContent>
</Card>
{/* Result list */}
{lines === null && !isLoading ? (
<Card>
<CardContent className="p-0">
<DataListEmpty
icon={<Tags className="h-6 w-6" />}
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."
/>
</CardContent>
</Card>
) : (
<DataList>
<DataListHeader>
<Checkbox
checked={headerChecked}
onClick={(e) => {
e.preventDefault()
toggleAll()
}}
aria-label="Markera alla rader"
disabled={!lines || lines.length === 0}
/>
<span className="text-xs text-muted-foreground">
{lines ? `${lines.length} rader` : ''}
</span>
{totalCapped && (
<span className="ml-auto text-xs text-muted-foreground">
Visar de första {lines?.length ?? 0} raderna förfina filtren för att se
fler.
</span>
)}
</DataListHeader>
{isLoading ? (
<DataListLoading />
) : lines && lines.length === 0 ? (
<DataListEmpty
icon={<Search className="h-6 w-6" />}
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 (
<DataListRow
key={line.id}
selected={isSelected}
className="select-none"
onClick={(e) => toggleRow(index, e.shiftKey)}
leading={
<Checkbox
checked={isSelected}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleRow(index, e.shiftKey)
}}
aria-label={`Markera rad ${line.voucher_series ?? ''}${line.voucher_number ?? ''} ${line.account_number}`}
/>
}
trailing={
<div className="text-right">
<p className="text-sm tabular-nums">
{formatCurrency(isDebit ? line.debit_amount : line.credit_amount)}
</p>
<p className="text-xs text-muted-foreground">
{isDebit ? 'Debet' : 'Kredit'}
</p>
</div>
}
>
<DataListPrimary className="flex items-center gap-2">
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{line.voucher_series ?? ''}
{line.voucher_number ?? ''}
</span>
<span className="truncate">{line.description}</span>
{inStornoPair && (
<span
title="Verifikatet ingår i ett storno-par"
className="inline-flex shrink-0"
>
<Undo2
className="h-3.5 w-3.5 text-muted-foreground"
aria-hidden="true"
/>
<span className="sr-only">Ingår i ett storno-par</span>
</span>
)}
</DataListPrimary>
<DataListMeta>
<span className="tabular-nums">{formatDate(line.entry_date)}</span>
<DataListMetaSeparator />
<span className="font-mono">{line.account_number}</span>
{dimEntries.length > 0 && <DataListMetaSeparator />}
{dimEntries.map(([dimNo, code]) => (
<Badge
key={dimNo}
variant="outline"
className="px-1.5 py-0 text-[10px] font-normal"
>
{dimensionLabel(dimNo)}{' '}
<span className="ml-1 font-mono">{code}</span>
</Badge>
))}
</DataListMeta>
{rowErrors[line.id] && (
<p className="mt-1 text-xs text-destructive">{rowErrors[line.id]}</p>
)}
</DataListRow>
)
})
)}
</DataList>
)}
{/* Spacer so the fixed apply panel never covers the last rows */}
{selected.size > 0 && <div aria-hidden="true" className="h-64 sm:h-48" />}
{/* Apply panel — fixed footer bar while a selection is active */}
{selected.size > 0 && (
<div className="fixed bottom-20 left-1/2 z-40 w-[calc(100vw-2rem)] max-w-2xl -translate-x-1/2 rounded-lg border border-border bg-background p-4 shadow-lg md:bottom-6">
{missingPairLineIds.length > 0 && (
<div className="mb-4 flex flex-col gap-3 rounded-lg border border-border bg-secondary/40 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<p className="text-sm">
Du taggar ett verifikat men inte dess motverifikat
projektresultatet kan bli skevt.
</p>
</div>
<Button variant="outline" size="sm" onClick={includeCounterVouchers}>
Inkludera motverifikat
</Button>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">{selected.size} rader valda</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelected(new Set())
anchorIndexRef.current = null
}}
>
<X className="mr-1 h-3 w-3" />
Avmarkera
</Button>
<div className="ml-auto flex items-center gap-2">
<Switch
id="tag-replace-mode"
checked={replaceMode}
onCheckedChange={setReplaceMode}
disabled={isApplying}
/>
<Label htmlFor="tag-replace-mode" className="text-sm font-normal">
Ersätt tagg
</Label>
</div>
</div>
<div className="mt-4 space-y-4">
<LineDimensionFields
dimensions={picked}
onChange={handlePick}
disabled={isApplying}
inputClassName="h-8"
/>
<div>
<Label htmlFor="tag-reason" className="text-xs text-muted-foreground">
Anledning
</Label>
<div className="mt-1 flex flex-col gap-3 sm:flex-row">
<Input
id="tag-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="T.ex. rättelse av projektkod (minst 3 tecken)"
disabled={isApplying}
className="flex-1"
/>
<Button onClick={() => void handleApply()} disabled={!canApply}>{/* storno-pair confirm inside */}
{isApplying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Tags className="mr-2 h-4 w-4" />
)}
Tagga {selected.size} rader
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
{replaceMode
? 'Ersätter hela taggningen på raderna med exakt de valda värdena. '
: ''}
Påverkar endast internredovisningen, inte verifikatet.
</p>
</div>
</div>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
)
}
+151
View File
@@ -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<string, string> | 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<Record<string, string>>({})
const [reason, setReason] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ändra dimensioner</DialogTitle>
<DialogDescription>
Påverkar endast internredovisningen, inte verifikatet. Ändringen
loggas med före/efter och anledning.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-border p-3 text-sm">
<AccountNumber number={line.account_number} showName />
<div className="mt-1 flex items-center justify-between text-muted-foreground">
<span className="truncate">{line.line_description || '—'}</span>
<span className="tabular-nums shrink-0 ml-3">
{amount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr {side}
</span>
</div>
</div>
<LineDimensionFields dimensions={dims} onChange={handleChange} />
<div className="space-y-2">
<Label htmlFor="retag-reason">Anledning</Label>
<Input
id="retag-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="t.ex. Raden hörde till projekt P002"
maxLength={500}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Avbryt
</Button>
<Button onClick={handleSave} disabled={isSaving || !reasonValid}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara ändring
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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<string, unknown> = {}) {
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<typeof vi.fn>).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<string, string>
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-012024-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<typeof vi.fn>).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<string, string>
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
})
+213
View File
@@ -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: {"<sie_dim_no>":"<kod eller namn>"}, 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 3500 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 {"<sie_dim_no>":"<kod eller namn>"} 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<string, unknown>
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<string, string>
// ── 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<string, unknown>,
{
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)',
+55
View File
@@ -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),
})
+2
View File
@@ -210,6 +210,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
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',
@@ -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>): 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-012024-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-012024-12-31',
})
const rpc = supabase.rpc as ReturnType<typeof vi.fn>
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/)
})
})
+81
View File
@@ -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<string, unknown>
): Promise<ExecutorResult> {
// 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
+4
View File
@@ -63,6 +63,10 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// (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
@@ -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<typeof RetagLineDimensionsParamsSchema>
@@ -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';
@@ -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';
+369
View File
@@ -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<string, string>
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<void> {
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<string, string>,
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()
}
})
})
+3
View File
@@ -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