fix(rattelse): let a bank-side strike land on the linked bank amount (#1701)
The inline rättelse RPC refused any net change on a 19xx account when the verifikat is linked to a bank transaction, including the one change that makes a mis-booked bank side match the feed (1930 D / 1930 K against a deposit, credit should have been 2970). Anchor the guard to the signed linked bank amount instead of to the pre-state: a non-zero change on the bank account is allowed iff the post-state net equals the linked amount (once per transaction, split links by allocated_amount). Reskontra sides stay strictly net-preserving. The refusal message now shows both amounts. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
06e554f3cc
commit
926ca75fd3
@@ -1081,3 +1081,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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';
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user