diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index 079112a6..c7668a4f 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -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, diff --git a/lib/bookkeeping/__tests__/dimension-resolver.test.ts b/lib/bookkeeping/__tests__/dimension-resolver.test.ts index 106a25b5..9b4a17f9 100644 --- a/lib/bookkeeping/__tests__/dimension-resolver.test.ts +++ b/lib/bookkeeping/__tests__/dimension-resolver.test.ts @@ -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 } } diff --git a/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts b/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts index b223b0c9..31573bfb 100644 --- a/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts +++ b/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts @@ -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> 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> 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) }) }) diff --git a/lib/bookkeeping/dimension-resolver.ts b/lib/bookkeeping/dimension-resolver.ts index 19442bee..f535bf93 100644 --- a/lib/bookkeeping/dimension-resolver.ts +++ b/lib/bookkeeping/dimension-resolver.ts @@ -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 diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 1750ab3a..c45c684a 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -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 ) { 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, } }) diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index fde72521..afa53b9a 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -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, } }) diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index f47ec68c..62910506 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -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 - 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), }) }) diff --git a/supabase/migrations/20260702230000_dimensions_generated_column_cutover.sql b/supabase/migrations/20260702230000_dimensions_generated_column_cutover.sql new file mode 100644 index 00000000..ff39103d --- /dev/null +++ b/supabase/migrations/20260702230000_dimensions_generated_column_cutover.sql @@ -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'; diff --git a/tests/pg/bulk-book-dimensions.pg.test.ts b/tests/pg/bulk-book-dimensions.pg.test.ts index 74ff2dab..1c1a54d2 100644 --- a/tests/pg/bulk-book-dimensions.pg.test.ts +++ b/tests/pg/bulk-book-dimensions.pg.test.ts @@ -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. diff --git a/tests/pg/dimension-retag.pg.test.ts b/tests/pg/dimension-retag.pg.test.ts index eab3be27..559f5259 100644 --- a/tests/pg/dimension-retag.pg.test.ts +++ b/tests/pg/dimension-retag.pg.test.ts @@ -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], ) diff --git a/tests/pg/dimensions-generated-cutover.pg.test.ts b/tests/pg/dimensions-generated-cutover.pg.test.ts new file mode 100644 index 00000000..911ea826 --- /dev/null +++ b/tests/pg/dimensions-generated-cutover.pg.test.ts @@ -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 + 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() + }) +}) diff --git a/tests/pg/dimensions-substrate.pg.test.ts b/tests/pg/dimensions-substrate.pg.test.ts index 3d707b81..70527dbf 100644 --- a/tests/pg/dimensions-substrate.pg.test.ts +++ b/tests/pg/dimensions-substrate.pg.test.ts @@ -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], )