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:
Mattsson
2026-05-26 22:29:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 0087b7be3f
commit 32d9978f1b
207 changed files with 24187 additions and 1052 deletions
@@ -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';