fix(arsredovisning): make the aktiekapital note completable via compa… (#1118)

* fix(arsredovisning): make the aktiekapital note completable via company settings

The annual report warned every AB that the aktiekapital note was missing
and pointed at Installningar -> Foretag, but the referenced columns
(aktiekapital, antal_aktier, kvotvarde) never existed and no settings UI
was ever built, so the warning was a dead end and no AB could produce a
complete note before Bolagsverket filing.

- migration 20260723103000: company_settings.aktiekapital (numeric) and
  antal_aktier (integer) with positive CHECKs; kvotvarde is intentionally
  not stored since ABL 1 kap 6 defines it as aktiekapital / antal aktier
- build-data.ts (K2 and K3 note paths): select only the two stored
  columns and derive kvotvarde with roundOre
- UpdateSettingsSchema: aktiekapital (positive), antal_aktier (positive
  integer), both nullable to allow clearing
- new ShareCapitalForm section on Installningar -> Foretag, rendered for
  aktiebolag only, with live derived kvotvarde display; wired through the
  existing CompanySettingsContent save path (empty string clears to null)
- sv/en strings; settings route tests (round-trip, clear, 400 on invalid);
  builder tests for derived kvotvarde and the empty-settings warning

Staging (metjnjrhvujscngnpzdv) already has the columns applied and the
note verified end-to-end against a rehearsal company.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(arsredovisning): address PR review findings on the share-capital note

- enforce aktiekapital/antal_aktier as an all-or-nothing pair (DB CHECK,
  K2/K3 note guard now requires both, partial pair warns instead)
- numeric(15,2) column, .int() Zod constraint, maxFractionDigits 0 render
- guard numberOrNull against NaN; align kvotvarde preview with schema
- strengthen clearing test, add fractional and partial-pair tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-23 11:41:14 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 466e55a015
commit b0044bfe98
11 changed files with 287 additions and 20 deletions
+3
View File
@@ -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(),
@@ -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
+26 -18
View File
@@ -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(