diff --git a/DECISIONS.md b/DECISIONS.md
index e07f6788..0ca44a34 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1662,4 +1662,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-09-08] #2391 skeptic pass: orgNumberKey only strips hyphens and spaces and only unprefixes 12-digit values behind 16/18/19/20. Reason: 26 prod supplier rows hold a VAT number (orgnr + 01, prefixes 55/52/87) in org_number, and 'last 10 of any 12 digits' would have rewritten them to another company's identity; letters stay because BE0123456789 is not the Swedish 0123456789. The matcher scans live suppliers only (archived_at IS NULL), the list and v1 search compare without separators, the CSV import and the provider migration orchestrator key and write through the same rule.
[2026-09-08] correctEntry re-points the original entry's transaction_voucher_links rows to the corrected entry (lib/core/bookkeeping/storno-service.ts relinkTransactionsToEntry) instead of deleting them as issue #2364 proposed. Why: for a samlingsverifikat (bulk-book N>1) the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into Att bokföra; the pointer column already follows the correction and the junction now follows it the same way, so every reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC) sees one live anchor. Rejected: a relink_entry_anchors RPC moving pointer and junction atomically (a migration plus pg test for a path that is already best-effort across five other statements; revisit if a partial failure ever shows up in the surfaced transactionRelinkError). Prod repair (planned, runs after merge on the founder's go; completion gets its own dated entry): the 7 stale links (3 companies) all sit on rows whose pointer names a posted entry (4 on a correction chain, 3 from a June 2026 samlingsverifikat storno that predates the junction cleanup and were re-booked 1:1); they will be re-pointed to the pointer's entry, the same rule the fix applies, rather than deleted.
[2026-09-08] delete_last_voucher returns a correction's bank anchors (transactions.journal_entry_id and transaction_voucher_links rows) to correction_of_id before the row is deleted (migration 20260908095907). Why: the #2364 skeptic showed that once the junction follows the correction, the two-step undo (delete the correction, then the storno) cascaded the links away and restored an original that explains bank rows nobody points at, so the rows surfaced as bookable again; before, the links had stayed on the original by accident. Chosen over releasing the rows (the restored original would still explain them, same trap) and over a TS pre-step in the DELETE route (not atomic with the RPC's own guards: a refused delete would leave anchors on a reversed entry). A duplicate of a link the original already holds is dropped, not re-pointed (UNIQUE (transaction_id, journal_entry_id)).
+[2026-09-08] Medelantal anställda (Not 2, ÅRL 5:20 §) gets a whole-number override on arsredovisning_narratives (migration 20260908130127) instead of the free-text note override the support request asked for. Why: the number keeps the statutory sentence and the iXBRL MedelantaletAnstallda fact correct; free text would let a non-compliant note through and could not be tagged. One resolver (lib/salary/medelantal.ts resolveMedelantalAnstallda: override, else FTE average over employees) feeds the K2 and K3 note builders and the iXBRL input, which also reads the previous period's override so the jämförelseår column shows the same figure the previous year's document did. Rejected: rounding 0.5 up globally (silently changes every company's note and does nothing for the 148 of 195 aktiebolag with salary but no employees rows); asking the user to backdate employment_start (fixes one company, misstates the hire date).
[2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched.
diff --git a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx
index 7bb07fab..02922404 100644
--- a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx
+++ b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx
@@ -79,6 +79,9 @@ export default function ArsredovisningPage() {
const [savedParentOrgNr, setSavedParentOrgNr] = useState('')
const [parentCity, setParentCity] = useState('')
const [savedParentCity, setSavedParentCity] = useState('')
+ // ÅRL 5:20 §: manual medelantal anställda. Empty = computed from Löner.
+ const [medelantalOverride, setMedelantalOverride] = useState('')
+ const [savedMedelantalOverride, setSavedMedelantalOverride] = useState('')
const [longTermDebtConfirmed, setLongTermDebtConfirmed] = useState(false)
const [savedLongTermDebtConfirmed, setSavedLongTermDebtConfirmed] = useState(false)
const [securitiesPledgedConfirmed, setSecuritiesPledgedConfirmed] = useState(false)
@@ -167,6 +170,10 @@ export default function ArsredovisningPage() {
setSavedParentOrgNr(d.disclosures.parent_company_org_number ?? '')
setParentCity(d.disclosures.parent_company_city ?? '')
setSavedParentCity(d.disclosures.parent_company_city ?? '')
+ const medel = d.disclosures.medelantal_anstallda_override
+ const medelStr = medel != null ? String(medel) : ''
+ setMedelantalOverride(medelStr)
+ setSavedMedelantalOverride(medelStr)
setLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years)
setSavedLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years)
setSecuritiesPledgedConfirmed(d.disclosures.confirmations.securities_pledged)
@@ -202,6 +209,7 @@ export default function ArsredovisningPage() {
parentName !== savedParentName ||
parentOrgNr !== savedParentOrgNr ||
parentCity !== savedParentCity ||
+ medelantalOverride !== savedMedelantalOverride ||
longTermDebtConfirmed !== savedLongTermDebtConfirmed ||
securitiesPledgedConfirmed !== savedSecuritiesPledgedConfirmed ||
contingentLiabilitiesConfirmed !== savedContingentLiabilitiesConfirmed ||
@@ -226,6 +234,21 @@ export default function ArsredovisningPage() {
}
longTermDebtParsed = parsed
}
+ // Medelantal anställda: empty clears the override (note falls back to
+ // the FTE average from Löner); otherwise a whole number of employees.
+ let medelantalParsed: number | null = null
+ if (medelantalOverride.trim()) {
+ const parsed = Number(medelantalOverride.trim())
+ if (!Number.isInteger(parsed) || parsed < 0) {
+ toast({
+ title: 'Ogiltigt antal',
+ description: 'Medelantal anställda måste vara ett heltal, noll eller större (eller lämnas tomt).',
+ variant: 'destructive',
+ })
+ return
+ }
+ medelantalParsed = parsed
+ }
let proposedDividendParsed = 0
if (proposedDividend.trim()) {
const parsed = Number(proposedDividend.replace(/\s/g, '').replace(',', '.'))
@@ -263,6 +286,7 @@ export default function ArsredovisningPage() {
parent_company_name: parentName.trim() || null,
parent_company_org_number: parentOrgNr.trim() || null,
parent_company_city: parentCity.trim() || null,
+ medelantal_anstallda_override: medelantalParsed,
long_term_debt_over_five_years_confirmed: longTermDebtConfirmed,
securities_pledged_confirmed: securitiesPledgedConfirmed,
contingent_liabilities_confirmed: contingentLiabilitiesConfirmed,
@@ -292,6 +316,7 @@ export default function ArsredovisningPage() {
setSavedParentName(parentName)
setSavedParentOrgNr(parentOrgNr)
setSavedParentCity(parentCity)
+ setSavedMedelantalOverride(medelantalOverride)
setSavedLongTermDebtConfirmed(longTermDebtConfirmed)
setSavedSecuritiesPledgedConfirmed(securitiesPledgedConfirmed)
setSavedContingentLiabilitiesConfirmed(contingentLiabilitiesConfirmed)
@@ -323,6 +348,7 @@ export default function ArsredovisningPage() {
parentName,
parentOrgNr,
parentCity,
+ medelantalOverride,
longTermDebtConfirmed,
securitiesPledgedConfirmed,
contingentLiabilitiesConfirmed,
@@ -719,6 +745,23 @@ export default function ArsredovisningPage() {
fält visas som "Inga." i PDF:en.
+
+
Medelantal anställda
+
setMedelantalOverride(e.target.value)}
+ placeholder="Beräknas från Löner"
+ className="max-w-[220px] tabular-nums"
+ />
+
+ ÅRL 5:20 §. Lämna tomt för att använda antalet anställda från Löner.
+ Ägare som tar ut lön räknas som anställd; fyll i om lönen bokförts utan
+ anställd i Löner.
+
+
Långfristiga skulder förfallande efter mer än fem år (kr)
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts
index bf9e0d62..096f6d38 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts
@@ -140,6 +140,34 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
expect(body.data.parent_company_org_number).toBe('CHE-123.456.789')
})
+ it('returns 400 for a fractional or negative medelantal anställda override', async () => {
+ setupSupabase()
+ expect((await POST(postReq({ medelantal_anstallda_override: 1.5 }), idParams)).status).toBe(400)
+ setupSupabase()
+ expect((await POST(postReq({ medelantal_anstallda_override: -1 }), idParams)).status).toBe(400)
+ })
+
+ it('saves a whole-number medelantal anställda override and lets null clear it', async () => {
+ const { enqueue } = setupSupabase()
+ enqueue({ data: { id: 'period-1' } }) // fiscal_periods ownership check
+ enqueue({ data: null }) // no registrerad submission
+ enqueue({ data: { ...narrativeRow, medelantal_anstallda_override: 1 } }) // upsert
+ enqueue({ data: null }) // clear narrative confirmation
+ const { status, body } = await parseJsonResponse<{
+ data: typeof narrativeRow & { medelantal_anstallda_override: number | null }
+ }>(await POST(postReq({ medelantal_anstallda_override: 1 }), idParams))
+ expect(status).toBe(200)
+ expect(body.data.medelantal_anstallda_override).toBe(1)
+
+ const cleared = setupSupabase()
+ cleared.enqueue({ data: { id: 'period-1' } })
+ cleared.enqueue({ data: null })
+ cleared.enqueue({ data: { ...narrativeRow, medelantal_anstallda_override: null } })
+ cleared.enqueue({ data: null })
+ const res = await POST(postReq({ medelantal_anstallda_override: null }), idParams)
+ expect(res.status).toBe(200)
+ })
+
it('returns 400 when the payload contains an unknown field', async () => {
setupSupabase()
const res = await POST(
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts
index cc5336ae..9905ab63 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts
@@ -87,6 +87,11 @@ const PostSchema = z.object({
.nullable()
.optional(),
parent_company_city: sanitizedText(100).nullable().optional(),
+ // ÅRL 5:20 §: manual medelantal anställda. Null clears the override and
+ // the note falls back to the FTE average over the employees table. Whole
+ // employees only (the K2 note and the iXBRL fact are integers); the cap
+ // matches the DB CHECK.
+ medelantal_anstallda_override: z.number().int().min(0).max(100_000).nullable().optional(),
long_term_debt_over_five_years_confirmed: z.boolean().optional(),
securities_pledged_confirmed: z.boolean().optional(),
contingent_liabilities_confirmed: z.boolean().optional(),
diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
index daa678c5..bcf5fc25 100644
--- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
+++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
@@ -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',
diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts
index 1e39208a..d95963ed 100644
--- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts
+++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts
@@ -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' })
diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-pdf-sign.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-pdf-sign.test.ts
index d8e38689..8ffe66cf 100644
--- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-pdf-sign.test.ts
+++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-pdf-sign.test.ts
@@ -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,
diff --git a/lib/bokslut/arsredovisning/__tests__/model-metrics.test.ts b/lib/bokslut/arsredovisning/__tests__/model-metrics.test.ts
new file mode 100644
index 00000000..d04b1766
--- /dev/null
+++ b/lib/bokslut/arsredovisning/__tests__/model-metrics.test.ts
@@ -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>
+
+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)
+ })
+})
diff --git a/lib/bokslut/arsredovisning/build-data.ts b/lib/bokslut/arsredovisning/build-data.ts
index 0cc51152..ed790cc1 100644
--- a/lib/bokslut/arsredovisning/build-data.ts
+++ b/lib/bokslut/arsredovisning/build-data.ts
@@ -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
diff --git a/lib/bokslut/arsredovisning/model.ts b/lib/bokslut/arsredovisning/model.ts
index 21cb82c2..fe0f0caf 100644
--- a/lib/bokslut/arsredovisning/model.ts
+++ b/lib/bokslut/arsredovisning/model.ts
@@ -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>,
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,
diff --git a/lib/bokslut/arsredovisning/narrative-service.ts b/lib/bokslut/arsredovisning/narrative-service.ts
index 51e5f7cc..c186f15a 100644
--- a/lib/bokslut/arsredovisning/narrative-service.ts
+++ b/lib/bokslut/arsredovisning/narrative-service.ts
@@ -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 {
+ 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
diff --git a/lib/bokslut/arsredovisning/types.ts b/lib/bokslut/arsredovisning/types.ts
index f3751e30..d86dd3f9 100644
--- a/lib/bokslut/arsredovisning/types.ts
+++ b/lib/bokslut/arsredovisning/types.ts
@@ -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
diff --git a/lib/bokslut/ixbrl/build-input.ts b/lib/bokslut/ixbrl/build-input.ts
index 58346b4d..939e730c 100644
--- a/lib/bokslut/ixbrl/build-input.ts
+++ b/lib/bokslut/ixbrl/build-input.ts
@@ -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,
}
}
diff --git a/lib/salary/__tests__/medelantal.test.ts b/lib/salary/__tests__/medelantal.test.ts
index 22a32893..aa1217cb 100644
--- a/lib/salary/__tests__/medelantal.test.ts
+++ b/lib/salary/__tests__/medelantal.test.ts
@@ -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'
diff --git a/lib/salary/medelantal.ts b/lib/salary/medelantal.ts
index 950a8af8..6e776c0b 100644
--- a/lib/salary/medelantal.ts
+++ b/lib/salary/medelantal.ts
@@ -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)
+}
diff --git a/supabase/migrations/20260908130127_arsredovisning_medelantal_override.sql b/supabase/migrations/20260908130127_arsredovisning_medelantal_override.sql
new file mode 100644
index 00000000..2fc506d0
--- /dev/null
+++ b/supabase/migrations/20260908130127_arsredovisning_medelantal_override.sql
@@ -0,0 +1,26 @@
+-- arsredovisning_narratives: manual override for the medelantal anställda
+-- note (ÅRL 5 kap. 20 §).
+--
+-- The note is otherwise computed as an FTE-weighted average over
+-- public.employees. That is wrong for every company that books salary
+-- without a Löner employee record (hand-booked salary, SIE import,
+-- migrated history): 148 of 195 aktiebolag with posted 70xx-73xx lines in
+-- prod have no employees rows at all, and the note then says "inga
+-- anställda" although the owner drew salary all year. A half-year hire in
+-- a broken fiscal year rounds 0.5 to 0 the same way.
+--
+-- NULL keeps the computed value. A whole number replaces it in the PDF note
+-- and the iXBRL MedelantaletAnstallda fact for the same period. The column
+-- is per-fiscal-period like the other disclosure overrides on this table.
+
+ALTER TABLE public.arsredovisning_narratives
+ ADD COLUMN medelantal_anstallda_override INTEGER
+ CHECK (
+ medelantal_anstallda_override IS NULL
+ OR (medelantal_anstallda_override >= 0 AND medelantal_anstallda_override <= 100000)
+ );
+
+COMMENT ON COLUMN public.arsredovisning_narratives.medelantal_anstallda_override IS
+ 'Manual medelantal anställda for the ÅRL 5:20 § note. NULL = compute from employees.';
+
+NOTIFY pgrst, 'reload schema';