Fix/chrome pdf preview csp (#572)
* feat: add option to exclude year-end closing entries in SIE export and related reports * delete docs * fix: allow Chrome's PDF viewer in verifikat document preview The /api/documents/:id/inline route shipped with `object-src 'none'` in its CSP, which blocked Chrome's built-in PDF viewer (it renders inline PDFs via an internal <embed>). Users on Chrome saw "Det här innehållet har blockerats" when expanding a PDF attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own viewer) were unaffected, and JPGs worked because <img> isn't subject to object-src. Drops the CSP for this route to the minimum needed for embeddability: `frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the fixed Content-Type from the handler already block MIME confusion; X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auth): add webmail deep link to email confirmation screens Mirrors Stripe's signup UX: after asking the user to verify their email, detect their webmail provider from the domain and show a button that opens the inbox in a new tab. Gmail gets a from:<sender> search pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly. Unknown / custom domains fall back to the existing copy. Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM (default noreply@gnubok.se) so white-label installs can match their Supabase Auth SMTP config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): unblock first-time password set for BankID users with MFA Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session is required" whenever a TOTP factor is enrolled. BankID magic-link logins produce AAL1, and middleware skips MFA enforcement for bankid_linked users, so they had no path to AAL2 — leaving them unable to set a backup password or disable MFA without going through the email-recovery escape hatch. - /api/account/password: branch on app_metadata.has_password. First-time set writes via service.auth.admin.updateUserById (no existing credential to protect, AAL2 guard does not apply). Change-password keeps the user-session updateUser so AAL2 still fires for credential rotation. - /mfa/verify: accept a safeReturnTo query param and route there after successful verify, so step-up flows can land back where they came from. - SecuritySettings: detect the AAL2 error from both change-password and mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account instead of toasting a dead-end error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add tests and rounding utility for öre precision in bokslut calculations - Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations. - Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries. - Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency. - Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies. - Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios. * fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility * fix: enhance security by rejecting data URIs in safeReturnTo function tests * fix: improve rounding logic in roundOre function and add customer_type migration * fix: add customer_type column to customers and enforce CHECK constraint --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0087b7be3f
commit
32d9978f1b
@@ -0,0 +1,24 @@
|
||||
-- Add customer_type to customers.
|
||||
--
|
||||
-- Distinguishes individual customers (private persons, ROT/RUT eligible),
|
||||
-- Swedish businesses, EU businesses (VIES VAT validation eligible), and
|
||||
-- non-EU businesses. Drives VAT treatment selection on invoices and which
|
||||
-- identifier (personnummer vs org_number) the UI surfaces.
|
||||
--
|
||||
-- Note: this column was previously created directly in production without
|
||||
-- a migration. This migration backfills the missing definition so that
|
||||
-- staging, preview branches, and CI databases match prod. Existing prod
|
||||
-- rows already carry valid values (individual/swedish_business/eu_business/
|
||||
-- non_eu_business) so the CHECK constraint passes against current data.
|
||||
--
|
||||
-- Application validation: lib/api/schemas.ts CustomerTypeSchema enforces
|
||||
-- the enum at the API boundary; the DB constraint is defense-in-depth.
|
||||
|
||||
ALTER TABLE public.customers
|
||||
ADD COLUMN IF NOT EXISTS customer_type TEXT NOT NULL DEFAULT 'individual';
|
||||
|
||||
ALTER TABLE public.customers DROP CONSTRAINT IF EXISTS customers_customer_type_check;
|
||||
ALTER TABLE public.customers ADD CONSTRAINT customers_customer_type_check
|
||||
CHECK (customer_type IN ('individual', 'swedish_business', 'eu_business', 'non_eu_business'));
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Add vat_rate and vat_amount to invoice_items.
|
||||
--
|
||||
-- These columns hold the per-line VAT rate and computed VAT amount and
|
||||
-- back the mixed-rate invoice support documented in CLAUDE.md
|
||||
-- (generatePerRateLines + getAvailableVatRates). They existed in
|
||||
-- production since the early invoicing work but were never declared by
|
||||
-- a migration, so staging, preview branches, and CI databases booted
|
||||
-- from a fresh migration replay were missing them.
|
||||
--
|
||||
-- Defaults match the prod shape: vat_rate defaults to 25 (Swedish
|
||||
-- standard rate), vat_amount defaults to 0 (lines are recomputed by the
|
||||
-- engine before persistence). Both NOT NULL because every committed
|
||||
-- invoice line must carry a deterministic VAT figure for VAT-declaration
|
||||
-- ruta mapping.
|
||||
--
|
||||
-- Application validation: lib/invoices/vat-rules.ts caps vat_rate to
|
||||
-- the rates allowed for the customer type at the API boundary.
|
||||
|
||||
ALTER TABLE public.invoice_items
|
||||
ADD COLUMN IF NOT EXISTS vat_rate NUMERIC NOT NULL DEFAULT 25,
|
||||
ADD COLUMN IF NOT EXISTS vat_amount NUMERIC NOT NULL DEFAULT 0;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,173 @@
|
||||
-- Rewrite replace_sie_import to hard-delete the prior import's entries
|
||||
-- instead of soft-cancelling them.
|
||||
--
|
||||
-- The previous implementation (20260415000000_schema_sync.sql) only set
|
||||
-- status='cancelled' on the affected journal_entries. Cancelled rows kept
|
||||
-- their voucher_numbers in the partial unique index
|
||||
-- uq_journal_entries_voucher_number, and voucher_sequences.last_number was
|
||||
-- never reset, so each successive re-import had to claim a fresh, higher
|
||||
-- range (A27, A28...) and any document the user had attached was left
|
||||
-- pinned to a now-invisible cancelled entry.
|
||||
--
|
||||
-- The new behaviour mirrors the manual cleanup we use for stuck tenants:
|
||||
-- 1. detach documents (PDFs stay in storage, become unlinked)
|
||||
-- 2. clear fiscal_periods.opening_balance_entry_id if it pointed to this
|
||||
-- import's OB entry (two-step around enforce_opening_balance_immutability)
|
||||
-- 3. clear sie_imports.opening_balance_entry_id on the replaced row
|
||||
-- 4. hard-delete all source_type='import' entries (posted OR previously
|
||||
-- cancelled) in the period; lines cascade
|
||||
-- 5. reset voucher_sequences.last_number per series to MAX(remaining
|
||||
-- voucher_number) or 0 -- handles interleaved manual/bank_transaction
|
||||
-- entries safely
|
||||
-- 6. mark sie_imports status='replaced', replaced_at=now()
|
||||
--
|
||||
-- Audit trail is preserved via:
|
||||
-- * sie_imports row (status, replaced_at, filename, file_hash,
|
||||
-- transactions_count, fiscal_year_start/end)
|
||||
-- * audit_log entries written automatically by the existing
|
||||
-- write_audit_log trigger on each journal_entries DELETE (old_state
|
||||
-- JSONB snapshot per row)
|
||||
--
|
||||
-- The gnubok.allow_delete='true' GUC is set transaction-local via
|
||||
-- set_config(..., is_local=true) and is honored by:
|
||||
-- * enforce_journal_entry_immutability (allows DELETE)
|
||||
-- * enforce_journal_entry_line_immutability (allows cascade DELETE)
|
||||
-- * enforce_retention_journal_entries (allows DELETE within 7y window)
|
||||
-- * enforce_document_journal_entry_immutability (allows clearing
|
||||
-- journal_entry_id on document_attachments)
|
||||
-- * enforce_document_metadata_immutability (allows metadata change on
|
||||
-- docs linked to posted entries)
|
||||
--
|
||||
-- enforce_opening_balance_immutability does NOT honor the GUC -- worked
|
||||
-- around by flipping opening_balances_set to false first, then clearing
|
||||
-- opening_balance_entry_id in a separate UPDATE.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.replace_sie_import(p_company_id uuid, p_import_id uuid)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_fiscal_period_id uuid;
|
||||
v_opening_balance_entry_id uuid;
|
||||
v_is_closed boolean;
|
||||
v_locked_at timestamptz;
|
||||
v_deleted integer := 0;
|
||||
BEGIN
|
||||
SELECT fiscal_period_id, opening_balance_entry_id
|
||||
INTO v_fiscal_period_id, v_opening_balance_entry_id
|
||||
FROM public.sie_imports
|
||||
WHERE id = p_import_id
|
||||
AND company_id = p_company_id
|
||||
AND status = 'completed';
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Import % not found or not in completed status', p_import_id;
|
||||
END IF;
|
||||
|
||||
IF v_fiscal_period_id IS NOT NULL THEN
|
||||
SELECT is_closed, locked_at
|
||||
INTO v_is_closed, v_locked_at
|
||||
FROM public.fiscal_periods
|
||||
WHERE id = v_fiscal_period_id;
|
||||
|
||||
IF v_is_closed OR v_locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot replace SIE import in a locked or closed fiscal period';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
-- Detach any documents the user attached to the import's entries.
|
||||
-- Files stay in Supabase storage; the document rows become unlinked
|
||||
-- and can be re-attached after the next import. We cover both
|
||||
-- entry-level and line-level attachments because both FKs are RESTRICT
|
||||
-- and the line variant would otherwise block the cascade delete below.
|
||||
UPDATE public.document_attachments
|
||||
SET journal_entry_id = NULL,
|
||||
journal_entry_line_id = NULL
|
||||
WHERE journal_entry_id IN (
|
||||
SELECT je.id
|
||||
FROM public.journal_entries je
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.fiscal_period_id = v_fiscal_period_id
|
||||
AND je.source_type = 'import'
|
||||
AND je.status IN ('posted', 'cancelled')
|
||||
)
|
||||
OR journal_entry_line_id IN (
|
||||
SELECT jel.id
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.fiscal_period_id = v_fiscal_period_id
|
||||
AND je.source_type = 'import'
|
||||
AND je.status IN ('posted', 'cancelled')
|
||||
);
|
||||
|
||||
-- Clear the fiscal-period OB pointer (if it came from this import).
|
||||
-- enforce_opening_balance_immutability blocks the change unless we
|
||||
-- flip opening_balances_set to false in a separate statement first --
|
||||
-- the trigger only raises when both opening_balances_set was true AND
|
||||
-- the id is being changed in the same UPDATE.
|
||||
IF v_opening_balance_entry_id IS NOT NULL THEN
|
||||
UPDATE public.fiscal_periods
|
||||
SET opening_balances_set = false
|
||||
WHERE id = v_fiscal_period_id
|
||||
AND opening_balance_entry_id = v_opening_balance_entry_id;
|
||||
|
||||
UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE id = v_fiscal_period_id
|
||||
AND opening_balance_entry_id = v_opening_balance_entry_id;
|
||||
END IF;
|
||||
|
||||
-- Drop the sie_imports -> opening_balance_entry FK before we delete the
|
||||
-- entry it points to (FK is SET NULL on delete, but explicit clear is
|
||||
-- clearer and avoids relying on cascade ordering).
|
||||
UPDATE public.sie_imports
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE id = p_import_id;
|
||||
|
||||
-- Hard-delete the import's journal entries. Lines cascade. The
|
||||
-- 'cancelled' predicate vacuums stragglers from any prior soft-replace
|
||||
-- so re-fixing a doubly-replaced period also cleans up the residue.
|
||||
WITH deleted AS (
|
||||
DELETE FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_fiscal_period_id
|
||||
AND source_type = 'import'
|
||||
AND status IN ('posted', 'cancelled')
|
||||
RETURNING id
|
||||
)
|
||||
SELECT count(*) INTO v_deleted FROM deleted;
|
||||
|
||||
-- Reset voucher_sequences for the period. For each series, set
|
||||
-- last_number to the max remaining voucher_number (or 0 if none) so
|
||||
-- the next next_voucher_number() call yields the right number whether
|
||||
-- the user re-imports straight away (starts at 1) or interleaved
|
||||
-- manual entries already occupy higher numbers in the series.
|
||||
UPDATE public.voucher_sequences vs
|
||||
SET last_number = COALESCE((
|
||||
SELECT MAX(je.voucher_number)
|
||||
FROM public.journal_entries je
|
||||
WHERE je.company_id = vs.company_id
|
||||
AND je.fiscal_period_id = vs.fiscal_period_id
|
||||
AND je.voucher_series = vs.voucher_series
|
||||
AND je.voucher_number > 0
|
||||
), 0),
|
||||
updated_at = now()
|
||||
WHERE vs.company_id = p_company_id
|
||||
AND vs.fiscal_period_id = v_fiscal_period_id;
|
||||
|
||||
UPDATE public.sie_imports
|
||||
SET status = 'replaced',
|
||||
replaced_at = now()
|
||||
WHERE id = p_import_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
RETURN v_deleted;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Migration: extend depreciation_method enum to allow 'restvardesavskrivning_25'
|
||||
-- and add restvarde_target column on assets.
|
||||
--
|
||||
-- Background: the 20260516120000_assets_and_depreciation.sql migration
|
||||
-- already reserved the 'declining_balance_30' and 'declining_balance_20'
|
||||
-- enum values but the API/engine only implemented planenlig linjär
|
||||
-- avskrivning. Easy-win #3 adds the three remaining Swedish methods used in
|
||||
-- skattemässig avskrivning:
|
||||
--
|
||||
-- * declining_balance_30 — räkenskapsenlig huvudregel (IL 18 kap 13§)
|
||||
-- 30% degressivt på avskrivningsunderlag
|
||||
-- * declining_balance_20 — räkenskapsenlig kompletteringsregel
|
||||
-- (IL 18 kap 17§), 20% linjärt på anskaffning
|
||||
-- -- typically used on byggnader/markanläggning
|
||||
-- when book = tax depreciation
|
||||
-- * restvardesavskrivning_25 — fallback method (IL 18 kap 13§ st.3) when
|
||||
-- the räkenskapsenlig requirements (ordnad
|
||||
-- bokföring + book=tax) cannot be met. Capped
|
||||
-- at 25% declining; the asset never fully
|
||||
-- depreciates so a restvärde_target is required
|
||||
-- so we know when to stop charging.
|
||||
--
|
||||
-- restvarde_target stores the explicit floor for restvärdeavskrivning. It is
|
||||
-- distinct from the existing `salvage_value` column (which is the salvage
|
||||
-- subtracted from the linear depreciable base) because the restvärde method
|
||||
-- caps the *remaining book value* at the target rather than reducing the
|
||||
-- depreciable base.
|
||||
|
||||
-- Replace the existing CHECK constraint to add 'restvardesavskrivning_25'.
|
||||
-- The constraint name is auto-generated; drop by re-listing the original
|
||||
-- definition. We use the column-level approach (DROP/ADD CONSTRAINT) so we
|
||||
-- don't rely on the catalog name.
|
||||
ALTER TABLE public.assets
|
||||
DROP CONSTRAINT IF EXISTS assets_depreciation_method_check;
|
||||
|
||||
ALTER TABLE public.assets
|
||||
ADD CONSTRAINT assets_depreciation_method_check
|
||||
CHECK (depreciation_method IN (
|
||||
'linear',
|
||||
'declining_balance_30',
|
||||
'declining_balance_20',
|
||||
'restvardesavskrivning_25'
|
||||
));
|
||||
|
||||
-- restvarde_target: the book-value floor for restvärdeavskrivning. Required
|
||||
-- iff method = 'restvardesavskrivning_25'; null otherwise.
|
||||
ALTER TABLE public.assets
|
||||
ADD COLUMN restvarde_target NUMERIC(15, 2) NULL CHECK (restvarde_target IS NULL OR restvarde_target >= 0);
|
||||
|
||||
-- Enforce the "required iff" relationship between method and restvarde_target.
|
||||
-- The biconditional avoids two failure modes:
|
||||
-- 1. Method = restvärde but no target → engine would loop charging 25% of
|
||||
-- the remaining book value with no floor (asymptotic to zero).
|
||||
-- 2. Target set but method != restvärde → dead column; misleading state if
|
||||
-- the user switches methods later.
|
||||
ALTER TABLE public.assets
|
||||
ADD CONSTRAINT assets_restvarde_target_method_match
|
||||
CHECK (
|
||||
(depreciation_method = 'restvardesavskrivning_25') = (restvarde_target IS NOT NULL)
|
||||
);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Per-company invoice branding: primary/accent color, font family, optional
|
||||
-- header/footer text. Applied to the invoice PDF and the customer email.
|
||||
--
|
||||
-- Defaults preserve current behavior:
|
||||
-- - primary color #1a1a1a (the existing PDF heading color)
|
||||
-- - accent color #666666 (the existing muted label color)
|
||||
-- - font family 'Helvetica' (the existing react-pdf built-in)
|
||||
--
|
||||
-- Font allowlist is restricted to the three react-pdf built-in PostScript
|
||||
-- fonts (Helvetica, Times-Roman, Courier). This keeps us AGPL-clean — no
|
||||
-- proprietary font binaries are bundled or fetched at render time.
|
||||
--
|
||||
-- Hex color format is enforced with a regex CHECK so invalid colors never
|
||||
-- reach the PDF renderer (which would silently fall back to black).
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN IF NOT EXISTS invoice_primary_color TEXT NOT NULL DEFAULT '#1a1a1a',
|
||||
ADD COLUMN IF NOT EXISTS invoice_accent_color TEXT NOT NULL DEFAULT '#666666',
|
||||
ADD COLUMN IF NOT EXISTS invoice_font_family TEXT NOT NULL DEFAULT 'Helvetica',
|
||||
ADD COLUMN IF NOT EXISTS invoice_header_text TEXT NULL,
|
||||
ADD COLUMN IF NOT EXISTS invoice_footer_text TEXT NULL;
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
DROP CONSTRAINT IF EXISTS company_settings_invoice_font_check;
|
||||
ALTER TABLE public.company_settings
|
||||
ADD CONSTRAINT company_settings_invoice_font_check
|
||||
CHECK (invoice_font_family IN ('Helvetica', 'Times-Roman', 'Courier'));
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
DROP CONSTRAINT IF EXISTS company_settings_primary_color_format;
|
||||
ALTER TABLE public.company_settings
|
||||
ADD CONSTRAINT company_settings_primary_color_format
|
||||
CHECK (invoice_primary_color ~ '^#[0-9A-Fa-f]{6}$');
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
DROP CONSTRAINT IF EXISTS company_settings_accent_color_format;
|
||||
ALTER TABLE public.company_settings
|
||||
ADD CONSTRAINT company_settings_accent_color_format
|
||||
CHECK (invoice_accent_color ~ '^#[0-9A-Fa-f]{6}$');
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Asset disposal: VAT + jämkning (Item 2)
|
||||
--
|
||||
-- Extends the assets table with the data needed to post a Swedish-compliant
|
||||
-- avyttring:
|
||||
--
|
||||
-- 1) Output VAT on proceeds (ML 3 kap 3 § / 7 kap 3 §).
|
||||
-- When an anläggningstillgång that had right-to-deduct VAT on
|
||||
-- acquisition is sold to a VAT-registered domestic counterparty,
|
||||
-- proceeds are momspliktig at the rate corresponding to the asset's VAT
|
||||
-- treatment. The two new columns capture the VAT amount and the
|
||||
-- treatment that was applied so SIE export, BAS 26xx reports, and audit
|
||||
-- trail all carry the same data.
|
||||
--
|
||||
-- 2) Jämkning (ML 8a kap 4-7 §§, formerly ML 9 kap 8-11 §§ pre-2023).
|
||||
-- When an asset that had input VAT deducted at acquisition is disposed
|
||||
-- of within the korrigeringstid (5 år / 60 mån för lös egendom, 10 år /
|
||||
-- 120 mån för fastighet och markanläggning), part of the original input
|
||||
-- VAT must be paid back. The four jamkning_* columns record the inputs
|
||||
-- to the calculation so the audit trail shows how the number was
|
||||
-- arrived at — purely descriptive metadata; the actual booking sits on
|
||||
-- a journal entry line against 2641.
|
||||
--
|
||||
-- All new columns default to safe zeros / nulls so existing assets without
|
||||
-- disposal data continue to satisfy the schema without backfilling.
|
||||
|
||||
ALTER TABLE public.assets
|
||||
ADD COLUMN disposed_proceeds_vat NUMERIC(15, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN disposed_vat_treatment TEXT NULL,
|
||||
ADD COLUMN jamkning_amount NUMERIC(15, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN jamkning_remaining_months INT NULL,
|
||||
ADD COLUMN jamkning_total_months INT NULL,
|
||||
ADD COLUMN jamkning_original_input_vat NUMERIC(15, 2) NULL;
|
||||
|
||||
-- Restrict the VAT treatment to the same enum the bookkeeping engine
|
||||
-- already understands. Mirrors the VatTreatment type in lib/bookkeeping/
|
||||
-- vat-entries.ts so the API and engine stay in sync.
|
||||
ALTER TABLE public.assets
|
||||
ADD CONSTRAINT assets_disposed_vat_treatment_check
|
||||
CHECK (
|
||||
disposed_vat_treatment IS NULL OR disposed_vat_treatment IN (
|
||||
'standard_25',
|
||||
'reduced_12',
|
||||
'reduced_6',
|
||||
'reverse_charge',
|
||||
'export',
|
||||
'exempt'
|
||||
)
|
||||
);
|
||||
|
||||
-- Treatment is required whenever a VAT amount is recorded. A nonzero
|
||||
-- proceeds_vat without an explicit treatment would be impossible to map
|
||||
-- back to a BAS 26xx account at SIE export / audit time.
|
||||
ALTER TABLE public.assets
|
||||
ADD CONSTRAINT assets_disposed_vat_consistency
|
||||
CHECK (
|
||||
(disposed_proceeds_vat = 0) OR (disposed_vat_treatment IS NOT NULL)
|
||||
);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Dröjsmålsränta + lagstadgad påminnelseavgift on invoice reminders.
|
||||
--
|
||||
-- When a payment reminder is sent we now compute:
|
||||
-- 1) Statutory late-payment interest per Räntelagen §6
|
||||
-- (Riksbankens referensränta + 8 procentenheter, or company override)
|
||||
-- 2) The lagstadgad påminnelseavgift (default 60 kr per Lag 1981:739)
|
||||
--
|
||||
-- The fee is booked as a journal entry (debit 1510 Kundfordringar,
|
||||
-- credit 3990 Övriga ersättningar, bidrag och intäkter) so it shows up
|
||||
-- on the customer's open balance and recognises the income on the income
|
||||
-- statement. The interest is computed and persisted for display in the
|
||||
-- email + on the public action page; we do NOT book interest as a
|
||||
-- journal entry on reminder send — interest is recognised on payment
|
||||
-- (when the customer actually pays the surcharge) to avoid recognising
|
||||
-- revenue we may never collect.
|
||||
--
|
||||
-- Columns:
|
||||
-- invoice_reminders.interest_amount — computed dröjsmålsränta (SEK)
|
||||
-- invoice_reminders.interest_rate — annual rate applied (e.g. 0.115 = 11.5%)
|
||||
-- invoice_reminders.interest_from_date — start date for interest calc (= invoice due_date)
|
||||
-- invoice_reminders.interest_days — number of overdue days used in the calc
|
||||
-- invoice_reminders.reminder_fee — lagstadgad påminnelseavgift booked
|
||||
-- invoice_reminders.fee_journal_entry_id — link to the verifikation that booked the fee
|
||||
--
|
||||
-- company_settings.reminder_fee_enabled — kill switch for the fee
|
||||
-- company_settings.reminder_fee_amount — default 60 kr (statutory cap, Lag 1981:739)
|
||||
-- company_settings.reminder_interest_rate_override — null = use Räntelagen §6 lookup
|
||||
--
|
||||
-- We also extend journal_entries.source_type to allow 'reminder_fee'
|
||||
-- so the fee posting passes the CHECK constraint.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- invoice_reminders: new columns for interest + fee
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE public.invoice_reminders
|
||||
ADD COLUMN IF NOT EXISTS interest_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS interest_rate NUMERIC(6,4) NULL,
|
||||
ADD COLUMN IF NOT EXISTS interest_from_date DATE NULL,
|
||||
ADD COLUMN IF NOT EXISTS interest_days INT NULL,
|
||||
ADD COLUMN IF NOT EXISTS reminder_fee NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS fee_journal_entry_id UUID NULL
|
||||
REFERENCES public.journal_entries(id) ON DELETE SET NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- company_settings: per-company toggles + override
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN IF NOT EXISTS reminder_fee_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ADD COLUMN IF NOT EXISTS reminder_fee_amount NUMERIC(10,2) NOT NULL DEFAULT 60,
|
||||
ADD COLUMN IF NOT EXISTS reminder_interest_rate_override NUMERIC(6,4) NULL;
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
DROP CONSTRAINT IF EXISTS company_settings_reminder_fee_check;
|
||||
ALTER TABLE public.company_settings
|
||||
ADD CONSTRAINT company_settings_reminder_fee_check
|
||||
CHECK (reminder_fee_amount >= 0);
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
DROP CONSTRAINT IF EXISTS company_settings_reminder_interest_override_check;
|
||||
ALTER TABLE public.company_settings
|
||||
ADD CONSTRAINT company_settings_reminder_interest_override_check
|
||||
CHECK (
|
||||
reminder_interest_rate_override IS NULL
|
||||
OR (reminder_interest_rate_override >= 0 AND reminder_interest_rate_override < 1)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- journal_entries: extend source_type to include 'reminder_fee'
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- See 20260516060000 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'
|
||||
));
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Migration: Voucher series defaults per source type
|
||||
-- Item 8 of make-a-detailed-plan-fancy-pie.md
|
||||
--
|
||||
-- The schema and RPCs already support multi-series voucher numbering
|
||||
-- (voucher_sequences.voucher_series, journal_entries.voucher_series,
|
||||
-- next_voucher_number(p_series)). This migration exposes the per-source
|
||||
-- defaulting mapping in company_settings so users can configure which
|
||||
-- series each journal_entries.source_type lands on by default.
|
||||
--
|
||||
-- All values default to 'A' to preserve current behavior. Common Swedish
|
||||
-- conventions (B for supplier invoices, C for salaries) can be applied
|
||||
-- by the user via the settings UI without a code change.
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN default_voucher_series_per_source_type JSONB NOT NULL 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"
|
||||
}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN public.company_settings.default_voucher_series_per_source_type IS
|
||||
'Maps journal_entries.source_type -> default voucher_series (single uppercase letter A-Z). Read by lib/bookkeeping/voucher-series-resolver.ts; written via /api/settings. Defaults to all "A" to preserve legacy single-series behaviour. Common Swedish conventions: supplier_invoice_* -> "B", salary_payment -> "C".';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,116 @@
|
||||
-- =============================================================================
|
||||
-- OB-tillägg & övertid: shift-premium automation
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Adds:
|
||||
-- 1. New salary_line_items.item_type values for shift premiums (OB) and
|
||||
-- tiered overtime (övertid 50 %/100 %).
|
||||
-- 2. Optional start_time / end_time columns on salary_worked_days so the
|
||||
-- calculator can match per-shift windows against premium rules. Legacy
|
||||
-- rows (NULL times) fall back to a default-shift assumption inside the
|
||||
-- engine (08:00–17:00) — pure-night/weekend rules will not trigger for
|
||||
-- hours-only days, which mirrors how those rows were intended to behave
|
||||
-- before this migration.
|
||||
-- 3. shift_premium_rules — per-company configuration of when and how much
|
||||
-- to top up the base hourly rate. Either applies to all employees or a
|
||||
-- filtered list. Multiple rules may match a shift; the engine prefers
|
||||
-- higher priority and tie-breaks on higher premium_percent. ISO weekday
|
||||
-- encoding (1 = Monday … 7 = Sunday) matches PostgreSQL's
|
||||
-- extract(isodow from date), so server-side queries can filter natively.
|
||||
--
|
||||
-- CHECK migration note: the new item_type CHECK preserves every existing
|
||||
-- value (including gross_deduction_*, net_deduction_advance/benefit_payment,
|
||||
-- semesterersattning, benefit_bike). Adding values without listing the
|
||||
-- originals would drop rows on commit.
|
||||
|
||||
ALTER TABLE public.salary_line_items DROP CONSTRAINT IF EXISTS salary_line_items_item_type_check;
|
||||
|
||||
ALTER TABLE public.salary_line_items
|
||||
ADD CONSTRAINT salary_line_items_item_type_check
|
||||
CHECK (item_type IN (
|
||||
'monthly_salary', 'hourly_salary',
|
||||
'overtime', 'overtime_50', 'overtime_100',
|
||||
'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
|
||||
'bonus', 'commission',
|
||||
'gross_deduction_pension', 'gross_deduction_other',
|
||||
'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other',
|
||||
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
|
||||
'vab', 'parental_leave', 'vacation', 'semesterersattning',
|
||||
'traktamente_taxfree', 'traktamente_taxable',
|
||||
'mileage_taxfree', 'mileage_taxable',
|
||||
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment', 'net_deduction_other',
|
||||
'correction', 'other'
|
||||
));
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Per-shift time columns on salary_worked_days
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Nullable so existing hours-only rows continue to work. When both times are
|
||||
-- set the engine uses the explicit overlap with the rule window; when either
|
||||
-- is NULL the engine falls back to a default-shift assumption.
|
||||
|
||||
ALTER TABLE public.salary_worked_days
|
||||
ADD COLUMN IF NOT EXISTS start_time TIME NULL,
|
||||
ADD COLUMN IF NOT EXISTS end_time TIME NULL;
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- shift_premium_rules
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.shift_premium_rules (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
applies_to_all_employees BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
applies_to_employee_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
-- ISO weekday array: 1 = Monday … 7 = Sunday. Matches extract(isodow from x).
|
||||
day_of_week INT[] NOT NULL,
|
||||
start_time TIME NOT NULL,
|
||||
end_time TIME NOT NULL,
|
||||
premium_percent NUMERIC(5, 2) NOT NULL
|
||||
CHECK (premium_percent >= 0 AND premium_percent <= 500),
|
||||
item_type TEXT NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
-- Only allow the dedicated premium types here. 'overtime' (untyped) is
|
||||
-- excluded because manually-flagged overtime line items do not need a rule;
|
||||
-- premium rules are exclusively for the new tiered + OB families.
|
||||
CONSTRAINT shift_premium_rules_item_type_check CHECK (item_type IN (
|
||||
'overtime_50', 'overtime_100',
|
||||
'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday'
|
||||
)),
|
||||
-- Day-of-week array must contain at least one ISO day in [1, 7].
|
||||
CONSTRAINT shift_premium_rules_day_of_week_check CHECK (
|
||||
array_length(day_of_week, 1) >= 1
|
||||
AND array_length(day_of_week, 1) <= 7
|
||||
AND day_of_week <@ ARRAY[1, 2, 3, 4, 5, 6, 7]
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shift_premium_rules_company
|
||||
ON public.shift_premium_rules (company_id)
|
||||
WHERE is_active = TRUE;
|
||||
|
||||
ALTER TABLE public.shift_premium_rules ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "shift_premium_rules_select" ON public.shift_premium_rules
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
CREATE POLICY "shift_premium_rules_insert" ON public.shift_premium_rules
|
||||
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
CREATE POLICY "shift_premium_rules_update" ON public.shift_premium_rules
|
||||
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
|
||||
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
CREATE POLICY "shift_premium_rules_delete" ON public.shift_premium_rules
|
||||
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
CREATE TRIGGER shift_premium_rules_updated_at
|
||||
BEFORE UPDATE ON public.shift_premium_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Add accounting_framework to companies.
|
||||
--
|
||||
-- Swedish AB can apply either K2 (BFNAR 2016:10, simplified ruleset for
|
||||
-- mindre företag) or K3 (BFNAR 2012:1, full principles-based). K2 is the
|
||||
-- default and what most of our customers use. K3 becomes mandatory when the
|
||||
-- company crosses any two of the three K2 thresholds (turnover >80 MSEK,
|
||||
-- assets >40 MSEK, employees >50) and is permitted earlier on a voluntary
|
||||
-- basis. The choice has substantial downstream effects (component
|
||||
-- depreciation, latent tax recognition, cash flow statement, more noter).
|
||||
--
|
||||
-- Only meaningful for entity_type='aktiebolag'. Enskild firma stays on the
|
||||
-- simpler EF rules and does not consume this column. The application keeps
|
||||
-- the K3 settings UI hidden for EF entities.
|
||||
--
|
||||
-- Default 'k2' so existing AB rows keep the current behavior (the engine and
|
||||
-- arsredovisning builder were written against K2 assumptions). Switching
|
||||
-- K2 → K3 is a deliberate user action gated by a confirmation dialog.
|
||||
|
||||
ALTER TABLE public.companies
|
||||
ADD COLUMN accounting_framework TEXT NOT NULL DEFAULT 'k2'
|
||||
CHECK (accounting_framework IN ('k2', 'k3'));
|
||||
|
||||
COMMENT ON COLUMN public.companies.accounting_framework IS
|
||||
'Swedish accounting framework. K2 (BFNAR 2016:10) is the simplified ruleset for small AB and the default. K3 (BFNAR 2012:1) is required for medium-to-large AB and required-when-larger-than-K2-thresholds. Only meaningful for entity_type=aktiebolag.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Latent tax (uppskjuten skatt) — placeholder migration for K3 framework.
|
||||
--
|
||||
-- The chart-of-accounts already seeds the two relevant BAS 2026 accounts via
|
||||
-- seed_chart_of_accounts() when a company is created:
|
||||
--
|
||||
-- 2240 Avsättningar för uppskjutna skatter (liability, K3-only)
|
||||
-- 8940 Uppskjuten skatt (expense, K3-only)
|
||||
--
|
||||
-- Both rows in lib/bookkeeping/bas-data/* carry k2_excluded=true so K2
|
||||
-- companies never see them in their chart. When an AB switches to K3 the
|
||||
-- application layer (settings handler) is responsible for inserting these
|
||||
-- two rows into chart_of_accounts for the company if they are not already
|
||||
-- present. We deliberately do NOT backfill them in this migration because
|
||||
-- (a) existing AB rows still default to K2 — those companies do not need
|
||||
-- the accounts until they opt-in, and (b) opt-in is a deliberate user
|
||||
-- action that should drive the seed, not a one-off DDL run.
|
||||
--
|
||||
-- This file exists to keep migration timestamps sequential alongside
|
||||
-- 20260526121500_k3_framework.sql, and to document the BAS account choice
|
||||
-- so future maintainers can find it.
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,64 @@
|
||||
-- ROT/RUT-avdrag on invoices.
|
||||
--
|
||||
-- Adds per-item deduction flags (ROT or RUT) and invoice-level claim info
|
||||
-- (customer personnummer + housing designation). The system computes the
|
||||
-- deduction amount per item and posts a receivable from Skatteverket on
|
||||
-- BAS 1513 (Övriga kortfristiga fordringar — kund / Skatteverket). The
|
||||
-- customer pays only the post-deduction amount; Skatteverket pays the
|
||||
-- rest later via Husavdragstjänsten (XML/SOAP submission is out of scope
|
||||
-- for v1 — this migration only enables the booking + PDF rendering).
|
||||
--
|
||||
-- Personnummer is sensitive PII. We never store it in plaintext: only
|
||||
-- the AES-256-GCM ciphertext (`deduction_personnummer_encrypted`) plus
|
||||
-- the last four digits (`deduction_personnummer_last4`) for display.
|
||||
-- The encryption helper lives at lib/salary/personnummer.ts and is
|
||||
-- reused unchanged for ROT/RUT.
|
||||
--
|
||||
-- Cross-table consistency (if any item carries deduction_type, the
|
||||
-- invoice MUST carry an encrypted personnummer; ROT specifically also
|
||||
-- requires housing_designation) is enforced at the API layer via Zod
|
||||
-- and lib/invoices/rot-rut-rules.ts — Postgres CHECK constraints cannot
|
||||
-- span tables without expensive triggers, and the API path is the only
|
||||
-- way to create an invoice with deduction lines (per-row writes via
|
||||
-- service role still go through the same engine).
|
||||
|
||||
-- 1. Per-item deduction columns.
|
||||
ALTER TABLE public.invoice_items
|
||||
ADD COLUMN IF NOT EXISTS deduction_type TEXT NULL,
|
||||
ADD COLUMN IF NOT EXISTS deduction_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS labor_hours NUMERIC(5,2) NULL,
|
||||
ADD COLUMN IF NOT EXISTS work_type TEXT NULL,
|
||||
ADD COLUMN IF NOT EXISTS housing_designation TEXT NULL,
|
||||
ADD COLUMN IF NOT EXISTS apartment_number TEXT NULL;
|
||||
|
||||
ALTER TABLE public.invoice_items DROP CONSTRAINT IF EXISTS invoice_items_deduction_type_check;
|
||||
ALTER TABLE public.invoice_items ADD CONSTRAINT invoice_items_deduction_type_check
|
||||
CHECK (deduction_type IS NULL OR deduction_type IN ('rot', 'rut'));
|
||||
|
||||
-- The deduction amount must be non-negative; computed at API layer and
|
||||
-- capped at the line total to stop a tampered request from creating a
|
||||
-- larger receivable than the invoice itself.
|
||||
ALTER TABLE public.invoice_items DROP CONSTRAINT IF EXISTS invoice_items_deduction_amount_check;
|
||||
ALTER TABLE public.invoice_items ADD CONSTRAINT invoice_items_deduction_amount_check
|
||||
CHECK (deduction_amount >= 0);
|
||||
|
||||
-- 2. Invoice-level totals and claim info.
|
||||
ALTER TABLE public.invoices
|
||||
ADD COLUMN IF NOT EXISTS deduction_total NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS deduction_personnummer_encrypted TEXT NULL,
|
||||
ADD COLUMN IF NOT EXISTS deduction_personnummer_last4 TEXT NULL;
|
||||
|
||||
ALTER TABLE public.invoices DROP CONSTRAINT IF EXISTS invoices_deduction_total_check;
|
||||
ALTER TABLE public.invoices ADD CONSTRAINT invoices_deduction_total_check
|
||||
CHECK (deduction_total >= 0);
|
||||
|
||||
-- 3. Helpful indexes for the common queries.
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_items_deduction_type
|
||||
ON public.invoice_items (deduction_type)
|
||||
WHERE deduction_type IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_deduction_total
|
||||
ON public.invoices (company_id, deduction_total)
|
||||
WHERE deduction_total > 0;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Migration: K3 component depreciation (komponentavskrivning)
|
||||
--
|
||||
-- BFNAR 2012:1 ch.17.4 requires K3-reporting entities to apply the
|
||||
-- "component approach" to substantial fixed assets (real estate is the
|
||||
-- canonical case): when significant components of an asset have materially
|
||||
-- different useful lives, each component must be depreciated separately
|
||||
-- on its own life. K2 (BFNAR 2016:10) has no such requirement and treats
|
||||
-- the asset as a single unit.
|
||||
--
|
||||
-- The `k3_components` JSONB column was already added by the original
|
||||
-- assets migration (20260516120000_assets_and_depreciation.sql) as a
|
||||
-- reserved placeholder. This migration now activates the column by
|
||||
-- documenting the shape via COMMENT and signalling the engine support.
|
||||
-- The structural shape stays:
|
||||
-- [{ name: string, cost: number, useful_life_months: number,
|
||||
-- salvage_value?: number }]
|
||||
--
|
||||
-- Validation rules (enforced application-side via Zod, not DB CHECK,
|
||||
-- because validating sum(cost) == acquisition_cost involves cross-column
|
||||
-- math that PostgreSQL CHECKs cannot express on JSONB):
|
||||
-- - sum of component costs equals acquisition_cost (±1 kr tolerance)
|
||||
-- - every component has cost > 0 and useful_life_months > 0
|
||||
-- - salvage_value (if present) ≤ component cost
|
||||
-- - array must be non-empty when set to non-null
|
||||
--
|
||||
-- Only meaningful for companies with accounting_framework = 'k3' (added in
|
||||
-- 20260526121500_k3_framework.sql). The API layer rejects k3_components
|
||||
-- writes for K2 companies with K3_REQUIRED_FOR_COMPONENTS.
|
||||
--
|
||||
-- No schema change is needed beyond refreshing the column comment — the
|
||||
-- column itself already exists with type JSONB.
|
||||
|
||||
COMMENT ON COLUMN public.assets.k3_components IS
|
||||
'K3 BFNAR 2012:1 ch 17.4 components. When non-null, asset is depreciated per-component instead of by depreciation_method. Shape: [{ name: string, cost: number, useful_life_months: number, salvage_value?: number }]. Only meaningful for companies with accounting_framework=k3. Sum of component costs must equal acquisition_cost; enforced in application code (Zod) because the cross-column constraint cannot be expressed in a CHECK on JSONB.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Tighten reminder_fee_amount upper bound to the statutory cap.
|
||||
--
|
||||
-- Lag 1981:739 about late-payment fee caps the lagstadgad
|
||||
-- påminnelseavgift at 60 kr. The original migration only enforced
|
||||
-- `>= 0`, so a user could set 500 kr in settings and the cron would
|
||||
-- happily book it. Add a hard upper bound at the DB level — the
|
||||
-- application layer also clamps in lib/invoices/reminder-processor.ts.
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
DROP CONSTRAINT IF EXISTS company_settings_reminder_fee_check;
|
||||
|
||||
-- Clamp any rows over 60 down before re-applying the constraint.
|
||||
UPDATE public.company_settings
|
||||
SET reminder_fee_amount = 60
|
||||
WHERE reminder_fee_amount > 60;
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD CONSTRAINT company_settings_reminder_fee_check
|
||||
CHECK (reminder_fee_amount >= 0 AND reminder_fee_amount <= 60);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Make Swish disabled by default. The original migration
|
||||
-- (20260521120050_company_settings_swish.sql) shipped with DEFAULT true,
|
||||
-- which meant every company without an explicit choice saw "show Swish"
|
||||
-- toggled on — even those that had never configured a Swish number.
|
||||
--
|
||||
-- Going forward: new rows default to false. Existing rows that never
|
||||
-- configured a Swish number (swish IS NULL or '') get flipped to false
|
||||
-- too, since the toggle being on without a number is meaningless. Rows
|
||||
-- that DO have a Swish number are left alone — those companies
|
||||
-- presumably want the toggle on.
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ALTER COLUMN invoice_show_swish SET DEFAULT false;
|
||||
|
||||
UPDATE public.company_settings
|
||||
SET invoice_show_swish = false
|
||||
WHERE invoice_show_swish IS NOT FALSE
|
||||
AND (swish IS NULL OR swish = '');
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user