diff --git a/DECISIONS.md b/DECISIONS.md index 730762af..075fd5df 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1081,3 +1081,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-18] Migration-reset eligibility treats every persisted Skatteverket VAT `submission_*` workflow row as authority interaction evidence, not only successful audit-log rows: historical direct locks stored the signing state without auditing it, and Accounted cannot observe whether a user completed BankID signing outside the app. Unsigned drafts must be removed through the product; locked or uncertain state fails closed and is escalated. [2026-08-18] Migration-reset eligibility also blocks AGI `pending_signature` and `agi_submission_*` state plus every ROT/RUT payout request: both flows hand work to Skatteverket for external upload or BankID signing before Accounted can observe the filing outcome, so a missing receipt or locally generated/cancelled status cannot prove that the data is disposable. [2026-08-19] Keep reversal allocation metadata limited to failures before any reversal header exists: later cleanup preserves a cancelled header with the allocated voucher number, so documenting it as an unused voucher gap would be false. +[2026-08-19] Inline rättelse bank guard anchors to the linked bank amount, per account, not to the pre-state and not to the 19xx group net: a non-zero change on a 19xx/cash-ledger account is allowed iff the post-state net on that account equals the signed sum of the linked transactions resolved to it (once per transaction, split links by allocated_amount; NULL cash_account_id resolves to the primary cash account, then 1930). Per account rather than group so a wrong-bank-account booking (1930 vs 1940) stays a storno job: a group check would let the net drift between accounts and break per-account bank reconciliation. When no anchor resolves the old strict refusal stands. Reskontra sides (15xx/24xx) stay strictly net-preserving because their anchor is the payment row, not a bank amount. diff --git a/supabase/migrations/20260819092408_inline_rattelse_bank_anchor.sql b/supabase/migrations/20260819092408_inline_rattelse_bank_anchor.sql new file mode 100644 index 00000000..ea5c5365 --- /dev/null +++ b/supabase/migrations/20260819092408_inline_rattelse_bank_anchor.sql @@ -0,0 +1,442 @@ +-- Migration: inline rättelse, bank-anchored strikes +-- +-- Support case 2026-08-19 (Discord, Sebastian): a bank-linked verifikat was +-- booked as 1930 D 10 874,81 / 1930 K 10 874,81 (the credit should have been +-- 2970 Förutbetalda intäkter). Striking the wrong 1930 K line and adding +-- 2970 K was refused by correct_entry_lines_inline with "Raden mot konto 1930 +-- kan inte ändras: verifikationen är kopplad till en banktransaktion". +-- +-- The original guard (20260723210000) required the per-account net on the +-- bank side to stay unchanged whenever the entry is linked to a transaction. +-- That protects the common case (the bank line is right, the contra line is +-- wrong) but also blocks the one rättelse that makes a mis-booked bank side +-- match the feed again: here the 1930 net was 0 against a +10 874,81 deposit, +-- and the fix moves it TO the bank amount. +-- +-- This redefinition keeps the reconciliation invariant but anchors it to the +-- external amount instead of to the pre-state: a non-zero change on a 19xx / +-- cash-ledger account is allowed iff the post-state net on that account +-- equals the signed sum of the linked bank transactions resolved to it +-- (counted once per transaction, split links by allocated_amount). When no +-- anchor resolves for the account, or the post-state does not match it, the +-- strike is still refused with a message that now shows both amounts. +-- Reskontra sides (15xx / 24xx behind invoice_payments / +-- supplier_invoice_payments) stay strictly net-preserving as before. +-- +-- Nothing else in the function changes. Tested in +-- tests/pg/inline-rattelse.pg.test.ts. + +CREATE OR REPLACE FUNCTION public.correct_entry_lines_inline( + p_company_id uuid, + p_entry_id uuid, + p_strike_line_ids uuid[], + p_new_lines jsonb DEFAULT '[]'::jsonb, + 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_entry record; + v_is_closed boolean; + v_locked_at timestamptz; + v_lock_date date; + v_strike_ids uuid[] := ARRAY(SELECT DISTINCT unnest(COALESCE(p_strike_line_ids, '{}'::uuid[]))); + v_strike_count int := COALESCE(array_length(v_strike_ids, 1), 0); + v_owned_count int; + v_line jsonb; + v_acc text; + v_debit numeric; + v_credit numeric; + v_new_count int := 0; + v_new_debit numeric := 0; + v_new_credit numeric := 0; + v_rem_debit numeric; + v_rem_credit numeric; + v_rem_count int; + v_struck_json jsonb; + v_struck_keys text[]; + v_added_keys text[]; + v_sort int; + v_added_ids uuid[] := '{}'; + v_added_json jsonb; + v_new_id uuid; + v_log_id uuid; + v_fin_debit numeric; + v_fin_credit numeric; + v_fin_count int; + v_bank_linked boolean; + v_invoice_linked boolean; + v_supplier_linked boolean; + v_delta numeric; + v_anchor numeric; + v_post_net numeric; +BEGIN + IF v_jwt_role IN ('anon', 'authenticated') THEN + IF NOT public.caller_is_company_member(p_company_id) THEN + RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id + USING ERRCODE = '42501'; + END IF; + -- A JWT caller can never act as someone else: p_user_id is only for + -- service-role paths, which authenticate the user application-side. + v_actor := auth.uid(); + END IF; + + 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 rätta verifikat.'; + END IF; + + IF p_new_lines IS NULL OR jsonb_typeof(p_new_lines) <> 'array' THEN + RAISE EXCEPTION 'Nya rader måste vara en lista.'; + END IF; + + IF v_strike_count = 0 AND jsonb_array_length(p_new_lines) = 0 THEN + RAISE EXCEPTION 'Rättelsen måste stryka eller lägga till minst en rad.'; + END IF; + + IF jsonb_array_length(p_new_lines) > 100 THEN + RAISE EXCEPTION 'Högst 100 nya rader per rättelse.'; + END IF; + + SELECT je.id, je.status, je.entry_date, je.source_type, + je.fiscal_period_id, je.company_id AS entry_company_id + INTO v_entry + FROM public.journal_entries je + WHERE je.id = p_entry_id + FOR UPDATE OF je; + + IF NOT FOUND OR v_entry.entry_company_id <> p_company_id THEN + RAISE EXCEPTION 'Verifikationen hittades inte.'; + END IF; + + IF v_entry.status <> 'posted' THEN + RAISE EXCEPTION 'Endast bokförda verifikat kan rättas (utkast redigeras direkt).'; + END IF; + + -- Structural entry types keep their dedicated flows: a storno mirrors its + -- original, an IB feeds opening_balance_entry_id, year-end vouchers feed + -- dispositions/idempotency checks. + IF v_entry.source_type IN ('storno', 'opening_balance', 'year_end', 'vat_settlement') THEN + RAISE EXCEPTION 'Den här verifikationstypen kan inte rättas radvis: använd dess egen rättelsefunktion.'; + END IF; + + SELECT fp.is_closed, fp.locked_at INTO v_is_closed, v_locked_at + FROM public.fiscal_periods fp + WHERE fp.id = v_entry.fiscal_period_id; + + IF v_is_closed OR v_locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Perioden är stängd eller låst: använd rättelseverifikat (storno).'; + 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_entry.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; + + -- Every struck id must be a line of THIS entry. + SELECT count(*) INTO v_owned_count + FROM public.journal_entry_lines jel + WHERE jel.journal_entry_id = p_entry_id + AND jel.id = ANY (v_strike_ids); + + IF v_owned_count <> v_strike_count THEN + RAISE EXCEPTION 'En eller flera rader som ska strykas hör inte till verifikationen.'; + END IF; + + -- Foreign-currency lines carry conversion data (amount_in_currency / + -- exchange_rate) that replacement lines cannot reproduce: those + -- corrections stay on the storno flow. + IF EXISTS ( + SELECT 1 FROM public.journal_entry_lines jel + WHERE jel.journal_entry_id = p_entry_id + AND jel.id = ANY (v_strike_ids) + AND jel.currency IS NOT NULL AND jel.currency <> 'SEK' + ) THEN + RAISE EXCEPTION 'Rader i utländsk valuta kan inte strykas: använd rättelseverifikat (storno).'; + END IF; + + -- A struck line with a line-level underlag link would sever the document + -- coupling (document_attachments.journal_entry_line_id is ON DELETE + -- RESTRICT, so the DELETE would fail anyway: this gives a clear message). + IF EXISTS ( + SELECT 1 FROM public.document_attachments da + WHERE da.journal_entry_line_id = ANY (v_strike_ids) + ) THEN + RAISE EXCEPTION 'En rad som ska strykas har ett kopplat underlag: använd rättelseverifikat (storno).'; + END IF; + + -- Validate the replacement lines. SEK only: inline additions never carry + -- foreign-currency conversion data (that correction stays on the storno flow). + FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_lines) + LOOP + v_acc := btrim(COALESCE(v_line ->> 'account_number', '')); + v_debit := round(COALESCE((v_line ->> 'debit_amount')::numeric, 0), 2); + v_credit := round(COALESCE((v_line ->> 'credit_amount')::numeric, 0), 2); + + IF v_acc !~ '^[0-9]{4}$' THEN + RAISE EXCEPTION 'Ogiltigt kontonummer: "%".', v_acc; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.chart_of_accounts coa + WHERE coa.company_id = p_company_id AND coa.account_number = v_acc + ) THEN + RAISE EXCEPTION 'Kontot % finns inte i kontoplanen.', v_acc; + END IF; + IF v_debit < 0 OR v_credit < 0 THEN + RAISE EXCEPTION 'Belopp kan inte vara negativa (konto %).', v_acc; + END IF; + IF v_debit > 0 AND v_credit > 0 THEN + RAISE EXCEPTION 'En rad kan inte ha både debet och kredit (konto %).', v_acc; + END IF; + IF v_debit = 0 AND v_credit = 0 THEN + RAISE EXCEPTION 'En rad måste ha ett belopp (konto %).', v_acc; + END IF; + + v_new_count := v_new_count + 1; + v_new_debit := v_new_debit + v_debit; + v_new_credit := v_new_credit + v_credit; + END LOOP; + + -- Effective post-state must balance and stay a real bokföringspost. + SELECT COALESCE(sum(jel.debit_amount), 0), COALESCE(sum(jel.credit_amount), 0), count(*) + INTO v_rem_debit, v_rem_credit, v_rem_count + FROM public.journal_entry_lines jel + WHERE jel.journal_entry_id = p_entry_id + AND NOT (jel.id = ANY (v_strike_ids)); + + IF (v_rem_count + v_new_count) < 2 THEN + RAISE EXCEPTION 'Verifikationen måste ha minst två rader efter rättelsen. Använd "Återför (storno)" för att makulera hela verifikationen.'; + END IF; + + IF abs((v_rem_debit + v_new_debit) - (v_rem_credit + v_new_credit)) >= 0.005 THEN + RAISE EXCEPTION 'Verifikationen balanserar inte efter rättelsen (debet %, kredit %).', + round(v_rem_debit + v_new_debit, 2), round(v_rem_credit + v_new_credit, 2); + END IF; + + IF (v_rem_debit + v_new_debit) < 0.005 THEN + RAISE EXCEPTION 'Rättelsen skulle nollställa verifikationen. Använd "Återför (storno)" i stället.'; + END IF; + + -- A rättelse must change something: striking rows and re-adding an + -- identical set is a no-op in disguise. + SELECT COALESCE(array_agg(k ORDER BY k), '{}'), COALESCE(jsonb_agg(to_jsonb(jel) ORDER BY jel.sort_order), '[]'::jsonb) + INTO v_struck_keys, v_struck_json + FROM public.journal_entry_lines jel, + LATERAL (SELECT jel.account_number || '|' || round(jel.debit_amount, 2)::text || '|' + || round(jel.credit_amount, 2)::text || '|' || COALESCE(jel.line_description, '')) AS key(k) + WHERE jel.journal_entry_id = p_entry_id + AND jel.id = ANY (v_strike_ids); + + SELECT COALESCE(array_agg(k ORDER BY k), '{}') + INTO v_added_keys + FROM ( + SELECT btrim(l ->> 'account_number') || '|' + || round(COALESCE((l ->> 'debit_amount')::numeric, 0), 2)::text || '|' + || round(COALESCE((l ->> 'credit_amount')::numeric, 0), 2)::text || '|' + || COALESCE(NULLIF(btrim(COALESCE(l ->> 'line_description', '')), ''), '') AS k + FROM jsonb_array_elements(p_new_lines) AS l + ) keys; + + IF v_struck_keys = v_added_keys THEN + RAISE EXCEPTION 'Rättelsen ändrar ingenting.'; + END IF; + + -- Reconciliation guard: when the entry is anchored to external records + -- (bank transactions, payment links), the anchored side must agree with + -- the external amount. The bank feed / payment amount is immutable, so a + -- strike that moves the 19xx/cash-account (or reskontra) net AWAY from it + -- would create a permanent unexplained reconciliation difference. + -- + -- On the bank side two shapes are allowed: + -- 1. net-preserving strikes (e.g. fixing a line description); + -- 2. strikes whose post-state net on the account EQUALS the linked bank + -- amount. This is the "wrong contra line on the bank account itself" + -- case (1930 D / 1930 K against a deposit): the entry never matched + -- the feed, and the rättelse is exactly what makes it match again. + -- Anything else still needs a rättelseverifikat (storno). Reskontra sides + -- (15xx for customer payments, 24xx for supplier payments) stay strictly + -- net-preserving: their anchor is the payment row, not a bank amount. + v_bank_linked := EXISTS (SELECT 1 FROM public.transactions t WHERE t.journal_entry_id = p_entry_id) + OR EXISTS (SELECT 1 FROM public.transaction_voucher_links tvl WHERE tvl.journal_entry_id = p_entry_id); + v_invoice_linked := EXISTS (SELECT 1 FROM public.invoice_payments ip WHERE ip.journal_entry_id = p_entry_id); + v_supplier_linked := EXISTS (SELECT 1 FROM public.supplier_invoice_payments sp WHERE sp.journal_entry_id = p_entry_id); + + IF v_bank_linked OR v_invoice_linked OR v_supplier_linked THEN + FOR v_acc, v_delta IN + SELECT x.acc, sum(x.delta) + FROM ( + SELECT jel.account_number AS acc, + -(jel.debit_amount - jel.credit_amount) AS delta + FROM public.journal_entry_lines jel + WHERE jel.journal_entry_id = p_entry_id + AND jel.id = ANY (v_strike_ids) + UNION ALL + SELECT btrim(l ->> 'account_number'), + round(COALESCE((l ->> 'debit_amount')::numeric, 0), 2) + - round(COALESCE((l ->> 'credit_amount')::numeric, 0), 2) + FROM jsonb_array_elements(p_new_lines) AS l + ) x + GROUP BY x.acc + LOOP + IF abs(v_delta) < 0.005 THEN + CONTINUE; + END IF; + + IF v_bank_linked AND (v_acc LIKE '19%' OR v_acc IN ( + SELECT ca.ledger_account FROM public.cash_accounts ca WHERE ca.company_id = p_company_id)) THEN + -- Signed bank amount anchored on this account across every linked + -- transaction, counted once per transaction: a split link + -- (transaction_voucher_links, bank_line role) carries the allocated + -- slice, otherwise the transaction's own amount (the 1:1 path sets + -- both the direct FK and a link row for the same transaction). + -- Positive = deposit = debit on the bank account, so it compares to + -- the post-state net debit - credit. A transaction without a + -- cash_account_id resolves to the company's primary cash account, + -- falling back to 1930 (the historical default ledger). + SELECT sum(x.amount) INTO v_anchor + FROM ( + SELECT COALESCE( + (SELECT sum(tvl.allocated_amount) + FROM public.transaction_voucher_links tvl + WHERE tvl.transaction_id = t.id + AND tvl.journal_entry_id = p_entry_id + AND tvl.role = 'bank_line'), + t.amount) AS amount + FROM public.transactions t + WHERE t.company_id = p_company_id + AND (t.journal_entry_id = p_entry_id + OR EXISTS (SELECT 1 FROM public.transaction_voucher_links tvl + WHERE tvl.transaction_id = t.id + AND tvl.journal_entry_id = p_entry_id + AND tvl.role = 'bank_line')) + AND COALESCE( + (SELECT ca.ledger_account FROM public.cash_accounts ca WHERE ca.id = t.cash_account_id), + (SELECT ca.ledger_account FROM public.cash_accounts ca + WHERE ca.company_id = p_company_id AND ca.is_primary + ORDER BY ca.created_at LIMIT 1), + '1930') = v_acc + ) x; + + SELECT COALESCE(sum(jel.debit_amount - jel.credit_amount), 0) INTO v_post_net + FROM public.journal_entry_lines jel + WHERE jel.journal_entry_id = p_entry_id + AND jel.account_number = v_acc + AND NOT (jel.id = ANY (v_strike_ids)); + v_post_net := v_post_net + COALESCE(( + SELECT sum(round(COALESCE((l ->> 'debit_amount')::numeric, 0), 2) + - round(COALESCE((l ->> 'credit_amount')::numeric, 0), 2)) + FROM jsonb_array_elements(p_new_lines) AS l + WHERE btrim(l ->> 'account_number') = v_acc), 0); + + IF v_anchor IS NULL THEN + RAISE EXCEPTION 'Raden mot konto % kan inte ändras: verifikationen är kopplad till en banktransaktion eller betalning. Använd rättelseverifikat (storno).', v_acc; + END IF; + IF abs(v_post_net - v_anchor) >= 0.005 THEN + RAISE EXCEPTION 'Raden mot konto % kan inte ändras så: verifikationen är kopplad till en banktransaktion på % kr, och kontots belopp efter rättelsen skulle bli % kr. Rättelsen måste få bankkontot att stämma med banken, annars: använd rättelseverifikat (storno).', + v_acc, round(v_anchor, 2), round(v_post_net, 2); + END IF; + ELSIF (v_invoice_linked AND v_acc LIKE '15%') + OR (v_supplier_linked AND v_acc LIKE '24%') THEN + RAISE EXCEPTION 'Raden mot konto % kan inte ändras: verifikationen är kopplad till en banktransaktion eller betalning. Använd rättelseverifikat (storno).', v_acc; + END IF; + END LOOP; + END IF; + + PERFORM set_config('gnubok.allow_line_rattelse', 'true', true); + + DELETE FROM public.journal_entry_lines + WHERE journal_entry_id = p_entry_id + AND id = ANY (v_strike_ids); + + SELECT COALESCE(max(jel.sort_order), 0) INTO v_sort + FROM public.journal_entry_lines jel + WHERE jel.journal_entry_id = p_entry_id; + + FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_lines) + LOOP + v_sort := v_sort + 1; + -- cost_center/project are GENERATED columns derived from dimensions: + -- never inserted explicitly, they recompute from the bag. + INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, account_id, debit_amount, credit_amount, + line_description, sort_order, dimensions, currency) + VALUES + (p_entry_id, + btrim(v_line ->> 'account_number'), + (SELECT coa.id FROM public.chart_of_accounts coa + WHERE coa.company_id = p_company_id + AND coa.account_number = btrim(v_line ->> 'account_number') + ORDER BY (coa.is_active IS TRUE) DESC, coa.created_at + LIMIT 1), + round(COALESCE((v_line ->> 'debit_amount')::numeric, 0), 2), + round(COALESCE((v_line ->> 'credit_amount')::numeric, 0), 2), + NULLIF(btrim(COALESCE(v_line ->> 'line_description', '')), ''), + v_sort, + COALESCE(v_line -> 'dimensions', '{}'::jsonb), + 'SEK') + RETURNING id INTO v_new_id; + v_added_ids := v_added_ids || v_new_id; + END LOOP; + + PERFORM set_config('gnubok.allow_line_rattelse', 'false', true); + + -- Authoritative post-state verification straight from the table: the entry + -- must still balance to the öre and hold at least two lines, or everything + -- rolls back. + SELECT COALESCE(sum(jel.debit_amount), 0), COALESCE(sum(jel.credit_amount), 0), count(*) + INTO v_fin_debit, v_fin_credit, v_fin_count + FROM public.journal_entry_lines jel + WHERE jel.journal_entry_id = p_entry_id; + + IF abs(v_fin_debit - v_fin_credit) >= 0.005 OR v_fin_count < 2 OR v_fin_debit < 0.005 THEN + RAISE EXCEPTION 'Internt fel: verifikationen balanserar inte efter rättelsen: ändringen har återställts.'; + END IF; + + -- Close the check-then-write window on period locks: if a lock or close + -- committed while this rättelse was running, abort and roll back rather + -- than write into a period that is now locked. + SELECT fp.is_closed, fp.locked_at INTO v_is_closed, v_locked_at + FROM public.fiscal_periods fp + WHERE fp.id = v_entry.fiscal_period_id; + IF v_is_closed OR v_locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Perioden är stängd eller låst: använd rättelseverifikat (storno).'; + END IF; + + SELECT COALESCE(jsonb_agg(to_jsonb(jel) ORDER BY jel.sort_order), '[]'::jsonb) + INTO v_added_json + FROM public.journal_entry_lines jel + WHERE jel.id = ANY (v_added_ids); + + INSERT INTO public.journal_entry_rattelse_log + (company_id, journal_entry_id, rattelse_type, struck_lines, added_lines, actor) + VALUES + (p_company_id, p_entry_id, 'lines', v_struck_json, v_added_json, v_actor) + RETURNING id INTO v_log_id; + + RETURN jsonb_build_object( + 'log_id', v_log_id, + 'struck_count', v_strike_count, + 'added_count', v_new_count, + 'total_debit', round(v_fin_debit, 2), + 'total_credit', round(v_fin_credit, 2) + ); +END; +$function$; + + +REVOKE ALL ON FUNCTION public.correct_entry_lines_inline(uuid, uuid, uuid[], jsonb, uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.correct_entry_lines_inline(uuid, uuid, uuid[], jsonb, uuid) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/inline-rattelse.pg.test.ts b/tests/pg/inline-rattelse.pg.test.ts index 21151593..582d23a1 100644 --- a/tests/pg/inline-rattelse.pg.test.ts +++ b/tests/pg/inline-rattelse.pg.test.ts @@ -6,6 +6,7 @@ import { insertAuthUser, insertCompanyMember, insertDraftJournalEntry, + insertCashAccount, } from '@/tests/pg/fixtures' // Migration 20260723210000_verifikat_inline_rattelse.sql: the founder-approved @@ -548,6 +549,132 @@ describe('inline rättelse: lines (correct_entry_lines_inline)', () => { expect(res2.rows[0].result.struck_count).toBe(1) }) + // 20260819092408_inline_rattelse_bank_anchor.sql: the bank-side guard is + // anchored to the linked bank amount, not to the pre-state. The Discord + // case (Sebastian, 2026-08-19): a +10 874,81 deposit booked as 1930 D / + // 1930 K (the credit should have been 2970), so the 1930 net was 0 and the + // only rättelse that makes the entry match the feed was refused. + it('allows a bank-side strike that makes the 19xx net equal the linked bank amount', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + await insertChartAccount(companyId, userId, '1930') + await insertChartAccount(companyId, userId, '2970') + const entryId = await insertDraftJournalEntry({ + userId, companyId, fiscalPeriodId, sourceType: 'bank_transaction', status: 'draft', voucherNumber: 21, + }) + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order) + VALUES ($1, '1930', 10874.81, 0, 1)`, + [entryId], + ) + const { rows: wrongRows } = await getPool().query<{ id: string }>( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order, line_description) + VALUES ($1, '1930', 0, 10874.81, 2, 'Förutbetalda intäkter') RETURNING id`, + [entryId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId]) + await getPool().query( + `INSERT INTO public.transactions (user_id, company_id, date, description, amount, journal_entry_id, is_business) + VALUES ($1, $2, '2026-02-10', 'BOKADIREKT X', 10874.81, $3, true)`, + [userId, companyId, entryId], + ) + + // Moving the 1930 net somewhere that is NOT the bank amount is still + // refused, and the message now carries both amounts. + await expect( + callStrike(companyId, entryId, [wrongRows[0].id], + [ + { account_number: '1930', debit_amount: 0, credit_amount: 5000 }, + { account_number: '2970', debit_amount: 0, credit_amount: 5874.81 }, + ], userId), + ).rejects.toThrow(/kopplad till en banktransaktion på 10874\.81 kr.*5874\.81 kr/) + + // Striking the wrong 1930 K line and re-adding it on 2970 takes the 1930 + // net from 0 to +10 874,81 = the deposit. Allowed. + const res = await callStrike(companyId, entryId, [wrongRows[0].id], + [{ account_number: '2970', debit_amount: 0, credit_amount: 10874.81, line_description: 'Förutbetalda intäkter' }], userId) + expect(res.rows[0].result.struck_count).toBe(1) + expect(res.rows[0].result.added_count).toBe(1) + + const { rows: after } = await getPool().query<{ account_number: string; debit_amount: string; credit_amount: string }>( + `SELECT account_number, debit_amount::text, credit_amount::text FROM public.journal_entry_lines + WHERE journal_entry_id = $1 ORDER BY sort_order`, + [entryId], + ) + expect(after.map((l) => [l.account_number, Number(l.debit_amount), Number(l.credit_amount)])).toEqual([ + ['1930', 10874.81, 0], + ['2970', 0, 10874.81], + ]) + + // Now that the bank side matches the feed, moving it again is refused: + // the anchor is the feed, not the pre-state. + const { rows: bankRows } = await getPool().query<{ id: string }>( + `SELECT id FROM public.journal_entry_lines WHERE journal_entry_id = $1 AND account_number = '1930'`, + [entryId], + ) + await expect( + callStrike(companyId, entryId, [bankRows[0].id], + [ + { account_number: '1930', debit_amount: 10000, credit_amount: 0 }, + { account_number: '2970', debit_amount: 874.81, credit_amount: 0 }, + ], userId), + ).rejects.toThrow(/kopplad till en banktransaktion/) + }) + + it('anchors split-linked entries by allocated_amount on the transaction cash account, once per transaction', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + await insertChartAccount(companyId, userId, '1940') + await insertChartAccount(companyId, userId, '3010') + const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1940' }) + const entryId = await insertDraftJournalEntry({ + userId, companyId, fiscalPeriodId, sourceType: 'bank_transaction', status: 'draft', voucherNumber: 22, + }) + // Two deposits (600 + 400) on the 1940 cash account, booked as + // 1940 D 1000 / 1940 K 1000: the contra line landed on the bank account. + await getPool().query( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order) + VALUES ($1, '1940', 1000, 0, 1)`, + [entryId], + ) + const { rows: wrongRows } = await getPool().query<{ id: string }>( + `INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order) + VALUES ($1, '1940', 0, 1000, 2) RETURNING id`, + [entryId], + ) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId]) + const { rows: txRows } = await getPool().query<{ id: string }>( + `INSERT INTO public.transactions (user_id, company_id, date, description, amount, cash_account_id, is_business, journal_entry_id) + VALUES ($1, $2, '2026-02-10', 'Swish 1', 600, $3, true, NULL), + ($1, $2, '2026-02-10', 'Swish 2', 400, $3, true, $4) + RETURNING id`, + [userId, companyId, cashAccountId, entryId], + ) + // Both transactions carry split links; the second ALSO has the direct FK + // (the 1:1 bulk-book shape). Double counting it would make the anchor + // 1 400 and wrongly refuse the fix below. + for (const tx of txRows) { + await getPool().query( + `INSERT INTO public.transaction_voucher_links + (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role) + VALUES ($1, $2, $3, $4, (SELECT amount FROM public.transactions WHERE id = $3), 'bank_line')`, + [userId, companyId, tx.id, entryId], + ) + } + + // Partial move: 1940 would end at 400 ≠ 1 000. Refused with the amounts. + await expect( + callStrike(companyId, entryId, [wrongRows[0].id], + [ + { account_number: '1940', debit_amount: 0, credit_amount: 600 }, + { account_number: '3010', debit_amount: 0, credit_amount: 400 }, + ], userId), + ).rejects.toThrow(/banktransaktion på 1000\.00 kr.*400\.00 kr/) + + // Full move: 1940 net 0 -> 1 000 = 600 + 400. Allowed. + const res = await callStrike(companyId, entryId, [wrongRows[0].id], + [{ account_number: '3010', debit_amount: 0, credit_amount: 1000 }], userId) + expect(res.rows[0].result.struck_count).toBe(1) + }) + it('keeps the journal_entry_rattelse_log immutable', async () => { const { companyId, userId, fiscalPeriodId } = await seedCompany() await insertChartAccount(companyId, userId, '5420')