20 KiB
20 KiB
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.
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).
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.
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.
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.
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
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
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.
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
-- 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.
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.
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
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
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
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:
-- Copy all CREATE TABLE, CREATE TYPE, CREATE FUNCTION, CREATE VIEW statements above
-- Run with: supabase db push