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:
co-authored by
Claude Fable 5
parent
fb3fe82a56
commit
816b1769c8
@@ -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 } })
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user