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,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';
|
||||
Reference in New Issue
Block a user