Files
accounted/lib/bokslut/arsredovisning/model.ts
T
MattssonandClaude Fable 5.1 e85eac317c 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>
2026-09-08 13:44:29 +02:00

186 lines
6.5 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { buildArsredovisningData } from './build-data'
import { listSignatureRequests } from './signature-service'
import { getAnnualReportProfile } from './profile-service'
import { evaluateAnnualReportEligibility } from './eligibility'
import {
validateAnnualReportCompleteness,
validateStatementIntegrity,
} from './completeness'
import {
ANNUAL_REPORT_SCHEMA_VERSION,
type AnnualReportDisclosureState,
type AnnualReportSizeMetrics,
type AnnualReportValidationStage,
type CanonicalAnnualReport,
} from './compliance-types'
import { buildIxbrlInput, type BuildIxbrlOptions } from '@/lib/bokslut/ixbrl/build-input'
import { resolveMedelantalAnstallda } from '@/lib/salary/medelantal'
import { getMedelantalOverride } from './narrative-service'
export interface BuildCanonicalAnnualReportOptions extends BuildIxbrlOptions {
stage?: AnnualReportValidationStage
generatedAt?: string
includeIxbrl?: boolean
}
interface EmployeeRow {
employment_start: string
employment_end: string | null
employment_degree: number
}
/**
* 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,
)
const previousOverview = report.previous_period
? report.forvaltningsberattelse.flerarsoversikt.find(
(row) => row.year === report.previous_period?.name,
)
: null
return {
current: {
employees: resolveMedelantalAnstallda(
report.disclosures.medelantal_anstallda_override,
employees,
report.fiscal_period.period_start,
report.fiscal_period.period_end,
),
balance_sheet_total: report.balansrakning.total_assets,
net_revenue: currentOverview?.net_revenue ?? null,
},
previous: report.previous_period
? {
employees: resolveMedelantalAnstallda(
previousMedelantalOverride,
employees,
report.previous_period.period_start,
report.previous_period.period_end,
),
balance_sheet_total: report.balansrakning.total_assets_previous,
net_revenue: previousOverview?.net_revenue ?? null,
}
: null,
}
}
function disclosureState(
report: Awaited<ReturnType<typeof buildArsredovisningData>>,
): AnnualReportDisclosureState {
return {
long_term_debt_over_five_years_confirmed:
report.disclosures.confirmations.long_term_debt_over_five_years,
securities_pledged_confirmed: report.disclosures.confirmations.securities_pledged,
contingent_liabilities_confirmed:
report.disclosures.confirmations.contingent_liabilities,
parent_company_confirmed: report.disclosures.confirmations.parent_company,
agm_disposition_outcome: report.forvaltningsberattelse.agm_disposition_outcome,
agm_disposition_decision: report.forvaltningsberattelse.agm_disposition_decision,
}
}
export async function buildCanonicalAnnualReport(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: BuildCanonicalAnnualReportOptions = {},
): Promise<CanonicalAnnualReport> {
const [report, profile, signatures, employeesResult] = await Promise.all([
buildArsredovisningData(supabase, companyId, fiscalPeriodId),
getAnnualReportProfile(supabase, companyId, fiscalPeriodId),
listSignatureRequests(supabase, companyId, fiscalPeriodId),
supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId),
])
if (employeesResult.error) {
throw new Error(`Failed to load annual report employee metrics: ${employeesResult.error.message}`)
}
const activeSignatures = signatures.filter((signature) => signature.status !== 'declined')
report.signatures = activeSignatures.map((signature) => ({
role: signature.role,
name: signature.signer_name,
signed_at: signature.signed_at,
}))
// 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,
periodStart: report.fiscal_period.period_start,
periodEnd: report.fiscal_period.period_end,
profile,
metrics,
})
const disclosures = disclosureState(report)
let validation = validateAnnualReportCompleteness({
report,
profile,
disclosures,
eligibility,
stage: options.stage ?? 'draft',
todayIso: options.todayIso,
})
let ixbrl = null
if (report.accounting_framework === 'k2' && options.includeIxbrl !== false) {
ixbrl = await buildIxbrlInput(supabase, companyId, fiscalPeriodId, {
undertecknare: options.undertecknare,
proposedDividend: options.proposedDividend,
todayIso: options.todayIso,
reportData: report,
signatureRequests: activeSignatures,
})
}
const crossDocumentIssues = validateStatementIntegrity(report, ixbrl).filter(
(issue) => issue.code === 'AR-IXBRL-STATEMENT-MISMATCH',
)
if (crossDocumentIssues.length > 0) {
validation = {
...validation,
ok: false,
error_count: validation.error_count + crossDocumentIssues.length,
issues: [...validation.issues, ...crossDocumentIssues],
}
}
return {
schema_version: ANNUAL_REPORT_SCHEMA_VERSION,
generated_at: options.generatedAt ?? new Date().toISOString(),
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
entity_type: report.company.entity_type,
report,
profile,
disclosures,
eligibility,
validation,
ixbrl,
}
}