Bug/gh issues fiz (#1103)

* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
This commit is contained in:
Mattsson
2026-07-21 23:00:15 +02:00
committed by GitHub
parent 702512437a
commit e11f70b347
467 changed files with 17569 additions and 2221 deletions
@@ -0,0 +1,107 @@
-- Exact kvarskatt deadlines and persisted post-company onboarding state.
--
-- Kvarskatt is not calculated from a guessed 90-day offset. Skatteverket
-- states that the exact payment due date is printed on the final tax notice,
-- and reassessment decisions can have a materially shorter payment window.
-- Store the notice date and its exact due date, then let the deadline
-- generator mirror that date without banking-day adjustment.
--
-- Oasis onboarding state belongs to the company, not localStorage. Existing
-- onboarded companies are backfilled as completed and dismissed so only newly
-- created companies see the new optional setup surface.
-- =============================================================================
-- 1. Persisted initial setup state
-- =============================================================================
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS initial_setup_path text NULL,
ADD COLUMN IF NOT EXISTS initial_setup_completed_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS initial_setup_dismissed_at timestamptz NULL;
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_initial_setup_path_check;
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_initial_setup_path_check
CHECK (initial_setup_path IS NULL OR initial_setup_path IN ('migration', 'bank', 'fresh'));
UPDATE public.company_settings
SET initial_setup_completed_at = COALESCE(initial_setup_completed_at, now()),
initial_setup_dismissed_at = COALESCE(initial_setup_dismissed_at, now())
WHERE onboarding_complete = true
AND initial_setup_completed_at IS NULL;
COMMENT ON COLUMN public.company_settings.initial_setup_path IS
'Optional first-value path selected after company creation: migration, bank, or fresh.';
COMMENT ON COLUMN public.company_settings.initial_setup_completed_at IS
'When the selected first-value setup path was completed.';
COMMENT ON COLUMN public.company_settings.initial_setup_dismissed_at IS
'When the optional dashboard setup checklist was dismissed.';
-- =============================================================================
-- 2. Final tax notices and reassessment decisions
-- =============================================================================
CREATE TABLE public.tax_assessment_notices (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT,
decision_type text NOT NULL CHECK (decision_type IN ('final', 'reassessment')),
decision_date date NOT NULL,
payment_due_date date NOT NULL,
archived_at timestamptz NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT tax_assessment_notices_due_after_decision
CHECK (payment_due_date >= decision_date),
CONSTRAINT tax_assessment_notices_company_period_type_key
UNIQUE (company_id, fiscal_period_id, decision_type)
);
ALTER TABLE public.tax_assessment_notices ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company tax_assessment_notices"
ON public.tax_assessment_notices FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company tax_assessment_notices"
ON public.tax_assessment_notices FOR INSERT
WITH CHECK (
company_id IN (SELECT user_company_ids())
AND user_id = auth.uid()
);
CREATE POLICY "update own-company tax_assessment_notices"
ON public.tax_assessment_notices FOR UPDATE
USING (company_id IN (SELECT user_company_ids()))
WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "delete own-company tax_assessment_notices"
ON public.tax_assessment_notices FOR DELETE
USING (company_id IN (SELECT user_company_ids()));
CREATE INDEX idx_tax_assessment_notices_company
ON public.tax_assessment_notices (company_id);
CREATE INDEX idx_tax_assessment_notices_due
ON public.tax_assessment_notices (company_id, payment_due_date)
WHERE archived_at IS NULL;
CREATE INDEX idx_tax_assessment_notices_period
ON public.tax_assessment_notices (fiscal_period_id);
CREATE TRIGGER set_updated_at_tax_assessment_notices
BEFORE UPDATE ON public.tax_assessment_notices
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_tax_assessment_notices
AFTER INSERT OR UPDATE OR DELETE ON public.tax_assessment_notices
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- Relational identity lets edits and archives reconcile the exact generated
-- deadline without encoding notice ids into user-facing titles.
ALTER TABLE public.deadlines
ADD COLUMN IF NOT EXISTS tax_assessment_notice_id uuid NULL
REFERENCES public.tax_assessment_notices(id) ON DELETE RESTRICT;
CREATE INDEX IF NOT EXISTS idx_deadlines_tax_assessment_notice
ON public.deadlines (tax_assessment_notice_id)
WHERE tax_assessment_notice_id IS NOT NULL;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,126 @@
-- Persist tax-only adjustments used by the year-end tax provision and INK2.
-- These rows do not create journal entries. They explain the bridge from the
-- accounting result to the taxable result for one fiscal period.
CREATE TABLE public.fiscal_period_tax_adjustments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT,
adjustment_type text NOT NULL CHECK (
adjustment_type IN ('non_deductible_expense', 'non_taxable_income')
),
source text NOT NULL CHECK (source IN ('detected', 'manual')),
source_key text NOT NULL,
description text NOT NULL,
account_number text NULL CHECK (
account_number IS NULL OR account_number ~ '^[0-9]{4}$'
),
amount numeric(15, 2) NOT NULL CHECK (amount >= 0),
included boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT fiscal_period_tax_adjustments_source_key
UNIQUE (company_id, fiscal_period_id, source_key)
);
ALTER TABLE public.fiscal_period_tax_adjustments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments FOR SELECT
USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "insert own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments FOR INSERT
WITH CHECK (
company_id IN (SELECT public.user_company_ids())
AND user_id = auth.uid()
);
CREATE POLICY "update own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments FOR UPDATE
USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (
company_id IN (SELECT public.user_company_ids())
AND user_id = auth.uid()
);
CREATE POLICY "delete own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments FOR DELETE
USING (company_id IN (SELECT public.user_company_ids()));
CREATE INDEX idx_fiscal_period_tax_adjustments_company
ON public.fiscal_period_tax_adjustments (company_id);
CREATE INDEX idx_fiscal_period_tax_adjustments_period
ON public.fiscal_period_tax_adjustments (fiscal_period_id);
CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_adjustment()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
DECLARE
adjustment_row public.fiscal_period_tax_adjustments%ROWTYPE;
period_row public.fiscal_periods%ROWTYPE;
BEGIN
IF TG_OP = 'UPDATE'
AND (
OLD.company_id IS DISTINCT FROM NEW.company_id
OR OLD.fiscal_period_id IS DISTINCT FROM NEW.fiscal_period_id
) THEN
RAISE EXCEPTION 'Tax adjustment company and fiscal period are immutable'
USING ERRCODE = '23514';
END IF;
IF TG_OP = 'DELETE' THEN
adjustment_row := OLD;
ELSE
adjustment_row := NEW;
END IF;
SELECT * INTO period_row
FROM public.fiscal_periods
WHERE id = adjustment_row.fiscal_period_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Fiscal period not found for tax adjustment'
USING ERRCODE = '23503';
END IF;
IF period_row.company_id IS DISTINCT FROM adjustment_row.company_id THEN
RAISE EXCEPTION 'Tax adjustment company does not match fiscal period company'
USING ERRCODE = '23514';
END IF;
IF period_row.is_closed
OR period_row.locked_at IS NOT NULL
OR period_row.closing_entry_id IS NOT NULL THEN
RAISE EXCEPTION 'Fiscal period is locked for tax adjustments'
USING ERRCODE = '23514';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER guard_fiscal_period_tax_adjustments
BEFORE INSERT OR UPDATE OR DELETE ON public.fiscal_period_tax_adjustments
FOR EACH ROW EXECUTE FUNCTION public.guard_fiscal_period_tax_adjustment();
CREATE TRIGGER set_updated_at_fiscal_period_tax_adjustments
BEFORE UPDATE ON public.fiscal_period_tax_adjustments
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_fiscal_period_tax_adjustments
AFTER INSERT OR UPDATE OR DELETE ON public.fiscal_period_tax_adjustments
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
COMMENT ON TABLE public.fiscal_period_tax_adjustments IS
'Tax-only adjustments bridging accounting result to taxable result for year-end tax and INK2.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,104 @@
-- Harden tax notice tenant integrity and preserve every reassessment decision.
-- A fiscal year can receive more than one reassessment decision. The decision
-- date is part of the notice identity so repeated POST requests stay
-- idempotent without overwriting an earlier decision.
ALTER TABLE public.tax_assessment_notices
DROP CONSTRAINT tax_assessment_notices_company_period_type_key;
ALTER TABLE public.tax_assessment_notices
ADD CONSTRAINT tax_assessment_notices_company_period_type_date_key
UNIQUE (company_id, fiscal_period_id, decision_type, decision_date);
-- The creator is attribution, not ownership. Company records must survive a
-- user deletion, including when a generated deadline references the notice.
ALTER TABLE public.tax_assessment_notices
ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE public.tax_assessment_notices
DROP CONSTRAINT tax_assessment_notices_user_id_fkey;
ALTER TABLE public.tax_assessment_notices
ADD CONSTRAINT tax_assessment_notices_user_id_fkey
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
-- Company-owned foreign keys must agree on company_id. The additional unique
-- keys are safe because both referenced id columns are already primary keys.
ALTER TABLE public.fiscal_periods
ADD CONSTRAINT fiscal_periods_id_company_id_key UNIQUE (id, company_id);
ALTER TABLE public.tax_assessment_notices
DROP CONSTRAINT tax_assessment_notices_fiscal_period_id_fkey;
ALTER TABLE public.tax_assessment_notices
ADD CONSTRAINT tax_assessment_notices_fiscal_period_company_fkey
FOREIGN KEY (fiscal_period_id, company_id)
REFERENCES public.fiscal_periods(id, company_id) ON DELETE RESTRICT;
ALTER TABLE public.tax_assessment_notices
ADD CONSTRAINT tax_assessment_notices_id_company_id_key UNIQUE (id, company_id);
ALTER TABLE public.deadlines
DROP CONSTRAINT deadlines_tax_assessment_notice_id_fkey;
ALTER TABLE public.deadlines
ADD CONSTRAINT deadlines_tax_assessment_notice_company_fkey
FOREIGN KEY (tax_assessment_notice_id, company_id)
REFERENCES public.tax_assessment_notices(id, company_id) ON DELETE RESTRICT;
-- Keep creator attribution immutable for authenticated PostgREST callers.
CREATE OR REPLACE FUNCTION public.enforce_tax_assessment_notice_attribution()
RETURNS trigger
LANGUAGE plpgsql
SET search_path TO 'public'
AS $function$
BEGIN
IF auth.uid() IS NOT NULL AND NEW.user_id IS DISTINCT FROM OLD.user_id THEN
RAISE EXCEPTION 'Tax assessment notice attribution is immutable'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$function$;
CREATE TRIGGER enforce_tax_assessment_notice_attribution
BEFORE UPDATE OF user_id ON public.tax_assessment_notices
FOR EACH ROW EXECUTE FUNCTION public.enforce_tax_assessment_notice_attribution();
-- Reads follow the repository-wide membership model. Writes additionally
-- require the active company and a non-viewer role, matching other tenant
-- tables and preventing direct PostgREST writes from bypassing the API guard.
DROP POLICY "insert own-company tax_assessment_notices"
ON public.tax_assessment_notices;
DROP POLICY "update own-company tax_assessment_notices"
ON public.tax_assessment_notices;
DROP POLICY "delete own-company tax_assessment_notices"
ON public.tax_assessment_notices;
CREATE POLICY tax_assessment_notices_insert
ON public.tax_assessment_notices FOR INSERT TO public
WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
AND user_id = auth.uid()
);
CREATE POLICY tax_assessment_notices_update
ON public.tax_assessment_notices FOR UPDATE TO public
USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
)
WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
CREATE POLICY tax_assessment_notices_delete
ON public.tax_assessment_notices FOR DELETE TO public
USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,127 @@
-- Repair production error root causes found in the Vercel runtime logs.
--
-- 1. Register the MCP approval event before processing_history references it.
-- 2. Add covering indexes for the two high-volume read paths.
-- 3. Aggregate account balances in Postgres instead of transferring every line.
-- 4. Page VAT source lines in Postgres instead of loading the full result set.
INSERT INTO public.processing_event_types (event_type)
VALUES ('PendingOperationApproved')
ON CONFLICT (event_type) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_audit_log_company_created_id
ON public.audit_log (company_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_journal_entries_company_posted_date_id
ON public.journal_entries (company_id, entry_date, id)
WHERE status IN ('posted', 'reversed');
CREATE OR REPLACE FUNCTION public.get_account_period_activity(
p_company_id uuid,
p_start date,
p_end date,
p_accounts text[],
p_exclude_journal_entry_id uuid DEFAULT NULL
)
RETURNS TABLE (
account_number text,
debit numeric,
credit numeric
)
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path TO 'public'
AS $$
SELECT
l.account_number,
COALESCE(sum(l.debit_amount), 0)::numeric AS debit,
COALESCE(sum(l.credit_amount), 0)::numeric AS credit
FROM public.journal_entries je
JOIN public.journal_entry_lines l ON l.journal_entry_id = je.id
WHERE je.company_id = p_company_id
AND je.status IN ('posted', 'reversed')
AND je.entry_date >= p_start
AND je.entry_date <= p_end
AND l.account_number = ANY (p_accounts)
AND (
p_exclude_journal_entry_id IS NULL
OR je.id <> p_exclude_journal_entry_id
)
GROUP BY l.account_number
ORDER BY l.account_number;
$$;
REVOKE ALL ON FUNCTION public.get_account_period_activity(uuid, date, date, text[], uuid)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_account_period_activity(uuid, date, date, text[], uuid)
TO authenticated, service_role;
CREATE OR REPLACE FUNCTION public.get_vat_ruta_source_lines(
p_company_id uuid,
p_start date,
p_end date,
p_accounts text[],
p_cursor_date date DEFAULT NULL,
p_cursor_voucher_number integer DEFAULT NULL,
p_cursor_entry_id uuid DEFAULT NULL,
p_cursor_line_id uuid DEFAULT NULL,
p_limit integer DEFAULT 501
)
RETURNS TABLE (
line_id uuid,
journal_entry_id uuid,
voucher_number integer,
voucher_series text,
entry_date date,
description text,
debit_amount numeric,
credit_amount numeric
)
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path TO 'public'
AS $$
SELECT
l.id AS line_id,
je.id AS journal_entry_id,
je.voucher_number,
COALESCE(je.voucher_series, 'A') AS voucher_series,
je.entry_date,
COALESCE(je.description, '') AS description,
l.debit_amount,
l.credit_amount
FROM public.journal_entries je
JOIN public.journal_entry_lines l ON l.journal_entry_id = je.id
WHERE je.company_id = p_company_id
AND je.status IN ('posted', 'reversed')
AND je.entry_date >= p_start
AND je.entry_date <= p_end
AND l.account_number = ANY (p_accounts)
AND (
p_cursor_date IS NULL
OR (
je.entry_date,
je.voucher_number,
je.id,
l.id
) > (
p_cursor_date,
p_cursor_voucher_number,
COALESCE(p_cursor_entry_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid),
COALESCE(p_cursor_line_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid)
)
)
ORDER BY je.entry_date, je.voucher_number, je.id, l.id
LIMIT LEAST(GREATEST(COALESCE(p_limit, 501), 1), 501);
$$;
REVOKE ALL ON FUNCTION public.get_vat_ruta_source_lines(
uuid, date, date, text[], date, integer, uuid, uuid, integer
) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_vat_ruta_source_lines(
uuid, date, date, text[], date, integer, uuid, uuid, integer
) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,69 @@
-- Add update_company_settings to the pending_operations operation type CHECK.
-- The operation changes payment-routing details shown on future invoices and
-- therefore stages at medium risk for explicit approval.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher',
'submit_vat_declaration',
'submit_agi',
'create_article',
'update_article',
'bulk_book_inbox_items',
'create_dimension_value',
'retag_line_dimensions',
'link_document_to_voucher',
'update_payslip_line',
'register_absence',
'create_employee',
'update_employee',
'set_employee_opening_balances',
'vacation_year_close',
'create_account',
'update_account',
'set_voucher_note',
'book_salary_run',
'delete_absence',
'update_company_settings'
)) NOT VALID;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,6 @@
-- Validate the operation type CHECK re-added in 20260721110750.
-- This separate transaction avoids a full-table scan while the preceding
-- migration holds its stronger table lock.
ALTER TABLE public.pending_operations
VALIDATE CONSTRAINT pending_operations_operation_type_check;
@@ -0,0 +1,70 @@
-- Add update_customer to the pending_operations operation type CHECK.
-- Customer master-data changes are staged at low risk and remain subject to
-- explicit approval unless the company has opted into trusted auto-commit.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher',
'submit_vat_declaration',
'submit_agi',
'create_article',
'update_article',
'bulk_book_inbox_items',
'create_dimension_value',
'retag_line_dimensions',
'link_document_to_voucher',
'update_payslip_line',
'register_absence',
'create_employee',
'update_employee',
'set_employee_opening_balances',
'vacation_year_close',
'create_account',
'update_account',
'set_voucher_note',
'book_salary_run',
'delete_absence',
'update_company_settings',
'update_customer'
)) NOT VALID;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,6 @@
-- Validate the operation type CHECK re-added in 20260721111331.
-- This separate transaction avoids a full-table scan while the preceding
-- migration holds its stronger table lock.
ALTER TABLE public.pending_operations
VALIDATE CONSTRAINT pending_operations_operation_type_check;
@@ -0,0 +1,34 @@
-- Keep API-key security and configuration changes in behandlingshistorik, but
-- do not create a full audit row for every authenticated API or MCP request.
-- validate_and_increment_api_key updates only these four telemetry columns.
DROP TRIGGER IF EXISTS audit_api_keys_update ON public.api_keys;
DROP TRIGGER IF EXISTS audit_api_keys ON public.api_keys;
-- Preserve the historical trigger name for account-deletion routines that
-- temporarily disable audit_api_keys while removing a user.
CREATE TRIGGER audit_api_keys
AFTER INSERT OR DELETE ON public.api_keys
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE TRIGGER audit_api_keys_update
AFTER UPDATE ON public.api_keys
FOR EACH ROW
WHEN (
(to_jsonb(OLD) - ARRAY[
'request_count',
'rate_limit_window_start',
'last_used_at',
'updated_at'
]::text[])
IS DISTINCT FROM
(to_jsonb(NEW) - ARRAY[
'request_count',
'rate_limit_window_start',
'last_used_at',
'updated_at'
]::text[])
)
EXECUTE FUNCTION public.write_audit_log();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,105 @@
-- The initial tax-adjustment migration used membership-only write policies.
-- Align writes with the global role gate: only a non-viewer member may write,
-- and only for the caller's active company.
ALTER TABLE public.fiscal_period_tax_adjustments
ALTER COLUMN id SET DEFAULT uuid_generate_v4();
-- Serialize adjustment writes with fiscal-period close/lock updates. The row
-- lock prevents a concurrent request from saving tax inputs after closing has
-- started but before the period status change becomes visible.
CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_adjustment()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
DECLARE
adjustment_row public.fiscal_period_tax_adjustments%ROWTYPE;
period_row public.fiscal_periods%ROWTYPE;
BEGIN
IF TG_OP = 'UPDATE'
AND (
OLD.company_id IS DISTINCT FROM NEW.company_id
OR OLD.fiscal_period_id IS DISTINCT FROM NEW.fiscal_period_id
) THEN
RAISE EXCEPTION 'Tax adjustment company and fiscal period are immutable'
USING ERRCODE = '23514';
END IF;
IF TG_OP = 'DELETE' THEN
adjustment_row := OLD;
ELSE
adjustment_row := NEW;
END IF;
SELECT * INTO period_row
FROM public.fiscal_periods
WHERE id = adjustment_row.fiscal_period_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Fiscal period not found for tax adjustment'
USING ERRCODE = '23503';
END IF;
IF period_row.company_id IS DISTINCT FROM adjustment_row.company_id THEN
RAISE EXCEPTION 'Tax adjustment company does not match fiscal period company'
USING ERRCODE = '23514';
END IF;
IF period_row.is_closed
OR period_row.locked_at IS NOT NULL
OR period_row.closing_entry_id IS NOT NULL THEN
RAISE EXCEPTION 'Fiscal period is locked for tax adjustments'
USING ERRCODE = '23514';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
DROP POLICY IF EXISTS "insert own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments;
CREATE POLICY "insert own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments FOR INSERT TO public
WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
AND user_id = auth.uid()
);
DROP POLICY IF EXISTS "update own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments;
CREATE POLICY "update own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments FOR UPDATE TO public
USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
)
WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
AND user_id = auth.uid()
);
DROP POLICY IF EXISTS "delete own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments;
CREATE POLICY "delete own-company fiscal period tax adjustments"
ON public.fiscal_period_tax_adjustments FOR DELETE TO public
USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
-- Only the corporate-tax disposition sets source_id to the fiscal period.
-- This database invariant closes the read-then-insert race between two POSTs.
CREATE UNIQUE INDEX uq_year_end_corporate_tax_per_period
ON public.journal_entries (company_id, source_id)
WHERE source_type = 'year_end'
AND source_id IS NOT NULL
AND status IN ('draft', 'posted');
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,33 @@
-- BFL 7 kap. 2 § requires accounting information to be preserved through
-- the end of the seventh calendar year after the calendar year in which the
-- fiscal year ended. retention_expires_at stores the first date on which the
-- statutory minimum retention period has elapsed.
CREATE OR REPLACE FUNCTION public.set_bfl_retention_expiry()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
NEW.retention_expires_at := make_date(
extract(year FROM NEW.period_end)::integer + 8,
1,
1
);
RETURN NEW;
END;
$$;
-- PostgreSQL fires triggers with the same timing alphabetically. The zz
-- prefix makes this legal correction run after the original migration 017
-- trigger without modifying that shipped enforcement migration.
CREATE TRIGGER zz_set_bfl_retention_expiry
BEFORE INSERT OR UPDATE OF period_end ON public.fiscal_periods
FOR EACH ROW EXECUTE FUNCTION public.set_bfl_retention_expiry();
UPDATE public.fiscal_periods
SET retention_expires_at = make_date(
extract(year FROM period_end)::integer + 8,
1,
1
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
-- Allow larger company logos while keeping the upload route and storage limit
-- aligned at 10 MB. Existing files and public access remain unchanged.
UPDATE storage.buckets
SET file_size_limit = 10485760
WHERE id = 'logos';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,37 @@
-- Add bundled and company-uploaded invoice fonts. Custom font files live in a
-- dedicated private bucket. The server embeds each font into the generated PDF,
-- so customer font files never need public URLs.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS invoice_custom_font_path TEXT NULL,
ADD COLUMN IF NOT EXISTS invoice_custom_font_name 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',
'Source Sans 3',
'Source Serif 4',
'Custom'
)
);
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'invoice-fonts',
'invoice-fonts',
false,
5242880,
ARRAY['font/ttf', 'font/woff']
)
ON CONFLICT (id) DO UPDATE
SET public = EXCLUDED.public,
file_size_limit = EXCLUDED.file_size_limit,
allowed_mime_types = EXCLUDED.allowed_mime_types;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,116 @@
-- Keep signer roster drafting available to authenticated company members, but
-- reserve legally significant signature transitions for the trusted service
-- role after the API has verified ownership, version state, and evidence.
DROP POLICY IF EXISTS "arsredovisning_sigreq_insert"
ON public.arsredovisning_signature_requests;
CREATE POLICY "arsredovisning_sigreq_insert"
ON public.arsredovisning_signature_requests
FOR INSERT
TO authenticated
WITH CHECK (
company_id IN (SELECT public.user_company_ids())
AND user_id = (SELECT auth.uid())
AND status = 'pending'
AND annual_report_version_id IS NULL
AND signing_method IS NULL
AND evidence_reference IS NULL
AND evidence_recorded_by IS NULL
AND evidence_recorded_at IS NULL
AND signed_at IS NULL
AND bankid_signature_data IS NULL
AND signer_personnummer_encrypted IS NULL
AND signer_personnummer_hash IS NULL
AND EXISTS (
SELECT 1
FROM public.fiscal_periods fp
WHERE fp.id = public.arsredovisning_signature_requests.fiscal_period_id
AND fp.company_id = public.arsredovisning_signature_requests.company_id
)
);
-- Authenticated users may no longer transition or rewrite a signature row
-- directly through PostgREST. The service role bypasses RLS and is used only
-- after the API route has completed its ownership and state checks.
DROP POLICY IF EXISTS "arsredovisning_sigreq_update"
ON public.arsredovisning_signature_requests;
DROP POLICY IF EXISTS "arsredovisning_sigreq_delete"
ON public.arsredovisning_signature_requests;
CREATE POLICY "arsredovisning_sigreq_delete"
ON public.arsredovisning_signature_requests
FOR DELETE
TO authenticated
USING (
company_id IN (SELECT public.user_company_ids())
AND status = 'pending'
AND annual_report_version_id IS NULL
AND EXISTS (
SELECT 1
FROM public.fiscal_periods fp
WHERE fp.id = public.arsredovisning_signature_requests.fiscal_period_id
AND fp.company_id = public.arsredovisning_signature_requests.company_id
)
);
-- Existing signed evidence is retained unchanged. NOT VALID preserves those
-- immutable historical rows while enforcing the structured format for every
-- new or updated reference.
ALTER TABLE public.arsredovisning_signature_requests
ADD CONSTRAINT arsredovisning_signature_evidence_reference_format
CHECK (
evidence_reference IS NULL
OR evidence_reference ~ '^(archive|document|receipt):[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$'
) NOT VALID;
COMMENT ON COLUMN public.arsredovisning_signature_requests.evidence_reference IS
'Opaque reference only: archive:<id>, document:<id>, or receipt:<id>. Never store free text or personal data.';
CREATE OR REPLACE FUNCTION public.enforce_annual_report_signature_version_state()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
linked_version_status text;
linked_version_finalized_date date;
BEGIN
IF NEW.status <> 'signed' THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE' AND OLD.status = 'signed' THEN
RETURN NEW;
END IF;
SELECT
version.status,
(version.finalized_at AT TIME ZONE 'Europe/Stockholm')::date
INTO linked_version_status, linked_version_finalized_date
FROM public.annual_report_versions version
WHERE version.id = NEW.annual_report_version_id;
IF linked_version_status IS DISTINCT FROM 'ready_for_signature'
OR linked_version_finalized_date IS NULL THEN
RAISE EXCEPTION 'Annual report version is not ready for signature evidence'
USING ERRCODE = 'check_violation';
END IF;
IF (NEW.signed_at AT TIME ZONE 'Europe/Stockholm')::date
< linked_version_finalized_date
OR (NEW.signed_at AT TIME ZONE 'Europe/Stockholm')::date
> (now() AT TIME ZONE 'Europe/Stockholm')::date THEN
RAISE EXCEPTION 'Signature date must be between version finalization and today'
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_annual_report_signature_version_state
BEFORE INSERT OR UPDATE ON public.arsredovisning_signature_requests
FOR EACH ROW EXECUTE FUNCTION public.enforce_annual_report_signature_version_state();
NOTIFY pgrst, 'reload schema';