Files
accounted/supabase/migrations/20260330130000_multi_tenant_company_refactor.sql
T
MattssonandClaude Opus 4.6 0dd1f5ebc1 feat: multi-tenant company refactor (GNU-19) (#153)
* feat: multi-tenant company refactor (GNU-19)

Introduce companies table, company_members, and user_preferences to
support multiple companies per user. All data scoping changes from
user_id to company_id across the entire codebase.

Key changes:
- Database migration: new tables, company_id on 40+ tables, backfill,
  RLS rewrite from user_id to company-member-based, updated RPCs
- Types: Company, CompanyMember, CompanyRole, UserPreferences types;
  company_id added to all entity interfaces; companyId on all events
- Engine: all 7 core functions take companyId; storno, period, year-end
  services updated; 16 report generators updated
- Middleware: company context resolution (cookie → prefs → first company)
- API routes: ~120 routes updated with requireCompanyId()
- Frontend: CompanyProvider context, layout/dashboard/onboarding updated
- Extensions: context factory, 9 extensions, all lib files updated
- Tests: 1880 tests passing, all helpers updated with company_id defaults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add database migrations for multi-tenant company and team system (GNU-19)

Adds company_invitations, company creation RPC, team_members, account
deletion RPC, and teams table refactor migrations. Updates base
multi-tenant migration with cascading FKs and onboarding_step column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team types and update core infrastructure for multi-tenancy (GNU-19)

Adds TeamRole, MemberSource, and Team types. Refactors Supabase service
client to be stateless, updates middleware for team-aware routing, extends
CompanyContext with team/role fields, and updates extension service types
to accept companyId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through business logic functions (GNU-19)

Replaces user_id scoping with company_id across all lib modules:
bookkeeping, documents, transactions, invoices, reconciliation, tax,
deadlines, and import. Updates corresponding tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through API routes and extensions (GNU-19)

Updates all existing API routes to extract and pass companyId. Updates
enable-banking and arcim-migration extensions for company-scoped
transaction ingestion and sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add company and team management API routes (GNU-19)

Adds CRUD endpoints for company members, company invitations, team
members, and team invitations. Includes invite token utilities, email
templates, and company switch server action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team/company UI components, pages, and dashboard updates (GNU-19)

Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company
members and team management panels. Updates dashboard layout for
team-aware routing, onboarding for multi-step role choice, and auth
callback for team invite acceptance. Ignores supabase/.branches/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in import page (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move appUrl declaration to outer scope in invite route (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for second company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in extension components (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update tests to use companyId instead of userId and improve type handling

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:41:52 +02:00

1260 lines
58 KiB
PL/PgSQL

-- Migration: Multi-tenant (multi-company) refactor (GNU-19)
--
-- Introduces companies, company_members, user_preferences tables.
-- Adds company_id to all data tables, backfills from existing users,
-- rewrites RLS policies, and updates RPCs.
--
-- Execution order:
-- 1. Create new tables + helper function
-- 2. Add company_id column (nullable) to all existing tables
-- 3. Backfill: one company per existing user
-- 4. NOT NULL constraints + unique constraint swaps
-- 5. Drop all old RLS policies
-- 6. Create new company-based RLS policies
-- 7. Update RPCs
-- 8. Update audit/enforcement triggers
-- 9. Indexes
-- =============================================================================
-- 1. NEW TABLES
-- =============================================================================
CREATE TABLE public.companies (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
name text NOT NULL,
org_number text,
entity_type text NOT NULL CHECK (entity_type IN ('enskild_firma', 'aktiebolag')),
created_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.companies ENABLE ROW LEVEL SECURITY;
CREATE TABLE public.company_members (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'member'
CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
invited_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
joined_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (company_id, user_id)
);
ALTER TABLE public.company_members ENABLE ROW LEVEL SECURITY;
CREATE TABLE public.user_preferences (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE,
active_company_id uuid REFERENCES public.companies(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.user_preferences ENABLE ROW LEVEL SECURITY;
-- updated_at triggers
CREATE TRIGGER companies_updated_at
BEFORE UPDATE ON public.companies
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER company_members_updated_at
BEFORE UPDATE ON public.company_members
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER user_preferences_updated_at
BEFORE UPDATE ON public.user_preferences
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- =============================================================================
-- 2. HELPER FUNCTION: user_company_ids()
-- Returns company_ids the authenticated user is a member of.
-- STABLE + SECURITY DEFINER: result cached per statement, bypasses RLS.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.user_company_ids()
RETURNS SETOF uuid
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT company_id FROM public.company_members WHERE user_id = auth.uid();
$$;
GRANT EXECUTE ON FUNCTION public.user_company_ids() TO authenticated;
-- =============================================================================
-- 3. RLS FOR NEW TABLES
-- =============================================================================
-- companies
CREATE POLICY "companies_select" ON public.companies
FOR SELECT USING (id IN (SELECT public.user_company_ids()));
CREATE POLICY "companies_insert" ON public.companies
FOR INSERT WITH CHECK (created_by = auth.uid());
CREATE POLICY "companies_update" ON public.companies
FOR UPDATE USING (id IN (SELECT public.user_company_ids()));
-- company_members
CREATE POLICY "company_members_select" ON public.company_members
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "company_members_insert" ON public.company_members
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "company_members_update" ON public.company_members
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "company_members_delete" ON public.company_members
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
-- user_preferences
CREATE POLICY "user_preferences_select" ON public.user_preferences
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "user_preferences_insert" ON public.user_preferences
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "user_preferences_update" ON public.user_preferences
FOR UPDATE USING (auth.uid() = user_id);
-- =============================================================================
-- 4. ADD company_id COLUMN TO ALL EXISTING TABLES
-- =============================================================================
DO $$
DECLARE
tbl TEXT;
tables_to_update TEXT[] := ARRAY[
'company_settings', 'chart_of_accounts', 'fiscal_periods',
'journal_entries', 'account_balances', 'voucher_sequences',
'transactions', 'bank_connections', 'bank_file_imports',
'customers', 'invoices', 'invoice_reminders', 'invoice_payments',
'suppliers', 'supplier_invoices', 'supplier_invoice_payments',
'receipts', 'document_attachments', 'invoice_inbox_items',
'mapping_rules', 'categorization_templates',
'deadlines', 'cost_centers', 'projects',
'salary_payments', 'mileage_entries',
'sie_imports', 'sie_account_mappings',
'calendar_feeds', 'chat_sessions', 'chat_messages',
'ai_usage_tracking', 'extension_data', 'api_keys',
'skatteverket_tokens', 'pending_operations', 'payment_match_log',
'event_log', 'notification_log', 'audit_log'
];
BEGIN
FOREACH tbl IN ARRAY tables_to_update
LOOP
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = tbl) THEN
EXECUTE format(
'ALTER TABLE public.%I ADD COLUMN IF NOT EXISTS company_id uuid REFERENCES public.companies(id) ON DELETE CASCADE',
tbl
);
END IF;
END LOOP;
END $$;
-- Add onboarding_step column (tracks multi-step onboarding progress)
ALTER TABLE public.company_settings ADD COLUMN IF NOT EXISTS onboarding_step integer NOT NULL DEFAULT 1;
-- =============================================================================
-- 5. BACKFILL: Create one company per existing user
-- =============================================================================
-- Temporary mapping table
CREATE TEMP TABLE _user_company_map AS
SELECT
cs.user_id,
uuid_generate_v4() AS new_company_id,
COALESCE(cs.company_name, 'Mitt företag') AS company_name,
cs.org_number,
COALESCE(cs.entity_type, 'enskild_firma') AS entity_type
FROM public.company_settings cs;
-- Insert companies
INSERT INTO public.companies (id, name, org_number, entity_type, created_by)
SELECT new_company_id, company_name, org_number, entity_type, user_id
FROM _user_company_map;
-- Insert owner memberships
INSERT INTO public.company_members (company_id, user_id, role)
SELECT new_company_id, user_id, 'owner'
FROM _user_company_map;
-- Insert user preferences
INSERT INTO public.user_preferences (user_id, active_company_id)
SELECT user_id, new_company_id
FROM _user_company_map;
-- Backfill company_id on all tables using the mapping
DO $$
DECLARE
tbl TEXT;
tables_to_backfill TEXT[] := ARRAY[
'company_settings', 'chart_of_accounts', 'fiscal_periods',
'journal_entries', 'account_balances', 'voucher_sequences',
'transactions', 'bank_connections', 'bank_file_imports',
'customers', 'invoices', 'invoice_reminders', 'invoice_payments',
'suppliers', 'supplier_invoices', 'supplier_invoice_payments',
'receipts', 'document_attachments', 'invoice_inbox_items',
'mapping_rules', 'categorization_templates',
'deadlines', 'cost_centers', 'projects',
'salary_payments', 'mileage_entries',
'sie_imports', 'sie_account_mappings',
'calendar_feeds', 'chat_sessions', 'chat_messages',
'ai_usage_tracking', 'extension_data', 'api_keys',
'skatteverket_tokens', 'pending_operations', 'payment_match_log',
'event_log', 'notification_log', 'audit_log'
];
BEGIN
FOREACH tbl IN ARRAY tables_to_backfill
LOOP
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = tbl) THEN
EXECUTE format(
'UPDATE public.%I t SET company_id = m.new_company_id FROM _user_company_map m WHERE t.user_id = m.user_id AND t.company_id IS NULL',
tbl
);
END IF;
END LOOP;
END $$;
DROP TABLE _user_company_map;
-- =============================================================================
-- 6. NOT NULL CONSTRAINTS + UNIQUE CONSTRAINT SWAPS
-- =============================================================================
-- Add NOT NULL on company_id (except tables where NULL is valid)
DO $$
DECLARE
tbl TEXT;
not_null_tables TEXT[] := ARRAY[
'company_settings', 'chart_of_accounts', 'fiscal_periods',
'journal_entries', 'account_balances', 'voucher_sequences',
'transactions', 'bank_connections', 'bank_file_imports',
'customers', 'invoices', 'invoice_reminders', 'invoice_payments',
'suppliers', 'supplier_invoices', 'supplier_invoice_payments',
'receipts', 'document_attachments', 'invoice_inbox_items',
'categorization_templates',
'deadlines', 'cost_centers', 'projects',
'salary_payments', 'mileage_entries',
'sie_imports', 'sie_account_mappings',
'calendar_feeds', 'chat_sessions', 'chat_messages',
'ai_usage_tracking', 'extension_data', 'api_keys',
'skatteverket_tokens', 'pending_operations'
];
BEGIN
FOREACH tbl IN ARRAY not_null_tables
LOOP
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = tbl) THEN
EXECUTE format('ALTER TABLE public.%I ALTER COLUMN company_id SET NOT NULL', tbl);
END IF;
END LOOP;
END $$;
-- mapping_rules, audit_log, event_log, notification_log, payment_match_log: company_id stays nullable
-- audit_log.user_id: nullable because some tables (e.g. company_settings) no longer carry user_id
ALTER TABLE public.audit_log ALTER COLUMN user_id DROP NOT NULL;
-- Drop old unique constraints and create new ones with company_id
-- company_settings: (user_id) → (company_id)
ALTER TABLE public.company_settings DROP CONSTRAINT IF EXISTS company_settings_user_id_key;
ALTER TABLE public.company_settings ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE public.company_settings ADD CONSTRAINT company_settings_company_id_key UNIQUE (company_id);
-- transactions: (user_id, external_id) → (company_id, external_id)
ALTER TABLE public.transactions DROP CONSTRAINT IF EXISTS transactions_user_id_external_id_key;
DROP INDEX IF EXISTS transactions_user_id_external_id_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_company_external_id
ON public.transactions (company_id, external_id) WHERE external_id IS NOT NULL;
-- invoices: (user_id, invoice_number) → (company_id, invoice_number)
ALTER TABLE public.invoices DROP CONSTRAINT IF EXISTS invoices_user_id_invoice_number_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_company_invoice_number
ON public.invoices (company_id, invoice_number) WHERE invoice_number IS NOT NULL;
-- supplier_invoices: (user_id, arrival_number) → (company_id, arrival_number)
ALTER TABLE public.supplier_invoices DROP CONSTRAINT IF EXISTS supplier_invoices_user_id_arrival_number_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_supplier_invoices_company_arrival_number
ON public.supplier_invoices (company_id, arrival_number);
-- supplier_invoices: (user_id, supplier_id, supplier_invoice_number) → (company_id, supplier_id, supplier_invoice_number)
ALTER TABLE public.supplier_invoices DROP CONSTRAINT IF EXISTS supplier_invoices_user_id_supplier_id_supplier_invoice_numbe_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_supplier_invoices_company_supplier_number
ON public.supplier_invoices (company_id, supplier_id, supplier_invoice_number)
WHERE supplier_invoice_number IS NOT NULL;
-- chart_of_accounts: (user_id, account_number) → (company_id, account_number)
ALTER TABLE public.chart_of_accounts DROP CONSTRAINT IF EXISTS chart_of_accounts_user_id_account_number_key;
ALTER TABLE public.chart_of_accounts ADD CONSTRAINT chart_of_accounts_company_id_account_number_key
UNIQUE (company_id, account_number);
-- fiscal_periods: (user_id, period_start, period_end) → (company_id, period_start, period_end)
ALTER TABLE public.fiscal_periods DROP CONSTRAINT IF EXISTS fiscal_periods_user_id_period_start_period_end_key;
ALTER TABLE public.fiscal_periods ADD CONSTRAINT fiscal_periods_company_id_period_start_period_end_key
UNIQUE (company_id, period_start, period_end);
-- account_balances: (user_id, fiscal_period_id, account_number) → (company_id, fiscal_period_id, account_number)
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'account_balances') THEN
ALTER TABLE public.account_balances DROP CONSTRAINT IF EXISTS account_balances_user_id_fiscal_period_id_account_number_key;
ALTER TABLE public.account_balances ADD CONSTRAINT account_balances_company_id_fiscal_period_id_account_number_key
UNIQUE (company_id, fiscal_period_id, account_number);
END IF;
END $$;
-- voucher_sequences: (user_id, fiscal_period_id, voucher_series) → (company_id, fiscal_period_id, voucher_series)
ALTER TABLE public.voucher_sequences DROP CONSTRAINT IF EXISTS voucher_sequences_user_id_fiscal_period_id_voucher_series_key;
ALTER TABLE public.voucher_sequences ADD CONSTRAINT voucher_sequences_company_id_fiscal_period_id_voucher_series_key
UNIQUE (company_id, fiscal_period_id, voucher_series);
-- cost_centers: (user_id, code) → (company_id, code)
ALTER TABLE public.cost_centers DROP CONSTRAINT IF EXISTS cost_centers_user_id_code_key;
ALTER TABLE public.cost_centers ADD CONSTRAINT cost_centers_company_id_code_key
UNIQUE (company_id, code);
-- projects: (user_id, code) → (company_id, code)
ALTER TABLE public.projects DROP CONSTRAINT IF EXISTS projects_user_id_code_key;
ALTER TABLE public.projects ADD CONSTRAINT projects_company_id_code_key
UNIQUE (company_id, code);
-- categorization_templates: (user_id, counterparty_name) → (company_id, counterparty_name)
ALTER TABLE public.categorization_templates DROP CONSTRAINT IF EXISTS categorization_templates_user_id_counterparty_name_key;
ALTER TABLE public.categorization_templates ADD CONSTRAINT categorization_templates_company_id_counterparty_name_key
UNIQUE (company_id, counterparty_name);
-- sie_account_mappings: (user_id, source_account) → (company_id, source_account)
ALTER TABLE public.sie_account_mappings DROP CONSTRAINT IF EXISTS sie_account_mappings_user_id_source_account_key;
ALTER TABLE public.sie_account_mappings ADD CONSTRAINT sie_account_mappings_company_id_source_account_key
UNIQUE (company_id, source_account);
-- sie_imports: (user_id, file_hash) → (company_id, file_hash)
ALTER TABLE public.sie_imports DROP CONSTRAINT IF EXISTS sie_imports_user_id_file_hash_key;
ALTER TABLE public.sie_imports ADD CONSTRAINT sie_imports_company_id_file_hash_key
UNIQUE (company_id, file_hash);
-- calendar_feeds: (user_id) → (company_id)
ALTER TABLE public.calendar_feeds DROP CONSTRAINT IF EXISTS calendar_feeds_user_id_key;
ALTER TABLE public.calendar_feeds ADD CONSTRAINT calendar_feeds_company_id_key
UNIQUE (company_id);
-- skatteverket_tokens: (user_id) → (company_id)
ALTER TABLE public.skatteverket_tokens DROP CONSTRAINT IF EXISTS skatteverket_tokens_user_id_key;
ALTER TABLE public.skatteverket_tokens ADD CONSTRAINT skatteverket_tokens_company_id_key
UNIQUE (company_id);
-- extension_data: (user_id, extension_id, key) → (company_id, extension_id, key)
ALTER TABLE public.extension_data DROP CONSTRAINT IF EXISTS extension_data_user_id_extension_id_key_key;
ALTER TABLE public.extension_data ADD CONSTRAINT extension_data_company_id_extension_id_key_key
UNIQUE (company_id, extension_id, key);
-- =============================================================================
-- 7. DROP ALL OLD RLS POLICIES ON AFFECTED TABLES
-- =============================================================================
DO $$
DECLARE
pol RECORD;
affected_tables TEXT[] := ARRAY[
'company_settings', 'chart_of_accounts', 'fiscal_periods',
'journal_entries', 'journal_entry_lines',
'account_balances', 'voucher_sequences',
'transactions', 'bank_connections', 'bank_file_imports',
'customers', 'invoices', 'invoice_items',
'invoice_reminders', 'invoice_payments',
'suppliers', 'supplier_invoices', 'supplier_invoice_items',
'supplier_invoice_payments',
'receipts', 'receipt_line_items',
'document_attachments', 'invoice_inbox_items',
'mapping_rules', 'categorization_templates',
'deadlines', 'cost_centers', 'projects',
'salary_payments', 'mileage_entries',
'sie_imports', 'sie_account_mappings',
'calendar_feeds', 'chat_sessions', 'chat_messages',
'ai_usage_tracking', 'extension_data', 'api_keys',
'skatteverket_tokens', 'pending_operations', 'payment_match_log',
'event_log', 'notification_log', 'audit_log'
];
tbl TEXT;
BEGIN
FOREACH tbl IN ARRAY affected_tables
LOOP
FOR pol IN SELECT policyname FROM pg_policies WHERE tablename = tbl AND schemaname = 'public'
LOOP
EXECUTE format('DROP POLICY %I ON public.%I', pol.policyname, tbl);
END LOOP;
END LOOP;
END $$;
-- =============================================================================
-- 8. CREATE NEW company_id-BASED RLS POLICIES
-- =============================================================================
-- Helper: standard company-scoped policies (SELECT, INSERT, UPDATE)
-- We create them per-table for clarity and auditability.
-- company_settings
CREATE POLICY "company_settings_select" ON public.company_settings
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "company_settings_insert" ON public.company_settings
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "company_settings_update" ON public.company_settings
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- chart_of_accounts
CREATE POLICY "chart_of_accounts_select" ON public.chart_of_accounts
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chart_of_accounts_insert" ON public.chart_of_accounts
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chart_of_accounts_update" ON public.chart_of_accounts
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- fiscal_periods
CREATE POLICY "fiscal_periods_select" ON public.fiscal_periods
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "fiscal_periods_insert" ON public.fiscal_periods
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "fiscal_periods_update" ON public.fiscal_periods
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- journal_entries
CREATE POLICY "journal_entries_select" ON public.journal_entries
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "journal_entries_insert" ON public.journal_entries
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "journal_entries_update" ON public.journal_entries
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- journal_entry_lines (child: join to parent journal_entries)
CREATE POLICY "journal_entry_lines_select" ON public.journal_entry_lines
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "journal_entry_lines_insert" ON public.journal_entry_lines
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "journal_entry_lines_update" ON public.journal_entry_lines
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id IN (SELECT public.user_company_ids()))
);
-- account_balances (may not exist on fresh DBs)
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'account_balances') THEN
EXECUTE 'CREATE POLICY "account_balances_select" ON public.account_balances FOR SELECT USING (company_id IN (SELECT public.user_company_ids()))';
EXECUTE 'CREATE POLICY "account_balances_insert" ON public.account_balances FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()))';
EXECUTE 'CREATE POLICY "account_balances_update" ON public.account_balances FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))';
END IF;
END $$;
-- voucher_sequences
CREATE POLICY "voucher_sequences_select" ON public.voucher_sequences
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "voucher_sequences_insert" ON public.voucher_sequences
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "voucher_sequences_update" ON public.voucher_sequences
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- transactions
CREATE POLICY "transactions_select" ON public.transactions
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "transactions_insert" ON public.transactions
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "transactions_update" ON public.transactions
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- bank_connections
CREATE POLICY "bank_connections_select" ON public.bank_connections
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "bank_connections_insert" ON public.bank_connections
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "bank_connections_update" ON public.bank_connections
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- bank_file_imports
CREATE POLICY "bank_file_imports_select" ON public.bank_file_imports
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "bank_file_imports_insert" ON public.bank_file_imports
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "bank_file_imports_update" ON public.bank_file_imports
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- customers
CREATE POLICY "customers_select" ON public.customers
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "customers_insert" ON public.customers
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "customers_update" ON public.customers
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- invoices
CREATE POLICY "invoices_select" ON public.invoices
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoices_insert" ON public.invoices
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoices_update" ON public.invoices
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- invoice_items (child: join to parent invoices)
CREATE POLICY "invoice_items_select" ON public.invoice_items
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "invoice_items_insert" ON public.invoice_items
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "invoice_items_update" ON public.invoice_items
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id IN (SELECT public.user_company_ids()))
);
-- invoice_reminders
CREATE POLICY "invoice_reminders_select" ON public.invoice_reminders
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_reminders_insert" ON public.invoice_reminders
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_reminders_update" ON public.invoice_reminders
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- invoice_payments
CREATE POLICY "invoice_payments_select" ON public.invoice_payments
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_payments_insert" ON public.invoice_payments
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_payments_update" ON public.invoice_payments
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- suppliers
CREATE POLICY "suppliers_select" ON public.suppliers
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "suppliers_insert" ON public.suppliers
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "suppliers_update" ON public.suppliers
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- supplier_invoices
CREATE POLICY "supplier_invoices_select" ON public.supplier_invoices
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "supplier_invoices_insert" ON public.supplier_invoices
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "supplier_invoices_update" ON public.supplier_invoices
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- supplier_invoice_items (child: join to parent supplier_invoices)
CREATE POLICY "supplier_invoice_items_select" ON public.supplier_invoice_items
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "supplier_invoice_items_insert" ON public.supplier_invoice_items
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "supplier_invoice_items_update" ON public.supplier_invoice_items
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id IN (SELECT public.user_company_ids()))
);
-- supplier_invoice_payments
CREATE POLICY "supplier_invoice_payments_select" ON public.supplier_invoice_payments
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "supplier_invoice_payments_insert" ON public.supplier_invoice_payments
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "supplier_invoice_payments_update" ON public.supplier_invoice_payments
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- receipts
CREATE POLICY "receipts_select" ON public.receipts
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "receipts_insert" ON public.receipts
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "receipts_update" ON public.receipts
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- receipt_line_items (child: join to parent receipts)
CREATE POLICY "receipt_line_items_select" ON public.receipt_line_items
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "receipt_line_items_insert" ON public.receipt_line_items
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "receipt_line_items_update" ON public.receipt_line_items
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id IN (SELECT public.user_company_ids()))
);
-- document_attachments (no DELETE policy — blocked by trigger)
CREATE POLICY "document_attachments_select" ON public.document_attachments
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "document_attachments_insert" ON public.document_attachments
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "document_attachments_update" ON public.document_attachments
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- invoice_inbox_items
CREATE POLICY "invoice_inbox_items_select" ON public.invoice_inbox_items
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_inbox_items_insert" ON public.invoice_inbox_items
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_inbox_items_update" ON public.invoice_inbox_items
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- mapping_rules (system rules have company_id IS NULL)
CREATE POLICY "mapping_rules_select" ON public.mapping_rules
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()) OR company_id IS NULL);
CREATE POLICY "mapping_rules_insert" ON public.mapping_rules
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "mapping_rules_update" ON public.mapping_rules
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- categorization_templates
CREATE POLICY "categorization_templates_select" ON public.categorization_templates
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "categorization_templates_insert" ON public.categorization_templates
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "categorization_templates_update" ON public.categorization_templates
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- deadlines
CREATE POLICY "deadlines_select" ON public.deadlines
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "deadlines_insert" ON public.deadlines
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "deadlines_update" ON public.deadlines
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- cost_centers
CREATE POLICY "cost_centers_select" ON public.cost_centers
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "cost_centers_insert" ON public.cost_centers
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "cost_centers_update" ON public.cost_centers
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- projects
CREATE POLICY "projects_select" ON public.projects
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "projects_insert" ON public.projects
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "projects_update" ON public.projects
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- salary_payments (may not exist on fresh DBs)
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'salary_payments') THEN
EXECUTE 'CREATE POLICY "salary_payments_select" ON public.salary_payments FOR SELECT USING (company_id IN (SELECT public.user_company_ids()))';
EXECUTE 'CREATE POLICY "salary_payments_insert" ON public.salary_payments FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()))';
EXECUTE 'CREATE POLICY "salary_payments_update" ON public.salary_payments FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))';
END IF;
END $$;
-- mileage_entries (may not exist on fresh DBs)
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'mileage_entries') THEN
EXECUTE 'CREATE POLICY "mileage_entries_select" ON public.mileage_entries FOR SELECT USING (company_id IN (SELECT public.user_company_ids()))';
EXECUTE 'CREATE POLICY "mileage_entries_insert" ON public.mileage_entries FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()))';
EXECUTE 'CREATE POLICY "mileage_entries_update" ON public.mileage_entries FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))';
END IF;
END $$;
-- sie_imports
CREATE POLICY "sie_imports_select" ON public.sie_imports
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "sie_imports_insert" ON public.sie_imports
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "sie_imports_update" ON public.sie_imports
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- sie_account_mappings
CREATE POLICY "sie_account_mappings_select" ON public.sie_account_mappings
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "sie_account_mappings_insert" ON public.sie_account_mappings
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "sie_account_mappings_update" ON public.sie_account_mappings
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- calendar_feeds
CREATE POLICY "calendar_feeds_select" ON public.calendar_feeds
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "calendar_feeds_insert" ON public.calendar_feeds
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "calendar_feeds_update" ON public.calendar_feeds
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- chat_sessions
CREATE POLICY "chat_sessions_select" ON public.chat_sessions
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chat_sessions_insert" ON public.chat_sessions
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chat_sessions_update" ON public.chat_sessions
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- chat_messages
CREATE POLICY "chat_messages_select" ON public.chat_messages
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chat_messages_insert" ON public.chat_messages
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chat_messages_update" ON public.chat_messages
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- ai_usage_tracking
CREATE POLICY "ai_usage_tracking_select" ON public.ai_usage_tracking
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "ai_usage_tracking_insert" ON public.ai_usage_tracking
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
-- extension_data
CREATE POLICY "extension_data_select" ON public.extension_data
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "extension_data_insert" ON public.extension_data
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "extension_data_update" ON public.extension_data
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- api_keys
CREATE POLICY "api_keys_select" ON public.api_keys
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "api_keys_insert" ON public.api_keys
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "api_keys_update" ON public.api_keys
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "api_keys_delete" ON public.api_keys
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
-- skatteverket_tokens
CREATE POLICY "skatteverket_tokens_select" ON public.skatteverket_tokens
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "skatteverket_tokens_insert" ON public.skatteverket_tokens
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "skatteverket_tokens_update" ON public.skatteverket_tokens
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- pending_operations (SELECT + UPDATE only, writes via service role)
CREATE POLICY "pending_operations_select" ON public.pending_operations
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "pending_operations_update" ON public.pending_operations
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- payment_match_log (nullable company_id)
CREATE POLICY "payment_match_log_select" ON public.payment_match_log
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "payment_match_log_insert" ON public.payment_match_log
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()) OR company_id IS NULL);
-- event_log (SELECT only for users)
CREATE POLICY "event_log_select" ON public.event_log
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
-- notification_log
CREATE POLICY "notification_log_select" ON public.notification_log
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()) OR company_id IS NULL);
-- audit_log (SELECT only for users, writes via SECURITY DEFINER triggers)
CREATE POLICY "audit_log_select" ON public.audit_log
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
-- =============================================================================
-- 9. UPDATE RPCs
-- =============================================================================
-- next_voucher_number: p_user_id → p_company_id
DROP FUNCTION IF EXISTS public.next_voucher_number(uuid, uuid, text);
CREATE OR REPLACE FUNCTION public.next_voucher_number(
p_company_id uuid,
p_fiscal_period_id uuid,
p_series text DEFAULT 'A'
)
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_next integer;
BEGIN
INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number)
VALUES (p_company_id, auth.uid(), p_fiscal_period_id, p_series, 1)
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
DO UPDATE SET
last_number = public.voucher_sequences.last_number + 1,
updated_at = now()
RETURNING last_number INTO v_next;
RETURN v_next;
END;
$$;
-- seed_chart_of_accounts: p_user_id → p_company_id
DROP FUNCTION IF EXISTS public.seed_chart_of_accounts(uuid, text);
CREATE OR REPLACE FUNCTION public.seed_chart_of_accounts(p_company_id uuid, p_entity_type text)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_account_count integer;
v_user_id uuid;
BEGIN
-- Determine user_id from company
SELECT created_by INTO v_user_id FROM public.companies WHERE id = p_company_id;
SELECT count(*) INTO v_account_count
FROM public.chart_of_accounts
WHERE company_id = p_company_id;
IF v_account_count > 0 THEN
RETURN;
END IF;
-- Assets (1xxx)
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '1510', 'Kundfordringar', 1, '15', 'asset', 'debit', 'k1', true),
(v_user_id, p_company_id, '1910', 'Kassa', 1, '19', 'asset', 'debit', 'k1', true),
(v_user_id, p_company_id, '1930', 'Foretagskonto / checkkonto', 1, '19', 'asset', 'debit', 'k1', true),
(v_user_id, p_company_id, '1940', 'Ovriga bankkonton', 1, '19', 'asset', 'debit', 'k1', true);
-- Equity (2xxx)
IF p_entity_type = 'enskild_firma' THEN
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '2010', 'Eget kapital', 2, '20', 'equity', 'credit', 'k1', true),
(v_user_id, p_company_id, '2013', 'Ovriga egna uttag', 2, '20', 'equity', 'credit', 'k1', true),
(v_user_id, p_company_id, '2018', 'Ovriga egna insattningar', 2, '20', 'equity', 'credit', 'k1', true);
END IF;
IF p_entity_type = 'aktiebolag' THEN
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '2081', 'Aktiekapital', 2, '20', 'equity', 'credit', 'k1', true),
(v_user_id, p_company_id, '2091', 'Balanserat resultat', 2, '20', 'equity', 'credit', 'k1', true),
(v_user_id, p_company_id, '2099', 'Arets resultat', 2, '20', 'equity', 'credit', 'k1', true);
END IF;
-- Liabilities (2xxx)
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '2440', 'Leverantorsskulder', 2, '24', 'liability', 'credit', 'k1', true),
(v_user_id, p_company_id, '2610', 'Utgaende moms 25%', 2, '26', 'liability', 'credit', 'k1', true),
(v_user_id, p_company_id, '2611', 'Utgaende moms 12%', 2, '26', 'liability', 'credit', 'k1', true),
(v_user_id, p_company_id, '2612', 'Utgaende moms 6%', 2, '26', 'liability', 'credit', 'k1', true),
(v_user_id, p_company_id, '2641', 'Debiterad ingaende moms', 2, '26', 'liability', 'credit', 'k1', true),
(v_user_id, p_company_id, '2650', 'Redovisningskonto for moms', 2, '26', 'liability', 'credit', 'k1', true),
(v_user_id, p_company_id, '2710', 'Personalskatt', 2, '27', 'liability', 'credit', 'k1', true),
(v_user_id, p_company_id, '2731', 'Avrakning socialavgifter', 2, '27', 'liability', 'credit', 'k1', true);
IF p_entity_type = 'aktiebolag' THEN
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '2893', 'Skuld till aktieagare', 2, '28', 'liability', 'credit', 'k1', true);
END IF;
-- Revenue (3xxx)
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '3001', 'Forsaljning tjanster 25%', 3, '30', 'revenue', 'credit', 'k1', true),
(v_user_id, p_company_id, '3002', 'Forsaljning varor 25%', 3, '30', 'revenue', 'credit', 'k1', true),
(v_user_id, p_company_id, '3100', 'Momsfri forsaljning', 3, '31', 'revenue', 'credit', 'k1', true),
(v_user_id, p_company_id, '3900', 'Ovriga rorelseintakter', 3, '39', 'revenue', 'credit', 'k1', true),
(v_user_id, p_company_id, '3960', 'Valutakursvinster', 3, '39', 'revenue', 'credit', 'k1', true);
-- COGS (4xxx)
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '4000', 'Varuinkop', 4, '40', 'expense', 'debit', 'k1', true);
-- External expenses (5xxx-6xxx)
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '5010', 'Lokalhyra', 5, '50', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '5410', 'Forbrukningsinventarier', 5, '54', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '5420', 'Programvaror', 5, '54', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '5460', 'Forbrukningsmaterial', 5, '54', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '5800', 'Resekostnader', 5, '58', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '5910', 'Annonsering', 5, '59', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '6071', 'Representation avdragsgill', 6, '60', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '6110', 'Kontorsmateriel', 6, '61', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '6212', 'Mobiltelefon', 6, '62', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '6230', 'Datakommunikation', 6, '62', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '6530', 'Redovisningstjanster', 6, '65', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '6570', 'Bankavgifter', 6, '65', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '6991', 'Ovriga avdragsgilla kostnader', 6, '69', 'expense', 'debit', 'k1', true);
-- Personnel (7xxx)
IF p_entity_type = 'aktiebolag' THEN
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '7010', 'Loner', 7, '70', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '7210', 'Semesterloner', 7, '72', 'expense', 'debit', 'k1', true),
(v_user_id, p_company_id, '7510', 'Arbetsgivaravgifter', 7, '75', 'expense', 'debit', 'k1', true);
END IF;
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '7960', 'Valutakursforluster', 7, '79', 'expense', 'debit', 'k1', true);
-- Financial (8xxx)
INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account)
VALUES
(v_user_id, p_company_id, '8310', 'Ranteintakter', 8, '83', 'revenue', 'credit', 'k1', true),
(v_user_id, p_company_id, '8410', 'Rantekostnader', 8, '84', 'expense', 'debit', 'k1', true);
END;
$$;
-- detect_voucher_gaps: p_user_id → p_company_id
DROP FUNCTION IF EXISTS public.detect_voucher_gaps(uuid, uuid, text);
CREATE OR REPLACE FUNCTION public.detect_voucher_gaps(
p_company_id uuid,
p_fiscal_period_id uuid,
p_series text DEFAULT 'A'
)
RETURNS TABLE (gap_start integer, gap_end integer)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
WITH numbered AS (
SELECT voucher_number,
LEAD(voucher_number) OVER (ORDER BY voucher_number) AS next_number
FROM public.journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id
AND voucher_series = p_series
AND status != 'draft'
ORDER BY voucher_number
)
SELECT
voucher_number + 1 AS gap_start,
next_number - 1 AS gap_end
FROM numbered
WHERE next_number IS NOT NULL
AND next_number > voucher_number + 1;
END;
$$;
-- generate_invoice_number: p_user_id → p_company_id
DROP FUNCTION IF EXISTS public.generate_invoice_number(uuid);
CREATE OR REPLACE FUNCTION public.generate_invoice_number(p_company_id UUID)
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_prefix TEXT;
v_number INTEGER;
v_year TEXT;
BEGIN
UPDATE public.company_settings
SET next_invoice_number = next_invoice_number + 1,
updated_at = now()
WHERE company_id = p_company_id
RETURNING invoice_prefix, next_invoice_number - 1
INTO v_prefix, v_number;
IF v_number IS NULL THEN
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
END IF;
v_year := EXTRACT(YEAR FROM CURRENT_DATE)::TEXT;
RETURN COALESCE(v_prefix, '') || v_year || LPAD(v_number::TEXT, 3, '0');
END;
$$;
-- generate_delivery_note_number: p_user_id → p_company_id
DROP FUNCTION IF EXISTS public.generate_delivery_note_number(uuid);
CREATE OR REPLACE FUNCTION public.generate_delivery_note_number(p_company_id UUID)
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_number INTEGER;
v_year TEXT;
BEGIN
UPDATE public.company_settings
SET next_delivery_note_number = next_delivery_note_number + 1,
updated_at = now()
WHERE company_id = p_company_id
RETURNING next_delivery_note_number - 1
INTO v_number;
IF v_number IS NULL THEN
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
END IF;
v_year := EXTRACT(YEAR FROM CURRENT_DATE)::TEXT;
RETURN 'FS-' || v_year || LPAD(v_number::TEXT, 3, '0');
END;
$$;
-- get_next_arrival_number: p_user_id → p_company_id
DROP FUNCTION IF EXISTS public.get_next_arrival_number(uuid);
CREATE OR REPLACE FUNCTION public.get_next_arrival_number(p_company_id uuid)
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_next integer;
BEGIN
SELECT COALESCE(MAX(arrival_number), 0) + 1
INTO v_next
FROM public.supplier_invoices
WHERE company_id = p_company_id;
RETURN v_next;
END;
$$;
-- validate_and_increment_api_key: add company_id to return type
DROP FUNCTION IF EXISTS public.validate_and_increment_api_key(text);
CREATE FUNCTION public.validate_and_increment_api_key(p_key_hash text)
RETURNS TABLE(user_id uuid, company_id uuid, rate_limited boolean, scopes text[])
LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id uuid;
v_company_id uuid;
v_rate_limit_rpm integer;
v_request_count integer;
v_window_start timestamptz;
v_scopes text[];
BEGIN
SELECT ak.user_id, ak.company_id, ak.rate_limit_rpm, ak.request_count, ak.rate_limit_window_start, ak.scopes
INTO v_user_id, v_company_id, v_rate_limit_rpm, v_request_count, v_window_start, v_scopes
FROM public.api_keys ak
WHERE ak.key_hash = p_key_hash AND ak.revoked_at IS NULL
FOR UPDATE;
IF v_user_id IS NULL THEN
RETURN;
END IF;
IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN
UPDATE public.api_keys
SET request_count = 1,
rate_limit_window_start = now(),
last_used_at = now()
WHERE key_hash = p_key_hash;
RETURN QUERY SELECT v_user_id, v_company_id, false, v_scopes;
RETURN;
END IF;
IF v_request_count >= v_rate_limit_rpm THEN
RETURN QUERY SELECT v_user_id, v_company_id, true, v_scopes;
RETURN;
END IF;
UPDATE public.api_keys
SET request_count = request_count + 1,
last_used_at = now()
WHERE key_hash = p_key_hash;
RETURN QUERY SELECT v_user_id, v_company_id, false, v_scopes;
END;
$$;
-- =============================================================================
-- 10. UPDATE AUDIT/ENFORCEMENT TRIGGERS
-- =============================================================================
-- write_audit_log: include company_id from record (via jsonb extraction)
CREATE OR REPLACE FUNCTION public.write_audit_log()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id uuid;
v_company_id uuid;
v_action text;
v_old_state jsonb;
v_new_state jsonb;
v_record_id uuid;
v_desc text;
BEGIN
IF TG_OP = 'DELETE' THEN
v_old_state := to_jsonb(OLD);
v_new_state := NULL;
v_record_id := OLD.id;
v_user_id := (v_old_state->>'user_id')::uuid;
v_company_id := (v_old_state->>'company_id')::uuid;
v_action := 'DELETE';
v_desc := 'Deleted ' || TG_TABLE_NAME || ' record';
ELSIF TG_OP = 'INSERT' THEN
v_old_state := NULL;
v_new_state := to_jsonb(NEW);
v_record_id := NEW.id;
v_user_id := (v_new_state->>'user_id')::uuid;
v_company_id := (v_new_state->>'company_id')::uuid;
v_action := 'INSERT';
v_desc := 'Created ' || TG_TABLE_NAME || ' record';
ELSIF TG_OP = 'UPDATE' THEN
v_old_state := to_jsonb(OLD);
v_new_state := to_jsonb(NEW);
v_record_id := COALESCE(NEW.id, OLD.id);
v_user_id := COALESCE((v_new_state->>'user_id')::uuid, (v_old_state->>'user_id')::uuid);
v_company_id := COALESCE((v_new_state->>'company_id')::uuid, (v_old_state->>'company_id')::uuid);
v_action := 'UPDATE';
v_desc := 'Updated ' || TG_TABLE_NAME || ' record';
IF TG_TABLE_NAME = 'journal_entries' THEN
IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
v_action := 'COMMIT';
v_desc := 'Committed journal entry ' || NEW.voucher_series || NEW.voucher_number;
ELSIF OLD.status = 'posted' AND NEW.status = 'reversed' THEN
v_action := 'REVERSE';
v_desc := 'Reversed journal entry ' || OLD.voucher_series || OLD.voucher_number;
END IF;
END IF;
IF TG_TABLE_NAME = 'fiscal_periods' THEN
IF (OLD.locked_at IS NULL AND NEW.locked_at IS NOT NULL) THEN
v_action := 'LOCK_PERIOD';
v_desc := 'Locked fiscal period "' || NEW.name || '"';
ELSIF (NOT OLD.is_closed AND NEW.is_closed) THEN
v_action := 'CLOSE_PERIOD';
v_desc := 'Closed fiscal period "' || NEW.name || '"';
END IF;
END IF;
END IF;
-- Fall back to auth.uid() when the row does not carry user_id
v_user_id := COALESCE(v_user_id, auth.uid());
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, old_state, new_state, description)
VALUES (v_user_id, v_company_id, v_action, TG_TABLE_NAME, v_record_id, v_user_id, v_old_state, v_new_state, v_desc);
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
-- block_document_deletion: include company_id in audit_log writes
CREATE OR REPLACE FUNCTION public.block_document_deletion()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_entry_status text;
v_retention_expires date;
BEGIN
IF OLD.journal_entry_id IS NOT NULL THEN
SELECT je.status INTO v_entry_status
FROM public.journal_entries je
WHERE je.id = OLD.journal_entry_id;
IF v_entry_status IN ('posted', 'reversed') THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'DOCUMENT_DELETE_BLOCKED', 'document_attachments', OLD.id,
'Attempted deletion of document linked to ' || v_entry_status || ' journal entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot delete document linked to a % journal entry (Bokföringslagen)',
v_entry_status;
END IF;
END IF;
IF OLD.journal_entry_id IS NOT NULL THEN
SELECT fp.retention_expires_at INTO v_retention_expires
FROM public.journal_entries je
JOIN public.fiscal_periods fp ON fp.id = je.fiscal_period_id
WHERE je.id = OLD.journal_entry_id;
IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'RETENTION_BLOCK', 'document_attachments', OLD.id,
'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
RAISE EXCEPTION 'Cannot delete document within 7-year retention period (expires %)',
v_retention_expires;
END IF;
END IF;
RETURN OLD;
END;
$$;
-- enforce_retention_journal_entries: include company_id in audit_log writes
CREATE OR REPLACE FUNCTION public.enforce_retention_journal_entries()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_retention_expires date;
BEGIN
SELECT fp.retention_expires_at INTO v_retention_expires
FROM public.fiscal_periods fp
WHERE fp.id = OLD.fiscal_period_id;
IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'RETENTION_BLOCK', 'journal_entries', OLD.id,
'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
RAISE EXCEPTION 'Cannot delete journal entry within 7-year retention period (expires %)',
v_retention_expires;
END IF;
RETURN OLD;
END;
$$;
-- =============================================================================
-- 11. INDEXES
-- =============================================================================
-- company_id indexes for key tables (high-query-volume)
CREATE INDEX IF NOT EXISTS idx_company_settings_company_id ON public.company_settings (company_id);
CREATE INDEX IF NOT EXISTS idx_journal_entries_company_id ON public.journal_entries (company_id);
CREATE INDEX IF NOT EXISTS idx_transactions_company_id ON public.transactions (company_id);
CREATE INDEX IF NOT EXISTS idx_invoices_company_id ON public.invoices (company_id);
CREATE INDEX IF NOT EXISTS idx_customers_company_id ON public.customers (company_id);
CREATE INDEX IF NOT EXISTS idx_suppliers_company_id ON public.suppliers (company_id);
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_company_id ON public.supplier_invoices (company_id);
CREATE INDEX IF NOT EXISTS idx_fiscal_periods_company_id ON public.fiscal_periods (company_id);
CREATE INDEX IF NOT EXISTS idx_chart_of_accounts_company_id ON public.chart_of_accounts (company_id);
CREATE INDEX IF NOT EXISTS idx_receipts_company_id ON public.receipts (company_id);
CREATE INDEX IF NOT EXISTS idx_document_attachments_company_id ON public.document_attachments (company_id);
CREATE INDEX IF NOT EXISTS idx_deadlines_company_id ON public.deadlines (company_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_company_id ON public.api_keys (company_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_company_id ON public.audit_log (company_id);
CREATE INDEX IF NOT EXISTS idx_company_members_user_id ON public.company_members (user_id);
CREATE INDEX IF NOT EXISTS idx_company_members_company_id ON public.company_members (company_id);
-- Composite indexes replacing user_id-based ones
CREATE INDEX IF NOT EXISTS idx_transactions_company_date ON public.transactions (company_id, date)
WHERE journal_entry_id IS NULL;