Files
accounted/lib/bokslut/arsredovisning/statement-rows.ts
T
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00

457 lines
18 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.
/**
* ÅRL uppställningsform presentation rows for the årsredovisning PDF.
*
* Derives post-level RR/BR rows from the same K2 risbs mapping that drives
* the iXBRL filing (lib/bokslut/ixbrl/k2-mapper.ts), so the human-readable
* PDF and the digitally filed document cannot diverge. Labels and row order
* mirror lib/bokslut/ixbrl/document/k2-document.ts.
*
* Account numbers must never appear in these labels — Bolagsverket rejects
* balans- och resultaträkningar that contain kontonummer; the statutory
* uppställningsform (ÅRL bilaga 1–2) knows only posts, never BAS accounts.
*
* Sign conventions match the iXBRL document layer: every ConceptAmount is
* oriented to the concept's natural balance, and cost/deduction posts get a
* presentational minus (`displayMinus`) so the RR reads top-to-bottom as
* intäkter − kostnader = resultat.
*/
import type { ConceptAmount } from '@/lib/bokslut/ixbrl/types'
import type { K2MappingResult } from '@/lib/bokslut/ixbrl/k2-mapper'
import type { StatementRow } from './types'
const ZERO: ConceptAmount = { current: 0, previous: null }
type StatementMapping = Pick<K2MappingResult, 'rr' | 'br' | 'totals'>
function hasValue(amount: ConceptAmount): boolean {
return amount.current !== 0 || (amount.previous ?? 0) !== 0
}
interface RowOptions {
indent?: number
semantic_key?: StatementRow['semantic_key']
/** Presentational minus — show cost posts as negative. */
displayMinus?: boolean
/** Emit the row even when zero in both years (statutory always-visible posts). */
alwaysShow?: boolean
}
class RowBuilder {
readonly rows: StatementRow[] = []
constructor(private readonly hasPrevious: boolean) {}
heading(label: string, indent = 0): void {
this.rows.push({ label, current: null, previous: null, is_heading: true, indent })
}
post(label: string, amount: ConceptAmount | undefined, opts: RowOptions = {}): void {
const value = amount ?? ZERO
if (!opts.alwaysShow && !hasValue(value)) return
this.rows.push(this.buildAmountRow(label, value, opts, false))
}
total(label: string, amount: ConceptAmount, opts: RowOptions = {}): void {
this.rows.push(this.buildAmountRow(label, amount, opts, true))
}
private buildAmountRow(
label: string,
amount: ConceptAmount,
opts: RowOptions,
isTotal: boolean,
): StatementRow {
const sign = opts.displayMinus ? -1 : 1
return {
label,
current: sign * amount.current,
previous: this.hasPrevious ? sign * (amount.previous ?? 0) : null,
...(opts.semantic_key ? { semantic_key: opts.semantic_key } : {}),
...(isTotal ? { is_total: true } : {}),
...(opts.indent ? { indent: opts.indent } : {}),
}
}
}
/** The mapper leaves `previous` null on every concept when the company has
* no previous fiscal year; any concept with a number means a jämförelseår
* exists. */
function mappingHasPrevious(mapping: StatementMapping): boolean {
return mapping.totals.tillgangar.previous !== null
}
/**
* Resultaträkning — kostnadsslagsindelad per ÅRL bilaga 2 / K2 risbs, in
* uppställningsform order.
*/
export function buildRrRows(mapping: StatementMapping): StatementRow[] {
const { rr, totals } = mapping
const b = new RowBuilder(mappingHasPrevious(mapping))
b.heading('Rörelseintäkter, lagerförändringar m.m.')
b.post('Nettoomsättning', rr['Nettoomsattning'], { indent: 1, alwaysShow: true })
b.post(
'Förändring av lager av produkter i arbete, färdiga varor och pågående arbete för annans räkning',
rr['ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning'],
{ indent: 1 },
)
b.post('Aktiverat arbete för egen räkning', rr['AktiveratArbeteEgenRakning'], { indent: 1 })
b.post('Övriga rörelseintäkter', rr['OvrigaRorelseintakter'], { indent: 1 })
b.total('Summa rörelseintäkter, lagerförändringar m.m.', totals.rorelseintakter)
b.heading('Rörelsekostnader')
const cost = (label: string, concept: string): void =>
b.post(label, rr[concept], { indent: 1, displayMinus: true })
cost('Råvaror och förnödenheter', 'RavarorFornodenheterKostnader')
cost('Handelsvaror', 'HandelsvarorKostnader')
cost('Övriga externa kostnader', 'OvrigaExternaKostnader')
cost('Personalkostnader', 'Personalkostnader')
cost(
'Av- och nedskrivningar av materiella och immateriella anläggningstillgångar',
'AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar',
)
cost(
'Nedskrivningar av omsättningstillgångar utöver normala nedskrivningar',
'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
)
cost('Övriga rörelsekostnader', 'OvrigaRorelsekostnader')
b.total('Summa rörelsekostnader', totals.rorelsekostnader, { displayMinus: true })
b.total('Rörelseresultat', totals.rorelseresultat)
b.heading('Finansiella poster')
b.post('Resultat från andelar i koncernföretag', rr['ResultatAndelarKoncernforetag'], {
indent: 1,
})
b.post(
'Resultat från andelar i intresseföretag och gemensamt styrda företag',
rr['ResultatAndelarIntresseforetagGemensamtStyrda'],
{ indent: 1 },
)
b.post(
'Resultat från övriga företag som det finns ett ägarintresse i',
rr['ResultatOvrigaforetagAgarintresse'],
{ indent: 1 },
)
b.post(
'Resultat från övriga finansiella anläggningstillgångar',
rr['ResultatOvrigaFinansiellaAnlaggningstillgangar'],
{ indent: 1 },
)
b.post(
'Övriga ränteintäkter och liknande resultatposter',
rr['OvrigaRanteintakterLiknandeResultatposter'],
{ indent: 1 },
)
b.post(
'Nedskrivningar av finansiella anläggningstillgångar och kortfristiga placeringar',
rr['NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar'],
{ indent: 1, displayMinus: true },
)
b.post(
'Räntekostnader och liknande resultatposter',
rr['RantekostnaderLiknandeResultatposter'],
{ indent: 1, displayMinus: true },
)
b.total('Summa finansiella poster', totals.finansiellaPoster)
b.total('Resultat efter finansiella poster', totals.resultatEfterFinansiellaPoster)
if (hasValue(totals.bokslutsdispositioner)) {
b.heading('Bokslutsdispositioner')
b.post('Erhållna koncernbidrag', rr['ErhallnaKoncernbidrag'], { indent: 1 })
b.post('Lämnade koncernbidrag', rr['LamnadeKoncernbidrag'], {
indent: 1,
displayMinus: true,
})
b.post('Förändring av periodiseringsfonder', rr['ForandringPeriodiseringsfond'], {
indent: 1,
})
b.post('Förändring av överavskrivningar', rr['ForandringOveravskrivningar'], { indent: 1 })
b.post('Övriga bokslutsdispositioner', rr['OvrigaBokslutsdispositioner'], { indent: 1 })
b.total('Summa bokslutsdispositioner', totals.bokslutsdispositioner)
}
b.total('Resultat före skatt', totals.resultatForeSkatt)
b.heading('Skatter')
b.post('Skatt på årets resultat', rr['SkattAretsResultat'], { indent: 1, displayMinus: true })
b.post('Övriga skatter', rr['OvrigaSkatter'], { indent: 1, displayMinus: true })
b.total('Årets resultat', totals.aretsResultat, {
semantic_key: 'income_statement_result',
})
return b.rows
}
/**
* Balansräkning per ÅRL bilaga 1 / K2 risbs. Subsections whose total is zero
* in both years are omitted entirely (matching the iXBRL document layer);
* kortfristiga fordringar, kassa och bank, eget kapital and kortfristiga
* skulder always render.
*/
export function buildBrRows(mapping: StatementMapping): {
assets: StatementRow[]
equityLiabilities: StatementRow[]
} {
const { br, totals } = mapping
const hasPrevious = mappingHasPrevious(mapping)
const a = new RowBuilder(hasPrevious)
a.post('Tecknat men ej inbetalt kapital', br['TecknatEjInbetaltKapital'])
a.heading('Anläggningstillgångar')
if (hasValue(totals.immateriellaAnlaggningstillgangar)) {
a.heading('Immateriella anläggningstillgångar', 1)
a.post(
'Koncessioner, patent, licenser, varumärken samt liknande rättigheter',
br['KoncessionerPatentLicenserVarumarkenLiknandeRattigheter'],
{ indent: 2 },
)
a.post('Hyresrätter och liknande rättigheter', br['HyresratterLiknandeRattigheter'], {
indent: 2,
})
a.post('Goodwill', br['Goodwill'], { indent: 2 })
a.post(
'Förskott avseende immateriella anläggningstillgångar',
br['ForskottImmateriellaAnlaggningstillgangar'],
{ indent: 2 },
)
a.total(
'Summa immateriella anläggningstillgångar',
totals.immateriellaAnlaggningstillgangar,
{ indent: 1 },
)
}
if (hasValue(totals.materiellaAnlaggningstillgangar)) {
a.heading('Materiella anläggningstillgångar', 1)
a.post('Byggnader och mark', br['ByggnaderMark'], { indent: 2 })
a.post(
'Maskiner och andra tekniska anläggningar',
br['MaskinerAndraTekniskaAnlaggningar'],
{ indent: 2 },
)
a.post(
'Inventarier, verktyg och installationer',
br['InventarierVerktygInstallationer'],
{ indent: 2 },
)
a.post(
'Förbättringsutgifter på annans fastighet',
br['ForbattringsutgifterAnnansFastighet'],
{ indent: 2 },
)
a.post(
'Övriga materiella anläggningstillgångar',
br['OvrigaMateriellaAnlaggningstillgangar'],
{ indent: 2 },
)
a.post(
'Pågående nyanläggningar och förskott avseende materiella anläggningstillgångar',
br['PagaendeNyanlaggningarForskottMateriellaAnlaggningstillgangar'],
{ indent: 2 },
)
a.total('Summa materiella anläggningstillgångar', totals.materiellaAnlaggningstillgangar, {
indent: 1,
})
}
if (hasValue(totals.finansiellaAnlaggningstillgangar)) {
a.heading('Finansiella anläggningstillgångar', 1)
a.post('Andelar i koncernföretag', br['AndelarKoncernforetag'], { indent: 2 })
a.post('Fordringar hos koncernföretag', br['FordringarKoncernforetagLangfristiga'], {
indent: 2,
})
a.post(
'Andelar i intresseföretag och gemensamt styrda företag',
br['AndelarIntresseforetagGemensamtStyrdaForetag'],
{ indent: 2 },
)
a.post(
'Fordringar hos intresseföretag och gemensamt styrda företag',
br['FordringarIntresseforetagGemensamtStyrdaForetagLangfristiga'],
{ indent: 2 },
)
a.post('Ägarintressen i övriga företag', br['AgarintressenOvrigaForetag'], { indent: 2 })
a.post(
'Fordringar hos övriga företag som det finns ett ägarintresse i',
br['FordringarOvrigaForetagAgarintresseLangfristiga'],
{ indent: 2 },
)
a.post(
'Andra långfristiga värdepappersinnehav',
br['AndraLangfristigaVardepappersinnehav'],
{ indent: 2 },
)
a.post('Lån till delägare eller närstående', br['LanDelagareNarstaende'], { indent: 2 })
a.post('Andra långfristiga fordringar', br['AndraLangfristigaFordringar'], { indent: 2 })
a.total(
'Summa finansiella anläggningstillgångar',
totals.finansiellaAnlaggningstillgangar,
{ indent: 1 },
)
}
a.total('Summa anläggningstillgångar', totals.anlaggningstillgangar)
a.heading('Omsättningstillgångar')
if (hasValue(totals.varulager)) {
a.heading('Varulager m.m.', 1)
a.post('Råvaror och förnödenheter', br['LagerRavarorFornodenheter'], { indent: 2 })
a.post('Varor under tillverkning', br['LagerVarorUnderTillverkning'], { indent: 2 })
a.post('Färdiga varor och handelsvaror', br['LagerFardigaVarorHandelsvaror'], { indent: 2 })
a.post(
'Pågående arbete för annans räkning',
br['PagaendeArbetenAnnansRakningOmsattningstillgangar'],
{ indent: 2 },
)
a.post('Förskott till leverantörer', br['ForskottTillLeverantorer'], { indent: 2 })
a.post('Övriga lagertillgångar', br['OvrigaLagertillgangar'], { indent: 2 })
a.total('Summa varulager m.m.', totals.varulager, { indent: 1 })
}
a.heading('Kortfristiga fordringar', 1)
a.post('Kundfordringar', br['Kundfordringar'], { indent: 2 })
a.post('Fordringar hos koncernföretag', br['FordringarKoncernforetagKortfristiga'], {
indent: 2,
})
a.post(
'Fordringar hos intresseföretag och gemensamt styrda företag',
br['FordringarIntresseforetagGemensamtStyrdaForetagKortfristiga'],
{ indent: 2 },
)
a.post(
'Fordringar hos övriga företag som det finns ett ägarintresse i',
br['FordringarOvrigaforetagAgarintresseKortfristiga'],
{ indent: 2 },
)
a.post('Övriga fordringar', br['OvrigaFordringarKortfristiga'], {
indent: 2,
alwaysShow: true,
})
a.post('Upparbetad men ej fakturerad intäkt', br['UpparbetadEjFaktureradIntakt'], {
indent: 2,
})
a.post(
'Förutbetalda kostnader och upplupna intäkter',
br['ForutbetaldaKostnaderUpplupnaIntakter'],
{ indent: 2 },
)
a.total('Summa kortfristiga fordringar', totals.kortfristigaFordringar, { indent: 1 })
if (hasValue(totals.kortfristigaPlaceringar)) {
a.heading('Kortfristiga placeringar', 1)
a.post('Andelar i koncernföretag', br['AndelarKoncernforetagKortfristiga'], { indent: 2 })
a.post('Övriga kortfristiga placeringar', br['OvrigaKortfristigaPlaceringar'], {
indent: 2,
})
a.total('Summa kortfristiga placeringar', totals.kortfristigaPlaceringar, { indent: 1 })
}
a.heading('Kassa och bank', 1)
a.post('Kassa och bank', br['KassaBankExklRedovisningsmedel'], {
indent: 2,
alwaysShow: true,
})
a.post('Redovisningsmedel', br['Redovisningsmedel'], { indent: 2 })
a.total('Summa kassa och bank', totals.kassaBank, { indent: 1 })
a.total('Summa omsättningstillgångar', totals.omsattningstillgangar)
a.total('Summa tillgångar', totals.tillgangar)
const e = new RowBuilder(hasPrevious)
e.heading('Eget kapital')
e.heading('Bundet eget kapital', 1)
e.post('Aktiekapital', br['Aktiekapital'], { indent: 2, alwaysShow: true })
e.post('Ej registrerat aktiekapital', br['EjRegistreratAktiekapital'], { indent: 2 })
e.post('Bunden överkursfond', br['OverkursfondBunden'], { indent: 2 })
e.post('Uppskrivningsfond', br['Uppskrivningsfond'], { indent: 2 })
e.post('Reservfond', br['Reservfond'], { indent: 2 })
e.total('Summa bundet eget kapital', totals.bundetEgetKapital, { indent: 1 })
e.heading('Fritt eget kapital', 1)
e.post('Överkursfond', br['Overkursfond'], { indent: 2 })
e.post('Balanserat resultat', br['BalanseratResultat'], { indent: 2, alwaysShow: true })
e.post('Årets resultat', br['AretsResultatEgetKapital'], {
indent: 2,
alwaysShow: true,
semantic_key: 'balance_sheet_current_year_result',
})
e.total('Summa fritt eget kapital', totals.frittEgetKapital, { indent: 1 })
e.total('Summa eget kapital', totals.egetKapital)
if (hasValue(totals.obeskattadeReserver)) {
e.heading('Obeskattade reserver')
e.post('Periodiseringsfonder', br['Periodiseringsfonder'], { indent: 1 })
e.post('Ackumulerade överavskrivningar', br['AckumuleradeOveravskrivningar'], { indent: 1 })
e.post('Övriga obeskattade reserver', br['OvrigaObeskattadeReserver'], { indent: 1 })
e.total('Summa obeskattade reserver', totals.obeskattadeReserver)
}
if (hasValue(totals.avsattningar)) {
e.heading('Avsättningar')
e.post(
'Avsättningar för pensioner och liknande förpliktelser enligt lag',
br['AvsattningarPensionerLiknandeForpliktelserEnligtLag'],
{ indent: 1 },
)
e.post(
'Övriga avsättningar för pensioner och liknande förpliktelser',
br['OvrigaAvsattningarPensionerLiknandeForpliktelser'],
{ indent: 1 },
)
e.post('Övriga avsättningar', br['OvrigaAvsattningar'], { indent: 1 })
e.total('Summa avsättningar', totals.avsattningar)
}
if (hasValue(totals.langfristigaSkulder)) {
e.heading('Långfristiga skulder')
e.post('Obligationslån', br['Obligationslan'], { indent: 1 })
e.post('Checkräkningskredit', br['CheckrakningskreditLangfristig'], { indent: 1 })
e.post(
'Övriga skulder till kreditinstitut',
br['OvrigaLangfristigaSkulderKreditinstitut'],
{ indent: 1 },
)
e.post('Skulder till koncernföretag', br['SkulderKoncernforetagLangfristiga'], {
indent: 1,
})
e.post(
'Skulder till intresseföretag och gemensamt styrda företag',
br['SkulderIntresseforetagGemensamtStyrdaForetagLangfristiga'],
{ indent: 1 },
)
e.post(
'Skulder till övriga företag som det finns ett ägarintresse i',
br['SkulderOvrigaForetagAgarintresseLangfristiga'],
{ indent: 1 },
)
e.post('Övriga skulder', br['OvrigaLangfristigaSkulder'], { indent: 1 })
e.total('Summa långfristiga skulder', totals.langfristigaSkulder)
}
e.heading('Kortfristiga skulder')
e.post('Förskott från kunder', br['ForskottFranKunder'], { indent: 1 })
e.post('Checkräkningskredit', br['CheckrakningskreditKortfristig'], { indent: 1 })
e.post('Övriga skulder till kreditinstitut', br['OvrigaKortfristigaSkulderKreditinstitut'], {
indent: 1,
})
e.post(
'Pågående arbete för annans räkning',
br['PagaendeArbetenAnnansRakningKortfristigaSkulder'],
{ indent: 1 },
)
e.post('Fakturerad men ej upparbetad intäkt', br['FaktureradEjUpparbetadIntakt'], {
indent: 1,
})
e.post('Leverantörsskulder', br['Leverantorsskulder'], { indent: 1, alwaysShow: true })
e.post('Växelskulder', br['Vaxelskulder'], { indent: 1 })
e.post('Skulder till koncernföretag', br['SkulderKoncernforetagKortfristiga'], { indent: 1 })
e.post(
'Skulder till intresseföretag och gemensamt styrda företag',
br['SkulderIntresseforetagGemensamtStyrdaForetagKortfristiga'],
{ indent: 1 },
)
e.post(
'Skulder till övriga företag som det finns ett ägarintresse i',
br['SkulderOvrigaForetagAgarintresseKortfristiga'],
{ indent: 1 },
)
e.post('Skatteskulder', br['Skatteskulder'], { indent: 1 })
e.post('Övriga skulder', br['OvrigaKortfristigaSkulder'], { indent: 1, alwaysShow: true })
e.post(
'Upplupna kostnader och förutbetalda intäkter',
br['UpplupnaKostnaderForutbetaldaIntakter'],
{ indent: 1 },
)
e.total('Summa kortfristiga skulder', totals.kortfristigaSkulder)
e.total('Summa eget kapital och skulder', totals.egetKapitalSkulder)
return { assets: a.rows, equityLiabilities: e.rows }
}