diff --git a/supabase/migrations/20260516130000_seed_chart_of_accounts_restore_swedish_chars.sql b/supabase/migrations/20260516130000_seed_chart_of_accounts_restore_swedish_chars.sql new file mode 100644 index 00000000..8399a976 --- /dev/null +++ b/supabase/migrations/20260516130000_seed_chart_of_accounts_restore_swedish_chars.sql @@ -0,0 +1,144 @@ +-- Restore Swedish characters (å/ä/ö/Å/Ä/Ö) in seed_chart_of_accounts and +-- populate sru_code from BAS 2026 reference data. Previously the account +-- names were inserted as ASCII-folded literals (e.g. 'Arets resultat'), +-- which left every newly-created company with chart-of-accounts rows +-- missing diacritics. SIE-imported accounts were unaffected because that +-- path reads names from lib/bookkeeping/bas-data/. +-- +-- SRU codes are populated from lib/bookkeeping/bas-data/ so the seeded +-- chart can produce valid SRU/INK2 filings for users who never import a +-- SIE file. +-- +-- This migration only changes the function definition. Existing companies +-- with broken seed rows are intentionally NOT backfilled here. + +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 +SET search_path = public +AS $$ +DECLARE + v_account_count integer; + v_user_id uuid; +BEGIN + 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, sru_code) + VALUES + (v_user_id, p_company_id, '1510', 'Kundfordringar', 1, '15', 'asset', 'debit', 'k1', true, '7211'), + (v_user_id, p_company_id, '1910', 'Kassa', 1, '19', 'asset', 'debit', 'k1', true, '7212'), + (v_user_id, p_company_id, '1930', 'Företagskonto / checkkonto', 1, '19', 'asset', 'debit', 'k1', true, '7212'), + (v_user_id, p_company_id, '1940', 'Övriga bankkonton', 1, '19', 'asset', 'debit', 'k1', true, '7212'); + + -- Equity (2xxx) + IF p_entity_type = 'enskild_firma' THEN + -- Enskild firma equity accounts: sru_code intentionally NULL. + -- BAS reference maps these to INK2 SRU 7221 ("Övrigt eget kapital"), + -- which is the aktiebolag tax form. EF entities file NE-bilaga, not + -- INK2, and owner drawings/contributions on 2013/2018 must not be + -- reported as balance-sheet equity by SIE/INK2 consumers. + 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, sru_code) + VALUES + (v_user_id, p_company_id, '2010', 'Eget kapital', 2, '20', 'equity', 'credit', 'k1', true, NULL), + (v_user_id, p_company_id, '2013', 'Övriga egna uttag', 2, '20', 'equity', 'credit', 'k1', true, NULL), + (v_user_id, p_company_id, '2018', 'Övriga egna insättningar', 2, '20', 'equity', 'credit', 'k1', true, NULL); + 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, sru_code) + VALUES + (v_user_id, p_company_id, '2081', 'Aktiekapital', 2, '20', 'equity', 'credit', 'k1', true, '7220'), + (v_user_id, p_company_id, '2091', 'Balanserat resultat', 2, '20', 'equity', 'credit', 'k1', true, '7221'), + (v_user_id, p_company_id, '2099', 'Årets resultat', 2, '20', 'equity', 'credit', 'k1', true, '7222'); + END IF; + + -- Liabilities (2xxx) — BAS 2026 VAT account labels + 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, sru_code) + VALUES + (v_user_id, p_company_id, '2440', 'Leverantörsskulder', 2, '24', 'liability', 'credit', 'k1', true, '7230'), + (v_user_id, p_company_id, '2611', 'Utgående moms försäljning inom Sverige, 25%', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2621', 'Utgående moms försäljning inom Sverige, 12%', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2631', 'Utgående moms försäljning inom Sverige, 6%', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2641', 'Debiterad ingående moms', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2650', 'Redovisningskonto för moms', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2710', 'Personalskatt', 2, '27', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2731', 'Avräkning socialavgifter', 2, '27', 'liability', 'credit', 'k1', true, '7231'); + + 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, sru_code) + VALUES + (v_user_id, p_company_id, '2893', 'Skuld till aktieägare', 2, '28', 'liability', 'credit', 'k1', true, '7231'); + 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, sru_code) + VALUES + (v_user_id, p_company_id, '3001', 'Försäljning tjänster 25%', 3, '30', 'revenue', 'credit', 'k1', true, '7310'), + (v_user_id, p_company_id, '3002', 'Försäljning varor 25%', 3, '30', 'revenue', 'credit', 'k1', true, '7310'), + (v_user_id, p_company_id, '3100', 'Momsfri försäljning', 3, '31', 'revenue', 'credit', 'k1', true, '7311'), + (v_user_id, p_company_id, '3900', 'Övriga rörelseintäkter', 3, '39', 'revenue', 'credit', 'k1', true, '7311'), + (v_user_id, p_company_id, '3960', 'Valutakursvinster', 3, '39', 'revenue', 'credit', 'k1', true, '7310'); + + -- 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, sru_code) + VALUES + (v_user_id, p_company_id, '4000', 'Varuinköp', 4, '40', 'expense', 'debit', 'k1', true, '7320'); + + -- 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, sru_code) + VALUES + (v_user_id, p_company_id, '5010', 'Lokalhyra', 5, '50', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5410', 'Förbrukningsinventarier', 5, '54', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5420', 'Programvaror', 5, '54', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5460', 'Förbrukningsmaterial', 5, '54', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5800', 'Resekostnader', 5, '58', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5910', 'Annonsering', 5, '59', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6071', 'Representation avdragsgill', 6, '60', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6110', 'Kontorsmateriel', 6, '61', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6212', 'Mobiltelefon', 6, '62', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6230', 'Datakommunikation', 6, '62', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6530', 'Redovisningstjänster', 6, '65', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6570', 'Bankavgifter', 6, '65', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6991', 'Övriga avdragsgilla kostnader', 6, '69', 'expense', 'debit', 'k1', true, '7330'); + + -- 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, sru_code) + VALUES + (v_user_id, p_company_id, '7010', 'Löner', 7, '70', 'expense', 'debit', 'k1', true, '7322'), + (v_user_id, p_company_id, '7210', 'Semesterlöner', 7, '72', 'expense', 'debit', 'k1', true, '7322'), + (v_user_id, p_company_id, '7510', 'Arbetsgivaravgifter', 7, '75', 'expense', 'debit', 'k1', true, '7322'); + 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, sru_code) + VALUES + (v_user_id, p_company_id, '7960', 'Valutakursförluster', 7, '79', 'expense', 'debit', 'k1', true, '7360'); + + -- 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, sru_code) + VALUES + (v_user_id, p_company_id, '8310', 'Ränteintäkter', 8, '83', 'revenue', 'credit', 'k1', true, '7313'), + (v_user_id, p_company_id, '8410', 'Räntekostnader', 8, '84', 'expense', 'debit', 'k1', true, '7323'); +END; +$$; + +-- Restore the EXECUTE grant. Each prior DROP/CREATE has silently dropped +-- the grant originally established in 20240101000009; SECURITY DEFINER +-- callers via service role still work, but RPC calls from the +-- authenticated role have been failing with permission errors since +-- 20260330130000. Match the original grant from migration 009. +GRANT EXECUTE ON FUNCTION public.seed_chart_of_accounts(uuid, text) TO authenticated; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/__tests__/seed-chart-of-accounts.pg.test.ts b/supabase/migrations/__tests__/seed-chart-of-accounts.pg.test.ts index 5b608f23..7caa4fbc 100644 --- a/supabase/migrations/__tests__/seed-chart-of-accounts.pg.test.ts +++ b/supabase/migrations/__tests__/seed-chart-of-accounts.pg.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { seedCompany } from '@/tests/pg/fixtures' +import { insertAuthUser, insertCompany, seedCompany } from '@/tests/pg/fixtures' import { getPool } from '@/tests/pg/setup' /** @@ -11,8 +11,40 @@ import { getPool } from '@/tests/pg/setup' * the VAT-rutor mapping both route by account number, so a mislabel in this * function never breaks any other test — only the chart-of-accounts UI shows * the wrong text. This test is the canary. + * + * The diacritic + SRU groups below cover the 2026-05-16 migration that + * restored å/ä/ö in account names and started populating sru_code from BAS + * reference. Both are bytes-on-disk concerns the engine never reads but the + * UI and tax-filing exports do. */ +interface AccountRow { + account_number: string + account_name: string + sru_code: string | null +} + +async function callSeed( + companyId: string, + entityType: 'aktiebolag' | 'enskild_firma', +): Promise { + await getPool().query( + `SELECT public.seed_chart_of_accounts($1::uuid, $2::text)`, + [companyId, entityType], + ) +} + +async function getAccounts(companyId: string): Promise { + const res = await getPool().query( + `SELECT account_number, account_name, sru_code + FROM public.chart_of_accounts + WHERE company_id = $1 + ORDER BY account_number`, + [companyId], + ) + return res.rows +} + describe('seed_chart_of_accounts', () => { it('seeds 26xx VAT accounts with BAS-correct labels', async () => { const { companyId } = await seedCompany() @@ -64,3 +96,164 @@ describe('seed_chart_of_accounts', () => { expect(rows[0].account_name).toMatch(/[Ii]ng.*moms/) }) }) + +describe('seed_chart_of_accounts — Swedish characters', () => { + it('inserts Swedish-character account names byte-for-byte for aktiebolag', async () => { + const { companyId } = await seedCompany() + await callSeed(companyId, 'aktiebolag') + const byNum = new Map((await getAccounts(companyId)).map((r) => [r.account_number, r])) + + // Spot-check every row whose name contains å/ä/ö/Å/Ä/Ö. If diacritics + // were stripped during insertion these assertions are the ones that + // would fail. + const expected: Record = { + '1930': 'Företagskonto / checkkonto', + '1940': 'Övriga bankkonton', + '2099': 'Årets resultat', + '2440': 'Leverantörsskulder', + '2611': 'Utgående moms försäljning inom Sverige, 25%', + '2621': 'Utgående moms försäljning inom Sverige, 12%', + '2631': 'Utgående moms försäljning inom Sverige, 6%', + '2641': 'Debiterad ingående moms', + '2650': 'Redovisningskonto för moms', + '2731': 'Avräkning socialavgifter', + '2893': 'Skuld till aktieägare', + '3001': 'Försäljning tjänster 25%', + '3002': 'Försäljning varor 25%', + '3100': 'Momsfri försäljning', + '3900': 'Övriga rörelseintäkter', + '4000': 'Varuinköp', + '5410': 'Förbrukningsinventarier', + '5460': 'Förbrukningsmaterial', + '6530': 'Redovisningstjänster', + '6991': 'Övriga avdragsgilla kostnader', + '7010': 'Löner', + '7210': 'Semesterlöner', + '7960': 'Valutakursförluster', + '8310': 'Ränteintäkter', + '8410': 'Räntekostnader', + } + + for (const [num, name] of Object.entries(expected)) { + const row = byNum.get(num) + expect(row, `account ${num} should be seeded`).toBeDefined() + expect(row!.account_name, `account ${num} name`).toBe(name) + } + }) + + it('inserts Swedish-character account names for enskild_firma equity accounts', async () => { + // seedCompany() defaults to aktiebolag, so go direct for the EF case. + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId, entityType: 'enskild_firma' }) + await callSeed(companyId, 'enskild_firma') + const byNum = new Map((await getAccounts(companyId)).map((r) => [r.account_number, r])) + + expect(byNum.get('2013')?.account_name).toBe('Övriga egna uttag') + expect(byNum.get('2018')?.account_name).toBe('Övriga egna insättningar') + + // AB-only equity accounts must not appear for enskild_firma. + expect(byNum.has('2081')).toBe(false) + expect(byNum.has('2091')).toBe(false) + expect(byNum.has('2099')).toBe(false) + + // EF equity accounts must NOT carry the AB-oriented INK2 SRU code 7221. + // EF entities file NE-bilaga instead of INK2; emitting `#SRU 2013 7221` + // in a SIE export would steer downstream tax software to report owner + // drawings as balance-sheet equity, which is wrong for sole traders. + expect(byNum.get('2010')?.sru_code).toBeNull() + expect(byNum.get('2013')?.sru_code).toBeNull() + expect(byNum.get('2018')?.sru_code).toBeNull() + }) + + it('stores diacritics as multi-byte UTF-8, not as ASCII folds', async () => { + // octet_length > char_length holds only when the string contains + // multi-byte UTF-8 code units. If a future regression strips å/ä/ö + // again the lengths would become equal and this fails loudly. + const { companyId } = await seedCompany() + await callSeed(companyId, 'aktiebolag') + const res = await getPool().query<{ + account_number: string + octets: number + chars: number + }>( + `SELECT account_number, + octet_length(account_name) AS octets, + char_length(account_name) AS chars + FROM public.chart_of_accounts + WHERE company_id = $1 + AND account_number IN ('2099', '2440', '2611', '2731', '7010', '8410')`, + [companyId], + ) + expect(res.rows.length).toBe(6) + for (const row of res.rows) { + expect( + row.octets, + `account ${row.account_number}: octet_length must exceed char_length`, + ).toBeGreaterThan(row.chars) + } + }) +}) + +describe('seed_chart_of_accounts — SRU codes', () => { + it('populates sru_code for every seeded account so the K1 chart can produce SRU/INK2 filings', async () => { + const { companyId } = await seedCompany() + await callSeed(companyId, 'aktiebolag') + const rows = await getAccounts(companyId) + + const missing = rows.filter((r) => r.sru_code === null) + expect( + missing, + `every seeded row must have sru_code populated. Missing: ${missing + .map((r) => `${r.account_number} (${r.account_name})`) + .join(', ')}`, + ).toHaveLength(0) + }) + + it('maps representative accounts to the SRU codes from BAS reference', async () => { + const { companyId } = await seedCompany() + await callSeed(companyId, 'aktiebolag') + const byNum = new Map((await getAccounts(companyId)).map((r) => [r.account_number, r])) + + // Spot-check values pulled from lib/bookkeeping/bas-data/. These three + // map to Skatteverket SRU fields in INK2; getting them wrong would + // silently break tax filing. + expect(byNum.get('2099')?.sru_code).toBe('7222') + expect(byNum.get('2611')?.sru_code).toBe('7231') + expect(byNum.get('2731')?.sru_code).toBe('7231') + expect(byNum.get('1930')?.sru_code).toBe('7212') + expect(byNum.get('3001')?.sru_code).toBe('7310') + expect(byNum.get('7010')?.sru_code).toBe('7322') + }) +}) + +describe('seed_chart_of_accounts — invariants', () => { + it('is idempotent: a second call on a company that already has accounts is a no-op', async () => { + const { companyId } = await seedCompany() + await callSeed(companyId, 'aktiebolag') + const firstCount = (await getAccounts(companyId)).length + + await callSeed(companyId, 'aktiebolag') + const secondCount = (await getAccounts(companyId)).length + + expect(secondCount).toBe(firstCount) + }) + + it('seeds plan_type=k1 and is_system_account=true on every starter account', async () => { + // These two flags are what the SIE importer's existing-account + // short-circuit relies on to distinguish seeded rows from imported + // ones. Drifting away from them would change the importer's + // behaviour for existing companies silently. + const { companyId } = await seedCompany() + await callSeed(companyId, 'aktiebolag') + const res = await getPool().query<{ bad_plan: number; bad_flag: number }>( + `SELECT + count(*) FILTER (WHERE plan_type IS DISTINCT FROM 'k1')::int AS bad_plan, + count(*) FILTER (WHERE is_system_account IS DISTINCT FROM true)::int AS bad_flag + FROM public.chart_of_accounts + WHERE company_id = $1`, + [companyId], + ) + expect(res.rows[0]!.bad_plan).toBe(0) + expect(res.rows[0]!.bad_flag).toBe(0) + }) +})