# Database Schema ## Entity Relationship Diagram ``` users (Supabase Auth) │ ├── profiles (1:1) │ └── company_settings (1:1) │ ├── bank_connections (1:N) │ └── transactions (1:N) │ ├── customers (1:N) │ └── invoices (1:N) └── invoice_items (1:N) ``` ## Tables ### profiles Extends Supabase auth.users. ```sql CREATE TABLE profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, email TEXT NOT NULL, full_name TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- RLS ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view own profile" ON profiles FOR SELECT USING (auth.uid() = id); CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = id); ``` ### company_settings Business/tax information for the company (enskild firma or AB). ```sql CREATE TYPE entity_type AS ENUM ( 'enskild_firma', 'aktiebolag' ); CREATE TYPE moms_period AS ENUM ( 'monthly', 'quarterly' ); CREATE TABLE company_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, -- Onboarding status onboarding_complete BOOLEAN DEFAULT FALSE, onboarding_step INTEGER DEFAULT 1, -- Track progress (1-6) -- Entity type entity_type entity_type NOT NULL DEFAULT 'enskild_firma', -- Company info company_name TEXT NOT NULL, org_number TEXT, -- Organisationsnummer (required for AB) vat_number TEXT, -- SE + org_number + 01 (if VAT registered) f_skatt_registered BOOLEAN DEFAULT TRUE, -- Address address_line1 TEXT, address_line2 TEXT, postal_code TEXT, city TEXT, country TEXT DEFAULT 'SE', -- Banking bank_name TEXT, clearing_number TEXT, account_number TEXT, iban TEXT, bic TEXT, -- Contact phone TEXT, website TEXT, -- Tax settings municipal_tax_rate DECIMAL(5,2) DEFAULT 32.00, -- User's kommun (enskild firma) preliminary_tax_monthly DECIMAL(12,2), -- Debiterad F-skatt per month vat_registered BOOLEAN DEFAULT FALSE, vat_registration_date DATE, moms_period moms_period DEFAULT 'quarterly', -- Fiscal year (räkenskapsår) fiscal_year_start_month INTEGER DEFAULT 1, -- 1 = calendar year, other = brutet räkenskapsår -- Example: 7 = July start (July 1 - June 30) -- Invoice settings invoice_prefix TEXT DEFAULT 'INV', next_invoice_number INTEGER DEFAULT 1001, -- Allow unusual starting numbers payment_terms_days INTEGER DEFAULT 30, default_currency TEXT DEFAULT 'SEK', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id) ); -- RLS ALTER TABLE company_settings ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can CRUD own company settings" ON company_settings FOR ALL USING (auth.uid() = user_id); ``` ### bank_connections PSD2 bank connection records. ```sql CREATE TABLE bank_connections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, provider TEXT NOT NULL, -- 'tink', 'enablebanking' provider_connection_id TEXT, -- External ID bank_name TEXT, account_name TEXT, account_number_masked TEXT, -- Last 4 digits status TEXT DEFAULT 'pending', -- pending, active, expired, error consent_expires_at TIMESTAMPTZ, last_synced_at TIMESTAMPTZ, -- Encrypted tokens stored in Supabase Vault or as encrypted column access_token_encrypted TEXT, refresh_token_encrypted TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- RLS ALTER TABLE bank_connections ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can CRUD own bank connections" ON bank_connections FOR ALL USING (auth.uid() = user_id); ``` ### transactions Bank transactions imported via PSD2. ```sql CREATE TYPE transaction_category AS ENUM ( 'uncategorized', 'business_income', 'business_expense', 'private', 'tax_payment', 'vat_payment', 'transfer' ); CREATE TYPE expense_type AS ENUM ( 'equipment', 'software', 'travel', 'meals', 'office', 'marketing', 'professional_services', 'other' ); CREATE TABLE transactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, bank_connection_id UUID REFERENCES bank_connections(id) ON DELETE SET NULL, -- Transaction data from bank external_id TEXT, -- Bank's transaction ID booking_date DATE NOT NULL, value_date DATE, amount DECIMAL(12,2) NOT NULL, -- Negative = expense, Positive = income currency TEXT DEFAULT 'SEK', description TEXT, counterparty_name TEXT, counterparty_account TEXT, mcc_code TEXT, -- Merchant Category Code from bank -- Categorization category transaction_category DEFAULT 'uncategorized', expense_type expense_type, business_percentage INTEGER DEFAULT 100, -- For mixed-use items -- BAS Accounting bas_account TEXT, -- e.g., '5410', '2013', '5910' bas_account_auto BOOLEAN DEFAULT FALSE, -- Was this auto-categorized? bas_account_confidence TEXT, -- 'high', 'medium', 'low' reverse_charge_applied BOOLEAN DEFAULT FALSE, -- VAT tracking vat_amount DECIMAL(12,2), -- Calculated or from receipt vat_rate DECIMAL(5,2), -- 25, 12, 6, or 0 -- Metadata notes TEXT, receipt_url TEXT, -- Link to uploaded receipt linked_invoice_id UUID, -- If this is payment for an invoice created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, external_id) ); -- Indexes CREATE INDEX idx_transactions_user_date ON transactions(user_id, booking_date DESC); CREATE INDEX idx_transactions_category ON transactions(user_id, category); -- RLS ALTER TABLE transactions ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can CRUD own transactions" ON transactions FOR ALL USING (auth.uid() = user_id); ``` ### customers Invoice recipients. ```sql CREATE TYPE customer_type AS ENUM ( 'individual', 'swedish_business', 'eu_business', 'non_eu_business' ); CREATE TABLE customers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, customer_type customer_type NOT NULL, -- Basic info name TEXT NOT NULL, email TEXT, phone TEXT, -- Business info org_number TEXT, vat_number TEXT, -- For EU reverse charge validation vat_number_validated BOOLEAN DEFAULT FALSE, vat_number_validated_at TIMESTAMPTZ, -- Address address_line1 TEXT, address_line2 TEXT, postal_code TEXT, city TEXT, country TEXT NOT NULL DEFAULT 'SE', -- Defaults for invoicing default_payment_terms_days INTEGER, default_currency TEXT DEFAULT 'SEK', notes TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- RLS ALTER TABLE customers ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can CRUD own customers" ON customers FOR ALL USING (auth.uid() = user_id); ``` ### invoices ```sql CREATE TYPE invoice_status AS ENUM ( 'draft', 'sent', 'viewed', 'paid', 'overdue', 'cancelled' ); CREATE TYPE invoice_type AS ENUM ( 'invoice', 'credit_note' ); CREATE TYPE vat_treatment AS ENUM ( 'standard', -- Normal VAT rate 'reverse_charge', -- EU B2B 'export', -- Non-EU 'exempt' -- VAT exempt services ); CREATE TABLE invoices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES customers(id), -- Invoice identification invoice_number TEXT NOT NULL, invoice_type invoice_type NOT NULL DEFAULT 'invoice', credited_invoice_id UUID REFERENCES invoices(id), -- For credit notes: which invoice is credited reference TEXT, -- Customer's reference/PO number -- Dates invoice_date DATE NOT NULL DEFAULT CURRENT_DATE, due_date DATE NOT NULL, -- Original currency (what customer pays) currency TEXT NOT NULL DEFAULT 'SEK', subtotal DECIMAL(12,2) NOT NULL DEFAULT 0, vat_amount DECIMAL(12,2) NOT NULL DEFAULT 0, total DECIMAL(12,2) NOT NULL DEFAULT 0, -- SEK conversion (for bokföring) exchange_rate DECIMAL(10,6) DEFAULT 1.0, exchange_rate_date DATE, subtotal_sek DECIMAL(12,2) NOT NULL DEFAULT 0, vat_amount_sek DECIMAL(12,2) NOT NULL DEFAULT 0, total_sek DECIMAL(12,2) NOT NULL DEFAULT 0, -- VAT handling vat_treatment vat_treatment NOT NULL DEFAULT 'standard', vat_rate DECIMAL(5,2) DEFAULT 25.00, reverse_charge_text TEXT, -- Legal text for reverse charge moms_ruta INTEGER, -- Which ruta in momsdeklaration (5, 39, 40) -- Status status invoice_status DEFAULT 'draft', sent_at TIMESTAMPTZ, viewed_at TIMESTAMPTZ, paid_at TIMESTAMPTZ, paid_amount DECIMAL(12,2), paid_currency TEXT, -- PDF pdf_url TEXT, pdf_generated_at TIMESTAMPTZ, -- Additional notes TEXT, -- Internal notes customer_notes TEXT, -- Visible on invoice created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, invoice_number), -- Credit notes must reference an original invoice CONSTRAINT credit_note_requires_original CHECK ( (invoice_type = 'credit_note' AND credited_invoice_id IS NOT NULL) OR (invoice_type = 'invoice') ) ); -- Indexes CREATE INDEX idx_invoices_user_status ON invoices(user_id, status); CREATE INDEX idx_invoices_due_date ON invoices(user_id, due_date); CREATE INDEX idx_invoices_type ON invoices(user_id, invoice_type); -- RLS ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can CRUD own invoices" ON invoices FOR ALL USING (auth.uid() = user_id); ``` ### invoice_items ```sql CREATE TABLE invoice_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, description TEXT NOT NULL, quantity DECIMAL(10,2) NOT NULL DEFAULT 1, unit TEXT DEFAULT 'st', -- st, timmar, etc. unit_price DECIMAL(12,2) NOT NULL, -- Calculated line_total DECIMAL(12,2) NOT NULL, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() ); -- RLS ALTER TABLE invoice_items ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can CRUD own invoice items" ON invoice_items FOR ALL USING ( EXISTS ( SELECT 1 FROM invoices WHERE invoices.id = invoice_items.invoice_id AND invoices.user_id = auth.uid() ) ); ``` ### salary_payments (Aktiebolag only) Track owner salary payments for AB entities. ```sql CREATE TABLE salary_payments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, -- Payment details payment_date DATE NOT NULL, pay_period_start DATE NOT NULL, -- e.g., 2024-01-01 pay_period_end DATE NOT NULL, -- e.g., 2024-01-31 -- Amounts gross_salary DECIMAL(12,2) NOT NULL, -- Bruttolön employer_contributions DECIMAL(12,2) NOT NULL, -- Arbetsgivaravgifter (31.42%) withheld_tax DECIMAL(12,2) NOT NULL, -- Preliminärskatteavdrag net_salary DECIMAL(12,2) NOT NULL, -- Nettolön (what's paid out) -- Total cost to company total_cost DECIMAL(12,2) NOT NULL, -- gross + employer_contributions -- Reporting status agi_reported BOOLEAN DEFAULT FALSE, -- AGI-deklaration submitted agi_reported_at TIMESTAMPTZ, notes TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- RLS ALTER TABLE salary_payments ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can CRUD own salary payments" ON salary_payments FOR ALL USING (auth.uid() = user_id); -- Index for period queries CREATE INDEX idx_salary_payments_period ON salary_payments(user_id, pay_period_start); ``` ### Constants for AB calculations ```sql -- Current rates (update annually) -- Arbetsgivaravgifter: 31.42% (2024) -- This should be configurable or fetched from a rates table CREATE TABLE tax_rates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), rate_type TEXT NOT NULL, -- 'arbetsgivaravgifter', 'bolagsskatt', etc. rate DECIMAL(5,4) NOT NULL, -- 0.3142 for 31.42% valid_from DATE NOT NULL, valid_to DATE, -- NULL = currently active UNIQUE(rate_type, valid_from) ); -- Seed current rates INSERT INTO tax_rates (rate_type, rate, valid_from) VALUES ('arbetsgivaravgifter', 0.3142, '2024-01-01'), ('bolagsskatt', 0.206, '2021-01-01'), ('egenavgifter', 0.2897, '2024-01-01'); ``` ### bas_accounts (Reference Table) Available BAS accounts for transaction categorization. ```sql CREATE TABLE bas_accounts ( account_number TEXT PRIMARY KEY, -- e.g., '5410' name_sv TEXT NOT NULL, -- Swedish name name_en TEXT, -- English name account_class INTEGER NOT NULL, -- 1-8 account_group INTEGER NOT NULL, -- e.g., 54 -- Categorization is_expense BOOLEAN DEFAULT FALSE, is_income BOOLEAN DEFAULT FALSE, is_asset BOOLEAN DEFAULT FALSE, is_liability BOOLEAN DEFAULT FALSE, -- Influencer-specific common_for_influencers BOOLEAN DEFAULT FALSE, risk_level TEXT, -- 'low', 'medium', 'high', 'very_high' default_vat_rate DECIMAL(5,2), -- Usage hints description TEXT, warning_text TEXT, -- Shown when selected requires_documentation BOOLEAN DEFAULT FALSE, active BOOLEAN DEFAULT TRUE ); -- Seed common influencer accounts INSERT INTO bas_accounts (account_number, name_sv, name_en, account_class, account_group, is_expense, common_for_influencers, risk_level, description) VALUES ('1210', 'Maskiner och inventarier', 'Equipment', 1, 12, FALSE, TRUE, 'low', 'Kameror, datorer över 29 400 kr'), ('2013', 'Eget uttag', 'Owner withdrawals', 2, 20, FALSE, TRUE, 'low', 'Privata utgifter betalda med företagskort'), ('3001', 'Försäljning 25%', 'Sales 25% VAT', 3, 30, FALSE, TRUE, 'low', 'Svenska samarbeten'), ('3044', 'Försäljning tjänst EU', 'EU service sales', 3, 30, FALSE, TRUE, 'low', 'AdSense, EU-affiliates. Kräver periodisk sammanställning'), ('3045', 'Försäljning tjänst export', 'Export service sales', 3, 30, FALSE, TRUE, 'low', 'Twitch, YouTube US. Momsfritt'), ('5410', 'Förbrukningsinventarier', 'Consumable equipment', 5, 54, TRUE, TRUE, 'low', 'Kameror, datorer under 29 400 kr'), ('5420', 'Programvaror', 'Software', 5, 54, TRUE, TRUE, 'low', 'Adobe CC, Epidemic Sound, SaaS'), ('5480', 'Arbetskläder', 'Work clothes', 5, 54, TRUE, TRUE, 'very_high', 'Endast skyddskläder/uniformer. EJ civila kläder!'), ('5800', 'Resekostnader', 'Travel expenses', 5, 58, TRUE, TRUE, 'medium', 'Tåg, flyg, hyrbil för tjänsteresor'), ('5820', 'Taxikostnader', 'Taxi', 5, 58, TRUE, TRUE, 'medium', 'Taxi/Uber för tjänsteresor'), ('5910', 'Annonsering', 'Advertising', 5, 59, TRUE, TRUE, 'low', 'Facebook Ads, Google Ads, TikTok'), ('6071', 'Representation, avdragsgill', 'Deductible representation', 6, 60, TRUE, TRUE, 'high', 'Max 60 kr/person. Kräver dokumentation'), ('6072', 'Representation, ej avdragsgill', 'Non-deductible representation', 6, 60, TRUE, TRUE, 'high', 'Måltider vid representation'), ('6230', 'Datakommunikation', 'Data/web hosting', 6, 62, TRUE, TRUE, 'low', 'Webbhotell, domäner, bredband'), ('6500', 'Övriga externa tjänster', 'Other services', 6, 65, TRUE, TRUE, 'low', 'Fotografer, klippare med F-skatt'); ``` ### mcc_mappings (Reference Table) MCC code to BAS account mappings for auto-categorization. ```sql CREATE TABLE mcc_mappings ( mcc_code TEXT PRIMARY KEY, description TEXT NOT NULL, risk_level TEXT NOT NULL, -- 'low', 'medium', 'high', 'very_high' primary_account TEXT REFERENCES bas_accounts(account_number), secondary_account TEXT REFERENCES bas_accounts(account_number), requires_user_input BOOLEAN DEFAULT FALSE, auto_reject_reason TEXT, -- If very_high risk, why? categorization_logic TEXT, active BOOLEAN DEFAULT TRUE ); -- Seed MCC mappings (subset - see 08-BAS-ACCOUNTING-GUIDE.md for full list) INSERT INTO mcc_mappings (mcc_code, description, risk_level, primary_account, secondary_account, requires_user_input, auto_reject_reason, categorization_logic) VALUES ('7311', 'Advertising Services', 'low', '5910', '5930', FALSE, NULL, 'Standard for Facebook/Google Ads. Triggers EU VAT check.'), ('5732', 'Electronics Stores', 'medium', '5410', '1210', FALSE, NULL, 'Amount determines routing. >29,400 kr → 1210.'), ('5946', 'Camera & Photo Supply', 'medium', '5410', '1210', FALSE, NULL, 'Same as electronics.'), ('5812', 'Eating Places/Restaurants', 'high', '6072', '2013', TRUE, NULL, 'Requires user input: Representation or private?'), ('5611', 'Mens Clothing', 'very_high', '2013', '5480', TRUE, 'Clothes usable privately are not deductible (RÅ81 1:29)', 'Default MUST be 2013.'), ('5621', 'Womens Ready-to-Wear', 'very_high', '2013', '5480', TRUE, 'Clothes usable privately are not deductible', 'Default MUST be 2013.'), ('5977', 'Cosmetic Stores', 'very_high', '2013', '5490', TRUE, 'Cosmetics are private expenses', 'Default private.'), ('4111', 'Commuter Transport', 'low', '5800', NULL, FALSE, NULL, 'Train/bus. 6% VAT in Sweden.'), ('4722', 'Travel Agencies', 'medium', '5800', '2013', TRUE, NULL, 'Is the trip business-related?'), ('7372', 'Computer Programming', 'low', '6230', '5420', FALSE, NULL, 'Web hosting, SaaS.'), ('5921', 'Package Stores (Systembolaget)', 'very_high', '2013', NULL, FALSE, 'Alcohol is not deductible', 'Always 2013.'), ('7941', 'Sports Clubs/Gyms', 'very_high', '2013', NULL, FALSE, 'Gym is private expense even for fitness influencers', 'Always 2013.'); ``` ## Functions ### Calculate invoice totals ```sql CREATE OR REPLACE FUNCTION calculate_invoice_totals() RETURNS TRIGGER AS $$ BEGIN UPDATE invoices SET subtotal = ( SELECT COALESCE(SUM(line_total), 0) FROM invoice_items WHERE invoice_id = NEW.invoice_id ), vat_amount = ( SELECT COALESCE(SUM(line_total), 0) * (vat_rate / 100) FROM invoice_items WHERE invoice_id = NEW.invoice_id ), total = ( SELECT COALESCE(SUM(line_total), 0) * (1 + vat_rate / 100) FROM invoice_items WHERE invoice_id = NEW.invoice_id ), updated_at = NOW() WHERE id = NEW.invoice_id; RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; CREATE TRIGGER trigger_calculate_invoice_totals AFTER INSERT OR UPDATE OR DELETE ON invoice_items FOR EACH ROW EXECUTE FUNCTION calculate_invoice_totals(); ``` ### Auto-create profile on signup ```sql CREATE OR REPLACE FUNCTION handle_new_user() RETURNS TRIGGER AS $$ BEGIN INSERT INTO profiles (id, email, full_name) VALUES ( NEW.id, NEW.email, NEW.raw_user_meta_data->>'full_name' ); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user(); ``` ## Views ### Dashboard summary ```sql CREATE VIEW dashboard_summary AS SELECT p.id AS user_id, -- Revenue YTD COALESCE(SUM( CASE WHEN t.amount > 0 AND t.category = 'business_income' AND t.booking_date >= DATE_TRUNC('year', CURRENT_DATE) THEN t.amount ELSE 0 END ), 0) AS revenue_ytd, -- Expenses YTD COALESCE(SUM( CASE WHEN t.amount < 0 AND t.category = 'business_expense' AND t.booking_date >= DATE_TRUNC('year', CURRENT_DATE) THEN ABS(t.amount) ELSE 0 END ), 0) AS expenses_ytd, -- Uncategorized count COUNT(CASE WHEN t.category = 'uncategorized' THEN 1 END) AS uncategorized_count, -- Unpaid invoices (SELECT COUNT(*) FROM invoices i WHERE i.user_id = p.id AND i.status IN ('sent', 'overdue')) AS unpaid_invoice_count, (SELECT COALESCE(SUM(total), 0) FROM invoices i WHERE i.user_id = p.id AND i.status IN ('sent', 'overdue')) AS unpaid_invoice_total FROM profiles p LEFT JOIN transactions t ON t.user_id = p.id GROUP BY p.id; ``` ## Migration File Save as `supabase/migrations/001_initial_schema.sql`: ```sql -- Copy all CREATE TABLE, CREATE TYPE, CREATE FUNCTION, CREATE VIEW statements above -- Run with: supabase db push ```