Files
accounted/supabase/migrations/20260825150000_reset_fiscal_year.sql
T
MattssonandClaude Fable 5 79013cf092 feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) (#1897)
* feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883)

Two deliverables from the community report where a bad SIE test import
left no way out short of deleting the company:

A) Discoverability: the voucher list shows one attn line linking to
   /import?history=sie whenever the page contains import-sourced
   vouchers, and /import?history=sie deep-links straight into the
   fold-open SIE import history where per-import Angra already lives.

B) Reset of an UNLOCKED fiscal year regardless of how the entries
   arrived: new reset_fiscal_year RPC (same gnubok.allow_delete escape
   hatch as undo_sie_import; no enforcement trigger touched) behind
   GET/POST /api/bookkeeping/fiscal-periods/[id]/reset and a typed
   type-the-year-name confirmation dialog on the fiscal years settings
   list. Refuses on: locked/closed year, company lock date over any part
   of the year, executed year-end, arsredovisning state, later year
   depending on this year's UB, VAT-declared evidence (vat_settlement
   verifikat, SKV lock/submit audit rows, extension workflow keys, fail
   closed) and AGI-declared months. Entries referenced by RESTRICT/NO
   ACTION FKs abort the whole reset (all-or-nothing). Documents are
   detached, never deleted (BFL 7 kap); every delete is audit-logged
   plus one behandlingshistorik summary row.

Fixes #1883

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

* fix(bookkeeping): harden fiscal-year reset after skeptic review (#1883)

Blocking skeptic findings on PR #1897, one consolidated pass:

- New snapshot blocker cross_year_reference: an entry outside the year whose
  correction_of_id / reverses_id / reversed_by_id points into the year made
  the delete crash with an uncaught P0001 (immutability trigger refusing the
  ON DELETE SET NULL referential UPDATE) after an eligible:true preview, and
  silently severed draft chains. 12 such chains exist in prod today.
- New snapshot blocker rot_rut_state: a begaran om utbetalning that reached
  Skatteverket (submitted/paid/partially_paid/rejected) was silently
  unlinked via SET NULL, erasing the bokforing behind a filed and possibly
  decided myndighetsarende.
- Rakenskapsinformation preservation (BFL 7 kap): line-level trigger audit
  rows carry no company_id and header rows no amounts, so a reset destroyed
  konton/belopp with no company-readable trace. The RPC now archives the
  full content of every verifikat in company-scoped RESET_SNAPSHOT audit
  rows before deleting (action added to audit_log_action_check, NOT VALID),
  and behandlingshistorik renders them.
- Dimension registry lockstep on reset (mirrors undo_sie_import): flipped
  imports can never be undone again, so their dimensions/values would have
  been orphaned forever.
- EXCEPTION WHEN raise_exception now returns a typed
  FISCAL_YEAR_RESET_LINKED_ENTRIES envelope instead of a bare 500;
  gnubok.allow_delete is cleared before leaving the guarded block.
- Voucher-list attn line fires only for source_type 'import':
  opening_balance is also written by year-end closing and the manual IB
  flows, which mislabelled every year-2+ company as SIE-imported.
- /import?history=sie now scrolls the SIE history into view.
- Reset dialog copy (sv+en) discloses that linked invoices, payments and
  bank transactions become unbooked; new blocker strings in both locales.
- pg fixture fix: document_attachments seeded without company_id (23502);
  new pg tests for both blockers, RESET_SNAPSHOT rows and the lockstep.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:36:18 +02:00

651 lines
28 KiB
PL/PgSQL

-- Reset an UNLOCKED fiscal year (issue #1883).
--
-- After a bad test import (SIE or otherwise) users saw no way out short of
-- deleting the whole company: undo_sie_import only covers entries a single
-- completed SIE import created, and manual/agent entries booked on top of a
-- bad import were unreachable. This adds a guarded, owner/admin-only reset of
-- one open fiscal year: hard-deletes ALL of the year's verifikationer
-- (every source_type and status), detaches documents (which are never
-- deleted: BFL 7 kap retention), resets voucher_sequences, and marks the
-- year's completed SIE imports as 'undone'.
--
-- Legality framing (BFL 5 kap, BFNAR 2013:2): this is the same category of
-- operation as the shipped undo_sie_import / replace_sie_import: re-doing the
-- löpande bokföring of a year that nothing has relied upon yet. The reset is
-- refused whenever any reliance state exists:
-- * the year is locked or closed (fiscal_periods.locked_at / is_closed)
-- * the company lock date covers any part of the year
-- * a year-end closing entry exists (fiscal_periods.closing_entry_id)
-- * an arsredovisning submission or signature request exists for the year
-- * a later year depends on this year's UB (next period has opening
-- balances, or is itself locked/closed/has a closing entry)
-- * VAT declared evidence: a posted/reversed vat_settlement verifikat in
-- the year, a successful Skatteverket declaration lock/submit for a
-- redovisningsperiod inside the year, or a Skatteverket-extension VAT
-- submission workflow key for the year (fail-closed on unparsable keys)
-- * AGI declared evidence: an agi_declarations row for a month inside the
-- year in a submitted/accepted/rejected state
-- * ROT/RUT reliance: a begäran om utbetalning that reached Skatteverket
-- (submitted/paid/partially_paid/rejected) whose settlement voucher or
-- source invoices are booked in the year
-- * cross-year rättelse/storno chains: an entry OUTSIDE the year whose
-- correction_of_id / reverses_id / reversed_by_id points INTO the year
-- (the delete's ON DELETE SET NULL referential action would either be
-- refused by the immutability trigger on a posted referrer, or silently
-- sever a draft's chain; both mean the year has been relied upon)
-- Entries referenced by other records (assets, accrual schedules, salary
-- runs: RESTRICT / NO ACTION FKs) make the whole reset roll back with a
-- distinct error instead of half-deleting. SET NULL references (invoices,
-- invoice payments, supplier invoices, bank/skattekonto transactions,
-- mileage trips) unlink and return to unbooked, which is the meaning of
-- resetting the year; the UI copy discloses this. Behandlingshistorik is
-- preserved: every deleted entry fires write_audit_log, and before the
-- delete a company-scoped RESET_SNAPSHOT audit row archives the FULL
-- content of every verifikat (accounts, amounts, line text, dimensions) so
-- the destroyed räkenskapsinformation stays retrievable (BFL 7 kap), plus
-- one summary audit_log row.
--
-- Deletion mechanism: the SAME escape hatch as undo_sie_import
-- (20260727121000): set_config('gnubok.allow_delete','true',true) inside a
-- SECURITY DEFINER RPC. No enforcement trigger is modified.
--
-- Actor gate mirrors undo_sie_import (20260727121000): p_user_id is honored
-- only for service_role callers (the cookieless server client, auth.uid()
-- NULL); every other caller is pinned to its own auth.uid().
-- ---------------------------------------------------------------------------
-- audit_log gains a RESET_SNAPSHOT action: the pre-delete verifikat content
-- archive written by reset_fiscal_year below. NOT VALID skips the full-table
-- validation scan: every existing row satisfies the previous, strictly
-- narrower constraint, and NOT VALID still enforces all new rows.
-- ---------------------------------------------------------------------------
ALTER TABLE public.audit_log DROP CONSTRAINT IF EXISTS audit_log_action_check;
ALTER TABLE public.audit_log ADD CONSTRAINT audit_log_action_check
CHECK (action = ANY (ARRAY[
'INSERT','UPDATE','DELETE','COMMIT','REVERSE','CORRECT',
'LOCK_PERIOD','CLOSE_PERIOD','DOCUMENT_DELETE_BLOCKED',
'RETENTION_BLOCK','SECURITY_EVENT','INTEGRITY_FAILURE',
'COMMITTED_AT_OVERRIDE','RESET_SNAPSHOT'
])) NOT VALID;
-- ---------------------------------------------------------------------------
-- Internal snapshot: eligibility + counts. Not callable by clients (REVOKEd);
-- both public functions below call it so the preview and the execution can
-- never drift apart.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fiscal_year_reset_snapshot(
p_company_id uuid,
p_period_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_period record;
v_next record;
v_blockers jsonb := '[]'::jsonb;
v_lock_through date;
v_arsred integer;
v_vat integer;
v_agi integer;
v_vouchers integer;
v_docs integer;
v_start_ym text;
v_end_ym text;
v_xref integer;
v_rotrut integer;
BEGIN
SELECT id, name, period_start, period_end, is_closed, locked_at,
closing_entry_id, opening_balance_entry_id
INTO v_period
FROM public.fiscal_periods
WHERE id = p_period_id
AND company_id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_NOT_FOUND');
END IF;
IF v_period.is_closed THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'period_closed'));
END IF;
IF v_period.locked_at IS NOT NULL THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'period_locked'));
END IF;
SELECT bookkeeping_locked_through
INTO v_lock_through
FROM public.company_settings
WHERE company_id = p_company_id;
IF v_lock_through IS NOT NULL AND v_lock_through >= v_period.period_start THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'company_lock_date', 'date', to_char(v_lock_through, 'YYYY-MM-DD')
));
END IF;
IF v_period.closing_entry_id IS NOT NULL THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'year_end_state'));
END IF;
SELECT (SELECT count(*) FROM public.arsredovisning_submissions
WHERE company_id = p_company_id AND fiscal_period_id = p_period_id)
+ (SELECT count(*) FROM public.arsredovisning_signature_requests
WHERE company_id = p_company_id AND fiscal_period_id = p_period_id)
INTO v_arsred;
IF v_arsred > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'arsredovisning_state', 'count', v_arsred
));
END IF;
-- Later-year dependency: chain lookup first, then date-based fallback
-- (mirrors findNextPeriod / scripts/undo-year-end-closing.ts). A next year
-- whose IB was generated from this year's UB, or that is itself locked,
-- closed or has a closing entry, blocks the reset.
SELECT id, is_closed, locked_at, closing_entry_id,
opening_balance_entry_id, opening_balances_set
INTO v_next
FROM public.fiscal_periods
WHERE company_id = p_company_id
AND previous_period_id = p_period_id
LIMIT 1;
IF NOT FOUND THEN
SELECT id, is_closed, locked_at, closing_entry_id,
opening_balance_entry_id, opening_balances_set
INTO v_next
FROM public.fiscal_periods
WHERE company_id = p_company_id
AND period_start = v_period.period_end + 1
LIMIT 1;
END IF;
IF v_next.id IS NOT NULL AND (
v_next.is_closed
OR v_next.locked_at IS NOT NULL
OR v_next.closing_entry_id IS NOT NULL
OR v_next.opening_balance_entry_id IS NOT NULL
OR v_next.opening_balances_set
) THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'next_year_dependency'));
END IF;
-- Cross-year rättelse/storno chains: an entry OUTSIDE the year whose
-- correction_of_id / reverses_id / reversed_by_id points INTO the year.
-- Deleting the target fires the FK's ON DELETE SET NULL as an UPDATE on
-- the referrer; enforce_journal_entry_immutability refuses that on a
-- posted referrer (the delete escape hatch covers only TG_OP = 'DELETE'),
-- and on a draft it would silently sever the rättelse chain. Either way
-- the year has been relied upon: refuse up front, so the preview and the
-- execution agree (mirrors the delete_last_voucher reference check,
-- 20260528120600).
SELECT count(*) INTO v_xref
FROM public.journal_entries outside
WHERE outside.company_id = p_company_id
AND outside.fiscal_period_id <> p_period_id
AND EXISTS (
SELECT 1 FROM public.journal_entries inside
WHERE inside.company_id = p_company_id
AND inside.fiscal_period_id = p_period_id
AND inside.id IN (outside.correction_of_id, outside.reverses_id, outside.reversed_by_id)
);
IF v_xref > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'cross_year_reference', 'count', v_xref
));
END IF;
-- VAT declared evidence. Skatteverket declaration state cannot be observed
-- reliably from this database (the final signature happens at SKV), so
-- every local trace counts and unparsable workflow keys fail closed. Same
-- conservative posture as company_migration_reset (20260818224000).
v_start_ym := to_char(v_period.period_start, 'YYYYMM');
v_end_ym := to_char(v_period.period_end, 'YYYYMM');
SELECT (SELECT count(*) FROM public.journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = p_period_id
AND source_type = 'vat_settlement'
AND status IN ('posted', 'reversed'))
+ (SELECT count(*) FROM public.skatteverket_api_audit_log
WHERE company_id = p_company_id
AND outcome = 'ok'
AND endpoint IN ('declaration/lock', 'declaration/submit')
AND (redovisningsperiod IS NULL
OR (redovisningsperiod >= v_start_ym AND redovisningsperiod <= v_end_ym)))
+ (SELECT count(*) FROM public.extension_data
WHERE company_id = p_company_id
AND extension_id = 'skatteverket'
AND key LIKE 'submission\_%' ESCAPE '\'
AND (substring(key FROM 12) !~ '^[0-9]{6}$'
OR (substring(key FROM 12) >= v_start_ym AND substring(key FROM 12) <= v_end_ym)))
INTO v_vat;
IF v_vat > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'vat_declared', 'count', v_vat
));
END IF;
-- AGI declared evidence for months inside the year.
SELECT count(*) INTO v_agi
FROM public.agi_declarations
WHERE company_id = p_company_id
AND (submitted_at IS NOT NULL OR status IN ('submitted', 'accepted', 'rejected'))
AND make_date(period_year, period_month, 1)
BETWEEN date_trunc('month', v_period.period_start)::date AND v_period.period_end;
IF v_agi > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'agi_declared', 'count', v_agi
));
END IF;
-- ROT/RUT reliance: a begäran om utbetalning that has reached Skatteverket
-- (submitted, or decided: paid/partially_paid/rejected) is external
-- reliance in the same category as VAT/AGI. Its links into the year are
-- ON DELETE SET NULL, so without this guard the reset would silently erase
-- the bokföring behind a filed and possibly decided myndighetsärende.
-- 'generated' (file never uploaded) and 'cancelled' do not block.
SELECT count(*) INTO v_rotrut
FROM public.rot_rut_payout_requests r
WHERE r.company_id = p_company_id
AND r.status IN ('submitted', 'paid', 'partially_paid', 'rejected')
AND (
EXISTS (
SELECT 1 FROM public.journal_entries je
WHERE je.id = r.settlement_journal_entry_id
AND je.fiscal_period_id = p_period_id
)
OR EXISTS (
SELECT 1
FROM public.rot_rut_payout_request_items ri
JOIN public.invoices inv ON inv.id = ri.invoice_id
JOIN public.journal_entries je ON je.id = inv.journal_entry_id
WHERE ri.request_id = r.id
AND je.fiscal_period_id = p_period_id
)
);
IF v_rotrut > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'rot_rut_state', 'count', v_rotrut
));
END IF;
SELECT count(*) INTO v_vouchers
FROM public.journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = p_period_id;
SELECT count(*) INTO v_docs
FROM public.document_attachments da
WHERE da.journal_entry_id IN (
SELECT je.id FROM public.journal_entries je
WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id)
OR da.journal_entry_line_id IN (
SELECT jel.id
FROM public.journal_entry_lines jel
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id);
RETURN jsonb_build_object(
'ok', true,
'eligible', jsonb_array_length(v_blockers) = 0,
'blockers', v_blockers,
'period', jsonb_build_object(
'id', v_period.id,
'name', v_period.name,
'period_start', to_char(v_period.period_start, 'YYYY-MM-DD'),
'period_end', to_char(v_period.period_end, 'YYYY-MM-DD')
),
'counts', jsonb_build_object(
'vouchers', v_vouchers,
'documents_to_detach', v_docs
)
);
END;
$function$;
REVOKE ALL ON FUNCTION public.fiscal_year_reset_snapshot(uuid, uuid)
FROM PUBLIC, anon, authenticated;
COMMENT ON FUNCTION public.fiscal_year_reset_snapshot(uuid, uuid) IS
'Internal fail-closed eligibility snapshot for reset_fiscal_year. Not client-callable.';
-- ---------------------------------------------------------------------------
-- Eligibility preview for the UI. Owner/admin only.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_fiscal_year_reset_eligibility(
p_company_id uuid,
p_period_id uuid,
p_user_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_actor uuid;
v_caller_role text;
BEGIN
-- Actor resolution: p_user_id is honored only for service_role callers
-- (auth.uid() NULL on the cookieless server client); everyone else is
-- pinned to their own auth.uid(). Same shape as undo_sie_import
-- (20260727121000).
IF auth.role() = 'service_role' THEN
v_actor := COALESCE(p_user_id, auth.uid());
ELSE
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') THEN
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_FORBIDDEN');
END IF;
RETURN public.fiscal_year_reset_snapshot(p_company_id, p_period_id);
END;
$function$;
REVOKE EXECUTE ON FUNCTION public.get_fiscal_year_reset_eligibility(uuid, uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_fiscal_year_reset_eligibility(uuid, uuid, uuid) TO authenticated, service_role;
COMMENT ON FUNCTION public.get_fiscal_year_reset_eligibility(uuid, uuid, uuid) IS
'Owner/admin-only eligibility preview for reset_fiscal_year. p_user_id is honored only for service_role callers.';
-- ---------------------------------------------------------------------------
-- The reset itself.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.reset_fiscal_year(
p_company_id uuid,
p_period_id uuid,
p_confirmed_name text,
p_user_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
-- The authenticated role carries statement_timeout=8s on hosted Supabase; a
-- year-sized batch delete (each row firing write_audit_log with a JSONB
-- snapshot, plus cascading lines) must not race that budget. Same shape as
-- undo_sie_import (20260629160100 / 20260727121000).
SET statement_timeout TO '290s'
AS $function$
DECLARE
v_actor uuid;
v_caller_role text;
v_snapshot jsonb;
v_period_name text;
v_period_start date;
v_period_end date;
v_deleted integer := 0;
v_docs_detached integer := 0;
v_flipped uuid[] := '{}';
BEGIN
IF auth.role() = 'service_role' THEN
v_actor := COALESCE(p_user_id, auth.uid());
ELSE
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') THEN
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_FORBIDDEN');
END IF;
-- Serialize concurrent resets / lock-state changes on the same year.
SELECT name, period_start, period_end
INTO v_period_name, v_period_start, v_period_end
FROM public.fiscal_periods
WHERE id = p_period_id
AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_NOT_FOUND');
END IF;
v_snapshot := public.fiscal_year_reset_snapshot(p_company_id, p_period_id);
IF NOT (v_snapshot ->> 'ok')::boolean THEN
RETURN v_snapshot;
END IF;
IF NOT (v_snapshot ->> 'eligible')::boolean THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'FISCAL_YEAR_RESET_INELIGIBLE',
'blockers', v_snapshot -> 'blockers'
);
END IF;
-- Typed confirmation: the caller must restate the year's label exactly.
IF p_confirmed_name IS NULL OR btrim(p_confirmed_name) <> btrim(v_period_name) THEN
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH');
END IF;
BEGIN
-- The sanctioned escape hatch (same as undo_sie_import): disarms the
-- immutability/retention DELETE guards for this transaction only. The
-- enforcement triggers themselves are untouched.
PERFORM set_config('gnubok.allow_delete', 'true', true);
-- Detach documents (entry- and line-level). Documents are NEVER deleted:
-- BFL 7 kap retention. They stay in the archive as unlinked underlag.
UPDATE public.document_attachments da
SET journal_entry_id = NULL,
journal_entry_line_id = NULL
WHERE da.journal_entry_id IN (
SELECT je.id FROM public.journal_entries je
WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id)
OR da.journal_entry_line_id IN (
SELECT jel.id
FROM public.journal_entry_lines jel
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id);
GET DIAGNOSTICS v_docs_detached = ROW_COUNT;
-- Clear the fiscal-period OB pointer (two-step around
-- enforce_opening_balance_immutability, same as undo_sie_import).
UPDATE public.fiscal_periods
SET opening_balances_set = false
WHERE id = p_period_id
AND opening_balance_entry_id IS NOT NULL;
UPDATE public.fiscal_periods
SET opening_balance_entry_id = NULL
WHERE id = p_period_id
AND opening_balance_entry_id IS NOT NULL;
-- Drop sie_imports -> opening_balance_entry FKs before the delete.
UPDATE public.sie_imports
SET opening_balance_entry_id = NULL
WHERE company_id = p_company_id
AND fiscal_period_id = p_period_id
AND opening_balance_entry_id IS NOT NULL;
-- Räkenskapsinformation preservation (BFL 7 kap, BFNAR 2013:2 kap 8):
-- the trigger's per-entry DELETE audit row keeps only the voucher
-- header, and line-level trigger rows carry no company_id (invisible to
-- every company-scoped surface). Archive the FULL content of every
-- verifikat (accounts, amounts, line text, dimensions) in company-scoped
-- RESET_SNAPSHOT rows BEFORE deleting, so what is destroyed stays
-- retrievable for the retention period.
INSERT INTO public.audit_log (
user_id, company_id, action, table_name, record_id, actor_id,
old_state, description
)
SELECT v_actor, p_company_id, 'RESET_SNAPSHOT', 'journal_entries', je.id, v_actor,
to_jsonb(je) || jsonb_build_object('lines', COALESCE(l.lines, '[]'::jsonb)),
'Fiscal year reset: full verifikat content archived before deletion (BFL 7 kap)'
FROM public.journal_entries je
LEFT JOIN LATERAL (
SELECT jsonb_agg(jsonb_build_object(
'account_number', jel.account_number,
'debit_amount', jel.debit_amount,
'credit_amount', jel.credit_amount,
'line_description', jel.line_description,
'dimensions', jel.dimensions
) ORDER BY jel.sort_order) AS lines
FROM public.journal_entry_lines jel
WHERE jel.journal_entry_id = je.id
) l ON true
WHERE je.company_id = p_company_id
AND je.fiscal_period_id = p_period_id;
-- Hard-delete the year's journal entries: every source_type and status.
-- SET NULL FKs (transactions, invoice payments) unlink, which is the
-- meaning of resetting the year; RESTRICT / NO ACTION FKs (assets,
-- accrual schedules, salary runs) abort the whole reset below.
WITH deleted AS (
DELETE FROM public.journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = p_period_id
RETURNING id
)
SELECT count(*) INTO v_deleted FROM deleted;
-- The year's completed SIE imports no longer have any vouchers: mark
-- them undone so the import history reflects reality and the partial
-- unique slot (sie_imports_company_id_file_hash_active_idx) frees for a
-- clean re-import.
WITH flipped AS (
UPDATE public.sie_imports
SET status = 'undone', replaced_at = now()
WHERE company_id = p_company_id
AND fiscal_period_id = p_period_id
AND status = 'completed'
RETURNING id
)
SELECT COALESCE(array_agg(id), '{}') INTO v_flipped FROM flipped;
-- Registry lockstep (mirrors undo_sie_import, 20260702154500): dimension
-- values and custom dimensions the flipped imports introduced would
-- otherwise be orphaned forever: undo_sie_import requires
-- status = 'completed' and can never run for an import this reset just
-- marked undone. Remove the ones no surviving posted/reversed line still
-- references; user-created rows (created_by_import_id NULL) are never
-- touched.
DELETE FROM public.dimension_values dv
USING public.dimensions d
WHERE dv.created_by_import_id = ANY (v_flipped)
AND dv.company_id = p_company_id
AND d.id = dv.dimension_id
AND NOT EXISTS (
SELECT 1
FROM public.journal_entries je
JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id
WHERE je.company_id = p_company_id
AND je.status IN ('posted', 'reversed')
AND jel.dimensions ->> d.sie_dim_no::text = dv.code
);
DELETE FROM public.dimensions d
WHERE d.created_by_import_id = ANY (v_flipped)
AND d.company_id = p_company_id
AND d.is_system = false
AND NOT EXISTS (
SELECT 1 FROM public.dimension_values dv WHERE dv.dimension_id = d.id
)
AND NOT EXISTS (
SELECT 1
FROM public.journal_entries je
JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id
WHERE je.company_id = p_company_id
AND je.status IN ('posted', 'reversed')
AND jel.dimensions ? d.sie_dim_no::text
);
-- Gap explanations describe a numbering that no longer exists.
DELETE FROM public.voucher_gap_explanations
WHERE company_id = p_company_id
AND fiscal_period_id = p_period_id;
-- Reset voucher_sequences per series to the max remaining number (0
-- after a full delete; same query as undo_sie_import for consistency).
UPDATE public.voucher_sequences vs
SET last_number = COALESCE((
SELECT MAX(je.voucher_number)
FROM public.journal_entries je
WHERE je.company_id = vs.company_id
AND je.fiscal_period_id = vs.fiscal_period_id
AND je.voucher_series = vs.voucher_series
AND je.voucher_number > 0
), 0),
updated_at = now()
WHERE vs.company_id = p_company_id
AND vs.fiscal_period_id = p_period_id;
-- The year's IB continuity claim no longer describes anything.
UPDATE public.fiscal_periods
SET continuity_verified = NULL,
opening_balances_set = false
WHERE id = p_period_id
AND company_id = p_company_id;
-- Disarm the escape hatch before leaving the block (mirrors
-- cleanup_sandbox_user, 20260807130000): nothing later in the same
-- transaction may run with the delete guard down.
PERFORM set_config('gnubok.allow_delete', '', true);
EXCEPTION
WHEN foreign_key_violation THEN
-- An entry in the year is referenced by another record (asset,
-- periodisering, salary run, ...). Half-deleting is worse than
-- refusing: the exception rolls back every change made in this block.
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_LINKED_ENTRIES');
WHEN raise_exception THEN
-- A protection trigger refused part of the reset (defense in depth
-- behind the snapshot guards, e.g. an immutability RAISE on a
-- referential-action UPDATE). The exception rolls back every change
-- made in this block; surface a typed refusal instead of a bare 500.
RETURN jsonb_build_object(
'ok', false,
'code', 'FISCAL_YEAR_RESET_LINKED_ENTRIES',
'detail', SQLERRM
);
END;
-- Behandlingshistorik (BFNAR 2013:2 kap 8): each deleted entry already
-- fired write_audit_log; this summary row records the reset as one control
-- action with who/when/what.
INSERT INTO public.audit_log (
user_id, company_id, action, table_name, record_id, actor_id,
old_state, new_state, description
) VALUES (
v_actor, p_company_id, 'DELETE', 'journal_entries', p_period_id, v_actor,
jsonb_build_object(
'fiscal_period_id', p_period_id,
'period_name', v_period_name,
'period_start', v_period_start,
'period_end', v_period_end
),
jsonb_build_object(
'deleted_entries', v_deleted,
'detached_documents', v_docs_detached
),
'Fiscal year reset: all verifikationer in the open year hard-deleted on user request; documents detached, never deleted (BFL 7 kap)'
);
RETURN jsonb_build_object(
'ok', true,
'deleted', v_deleted,
'detached_documents', v_docs_detached,
'period_name', v_period_name
);
END;
$function$;
-- Least privilege, same discipline as undo_sie_import (20260727121000).
REVOKE EXECUTE ON FUNCTION public.reset_fiscal_year(uuid, uuid, text, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.reset_fiscal_year(uuid, uuid, text, uuid) TO authenticated, service_role;
COMMENT ON FUNCTION public.reset_fiscal_year(uuid, uuid, text, uuid) IS
'Hard-deletes ALL verifikationer in one OPEN, un-relied-upon fiscal year (any source_type/status), detaches documents (never deletes them), resets voucher_sequences and marks the year''s completed SIE imports undone. Refuses on lock/close/lock-date/year-end/arsredovisning/VAT/AGI/ROT-RUT/next-year-dependency/cross-year-reference state and on entries referenced by other records. Archives every verifikat''s full content in RESET_SNAPSHOT audit rows before deleting (BFL 7 kap). Requires owner/admin; p_user_id honored only for service_role callers. Typed confirmation: p_confirmed_name must equal the period name.';
NOTIFY pgrst, 'reload schema';