feat(dimensions): PR9 cutover — cost_center/project become GENERATED columns, dual-write removed (#870)

The dual-write window ends (dev_docs/dimensions_implementation_plan.md PR9):
journal_entry_lines.cost_center/project are now GENERATED ALWAYS AS
(NULLIF(dimensions->>'1'/'6','')) STORED — divergence from the bag is
impossible by construction instead of by convention.

- migration 20260702230000: drift pre-flight (refuses cutover on
  inconsistent data; prod verified 0 drift across 593k rows), column swap
  (DROP metadata-only + one-rewrite ADD pair), and atomic redefinition of
  the two SQL writers — retag_line_dimensions (SET dimensions only) and
  bulk_book_transactions (INSERT names the bag only)
- TS writers stripped of the mirror spread: engine buildLineInserts
  (covers create/update/reversal), storno-service (reversal + correction),
  SIE import bulk insert, sandbox seed
- lineDimensionColumns() removed from dimension-resolver — nothing derives
  mirrors in TypeScript anymore; normalizeLineDimensions + the deprecated
  cost_center/project INPUT aliases stay (API contract, they normalize
  into the bag); JournalEntryLine ROW type keeps the fields (generated
  columns still SELECT)
- immutability carve-out unchanged BY DESIGN: its whole-row diff already
  subtracts dimensions/cost_center/project on both sides, which is exactly
  what makes it correct with generated columns (BEFORE-trigger NEW carries
  not-yet-recomputed mirror values)
- audited every reader (v1 journal-entries, MCP query_journal filters +
  group_by, rc-basis-gaps) — reads are untouched; no index, view, or
  constraint referenced the TEXT columns, so DROP COLUMN cascades nothing
- new pg suite: generated derivation, explicit-mirror-write rejection,
  draft-update recompute; existing retag/substrate/bulk-book suites
  updated to bag-only writes (their mirror assertions now exercise the
  generation expression)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 23:28:42 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 163fbd8222
commit fb3f0a9cee
12 changed files with 713 additions and 78 deletions
+2 -4
View File
@@ -7,7 +7,6 @@ import { createLogger } from '@/lib/logger'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { truncateIp } from '@/lib/api/v1/with-api-v1'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
// Anonymous sign-in is enabled in all environments so visitors can try the
// product; a per-/24 cap on the seed endpoint keeps a single network from
@@ -463,8 +462,8 @@ export async function POST(request: Request) {
// 10. Create journal entry lines. The P&L line carries demo dimensions
// ({"1":"BUTIK","6":"P001"}) so the register's "antal taggade rader",
// voucher-detail badges, and the dimension P&L report light up in the
// sandbox. Mirror columns derived via lineDimensionColumns — never set
// independently of the dimensions map.
// sandbox. cost_center/project are GENERATED from the bag since the PR9
// cutover — writing them explicitly would error.
const revenueDims = { '1': 'BUTIK', '6': 'P001' }
const { error: jelError } = await supabase
.from('journal_entry_lines')
@@ -491,7 +490,6 @@ export async function POST(request: Request) {
credit_amount: 15000,
sort_order: 1,
dimensions: revenueDims,
...lineDimensionColumns(revenueDims),
},
{
journal_entry_id: je1.id,
@@ -1,11 +1,8 @@
import { describe, it, expect } from 'vitest'
import {
normalizeLineDimensions,
lineDimensionColumns,
coerceDimensionsBag,
validateEntryDimensions,
DIM_COST_CENTER,
DIM_PROJECT,
} from '@/lib/bookkeeping/dimension-resolver'
// Imported from errors.ts on purpose: proves the re-export surface every
// server consumer uses (the class itself lives in dimension-errors.ts).
@@ -63,10 +60,11 @@ describe('normalizeLineDimensions', () => {
).toEqual({ '6': 'P001' })
})
it("canonicalizes leading-zero keys ('01' -> '1') so mirrors are derived", () => {
it("canonicalizes leading-zero keys ('01' -> '1') so the generated mirrors derive", () => {
// The DB generates cost_center/project from keys '1'/'6' (PR9 cutover) —
// '01' must land on '1' or the generated mirror misses the value.
const dims = normalizeLineDimensions({ dimensions: { '01': 'KS01', '06': 'P001' } })
expect(dims).toEqual({ '1': 'KS01', '6': 'P001' })
expect(lineDimensionColumns(dims)).toEqual({ cost_center: 'KS01', project: 'P001' })
})
it("clearing via a leading-zero key ('01': '') also clears the alias-filled '1'", () => {
@@ -120,24 +118,9 @@ describe('coerceDimensionsBag (boundary validator for staged payloads)', () => {
})
})
describe('lineDimensionColumns', () => {
it('derives both mirrors from the map', () => {
expect(lineDimensionColumns({ [DIM_COST_CENTER]: 'KS01', [DIM_PROJECT]: 'P001' })).toEqual({
cost_center: 'KS01',
project: 'P001',
})
})
it('returns nulls for missing keys', () => {
expect(lineDimensionColumns({})).toEqual({ cost_center: null, project: null })
expect(lineDimensionColumns({ '7': 'ANST-4' })).toEqual({ cost_center: null, project: null })
})
it('round-trips with normalizeLineDimensions (mirror consistency)', () => {
const dims = normalizeLineDimensions({ cost_center: 'KS01', dimensions: { '6': 'P001' } })
expect(lineDimensionColumns(dims)).toEqual({ cost_center: 'KS01', project: 'P001' })
})
})
// lineDimensionColumns() tests were removed with the function in the PR9
// cutover — mirror derivation now lives in the database as GENERATED columns
// and is covered by tests/pg/dimensions-generated-cutover.pg.test.ts.
describe('validateEntryDimensions (soft registry validation, PR3)', () => {
const enabledSettings = { data: { dimensions_enabled: true } }
@@ -145,10 +145,11 @@ describe('createDraftEntry — dimension validation wiring', () => {
// Toggle checked once, registry never consulted (free-text passthrough).
expect(queriedTables().filter((t) => t === 'company_settings')).toHaveLength(1)
expect(queriedTables()).not.toContain('dimensions')
// The free-text tag still lands on the inserted lines (bag + mirror).
// The free-text tag still lands on the inserted lines (bag only — the
// project mirror is GENERATED at the database since the PR9 cutover).
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '6': 'FRITEXT-PROJEKT' })
expect(lineRows[0].project).toBe('FRITEXT-PROJEKT')
expect('project' in lineRows[0]).toBe(false)
})
it('rejects an unknown code before ANY row is inserted (toggle on)', async () => {
@@ -194,8 +195,10 @@ describe('createDraftEntry — dimension validation wiring', () => {
expect(entry.id).toBe('entry-1')
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(lineRows[0].cost_center).toBe('KS01')
expect(lineRows[0].project).toBe('P001')
// PR9 cutover: the payload must NOT name the generated mirror columns —
// an explicit value would make Postgres reject the insert.
expect('cost_center' in lineRows[0]).toBe(false)
expect('project' in lineRows[0]).toBe(false)
})
})
+8 -20
View File
@@ -1,13 +1,12 @@
/**
* Dimension resolver — the single place line dimensions are normalized and
* mirrored (dev_docs/dimensions_implementation_plan.md).
* Dimension resolver — the single place line dimensions are normalized
* (dev_docs/dimensions_implementation_plan.md).
*
* Storage model: journal_entry_lines.dimensions is a JSONB map keyed by SIE
* dimension number ({"1":"KS01","6":"P001"}) and is the single source of
* truth. The legacy cost_center/project TEXT columns are deterministic mirrors
* of keys '1'/'6' during the dual-write window (they become GENERATED columns
* in a later migration). Every journal_entry_lines writer MUST derive the
* mirror columns via lineDimensionColumns() — never set them independently.
* truth. Since the PR9 cutover (20260702230000) the cost_center/project
* columns are GENERATED ALWAYS from keys '1'/'6' — writers set only the
* bag; writing the mirror columns explicitly errors at the database.
*/
import { z } from 'zod'
@@ -134,20 +133,9 @@ export function dimensionsBagKey(dimensions?: LineDimensions): string {
)
}
/**
* Derive the legacy mirror columns from the canonical map. Pure function —
* divergence between `dimensions` and cost_center/project is impossible as
* long as every writer goes through this.
*/
export function lineDimensionColumns(dimensions: LineDimensions): {
cost_center: string | null
project: string | null
} {
return {
cost_center: dimensions[DIM_COST_CENTER] ?? null,
project: dimensions[DIM_PROJECT] ?? null,
}
}
// lineDimensionColumns() was removed in the PR9 cutover: the mirror columns
// are GENERATED ALWAYS from the bag at the database, so there is nothing for
// TypeScript writers to derive — they set only `dimensions`.
/**
* Soft registry validation of the dimensions referenced by a set of entry
+4 -5
View File
@@ -16,7 +16,6 @@ import {
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import {
normalizeLineDimensions,
lineDimensionColumns,
validateEntryDimensions,
} from '@/lib/bookkeeping/dimension-resolver'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
@@ -183,7 +182,7 @@ export async function findFiscalPeriod(
/**
* Build line insert objects from input lines, resolving account IDs and
* including tax_code, cost_center, project dimensions
* including tax_code and the dimensions bag
*/
function buildLineInserts(
entryId: string,
@@ -191,8 +190,9 @@ function buildLineInserts(
accountIdMap: Map<string, string>
) {
return lines.map((line, index) => {
// dimensions JSONB is the source of truth; cost_center/project are
// derived mirrors (dual-write window — see lib/bookkeeping/dimension-resolver.ts)
// dimensions JSONB is the single source of truth; cost_center/project
// are GENERATED columns derived from keys '1'/'6' since the PR9 cutover
// (20260702230000) — writing them explicitly would error.
const dimensions = normalizeLineDimensions(line)
return {
journal_entry_id: entryId,
@@ -206,7 +206,6 @@ function buildLineInserts(
line_description: line.line_description || null,
tax_code: line.tax_code || null,
dimensions,
...lineDimensionColumns(dimensions),
sort_order: index,
}
})
+1 -3
View File
@@ -6,7 +6,7 @@ import type {
JournalEntryLine,
} from '@/types'
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import {
@@ -287,7 +287,6 @@ export async function correctEntry(
line_description: `Storno: ${line.line_description || ''}`,
tax_code: line.tax_code || null,
dimensions,
...lineDimensionColumns(dimensions),
sort_order: index,
}
})
@@ -372,7 +371,6 @@ export async function correctEntry(
line_description: line.line_description || null,
tax_code: line.tax_code || null,
dimensions,
...lineDimensionColumns(dimensions),
sort_order: index,
}
})
+1 -4
View File
@@ -7,7 +7,7 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
import { importDimensionRegistry } from './sie-dimensions'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import type {
@@ -1345,8 +1345,6 @@ export async function importVouchers(
line_description: string | null
sort_order: number
dimensions: Record<string, string>
cost_center: string | null
project: string | null
}[] = []
for (let i = 0; i < batch.length; i++) {
@@ -1372,7 +1370,6 @@ export async function importVouchers(
line_description: line.line_description,
sort_order: lineIndex,
dimensions: dims,
...lineDimensionColumns(dims),
})
})
@@ -0,0 +1,571 @@
-- Dimensions PR9 (cutover): the dual-write window ends. The legacy TEXT
-- mirror columns journal_entry_lines.cost_center/project become GENERATED
-- ALWAYS ... STORED, derived from the dimensions JSONB (keys '1'/'6') —
-- divergence is now impossible by construction instead of by convention.
--
-- Ordering contract with the application (dev_docs plan §7 row 9):
-- * TS writers (engine, storno, SIE import) stopped sending the mirror
-- keys in the SAME PR — after this migration an explicit write to
-- either column errors ("cannot insert a non-DEFAULT value into column
-- ... generated always").
-- * The two SQL writers are redefined HERE, atomically with the column
-- swap: retag_line_dimensions (stops SET-ing mirrors — they recompute)
-- and bulk_book_transactions (stops inserting mirror columns).
-- * Readers are untouched: generated columns SELECT exactly like the TEXT
-- columns did, with identical values (NULLIF(dims->>N,'') is the same
-- derivation lineDimensionColumns/the RPCs used).
--
-- The immutability carve-out (20260702170000) needs NO change: its
-- whole-row diff subtracts 'dimensions', 'cost_center' and 'project' from
-- both to_jsonb(NEW) and to_jsonb(OLD), so the fact that BEFORE-trigger NEW
-- carries not-yet-recomputed generated values is irrelevant.
--
-- Operational: DROP COLUMN is metadata-only; the ADD ... STORED pair is ONE
-- table rewrite (~594k rows / 148 MB on prod at cutover time — seconds of
-- exclusive lock; apply off-peak). The pre-flight below refuses to cut over
-- if any row's mirrors have drifted from the bag (prod verified 0 drift).
--
-- pg-test: tests/pg/dimensions-generated-cutover.pg.test.ts.
-- ── 0. Pre-flight: refuse to cut over on drifted data ─────────────────────
DO $$
DECLARE
v_drift bigint;
BEGIN
SELECT count(*) INTO v_drift
FROM public.journal_entry_lines
WHERE cost_center IS DISTINCT FROM NULLIF(dimensions ->> '1', '')
OR project IS DISTINCT FROM NULLIF(dimensions ->> '6', '');
IF v_drift > 0 THEN
RAISE EXCEPTION 'dimension mirror drift on % row(s) — reconcile before the generated-column cutover', v_drift;
END IF;
END
$$;
-- ── 1. Column swap ────────────────────────────────────────────────────────
ALTER TABLE public.journal_entry_lines
DROP COLUMN cost_center,
DROP COLUMN project;
ALTER TABLE public.journal_entry_lines
ADD COLUMN cost_center text GENERATED ALWAYS AS (NULLIF(dimensions ->> '1', '')) STORED,
ADD COLUMN project text GENERATED ALWAYS AS (NULLIF(dimensions ->> '6', '')) STORED;
COMMENT ON COLUMN public.journal_entry_lines.cost_center IS
'GENERATED from dimensions->>''1'' (SIE #DIM 1 object code) since the PR9 cutover — read-only mirror, write the dimensions bag instead.';
COMMENT ON COLUMN public.journal_entry_lines.project IS
'GENERATED from dimensions->>''6'' (SIE #DIM 6 object code) since the PR9 cutover — read-only mirror, write the dimensions bag instead.';
-- ── 2. retag_line_dimensions: stop SET-ing the mirrors ────────────────────
-- Body byte-identical to 20260702170000 except the UPDATE writes only the
-- bag (the generated mirrors recompute). CREATE OR REPLACE resets proconfig
-- — SET search_path restated; grants restated for auditability.
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);
-- PR9: write the bag only — cost_center/project are GENERATED and
-- recompute from the bag in the same statement.
UPDATE public.journal_entry_lines
SET dimensions = p_dimensions
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;
-- ── 3. bulk_book_transactions: stop inserting the mirror columns ──────────
-- Body byte-identical to 20260702201000 except the journal_entry_lines
-- INSERT no longer names cost_center/project (generated). Bag normalization
-- stays. SET search_path + grants restated.
CREATE OR REPLACE FUNCTION public.bulk_book_transactions(
p_tx_ids uuid[],
p_existing_journal_entry_id uuid,
p_new_entry jsonb,
p_company_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_tx RECORD;
v_tx_date date;
v_total_amount numeric := 0;
v_total_amount_abs numeric;
v_direction text;
v_tx_count int := 0;
v_voucher RECORD;
v_voucher_bank_net numeric := 0;
v_fiscal_period_id uuid;
v_period_is_closed boolean;
v_period_locked_at timestamptz;
v_journal_entry_id uuid;
v_voucher_series text := 'A';
v_voucher_number int;
v_entry_description text;
v_line jsonb;
v_line_account text;
v_line_debit numeric;
v_line_credit numeric;
v_line_currency text;
v_line_dims jsonb;
v_lines_total_debit numeric := 0;
v_lines_total_credit numeric := 0;
v_lines_bank_net numeric := 0;
v_sort_order int := 0;
v_docs_linked int := 0;
v_target_je uuid;
v_invalid_accounts text[];
v_now timestamptz := now();
v_caller uuid := auth.uid();
BEGIN
IF v_caller IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED');
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = v_caller AND company_id = p_company_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED');
END IF;
IF p_tx_ids IS NULL OR array_length(p_tx_ids, 1) IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_TXS');
END IF;
IF (p_existing_journal_entry_id IS NULL AND p_new_entry IS NULL)
OR (p_existing_journal_entry_id IS NOT NULL AND p_new_entry IS NOT NULL) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_PAYLOAD');
END IF;
FOR v_tx IN
SELECT * FROM public.transactions
WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
ORDER BY id
FOR UPDATE
LOOP
v_tx_count := v_tx_count + 1;
IF v_tx.journal_entry_id IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED',
'details', jsonb_build_object('tx_id', v_tx.id));
END IF;
IF EXISTS (
SELECT 1 FROM public.transaction_voucher_links tvl
WHERE tvl.transaction_id = v_tx.id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED',
'details', jsonb_build_object('tx_id', v_tx.id, 'via', 'transaction_voucher_links'));
END IF;
IF v_tx.amount = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ZERO_AMOUNT',
'details', jsonb_build_object('tx_id', v_tx.id));
END IF;
IF v_tx_date IS NULL THEN
v_tx_date := v_tx.date;
ELSIF v_tx_date <> v_tx.date THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DATE_MISMATCH',
'details', jsonb_build_object('first_date', v_tx_date, 'other_date', v_tx.date));
END IF;
IF v_direction IS NULL THEN
v_direction := CASE WHEN v_tx.amount > 0 THEN 'income' ELSE 'expense' END;
ELSIF (v_direction = 'income' AND v_tx.amount < 0)
OR (v_direction = 'expense' AND v_tx.amount > 0) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DIRECTION_MISMATCH',
'details', jsonb_build_object('expected', v_direction, 'tx_id', v_tx.id));
END IF;
v_total_amount := v_total_amount + v_tx.amount;
END LOOP;
IF v_tx_count = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND');
END IF;
IF v_tx_count <> COALESCE(array_length(p_tx_ids, 1), 0) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND',
'details', jsonb_build_object('expected', array_length(p_tx_ids, 1), 'found', v_tx_count));
END IF;
v_total_amount_abs := ABS(v_total_amount);
IF p_existing_journal_entry_id IS NOT NULL THEN
SELECT * INTO v_voucher FROM public.journal_entries
WHERE id = p_existing_journal_entry_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_JE_NOT_FOUND',
'details', jsonb_build_object('journal_entry_id', p_existing_journal_entry_id));
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_JE_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status));
END IF;
SELECT COALESCE(SUM(debit_amount - credit_amount), 0) INTO v_voucher_bank_net
FROM public.journal_entry_lines
WHERE journal_entry_id = p_existing_journal_entry_id
AND length(account_number) = 4
AND account_number BETWEEN '1900' AND '1999';
IF ABS(v_voucher_bank_net - v_total_amount) > 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH',
'details', jsonb_build_object(
'tx_sum', v_total_amount, 'voucher_bank_net', v_voucher_bank_net));
END IF;
FOR v_tx IN
SELECT * FROM public.transactions
WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
ORDER BY id
LOOP
INSERT INTO public.transaction_voucher_links
(user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
VALUES
(v_caller, p_company_id, v_tx.id, p_existing_journal_entry_id, v_tx.amount, 'bank_line');
END LOOP;
IF v_tx_count = 1 THEN
UPDATE public.transactions
SET journal_entry_id = p_existing_journal_entry_id,
reconciliation_method = 'manual',
is_business = TRUE,
updated_at = v_now
WHERE id = p_tx_ids[1];
ELSE
UPDATE public.transactions
SET is_business = TRUE, updated_at = v_now
WHERE id = ANY(p_tx_ids);
END IF;
v_target_je := p_existing_journal_entry_id;
v_voucher_series := v_voucher.voucher_series;
v_voucher_number := v_voucher.voucher_number;
ELSE
v_entry_description := p_new_entry->>'description';
IF v_entry_description IS NULL OR LENGTH(TRIM(v_entry_description)) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_MISSING_DESCRIPTION');
END IF;
IF jsonb_typeof(p_new_entry->'lines') IS DISTINCT FROM 'array'
OR jsonb_array_length(p_new_entry->'lines') < 2 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_LINES');
END IF;
WITH submitted AS (
SELECT DISTINCT value->>'account_number' AS acct
FROM jsonb_array_elements(p_new_entry->'lines')
)
SELECT array_agg(s.acct ORDER BY s.acct) INTO v_invalid_accounts
FROM submitted s
WHERE NOT EXISTS (
SELECT 1 FROM public.chart_of_accounts coa
WHERE coa.account_number = s.acct
AND coa.company_id = p_company_id
AND coa.is_active = true
);
IF v_invalid_accounts IS NOT NULL AND array_length(v_invalid_accounts, 1) > 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_ACCOUNT',
'details', jsonb_build_object('invalid_accounts', v_invalid_accounts));
END IF;
FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines')
LOOP
v_line_account := v_line->>'account_number';
v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0);
v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0);
IF v_line_debit < 0 OR v_line_credit < 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NEGATIVE_LINE',
'details', jsonb_build_object('account', v_line_account));
END IF;
IF v_line_debit > 0 AND v_line_credit > 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_BOTH_SIDES_NONZERO',
'details', jsonb_build_object('account', v_line_account));
END IF;
IF v_line ? 'dimensions'
AND jsonb_typeof(v_line->'dimensions') IS DISTINCT FROM 'object' THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_DIMENSIONS',
'details', jsonb_build_object('account', v_line_account));
END IF;
v_lines_total_debit := v_lines_total_debit + v_line_debit;
v_lines_total_credit := v_lines_total_credit + v_line_credit;
IF length(v_line_account) = 4 AND v_line_account BETWEEN '1900' AND '1999' THEN
v_lines_bank_net := v_lines_bank_net + v_line_debit - v_line_credit;
END IF;
END LOOP;
IF ABS(v_lines_total_debit - v_lines_total_credit) > 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNBALANCED',
'details', jsonb_build_object(
'debit_sum', v_lines_total_debit, 'credit_sum', v_lines_total_credit));
END IF;
IF ABS(v_lines_bank_net - v_total_amount) > 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH',
'details', jsonb_build_object(
'tx_sum', v_total_amount,
'lines_bank_net', v_lines_bank_net));
END IF;
SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
FROM public.fiscal_periods
WHERE company_id = p_company_id AND v_tx_date BETWEEN period_start AND period_end
ORDER BY period_start DESC LIMIT 1;
IF v_fiscal_period_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_FISCAL_PERIOD',
'details', jsonb_build_object('tx_date', v_tx_date));
END IF;
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_PERIOD_LOCKED',
'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id));
END IF;
v_journal_entry_id := gen_random_uuid();
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
(v_journal_entry_id, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
v_tx_date, v_entry_description, 'manual', 'draft');
v_sort_order := 0;
FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines')
LOOP
v_line_account := v_line->>'account_number';
v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0);
v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0);
v_line_currency := COALESCE(v_line->>'currency', 'SEK');
-- Bag normalization as before (DimensionsBagSchema parity). PR9: the
-- generated mirrors derive from the stored bag — no explicit columns.
SELECT COALESCE(jsonb_object_agg(d.key, btrim(d.value)), '{}'::jsonb)
INTO v_line_dims
FROM jsonb_each_text(COALESCE(v_line->'dimensions', '{}'::jsonb)) AS d
WHERE d.key ~ '^[1-9][0-9]*$' AND btrim(d.value) <> '';
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description, dimensions)
VALUES
(v_journal_entry_id, v_line_account, v_line_debit, v_line_credit, v_line_currency,
COALESCE((v_line->>'sort_order')::int, v_sort_order),
v_line->>'line_description',
v_line_dims);
v_sort_order := v_sort_order + 1;
END LOOP;
SELECT voucher_number INTO v_voucher_number
FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
FOR v_tx IN
SELECT * FROM public.transactions
WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
ORDER BY id
LOOP
INSERT INTO public.transaction_voucher_links
(user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
VALUES
(v_caller, p_company_id, v_tx.id, v_journal_entry_id, v_tx.amount, 'bank_line');
END LOOP;
IF v_tx_count = 1 THEN
UPDATE public.transactions
SET journal_entry_id = v_journal_entry_id,
is_business = TRUE,
updated_at = v_now
WHERE id = p_tx_ids[1];
ELSE
UPDATE public.transactions
SET is_business = TRUE, updated_at = v_now
WHERE id = ANY(p_tx_ids);
END IF;
v_target_je := v_journal_entry_id;
END IF;
WITH linked AS (
UPDATE public.document_attachments AS d
SET journal_entry_id = v_target_je,
updated_at = v_now
FROM public.transactions AS t
WHERE t.id = ANY(p_tx_ids)
AND t.company_id = p_company_id
AND t.document_id = d.id
AND d.company_id = p_company_id
AND d.journal_entry_id IS NULL
RETURNING d.id
)
SELECT COUNT(*)::int INTO v_docs_linked FROM linked;
RETURN jsonb_build_object(
'ok', true,
'mode', CASE WHEN p_existing_journal_entry_id IS NOT NULL THEN 'link_existing' ELSE 'create_new' END,
'journal_entry_id', v_target_je,
'voucher_series', v_voucher_series,
'voucher_number', v_voucher_number,
'linked_tx_count', v_tx_count,
'tx_sum', v_total_amount,
'docs_linked', v_docs_linked
);
END;
$$;
COMMENT ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) IS
'Bulk-book N bank transactions sharing the same date into a single combined verifikat (samlingsverifikation per BFL 5 kap 6§). Dimensions PR9: lines write the dimensions bag only — cost_center/project are GENERATED columns derived from keys 1/6.';
REVOKE ALL ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) TO authenticated;
NOTIFY pgrst, 'reload schema';
+3 -3
View File
@@ -12,9 +12,9 @@ import { getPool, withUserContext } from '@/tests/pg/setup'
* Covers 20260702201000_bulk_book_transactions_dimensions (dimensions PR7):
*
* - p_new_entry lines may carry a dimensions bag {sie_dim_no: code}. The
* RPC stores it on journal_entry_lines.dimensions AND derives the
* dual-write mirror columns cost_center/project from keys '1'/'6'
* (lineDimensionColumns contract — mirrors never set independently).
* RPC stores it on journal_entry_lines.dimensions; cost_center/project
* derive from keys '1'/'6' (GENERATED columns since the PR9 cutover —
* the mirror assertions below now exercise the generation expression).
* - Bag normalization mirrors DimensionsBagSchema: non-canonical keys
* (leading zeros, non-numeric) and blank values are dropped; values are
* trimmed.
+6 -5
View File
@@ -41,10 +41,10 @@ async function insertPostedTaggedEntry(params: {
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)
(journal_entry_id, account_number, debit_amount, credit_amount, dimensions)
VALUES ($1, '5010', 1000, 0, $2::jsonb)
RETURNING id`,
[entryId, JSON.stringify(dims), dims['1'] ?? null, dims['6'] ?? null],
[entryId, JSON.stringify(dims)],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines
@@ -207,12 +207,13 @@ describe('dimension retag carve-out (PR6)', () => {
).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).
// A pure dimension diff IS admitted under the GUC (the RPC's write
// shape). PR9: the bag alone — the generated mirrors recompute.
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'
SET dimensions = '{"6":"P001"}'::jsonb
WHERE id = $1`,
[lineId],
)
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
insertDraftJournalEntry,
} from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
/**
* Covers 20260702230000_dimensions_generated_column_cutover (dimensions PR9):
*
* - cost_center/project are GENERATED ALWAYS AS (NULLIF(dimensions->>'1'/'6',''))
* STORED — inserting only the bag produces the same mirror values the
* dual-write produced.
* - An INSERT that names either mirror column errors (the reason every
* writer was stripped in this PR).
* - The retag RPC (redefined here to SET only the bag) still updates a
* posted line under its carve-out, and the mirrors recompute.
*/
async function seedTenant() {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({
userId,
companyId,
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
})
return { userId, companyId, fiscalPeriodId }
}
describe('journal_entry_lines generated mirrors (PR9 cutover)', () => {
it('derives cost_center/project from the bag on insert; empty bag yields NULLs', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
const { rows } = await getPool().query<{
dimensions: Record<string, string>
cost_center: string | null
project: string | null
}>(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, dimensions)
VALUES ($1, '4010', 1000, 0, '{"1":"KS01","6":"P001"}'::jsonb),
($1, '1930', 0, 1000, '{}'::jsonb)
RETURNING dimensions, cost_center, project`,
[entryId],
)
const tagged = rows.find((r) => r.dimensions['1'])!
expect(tagged.cost_center).toBe('KS01')
expect(tagged.project).toBe('P001')
const untagged = rows.find((r) => !r.dimensions['1'])!
expect(untagged.cost_center).toBeNull()
expect(untagged.project).toBeNull()
})
it('rejects an INSERT that names a mirror column explicitly', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await expect(
getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, dimensions, cost_center)
VALUES ($1, '4010', 1000, 0, '{"1":"KS01"}'::jsonb, 'KS01')`,
[entryId],
),
).rejects.toThrow(/non-DEFAULT value into column/i)
})
it('mirrors recompute when a draft bag is updated (bag-only write)', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, dimensions)
VALUES ($1, '4010', 500, 0, '{"6":"P001"}'::jsonb)
RETURNING id`,
[entryId],
)
await getPool().query(
`UPDATE public.journal_entry_lines SET dimensions = '{"1":"KS02"}'::jsonb WHERE id = $1`,
[rows[0].id],
)
const after = await getPool().query<{ cost_center: string | null; project: string | null }>(
`SELECT cost_center, project FROM public.journal_entry_lines WHERE id = $1`,
[rows[0].id],
)
expect(after.rows[0].cost_center).toBe('KS02')
expect(after.rows[0].project).toBeNull()
})
})
+5 -7
View File
@@ -47,16 +47,14 @@ async function insertDimensionedLines(
const lineId = randomUUID()
await getPool().query(
`INSERT INTO public.journal_entry_lines
(id, journal_entry_id, account_number, debit_amount, credit_amount, dimensions, cost_center, project)
VALUES ($1, $2, '4010', $3, 0, $4, $5, $6),
(gen_random_uuid(), $2, '1930', 0, $3, '{}', NULL, NULL)`,
(id, journal_entry_id, account_number, debit_amount, credit_amount, dimensions)
VALUES ($1, $2, '4010', $3, 0, $4),
(gen_random_uuid(), $2, '1930', 0, $3, '{}')`,
[
lineId,
journalEntryId,
amount,
JSON.stringify(dimensions),
dimensions['1'] ?? null,
dimensions['6'] ?? null,
],
)
return lineId
@@ -267,9 +265,9 @@ describe('journal_entry_lines.dimensions', () => {
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
const lineId = await insertDimensionedLines(entryId, { '6': 'P001' })
// Draft: retagging is allowed
// Draft: retagging is allowed (PR9: the bag alone — mirrors generate)
await getPool().query(
`UPDATE public.journal_entry_lines SET dimensions = '{"6":"P002"}', project = 'P002' WHERE id = $1`,
`UPDATE public.journal_entry_lines SET dimensions = '{"6":"P002"}' WHERE id = $1`,
[lineId],
)