* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
77cacdcf34
commit
79013cf092
@@ -0,0 +1,650 @@
|
||||
-- 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';
|
||||
Reference in New Issue
Block a user