Files
accounted/supabase/migrations/20260723210000_verifikat_inline_rattelse.sql
T
MattssonandClaude Fable 5 d840257c0c Add/stripe connect transactions (#1139)
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login

Add chatgpt.com/connector/oauth/* (per-instance) and the legacy
chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth
redirect allowlist so ChatGPT MCP connectors can register and authorize.

Fix the login page dropping the ?next= destination: an OAuth-initiated
visit that required login previously ended on the dashboard and the
connection flow silently died. Login now resumes to the sanitized next
path (hard navigation, since the consent page is route-handler HTML),
carries it through the MFA step-up as returnTo, and /mfa/verify
hard-navigates for /api/ destinations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): dedup incoming feed rows against booked hand-entered twins

Users who bookkeep via MCP/chat first and connect their bank afterwards got
the same movement twice: the synced row's external_id lives in a different
namespace, the free-form manual title never text-bridges the bank's raw
string, and the cross-channel mirror deliberately excluded manual/mcp rows.

Extend the mirror with a booked-hand-entered track: an incoming feed row is
skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count-
symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be
booked (staged rows never consume an import), currencies must not contradict
(bucket key is date+ore only), the cash-account guard applies to the count
exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming
count so an already-stored row cannot inflate it. Consumption stamps the
batch cash_account_id onto an account-unbound hand row, so one hand row can
never consume feed rows on other accounts in later syncs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit)

Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style:
strike lines inside a posted verifikat with replacements in the same
voucher, and correct description/entry_date without an andringsverifikat.
Envelope: posted entries, open unlocked periods, company lock date,
same-period date moves, structural/FX/doc-linked lines excluded, and a
reconciliation guard preserving per-account net on bank/reskontra sides of
externally linked entries. Every rattelse writes an immutable who/when row
(journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and
struck originals render struck-through in the verifikat; list rows and the
detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the
swedish-accounting-compliance skill are amended to state the two-track
rule. Staging carries the DDL; prod gets it on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB

- Manual journal entry: saldo column now shows before -> after computed
  from the typed debit/credit amounts (direction feedback while booking)
- Resultatrapport: a narrowed date range now compares against the same
  window shifted one year back (#862), merged across fiscal periods for
  brutet rakenskapsar; P&L rows report window activity instead of
  rolled-forward YTD closing
- Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab,
  settings > assistant), sidebar entry unaffected; collapsed sessions keep
  their reopen handle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(stripe): sync balance transactions as a bank feed on 1686

Import the connected Stripe balance into the transactions inbox, opt-in
per connection (transaction_sync_enabled on stripe_connections):

- Balance transactions map to feed rows with the two-row gross+fee split
  and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on
  created, bound to a provisioned "Stripe-saldo" cash account on 1686 so
  booking settles against the clearing account by construction.
- Double-booking protection: settled payment-link charges import
  pre-linked to their settlement entry; payout rows import pre-linked to
  the payout entry; processPayoutPaidEvent claims the payout's fee rows
  at booking time (linkPayoutFeedRows, idempotent from both directions).
- Cursor last_balance_txn_synced_at with 24h overlap; first run
  backfills 90 days floored at the day after the company lock date.
- Nightly cron /api/extensions/stripe/transactions/cron (03:30),
  transaction-sync toggle route, "Synka nu" covers both feeds, settings
  panel toggle with last-synced/backfill note, sv+en strings.
- Migration 20260723200000 (applied to staging).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): offer match-to-voucher on unbooked history rows

Unbooked transactions with is_business already set (e.g. left behind when
a voucher was removed without a full uncategorize) land in the history
list instead of the inbox, where the match-against-existing-voucher
action did not exist, leaving them with no path back to voucher
matching. Add the same menu item to the history list for unbooked rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(transactions): enhance ownership checks and error handling in journal entry routes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:16:20 +02:00

753 lines
32 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Verifikat inline rättelse (founder-approved 2026-07-23, both cards):
--
-- Card 1: correct description/entry_date of a POSTED entry without a
-- rättelseverifikation (BFL 5 kap 9 §: a verifikation may be
-- corrected if who/when is recorded).
-- Card 2: strike lines inside a POSTED verifikat and add replacement
-- lines in the SAME verifikat (BFL 5 kap 5 §: a bokföringspost
-- may be corrected other than via a särskild rättelsepost, as
-- long as the original remains visible and who/when is recorded).
--
-- Legal position: Fortnox and Visma both offer exactly this inside the same
-- envelope. The envelope here is strictly bounded: posted entries only, open
-- periods only (not closed, not locked), company lock date honored, one
-- audited SECURITY DEFINER write path per operation, immutable before/after
-- log (journal_entry_rattelse_log), and the struck original preserved both in
-- the log (full row snapshots) and in audit_log (write_audit_log fires on all
-- line DML). Past a lock/close, the storno flow remains the only path.
--
-- Carve-out pattern follows the sanctioned precedents:
-- 20260608120000 (notes-only entry update), 20260613120000 (source_type
-- retag GUC), 20260702170000 (dimension retag GUC + immutable log).
--
-- pg-test: tests/pg/inline-rattelse.pg.test.ts
-- =============================================================================
-- 1. journal_entry_rattelse_log — immutable rättelse audit trail
-- =============================================================================
-- Like dimension_retag_log: no FK to journal_entries so the log survives
-- hard-deletes (undo_sie_import); behandlingshistorik must not vanish with
-- its subject. Company FK keeps tenant lifecycle.
CREATE TABLE IF NOT EXISTS public.journal_entry_rattelse_log (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
journal_entry_id uuid NOT NULL,
rattelse_type text NOT NULL CHECK (rattelse_type IN ('metadata', 'lines')),
old_description text,
new_description text,
old_entry_date date,
new_entry_date date,
struck_lines jsonb,
added_lines jsonb,
actor uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.journal_entry_rattelse_log ENABLE ROW LEVEL SECURITY;
-- Read-only for members; INSERT happens exclusively inside the SECURITY
-- DEFINER RPCs (no INSERT/UPDATE/DELETE policies on purpose).
DROP POLICY IF EXISTS "view own-company journal_entry_rattelse_log"
ON public.journal_entry_rattelse_log;
CREATE POLICY "view own-company journal_entry_rattelse_log"
ON public.journal_entry_rattelse_log FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE INDEX IF NOT EXISTS idx_journal_entry_rattelse_log_entry
ON public.journal_entry_rattelse_log (company_id, journal_entry_id);
CREATE OR REPLACE FUNCTION public.journal_entry_rattelse_log_immutable()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
RAISE EXCEPTION 'journal_entry_rattelse_log är oföränderlig — rader kan inte ändras eller tas bort.';
END;
$$;
DROP TRIGGER IF EXISTS journal_entry_rattelse_log_immutable
ON public.journal_entry_rattelse_log;
CREATE TRIGGER journal_entry_rattelse_log_immutable
BEFORE UPDATE OR DELETE ON public.journal_entry_rattelse_log
FOR EACH ROW EXECUTE FUNCTION public.journal_entry_rattelse_log_immutable();
-- =============================================================================
-- 2. Entry-immutability carve-out: metadata rättelse (append-only replacement)
-- =============================================================================
-- Adds ONE branch to the current function body (verbatim from staging/prod,
-- last replaced by 20260613120000): while the transaction-local GUC set by
-- correct_entry_metadata() is active, permit an UPDATE of a POSTED entry iff
-- ONLY description/entry_date change (whole-row to_jsonb diff; updated_at
-- exempt because journal_entries_updated_at bumps it). Voucher number, period,
-- amounts, linkage and every future column stay immutable by construction.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger
LANGUAGE plpgsql
SET search_path TO 'public'
AS $function$
BEGIN
IF TG_OP = 'DELETE' THEN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
RETURN NEW;
END IF;
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number
OR NEW.commit_method IS DISTINCT FROM OLD.commit_method
OR NEW.rubric_version IS DISTINCT FROM OLD.rubric_version
OR NEW.source_voucher_series IS DISTINCT FROM OLD.source_voucher_series
OR NEW.source_voucher_number IS DISTINCT FROM OLD.source_voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
-- Narrow un-reversal path: when delete_last_voucher removes a storno entry,
-- it flips the original from 'reversed' back to 'posted'. No other fields
-- may change, and the bypass flag must be set.
IF OLD.status = 'reversed' AND NEW.status = 'posted'
AND current_setting('gnubok.allow_delete', true) = 'true' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields during un-reversal (id: %)', OLD.id;
END IF;
RETURN NEW;
END IF;
-- Notes-only annotation on a committed entry (posted/reversed/cancelled).
-- `notes` is internal metadata, not verifikation content, so editing it does
-- not violate immutability. Allowed ONLY when the status is unchanged and the
-- sole difference between OLD and NEW is `notes` (updated_at is exempt because
-- the journal_entries_updated_at trigger bumps it). The to_jsonb() diff covers
-- every other column automatically, so any real bookkeeping change still raises.
IF OLD.status = NEW.status
AND OLD.status IN ('posted', 'reversed', 'cancelled')
AND (to_jsonb(NEW) - 'notes' - 'updated_at')
= (to_jsonb(OLD) - 'notes' - 'updated_at') THEN
RETURN NEW;
END IF;
-- Source-type re-tag of a mis-typed opening balance. source_type is internal
-- classification metadata, not verifikation content (see header), so moving a
-- bank-account IB from manual/import to opening_balance does not alter the
-- bokföringspost. Allowed ONLY when: the transaction-local bypass flag set by
-- mark_entry_as_opening_balance() is present; status is unchanged 'posted'; the
-- value moves manual/import -> opening_balance; and source_type is the SOLE
-- changed column (whole-row to_jsonb diff, updated_at exempt as above). Any other
-- field delta, status change, or missing flag still raises below.
IF OLD.status = NEW.status
AND OLD.status = 'posted'
AND current_setting('gnubok.allow_source_type_retag', true) = 'true'
AND OLD.source_type IN ('manual', 'import')
AND NEW.source_type = 'opening_balance'
AND (to_jsonb(NEW) - 'source_type' - 'updated_at')
= (to_jsonb(OLD) - 'source_type' - 'updated_at') THEN
RETURN NEW;
END IF;
-- Metadata rättelse of a posted verifikation (BFL 5 kap 9 §): while the
-- transaction-local GUC set by correct_entry_metadata() is active, permit an
-- UPDATE iff ONLY description and/or entry_date change. The RPC has already
-- recorded who/when in journal_entry_rattelse_log, verified the period is
-- open/unlocked and (for date moves) that the new date stays inside the same
-- fiscal period. Any other field delta still raises below.
IF OLD.status = NEW.status
AND OLD.status = 'posted'
AND current_setting('gnubok.allow_metadata_rattelse', true) = 'true'
AND (to_jsonb(NEW) - 'description' - 'entry_date' - 'updated_at')
= (to_jsonb(OLD) - 'description' - 'entry_date' - 'updated_at') THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END;
$function$;
ALTER FUNCTION public.enforce_journal_entry_immutability() SET search_path = public;
-- =============================================================================
-- 3. Line-immutability carve-out: inline line rättelse
-- =============================================================================
-- Current body verbatim (last replaced by 20260702170000) plus ONE branch:
-- while the transaction-local GUC set by correct_entry_lines_inline() is
-- active, permit DELETE of lines on a POSTED entry (the struck originals; the
-- RPC snapshots them to journal_entry_rattelse_log first and re-verifies the
-- entry balances afterwards). UPDATE of posted lines stays blocked: a strike
-- is remove-and-replace, never edit-in-place. (Line INSERT has no immutability
-- trigger; the balance invariant is enforced by the RPC.)
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
DECLARE v_status text;
BEGIN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
SELECT status INTO v_status FROM public.journal_entries
WHERE id = COALESCE(OLD.journal_entry_id, NEW.journal_entry_id);
-- Dimension retag carve-out (dimensions plan PR6, founder-approved):
-- while the transaction-local GUC set by retag_line_dimensions is active,
-- permit UPDATE of a POSTED line iff ONLY the dimension columns change —
-- dimensions (source of truth) and its derived mirrors cost_center/project.
-- Account, amounts, description, currency fields, sort order and entry
-- linkage remain absolutely immutable.
IF TG_OP = 'UPDATE'
AND v_status = 'posted'
AND current_setting('gnubok.allow_dimension_retag', true) = 'true'
AND (to_jsonb(NEW) - 'dimensions' - 'cost_center' - 'project')
= (to_jsonb(OLD) - 'dimensions' - 'cost_center' - 'project') THEN
RETURN NEW;
END IF;
-- Inline rättelse carve-out (BFL 5 kap 5 §, founder-approved 2026-07-23):
-- while the transaction-local GUC set by correct_entry_lines_inline() is
-- active, permit DELETE of a POSTED line (a struck line). The RPC has
-- already snapshotted the row to journal_entry_rattelse_log and verifies
-- post-state balance before committing.
IF TG_OP = 'DELETE'
AND v_status = 'posted'
AND current_setting('gnubok.allow_line_rattelse', true) = 'true' THEN
RETURN OLD;
END IF;
IF v_status = 'draft' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF v_status = 'cancelled' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RAISE EXCEPTION 'Cannot % lines of a cancelled journal entry.', TG_OP;
END IF;
RAISE EXCEPTION 'Cannot % lines of a % journal entry.', TG_OP, v_status;
END; $function$;
ALTER FUNCTION public.enforce_journal_entry_line_immutability() SET search_path = public;
-- =============================================================================
-- 4. correct_entry_metadata — the ONE write path for Card 1
-- =============================================================================
CREATE OR REPLACE FUNCTION public.correct_entry_metadata(
p_company_id uuid,
p_entry_id uuid,
p_description text DEFAULT NULL,
p_entry_date date DEFAULT NULL,
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_p_start date;
v_p_end date;
v_lock_date date;
v_new_desc text;
v_new_date date;
v_log_id 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;
SELECT je.id, je.status, je.description, 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;
v_new_desc := COALESCE(NULLIF(btrim(p_description), ''), v_entry.description);
v_new_date := COALESCE(p_entry_date, v_entry.entry_date);
IF length(v_new_desc) > 500 THEN
RAISE EXCEPTION 'Beskrivningen får vara högst 500 tecken.';
END IF;
-- A storno mirrors its original: its generated text and date are part of
-- the correction chain and are never edited directly.
IF v_entry.source_type = 'storno' THEN
RAISE EXCEPTION 'Stornoverifikat kan inte rättas — rätta eller återför originalverifikatet i stället.';
END IF;
-- Date moves are forbidden for entry types whose date carries structural
-- meaning (IB and year-end are period-bound; a vat_settlement is looked up
-- by entry_date when the "already booked" gate runs, so moving it enables
-- double-booking a settlement).
IF v_new_date <> v_entry.entry_date
AND v_entry.source_type IN ('opening_balance', 'year_end', 'vat_settlement') THEN
RAISE EXCEPTION 'Datumet på den här verifikationstypen kan inte ändras.';
END IF;
SELECT fp.is_closed, fp.locked_at, fp.period_start, fp.period_end
INTO v_is_closed, v_locked_at, v_p_start, v_p_end
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;
-- The date may only move WITHIN the entry's fiscal period; cross-period
-- moves change period sums and must go through the recordate (storno) flow.
IF v_new_date <> v_entry.entry_date
AND (v_new_date < v_p_start OR v_new_date > v_p_end) THEN
RAISE EXCEPTION 'Nytt datum måste ligga inom samma bokföringsperiod (% %). Använd "Flytta till annat datum" för att byta period.', v_p_start, v_p_end;
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 OR v_new_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_new_desc = v_entry.description AND v_new_date = v_entry.entry_date THEN
RETURN jsonb_build_object('changed', false, 'log_id', NULL);
END IF;
-- Immutable who/when log FIRST — the carve-out is only ever exercised in a
-- transaction that has already recorded the rättelse (BFL 5 kap 9 §).
INSERT INTO public.journal_entry_rattelse_log
(company_id, journal_entry_id, rattelse_type,
old_description, new_description, old_entry_date, new_entry_date, actor)
VALUES
(p_company_id, p_entry_id, 'metadata',
v_entry.description, v_new_desc, v_entry.entry_date, v_new_date, v_actor)
RETURNING id INTO v_log_id;
PERFORM set_config('gnubok.allow_metadata_rattelse', 'true', true);
UPDATE public.journal_entries
SET description = v_new_desc,
entry_date = v_new_date
WHERE id = p_entry_id;
PERFORM set_config('gnubok.allow_metadata_rattelse', 'false', true);
RETURN jsonb_build_object(
'changed', true,
'log_id', v_log_id,
'old_description', v_entry.description,
'new_description', v_new_desc,
'old_entry_date', v_entry.entry_date,
'new_entry_date', v_new_date
);
END;
$function$;
REVOKE ALL ON FUNCTION public.correct_entry_metadata(uuid, uuid, text, date, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.correct_entry_metadata(uuid, uuid, text, date, uuid) TO authenticated, service_role;
-- =============================================================================
-- 5. correct_entry_lines_inline — the ONE write path for Card 2
-- =============================================================================
-- Strikes lines (delete + snapshot) and/or adds replacement lines inside the
-- same posted verifikat. The effective line set must balance to the öre and
-- stay non-empty; the original rows survive in the log and in audit_log.
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;
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 keep its
-- per-account net. The bank feed / payment amount is immutable, so letting
-- a strike change the 19xx/cash-account (or reskontra) net would create a
-- permanent unexplained reconciliation difference. Net-preserving strikes
-- (e.g. fixing a line description) stay allowed.
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 AND (
(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)))
OR (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';