* feat(bookkeeping): cascade opening-balance corrections to later years Fortnox/SIE migrations book one IB verifikat per imported year, so correcting one year's ingaende balans left every later year's linked IB carrying the stale figures (support case: a 2019 IB fixed in Fortnox after export never reached Accounted, skewing all subsequent saldon). - POST /api/import/opening-balance/correct accepts cascade: true and applies the correction's per-account delta to each subsequent year's IB via storno + rebook + relink (lib/import/opening-balance/cascade.ts). Locked/closed/lock-dated/bokslut years are skipped and reported, never forced; a failed year is compensated and the cascade continues. - CorrectOpeningBalanceDialog offers the cascade as a default-checked checkbox when later years have their own IB verifikat, and when the current year is blocked it points at the earliest open year's IB verifikat instead of dead-ending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): atomic cascade replacement + review findings for PR #2076 - Cascade now books each later year through replaceOpeningBalanceEntry (one RPC transaction: storno + corrected voucher + pointer swap, CAS on the expected old entry), removing the create/reverse/relink window that could leave a period linked to a reversed IB entry. - Cascaded verifikat keep the original lines verbatim (descriptions and dimensions) and append labelled IB-rättelse adjustment lines per changed account instead of collapsing per-account nets. - Year-end lookup fails closed: a query error skips the period instead of reading as 'no bokslut'. - Dialog always sends the cascade flag (a cold reference cache no longer silently disables the default-on cascade), the success toast separates blocked years from failed years needing review, and the checkbox notes that a resultat correction may still need an omforing to 2091. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * feat(bookkeeping): Fortnox-style inline IB correction without storno Founder decision 2026-08-31: IB edits in open unlocked years should feel like Fortnox (change the number, no extra verifikat) instead of always producing a storno + rebook pair in serie A. - Migration 20260831150000 redefines correct_entry_lines_inline to admit source_type 'opening_balance' with three IB guards: only the period's current linked IB, no posted bokslut on the period, and replacement lines restricted to balance-sheet accounts (class 1-2). The entry id never changes, so fiscal_periods.opening_balance_entry_id stays valid and every report reads the corrected lines automatically. Storno, year_end and vat_settlement stay excluded; locked/closed/lock-dated periods are still refused (BFL 5 kap 5 par: storno is the only track there). - New POST /api/import/opening-balance/correct-inline: diff-based strike and replace inside the same IB verifikat, same OB_* pre-flight codes as the storno route, RPC rule violations surfaced verbatim as 409 OB_INLINE_REFUSED. With cascade: true the per-account delta is appended as labelled IB-rattelse lines inside each later open year's own IB verifikat (cascade mode 'inline'): a multi-year correction with zero new verifikat. - CorrectOpeningBalanceDialog computes the row diff (untouched lines keep ids, descriptions and dimensions) and posts to the inline route; copy updated (no storno language), toast reports inline updates. - In-app agent guidance (shared-rules) updated to describe the inline flow and the cascade checkbox. - Tests: pg-real suite for the redefined RPC (IB accept, linked-IB guard, bokslut guard, P&L guard, structural types still refused, non-IB unaffected), route tests, cascade inline-mode unit tests. The storno-based /correct route and engine paths are untouched: they remain for the import replace flow and API compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(agent): avoid the BFL 5 kap 5 par marker string in IB guidance The verifikation-draft period-lock gate test uses the literal 'BFL 5 kap 5 §' as a marker for locked-period-only guidance; the new IB bullet in shared-rules carried the same string in every prompt and broke the open-period assertion. Reference Bokföringslagen generically instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): derive inline cascade delta from the rattelse log Swedish-review finding on PR #2076: the cascade delta was computed from a route-side line snapshot read before the RPC, which a concurrent edit could theoretically desync from what the RPC actually committed. The delta now comes from the RPC's own journal_entry_rattelse_log row (struck_lines/added_lines snapshotted inside the RPC transaction), so the cascade always matches the committed base correction. Also softened the blocked-year guidance copy (declared-status is an assumption, not a verified fact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): visible cascade failure + dimensions-aware no-op check CodeRabbit round-2 findings on PR #2076: - A cascade that failed to run (log fetch error, unexpected throw) was returned as an empty successful summary, so the dialog reported nothing wrong while later years stayed unverified. Both routes now mark it failed: true and the dialog tells the user to check later years' opening balances. - The RPC's no-op guard compared account/amount/description only, so a dimensions-only rattelse raised 'Rattelsen andrar ingenting'. The comparison keys now include canonical dimensions jsonb text (fixed in the unmerged 20260831150000 migration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
482 lines
22 KiB
PL/PgSQL
482 lines
22 KiB
PL/PgSQL
-- Migration: inline rättelse for opening-balance verifikat
|
|
--
|
|
-- Founder decision 2026-08-31: IB corrections should feel like Fortnox.
|
|
-- Fortnox stores ingående balanser as directly editable saldon; in Accounted
|
|
-- the IB is a posted verifikat, so every edit went through the storno flow
|
|
-- (särskild rättelsepost) and produced two extra verifikat in serie A even
|
|
-- for open, unlocked years. BFL 5 kap 5 § permits a second track while the
|
|
-- year is open and unlocked: rättelse inside the same verifikat with an
|
|
-- immutable who/when log, which correct_entry_lines_inline already
|
|
-- implements for regular verifikat (journal_entry_rattelse_log).
|
|
--
|
|
-- This redefinition admits source_type = 'opening_balance' with three
|
|
-- IB-specific guards on top of the existing ones:
|
|
-- 1. The entry must be the period's CURRENT linked IB
|
|
-- (fiscal_periods.opening_balance_entry_id = the entry): a superseded
|
|
-- or orphaned IB entry is not a meaningful rättelse target.
|
|
-- 2. The period must not carry a posted year-end verifikat: correcting IB
|
|
-- under a bokslut would leave the close inconsistent (same rule as the
|
|
-- storno-based /opening-balance/correct route).
|
|
-- 3. New lines must be balance-sheet accounts (class 1-2): an IB carrying
|
|
-- P&L accounts would violate BFNAR 2013:2 (P&L resets at year start).
|
|
--
|
|
-- The period's opening_balance_entry_id never changes (same entry id), so
|
|
-- enforce_opening_balance_immutability is untouched and every report reads
|
|
-- the corrected lines automatically. Storno ('storno'), year-end
|
|
-- ('year_end') and vat_settlement entries keep their dedicated flows, and
|
|
-- locked/closed/lock-dated periods are still refused: there the storno track
|
|
-- remains the only lawful rättelse.
|
|
--
|
|
-- Everything else is byte-identical to 20260819092408. Tested in
|
|
-- tests/pg/inline-rattelse-opening-balance.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;
|
|
v_is_ob boolean := false;
|
|
v_linked_ob uuid;
|
|
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, year-end vouchers feed dispositions/idempotency checks. An IB
|
|
-- (source_type 'opening_balance') IS allowed since 20260831150000: the
|
|
-- entry id (and thus fiscal_periods.opening_balance_entry_id) never
|
|
-- changes, and the IB-specific guards below apply.
|
|
IF v_entry.source_type IN ('storno', 'year_end', 'vat_settlement') THEN
|
|
RAISE EXCEPTION 'Den här verifikationstypen kan inte rättas radvis: använd dess egen rättelsefunktion.';
|
|
END IF;
|
|
|
|
v_is_ob := v_entry.source_type = 'opening_balance';
|
|
|
|
SELECT fp.is_closed, fp.locked_at, fp.opening_balance_entry_id
|
|
INTO v_is_closed, v_locked_at, v_linked_ob
|
|
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;
|
|
|
|
IF v_is_ob THEN
|
|
-- Guard 1: only the period's CURRENT linked IB is a rättelse target.
|
|
IF v_linked_ob IS DISTINCT FROM p_entry_id THEN
|
|
RAISE EXCEPTION 'Verifikationen är inte periodens aktuella ingående balans.';
|
|
END IF;
|
|
|
|
-- Guard 2: a posted bokslut on the period must be unwound first, same
|
|
-- rule as the storno-based IB correction flow.
|
|
IF EXISTS (
|
|
SELECT 1 FROM public.journal_entries je2
|
|
WHERE je2.company_id = p_company_id
|
|
AND je2.fiscal_period_id = v_entry.fiscal_period_id
|
|
AND je2.source_type = 'year_end'
|
|
AND je2.status = 'posted'
|
|
) THEN
|
|
RAISE EXCEPTION 'Perioden har ett bokslut. Återför bokslutet innan ingående balanser kan rättas.';
|
|
END IF;
|
|
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;
|
|
-- Guard 3 (IB only): opening balances hold balance-sheet accounts
|
|
-- (class 1-2). P&L balances reset at year start (BFNAR 2013:2).
|
|
IF v_is_ob AND left(v_acc, 1) NOT IN ('1', '2') THEN
|
|
RAISE EXCEPTION 'Resultatkonton (klass 3-8) kan inte användas i ingående balanser (konto %).', 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. The key includes dimensions (as
|
|
-- canonical jsonb text) so a dimensions-only rättelse counts as a change
|
|
-- (CodeRabbit finding on PR #2076; the 20260819 version omitted it).
|
|
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, '') || '|'
|
|
|| COALESCE(jel.dimensions, '{}'::jsonb)::text) 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', '')), ''), '') || '|'
|
|
|| COALESCE(l -> 'dimensions', '{}'::jsonb)::text 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';
|