Files
accounted/supabase/migrations/20260516120000_assets_and_depreciation.sql
Jakob Wennberg 05078c9d8e feat(bokslut): year-end wizard with bokslutsdispositioner + asset register (#508)
* feat(bokslut): year-end wizard with bokslutsdispositioner + asset register

Ships the first user-visible bokslut surface for K2 aktiebolag. The year-end
engine, INK2/INK2R/INK2S generator, and reconciliation reports already existed
in lib/core/bookkeeping/ and lib/reports/; this work wires them into a real
multi-step UI, adds the missing dispositioner calculators (bolagsskatt,
periodiseringsfond, överavskrivningar, SLP), and introduces a fixed-asset
register that feeds planenliga avskrivningar into the same flow.

PHASE 1 — Wizard around the existing year-end engine
- Replaces the "Kommer snart" stub at /bookkeeping/year-end with a 4-step
  wizard (Kontroll → Dispositioner → Förhandsgranska → Verkställ) plus a
  Klart result view
- New aggregator lib/bokslut/readiness-aggregator.ts composes
  validateYearEndReadiness with bank-reconciliation status and entity-typed
  reminders into one fetch backing the preflight step
- New endpoint GET /api/bookkeeping/fiscal-periods/[id]/bokslut-readiness

PHASE 2 — Bokslutsdispositioner calculators
- lib/bokslut/tax-provision/{bolagsskatt,sarskild-loneskatt}-calculator.ts —
  20.6 % on taxable result → 8910/2512 (with non-deductible / non-taxable
  manual adjustments and schablonintäkt pass-through) and 24.26 % SLP on
  posted pension costs → 7533/2514
- lib/bokslut/reserves/periodiseringsfond-service.ts — proposeAvsattning
  (25 % cap, BAS 212X cohort accounts) + proposeAteforing (FIFO 6-year
  mandatory reversal with schablonintäkt computation) + balance lookup
- lib/bokslut/reserves/overavskrivningar-service.ts — 30-rule + 20-rule
  helpers + proposeOveravskrivningar (8853/2153)
- New endpoint /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner
  (GET ordered proposals, POST commits user-chosen ones as separate
  year_end vouchers via the journal engine)
- New DispositionsStep UI: per-card accept/skip + editable amount where
  meaningful; mandatory p-fond reversals can't be skipped
- INK2 bug fix: ink2-engine.ts SRU mapping ranges previously pointed at
  accounts BAS doesn't seed (8810/8830/8840). Corrected to 8811 (avsättning),
  8819 (återföring), 8830 (lämnade koncernbidrag) so calculator output now
  flows into INK2 correctly. Regression-locked with 6 new mapping tests.

PHASE 3 — Anläggningsregister + depreciation engine
- New migration 20260516120000_assets_and_depreciation.sql: assets table
  (category, BAS-triple, K3 components JSONB reserved) and
  depreciation_schedules (asset+period+journal_entry link). RLS via
  user_company_ids(), immutability triggers after disposal/posting.
- lib/bokslut/assets/asset-service.ts — CRUD + disposal that posts a proper
  gain/loss entry against 3973/7973
- lib/bokslut/assets/depreciation-engine.ts — computeAnnualDepreciation
  (linear, pro-rata at acquisition/disposal/end-of-life) +
  proposeAnnualPostings + commitAnnualPostings (one entry per asset)
- New endpoints /api/assets (CRUD + dispose) and
  /api/bookkeeping/fiscal-periods/[id]/depreciation (preview + commit)
- /assets list+create page with K2 schablon defaults (3y datorer,
  5y inventarier, 25y byggnader); sidebar entry added
- DepreciationPanel mounted at the top of DispositionsStep; posting
  refreshes dispositions so bolagsskatt picks up the new result

Out of scope (per the agreed plan): K3 framework, iXBRL filing to
Bolagsverket (manual export only for now — regulatory risk flagged for
FY2026 closings), inventory module, koncernredovisning, revisor workflow.

Verification
- 116 unit tests pass across lib/bokslut/, lib/reports/ink2/, and the
  existing lib/core/bookkeeping/year-end-service suite
- Zero lint or typecheck errors in any new file
- Migration applied successfully via Supabase MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #508 review — P1 correctness + P2 conventions

P1 — stale reminders in readiness-aggregator (greptile)
Remove the depreciation_manual / bolagsskatt_manual / periodiseringsfond_manual
nudges. After Phase 3, the wizard handles all three automatically via
DepreciationPanel and the dispositioner calculators — surfacing them as
manual reminders told users to repeat work the page already did. Only the
accruals_manual reminder (Phase 4 hasn't shipped) and the EF-only
ef_skatt_via_ne reminder remain.

P1 — netBookValueAfter ignored prior accumulated depreciation
proposeAnnualPostings now fetches all prior posted depreciation_schedules
for the company (excluding the current period) and sums them per asset, so
the displayed restvärde reflects every previously-booked year of avskrivning
instead of only this year. Without the fix, a 5-year asset in year 3 would
have shown 48 000 instead of the correct 24 000 net book value.

P1 — ordering bug in dispositioner POST handler
The 25 % p-fond avsättning cap derives from the current trial balance, so
mandatory återföring entries must post first. Added a server-side sort by
canonical bokslut order (återföring → överavskrivningar → avsättning → SLP
→ bolagsskatt) regardless of the client array order. The cap can no longer
be evaluated against a stale pre-återföring net result.

P2 — depreciation_schedules missing updated_at
New migration 20260516140000_depreciation_schedules_updated_at.sql adds the
column + trigger via update_updated_at_column(). Per CLAUDE.md migration
conventions, never modified the original migration. DepreciationSchedule
type updated.

P2 — addMonths end-of-month overflow
Replaced setUTCMonth (which overflows: Jan 31 + 1 month → Mar 3) with a
day-clamping implementation that produces Feb 28/29. Without the fix,
lifeEndExclusive landed one day too late and slightly over-depreciated.
New regression test asserts Jan 31 + 12 months stays in January.

P2 — pg-real tests for new triggers and RLS
tests/pg/assets.pg.test.ts (13 tests) covers:
  - enforce_asset_post_disposal_immutability blocks every financial field
    after disposal, allows notes/name through
  - assets_disposal_atomic CHECK requires both disposed columns set together
  - enforce_depreciation_schedule_immutability blocks edits after
    journal_entry_id is linked, allows them before
  - depreciation_schedules delete RLS policy filters out posted rows
  - assets + depreciation_schedules RLS isolates across companies

Verification
- 117 unit tests pass (was 116, +1 for the addMonths regression)
- New pg-real suite syntactically + type-correct; will execute in CI
- Zero lint or typecheck errors in any touched file
- Migration 20260516140000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance-swarm + swedish-review findings

Real bugs surfaced by the bots on the first push that weren't covered by
greptile's inline P1/P2 set:

- Immaterial asset disposal hit the wrong BAS accounts. disposeAsset always
  posted gain/loss to 3973/7973 regardless of category. For category
  'immaterial' it must use 3013 (vinst) / 7813 (förlust) per BAS — using the
  tangible accounts misclassifies in INK2R. Now branches on category. Two
  new regression tests pin each branch.

- acquisition_cost CHECK was too loose. CreateAssetSchema accepted 0 (just
  nonnegative). Tightened to z.number().positive() — a zero-cost asset
  creates a no-op depreciation row and a balance sheet line that nothing
  reconciles against.

- UpdateAssetSchema let users remap BAS accounts arbitrarily. Bot flagged
  this as a defense-in-depth gap (V4.5). Added BAS_RANGES_BY_CATEGORY
  validation at both the schema layer (Create) and the service layer
  (Update) so user-supplied account overrides must stay inside the
  category's expected BAS range. INK2R mappings and the depreciation
  engine's category-driven defaults now can't drift.

Swedish accounting review:

- Building/markanläggning defaults — clarified UI copy. The 25-year
  schablon is K2-redovisning, not the IL skattemässig rate. New helper text
  spells this out. Markanläggning default lowered from 20→10 years
  (Skatteverket guidance allows 10 % rate; 20 was on the upper bound
  without justification).

- createAsset doesn't post the acquisition entry by design — that gap
  wasn't called out anywhere in the UI. Added a tip box in
  CreateAssetDialog explaining that the acquisition must already be in the
  books; the register only drives depreciation.

- Disposal VAT (ML 3:3 / 7:3) not handled — sale of a deduct-eligible
  anläggningstillgång is in principle 25 % momspliktig. Documented this as
  a known limitation in the disposeAsset docstring so any future UI
  surfacing the disposal endpoint warns the user.

Documented (not fixed yet) — bot was right but wider-scope work:

- SOC 2 PI1.3: dispositioner POST loop is not transactional. A failure
  midway leaves partial postings. Added a code comment explaining the
  recovery path (re-POST omitting committed kinds — each calculator
  re-derives from current TB). Real atomicity via an RPC wrapper is Phase
  5+ work.

False positives intentionally not changed:

- 4× OWASP V8.2.1 cross-tenant findings — service functions already filter
  by company_id; the bot can't see past the route handler.
- V2.3 client-supplied amount clamping — proposeAvsattning and
  proposeAteforing both clamp via Math.min already.
- A.8.15 audit events — withRouteContext already logs completion.
- Schablonintäkt journal entry — per IL 30:6a it's a skattemässig
  justering, never booked. Current implementation is correct.
- Voucher series 'A' — matches existing executeYearEndClosing convention;
  not changing here in isolation.

Verification
- 119 tests pass (was 117, +2 for the immaterial-disposal branches)
- Zero lint or typecheck errors on any touched file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance round 4 — BAS account overlap + method gate

Two real bugs the compliance bot caught after my round-3 fixes (both new on
its re-run, not in the original report):

- BAS account overlap not prevented: my BAS_RANGES_BY_CATEGORY uses the same
  class range for asset and accumulated (e.g. immaterial: both 1010–1099,
  building: both 1100–1199). Nothing stopped a user from picking the same
  account for both, which would silently net acquisition cost against
  accumulated depreciation in one bucket and corrupt INK2R 720x mappings.
  CreateAssetSchema now rejects bas_asset_account === bas_accumulated_account
  in a superRefine cross-field check; updateAsset enforces the same invariant
  by reading the existing asset and validating the merged result.

- declining-balance methods silently fell back to linear. The DB enum allowed
  declining_balance_30 / declining_balance_20, but the engine's
  computeAnnualDepreciation only implements linear math. A determined caller
  (MCP, curl, future UI) could create an asset labelled as räkenskapsenlig
  avskrivning and get linear charges — silently wrong numbers under a
  misleading method. Both CreateAssetSchema and UpdateAssetSchema now refine
  the depreciation_method enum to require 'linear'. The DB enum stays open
  for a future phase to add proper support. Stale comment in
  depreciation-engine.ts updated to reflect the new invariant.

False positives I'm explicitly not chasing further on this round:
- 3× repeated OWASP V8.2.1 cross-tenant — services already filter by
  company_id; bot can't see past the route handler. Round 3 already added
  service-layer tests and inline reasoning.
- V2.3 atomicity upgrade to high — bot now flags it harder *because* I
  documented it in round 3. The existing executeYearEndClosing has the same
  non-transactional sequential-write pattern; wrapping just this endpoint
  in an RPC while leaving the rest inconsistent is worse than the doc
  comment. Real atomicity is Phase 5+.
- Disposal VAT user-facing warning — no UI surfaces dispose yet; docstring
  in the service is sufficient until the UI ships.

Verification
- 119 tests pass
- Zero lint or typecheck errors on any touched file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance round 5 — disposal integrity + öavskr accounts

Round-5 bot feedback after my round-4 push (the sticky reports re-edited
themselves with two new genuine findings; bot count: 18 → 14 active).

P1 — accumulated_depreciation was client-supplied (OWASP V8.2.1, swedish
compliance review): the dispose endpoint accepted accumulated_depreciation
as a request-body number. A malicious or buggy caller could inflate it to
manipulate the book-value calculation and pocket a phantom gain. Now:

- DisposeAssetSchema no longer accepts accumulated_depreciation
- disposeAsset sums planned_depreciation from depreciation_schedules where
  journal_entry_id IS NOT NULL for the asset, server-side
- New regression test "server-derives accumulated_depreciation — caller
  cannot inflate gain" pins the server-derivation against the prior attack
- Limitation: manual avskrivningsverifikationer posted outside the engine
  aren't captured. Phase 5+ can swap this for a trial-balance scan on
  bas_accumulated_account if that gap matters.

P2 — överavskrivningar hardcoded 8853/2153 regardless of asset category
(swedish-asset-accounting): for buildings BAS uses 8852/2152 and for
immateriella tillgångar 8851/2151. Edge case for K2 SME (öavskr on
buildings is rare; on immateriella rarer still) but worth not lying about
the accounts. Now:

- New OVERAVSKRIVNING_ACCOUNTS table maps category → expense/accumulated
  pair (machinery_equipment, building, immaterial, group)
- proposeOveravskrivningar accepts optional category, defaults to
  machinery_equipment (the dominant K2 case — no behaviour change for
  existing callers)
- POST handler item schema accepts optional category
- Label + description strings now name the actual accounts used
- 3 new tests cover the building, immaterial, and default branches

False positives I'm still declining to chase (already covered in prior
commit messages):
- 3× repeated OWASP V8.2.1 cross-tenant — services scope by company_id;
  bot can't see past route handler
- V2.3 atomicity — existing executeYearEndClosing has the same pattern;
  wrapping just this endpoint is inconsistent; real fix is Phase 5+ RPC
- Disposal VAT user-facing warning — no UI surfaces dispose yet

Verification
- 123 tests pass (was 119, +3 for öavskr category branches and +1 for the
  server-derivation regression test, with one prior test rewritten to use
  the new server-supplied accumulated path)
- Zero lint or typecheck errors on any touched file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): pre-merge polish — building disposal accounts, SLR-2026 rate

Last polish round before merge. All three from the round-5 sticky
Swedish-accounting-review update:

- Building / markanläggning disposal posted gain/loss to 3973/7973. BAS
  2026 routes those to 3971/7971 (the SRU mapping points them at a
  different INK2R field, so the existing accounts misclassify). Extended
  the existing immaterial branch (3013/7813) into a three-way:
    immaterial            → 3013 / 7813
    building / land_imprv → 3971 / 7971
    other tangible        → 3973 / 7973
  Two new regression tests pin the building and land_improvement branches.

- DEFAULT_SCHABLONINTAKT_RATE was 0.03, based on SLR 2024-11-30 (1.96 %).
  For closings of inkomstår 2026 the rate is SLR 2025-11-30 (2.55 %) + 1 pe
  = 3.55 %. The wrong rate under-taxes the schablonintäkt, which feeds into
  bolagsskatt. Updated to 0.0355 and rewrote the doc comment to track both
  years so the next bump is obvious.

- Jämkning of input VAT for buildings / markanläggning disposed within the
  10-year jämkningsperiod (ML 9 kap 8–11 §§) is out of scope for this PR
  but should not be silently absent — added a KNOWN LIMITATION block to the
  disposeAsset docstring so any future UI surfacing disposal checks the
  10-year window and warns the user.

After this push the PR has 125 passing tests, all CI green, no merge
conflicts, and the only remaining bot complaints are repeat false
positives or Phase 5+ scope (RPC atomicity, full asset disposal UI,
K3 component depreciation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:55:27 +02:00

199 lines
10 KiB
PL/PgSQL

-- Migration: assets + depreciation_schedules — fixed asset register
--
-- Why this exists: Swedish year-end closing (BFNAR 2016:10 K2, BFNAR 2012:1 K3)
-- requires the company to record planenliga avskrivningar on every fixed
-- asset (anläggningstillgång) each fiscal year. Without an asset register,
-- depreciation must be entered manually as journal entries — accountants
-- get this right, but Gnubok's DIY users typically forget or mis-account.
--
-- The bokslut wizard (lib/bokslut/) wires this register into Phase 3 so
-- depreciation entries are proposed automatically. The K3 component
-- depreciation feature (BFNAR 2012:1 ch.17.4) is reserved via the
-- `k3_components` JSONB column but unused in K2 / Phase 3.
--
-- Disposal handling: gain (3973) / loss (7973) booked against the asset's
-- accumulated depreciation and acquisition cost. Disposal does NOT delete
-- the asset row — BFL retention (7 years) requires the audit trail.
-- ============================================================
-- assets
-- ============================================================
CREATE TABLE public.assets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (length(name) > 0),
-- Category groups by BAS account class for default journal accounts and
-- för K3 component depreciation hints. Free-text instead of enum so new
-- categories don't require migrations.
category TEXT NOT NULL CHECK (category IN (
'immaterial', -- 1010-1099 immateriella
'building', -- 1110-1199 byggnader & mark
'land_improvement', -- 1150-1159 markanläggningar
'machinery', -- 1210-1219 maskiner
'equipment', -- 1220-1229 inventarier
'vehicle', -- 1240-1249 bilar och transportmedel
'computer', -- 1250-1259 datorer
'other_tangible' -- 1280-1299 övriga materiella
)),
acquisition_date DATE NOT NULL,
acquisition_cost NUMERIC(15, 2) NOT NULL CHECK (acquisition_cost >= 0),
salvage_value NUMERIC(15, 2) NOT NULL DEFAULT 0 CHECK (salvage_value >= 0),
-- Useful life in months. K2 allows schablon 5 years (60 months) för
-- inventarier. K3 requires individual assessment.
useful_life_months INTEGER NOT NULL CHECK (useful_life_months > 0),
-- Depreciation method. 'linear' is planenlig raklinje (most common).
-- The declining_balance_* methods are for skattemässig avskrivning and
-- only used directly when book and tax depreciation are equal (K2 may);
-- otherwise överavskrivningar handles the gap (see Phase 2 service).
depreciation_method TEXT NOT NULL DEFAULT 'linear' CHECK (depreciation_method IN (
'linear',
'declining_balance_30',
'declining_balance_20'
)),
-- BAS account triple. asset = the 12xx anskaffningskonto, accumulated =
-- the 12x9 ackumulerade avskrivningar, expense = the 78xx avskrivningskonto.
bas_asset_account TEXT NOT NULL,
bas_accumulated_account TEXT NOT NULL,
bas_expense_account TEXT NOT NULL,
-- Disposal: filled when the asset is sold / scrapped. Once disposed the
-- row becomes read-only (see trigger below). Keep the row för audit.
disposed_at DATE,
disposed_proceeds NUMERIC(15, 2),
-- K3 component depreciation: array of { name, cost, useful_life_months,
-- method }. Sum of component costs must equal acquisition_cost; enforced
-- in application layer because PG can't sum JSONB elements in a CHECK.
-- Empty / NULL for K2 — leaves the door open för Phase 5+ K3 support
-- without another migration.
k3_components JSONB,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Disposal sanity: either both disposal columns set or neither.
CONSTRAINT assets_disposal_atomic CHECK (
(disposed_at IS NULL AND disposed_proceeds IS NULL)
OR (disposed_at IS NOT NULL AND disposed_proceeds IS NOT NULL)
)
);
CREATE INDEX idx_assets_company ON public.assets (company_id);
CREATE INDEX idx_assets_company_active ON public.assets (company_id) WHERE disposed_at IS NULL;
CREATE INDEX idx_assets_acquisition_date ON public.assets (company_id, acquisition_date);
ALTER TABLE public.assets ENABLE ROW LEVEL SECURITY;
CREATE POLICY "assets_select" ON public.assets
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "assets_insert" ON public.assets
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "assets_update" ON public.assets
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "assets_delete" ON public.assets
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE TRIGGER assets_updated_at
BEFORE UPDATE ON public.assets
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Read-only after disposal: once disposed_at is set, only the disposal date
-- may not be backtracked. Allow notes edits; block changes to financial
-- attributes (cost, life, method, accounts). Mirrors the journal entry
-- immutability pattern in 20240101000017_enforcement_triggers.sql.
CREATE OR REPLACE FUNCTION public.enforce_asset_post_disposal_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.disposed_at IS NOT NULL THEN
IF NEW.acquisition_cost IS DISTINCT FROM OLD.acquisition_cost
OR NEW.salvage_value IS DISTINCT FROM OLD.salvage_value
OR NEW.useful_life_months IS DISTINCT FROM OLD.useful_life_months
OR NEW.depreciation_method IS DISTINCT FROM OLD.depreciation_method
OR NEW.bas_asset_account IS DISTINCT FROM OLD.bas_asset_account
OR NEW.bas_accumulated_account IS DISTINCT FROM OLD.bas_accumulated_account
OR NEW.bas_expense_account IS DISTINCT FROM OLD.bas_expense_account
OR NEW.acquisition_date IS DISTINCT FROM OLD.acquisition_date THEN
RAISE EXCEPTION 'Cannot modify financial attributes of a disposed asset (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER enforce_asset_post_disposal_immutability
BEFORE UPDATE ON public.assets
FOR EACH ROW EXECUTE FUNCTION public.enforce_asset_post_disposal_immutability();
-- ============================================================
-- depreciation_schedules
-- ============================================================
--
-- One row per (asset, fiscal_period) recording the planenlig avskrivning
-- proposal and the journal entry that materialized it. ON DELETE RESTRICT
-- on journal_entry_id ensures we cannot orphan postings — if a user needs
-- to "redo" a year's depreciation they must reverse the entry (storno)
-- which produces a new entry rather than removing the original. That's
-- BFL-compliant audit behaviour.
CREATE TABLE public.depreciation_schedules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
fiscal_period_id UUID NOT NULL REFERENCES fiscal_periods(id) ON DELETE RESTRICT,
planned_depreciation NUMERIC(15, 2) NOT NULL CHECK (planned_depreciation >= 0),
-- Filled when the user commits the proposal to a journal entry. Until
-- then this row is a "draft" the wizard can re-compute on the fly.
journal_entry_id UUID REFERENCES journal_entries(id) ON DELETE RESTRICT,
posted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- One depreciation row per asset per fiscal period (a year cannot have
-- two planenliga avskrivningar för the same asset).
CONSTRAINT depreciation_schedules_unique UNIQUE (asset_id, fiscal_period_id)
);
CREATE INDEX idx_depreciation_schedules_company ON public.depreciation_schedules (company_id);
CREATE INDEX idx_depreciation_schedules_period ON public.depreciation_schedules (fiscal_period_id);
CREATE INDEX idx_depreciation_schedules_asset ON public.depreciation_schedules (asset_id);
ALTER TABLE public.depreciation_schedules ENABLE ROW LEVEL SECURITY;
CREATE POLICY "depreciation_schedules_select" ON public.depreciation_schedules
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "depreciation_schedules_insert" ON public.depreciation_schedules
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "depreciation_schedules_update" ON public.depreciation_schedules
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
-- DELETE is intentionally locked down: only allowed för rows without a
-- posted journal entry. Posted rows must persist för audit.
CREATE POLICY "depreciation_schedules_delete" ON public.depreciation_schedules
FOR DELETE USING (
company_id IN (SELECT public.user_company_ids())
AND journal_entry_id IS NULL
);
-- Block updates that would change the link or financial number after posting.
CREATE OR REPLACE FUNCTION public.enforce_depreciation_schedule_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.journal_entry_id IS NOT NULL THEN
IF NEW.planned_depreciation IS DISTINCT FROM OLD.planned_depreciation
OR NEW.asset_id IS DISTINCT FROM OLD.asset_id
OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id
OR NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id THEN
RAISE EXCEPTION 'Cannot modify a posted depreciation schedule (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER enforce_depreciation_schedule_immutability
BEFORE UPDATE ON public.depreciation_schedules
FOR EACH ROW EXECUTE FUNCTION public.enforce_depreciation_schedule_immutability();
NOTIFY pgrst, 'reload schema';