Files
accounted/lib/bokslut/ixbrl/document/k2-document.ts
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

1300 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* K2 AB årsredovisning → iXBRL 1.1 XHTML document (entry point risbs).
*
* Layout and tagging follow the official example
* dev_docs/bokslut/exempel/k2/faststalld-arsredovisning-exempel-1-*.xhtml and
* TILLAMPNINGSANVISNING v1.8:
* - XHTML + UTF-8, no scripts/external refs, inline CSS gathered in <head>
* - contexts period0/period1/…, balans0/balans1/…; unit SEK
* - vallistor hidden in ix:hidden; räkenskapsårets dagar hidden
* - fastställelseintyg wrapped in id="id-innehall-faststallelseintyg"
* (kontrollsumma exclusion, TA §4.5.3) with the signing-date element
* carrying id="ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG" (TA §4.4.2)
* - per-signer dates via UnderskriftArsredovisningForetradareTuple
* (TA §2.9.1)
* - presentational minus for cost rows lives OUTSIDE the fact element;
* `sign="-"` is reserved for values deviating from the concept's nature
*/
import { getEntryPoint } from '../taxonomy/entry-points'
import { getRegistry, getConcept } from '../taxonomy/registry'
import type { IxbrlArsredovisningInput, ConceptAmount } from '../types'
import { FactWriter } from './ix'
import { el, selfClosing, escapeText, escapeAttr, paragraphs, formatSekAbs } from './xml'
const CSS = `
body { font-family: 'Times New Roman', Times, serif; margin: 0 auto; max-width: 46em; color: #000; }
.ar-page { padding: 2em 2.5em; margin: 1em 0; border: 1px solid #eee; page-break-after: always; }
.ar-page-hdr { display: flex; justify-content: space-between; font-size: 80%; color: #333; border-bottom: 1px solid #999; margin-bottom: 1.5em; padding-bottom: 0.25em; }
h1 { font-size: 160%; margin: 0 0 0.5em; }
h2 { font-size: 125%; margin: 1.5em 0 0.5em; }
h3 { font-size: 105%; margin: 1.2em 0 0.4em; }
h4 { font-size: 100%; font-style: italic; font-weight: normal; margin: 1em 0 0.3em; }
p { margin: 0 0 0.8em; line-height: 1.35; }
table { width: 100%; border-collapse: collapse; margin: 0 0 1.2em; }
th, td { text-align: left; vertical-align: bottom; padding: 0.15em 0.4em 0.15em 0; font-size: 95%; }
th.num, td.num { text-align: right; white-space: nowrap; }
tr.subtotal th, tr.subtotal td { border-top: 1px solid #000; font-weight: bold; }
tr.total th, tr.total td { border-top: 1px solid #000; border-bottom: 3px double #000; font-weight: bold; }
tr.section th { font-weight: bold; padding-top: 0.8em; }
tr.subsection th { font-style: italic; }
.ar-cert { border: 1px solid #000; padding: 1em 1.25em; margin: 1.5em 0; }
.ar-sign-name { margin-top: 2.2em; }
.ar-sign-name .sig { font-style: italic; }
@media print { .ar-page { border: none; margin: 0; } }
`.trim()
interface Ctx {
period0: string
period1: string | null
balans0: string
balans1: string | null
}
function fmtRange(start: string, end: string): string {
return `${start} till ${end}`
}
/** Two-amount row where the concept is tagged in both year columns. */
function moneyRow(
writer: FactWriter,
label: string,
concept: string,
amount: ConceptAmount | undefined,
ctx: { current: string; previous: string | null },
opts: { displayMinus?: boolean; rowClass?: string; indent?: boolean; alwaysShow?: boolean } = {},
): string {
const current = amount?.current ?? 0
const previous = amount?.previous ?? null
if (!opts.alwaysShow && current === 0 && (previous === null || previous === 0)) return ''
const cells = [
el('th', { scope: 'row', style: opts.indent ? 'padding-left:1.2em' : null }, escapeText(label)),
el(
'td',
{ class: 'num' },
writer.money(concept, ctx.current, current, { displayMinus: opts.displayMinus }),
),
el(
'td',
{ class: 'num' },
previous === null || ctx.previous === null
? ''
: writer.money(concept, ctx.previous, previous, { displayMinus: opts.displayMinus }),
),
]
return el('tr', { class: opts.rowClass ?? null }, cells.join(''))
}
function sectionRow(label: string, cls: 'section' | 'subsection' = 'section'): string {
return el('tr', { class: cls }, el('th', { colspan: '3' }, escapeText(label)))
}
function pageHeader(input: IxbrlArsredovisningInput, page: number, total: number): string {
// el() treats its content argument as raw markup: build it exclusively
// from escapeText() output joined with builder-emitted tags so no user
// value can ever reach the string unescaped.
const identity = [
escapeText(input.company.name),
selfClosing('br', {}),
escapeText(input.company.orgNumber),
].join('')
return el(
'div',
{ class: 'ar-page-hdr' },
el('span', {}, identity) + el('span', {}, `${page} (${total})`),
)
}
export interface GeneratedIxbrl {
xhtml: string
warnings: string[]
}
export function generateK2IxbrlDocument(input: IxbrlArsredovisningInput): GeneratedIxbrl {
const entryPoint = getEntryPoint(input.entryPointId)
const registry = getRegistry(entryPoint.registryId)
const writer = new FactWriter(entryPoint, registry, input.company.orgNumber)
const warnings: string[] = [...input.warnings]
// ---- contexts -----------------------------------------------------------
writer.addDurationContext('period0', input.period.start, input.period.end)
writer.addInstantContext('balans0', input.period.end)
if (input.previousPeriod) {
writer.addDurationContext('period1', input.previousPeriod.start, input.previousPeriod.end)
writer.addInstantContext('balans1', input.previousPeriod.end)
}
// Flerårsöversikt reaches further back: register period2/3 + balans2/3 from
// the explicit ranges build-input aligned with the rows (never derived from
// row count: broken fiscal years would corrupt the contexts).
input.forvaltningsberattelse.flerarsPerioder.forEach((range, index) => {
if (index < 2) return // period0/period1 already registered
writer.addDurationContext(`period${index}`, range.start, range.end)
writer.addInstantContext(`balans${index}`, range.end)
})
const ctx: Ctx = {
period0: 'period0',
period1: input.previousPeriod ? 'period1' : null,
balans0: 'balans0',
balans1: input.previousPeriod ? 'balans1' : null,
}
const yearCtx = { current: ctx.period0, previous: ctx.period1 }
const balCtx = { current: ctx.balans0, previous: ctx.balans1 }
// ---- hidden allmän-information facts (TA §2.15, §3.9.3) ------------------
writer.hiddenEnum('SprakHandlingUpprattadList', 'period0', 'se-mem-base:SprakSvenskaMember')
writer.hiddenEnum('LandForetagetsSateList', 'period0', 'se-mem-base:LandSverigeMember')
writer.hiddenEnum(
'RedovisningsvalutaHandlingList',
'period0',
'se-mem-base:ValutaSvenskaKronorMember',
)
writer.hiddenEnum('BeloppsformatList', 'period0', 'se-mem-base:BeloppsformatNormalformMember')
writer.hiddenEnum(
'FinansiellRapportList',
'period0',
input.underskrifter.harVd
? 'se-mem-base:FinansiellRapportStyrelsenVerkstallandeDirektorenAvgerArsredovisningMember'
: 'se-mem-base:FinansiellRapportStyrelsenAvgerArsredovisningMember',
)
writer.hiddenDate('RakenskapsarForstaDag', 'period0', input.period.start)
writer.hiddenDate('RakenskapsarSistaDag', 'period0', input.period.end)
// Note on TA §2.22 (status för taggning): the 2024-09-12 K2 AB taxonomy has
// no tagging-status concept (verified against the generated registry and
// the official example filings), so nothing is emitted even though note
// bodies beyond Not 1/medelantal are rendered untagged.
const totalPages = 6
const pages: string[] = []
// ====== Page 1: titel + innehåll + fastställelseintyg ======
{
const fb = input.faststallelseintyg
// A missing AGM date renders as a visible placeholder: never today's
// date. Preflight 1103 blocks the submission path until the date exists.
const arsstammaFact =
fb.arsstammaDatum !== null
? writer.date('Arsstamma', 'balans0', fb.arsstammaDatum)
: escapeText('[datum för årsstämma saknas]')
const dispositionFact =
fb.resultatdispositionOutcome === 'proposal_approved'
? writer.textPlain(
'ArsstammaResultatDispositionGodkannaStyrelsensForslag',
'balans0',
'Årsstämman beslöt att godkänna styrelsens förslag till resultatdisposition.',
)
: fb.resultatdispositionOutcome === 'alternative_decision'
? writer.textPlain(
'ArsstammaResultatDispositionInteGodkannaStyrelsensForslag',
'balans0',
'Årsstämman beslöt att inte godkänna styrelsens förslag till resultatdisposition.',
) +
'<br/>' +
writer.textPlain(
'ArsstammaResultatDispositionBeslutstext',
'balans0',
fb.resultatdispositionDecision ?? '[årsstämmans beslut saknas]',
)
: escapeText('[årsstämmans beslut om resultatdisposition saknas]')
const fi = el(
'div',
{ class: 'ar-cert', id: 'id-innehall-faststallelseintyg' },
[
el('h3', {}, 'Fastställelseintyg'),
el(
'p',
{},
writer.textHtml(
'ArsstammaIntygande',
'balans0',
writer.textPlain(
'FaststallelseResultatBalansrakning',
'balans0',
'Jag intygar att resultaträkningen och balansräkningen har fastställts på årsstämma',
) +
' ' +
arsstammaFact +
'.<br/>' +
dispositionFact,
{ continuedAt: 'intygande_forts' },
),
),
// The space inside ix:continuation keeps the sentences separated in
// the transformed XBRL value (same trick as the official example).
el(
'p',
{},
el(
'ix:continuation',
{ id: 'intygande_forts' },
' ' +
writer.textPlain(
'IntygandeOriginalInnehall',
'balans0',
'Jag intygar att innehållet i dessa elektroniska handlingar överensstämmer med originalen och att originalen undertecknats av samtliga personer som enligt lag ska underteckna dessa.',
),
),
),
el(
'p',
{},
`<strong>${writer.textPlain(
'UnderskriftFaststallelseintygElektroniskt',
'balans0',
'Elektroniskt underskriven av',
)}:</strong><br/>` +
writer.textPlain(
'UnderskriftFaststallelseintygForetradareTilltalsnamn',
'period0',
fb.signerFirstName,
) +
' ' +
writer.textPlain(
'UnderskriftFaststallelseintygForetradareEfternamn',
'period0',
fb.signerLastName,
) +
', ' +
writer.textPlain(
'UnderskriftFaststallelseintygForetradareForetradarroll',
'period0',
fb.signerRole,
) +
'<br/>' +
writer.date('UnderskriftFastallelseintygDatum', 'balans0', fb.genereratDatum, {
id: 'ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG',
}),
),
].join('\n'),
)
pages.push(
el(
'div',
{ class: 'ar-page', id: 'ar-page-1' },
[
pageHeader(input, 1, totalPages),
el(
'p',
{},
writer.textPlain('ForetagetsNamn', 'period0', input.company.name) +
'<br/><abbr>Org.nr</abbr> ' +
writer.textPlain('Organisationsnummer', 'period0', input.company.orgNumber),
),
el('h1', {}, `Årsredovisning för räkenskapsåret ${escapeText(fmtRange(input.period.start, input.period.end))}`),
el(
'p',
{},
input.underskrifter.harVd
? 'Styrelsen och verkställande direktören avger följande årsredovisning.'
: 'Styrelsen avger följande årsredovisning.',
),
el(
'p',
{},
'Om inte annat särskilt anges, redovisas alla belopp i hela kronor (sek). Uppgifter inom parentes avser föregående år.',
),
fi,
].join('\n'),
),
)
}
// ====== Page 2: förvaltningsberättelse ======
{
const fb = input.forvaltningsberattelse
const parts: string[] = [
pageHeader(input, 2, totalPages),
el('h2', {}, 'Förvaltningsberättelse'),
el('h3', {}, 'Verksamheten'),
el('h4', {}, 'Allmänt om verksamheten'),
writer.textHtml('AllmantVerksamheten', 'period0', paragraphs(fb.allmantOmVerksamheten)),
el('h4', {}, 'Väsentliga händelser under räkenskapsåret'),
writer.textHtml(
'VasentligaHandelserRakenskapsaret',
'period0',
paragraphs(fb.vasentligaHandelser),
),
]
// Flerårsöversikt: column per year; whole SEK everywhere keeps repeated
// facts trivially consistent across scales (TA §2.7.3).
if (fb.flerarsoversikt.length > 0) {
const header = el(
'tr',
{},
el('th', {}, 'Flerårsöversikt (kr)') +
fb.flerarsoversikt.map((row) => el('th', { class: 'num' }, escapeText(row.year))).join(''),
)
const factCtx = (index: number, kind: 'period' | 'balans'): string | null => {
const id = `${kind}${index}`
return writer.hasContext(id) ? id : null
}
const rowFor = (
label: string,
emit: (ctxId: string, index: number) => string,
kind: 'period' | 'balans',
): string =>
el(
'tr',
{},
el('th', {}, escapeText(label)) +
fb.flerarsoversikt
.map((_, index) => {
const ctxId = factCtx(index, kind)
return el('td', { class: 'num' }, ctxId ? emit(ctxId, index) : '')
})
.join(''),
)
parts.push(
el(
'table',
{},
header +
rowFor(
'Nettoomsättning',
(ctxId, index) =>
writer.money('Nettoomsattning', ctxId, fb.flerarsoversikt[index].nettoomsattning),
'period',
) +
rowFor(
'Resultat efter finansiella poster',
(ctxId, index) =>
writer.money(
'ResultatEfterFinansiellaPoster',
ctxId,
fb.flerarsoversikt[index].resultatEfterFinansiellaPoster,
),
'period',
) +
rowFor(
'Soliditet (%)',
(ctxId, index) => {
const value = fb.flerarsoversikt[index].soliditetPct
return value === null ? '' : writer.percent('Soliditet', ctxId, value)
},
'balans',
),
),
)
}
// Förändringar i eget kapital
const ek = fb.egetKapital
const hasOvriga = ek.ovrigaPoster.ib !== 0 || ek.ovrigaPoster.ub !== 0
if (hasOvriga) {
warnings.push(
'Eget kapital innehåller poster utöver aktiekapital/balanserat resultat/årets resultat (t.ex. reservfond eller överkursfond). Kolumnen "Övriga poster" i förändringstabellen är inte XBRL-taggad: granska innan inlämning.',
)
}
const ekHead = el(
'tr',
{},
el('th', {}, 'Förändringar i eget kapital') +
el('th', { class: 'num' }, 'Aktiekapital') +
(hasOvriga ? el('th', { class: 'num' }, 'Övriga poster') : '') +
el('th', { class: 'num' }, 'Balanserat resultat') +
el('th', { class: 'num' }, 'Årets resultat') +
el('th', { class: 'num' }, 'Totalt'),
)
const ekRows: string[] = [ekHead]
const td = (content: string): string => el('td', { class: 'num' }, content)
// Untagged residual cells: plain text, but with the same sign handling as
// the other untagged rows (negative renders with a minus).
const signedSek = (value: number): string =>
escapeText(`${value < 0 ? '' : ''}${formatSekAbs(value)}`)
const ibCells = [
writer.money('Aktiekapital', ctx.balans1 ?? ctx.balans0, ek.aktiekapital.ib),
...(hasOvriga ? [signedSek(ek.ovrigaPoster.ib)] : []),
writer.money('BalanseratResultat', ctx.balans1 ?? ctx.balans0, ek.balanseratResultat.ib),
writer.money('AretsResultatEgetKapital', ctx.balans1 ?? ctx.balans0, ek.aretsResultat.ib),
writer.money('EgetKapital', ctx.balans1 ?? ctx.balans0, ek.totalt.ib),
]
if (ctx.balans1) {
ekRows.push(
el('tr', {}, el('th', {}, 'Belopp vid årets ingång') + ibCells.map(td).join('')),
)
}
if (ek.balanserasINyRakning !== 0) {
ekRows.push(
el(
'tr',
{},
el('th', {}, 'Balanseras i ny räkning') +
td('') +
(hasOvriga ? td('') : '') +
td(
writer.money(
'ForandringEgetKapitalBalanseratResultatBalanserasNyRakning',
'period0',
ek.balanserasINyRakning,
),
) +
td(
writer.money(
'ForandringEgetKapitalAretsResultatBalanserasNyRakning',
'period0',
ek.balanserasINyRakning,
{ displayMinus: true },
),
) +
td(''),
),
)
}
if (ek.utdelning !== 0) {
ekRows.push(
el(
'tr',
{},
el('th', {}, 'Utdelning') +
td('') +
(hasOvriga ? td('') : '') +
td(
writer.money('ForandringEgetKapitalBalanseratResultatUtdelning', 'period0', ek.utdelning, {
displayMinus: true,
}),
) +
td('') +
td(
writer.money('ForandringEgetKapitalTotaltUtdelning', 'period0', ek.utdelning, {
displayMinus: true,
}),
),
),
)
}
if (ek.forandringAktiekapital !== 0) {
ekRows.push(
el(
'tr',
{},
el('th', {}, 'Nyemission') +
td(
writer.money(
'ForandringEgetKapitalAktiekapitalNyemission',
'period0',
ek.forandringAktiekapital,
),
) +
(hasOvriga ? td('') : '') +
td('') +
td('') +
td(
writer.money(
'ForandringEgetKapitalTotaltNyemission',
'period0',
ek.forandringAktiekapital,
),
),
),
)
}
if (ek.ovrigForandringBalanserat !== 0) {
warnings.push(
`Förändringen av balanserat resultat innehåller en post om ${ek.ovrigForandringBalanserat} kr som inte kan härledas till utdelning eller balansering: raden "Övrig förändring" är otaggad, granska den.`,
)
ekRows.push(
el(
'tr',
{},
el('th', {}, 'Övrig förändring') +
td('') +
(hasOvriga ? td('') : '') +
td(signedSek(ek.ovrigForandringBalanserat)) +
td('') +
td(signedSek(ek.ovrigForandringBalanserat)),
),
)
}
if (hasOvriga && ek.ovrigaPoster.ib !== ek.ovrigaPoster.ub) {
ekRows.push(
el(
'tr',
{},
el('th', {}, 'Förändring övriga poster') +
td('') +
td(signedSek(ek.ovrigaPoster.ub - ek.ovrigaPoster.ib)) +
td('') +
td('') +
td(''),
),
)
}
ekRows.push(
el(
'tr',
{},
el('th', {}, 'Årets resultat') +
td('') +
(hasOvriga ? td('') : '') +
td('') +
td(
writer.money(
'ForandringEgetKapitalAretsResultatAretsResultat',
'period0',
ek.aretsResultatRorelse,
),
) +
td(
writer.money('ForandringEgetKapitalTotaltAretsResultat', 'period0', ek.aretsResultatRorelse),
),
),
)
const ubCells = [
writer.money('Aktiekapital', 'balans0', ek.aktiekapital.ub),
...(hasOvriga ? [signedSek(ek.ovrigaPoster.ub)] : []),
writer.money('BalanseratResultat', 'balans0', ek.balanseratResultat.ub),
writer.money('AretsResultatEgetKapital', 'balans0', ek.aretsResultat.ub),
writer.money('EgetKapital', 'balans0', ek.totalt.ub),
]
ekRows.push(
el(
'tr',
{ class: 'subtotal' },
el('th', {}, 'Belopp vid årets utgång') + ubCells.map(td).join(''),
),
)
parts.push(el('table', {}, ekRows.join('')))
// Resultatdisposition. BalanseratResultat/AretsResultatEgetKapital/
// Overkursfond are repeated from the BR in the same balans0 context, so
// the values MUST be byte-identical (TA §2.7.3): fri överkursfond gets
// its own row tagged with its own concept instead of being folded into
// balanserat resultat.
const rd = fb.resultatdisposition
const rdRows: string[] = [
el(
'tr',
{},
el('th', {}, 'Till årsstämmans förfogande står följande medel (kr)') +
el('th', { class: 'num' }, ''),
),
]
if (rd.overkursfond !== 0) {
rdRows.push(
el(
'tr',
{},
el('th', {}, 'Överkursfond') +
el('td', { class: 'num' }, writer.money('Overkursfond', 'balans0', rd.overkursfond)),
),
)
}
rdRows.push(
el(
'tr',
{},
el('th', {}, 'Balanserat resultat') +
el('td', { class: 'num' }, writer.money('BalanseratResultat', 'balans0', rd.balanseratResultat)),
),
el(
'tr',
{},
el('th', {}, 'Årets resultat') +
el('td', { class: 'num' }, writer.money('AretsResultatEgetKapital', 'balans0', rd.aretsResultat)),
),
el(
'tr',
{ class: 'subtotal' },
el('th', {}, 'Summa') +
el('td', { class: 'num' }, writer.money('FrittEgetKapital', 'balans0', rd.summa)),
),
el(
'tr',
{},
el('th', {}, 'Styrelsen föreslår att medlen disponeras så att') +
el('th', { class: 'num' }, ''),
),
)
if (rd.utdelning !== 0) {
rdRows.push(
el(
'tr',
{},
el('th', {}, 'till aktieägarna utdelas') +
el(
'td',
{ class: 'num' },
writer.money('ForslagDispositionUtdelning', 'balans0', rd.utdelning),
),
),
)
}
rdRows.push(
el(
'tr',
{},
el('th', {}, 'i ny räkning balanseras') +
el(
'td',
{ class: 'num' },
writer.money('ForslagDispositionBalanserasINyRakning', 'balans0', rd.balanserasINyRakning),
),
),
el(
'tr',
{ class: 'subtotal' },
el('th', {}, 'Summa') +
el('td', { class: 'num' }, writer.money('ForslagDisposition', 'balans0', rd.summa)),
),
)
parts.push(el('h3', {}, 'Resultatdisposition'), el('table', {}, rdRows.join('')))
if (rd.kommentar && rd.kommentar.trim().length > 0) {
parts.push(
writer.textHtml('DispositionerVinstForlustKommentar', 'balans0', paragraphs(rd.kommentar)),
)
}
pages.push(el('div', { class: 'ar-page', id: 'ar-page-2' }, parts.join('\n')))
}
// ====== Page 3: resultaträkning ======
{
const rr = input.rr
const totals = input.totals
const head = el(
'tr',
{},
el('th', {}, 'Resultaträkning (kr)') +
el('th', { class: 'num' }, escapeText(fmtRange(input.period.start, input.period.end))) +
el(
'th',
{ class: 'num' },
input.previousPeriod
? escapeText(fmtRange(input.previousPeriod.start, input.previousPeriod.end))
: '',
),
)
const rows: string[] = [head]
rows.push(sectionRow('Rörelseintäkter, lagerförändringar m.m.'))
rows.push(
moneyRow(writer, 'Nettoomsättning', 'Nettoomsattning', rr['Nettoomsattning'], yearCtx, {
indent: true,
alwaysShow: true,
}),
moneyRow(
writer,
'Förändring av lager av produkter i arbete, färdiga varor och pågående arbete för annans räkning',
'ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning',
rr['ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Aktiverat arbete för egen räkning',
'AktiveratArbeteEgenRakning',
rr['AktiveratArbeteEgenRakning'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Övriga rörelseintäkter',
'OvrigaRorelseintakter',
rr['OvrigaRorelseintakter'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Summa rörelseintäkter, lagerförändringar m.m.',
'RorelseintakterLagerforandringarMm',
totals.rorelseintakter,
yearCtx,
{ rowClass: 'subtotal', alwaysShow: true },
),
)
rows.push(sectionRow('Rörelsekostnader'))
const costRow = (label: string, concept: string): string =>
moneyRow(writer, label, concept, rr[concept], yearCtx, { indent: true, displayMinus: true })
rows.push(
costRow('Råvaror och förnödenheter', 'RavarorFornodenheterKostnader'),
costRow('Handelsvaror', 'HandelsvarorKostnader'),
costRow('Övriga externa kostnader', 'OvrigaExternaKostnader'),
costRow('Personalkostnader', 'Personalkostnader'),
costRow(
'Av- och nedskrivningar av materiella och immateriella anläggningstillgångar',
'AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar',
),
costRow(
'Nedskrivningar av omsättningstillgångar utöver normala nedskrivningar',
'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
),
costRow('Övriga rörelsekostnader', 'OvrigaRorelsekostnader'),
moneyRow(writer, 'Summa rörelsekostnader', 'Rorelsekostnader', totals.rorelsekostnader, yearCtx, {
rowClass: 'subtotal',
displayMinus: true,
alwaysShow: true,
}),
moneyRow(writer, 'Rörelseresultat', 'Rorelseresultat', totals.rorelseresultat, yearCtx, {
rowClass: 'subtotal',
alwaysShow: true,
}),
)
rows.push(sectionRow('Finansiella poster'))
rows.push(
moneyRow(
writer,
'Resultat från andelar i koncernföretag',
'ResultatAndelarKoncernforetag',
rr['ResultatAndelarKoncernforetag'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Resultat från andelar i intresseföretag och gemensamt styrda företag',
'ResultatAndelarIntresseforetagGemensamtStyrda',
rr['ResultatAndelarIntresseforetagGemensamtStyrda'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Resultat från övriga företag som det finns ett ägarintresse i',
'ResultatOvrigaforetagAgarintresse',
rr['ResultatOvrigaforetagAgarintresse'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Resultat från övriga finansiella anläggningstillgångar',
'ResultatOvrigaFinansiellaAnlaggningstillgangar',
rr['ResultatOvrigaFinansiellaAnlaggningstillgangar'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Övriga ränteintäkter och liknande resultatposter',
'OvrigaRanteintakterLiknandeResultatposter',
rr['OvrigaRanteintakterLiknandeResultatposter'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Nedskrivningar av finansiella anläggningstillgångar och kortfristiga placeringar',
'NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar',
rr['NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar'],
yearCtx,
{ indent: true, displayMinus: true },
),
moneyRow(
writer,
'Räntekostnader och liknande resultatposter',
'RantekostnaderLiknandeResultatposter',
rr['RantekostnaderLiknandeResultatposter'],
yearCtx,
{ indent: true, displayMinus: true },
),
moneyRow(
writer,
'Summa finansiella poster',
'FinansiellaPoster',
totals.finansiellaPoster,
yearCtx,
{ rowClass: 'subtotal', alwaysShow: true },
),
moneyRow(
writer,
'Resultat efter finansiella poster',
'ResultatEfterFinansiellaPoster',
totals.resultatEfterFinansiellaPoster,
yearCtx,
{ rowClass: 'subtotal', alwaysShow: true },
),
)
const hasDispositioner =
totals.bokslutsdispositioner.current !== 0 ||
(totals.bokslutsdispositioner.previous ?? 0) !== 0
if (hasDispositioner) {
rows.push(sectionRow('Bokslutsdispositioner'))
rows.push(
moneyRow(
writer,
'Erhållna koncernbidrag',
'ErhallnaKoncernbidrag',
rr['ErhallnaKoncernbidrag'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Lämnade koncernbidrag',
'LamnadeKoncernbidrag',
rr['LamnadeKoncernbidrag'],
yearCtx,
{ indent: true, displayMinus: true },
),
moneyRow(
writer,
'Förändring av periodiseringsfonder',
'ForandringPeriodiseringsfond',
rr['ForandringPeriodiseringsfond'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Förändring av överavskrivningar',
'ForandringOveravskrivningar',
rr['ForandringOveravskrivningar'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Övriga bokslutsdispositioner',
'OvrigaBokslutsdispositioner',
rr['OvrigaBokslutsdispositioner'],
yearCtx,
{ indent: true },
),
moneyRow(
writer,
'Summa bokslutsdispositioner',
'Bokslutsdispositioner',
totals.bokslutsdispositioner,
yearCtx,
{ rowClass: 'subtotal', alwaysShow: true },
),
)
}
rows.push(
moneyRow(writer, 'Resultat före skatt', 'ResultatForeSkatt', totals.resultatForeSkatt, yearCtx, {
rowClass: 'subtotal',
alwaysShow: true,
}),
)
rows.push(sectionRow('Skatter'))
rows.push(
moneyRow(writer, 'Skatt på årets resultat', 'SkattAretsResultat', rr['SkattAretsResultat'], yearCtx, {
indent: true,
displayMinus: true,
}),
moneyRow(writer, 'Övriga skatter', 'OvrigaSkatter', rr['OvrigaSkatter'], yearCtx, {
indent: true,
displayMinus: true,
}),
moneyRow(writer, 'Årets resultat', 'AretsResultat', totals.aretsResultat, yearCtx, {
rowClass: 'total',
alwaysShow: true,
}),
)
pages.push(
el(
'div',
{ class: 'ar-page', id: 'ar-page-3' },
pageHeader(input, 3, totalPages) +
el('h2', {}, 'Resultaträkning') +
el('table', {}, rows.filter(Boolean).join('')),
),
)
}
// ====== Page 4: balansräkning ======
{
const br = input.br
const totals = input.totals
const head = (title: string): string =>
el(
'tr',
{},
el('th', {}, escapeText(title)) +
el('th', { class: 'num' }, escapeText(input.period.end)) +
el('th', { class: 'num' }, input.previousPeriod ? escapeText(input.previousPeriod.end) : ''),
)
const post = (label: string, concept: string, opts: { alwaysShow?: boolean } = {}): string =>
moneyRow(writer, label, concept, br[concept], balCtx, { indent: true, ...opts })
const subtotal = (label: string, concept: string, amountValue: ConceptAmount): string =>
moneyRow(writer, label, concept, amountValue, balCtx, { rowClass: 'subtotal', alwaysShow: true })
const assetRows: string[] = [head('Balansräkning: Tillgångar (kr)')]
assetRows.push(
moneyRow(writer, 'Tecknat men ej inbetalt kapital', 'TecknatEjInbetaltKapital', br['TecknatEjInbetaltKapital'], balCtx, {}),
)
assetRows.push(sectionRow('Anläggningstillgångar'))
if (totals.immateriellaAnlaggningstillgangar.current !== 0 || (totals.immateriellaAnlaggningstillgangar.previous ?? 0) !== 0) {
assetRows.push(sectionRow('Immateriella anläggningstillgångar', 'subsection'))
assetRows.push(
post('Koncessioner, patent, licenser, varumärken samt liknande rättigheter', 'KoncessionerPatentLicenserVarumarkenLiknandeRattigheter'),
post('Hyresrätter och liknande rättigheter', 'HyresratterLiknandeRattigheter'),
post('Goodwill', 'Goodwill'),
post('Förskott avseende immateriella anläggningstillgångar', 'ForskottImmateriellaAnlaggningstillgangar'),
subtotal('Summa immateriella anläggningstillgångar', 'ImmateriellaAnlaggningstillgangar', totals.immateriellaAnlaggningstillgangar),
)
}
if (totals.materiellaAnlaggningstillgangar.current !== 0 || (totals.materiellaAnlaggningstillgangar.previous ?? 0) !== 0) {
assetRows.push(sectionRow('Materiella anläggningstillgångar', 'subsection'))
assetRows.push(
post('Byggnader och mark', 'ByggnaderMark'),
post('Maskiner och andra tekniska anläggningar', 'MaskinerAndraTekniskaAnlaggningar'),
post('Inventarier, verktyg och installationer', 'InventarierVerktygInstallationer'),
post('Förbättringsutgifter på annans fastighet', 'ForbattringsutgifterAnnansFastighet'),
post('Övriga materiella anläggningstillgångar', 'OvrigaMateriellaAnlaggningstillgangar'),
post('Pågående nyanläggningar och förskott avseende materiella anläggningstillgångar', 'PagaendeNyanlaggningarForskottMateriellaAnlaggningstillgangar'),
subtotal('Summa materiella anläggningstillgångar', 'MateriellaAnlaggningstillgangar', totals.materiellaAnlaggningstillgangar),
)
}
if (totals.finansiellaAnlaggningstillgangar.current !== 0 || (totals.finansiellaAnlaggningstillgangar.previous ?? 0) !== 0) {
assetRows.push(sectionRow('Finansiella anläggningstillgångar', 'subsection'))
assetRows.push(
post('Andelar i koncernföretag', 'AndelarKoncernforetag'),
post('Fordringar hos koncernföretag', 'FordringarKoncernforetagLangfristiga'),
post('Andelar i intresseföretag och gemensamt styrda företag', 'AndelarIntresseforetagGemensamtStyrdaForetag'),
post('Fordringar hos intresseföretag och gemensamt styrda företag', 'FordringarIntresseforetagGemensamtStyrdaForetagLangfristiga'),
post('Ägarintressen i övriga företag', 'AgarintressenOvrigaForetag'),
post('Fordringar hos övriga företag som det finns ett ägarintresse i', 'FordringarOvrigaForetagAgarintresseLangfristiga'),
post('Andra långfristiga värdepappersinnehav', 'AndraLangfristigaVardepappersinnehav'),
post('Lån till delägare eller närstående', 'LanDelagareNarstaende'),
post('Andra långfristiga fordringar', 'AndraLangfristigaFordringar'),
subtotal('Summa finansiella anläggningstillgångar', 'FinansiellaAnlaggningstillgangar', totals.finansiellaAnlaggningstillgangar),
)
}
assetRows.push(subtotal('Summa anläggningstillgångar', 'Anlaggningstillgangar', totals.anlaggningstillgangar))
assetRows.push(sectionRow('Omsättningstillgångar'))
if (totals.varulager.current !== 0 || (totals.varulager.previous ?? 0) !== 0) {
assetRows.push(sectionRow('Varulager m.m.', 'subsection'))
assetRows.push(
post('Råvaror och förnödenheter', 'LagerRavarorFornodenheter'),
post('Varor under tillverkning', 'LagerVarorUnderTillverkning'),
post('Färdiga varor och handelsvaror', 'LagerFardigaVarorHandelsvaror'),
post('Pågående arbete för annans räkning', 'PagaendeArbetenAnnansRakningOmsattningstillgangar'),
post('Förskott till leverantörer', 'ForskottTillLeverantorer'),
post('Övriga lagertillgångar', 'OvrigaLagertillgangar'),
subtotal('Summa varulager m.m.', 'VarulagerMm', totals.varulager),
)
}
assetRows.push(sectionRow('Kortfristiga fordringar', 'subsection'))
assetRows.push(
post('Kundfordringar', 'Kundfordringar'),
post('Fordringar hos koncernföretag', 'FordringarKoncernforetagKortfristiga'),
post('Fordringar hos intresseföretag och gemensamt styrda företag', 'FordringarIntresseforetagGemensamtStyrdaForetagKortfristiga'),
post('Fordringar hos övriga företag som det finns ett ägarintresse i', 'FordringarOvrigaforetagAgarintresseKortfristiga'),
post('Övriga fordringar', 'OvrigaFordringarKortfristiga', { alwaysShow: true }),
post('Upparbetad men ej fakturerad intäkt', 'UpparbetadEjFaktureradIntakt'),
post('Förutbetalda kostnader och upplupna intäkter', 'ForutbetaldaKostnaderUpplupnaIntakter'),
subtotal('Summa kortfristiga fordringar', 'KortfristigaFordringar', totals.kortfristigaFordringar),
)
if (totals.kortfristigaPlaceringar.current !== 0 || (totals.kortfristigaPlaceringar.previous ?? 0) !== 0) {
assetRows.push(sectionRow('Kortfristiga placeringar', 'subsection'))
assetRows.push(
post('Andelar i koncernföretag', 'AndelarKoncernforetagKortfristiga'),
post('Övriga kortfristiga placeringar', 'OvrigaKortfristigaPlaceringar'),
subtotal('Summa kortfristiga placeringar', 'KortfristigaPlaceringar', totals.kortfristigaPlaceringar),
)
}
assetRows.push(sectionRow('Kassa och bank', 'subsection'))
assetRows.push(
post('Kassa och bank', 'KassaBankExklRedovisningsmedel', { alwaysShow: true }),
post('Redovisningsmedel', 'Redovisningsmedel'),
subtotal('Summa kassa och bank', 'KassaBank', totals.kassaBank),
subtotal('Summa omsättningstillgångar', 'Omsattningstillgangar', totals.omsattningstillgangar),
moneyRow(writer, 'Summa tillgångar', 'Tillgangar', totals.tillgangar, balCtx, {
rowClass: 'total',
alwaysShow: true,
}),
)
const eqRows: string[] = [head('Balansräkning: Eget kapital och skulder (kr)')]
eqRows.push(sectionRow('Eget kapital'))
eqRows.push(sectionRow('Bundet eget kapital', 'subsection'))
eqRows.push(
post('Aktiekapital', 'Aktiekapital', { alwaysShow: true }),
post('Ej registrerat aktiekapital', 'EjRegistreratAktiekapital'),
post('Bunden överkursfond', 'OverkursfondBunden'),
post('Uppskrivningsfond', 'Uppskrivningsfond'),
post('Reservfond', 'Reservfond'),
subtotal('Summa bundet eget kapital', 'BundetEgetKapital', totals.bundetEgetKapital),
)
eqRows.push(sectionRow('Fritt eget kapital', 'subsection'))
eqRows.push(
post('Överkursfond', 'Overkursfond'),
post('Balanserat resultat', 'BalanseratResultat', { alwaysShow: true }),
post('Årets resultat', 'AretsResultatEgetKapital', { alwaysShow: true }),
subtotal('Summa fritt eget kapital', 'FrittEgetKapital', totals.frittEgetKapital),
subtotal('Summa eget kapital', 'EgetKapital', totals.egetKapital),
)
if (totals.obeskattadeReserver.current !== 0 || (totals.obeskattadeReserver.previous ?? 0) !== 0) {
eqRows.push(sectionRow('Obeskattade reserver'))
eqRows.push(
post('Periodiseringsfonder', 'Periodiseringsfonder'),
post('Ackumulerade överavskrivningar', 'AckumuleradeOveravskrivningar'),
post('Övriga obeskattade reserver', 'OvrigaObeskattadeReserver'),
subtotal('Summa obeskattade reserver', 'ObeskattadeReserver', totals.obeskattadeReserver),
)
}
if (totals.avsattningar.current !== 0 || (totals.avsattningar.previous ?? 0) !== 0) {
eqRows.push(sectionRow('Avsättningar'))
eqRows.push(
post('Avsättningar för pensioner och liknande förpliktelser enligt lag', 'AvsattningarPensionerLiknandeForpliktelserEnligtLag'),
post('Övriga avsättningar för pensioner och liknande förpliktelser', 'OvrigaAvsattningarPensionerLiknandeForpliktelser'),
post('Övriga avsättningar', 'OvrigaAvsattningar'),
subtotal('Summa avsättningar', 'Avsattningar', totals.avsattningar),
)
}
if (totals.langfristigaSkulder.current !== 0 || (totals.langfristigaSkulder.previous ?? 0) !== 0) {
eqRows.push(sectionRow('Långfristiga skulder'))
eqRows.push(
post('Obligationslån', 'Obligationslan'),
post('Checkräkningskredit', 'CheckrakningskreditLangfristig'),
post('Övriga skulder till kreditinstitut', 'OvrigaLangfristigaSkulderKreditinstitut'),
post('Skulder till koncernföretag', 'SkulderKoncernforetagLangfristiga'),
post('Skulder till intresseföretag och gemensamt styrda företag', 'SkulderIntresseforetagGemensamtStyrdaForetagLangfristiga'),
post('Skulder till övriga företag som det finns ett ägarintresse i', 'SkulderOvrigaForetagAgarintresseLangfristiga'),
post('Övriga skulder', 'OvrigaLangfristigaSkulder'),
subtotal('Summa långfristiga skulder', 'LangfristigaSkulder', totals.langfristigaSkulder),
)
}
eqRows.push(sectionRow('Kortfristiga skulder'))
eqRows.push(
post('Förskott från kunder', 'ForskottFranKunder'),
post('Checkräkningskredit', 'CheckrakningskreditKortfristig'),
post('Övriga skulder till kreditinstitut', 'OvrigaKortfristigaSkulderKreditinstitut'),
post('Pågående arbete för annans räkning', 'PagaendeArbetenAnnansRakningKortfristigaSkulder'),
post('Fakturerad men ej upparbetad intäkt', 'FaktureradEjUpparbetadIntakt'),
post('Leverantörsskulder', 'Leverantorsskulder', { alwaysShow: true }),
post('Växelskulder', 'Vaxelskulder'),
post('Skulder till koncernföretag', 'SkulderKoncernforetagKortfristiga'),
post('Skulder till intresseföretag och gemensamt styrda företag', 'SkulderIntresseforetagGemensamtStyrdaForetagKortfristiga'),
post('Skulder till övriga företag som det finns ett ägarintresse i', 'SkulderOvrigaForetagAgarintresseKortfristiga'),
post('Skatteskulder', 'Skatteskulder'),
post('Övriga skulder', 'OvrigaKortfristigaSkulder', { alwaysShow: true }),
post('Upplupna kostnader och förutbetalda intäkter', 'UpplupnaKostnaderForutbetaldaIntakter'),
subtotal('Summa kortfristiga skulder', 'KortfristigaSkulder', totals.kortfristigaSkulder),
moneyRow(writer, 'Summa eget kapital och skulder', 'EgetKapitalSkulder', totals.egetKapitalSkulder, balCtx, {
rowClass: 'total',
alwaysShow: true,
}),
)
pages.push(
el(
'div',
{ class: 'ar-page', id: 'ar-page-4' },
pageHeader(input, 4, totalPages) +
el('h2', {}, 'Balansräkning') +
el('table', {}, assetRows.filter(Boolean).join('')) +
el('table', {}, eqRows.filter(Boolean).join('')),
),
)
}
// ====== Page 5: noter ======
{
const parts: string[] = [pageHeader(input, 5, totalPages), el('h2', {}, 'Noter')]
for (const note of input.noter) {
parts.push(
el(
'h3',
{ id: `not-${note.number}` },
`Not ${note.number} ${escapeText(note.title)}`,
),
)
const isPrinciples = /redovisnings.*principer/i.test(note.title)
const isMedelantal = /medelantal.*anst/i.test(note.title)
const isLongTermDebt = /långfristiga skulder/i.test(note.title)
const isSecurities = /ställda säkerheter/i.test(note.title)
const isContingencies = /eventualförpliktelser/i.test(note.title)
const isParentCompany = /koncernförhållanden/i.test(note.title)
if (isPrinciples) {
parts.push(
writer.textHtml('RedovisningsVarderingsprinciper', 'period0', paragraphs(note.body)),
)
} else if (isMedelantal) {
// Render the FTE figure as tagged facts (TA §2.14) for both years.
const medel = input.medelantalAnstallda
parts.push(
el(
'table',
{},
el(
'tr',
{},
el('th', {}, '') +
el('th', { class: 'num' }, escapeText(input.period.end.slice(0, 4))) +
el(
'th',
{ class: 'num' },
input.previousPeriod ? escapeText(input.previousPeriod.end.slice(0, 4)) : '',
),
) +
el(
'tr',
{},
el('th', {}, 'Medelantal anställda') +
el(
'td',
{ class: 'num' },
writer.antalAnstallda('MedelantaletAnstallda', 'period0', medel.current),
) +
el(
'td',
{ class: 'num' },
ctx.period1 && medel.previous !== null
? writer.antalAnstallda('MedelantaletAnstallda', ctx.period1, medel.previous)
: '',
),
),
),
)
} else if (isLongTermDebt) {
parts.push(
paragraphs(note.body) +
el(
'p',
{ class: 'note-fact' },
'Belopp som förfaller senare än fem år efter balansdagen: ' +
writer.money(
'LangfristigaSkulderForfallerSenare5Ar',
'balans0',
input.disclosures.longTermDebtOverFiveYears,
) +
' kr.',
),
)
} else if (isSecurities) {
parts.push(
writer.textPlain(
'NotStalldaSakerheter',
'balans0',
input.disclosures.securitiesPledged,
),
)
} else if (isContingencies) {
parts.push(
writer.textPlain(
'NotEventualforpliktelser',
'balans0',
input.disclosures.contingentLiabilities,
),
)
} else if (isParentCompany && input.disclosures.parentCompany) {
parts.push(
writer.textPlain('NotUpplysningModerforetag', 'period0', input.disclosures.parentCompany),
)
} else {
parts.push(paragraphs(note.body))
}
}
pages.push(el('div', { class: 'ar-page', id: 'ar-page-5' }, parts.join('\n')))
}
// ====== Page 6: underskrifter ======
{
const u = input.underskrifter
const parts: string[] = [pageHeader(input, 6, totalPages), el('h2', {}, 'Underskrifter')]
const ortFact = getConcept(registry, 'UndertecknandeArsredovisningOrt')
? writer.textPlain('UndertecknandeArsredovisningOrt', 'period0', u.ort)
: escapeText(u.ort)
// Datering av årsredovisning is reinstated for fiscal years beginning
// 2024-07-01 or later (element-list note on UndertecknandeArsredovisningDatum);
// TA §2.9.3 forbade it for earlier years.
const tagDatering = u.dateringsdatum !== null && input.period.start >= '2024-07-01'
parts.push(
el(
'p',
{},
ortFact + (tagDatering && u.dateringsdatum
? ' den ' + writer.date('UndertecknandeArsredovisningDatum', 'period0', u.dateringsdatum)
: ''),
),
)
const tupleMembers = registry.tuples['UnderskriftArsredovisningForetradareTuple']?.members ?? []
const orderOf = (member: string): string => {
const index = tupleMembers.findIndex((m) => m.name === member)
return `${(index === -1 ? 0 : index) + 1}.0`
}
const tupleDecls: string[] = []
const signerDivs: string[] = []
input.underskrifter.signers.forEach((signer) => {
const tupleId = writer.declareTupleId('UnderskriftArsredovisningForetradareTuple')
tupleDecls.push(writer.tupleDeclaration('UnderskriftArsredovisningForetradareTuple', tupleId))
const fullName = `${signer.firstName} ${signer.lastName}`.trim()
const inner: string[] = [
el('span', { class: 'sig' }, escapeText(fullName)),
'<br/>',
writer.textPlain('UnderskriftHandlingTilltalsnamn', 'period0', signer.firstName, {
tupleRef: tupleId,
order: orderOf('UnderskriftHandlingTilltalsnamn'),
}),
' ',
writer.textPlain('UnderskriftHandlingEfternamn', 'period0', signer.lastName, {
tupleRef: tupleId,
order: orderOf('UnderskriftHandlingEfternamn'),
}),
]
if (signer.role) {
inner.push(
'<br/>',
writer.textPlain('UnderskriftHandlingRoll', 'period0', signer.role, {
tupleRef: tupleId,
order: orderOf('UnderskriftHandlingRoll'),
}),
)
}
// Per-signer date: mandatory for FY ending 2021-12-31+ (TA §2.9.1,
// kontrollera 1107/1214). An unsigned request has signedDate null: the
// fact is omitted (never fabricated) and preflight 1214 blocks filing.
if (signer.signedDate !== null) {
inner.push(
'<br/>',
writer.date('UndertecknandeDatum', 'period0', signer.signedDate, {
tupleRef: tupleId,
order: orderOf('UndertecknandeDatum'),
}),
)
}
signerDivs.push(el('div', { class: 'ar-sign-name' }, inner.join('')))
})
parts.push(tupleDecls.join('\n'), signerDivs.join('\n'))
pages.push(el('div', { class: 'ar-page', id: 'ar-page-6' }, parts.join('\n')))
}
// ---- assemble -------------------------------------------------------------
const xmlnsAttrs = Object.entries(entryPoint.namespaces)
.map(([prefix, uri]) => `xmlns:${prefix}="${escapeAttr(uri)}"`)
.join('\n ')
const title = `Årsredovisning ${input.company.name} ${input.company.orgNumber} räkenskapsåret ${input.period.start}-${input.period.end}`
const head = el(
'head',
{},
[
el('title', {}, escapeText(title)),
'<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>',
`<meta name="programvara" content="${escapeAttr(input.programvara.namn)}"/>`,
`<meta name="programversion" content="${escapeAttr(input.programvara.version)}"/>`,
el('style', { type: 'text/css' }, '\n' + CSS + '\n'),
].join('\n'),
)
const body = el('body', {}, [writer.renderHeader(), el('div', { id: 'wrapper' }, pages.join('\n'))].join('\n'))
const xhtml =
'<?xml version="1.0" encoding="utf-8"?>\n' +
`<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="sv"\n ${xmlnsAttrs}>\n` +
head +
'\n' +
body +
'\n</html>\n'
return { xhtml, warnings }
}
/**
* Insert the Bolagsverket-computed kontrollsumma meta tags into a generated
* document (TA §4.5.2). Run AFTER skapa-kontrollsumma since the checksum is
* computed over the file content excluding fastställelseintyg/underskrifts-
* datum/meta tags: adding the meta tags does not invalidate it.
*/
export function embedKontrollsumma(xhtml: string, kontrollsumma: string, algoritm: string): string {
const meta =
`<meta name="ixbrl.innehall.kontrollsumman" content="${escapeAttr(kontrollsumma)}"/>\n` +
`<meta name="ixbrl.innehall.kontrollsumman.algoritm" content="${escapeAttr(algoritm)}"/>\n`
return xhtml.replace('</head>', meta + '</head>')
}