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:
Mattsson
2026-08-04 11:43:58 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 00ae3540db
commit 5ca64bde30
25 changed files with 2863 additions and 657 deletions
@@ -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';