Add/bokslut (#718)

* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-12 16:35:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8e8b63a200
commit db8983ba9e
131 changed files with 33796 additions and 340 deletions
@@ -0,0 +1,225 @@
-- Digital inlämning av årsredovisning till Bolagsverket — submission state.
--
-- Three tables backing the bolagsverket extension:
-- 1. arsredovisning_submissions — one row per filing attempt (state
-- machine draft → kontrollerad → uploaded → inkommen → [förelagd ↔
-- komplettering] → registrerad | avslutad | error). Immutable after
-- upload except the status-tracking fields (mirrors the accounting
-- guard-rail philosophy: what was sent to the authority never mutates).
-- 2. bolagsverket_avtal_acceptances — GUIDE §4.2: the avtalstext returned
-- by skapa-inlamningtoken MUST be shown and accepted per company before
-- kontrollera/inlämning may run; re-shown when avtalstextAndrad changes.
-- 3. bolagsverket_subscriptions — händelseprenumerationer (GUIDE §5.4):
-- per-company webhook registration incl. the `auth` header secret we set
-- at subscription time and the 6-month expiry for idempotent re-subscribe.
--
-- Personnummer (avsändare + undertecknare) are REQUIRED by the API but are
-- GDPR-sensitive — only SHA-256 hashes are persisted (open decision #1 in
-- dev_docs/bokslut/arsredovisning_implementation.md resolved as transient).
-- =============================================================================
-- 1. arsredovisning_submissions
-- =============================================================================
CREATE TABLE public.arsredovisning_submissions (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- ON DELETE SET NULL (not CASCADE): a filing attempt against the authority
-- is audit history for the COMPANY — it must survive deletion of the user
-- account that happened to press the button.
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE CASCADE,
handling_typ text NOT NULL DEFAULT 'arsredovisning_komplett'
CHECK (handling_typ IN ('arsredovisning_komplett', 'arsredovisning', 'revisionsberattelse')),
taxonomy_version text NOT NULL,
entry_point text NOT NULL,
environment text NOT NULL DEFAULT 'test' CHECK (environment IN ('test', 'accept', 'prod')),
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'kontrollerad', 'uploaded', 'inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad', 'error')),
undertecknare_namn text,
undertecknare_epost text,
undertecknare_pnr_hash text,
avsandare_pnr_hash text,
-- Response fields from inlämning (GUIDE §5.3.3)
idnummer text,
sha256_checksumma text,
kontrollsumma text,
bolagsverket_url text,
kontrollera_utfall jsonb,
-- Generated .xhtml stored as räkenskapsinformation (7-year retention) via
-- the documents service; linked here for traceability.
dokument_id uuid REFERENCES public.document_attachments(id),
error_message text,
uploaded_at timestamptz,
registered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.arsredovisning_submissions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company arsredovisning submissions"
ON public.arsredovisning_submissions FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company arsredovisning submissions"
ON public.arsredovisning_submissions FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "update own-company arsredovisning submissions"
ON public.arsredovisning_submissions FOR UPDATE
USING (company_id IN (SELECT user_company_ids()));
-- No DELETE policy: a filing attempt against the authority is part of the
-- audit trail; rows are closed via status, never removed by users.
CREATE INDEX idx_arsred_submissions_company
ON public.arsredovisning_submissions (company_id, fiscal_period_id);
CREATE INDEX idx_arsred_submissions_status
ON public.arsredovisning_submissions (company_id, status);
-- The webhook receiver correlates by document idnummer.
CREATE INDEX idx_arsred_submissions_idnummer
ON public.arsredovisning_submissions (idnummer) WHERE idnummer IS NOT NULL;
CREATE TRIGGER set_updated_at_arsred_submissions
BEFORE UPDATE ON public.arsredovisning_submissions
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_arsred_submissions
AFTER INSERT OR UPDATE OR DELETE ON public.arsredovisning_submissions
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- ---- immutability + status-machine enforcement ------------------------------
-- After upload (uploaded_at set), the submission's identity and the uploaded
-- artefact's fingerprints are frozen; only the status-tracking columns may
-- move, and only along documented transitions (GUIDE §5.2.2).
CREATE OR REPLACE FUNCTION public.enforce_arsred_submission_immutability()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.uploaded_at IS NOT NULL THEN
IF NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id
OR NEW.handling_typ IS DISTINCT FROM OLD.handling_typ
OR NEW.taxonomy_version IS DISTINCT FROM OLD.taxonomy_version
OR NEW.entry_point IS DISTINCT FROM OLD.entry_point
OR NEW.environment IS DISTINCT FROM OLD.environment
OR NEW.idnummer IS DISTINCT FROM OLD.idnummer
OR NEW.sha256_checksumma IS DISTINCT FROM OLD.sha256_checksumma
OR NEW.kontrollsumma IS DISTINCT FROM OLD.kontrollsumma
OR NEW.dokument_id IS DISTINCT FROM OLD.dokument_id
OR NEW.undertecknare_pnr_hash IS DISTINCT FROM OLD.undertecknare_pnr_hash
OR NEW.avsandare_pnr_hash IS DISTINCT FROM OLD.avsandare_pnr_hash
OR NEW.uploaded_at IS DISTINCT FROM OLD.uploaded_at
THEN
RAISE EXCEPTION 'Inlämnad årsredovisning kan inte ändras (submission % är uppladdad till Bolagsverket)', OLD.id
USING ERRCODE = 'P0001';
END IF;
END IF;
-- Post-upload statuses (inkommen/forelagd/komplettering/registrerad/
-- avslutad) are asserted by Bolagsverket, not derived by us. Webhooks can
-- be missed and the polling fallback may skip intermediate events, so all
-- FORWARD jumps between externally-asserted statuses are allowed; only
-- regressions (e.g. registrerad → draft) are rejected.
IF NEW.status IS DISTINCT FROM OLD.status THEN
IF NOT (
(OLD.status = 'draft' AND NEW.status IN ('kontrollerad', 'uploaded', 'error'))
OR (OLD.status = 'kontrollerad' AND NEW.status IN ('kontrollerad', 'uploaded', 'error', 'draft'))
OR (OLD.status = 'uploaded' AND NEW.status IN ('inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad', 'error'))
OR (OLD.status = 'inkommen' AND NEW.status IN ('forelagd', 'komplettering', 'registrerad', 'avslutad'))
OR (OLD.status = 'forelagd' AND NEW.status IN ('komplettering', 'registrerad', 'avslutad'))
OR (OLD.status = 'komplettering' AND NEW.status IN ('forelagd', 'registrerad', 'avslutad'))
OR (OLD.status = 'error' AND NEW.status IN ('draft', 'kontrollerad'))
) THEN
RAISE EXCEPTION 'Ogiltig statusövergång för årsredovisningsinlämning: % → %', OLD.status, NEW.status
USING ERRCODE = 'P0001';
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_arsred_submission_immutability
BEFORE UPDATE ON public.arsredovisning_submissions
FOR EACH ROW EXECUTE FUNCTION public.enforce_arsred_submission_immutability();
-- =============================================================================
-- 2. bolagsverket_avtal_acceptances
-- =============================================================================
CREATE TABLE public.bolagsverket_avtal_acceptances (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- ON DELETE SET NULL: the acceptance is an audit record of WHO accepted the
-- Bolagsverket avtalstext for the company; it must survive user deletion.
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
-- The avtalstextAndrad date from skapa-inlamningtoken; a NEW acceptance row
-- is required whenever Bolagsverket bumps this (GUIDE §4.2/§5.3.1).
avtalstext_andrad text NOT NULL,
accepted_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.bolagsverket_avtal_acceptances ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company avtal acceptances"
ON public.bolagsverket_avtal_acceptances FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company avtal acceptances"
ON public.bolagsverket_avtal_acceptances FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()) AND user_id = auth.uid());
-- Acceptances are an audit record — no UPDATE/DELETE policies.
CREATE UNIQUE INDEX uq_bolagsverket_avtal_acceptance
ON public.bolagsverket_avtal_acceptances (company_id, user_id, avtalstext_andrad);
-- =============================================================================
-- 3. bolagsverket_subscriptions (händelseprenumerationer)
-- =============================================================================
CREATE TABLE public.bolagsverket_subscriptions (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- ON DELETE SET NULL: the subscription authenticates COMPANY-level webhook
-- deliveries (the auth secret below). Cascading it away with the user who
-- happened to register it would silently 401 all subsequent deliveries.
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
orgnr text NOT NULL,
url text NOT NULL,
-- Secret we provide as the `auth` field at subscription; Bolagsverket echoes
-- it as an `auth` http header on every webhook delivery (GUIDE §5.4.5.2).
auth_secret text NOT NULL,
environment text NOT NULL DEFAULT 'test' CHECK (environment IN ('test', 'accept', 'prod')),
subscribed_at timestamptz NOT NULL DEFAULT now(),
-- Subscriptions auto-expire after 6 months; re-subscribe after every
-- inlämning extends this (GUIDE §4.3).
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.bolagsverket_subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "update own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions FOR UPDATE
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "delete own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions FOR DELETE
USING (company_id IN (SELECT user_company_ids()));
CREATE UNIQUE INDEX uq_bolagsverket_subscription
ON public.bolagsverket_subscriptions (company_id, orgnr, url, environment);
-- The webhook receiver looks subscriptions up by orgnr alone (the unique
-- index above leads with company_id and cannot serve that path).
CREATE INDEX idx_bolagsverket_subscriptions_orgnr
ON public.bolagsverket_subscriptions (orgnr);
CREATE TRIGGER set_updated_at_bolagsverket_subscriptions
BEFORE UPDATE ON public.bolagsverket_subscriptions
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,335 @@
-- Periodisering (accrual/deferral) of supplier & customer invoice lines.
--
-- A schedule spreads one invoice line's net amount (ex VAT) over the months
-- of a service period, Fortnox-style:
--
-- Supplier invoice (förutbetald kostnad): the registration entry debits a
-- 17xx interim asset account instead of the line's cost account; one
-- monthly "upplösning" entry (Dr cost / Cr 17xx) per calendar month.
--
-- Customer invoice (förutbetald intäkt): the revenue entry credits a 29xx
-- interim liability account instead of the line's 3xxx account; monthly
-- dissolution is Dr 29xx / Cr 3xxx.
--
-- VAT is never deferred (ML: redovisas på fakturadatum) — only the net line
-- amount moves to the interim account. All dissolution entries are created
-- through lib/bookkeeping/engine.ts with source_type='accrual'; this module
-- adds that source type, the schedule tables, and the per-line period fields
-- on both invoice item tables.
--
-- Legal basis: ÅRL 2 kap 4 § (periodiseringsprincipen). K2 (BFNAR 2016:10)
-- allows skipping accruals < 5 000 kr — enforced as a UI hint, never a block.
-- ============================================================
-- accrual_schedules — one row per deferred invoice line
-- ============================================================
CREATE TABLE public.accrual_schedules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- 'expense' = förutbetald kostnad (17xx), 'revenue' = förutbetald intäkt (29xx)
direction text NOT NULL CHECK (direction IN ('expense', 'revenue')),
-- Exactly one source: a supplier invoice line or a customer invoice line.
-- ON DELETE RESTRICT: a booked invoice with a schedule must be credited
-- (which cancels the schedule via the service layer), never hard-deleted.
supplier_invoice_id uuid REFERENCES public.supplier_invoices(id) ON DELETE RESTRICT,
supplier_invoice_item_id uuid REFERENCES public.supplier_invoice_items(id) ON DELETE SET NULL,
invoice_id uuid REFERENCES public.invoices(id) ON DELETE RESTRICT,
invoice_item_id uuid REFERENCES public.invoice_items(id) ON DELETE SET NULL,
-- Interim balance account (17xx/29xx) and the P&L account the amount
-- dissolves to. Account numbers are strings, per project convention.
balance_account text NOT NULL,
target_account text NOT NULL,
-- Net amount in SEK as booked (ex VAT). Exchange differences never touch
-- the schedule; they are realized on payment like any other invoice.
total_amount numeric(15, 2) NOT NULL CHECK (total_amount > 0),
period_start date NOT NULL,
period_end date NOT NULL,
-- Number of calendar months touched by [period_start, period_end]; equals
-- the number of installment rows. Denormalized for list views.
months integer NOT NULL CHECK (months >= 1),
-- The registration/revenue entry that put the amount on balance_account.
origin_journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE RESTRICT,
-- Floor for dissolution entry dates (= the origin entry's entry_date).
-- Catch-up installments for already-elapsed months are dated
-- max(period_month, posting_floor_date) so the interim account can never
-- go negative before the origin entry exists.
posting_floor_date date NOT NULL DEFAULT CURRENT_DATE,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'completed', 'cancelled')),
description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT accrual_schedules_period_valid CHECK (period_end >= period_start),
CONSTRAINT accrual_schedules_one_source CHECK (
(supplier_invoice_id IS NOT NULL)::int + (invoice_id IS NOT NULL)::int = 1
),
CONSTRAINT accrual_schedules_direction_matches_source CHECK (
(direction = 'expense' AND supplier_invoice_id IS NOT NULL)
OR (direction = 'revenue' AND invoice_id IS NOT NULL)
),
-- Mirror the item-level account-range rules: förutbetalda kostnader live on
-- 17xx (interimsfordringar), förutbetalda intäkter on 29xx (interimsskulder).
CONSTRAINT accrual_schedules_balance_account_range CHECK (
(direction = 'expense' AND balance_account ~ '^17[0-9]{2}$')
OR (direction = 'revenue' AND balance_account ~ '^29[0-9]{2}$')
),
-- The dissolution target must be a plausible 4-digit BAS account (class
-- 1–8); the engine still validates it against chart_of_accounts at booking.
CONSTRAINT accrual_schedules_target_account_range CHECK (
target_account ~ '^[1-8][0-9]{3}$'
)
);
CREATE INDEX idx_accrual_schedules_company ON public.accrual_schedules (company_id);
CREATE INDEX idx_accrual_schedules_company_status ON public.accrual_schedules (company_id, status);
CREATE INDEX idx_accrual_schedules_supplier_invoice ON public.accrual_schedules (supplier_invoice_id)
WHERE supplier_invoice_id IS NOT NULL;
CREATE INDEX idx_accrual_schedules_invoice ON public.accrual_schedules (invoice_id)
WHERE invoice_id IS NOT NULL;
ALTER TABLE public.accrual_schedules ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company accrual_schedules"
ON public.accrual_schedules FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company accrual_schedules"
ON public.accrual_schedules FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "update own-company accrual_schedules"
ON public.accrual_schedules FOR UPDATE
USING (company_id IN (SELECT user_company_ids()))
WITH CHECK (company_id IN (SELECT user_company_ids()));
-- The DELETE policy references accrual_schedule_installments and is created
-- after that table exists (policies resolve table references at CREATE time).
CREATE TRIGGER set_updated_at_accrual_schedules
BEFORE UPDATE ON public.accrual_schedules
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_accrual_schedules
AFTER INSERT OR UPDATE OR DELETE ON public.accrual_schedules
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- ============================================================
-- accrual_schedule_installments — one row per calendar month
-- ============================================================
CREATE TABLE public.accrual_schedule_installments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
schedule_id uuid NOT NULL REFERENCES public.accrual_schedules(id) ON DELETE CASCADE,
-- First day of the month the installment belongs to.
period_month date NOT NULL,
amount numeric(15, 2) NOT NULL CHECK (amount > 0),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'posted', 'cancelled')),
-- ON DELETE RESTRICT: the dissolution verifikat cannot be deleted while an
-- installment references it (storno produces a new entry instead).
journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE RESTRICT,
posted_at timestamptz,
-- Populated by the posting cron when an installment cannot be booked
-- (e.g. no open fiscal period); surfaced in the periodiseringar UI.
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT accrual_installments_unique UNIQUE (schedule_id, period_month),
CONSTRAINT accrual_installments_month_normalized CHECK (
period_month = date_trunc('month', period_month)::date
),
CONSTRAINT accrual_installments_posted_consistent CHECK (
status <> 'posted' OR journal_entry_id IS NOT NULL
)
);
CREATE INDEX idx_accrual_installments_company ON public.accrual_schedule_installments (company_id);
CREATE INDEX idx_accrual_installments_schedule ON public.accrual_schedule_installments (schedule_id);
-- Hot path for the posting cron: all pending installments due by today.
CREATE INDEX idx_accrual_installments_due ON public.accrual_schedule_installments (period_month)
WHERE status = 'pending';
ALTER TABLE public.accrual_schedule_installments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company accrual_installments"
ON public.accrual_schedule_installments FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company accrual_installments"
ON public.accrual_schedule_installments FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "update own-company accrual_installments"
ON public.accrual_schedule_installments FOR UPDATE
USING (company_id IN (SELECT user_company_ids()))
WITH CHECK (company_id IN (SELECT user_company_ids()));
-- Posted installments persist for audit; only unposted rows may be removed.
CREATE POLICY "delete own-company accrual_installments"
ON public.accrual_schedule_installments FOR DELETE
USING (
company_id IN (SELECT user_company_ids())
AND journal_entry_id IS NULL
);
-- Hard delete of a schedule only while nothing has been posted; cancellation
-- is a status change. Mirrors the depreciation_schedules audit posture.
CREATE POLICY "delete own-company accrual_schedules"
ON public.accrual_schedules FOR DELETE
USING (
company_id IN (SELECT user_company_ids())
AND NOT EXISTS (
SELECT 1 FROM public.accrual_schedule_installments i
WHERE i.schedule_id = accrual_schedules.id
AND i.journal_entry_id IS NOT NULL
)
);
CREATE TRIGGER set_updated_at_accrual_installments
BEFORE UPDATE ON public.accrual_schedule_installments
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_accrual_installments
AFTER INSERT OR UPDATE OR DELETE ON public.accrual_schedule_installments
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- Once an installment is materialized as a verifikat its financial fields are
-- frozen — same posture as enforce_depreciation_schedule_immutability.
CREATE OR REPLACE FUNCTION public.enforce_accrual_installment_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.journal_entry_id IS NOT NULL THEN
IF NEW.amount IS DISTINCT FROM OLD.amount
OR NEW.period_month IS DISTINCT FROM OLD.period_month
OR NEW.schedule_id IS DISTINCT FROM OLD.schedule_id
OR NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id THEN
RAISE EXCEPTION 'Cannot modify a posted accrual installment (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SET search_path TO 'public';
CREATE TRIGGER enforce_accrual_installment_immutability
BEFORE UPDATE ON public.accrual_schedule_installments
FOR EACH ROW EXECUTE FUNCTION public.enforce_accrual_installment_immutability();
-- ============================================================
-- Per-line period fields on both invoice item tables
-- ============================================================
-- The line generators read these to swap the P&L account for the interim
-- account at booking time. They are written by the create-invoice APIs and
-- frozen thereafter (booked invoices are immutable). Credit notes never copy
-- them — crediting cancels the schedule instead.
ALTER TABLE public.supplier_invoice_items
ADD COLUMN IF NOT EXISTS accrual_period_start date,
ADD COLUMN IF NOT EXISTS accrual_period_end date,
ADD COLUMN IF NOT EXISTS accrual_balance_account text;
ALTER TABLE public.supplier_invoice_items
DROP CONSTRAINT IF EXISTS supplier_invoice_items_accrual_atomic;
ALTER TABLE public.supplier_invoice_items
ADD CONSTRAINT supplier_invoice_items_accrual_atomic CHECK (
(accrual_period_start IS NULL AND accrual_period_end IS NULL)
OR (
accrual_period_start IS NOT NULL
AND accrual_period_end IS NOT NULL
AND accrual_period_end >= accrual_period_start
)
);
-- Förutbetalda kostnader live on 17xx (interimsfordringar).
ALTER TABLE public.supplier_invoice_items
DROP CONSTRAINT IF EXISTS supplier_invoice_items_accrual_account_range;
ALTER TABLE public.supplier_invoice_items
ADD CONSTRAINT supplier_invoice_items_accrual_account_range CHECK (
accrual_balance_account IS NULL OR accrual_balance_account ~ '^17[0-9]{2}$'
);
ALTER TABLE public.invoice_items
ADD COLUMN IF NOT EXISTS accrual_period_start date,
ADD COLUMN IF NOT EXISTS accrual_period_end date,
ADD COLUMN IF NOT EXISTS accrual_balance_account text;
ALTER TABLE public.invoice_items
DROP CONSTRAINT IF EXISTS invoice_items_accrual_atomic;
ALTER TABLE public.invoice_items
ADD CONSTRAINT invoice_items_accrual_atomic CHECK (
(accrual_period_start IS NULL AND accrual_period_end IS NULL)
OR (
accrual_period_start IS NOT NULL
AND accrual_period_end IS NOT NULL
AND accrual_period_end >= accrual_period_start
)
);
-- Förutbetalda intäkter live on 29xx (interimsskulder).
ALTER TABLE public.invoice_items
DROP CONSTRAINT IF EXISTS invoice_items_accrual_account_range;
ALTER TABLE public.invoice_items
ADD CONSTRAINT invoice_items_accrual_account_range CHECK (
accrual_balance_account IS NULL OR accrual_balance_account ~ '^29[0-9]{2}$'
);
-- ============================================================
-- journal_entries.source_type: add 'accrual'
-- ============================================================
-- See 20260526120400 for the previous expansion pattern. We preserve all
-- pre-existing source_type values and append the new one.
ALTER TABLE public.journal_entries
DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
ALTER TABLE public.journal_entries
ADD CONSTRAINT journal_entries_source_type_check
CHECK (source_type IN (
'manual', 'bank_transaction', 'invoice_created',
'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment',
'opening_balance', 'year_end',
'storno', 'correction', 'import', 'system',
'inbox_item',
'supplier_invoice_registered', 'supplier_invoice_paid',
'supplier_invoice_cash_payment', 'supplier_credit_note',
'currency_revaluation',
'supplier_invoice_privately_paid',
'reminder_fee',
'accrual'
));
-- ============================================================
-- Voucher series default for the new source type
-- ============================================================
ALTER TABLE public.company_settings
ALTER COLUMN default_voucher_series_per_source_type
SET DEFAULT '{
"manual": "A",
"invoice_created": "A",
"invoice_paid": "A",
"invoice_cash_payment": "A",
"credit_note": "A",
"supplier_invoice_registered": "A",
"supplier_invoice_paid": "A",
"supplier_invoice_cash_payment": "A",
"supplier_invoice_privately_paid": "A",
"supplier_credit_note": "A",
"salary_payment": "A",
"bank_transaction": "A",
"reminder_fee": "A",
"opening_balance": "A",
"year_end": "A",
"currency_revaluation": "A",
"inbox_item": "A",
"import": "A",
"system": "A",
"storno": "A",
"correction": "A",
"accrual": "A"
}'::jsonb;
-- Backfill existing rows so the settings UI shows the new key. The resolver
-- falls back to 'A' for missing keys, so this is cosmetic-but-consistent.
UPDATE public.company_settings
SET default_voucher_series_per_source_type =
default_voucher_series_per_source_type || '{"accrual": "A"}'::jsonb
WHERE NOT (default_voucher_series_per_source_type ? 'accrual');
NOTIFY pgrst, 'reload schema';