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:
@@ -1662,4 +1662,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-medelantal">Medelantal anställda</Label>
|
||||
<Input
|
||||
id="ar-medelantal"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={medelantalOverride}
|
||||
onChange={(e) => setMedelantalOverride(e.target.value)}
|
||||
placeholder="Beräknas från Löner"
|
||||
className="max-w-[220px] tabular-nums"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Å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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-ltd">
|
||||
Långfristiga skulder förfallande efter mer än fem år (kr)
|
||||
|
||||
+28
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user