db8983ba9e
* 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>
336 lines
16 KiB
PL/PgSQL
336 lines
16 KiB
PL/PgSQL
-- 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';
|