* 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>
651 lines
28 KiB
PL/PgSQL
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';
|