feat(arsredovisning): manual override for medelantal anställda (Not 2) (#2420)

* feat(arsredovisning): manual override for medelantal anstallda (Not 2)

Why the problem occurred: the ÅRL 5:20 § note was derived only from the
employees table, and most aktiebolag that book salary never create a
Löner employee record (hand-booked salary, SIE import, migrated
history). In prod 148 of 195 aktiebolag with posted 70xx-73xx lines have
no employees rows, so their note reads "inga anställda". The reporting
company had one row created the same day with a start date halfway
through a July-June year: 181/365 = 0.5 FTE rounds to 0.

What was removed or simplified: nothing removed. One resolver
(resolveMedelantalAnstallda: override, else FTE average) now feeds the
K2 note, the K3 note and the iXBRL fact, so no reader can pick a
different number. The override sits on arsredovisning_narratives next to
the other ÅRL 5 kap. disclosures and rides the existing narrative
GET/POST route, service and page save.

Why this and not the proposed one: the request asked support to "enable
override of Not 2" as free text. A whole number keeps the statutory
sentence intact and the iXBRL MedelantaletAnstallda fact taggable; free
text would allow a non-compliant note. Rounding 0.5 up globally was
rejected (changes every company's note silently, does nothing for
companies with no employees rows), as was backdating the hire date
(fixes one company, misstates the fact).

The iXBRL input also reads the previous period's override so the
jämförelseår column shows what last year's document showed.

Migration 20260908130127 is additive (nullable INTEGER with a CHECK) and
is applied and tracked on staging.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD

* fix(arsredovisning): size-threshold metrics use the medelantal override too

Skeptic refutation on 442fa1bc5: reportMetrics in model.ts still read
the FTE average from the employees table, so the ÅRL 1:3 § större-
företag test and the K2 relief thresholds could disagree with the figure
Not 2 and the iXBRL fact disclose. A SIE-migrated aktiebolag with no
employees rows and an override of 60 both years, balansomslutning over
40 MSEK, would have validated as K2-eligible while its own document said
60 employees.

Fix: the metrics resolve the employee figure the same way the note does
(current period override from report.disclosures, previous period via
getMedelantalOverride on that period's narrative row). previous_period
on ArsredovisningData now carries the period id so the lookup needs no
extra fiscal_periods read. The iXBRL employees-error fallback keeps the
previous period's override instead of blanking the jämförelseår.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-08 13:44:29 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 59d5b7b366
commit e85eac317c
16 changed files with 382 additions and 30 deletions
@@ -159,6 +159,7 @@ function makeMinimalK3Data(): ArsredovisningData {
parent_company_name: null,
parent_company_org_number: null,
parent_company_city: null,
medelantal_anstallda_override: null,
confirmations: {
long_term_debt_over_five_years: true,
securities_pledged: true,
@@ -214,6 +215,7 @@ describe('ArsredovisningK3PDF', () => {
it('renders the jämförelseår column when previous_period is set', async () => {
const data = makeMinimalK3Data()
data.previous_period = {
id: 'fp-2024',
name: '2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
@@ -52,6 +52,7 @@ function makeSupabase(opts: {
antalAktier?: number | null
agmDate?: string | null
previousPeriodId?: string | null
medelantalOverride?: number | null
}): ChainableMock {
const from = vi.fn((table: string) => {
if (table === 'fiscal_periods') {
@@ -124,14 +125,16 @@ function makeSupabase(opts: {
eq: () => ({
maybeSingle: () =>
Promise.resolve({
data: opts.agmDate
? {
agm_date: opts.agmDate,
description: null,
important_events: null,
resultatdisposition: null,
}
: null,
data:
opts.agmDate || opts.medelantalOverride != null
? {
agm_date: opts.agmDate ?? null,
description: null,
important_events: null,
resultatdisposition: null,
medelantal_anstallda_override: opts.medelantalOverride ?? null,
}
: null,
error: null,
}),
}),
@@ -288,6 +291,32 @@ beforeEach(() => {
plantStandardReports()
})
describe('buildArsredovisningData: medelantal anställda override (ÅRL 5:20 §)', () => {
it.each(['k2', 'k3'] as const)(
'%s: without an override the note reports no employees',
async (framework) => {
const supabase = makeSupabase({ accountingFramework: framework })
// @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 === 'Medelantal anställda')
expect(note?.body).toContain('inte haft några anställda')
expect(data.disclosures.medelantal_anstallda_override).toBeNull()
},
)
it.each(['k2', 'k3'] as const)(
'%s: a manual figure on the narrative replaces the computed note',
async (framework) => {
const supabase = makeSupabase({ accountingFramework: framework, medelantalOverride: 1 })
// @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 === 'Medelantal anställda')
expect(note?.body).toBe('Under räkenskapsåret har medeltalet anställda uppgått till 1.')
expect(data.disclosures.medelantal_anstallda_override).toBe(1)
},
)
})
describe('buildArsredovisningData: K3', () => {
it('records accounting_framework=k3 in the output', async () => {
const supabase = makeSupabase({ accountingFramework: 'k3' })
@@ -116,6 +116,7 @@ function makeLossYearData(framework: 'k2' | 'k3'): ArsredovisningData {
parent_company_name: null,
parent_company_org_number: null,
parent_company_city: null,
medelantal_anstallda_override: null,
confirmations: {
long_term_debt_over_five_years: true,
securities_pledged: true,
@@ -0,0 +1,80 @@
/**
* The size metrics behind the ÅRL 1:3 § (större företag) and K2-relief
* checks must disclose the same employee figure as Not 2 and the iXBRL
* fact. The skeptic on the medelantal override found the metrics still
* reading the FTE average while the note used the manual figure: a
* SIE-migrated company with no employees rows and an override of 60 would
* have validated as a mindre företag while its own document said 60.
*/
import { describe, it, expect } from 'vitest'
import { reportMetrics } from '../model'
import type { buildArsredovisningData } from '../build-data'
type Report = Awaited<ReturnType<typeof buildArsredovisningData>>
function makeReport(overrides: {
currentOverride: number | null
withPrevious?: boolean
}): Report {
return {
fiscal_period: {
id: 'fp-2025',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
},
previous_period: overrides.withPrevious
? { id: 'fp-2024', name: '2024', period_start: '2024-01-01', period_end: '2024-12-31' }
: null,
forvaltningsberattelse: {
flerarsoversikt: [
{ year: '2025', net_revenue: 50_000_000 },
{ year: '2024', net_revenue: 50_000_000 },
],
},
balansrakning: {
total_assets: 45_000_000,
total_assets_previous: overrides.withPrevious ? 45_000_000 : null,
},
disclosures: {
medelantal_anstallda_override: overrides.currentOverride,
},
} as unknown as Report
}
const fullYearEmployee = {
employment_start: '2020-01-01',
employment_end: null,
employment_degree: 100,
}
describe('reportMetrics: employee figure follows the medelantal override', () => {
it('uses the FTE average from employees when no override is set', () => {
const metrics = reportMetrics(makeReport({ currentOverride: null }), [fullYearEmployee])
expect(metrics.current.employees).toBe(1)
})
it('uses the current period override for the current year', () => {
const metrics = reportMetrics(makeReport({ currentOverride: 60 }), [])
expect(metrics.current.employees).toBe(60)
})
it('uses the previous period override for the jämförelseår', () => {
const metrics = reportMetrics(
makeReport({ currentOverride: 60, withPrevious: true }),
[],
60,
)
expect(metrics.previous?.employees).toBe(60)
})
it('falls back to the FTE average for the previous year when it has no override', () => {
const metrics = reportMetrics(
makeReport({ currentOverride: 2, withPrevious: true }),
[fullYearEmployee],
null,
)
expect(metrics.current.employees).toBe(2)
expect(metrics.previous?.employees).toBe(1)
})
})
+12 -4
View File
@@ -25,7 +25,7 @@ import {
type AnlaggningAsset,
} from './anlaggningstillgangar-note'
import { computeAssetNoteFigures, loadPostedSchedules } from './asset-note-figures'
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
import { resolveMedelantalAnstallda } from '@/lib/salary/medelantal'
import type {
ArsredovisningData,
EgenKapitalRow,
@@ -202,6 +202,7 @@ export async function buildArsredovisningData(
const previousPeriod =
prevPeriodRow && previousTb
? {
id: prevPeriodRow.id,
name: prevPeriodRow.name,
period_start: prevPeriodRow.period_start,
period_end: prevPeriodRow.period_end,
@@ -449,6 +450,7 @@ export async function buildArsredovisningData(
parent_company_name: narrative?.parent_company_name ?? null,
parent_company_org_number: narrative?.parent_company_org_number ?? null,
parent_company_city: narrative?.parent_company_city ?? null,
medelantal_anstallda_override: narrative?.medelantal_anstallda_override ?? null,
confirmations: {
long_term_debt_over_five_years:
narrative?.long_term_debt_over_five_years_confirmed ?? false,
@@ -759,7 +761,11 @@ async function buildK2Noter(
// ÅRL 5:20 § requires the note for AB regardless of value: "0" must be
// disclosed as "Inga anställda". For enskild firma the disclosure is
// discretionary, so we still skip when medelantal === 0 there.
const medelantal = computeMedelantalAnstallda(
// A manual figure on arsredovisning_narratives wins over the FTE average:
// salary booked without a Löner employee record (hand-booked, SIE import)
// otherwise reads as "inga anställda" although the owner drew salary.
const medelantal = resolveMedelantalAnstallda(
narrative?.medelantal_anstallda_override,
(employeesResult.data ?? []) as Array<{
employment_start: string
employment_end: string | null
@@ -1157,10 +1163,12 @@ async function buildK3Noter(
)
}
// 5. Medelantal anställda: FTE-weighted average per ÅRL 5:20 §. The note is
// 5. Medelantal anställda: FTE-weighted average per ÅRL 5:20 §, or the
// manual figure on arsredovisning_narratives when set. The note is
// statutory for AB regardless of value (disclose "0" explicitly); for non-AB
// entities we still skip when there are no employees.
const medelantal = computeMedelantalAnstallda(
const medelantal = resolveMedelantalAnstallda(
narrative?.medelantal_anstallda_override,
(employeesResult.data ?? []) as Array<{
employment_start: string
employment_end: string | null
+26 -5
View File
@@ -15,7 +15,8 @@ import {
type CanonicalAnnualReport,
} from './compliance-types'
import { buildIxbrlInput, type BuildIxbrlOptions } from '@/lib/bokslut/ixbrl/build-input'
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
import { resolveMedelantalAnstallda } from '@/lib/salary/medelantal'
import { getMedelantalOverride } from './narrative-service'
export interface BuildCanonicalAnnualReportOptions extends BuildIxbrlOptions {
stage?: AnnualReportValidationStage
@@ -29,9 +30,17 @@ interface EmployeeRow {
employment_degree: number
}
function reportMetrics(
/**
* Size metrics for the ÅRL 1:3 § and K2-relief thresholds. The employee
* figure is the same one the note and the iXBRL fact disclose: a manual
* override on arsredovisning_narratives for the period wins over the FTE
* average, so the eligibility verdict and the document cannot disagree
* about how many people the company employs.
*/
export function reportMetrics(
report: Awaited<ReturnType<typeof buildArsredovisningData>>,
employees: EmployeeRow[],
previousMedelantalOverride: number | null = null,
): AnnualReportSizeMetrics {
const currentOverview = report.forvaltningsberattelse.flerarsoversikt.find(
(row) => row.year === report.fiscal_period.name,
@@ -43,7 +52,8 @@ function reportMetrics(
: null
return {
current: {
employees: computeMedelantalAnstallda(
employees: resolveMedelantalAnstallda(
report.disclosures.medelantal_anstallda_override,
employees,
report.fiscal_period.period_start,
report.fiscal_period.period_end,
@@ -53,7 +63,8 @@ function reportMetrics(
},
previous: report.previous_period
? {
employees: computeMedelantalAnstallda(
employees: resolveMedelantalAnstallda(
previousMedelantalOverride,
employees,
report.previous_period.period_start,
report.previous_period.period_end,
@@ -106,7 +117,17 @@ export async function buildCanonicalAnnualReport(
signed_at: signature.signed_at,
}))
const metrics = reportMetrics(report, (employeesResult.data ?? []) as EmployeeRow[])
// The jämförelseår's manual figure lives on that period's narrative row;
// without it a SIE-migrated company with no employees rows would count
// as 0 last year and dodge the two-year ÅRL 1:3 § test.
const previousMedelantalOverride = report.previous_period
? await getMedelantalOverride(supabase, companyId, report.previous_period.id)
: null
const metrics = reportMetrics(
report,
(employeesResult.data ?? []) as EmployeeRow[],
previousMedelantalOverride,
)
const eligibility = evaluateAnnualReportEligibility({
entityType: report.company.entity_type,
framework: report.accounting_framework,
@@ -42,6 +42,10 @@ export interface NarrativeOverrides {
parent_company_name: string | null
parent_company_org_number: string | null
parent_company_city: string | null
/** ÅRL 5:20 §: manual medelantal anställda. Null → computed as an FTE
* average over the employees table. A whole number replaces the computed
* value in the note and the iXBRL fact for this period. */
medelantal_anstallda_override: number | null
long_term_debt_over_five_years_confirmed: boolean
securities_pledged_confirmed: boolean
contingent_liabilities_confirmed: boolean
@@ -70,6 +74,7 @@ export interface NarrativeRow {
parent_company_name: string | null
parent_company_org_number: string | null
parent_company_city: string | null
medelantal_anstallda_override: number | null
long_term_debt_over_five_years_confirmed: boolean
securities_pledged_confirmed: boolean
contingent_liabilities_confirmed: boolean
@@ -85,7 +90,31 @@ const TABLE = 'arsredovisning_narratives'
// of API responses. GDPR Art.25.2 / ISO A.8.3 data-minimization: callers
// only need the narrative content + last-updated timestamp.
const NARRATIVE_API_COLUMNS =
'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, proposed_dividend, agm_date, long_term_debt_over_five_years, securities_pledged, contingent_liabilities, parent_company_name, parent_company_org_number, parent_company_city, long_term_debt_over_five_years_confirmed, securities_pledged_confirmed, contingent_liabilities_confirmed, parent_company_confirmed, agm_disposition_outcome, agm_disposition_decision, updated_at'
'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, proposed_dividend, agm_date, long_term_debt_over_five_years, securities_pledged, contingent_liabilities, parent_company_name, parent_company_org_number, parent_company_city, medelantal_anstallda_override, long_term_debt_over_five_years_confirmed, securities_pledged_confirmed, contingent_liabilities_confirmed, parent_company_confirmed, agm_disposition_outcome, agm_disposition_decision, updated_at'
/**
* The medelantal anställda override alone, for a period other than the one
* being built (the iXBRL note shows the jämförelseår in the same table, so
* last year's manual figure must win there too). Null when no row or no
* override; errors are swallowed because a missing jämförelsetal must never
* block the current year's document.
*/
export async function getMedelantalOverride(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<number | null> {
const { data, error } = await supabase
.from(TABLE)
.select('medelantal_anstallda_override')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.maybeSingle()
if (error || !data) return null
const value = (data as { medelantal_anstallda_override: number | null })
.medelantal_anstallda_override
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/**
* Load persisted narrative overrides for a fiscal period. Returns null when
+5
View File
@@ -73,6 +73,7 @@ export interface ArsredovisningData {
* Null for the company's first fiscal year, or when the previous year's
* trial balance could not be generated (a warning is emitted then). */
previous_period: {
id: string
name: string
period_start: string
period_end: string
@@ -155,6 +156,10 @@ export interface ArsredovisningData {
parent_company_name: string | null
parent_company_org_number: string | null
parent_company_city: string | null
/** ÅRL 5:20 §: manual medelantal anställda. Null means "computed from
* the employees table"; the note and the iXBRL fact already reflect
* whichever won. */
medelantal_anstallda_override: number | null
confirmations: {
long_term_debt_over_five_years: boolean
securities_pledged: boolean
+31 -11
View File
@@ -12,7 +12,8 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { listSignatureRequests } from '@/lib/bokslut/arsredovisning/signature-service'
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
import { resolveMedelantalAnstallda } from '@/lib/salary/medelantal'
import { getMedelantalOverride } from '@/lib/bokslut/arsredovisning/narrative-service'
import { mapTrialBalancesToK2, type TrialBalancePair } from './k2-mapper'
import { resolveEntryPoint } from './taxonomy/entry-points'
import type {
@@ -89,6 +90,7 @@ export async function buildIxbrlInput(
// Previous period: trial balances for jämförelsesiffror (same full/
// pre-closing split as the current year).
let previousPeriod: { start: string; end: string } | null = null
let previousPeriodId: string | null = null
let previousTb: TrialBalancePair | null = null
if (period.previous_period_id) {
const { data: prev } = await supabase
@@ -99,6 +101,7 @@ export async function buildIxbrlInput(
.maybeSingle()
if (prev) {
previousPeriod = { start: prev.period_start, end: prev.period_end }
previousPeriodId = prev.id
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prev.id, { closingEntry: 'include' }),
@@ -326,16 +329,23 @@ export async function buildIxbrlInput(
}
// ---- medelantal anställda ---------------------------------------------------
// Compute BOTH years with the real FTE helper (the same one the PDF note
// uses) over the employees table. The note-prose regex stays only as a
// last-resort fallback when the employees query fails.
// Compute BOTH years with the same resolver the PDF note uses: a manual
// override on arsredovisning_narratives for that period wins, otherwise
// the FTE average over the employees table. The note-prose regex stays
// only as a last-resort fallback when the employees query fails.
let medelantalAnstallda: { current: number; previous: number | null }
const { data: employeeRows, error: employeesError } = await supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId)
const [{ data: employeeRows, error: employeesError }, previousOverride] = await Promise.all([
supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId),
previousPeriodId ? getMedelantalOverride(supabase, companyId, previousPeriodId) : null,
])
if (employeesError) {
medelantalAnstallda = extractMedelantal(pdfData.noter, null)
// The note already embeds the current year's override; last year's
// manual figure is still worth showing when only the employees read
// failed.
medelantalAnstallda = extractMedelantal(pdfData.noter, previousOverride)
} else {
const employees = (employeeRows ?? []) as Array<{
employment_start: string
@@ -343,9 +353,19 @@ export async function buildIxbrlInput(
employment_degree: number
}>
medelantalAnstallda = {
current: computeMedelantalAnstallda(employees, period.period_start, period.period_end),
current: resolveMedelantalAnstallda(
pdfData.disclosures.medelantal_anstallda_override,
employees,
period.period_start,
period.period_end,
),
previous: previousPeriod
? computeMedelantalAnstallda(employees, previousPeriod.start, previousPeriod.end)
? resolveMedelantalAnstallda(
previousOverride,
employees,
previousPeriod.start,
previousPeriod.end,
)
: null,
}
}
+37 -1
View File
@@ -1,5 +1,41 @@
import { describe, it, expect } from 'vitest'
import { computeMedelantalAnstallda } from '../medelantal'
import { computeMedelantalAnstallda, resolveMedelantalAnstallda } from '../medelantal'
describe('resolveMedelantalAnstallda', () => {
const START = '2025-07-01'
const END = '2026-06-30'
// The support case: owner registered in Löner from 2026-01-01 in a
// July-June year. 181 / 365 = 0.496 rounds to 0 although salary was
// drawn all year.
const halfYearOwner = [
{ employment_start: '2026-01-01', employment_end: null, employment_degree: 100 },
]
it('falls back to the FTE average when no override is set', () => {
expect(resolveMedelantalAnstallda(null, halfYearOwner, START, END)).toBe(0)
expect(resolveMedelantalAnstallda(undefined, halfYearOwner, START, END)).toBe(0)
})
it('lets a manual figure replace the FTE average', () => {
expect(resolveMedelantalAnstallda(1, halfYearOwner, START, END)).toBe(1)
expect(resolveMedelantalAnstallda(3, [], START, END)).toBe(3)
})
it('treats an explicit 0 as an override, not as "unset"', () => {
const fullYear = [
{ employment_start: '2020-01-01', employment_end: null, employment_degree: 100 },
]
expect(resolveMedelantalAnstallda(0, fullYear, START, END)).toBe(0)
})
it('ignores a negative or non-finite override', () => {
const fullYear = [
{ employment_start: '2020-01-01', employment_end: null, employment_degree: 100 },
]
expect(resolveMedelantalAnstallda(-1, fullYear, START, END)).toBe(1)
expect(resolveMedelantalAnstallda(Number.NaN, fullYear, START, END)).toBe(1)
})
})
describe('computeMedelantalAnstallda', () => {
const START = '2025-01-01'
+18
View File
@@ -66,3 +66,21 @@ export function computeMedelantalAnstallda(
return Math.round(totalFteDays / periodDays)
}
/**
* The figure the årsredovisning discloses: a manual override from
* arsredovisning_narratives when the user has set one, otherwise the FTE
* average above. One resolver so the PDF note, the iXBRL fact, and any
* other reader cannot disagree about which number wins.
*/
export function resolveMedelantalAnstallda(
override: number | null | undefined,
employees: EmployeePeriodInput[],
periodStartIso: string,
periodEndIso: string,
): number {
if (typeof override === 'number' && Number.isFinite(override) && override >= 0) {
return Math.round(override)
}
return computeMedelantalAnstallda(employees, periodStartIso, periodEndIso)
}