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:
Jakob Wennberg
2026-08-19 20:09:23 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 06e554f3cc
commit 926ca75fd3
3 changed files with 570 additions and 0 deletions
+1
View File
@@ -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';
+127
View File
@@ -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')