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:
co-authored by
Claude Fable 5
parent
8e8b63a200
commit
db8983ba9e
@@ -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';
|
||||
Reference in New Issue
Block a user