feat(settings): add toggle for displaying company name on invoice PDF… (#457)
* feat(settings): add toggle for displaying company name on invoice PDF header * fix(migrations): rename duplicate-timestamped migration to unique version Two migrations shared timestamp 20260513120000, causing schema_migrations PK collision (SQLSTATE 23505) on apply. Bump the VAT seed migration to 20260513120100 so both insert cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,9 +68,10 @@ export async function PUT(request: Request) {
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Lock company_name and org_number after onboarding is complete
|
||||
// Lock org_number after onboarding is complete (legal identifier — changing it
|
||||
// would orphan vouchers, SIE history, and tax filings). company_name remains
|
||||
// editable so users can update their display/brand name (e.g. särskilt företagsnamn).
|
||||
if (oldSettings && (oldSettings as Record<string, unknown>).onboarding_complete === true) {
|
||||
delete (body as Record<string, unknown>).company_name
|
||||
delete (body as Record<string, unknown>).org_number
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,17 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_logo', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa företagsnamn i faktura</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa företagsnamn under loggan i fakturahuvudet</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_company_name ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_company_name', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
|
||||
@@ -399,6 +399,7 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_show_bankgiro: z.boolean().optional(),
|
||||
invoice_show_plusgiro: z.boolean().optional(),
|
||||
invoice_show_logo: z.boolean().optional(),
|
||||
invoice_show_company_name: z.boolean().optional(),
|
||||
invoice_late_fee_text: z.string().nullable().optional(),
|
||||
invoice_credit_terms_text: z.string().nullable().optional(),
|
||||
// AI agent flow
|
||||
|
||||
@@ -377,7 +377,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{company.logo_url && (company.invoice_show_logo ?? true) && (
|
||||
<Image src={company.logo_url} style={{ maxHeight: 40, maxWidth: 150, marginBottom: 6, alignSelf: 'flex-start' }} />
|
||||
)}
|
||||
<Text style={styles.companyName}>{company.company_name}</Text>
|
||||
{(company.invoice_show_company_name ?? true) && (
|
||||
<Text style={styles.companyName}>{company.company_name}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ textAlign: 'right' }}>
|
||||
<Text style={[styles.title, isCreditNote ? styles.creditNoteTitle : {}]}>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add invoice_show_company_name toggle for invoice PDF header.
|
||||
-- When false, the company name is hidden under the logo in the invoice PDF.
|
||||
-- Default true preserves existing invoice layout for all current users.
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN IF NOT EXISTS invoice_show_company_name boolean DEFAULT true;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,138 @@
|
||||
-- Fix seed_chart_of_accounts: VAT account labels did not match BAS 2026.
|
||||
--
|
||||
-- The previous seed (introduced in 20260330130000_multi_tenant_company_refactor.sql)
|
||||
-- created the 26xx output VAT accounts with the wrong account numbers:
|
||||
--
|
||||
-- 2611 labelled "Utgaende moms 12%" ← per BAS, 2611 is 25%
|
||||
-- 2612 labelled "Utgaende moms 6%" ← per BAS, 2612 is "egna uttag 25%"; 6% is 2631
|
||||
-- 2610 was seeded as plain "25%" ← 2610 is a collective parent in BAS, the engine
|
||||
-- does not route to it
|
||||
--
|
||||
-- Per BAS 2026 (BAS-intressenternas Förening) and the rest of this codebase
|
||||
-- (lib/bookkeeping/bas-data, lib/bookkeeping/account-descriptions.ts, the VAT
|
||||
-- declaration mapping, and the engine itself), the correct accounts are:
|
||||
--
|
||||
-- 2611 Utgaende moms forsaljning inom Sverige, 25%
|
||||
-- 2621 Utgaende moms forsaljning inom Sverige, 12%
|
||||
-- 2631 Utgaende moms forsaljning inom Sverige, 6%
|
||||
--
|
||||
-- This migration only fixes the seed function. Existing companies with the bad
|
||||
-- seed 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
|
||||
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)
|
||||
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) — corrected VAT account labels per BAS 2026
|
||||
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, '2611', 'Utgaende moms forsaljning inom Sverige, 25%', 2, '26', 'liability', 'credit', 'k1', true),
|
||||
(v_user_id, p_company_id, '2621', 'Utgaende moms forsaljning inom Sverige, 12%', 2, '26', 'liability', 'credit', 'k1', true),
|
||||
(v_user_id, p_company_id, '2631', 'Utgaende moms forsaljning inom Sverige, 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;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Locks in the labels seed_chart_of_accounts() writes for the 26xx output VAT
|
||||
* accounts. Per BAS 2026: 2611=25%, 2621=12%, 2631=6%.
|
||||
*
|
||||
* Background: this mapping has regressed once already (2026-03-30 multi-tenant
|
||||
* refactor copy-pasted the original 2024 buggy seed back in). The engine and
|
||||
* 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.
|
||||
*/
|
||||
|
||||
describe('seed_chart_of_accounts', () => {
|
||||
it('seeds 26xx VAT accounts with BAS-correct labels', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
const pool = getPool()
|
||||
|
||||
await pool.query(`SELECT public.seed_chart_of_accounts($1::uuid, $2::text)`, [
|
||||
companyId,
|
||||
'aktiebolag',
|
||||
])
|
||||
|
||||
const { rows } = await pool.query<{ account_number: string; account_name: string }>(
|
||||
`SELECT account_number, account_name
|
||||
FROM public.chart_of_accounts
|
||||
WHERE company_id = $1
|
||||
AND account_number IN ('2611', '2621', '2631')
|
||||
ORDER BY account_number`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
const byNumber = Object.fromEntries(rows.map((r) => [r.account_number, r.account_name]))
|
||||
|
||||
expect(byNumber['2611']).toBeDefined()
|
||||
expect(byNumber['2611']).toMatch(/25\s*%/)
|
||||
expect(byNumber['2611']).toMatch(/[Uu]tg.*moms/)
|
||||
|
||||
expect(byNumber['2621']).toBeDefined()
|
||||
expect(byNumber['2621']).toMatch(/12\s*%/)
|
||||
|
||||
expect(byNumber['2631']).toBeDefined()
|
||||
expect(byNumber['2631']).toMatch(/6\s*%/)
|
||||
})
|
||||
|
||||
it('seeds the input VAT account 2641', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
const pool = getPool()
|
||||
|
||||
await pool.query(`SELECT public.seed_chart_of_accounts($1::uuid, $2::text)`, [
|
||||
companyId,
|
||||
'aktiebolag',
|
||||
])
|
||||
|
||||
const { rows } = await pool.query<{ account_name: string }>(
|
||||
`SELECT account_name FROM public.chart_of_accounts
|
||||
WHERE company_id = $1 AND account_number = '2641'`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].account_name).toMatch(/[Ii]ng.*moms/)
|
||||
})
|
||||
})
|
||||
@@ -531,6 +531,7 @@ export function makeCompanySettings(
|
||||
invoice_show_bankgiro: true,
|
||||
invoice_show_plusgiro: true,
|
||||
invoice_show_logo: true,
|
||||
invoice_show_company_name: true,
|
||||
invoice_late_fee_text: null,
|
||||
invoice_credit_terms_text: null,
|
||||
logo_url: null,
|
||||
|
||||
@@ -240,6 +240,7 @@ export interface CompanySettings {
|
||||
invoice_show_bankgiro: boolean
|
||||
invoice_show_plusgiro: boolean
|
||||
invoice_show_logo: boolean
|
||||
invoice_show_company_name: boolean
|
||||
invoice_late_fee_text: string | null
|
||||
invoice_credit_terms_text: string | null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user