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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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 } })
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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-01–2024-12-31/)
|
||||
expect(result.preview.filter_summary).toMatch(/text "Bygg AB"/)
|
||||
expect(result.preview.sample).toEqual([
|
||||
{ account: '4010', date: '2024-03-01', debit: 250, credit: 0 },
|
||||
{ account: '4010', date: '2024-03-01', debit: 250, credit: 0 },
|
||||
])
|
||||
|
||||
const insertCalls = (supabase.from as ReturnType<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)
|
||||
})
|
||||
})
|
||||
@@ -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 3–500 characters — it is stored in the immutable dimension_retag_log.')
|
||||
}
|
||||
|
||||
const inputBag = parseDimensionsArg(args.dimensions, 'dimensions')
|
||||
if (!inputBag) {
|
||||
throw new Error('dimensions must contain at least one {"<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)',
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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-01–2024-12-31',
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({
|
||||
retagged: 1,
|
||||
unchanged: 1,
|
||||
failed_count: 0,
|
||||
failed: [],
|
||||
dimensions: { '1': 'KS01', '6': 'P01' },
|
||||
filter_summary: 'konto 4010, datum 2024-01-01–2024-12-31',
|
||||
})
|
||||
|
||||
const rpc = supabase.rpc as ReturnType<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/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user