diff --git a/DECISIONS.md b/DECISIONS.md index 2039f007..275bf7d1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -310,3 +310,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-23] Validate preview-PDF payment settings before fetching customer data using the requested currency and document type: this preserves the same exemption semantics while minimizing personal-data processing for requests that cannot render. [2026-07-23] Retain an exact pending delivery snapshot when the provider succeeds but the terminal evidence RPC cannot be confirmed, and keep it outside the preparing-only reservation lock: inventing a sent state would be unsafe, while immutable payload, PDF, operator warnings, and later explicit resend availability preserve evidence and recovery. [2026-07-23] Keep the invoice-delivery DPIA as a documented screening rather than fabricating a full Article 35 assessment or DPO sign-off: the screened processing does not meet the high-risk threshold, and the implemented controls minimize routine access while preserving statutory evidence. +[2026-07-23] Derive kvotvärde (aktiekapital / antal_aktier) in the annual-report note instead of adding the kvotvarde column the builder used to read: a stored third value could desync from the other two and file an internally inconsistent Bolagsverket note; ABL 1 kap 6 § makes it purely derived. diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index bbe9b5f0..c741cb8d 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -97,6 +97,54 @@ describe('PUT /api/settings', () => { expect(deadlineMocks.regenerate).not.toHaveBeenCalled() }) + it('round-trips share capital fields and clears them with null', async () => { + const updates = { aktiekapital: 25000, antal_aktier: 500 } + enqueueMany([ + { data: { entity_type: 'aktiebolag', onboarding_complete: true } }, + { data: { id: 's1', ...updates } }, + { data: null, count: 5 }, + ]) + + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: updates, + }), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: typeof updates }>(response) + + expect(status).toBe(200) + expect(body.data).toMatchObject(updates) + + enqueueMany([ + { data: { entity_type: 'aktiebolag', onboarding_complete: true } }, + { data: { id: 's1', aktiekapital: null, antal_aktier: null } }, + { data: null, count: 5 }, + ]) + const clearResponse = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: { aktiekapital: null, antal_aktier: null }, + }), { params: Promise.resolve({}) }) + const cleared = await parseJsonResponse<{ data: Record }>(clearResponse) + expect(cleared.status).toBe(200) + expect(cleared.body.data.aktiekapital).toBeNull() + expect(cleared.body.data.antal_aktier).toBeNull() + }) + + it('rejects non-positive aktiekapital and fractional antal_aktier', async () => { + for (const body of [ + { aktiekapital: 0 }, + { aktiekapital: -25000 }, + { aktiekapital: 25000.5 }, + { antal_aktier: 0 }, + { antal_aktier: 500.5 }, + ]) { + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body, + }), { params: Promise.resolve({}) }) + expect((await parseJsonResponse(response)).status).toBe(400) + } + }) + it('updates invoice email recipients and payment accounts', async () => { const updates = { invoice_email_cc_addresses: ['info@example.com', 'owner@example.com'], diff --git a/components/settings/ShareCapitalForm.tsx b/components/settings/ShareCapitalForm.tsx new file mode 100644 index 00000000..193dd469 --- /dev/null +++ b/components/settings/ShareCapitalForm.tsx @@ -0,0 +1,82 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { roundOre } from '@/lib/money' +import { formatCurrency } from '@/lib/utils' +import type { CompanySettings } from '@/types' + +interface ShareCapitalFormProps { + settings: CompanySettings +} + +/** + * Registered share capital per Bolagsverket, feeding the statutory + * aktiekapital note in the annual report. Kvotvärde (ABL 1 kap 6 §: + * aktiekapital / antal aktier) is derived, never entered. + */ +export function ShareCapitalForm({ settings }: ShareCapitalFormProps) { + const t = useTranslations('settings_company') + const [aktiekapital, setAktiekapital] = useState( + settings.aktiekapital != null ? String(settings.aktiekapital) : '', + ) + const [antalAktier, setAntalAktier] = useState( + settings.antal_aktier != null ? String(settings.antal_aktier) : '', + ) + + const capital = Number(aktiekapital) + const shares = Number(antalAktier) + // Mirror UpdateSettingsSchema: whole-krona capital > 0, positive integer + // share count. No preview for values the server would reject. + const kvotvarde = + Number.isSafeInteger(capital) && capital > 0 && Number.isSafeInteger(shares) && shares > 0 + ? roundOre(capital / shares) + : null + + return ( +
+

+ {t('share_capital_heading')} +

+ +
+
+ + setAktiekapital(e.target.value)} + /> +

{t('aktiekapital_help')}

+
+
+ + setAntalAktier(e.target.value)} + /> +

{t('antal_aktier_help')}

+
+
+ + {kvotvarde !== null && ( +

+ {t('kvotvarde_display', { value: formatCurrency(kvotvarde) })} +

+ )} +
+ ) +} diff --git a/components/settings/sections/CompanySettingsContent.tsx b/components/settings/sections/CompanySettingsContent.tsx index 6a8cb087..4c461d3b 100644 --- a/components/settings/sections/CompanySettingsContent.tsx +++ b/components/settings/sections/CompanySettingsContent.tsx @@ -10,6 +10,7 @@ import { LogoUpload } from '@/components/settings/LogoUpload' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' import { SettingsLoadError } from '@/components/settings/SettingsLoadError' import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' +import { ShareCapitalForm } from '@/components/settings/ShareCapitalForm' import { useSettings } from '@/components/settings/useSettings' import type { CompanySettings } from '@/types' @@ -21,6 +22,14 @@ export function CompanySettingsContent() { if (!settings) return function handleSave(formData: FormData) { + // Empty string clears the value (schema accepts null, not ''). + const numberOrNull = (name: string) => { + const raw = String(formData.get(name) ?? '').trim() + if (raw === '') return null + const parsed = Number(raw) + // NaN would serialize to null in JSON and silently clear the value. + return Number.isFinite(parsed) ? parsed : null + } const updates: Record = { ...(formData.has('company_name') && { company_name: formData.get('company_name') as string }), ...(formData.has('org_number') && { org_number: formData.get('org_number') as string }), @@ -30,6 +39,8 @@ export function CompanySettingsContent() { phone: (formData.get('phone') as string) || '', email: (formData.get('email') as string) || '', website: (formData.get('website') as string) || '', + ...(formData.has('aktiekapital') && { aktiekapital: numberOrNull('aktiekapital') }), + ...(formData.has('antal_aktier') && { antal_aktier: numberOrNull('antal_aktier') }), } return { updates, @@ -48,6 +59,7 @@ export function CompanySettingsContent() {
+ {settings.entity_type === 'aktiebolag' && }
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 2175490e..461a3ceb 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1599,6 +1599,9 @@ export const UpdateSettingsSchema = z.object({ tax_contact_email: z.string().email().nullable().optional().or(z.literal('')), fiscal_year_start_month: z.number().int().min(1).max(12).optional(), preliminary_tax_monthly: z.number().nullable().optional(), + // Share capital per Bolagsverket (annual report aktiekapital note). + aktiekapital: z.number().int('Aktiekapital anges i hela kronor').positive('Aktiekapital måste vara större än 0').nullable().optional(), + antal_aktier: z.number().int('Antal aktier måste vara ett heltal').positive('Antal aktier måste vara större än 0').nullable().optional(), employer_registered: z.boolean().nullable().optional(), employer_seasonal: z.boolean().optional(), bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').nullable().optional(), diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts index fd745ae2..56705227 100644 --- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts +++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts @@ -46,6 +46,7 @@ function makeSupabase(opts: { accountingFramework: 'k2' | 'k3' entityType?: string aktiekapital?: number | null + antalAktier?: number | null agmDate?: string | null }): ChainableMock { const from = vi.fn((table: string) => { @@ -83,8 +84,12 @@ function makeSupabase(opts: { address: { city: 'Stockholm' }, entity_type: opts.entityType ?? 'aktiebolag', aktiekapital: opts.aktiekapital ?? null, - antal_aktier: opts.aktiekapital ? 500 : null, - kvotvarde: opts.aktiekapital ? 100 : null, + antal_aktier: + opts.antalAktier !== undefined + ? opts.antalAktier + : opts.aktiekapital + ? 500 + : null, }, error: null, }), @@ -340,6 +345,37 @@ describe('buildArsredovisningData: K3', () => { ).toBeDefined() }) + it('derives kvotvärde in the aktiekapital note instead of reading a stored column', async () => { + const supabase = makeSupabase({ accountingFramework: 'k3', aktiekapital: 25_000 }) + // @ts-expect-error: chainable mock isn't fully typed as SupabaseClient + const data = await buildArsredovisningData(supabase, 'co1', 'fp1') + const note = data.noter.find((n) => n.title === 'Aktiekapital') + expect(note).toBeDefined() + // 25 000 kr / 500 aktier per the settings mock (ABL 1 kap 6 §). + expect(note!.body).toContain('Antal aktier: 500.') + expect(note!.body).toContain('Kvotvärde per aktie: 50 kr.') + }) + + it('warns instead of emitting an aktiekapital note when settings are empty', async () => { + const supabase = makeSupabase({ accountingFramework: 'k3' }) + // @ts-expect-error: chainable mock isn't fully typed as SupabaseClient + const data = await buildArsredovisningData(supabase, 'co1', 'fp1') + expect(data.noter.find((n) => n.title === 'Aktiekapital')).toBeUndefined() + expect(data.warnings.find((w) => w.startsWith('Aktiekapitalnoten saknas'))).toBeDefined() + }) + + it('treats a partial share-capital pair as missing (warns, no note) for K3', async () => { + const supabase = makeSupabase({ + accountingFramework: 'k3', + aktiekapital: 25_000, + antalAktier: null, + }) + // @ts-expect-error: chainable mock isn't fully typed as SupabaseClient + const data = await buildArsredovisningData(supabase, 'co1', 'fp1') + expect(data.noter.find((n) => n.title === 'Aktiekapital')).toBeUndefined() + expect(data.warnings.find((w) => w.startsWith('Aktiekapitalnoten saknas'))).toBeDefined() + }) + it('DROPS the old "K3 noter need manual augmentation" warning text', async () => { const supabase = makeSupabase({ accountingFramework: 'k3' }) // @ts-expect-error: chainable mock isn't fully typed as SupabaseClient @@ -421,6 +457,28 @@ describe('buildArsredovisningData: K2 byte-equivalence', () => { expect(principles!.body).toContain('BFNAR 2016:10') }) + it('derives kvotvärde in the K2 aktiekapital note', async () => { + const supabase = makeSupabase({ accountingFramework: 'k2', aktiekapital: 25_000 }) + // @ts-expect-error: chainable mock isn't fully typed as SupabaseClient + const data = await buildArsredovisningData(supabase, 'co1', 'fp1') + const note = data.noter.find((n) => n.title === 'Aktiekapital') + expect(note).toBeDefined() + expect(note!.body).toContain('Antal aktier: 500.') + expect(note!.body).toContain('Kvotvärde per aktie: 50 kr.') + }) + + it('treats a partial share-capital pair as missing (warns, no note) for K2', async () => { + const supabase = makeSupabase({ + accountingFramework: 'k2', + aktiekapital: 25_000, + antalAktier: null, + }) + // @ts-expect-error: chainable mock isn't fully typed as SupabaseClient + const data = await buildArsredovisningData(supabase, 'co1', 'fp1') + expect(data.noter.find((n) => n.title === 'Aktiekapital')).toBeUndefined() + expect(data.warnings.find((w) => w.startsWith('Aktiekapitalnoten saknas'))).toBeDefined() + }) + it('does NOT call generateKassaflodesanalys for K2', async () => { const supabase = makeSupabase({ accountingFramework: 'k2' }) // @ts-expect-error: chainable mock isn't fully typed as SupabaseClient diff --git a/lib/bokslut/arsredovisning/build-data.ts b/lib/bokslut/arsredovisning/build-data.ts index c8a741f6..941ea993 100644 --- a/lib/bokslut/arsredovisning/build-data.ts +++ b/lib/bokslut/arsredovisning/build-data.ts @@ -4,6 +4,7 @@ import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys' import { listAssets } from '@/lib/bokslut/assets/asset-service' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { LATENT_TAX_DEFAULT_RATE } from '@/lib/bokslut/tax-provision/latent-tax-calculator' +import { roundOre } from '@/lib/money' import { mapTrialBalancesToK2, type K2MappingResult, @@ -526,23 +527,27 @@ async function buildK2Noter( if (maybeAb) { const { data: settings } = await supabase .from('company_settings') - .select('aktiekapital, antal_aktier, kvotvarde') + .select('aktiekapital, antal_aktier') .eq('company_id', companyId) .maybeSingle() - type AktiekapitalShape = { aktiekapital?: number | null; antal_aktier?: number | null; kvotvarde?: number | null } + type AktiekapitalShape = { aktiekapital?: number | null; antal_aktier?: number | null } const ak = settings as AktiekapitalShape | null const aktiekapital = ak?.aktiekapital ?? null const antalAktier = ak?.antal_aktier ?? null - const kvotvarde = ak?.kvotvarde ?? null - if (aktiekapital || antalAktier) { - const parts: string[] = [] - if (aktiekapital) parts.push(`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE')} kr.`) - if (antalAktier) parts.push(`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`) - if (kvotvarde) parts.push(`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`) + // Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier; + // deriving it here keeps the filed note internally consistent. ÅRL + // 5 kap 14 § requires BOTH the registered amount and the number of + // shares, so a partial pair is treated as missing (warn, no note). + if (aktiekapital && antalAktier) { + const kvotvarde = roundOre(aktiekapital / antalAktier) notes.push({ number: notes.length + 1, title: 'Aktiekapital', - body: parts.join(' '), + body: [ + `Aktiekapital: ${aktiekapital.toLocaleString('sv-SE', { maximumFractionDigits: 0 })} kr.`, + `Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`, + `Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`, + ].join(' '), }) } else { // Don't write a "saknas: komplettera" placeholder into the PDF body: // that text would land in the Bolagsverket-filed document as a user- @@ -778,27 +783,30 @@ async function buildK3Noter( if (maybeAb) { const { data: settings } = await supabase .from('company_settings') - .select('aktiekapital, antal_aktier, kvotvarde') + .select('aktiekapital, antal_aktier') .eq('company_id', companyId) .maybeSingle() type AktiekapitalShape = { aktiekapital?: number | null antal_aktier?: number | null - kvotvarde?: number | null } const ak = settings as AktiekapitalShape | null const aktiekapital = ak?.aktiekapital ?? null const antalAktier = ak?.antal_aktier ?? null - const kvotvarde = ak?.kvotvarde ?? null - if (aktiekapital || antalAktier) { - const parts: string[] = [] - if (aktiekapital) parts.push(`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE')} kr.`) - if (antalAktier) parts.push(`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`) - if (kvotvarde) parts.push(`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`) + // Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier; + // deriving it here keeps the filed note internally consistent. ÅRL + // 5 kap 14 § requires BOTH the registered amount and the number of + // shares, so a partial pair is treated as missing (warn, no note). + if (aktiekapital && antalAktier) { + const kvotvarde = roundOre(aktiekapital / antalAktier) notes.push({ number: notes.length + 1, title: 'Aktiekapital', - body: parts.join(' '), + body: [ + `Aktiekapital: ${aktiekapital.toLocaleString('sv-SE', { maximumFractionDigits: 0 })} kr.`, + `Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`, + `Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`, + ].join(' '), }) } else if (isAb) { warnings.push( diff --git a/messages/en.json b/messages/en.json index 2346189b..ecb1aa3e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1322,6 +1322,12 @@ }, "settings_company": { "company_info_heading": "Company details", + "share_capital_heading": "Share capital", + "aktiekapital_label": "Share capital (SEK)", + "aktiekapital_help": "Registered share capital per Bolagsverket. Used in the annual report note on share capital.", + "antal_aktier_label": "Number of shares", + "antal_aktier_help": "Total number of shares per Bolagsverket.", + "kvotvarde_display": "Quota value per share: {value}", "company_name_label": "Company name", "company_name_help": "Shown on invoices, emails and declaration files. For a sole trader it is usually your own name (First Last).", "org_number_label": "Organisation number", diff --git a/messages/sv.json b/messages/sv.json index 2fa103af..f984da05 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1322,6 +1322,12 @@ }, "settings_company": { "company_info_heading": "Företagsuppgifter", + "share_capital_heading": "Aktiekapital", + "aktiekapital_label": "Aktiekapital (kr)", + "aktiekapital_help": "Registrerat aktiekapital enligt Bolagsverket. Används i årsredovisningens not om aktiekapital.", + "antal_aktier_label": "Antal aktier", + "antal_aktier_help": "Totalt antal aktier enligt Bolagsverket.", + "kvotvarde_display": "Kvotvärde per aktie: {value}", "company_name_label": "Företagsnamn", "company_name_help": "Visas på fakturor, e-post och deklarationsfiler. För enskild firma är det vanligtvis ditt eget namn (Förnamn Efternamn).", "org_number_label": "Organisationsnummer", diff --git a/supabase/migrations/20260723103000_company_settings_share_capital.sql b/supabase/migrations/20260723103000_company_settings_share_capital.sql new file mode 100644 index 00000000..358ae77a --- /dev/null +++ b/supabase/migrations/20260723103000_company_settings_share_capital.sql @@ -0,0 +1,38 @@ +-- Share-capital facts for the annual report aktiekapital note (K2/K3, ÅRL). +-- The report builder has read these since the ÅR feature shipped, but the +-- columns were never created, so the "fyll i under Inställningar → Företag" +-- warning was a dead end for every AB. Kvotvärde is intentionally NOT stored: +-- ABL 1 kap 6 § defines it as aktiekapital / antal aktier, so it is derived +-- at read time to keep a Bolagsverket-filed note internally consistent. + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS aktiekapital numeric(15,2), + ADD COLUMN IF NOT EXISTS antal_aktier integer; + +COMMENT ON COLUMN public.company_settings.aktiekapital IS + 'Registered share capital in SEK per Bolagsverket. Used for the aktiekapital note in the annual report; may differ from the booked 2081 balance during a pending emission.'; +COMMENT ON COLUMN public.company_settings.antal_aktier IS + 'Total number of issued shares per Bolagsverket. Used for the aktiekapital note; kvotvärde is derived as aktiekapital / antal_aktier.'; + +ALTER TABLE public.company_settings + DROP CONSTRAINT IF EXISTS company_settings_aktiekapital_positive; +ALTER TABLE public.company_settings + ADD CONSTRAINT company_settings_aktiekapital_positive + CHECK (aktiekapital IS NULL OR aktiekapital > 0); + +ALTER TABLE public.company_settings + DROP CONSTRAINT IF EXISTS company_settings_antal_aktier_positive; +ALTER TABLE public.company_settings + ADD CONSTRAINT company_settings_antal_aktier_positive + CHECK (antal_aktier IS NULL OR antal_aktier > 0); + +-- The aktiekapital note (ÅRL 5 kap 14 §) needs both the registered amount +-- and the number of shares; a lone value would produce an incomplete +-- statutory note, so the pair is all-or-nothing. +ALTER TABLE public.company_settings + DROP CONSTRAINT IF EXISTS company_settings_share_capital_pair; +ALTER TABLE public.company_settings + ADD CONSTRAINT company_settings_share_capital_pair + CHECK ((aktiekapital IS NULL) = (antal_aktier IS NULL)); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 674f4301..503d59bd 100644 --- a/types/index.ts +++ b/types/index.ts @@ -284,6 +284,11 @@ export interface CompanySettings { // Preliminary tax preliminary_tax_monthly: number | null + // Share capital per Bolagsverket (aktiekapital note in the annual report). + // Kvotvärde is derived as aktiekapital / antal_aktier, never stored. + aktiekapital?: number | null + antal_aktier?: number | null + // Bank details for invoices bank_name: string | null clearing_number: string | null