feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)
* feat(bokslut): IL 18 kap pooled tax depreciation with method election Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25 as a company-level annual pool separate from per-asset book depreciation. Method election persisted with immutable snapshots and book-conformity confirmation for rakenskapsenlig. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): move tax depreciation migrations to coordinated versions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): keep tax depreciation view loadable when a saved election goes stale A predecessor's changed closing value can push a saved elected deduction above the new statutory maximum; the view now falls back to the statutory recomputation so the snapshot is flagged stale instead of crashing. Ratchet naive-ore-round baseline down by 3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): resolve tax-depreciation period selects statically The no-phantom-columns guard counts every select it cannot resolve toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo 4 over (364 > 360). Inline the literal column list at the four call sites so the guard verifies these columns instead of skipping them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): address review findings on tax depreciation election - DepreciationPanel: gate the saving flag on a dedicated save sequence so a successful save (which refreshes the view and bumps the request version) no longer leaves the card permanently busy - computeTaxDepreciation: refuse kompletteringsregel_20 with a positive basis and no acquisition cohorts instead of degenerating to a full write-off the cohort evidence does not support (IL 18 kap. 17 §) - migration 227000: judge the asset-method guards on NEW.disposed_at so reversing a disposal cannot reactivate a grandfathered non-linear row - migration 227200: require snapshot column completeness in the CHECK; SQL NULL semantics let partially populated snapshots pass the pure arithmetic comparisons - depreciation route: use the string issue code 'custom' like the rest of the codebase instead of the Zod 3 compat enum 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
00ae3540db
commit
5ca64bde30
@@ -0,0 +1,173 @@
|
||||
-- Issue #324: separate ordinary asset depreciation from the pooled tax
|
||||
-- depreciation election under IL 18 kap.
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN tax_depreciation_method TEXT NULL;
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD CONSTRAINT company_settings_tax_depreciation_method_check
|
||||
CHECK (tax_depreciation_method IS NULL OR tax_depreciation_method IN (
|
||||
'rakenskapsenlig',
|
||||
'restvarde'
|
||||
));
|
||||
|
||||
ALTER TABLE public.fiscal_periods
|
||||
ADD COLUMN tax_depreciation_method TEXT NULL,
|
||||
ADD COLUMN tax_depreciation_rule TEXT NULL,
|
||||
ADD COLUMN tax_depreciation_opening_value NUMERIC(15, 2) NULL,
|
||||
ADD COLUMN tax_depreciation_base NUMERIC(15, 2) NULL,
|
||||
ADD COLUMN tax_depreciation_deduction NUMERIC(15, 2) NULL,
|
||||
ADD COLUMN tax_depreciation_closing_value NUMERIC(15, 2) NULL,
|
||||
ADD COLUMN tax_depreciation_calculation JSONB NULL;
|
||||
|
||||
ALTER TABLE public.fiscal_periods
|
||||
ADD CONSTRAINT fiscal_periods_tax_depreciation_snapshot_check
|
||||
CHECK (
|
||||
(
|
||||
tax_depreciation_method IS NULL
|
||||
AND tax_depreciation_rule IS NULL
|
||||
AND tax_depreciation_opening_value IS NULL
|
||||
AND tax_depreciation_base IS NULL
|
||||
AND tax_depreciation_deduction IS NULL
|
||||
AND tax_depreciation_closing_value IS NULL
|
||||
AND tax_depreciation_calculation IS NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
tax_depreciation_method IN ('rakenskapsenlig', 'restvarde')
|
||||
AND tax_depreciation_opening_value >= 0
|
||||
AND tax_depreciation_base >= 0
|
||||
AND tax_depreciation_deduction >= 0
|
||||
AND tax_depreciation_closing_value >= 0
|
||||
AND jsonb_typeof(tax_depreciation_calculation) = 'object'
|
||||
AND (
|
||||
(
|
||||
tax_depreciation_method = 'rakenskapsenlig'
|
||||
AND tax_depreciation_rule IN ('huvudregel_30', 'kompletteringsregel_20')
|
||||
)
|
||||
OR
|
||||
(
|
||||
tax_depreciation_method = 'restvarde'
|
||||
AND tax_depreciation_rule IS NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_depreciation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF (
|
||||
NEW.tax_depreciation_method IS DISTINCT FROM OLD.tax_depreciation_method
|
||||
OR NEW.tax_depreciation_rule IS DISTINCT FROM OLD.tax_depreciation_rule
|
||||
OR NEW.tax_depreciation_opening_value IS DISTINCT FROM OLD.tax_depreciation_opening_value
|
||||
OR NEW.tax_depreciation_base IS DISTINCT FROM OLD.tax_depreciation_base
|
||||
OR NEW.tax_depreciation_deduction IS DISTINCT FROM OLD.tax_depreciation_deduction
|
||||
OR NEW.tax_depreciation_closing_value IS DISTINCT FROM OLD.tax_depreciation_closing_value
|
||||
OR NEW.tax_depreciation_calculation IS DISTINCT FROM OLD.tax_depreciation_calculation
|
||||
) AND (
|
||||
OLD.is_closed = true
|
||||
OR OLD.locked_at IS NOT NULL
|
||||
OR OLD.closing_entry_id IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Fiscal period is locked for tax depreciation elections'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER guard_fiscal_period_tax_depreciation
|
||||
BEFORE UPDATE OF
|
||||
tax_depreciation_method,
|
||||
tax_depreciation_rule,
|
||||
tax_depreciation_opening_value,
|
||||
tax_depreciation_base,
|
||||
tax_depreciation_deduction,
|
||||
tax_depreciation_closing_value,
|
||||
tax_depreciation_calculation
|
||||
ON public.fiscal_periods
|
||||
FOR EACH ROW EXECUTE FUNCTION public.guard_fiscal_period_tax_depreciation();
|
||||
|
||||
-- Stop the rollout if an active legacy tax-labelled asset has already driven
|
||||
-- a posted ordinary-depreciation voucher. That state needs an explicit storno
|
||||
-- and accountant review, not an automatic data rewrite.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.assets a
|
||||
JOIN public.depreciation_schedules ds ON ds.asset_id = a.id
|
||||
WHERE a.disposed_at IS NULL
|
||||
AND a.depreciation_method <> 'linear'
|
||||
AND ds.journal_entry_id IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Cannot normalize active tax-labelled assets with posted depreciation schedules';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Active rows without postings can safely return to ordinary linear book
|
||||
-- depreciation. Disposed rows retain their historical method values because
|
||||
-- their financial attributes are immutable.
|
||||
UPDATE public.assets
|
||||
SET depreciation_method = 'linear',
|
||||
restvarde_target = NULL
|
||||
WHERE disposed_at IS NULL
|
||||
AND depreciation_method <> 'linear';
|
||||
|
||||
ALTER TABLE public.assets
|
||||
DROP CONSTRAINT IF EXISTS assets_restvarde_target_method_match;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_ordinary_asset_depreciation_method()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.depreciation_method <> 'linear' THEN
|
||||
RAISE EXCEPTION 'Asset depreciation_method must be linear; tax depreciation is elected per fiscal period'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
IF NEW.restvarde_target IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Asset restvarde_target is deprecated; tax rest value is calculated per fiscal period'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
ELSE
|
||||
-- Judge the post-write state: NEW.disposed_at also catches an UPDATE that
|
||||
-- reverses a disposal on a grandfathered non-linear row, which would
|
||||
-- otherwise reactivate it with a tax-method label.
|
||||
IF NEW.depreciation_method <> 'linear' AND (
|
||||
OLD.depreciation_method IS DISTINCT FROM NEW.depreciation_method
|
||||
OR NEW.disposed_at IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Asset depreciation_method must be linear; tax depreciation is elected per fiscal period'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
IF NEW.restvarde_target IS NOT NULL AND (
|
||||
OLD.restvarde_target IS DISTINCT FROM NEW.restvarde_target
|
||||
OR NEW.disposed_at IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Asset restvarde_target is deprecated; tax rest value is calculated per fiscal period'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_ordinary_asset_depreciation_method
|
||||
BEFORE INSERT OR UPDATE ON public.assets
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_ordinary_asset_depreciation_method();
|
||||
|
||||
COMMENT ON COLUMN public.company_settings.tax_depreciation_method IS
|
||||
'Company-level IL 18 tax depreciation method. NULL until explicitly selected.';
|
||||
COMMENT ON COLUMN public.fiscal_periods.tax_depreciation_calculation IS
|
||||
'Versioned annual IL 18 calculation details supporting the saved tax depreciation election.';
|
||||
COMMENT ON COLUMN public.assets.restvarde_target IS
|
||||
'Deprecated legacy per-asset field. New tax depreciation is pooled per fiscal period.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER FUNCTION public.guard_fiscal_period_tax_depreciation()
|
||||
SET search_path = '';
|
||||
|
||||
ALTER FUNCTION public.enforce_ordinary_asset_depreciation_method()
|
||||
SET search_path = '';
|
||||
@@ -0,0 +1,136 @@
|
||||
-- Issue #324 follow-up: make annual tax-depreciation snapshots coherent and
|
||||
-- preserve their fiscal-period chain even for direct RLS-authorized writes.
|
||||
|
||||
ALTER TABLE public.fiscal_periods
|
||||
ADD CONSTRAINT fiscal_periods_tax_depreciation_arithmetic_check
|
||||
CHECK (
|
||||
tax_depreciation_method IS NULL
|
||||
OR (
|
||||
-- Completeness first: SQL NULL semantics would let a partially
|
||||
-- populated snapshot slip past the arithmetic comparisons below
|
||||
-- (NULL operands make the whole expression NULL, which passes CHECK).
|
||||
tax_depreciation_opening_value IS NOT NULL
|
||||
AND tax_depreciation_base IS NOT NULL
|
||||
AND tax_depreciation_deduction IS NOT NULL
|
||||
AND tax_depreciation_closing_value IS NOT NULL
|
||||
AND tax_depreciation_calculation IS NOT NULL
|
||||
AND (
|
||||
tax_depreciation_method <> 'rakenskapsenlig'
|
||||
OR tax_depreciation_rule IS NOT NULL
|
||||
)
|
||||
AND tax_depreciation_deduction <= tax_depreciation_base
|
||||
AND tax_depreciation_closing_value
|
||||
= tax_depreciation_base - tax_depreciation_deduction
|
||||
AND (tax_depreciation_calculation ->> 'version')::integer >= 2
|
||||
AND (tax_depreciation_calculation ->> 'elected_deduction')::numeric
|
||||
= tax_depreciation_deduction
|
||||
AND (tax_depreciation_calculation ->> 'maximum_deduction')::numeric
|
||||
>= tax_depreciation_deduction
|
||||
AND (
|
||||
tax_depreciation_method <> 'rakenskapsenlig'
|
||||
OR tax_depreciation_calculation ->> 'book_conformity_confirmed' = 'true'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_depreciation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
previous_period public.fiscal_periods%ROWTYPE;
|
||||
tax_snapshot_changed boolean;
|
||||
BEGIN
|
||||
tax_snapshot_changed := (
|
||||
NEW.tax_depreciation_method IS DISTINCT FROM OLD.tax_depreciation_method
|
||||
OR NEW.tax_depreciation_rule IS DISTINCT FROM OLD.tax_depreciation_rule
|
||||
OR NEW.tax_depreciation_opening_value IS DISTINCT FROM OLD.tax_depreciation_opening_value
|
||||
OR NEW.tax_depreciation_base IS DISTINCT FROM OLD.tax_depreciation_base
|
||||
OR NEW.tax_depreciation_deduction IS DISTINCT FROM OLD.tax_depreciation_deduction
|
||||
OR NEW.tax_depreciation_closing_value IS DISTINCT FROM OLD.tax_depreciation_closing_value
|
||||
OR NEW.tax_depreciation_calculation IS DISTINCT FROM OLD.tax_depreciation_calculation
|
||||
);
|
||||
|
||||
IF NOT tax_snapshot_changed THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.is_closed = true OR OLD.locked_at IS NOT NULL OR OLD.closing_entry_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Fiscal period is locked for tax depreciation elections'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.fiscal_periods successor
|
||||
WHERE successor.company_id = NEW.company_id
|
||||
AND successor.period_start > NEW.period_start
|
||||
AND successor.tax_depreciation_method IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'A later fiscal period already has a tax depreciation snapshot'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
IF NEW.tax_depreciation_method IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.previous_period_id IS NOT NULL THEN
|
||||
SELECT *
|
||||
INTO previous_period
|
||||
FROM public.fiscal_periods candidate
|
||||
WHERE candidate.id = NEW.previous_period_id
|
||||
AND candidate.company_id = NEW.company_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Previous fiscal period is missing or belongs to another company'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
IF previous_period.period_end + 1 <> NEW.period_start THEN
|
||||
RAISE EXCEPTION 'Previous fiscal period is not date-adjacent'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
ELSE
|
||||
SELECT *
|
||||
INTO previous_period
|
||||
FROM public.fiscal_periods candidate
|
||||
WHERE candidate.company_id = NEW.company_id
|
||||
AND candidate.period_end + 1 = NEW.period_start
|
||||
ORDER BY candidate.period_end DESC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF previous_period.id IS NOT NULL THEN
|
||||
IF previous_period.tax_depreciation_method IS NULL
|
||||
OR previous_period.tax_depreciation_closing_value IS NULL THEN
|
||||
RAISE EXCEPTION 'Previous fiscal period has no tax depreciation snapshot'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
IF NEW.tax_depreciation_method <> previous_period.tax_depreciation_method THEN
|
||||
RAISE EXCEPTION 'Tax depreciation method must match the previous fiscal period'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
IF NEW.tax_depreciation_opening_value
|
||||
IS DISTINCT FROM previous_period.tax_depreciation_closing_value THEN
|
||||
RAISE EXCEPTION 'Tax depreciation opening value must equal the previous closing value'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- The annual snapshot chain is authoritative. Keeping a second company-level
|
||||
-- method introduced an admin/member RLS mismatch and a non-atomic second write.
|
||||
ALTER TABLE public.company_settings
|
||||
DROP CONSTRAINT IF EXISTS company_settings_tax_depreciation_method_check;
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
DROP COLUMN IF EXISTS tax_depreciation_method;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user