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:
co-authored by
Claude Fable 5
parent
466e55a015
commit
b0044bfe98
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user