Files
accounted/lib/bokslut/arsredovisning/__tests__/completeness.test.ts
T

326 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { ArsredovisningData } from '../types'
import {
emptyAnnualReportProfile,
type AnnualReportEligibilityResult,
type AnnualReportProfile,
} from '../compliance-types'
import {
validateAnnualReportCompleteness,
validateStatementIntegrity,
} from '../completeness'
import { mapTrialBalancesToK2 } from '../../ixbrl/k2-mapper'
import { buildBrRows, buildRrRows } from '../statement-rows'
const eligibility: AnnualReportEligibilityResult = {
k2_eligible: true,
digital_filing_eligible: true,
size_classification: 'smaller',
k2_relief_rule: 'eligible',
issues: [],
digital_issues: [],
}
function report(): ArsredovisningData {
return {
accounting_framework: 'k2',
company: { name: 'Test AB', org_number: '556012-5790', city: 'Stockholm' },
fiscal_period: {
id: 'period-1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
},
previous_period: null,
forvaltningsberattelse: {
description: 'Bolaget bedriver konsultverksamhet.',
important_events: 'Inga väsentliga händelser.',
resultatdisposition: 'Resultatet balanseras i ny räkning.',
proposed_dividend: 0,
resultatdisposition_amounts: {
retained_earnings: 80,
share_premium_reserve: 0,
current_year_result: 20,
total: 100,
proposed_dividend: 0,
carried_forward: 100,
},
agm_date: '2026-03-15',
},
balansrakning: {
total_assets: 100,
total_equity_liabilities: 100,
total_assets_previous: null,
total_equity_liabilities_previous: null,
assets: [{ label: 'Bank', current: 100, previous: null }],
equity_liabilities: [
{ label: 'Eget kapital', current: 100, previous: null },
{ label: 'Årets resultat', current: 20, previous: null },
],
},
resultatrakning: [
{ label: 'Nettoomsättning', current: 20, previous: null },
{ label: 'Årets resultat', current: 20, previous: null, is_total: true },
],
noter: [{ number: 1, title: 'Principer', body: 'K2' }],
signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: '2026-03-01' }],
warnings: [],
} as unknown as ArsredovisningData
}
function input(stage: 'draft' | 'signing' | 'filing') {
const profile: AnnualReportProfile = {
...emptyAnnualReportProfile('company-1', 'period-1'),
k2_assessment_confirmed_at: '2026-02-01T10:00:00Z',
narrative_confirmed_at: '2026-02-01T10:00:00Z',
signer_roster_confirmed_at: '2026-02-01T10:00:00Z',
is_in_liquidation: false,
auditor_report_required: false,
}
return {
report: report(),
profile,
eligibility,
stage,
todayIso: '2026-03-20',
disclosures: {
long_term_debt_over_five_years_confirmed: true,
securities_pledged_confirmed: true,
contingent_liabilities_confirmed: true,
parent_company_confirmed: true,
agm_disposition_outcome: 'proposal_approved' as
| 'proposal_approved'
| 'alternative_decision'
| null,
agm_disposition_decision: null,
},
}
}
describe('validateAnnualReportCompleteness', () => {
it('accepts a complete filing model', () => {
const result = validateAnnualReportCompleteness(input('filing'))
expect(result.ok).toBe(true)
expect(result.error_count).toBe(0)
})
it('keeps an unsupported reporting currency blocking at signing stage', () => {
const value = input('signing')
const currencyIssue = {
code: 'AR-SCOPE-CURRENCY',
severity: 'error' as const,
section: 'scope' as const,
message: 'Accounteds årsredovisningsflöde stöder ännu endast SEK som redovisningsvaluta.',
}
value.eligibility = {
...eligibility,
k2_eligible: false,
digital_filing_eligible: false,
issues: [currencyIssue],
digital_issues: [currencyIssue],
}
const result = validateAnnualReportCompleteness(value)
expect(result.ok).toBe(false)
expect(result.issues).toEqual(expect.arrayContaining([currencyIssue]))
})
it('does not infer an unanswered disclosure confirmation', () => {
const value = input('draft')
value.disclosures.securities_pledged_confirmed = false
const result = validateAnnualReportCompleteness(value)
expect(result.ok).toBe(false)
expect(result.issues.some((issue) => issue.code === 'AR-NOTE-SECURITIES-UNCONFIRMED')).toBe(
true,
)
})
it('requires signers before a version can be locked', () => {
const value = input('signing')
value.report.signatures = []
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-SIGNERS-MISSING')).toBe(true)
})
it('validates the organisation number check digit', () => {
const value = input('draft')
value.report.company.org_number = '556012-5791'
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-COMPANY-ORGNR')).toBe(true)
})
it('requires confirmation that the signer roster matches Bolagsverket', () => {
const value = input('signing')
value.profile.signer_roster_confirmed_at = null
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-SIGNER-ROSTER-UNCONFIRMED')).toBe(true)
})
it('blocks impossible signature and AGM chronology', () => {
const value = input('filing')
value.report.signatures[0].signed_at = '2025-12-30'
value.report.forvaltningsberattelse.agm_date = '2025-12-29'
const result = validateAnnualReportCompleteness(value)
expect(result.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining(['AR-SIGNATURE-BEFORE-PERIOD-END', 'AR-AGM-BEFORE-PERIOD-END']),
)
})
it('keeps K3 output draft-only until the complete K3 disclosure matrix exists', () => {
const value = input('signing')
value.report.accounting_framework = 'k3'
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-K3-DRAFT-ONLY')).toBe(true)
})
it('requires the AGM decision and evidence dates at filing stage', () => {
const value = input('filing')
value.report.signatures[0].signed_at = null
value.disclosures.agm_disposition_outcome = null
const result = validateAnnualReportCompleteness(value)
expect(result.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining(['AR-SIGNATURES-INCOMPLETE', 'AR-AGM-DISPOSITION-OUTCOME']),
)
})
it('blocks a proposed dividend above distributable equity', () => {
const value = input('draft')
value.report.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend = 101
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code === 'AR-DIVIDEND-EXCEEDS-EQUITY')).toBe(true)
})
it('blocks a version whose income-statement result differs from equity', () => {
const value = input('draft')
value.report.resultatrakning = [
{ label: 'Årets resultat', current: 790_296, previous: null, is_total: true },
]
value.report.forvaltningsberattelse.resultatdisposition_amounts.current_year_result = 469_542
const result = validateAnnualReportCompleteness(value)
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISMATCH', severity: 'error' }),
]),
)
})
it('compares annual-report results at whole-ore precision', () => {
const value = report()
value.resultatrakning = [
{ label: 'Årets resultat', current: 0.1 + 0.2, previous: null, is_total: true },
]
value.balansrakning.equity_liabilities = [
{ label: 'Årets resultat', current: 0.3, previous: null },
]
value.forvaltningsberattelse.resultatdisposition_amounts.current_year_result = 0.3
expect(validateStatementIntegrity(value)).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISMATCH' }),
]),
)
})
it('blocks a version when the income statement has no final result row', () => {
const value = input('draft')
value.report.resultatrakning = [
{ label: 'Nettoomsättning', current: 100, previous: null },
]
const result = validateAnnualReportCompleteness(value)
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISSING', severity: 'error' }),
]),
)
})
it('identifies the statutory result by semantic key instead of its K2 label', () => {
const value = report()
value.resultatrakning = [{
label: 'Årets resultat/förlust',
semantic_key: 'income_statement_result',
current: 20,
previous: null,
is_total: true,
}]
value.balansrakning.equity_liabilities = [{
label: 'Periodens resultat',
semantic_key: 'balance_sheet_current_year_result',
current: 20,
previous: null,
}]
expect(validateStatementIntegrity(value)).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISSING' }),
]),
)
})
it('detects a line reclassification between PDF and iXBRL with unchanged totals', () => {
const value = input('draft')
const full = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '2099', account_name: 'Current result', closing_debit: 0, closing_credit: 20 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 20, closing_credit: 20 },
]
const preClosing = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 0, closing_credit: 20 },
]
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
const balanceRows = buildBrRows(mapping)
value.report.resultatrakning = buildRrRows(mapping)
value.report.balansrakning.assets = balanceRows.assets
value.report.balansrakning.equity_liabilities = balanceRows.equityLiabilities
value.report.balansrakning.total_assets = mapping.totals.tillgangar.current
value.report.balansrakning.total_equity_liabilities =
mapping.totals.egetKapitalSkulder.current
const ixbrl = {
rr: {
...mapping.rr,
Nettoomsattning: { current: 0, previous: null },
OvrigaRorelseintakter: { current: 20, previous: null },
},
br: mapping.br,
totals: mapping.totals,
}
expect(validateStatementIntegrity(value.report, ixbrl as never)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-IXBRL-STATEMENT-MISMATCH' }),
]),
)
})
it('requires a documented prudence assessment for a positive dividend', () => {
const value = input('draft')
value.report.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend = 50
const result = validateAnnualReportCompleteness(value)
expect(
result.issues.some((issue) => issue.code === 'AR-DIVIDEND-PRUDENCE-UNCONFIRMED'),
).toBe(true)
value.profile.dividend_prudence_confirmed = true
const confirmed = validateAnnualReportCompleteness(value)
expect(
confirmed.issues.some((issue) => issue.code === 'AR-DIVIDEND-PRUDENCE-UNCONFIRMED'),
).toBe(false)
})
it('does not report a dividend error for zero dividend and negative equity', () => {
const value = input('draft')
value.report.forvaltningsberattelse.resultatdisposition_amounts.total = -100
value.report.forvaltningsberattelse.resultatdisposition_amounts.carried_forward = -100
const result = validateAnnualReportCompleteness(value)
expect(result.issues.some((issue) => issue.code.startsWith('AR-DIVIDEND-'))).toBe(false)
})
})