Files
accounted/lib/bokslut/arsredovisning/build-data.ts
T
0e3c0af841 fix(sie-import): refuse a closed or locked target year up front and point at Öppna igen (#2334)
A SIE file whose #RAR falls inside an existing fiscal year answered
'match' in precheckFiscalPeriod without looking at is_closed or
locked_at, so the import ran into the atomic voucher RPC and surfaced
the DB trigger's own text ("Cannot write to locked/closed fiscal
period"). Observed 2026-09-04: an owner klarmarkerade an empty prior
year, could not import its single aktiekapital voucher, and never found
the "Öppna igen" button that undoes klarmarkera.

The precheck now returns a conflict verdict for a closed or locked
containing year, with the remedy per state: Öppna igen for a
klarmarkerad year, Lås upp for a locked one, and no false hope for a
year closed by a year-end run. The parse preview shows the same text
and disables the import; executeSIEImport's no-create branch gets the
same refusal.

The årsredovisning builder now warns when the comparison year exists
but holds no entries in Accounted: the column reads 0 kr, ÅRL 3 kap.
5 § requires the prior year's amounts, and the warning says where the
fix lives instead of printing zeros silently.


Claude-Session: https://claude.ai/code/session_015kJVo845t3ZMtFcCMhFEuk

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 19:13:28 +02:00

1288 lines
54 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { LATENT_TAX_DEFAULT_RATE } from '@/lib/bokslut/tax-provision/latent-tax-calculator'
import { roundOre } from '@/lib/money'
import {
mapTrialBalancesToK2,
type K2MappingResult,
type TrialBalancePair,
} from '@/lib/bokslut/ixbrl/k2-mapper'
import { buildBrRows, buildRrRows } from './statement-rows'
import { getNarrative, type NarrativeRow } from './narrative-service'
import {
anyAssetHasComponents,
buildEquityChangesNote,
buildK3RedovisningsPrinciper,
buildMateriellaAnlaggningsNot,
buildUppskjutenSkattNot,
} from './k3-noter-builder'
import {
buildAnlaggningstillgangarNote,
computeRollforwardTotals,
type AnlaggningAsset,
} from './anlaggningstillgangar-note'
import { computeAssetNoteFigures, loadPostedSchedules } from './asset-note-figures'
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
import type {
ArsredovisningData,
EgenKapitalRow,
FlerarsoversiktRow,
NoteEntry,
KassaflodesAnalysisSummary,
} from './types'
import type { AccountingFramework, Asset, TrialBalanceRow } from '@/types'
/**
* Pre-populate the K2 årsredovisning data for a fiscal period. Loads:
* - Income statement + balance sheet for the current period
* - Up to 3 prior periods for the flerårsöversikt
* - Asset register so noter can list avskrivningstider per category
* - Active employees count for medelantal anställda
* - Equity-account movements for förändring av eget kapital
*
* Manually-authored fields (description, important_events,
* resultatdisposition, ställda säkerheter, eventualförpliktelser) are
* pre-filled with sensible boilerplate the user can replace. The narrative
* editor in the UI persists overrides via /api/.../arsredovisning POST.
*/
export async function buildArsredovisningData(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
overrides: Partial<ArsredovisningData['forvaltningsberattelse']> = {},
): Promise<ArsredovisningData> {
// The RR/BR are rendered at ÅRL post level from the same K2 risbs mapping
// that drives the iXBRL filing, never from per-account report rows.
// Bolagsverket rejects balans-/resultaträkningar med kontonummer, so the
// statement data must not carry account-level granularity at all. Two TB
// variants per year (see TrialBalancePair): the FULL trial balance drives
// the BR (2099 booked), the PRE-CLOSING one drives the RR (class 3-8
// still open).
const [periodResult, settingsResult, companyResult, periodList, tbFull, tbPreClosing, narrative] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, previous_period_id, closing_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
supabase
.from('company_settings')
.select('company_name, org_number, city, entity_type')
.eq('company_id', companyId)
.maybeSingle(),
// Source-of-truth for entity_type and accounting_framework lives on
// companies. company_settings.entity_type is a legacy mirror; the
// framework column was added later and only exists on companies.
supabase
.from('companies')
.select('entity_type, accounting_framework')
.eq('id', companyId)
.maybeSingle(),
fetchAllRows(({ from, to }) =>
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.range(from, to),
),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'exclude-final' }),
// Load persisted narrative overrides: replaces the URL-query-param
// carry from earlier phases. Caller-supplied overrides (passed in via
// the second arg) still win, so the API can layer per-request edits on
// top of the saved baseline if needed.
getNarrative(supabase, companyId, fiscalPeriodId).catch(() => null),
])
if (periodResult.error || !periodResult.data) {
throw new Error('Fiscal period not found')
}
const period = periodResult.data
const settings = settingsResult.data
const companyRow = companyResult.data as
| { entity_type?: string | null; accounting_framework?: AccountingFramework | null }
| null
const companyName = settings?.company_name ?? 'Bolaget'
const orgNumber = settings?.org_number ?? ''
// Default to 'unknown' (not 'aktiebolag') when entity_type isn't set: // otherwise the K2 guard in buildK2Noter would claim K2 for every
// unconfigured company, which is exactly the false-assertion the guard
// was added to prevent. Prefer the companies row over company_settings
// since the multi-tenant refactor made companies the source of truth.
const entityType =
companyRow?.entity_type
?? (settings as { entity_type?: string } | null)?.entity_type
?? 'unknown'
// K3 is opt-in; only AB ever set it. Default to K2 when not set.
const accountingFramework: AccountingFramework =
companyRow?.accounting_framework === 'k3' ? 'k3' : 'k2'
// company_settings stores the address as flat columns (address_line1,
// postal_code, city): there is no `address` json column. Selecting one
// made the whole settings query fail, so every ÅR fell back to "Bolaget"
// with an empty org number.
const city = (settings as { city?: string | null } | null)?.city ?? null
// Previous fiscal year → jämförelsesiffror (ÅRL 3:5 §). Resolved from the
// already-fetched period list; a TB failure downgrades to "no comparison
// year" with a warning instead of blocking the whole document (partial SIE
// imports can leave prior years without IB continuity).
const statementWarnings: string[] = []
const prevPeriodRow = period.previous_period_id
? ((periodList ?? []) as PeriodRow[]).find((p) => p.id === period.previous_period_id) ?? null
: null
// Flerårsöversikt window: the current period + up to 3 prior (oldest
// first). Resolved here so the prior-period trial balances it needs can
// share one parallel wave with the comparative-year pair instead of being
// fetched sequentially (and, for the previous year, twice).
const sortedPeriods = [...((periodList ?? []) as PeriodRow[])].sort((a, b) =>
a.period_start.localeCompare(b.period_start),
)
const currentIdx = sortedPeriods.findIndex((p) => p.id === fiscalPeriodId)
const overviewSlice =
currentIdx === -1 ? [] : sortedPeriods.slice(Math.max(0, currentIdx - 3), currentIdx + 1)
// Every prior period needed by the comparatives and/or the flerårsöversikt
// gets its TB pair fetched exactly once. Comparative RR figures need the
// same statutory view as the current year: keep booked depreciation,
// appropriations, and tax, excluding only the linked final result-closing
// entry. A failed pair downgrades to null so a broken prior year (e.g. a
// partial SIE import without IB continuity) never blocks the document.
const tbTargets = new Map<string, PeriodRow>()
if (prevPeriodRow) tbTargets.set(prevPeriodRow.id, prevPeriodRow)
for (const p of overviewSlice) {
if (p.id !== fiscalPeriodId) tbTargets.set(p.id, p)
}
const tbPairs = new Map<string, TrialBalancePair | null>()
await Promise.all(
[...tbTargets.values()].map(async (p) => {
try {
const [full, preClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, p.id, { closingEntry: 'include' }),
generateTrialBalance(supabase, companyId, p.id, { closingEntry: 'exclude-final' }),
])
tbPairs.set(p.id, { full: full.rows, preClosing: preClosing.rows })
} catch {
tbPairs.set(p.id, null)
}
}),
)
// Previous fiscal year comparison (jämförelsesiffror): a TB failure
// downgrades to "no comparison year" with a warning instead of blocking.
const previousTb = prevPeriodRow ? tbPairs.get(prevPeriodRow.id) ?? null : null
if (prevPeriodRow && !previousTb) {
statementWarnings.push(
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår, balans- och resultaträkningen visas utan jämförelseår. Kontrollera det föregående årets bokföring.',
)
}
// A previous year that exists but holds no bookkeeping in Accounted (the
// year was done in another system and klarmarkerad here) yields an all-zero
// comparison column, not a missing one. ÅRL 3 kap. 5 § requires the prior
// year's amount for every post, so say so instead of printing 0 kr silently
// (observed 2026-09-04: 25 000 kr aktiekapital shown as 0 kr).
if (
prevPeriodRow &&
previousTb &&
previousTb.full.length === 0 &&
previousTb.preClosing.length === 0
) {
statementWarnings.push(
`Föregående räkenskapsår (${prevPeriodRow.name}) saknar bokföring i Accounted, så jämförelsesiffrorna visar 0 kr. ÅRL 3 kap. 5 § kräver föregående års belopp för varje post. Bokför eller SIE-importera året innan årsredovisningen lämnas in; ett klarmarkerat år öppnas igen under Inställningar > Bokföring > Räkenskapsår.`,
)
}
const mapping = mapTrialBalancesToK2(
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
previousTb,
)
const previousPeriod =
prevPeriodRow && previousTb
? {
name: prevPeriodRow.name,
period_start: prevPeriodRow.period_start,
period_end: prevPeriodRow.period_end,
}
: null
// Merge precedence: caller overrides → persisted narrative → boilerplate
const persistedDescription = narrative?.description ?? undefined
const persistedEvents = narrative?.important_events ?? undefined
const persistedRd = narrative?.resultatdisposition ?? undefined
const persistedAgmDate = narrative?.agm_date ?? null
const flerarsoversikt = buildFlerarsoversikt(overviewSlice, fiscalPeriodId, mapping, tbPairs)
const egen_kapital_changes = buildEquityChanges(mapping)
const proposedDividend = narrative?.proposed_dividend ?? 0
const retainedEarnings = mapping.br['BalanseratResultat']?.current ?? 0
const sharePremiumReserve = mapping.br['Overkursfond']?.current ?? 0
const currentYearResult = mapping.br['AretsResultatEgetKapital']?.current ?? 0
const distributableEquity = mapping.totals.frittEgetKapital.current
// Duplicate-value consistency with the RR (mirrors build-input.ts): the
// flerårsöversikt is computed from the income statement (ALL class-3
// revenue), but nettoomsättning per ÅRL is strictly 3000-3799. Override
// the current + previous year so the FB table ties to the RR two pages
// later. Older years have no RR in the document and keep the IS values.
if (flerarsoversikt.length > 0) {
const lastIdx = flerarsoversikt.length - 1
flerarsoversikt[lastIdx] = {
...flerarsoversikt[lastIdx],
net_revenue: mapping.rr['Nettoomsattning']?.current ?? 0,
result_after_financial: mapping.totals.resultatEfterFinansiellaPoster.current,
}
if (lastIdx > 0 && previousPeriod && flerarsoversikt[lastIdx - 1].year === previousPeriod.name) {
flerarsoversikt[lastIdx - 1] = {
...flerarsoversikt[lastIdx - 1],
net_revenue: mapping.rr['Nettoomsattning']?.previous ?? 0,
result_after_financial: mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
}
}
}
// K3 vs K2 split: K3 has a richer note set + a kassaflöde + a separate
// equity-changes statement. The 18a/b warning that flagged "K3 noter not
// yet emitted" is removed below now that we actually emit them.
//
// Kassaflödesanalys + separate equity-changes statement, K3 only. K2
// mindre företag is exempt from kassaflödesanalys (BFNAR 2016:10 punkt
// 5.2) and keeps equity changes inside förvaltningsberättelsen. The K3
// noter and the kassaflödesanalys are independent reads, so they share
// one round trip; the kassaflöde failure warning still lands AFTER the
// noter warnings so the warnings array order is unchanged.
let noter: NoteEntry[]
let noterWarnings: string[]
let kassaflodesanalys: KassaflodesAnalysisSummary | undefined
let equity_changes_statement:
| { rows: EgenKapitalRow[]; closing_total: number }
| undefined
if (accountingFramework === 'k3') {
const [noterResult, cashFlowSettled] = await Promise.all([
buildK3Noter(
supabase,
companyId,
entityType,
period.period_start,
period.period_end,
narrative,
tbFull.rows,
fiscalPeriodId,
(periodList ?? []) as PeriodRow[],
),
generateKassaflodesanalys(supabase, companyId, fiscalPeriodId).then(
(cashFlow) => ({ ok: true as const, cashFlow }),
() => ({ ok: false as const }),
),
])
noter = noterResult.notes
noterWarnings = noterResult.warnings
if (cashFlowSettled.ok) {
const { cashFlow } = cashFlowSettled
// Strip fiscal_period_id from the embedded report: period info is
// already on ArsredovisningData.fiscal_period; carrying it twice in
// the payload would be redundant.
kassaflodesanalys = {
period_start: cashFlow.period_start,
period_end: cashFlow.period_end,
lopande: cashFlow.lopande,
investerings: cashFlow.investerings,
finansierings: cashFlow.finansierings,
total_cash_flow: cashFlow.total_cash_flow,
reconciliation: cashFlow.reconciliation,
}
} else {
// A partial SIE import can leave 1xxx without an IB row: the report
// throws. Surface as a warning instead of blocking the whole ÅR.
noterWarnings.push(
'Kassaflödesanalysen kunde inte genereras automatiskt. Kontrollera att ingående och utgående saldo på 19xx finns och kör om bokslutet.',
)
}
// Equity-changes statement: derived from the post-level mapping. We
// reuse buildEquityChangesNote's roll-forward to keep one source of
// truth for the closing total.
equity_changes_statement = buildK3EquityChangesStatement(mapping)
} else {
const k2Noter = await buildK2Noter(
supabase,
companyId,
entityType,
period.period_start,
period.period_end,
narrative,
tbFull.rows,
fiscalPeriodId,
(periodList ?? []) as PeriodRow[],
)
noter = k2Noter.notes
noterWarnings = k2Noter.warnings
}
const resultatrakning = buildRrRows(mapping)
const brRows = buildBrRows(mapping)
const balansrakning = {
assets: brRows.assets,
total_assets: mapping.totals.tillgangar.current,
total_assets_previous: mapping.totals.tillgangar.previous,
equity_liabilities: brRows.equityLiabilities,
total_equity_liabilities: mapping.totals.egetKapitalSkulder.current,
total_equity_liabilities_previous: mapping.totals.egetKapitalSkulder.previous,
}
// mapping.warnings carry the compliance-critical signals (unmapped
// accounts whose balances are MISSING from the document, RR ≠ 2099,
// obalans, reclass review nudges), surfacing them pre-download is what
// keeps a non-fileable PDF from reaching Bolagsverket.
const warnings: string[] = [...statementWarnings, ...mapping.warnings, ...noterWarnings]
if (entityType !== 'aktiebolag' && entityType !== 'unknown') {
warnings.push(
'Den här årsredovisningen genereras med K2-mallen (BFNAR 2016:10) som standard. För K3- eller annan företagsform kan strukturen behöva justeras manuellt innan inlämning.',
)
}
if (entityType === 'aktiebolag' && accountingFramework === 'k3') {
// Soliditet now reflects the K3 split (79,4 % equity portion of 21xx is
// folded into eget kapital). 18e/f provides the K3 noter, kassaflöde
// and separate equity-changes statement so the PDF is now substantively
// K3-compliant; we keep a soft notice here so the filer remembers to
// verify the document against their specific obligations before sending
// to Bolagsverket.
// The enumeration is conditional on what was actually produced: when
// generateKassaflodesanalys failed we already pushed "kunde inte
// genereras" above, and claiming the PDF contains one in the very next
// warning would contradict it on the same screen.
warnings.push(
'Bolaget redovisar enligt K3 (BFNAR 2012:1). Soliditeten är beräknad med 79,4 % av obeskattade reserver inräknat i eget kapital. PDF:en innehåller '
+ (kassaflodesanalys ? 'kassaflödesanalys, förändring av eget kapital och utökade noter' : 'förändring av eget kapital och utökade noter')
+ ': granska innehållet mot er specifika redovisning innan inlämning.',
)
}
if (entityType === 'unknown') {
warnings.push(
'Företagsform saknas i inställningarna: fyll i Inställningar → Företag för att få rätt redovisningsprinciper i not 1.',
)
}
if (!persistedAgmDate) {
warnings.push(
'Datum för årsstämma saknas. Fastställelseintyget i PDF:en lämnas tomt på datumraden tills det fylls i nedan.',
)
} else {
// ÅRL 8 kap 3 § + ÅRL 7 kap 10 §: AGM must be held after the räkenskapsår
// ends and within 6 months of period end (för privat AB). A date before
// period_end is logically impossible; after the deadline is a legally
// defective fastställelseintyg.
if (persistedAgmDate <= period.period_end) {
warnings.push(
`Datum för årsstämma (${persistedAgmDate}) ligger på eller före räkenskapsårets slut (${period.period_end}): fastställelseintyget blir juridiskt felaktigt. Kontrollera datumet.`,
)
} else {
const periodEndDate = new Date(`${period.period_end}T00:00:00Z`)
const deadline = new Date(periodEndDate)
deadline.setUTCMonth(deadline.getUTCMonth() + 6)
const deadlineIso = deadline.toISOString().slice(0, 10)
if (persistedAgmDate > deadlineIso) {
warnings.push(
`Datum för årsstämma (${persistedAgmDate}) är efter 6-månadersgränsen (${deadlineIso}). För privat AB ska årsstämman hållas inom 6 månader från räkenskapsårets slut (ÅRL 7 kap 10 §).`,
)
}
}
}
return {
company: {
name: companyName,
org_number: orgNumber,
entity_type: entityType,
city,
},
fiscal_period: {
id: period.id,
name: period.name,
period_start: period.period_start,
period_end: period.period_end,
},
previous_period: previousPeriod,
accounting_framework: accountingFramework,
forvaltningsberattelse: {
description:
overrides.description ??
persistedDescription ??
`${companyName} bedriver verksamhet enligt verksamhetsbeskrivningen i bolagsordningen.`,
important_events:
overrides.important_events ??
persistedEvents ??
'Inga väsentliga händelser utöver löpande verksamhet har inträffat under räkenskapsåret.',
kontrollbalans_required: overrides.kontrollbalans_required ?? false,
flerarsoversikt,
egen_kapital_changes,
resultatdisposition:
overrides.resultatdisposition ??
persistedRd ??
'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
proposed_dividend: proposedDividend,
resultatdisposition_amounts: {
retained_earnings: retainedEarnings,
share_premium_reserve: sharePremiumReserve,
current_year_result: currentYearResult,
total: distributableEquity,
proposed_dividend: proposedDividend,
carried_forward: distributableEquity - proposedDividend,
},
agm_date: persistedAgmDate,
agm_disposition_outcome: narrative?.agm_disposition_outcome ?? null,
agm_disposition_decision: narrative?.agm_disposition_decision ?? null,
},
resultatrakning,
warnings,
balansrakning,
noter,
kassaflodesanalys,
equity_changes_statement,
signatures: [], // populated by signature-flow service in a later phase step
disclosures: {
long_term_debt_over_five_years: narrative?.long_term_debt_over_five_years ?? null,
securities_pledged: narrative?.securities_pledged ?? null,
contingent_liabilities: narrative?.contingent_liabilities ?? null,
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,
confirmations: {
long_term_debt_over_five_years:
narrative?.long_term_debt_over_five_years_confirmed ?? false,
securities_pledged: narrative?.securities_pledged_confirmed ?? false,
contingent_liabilities: narrative?.contingent_liabilities_confirmed ?? false,
parent_company: narrative?.parent_company_confirmed ?? false,
},
},
}
}
interface PeriodRow {
id: string
name: string
period_start: string
period_end: string
}
export function calculateSoliditet(mapping: K2MappingResult): number | null {
const totalAssets = mapping.totals.tillgangar.current
if (totalAssets <= 0) return null
const adjustedEquity =
mapping.totals.egetKapital.current +
mapping.totals.obeskattadeReserver.current * (1 - LATENT_TAX_DEFAULT_RATE)
return Math.round((adjustedEquity / totalAssets) * 1000) / 10
}
/**
* Flerårsöversikt from pre-fetched trial-balance pairs. `overviewSlice` is
* the current period + up to 3 prior, oldest first (resolved by the caller
* so the pairs could be fetched in one parallel wave); `tbPairs` holds the
* prior-period pairs, with null marking a period whose TB fetch failed.
*/
function buildFlerarsoversikt(
overviewSlice: PeriodRow[],
currentPeriodId: string,
currentMapping: K2MappingResult,
tbPairs: Map<string, TrialBalancePair | null>,
): FlerarsoversiktRow[] {
const rows: FlerarsoversiktRow[] = []
for (const p of overviewSlice) {
try {
let mapping = currentMapping
if (p.id !== currentPeriodId) {
const pair = tbPairs.get(p.id)
if (!pair) throw new Error('trial balance unavailable')
mapping = mapTrialBalancesToK2(pair, null)
}
const netRevenue = mapping.rr['Nettoomsattning']?.current ?? 0
const resultAfterFinancial = mapping.totals.resultatEfterFinansiellaPoster.current
// K2 flerårsöversikt defines soliditet as adjusted equity divided by
// total assets. Adjusted equity includes the equity portion of untaxed
// reserves even though those reserves remain a separate BR section.
const soliditet = calculateSoliditet(mapping)
rows.push({
year: p.name,
net_revenue: Math.round(netRevenue),
result_after_financial: Math.round(resultAfterFinancial),
soliditet_pct: soliditet,
})
} catch {
// Prior periods may lack continuity if SIE import was partial. Skip
// rather than blocking the whole årsredovisning.
rows.push({
year: p.name,
net_revenue: 0,
result_after_financial: 0,
soliditet_pct: null,
})
}
}
return rows
}
/**
* Förvaltningsberättelsens "Förändring av eget kapital" table, post-level
* labels only (no kontonummer). Only genuine equity posts (20xx) appear;
* obeskattade reserver are NOT eget kapital and were dropped from the table
* when the account-row version was replaced by the mapping-driven one.
*/
function buildEquityChanges(mapping: K2MappingResult): EgenKapitalRow[] {
const posts: Array<{ label: string; concept: string; alwaysShow?: boolean }> = [
{ label: 'Aktiekapital', concept: 'Aktiekapital', alwaysShow: true },
{ label: 'Ej registrerat aktiekapital', concept: 'EjRegistreratAktiekapital' },
{ label: 'Bunden överkursfond', concept: 'OverkursfondBunden' },
{ label: 'Uppskrivningsfond', concept: 'Uppskrivningsfond' },
{ label: 'Reservfond', concept: 'Reservfond' },
{ label: 'Överkursfond', concept: 'Overkursfond' },
{ label: 'Balanserat resultat', concept: 'BalanseratResultat', alwaysShow: true },
{ label: 'Årets resultat', concept: 'AretsResultatEgetKapital', alwaysShow: true },
]
const rows: EgenKapitalRow[] = []
for (const post of posts) {
const amount = mapping.br[post.concept]?.current ?? 0
if (amount === 0 && !post.alwaysShow) continue
rows.push({ label: post.label, amount })
}
rows.push({ label: 'Summa eget kapital', amount: mapping.totals.egetKapital.current })
return rows
}
/**
* Map register assets to the roll-forward note input, resolving per-asset
* depreciation figures from posted schedules (engine fallback) so the note
* ties to the ledger. Shared by the K2 and K3 note builders. Skips the
* schedules query entirely when the register is empty.
*/
async function buildRollforwardAssets(
supabase: SupabaseClient,
companyId: string,
assets: Asset[],
allPeriods: PeriodRow[],
fiscalPeriodId: string,
): Promise<AnlaggningAsset[]> {
if (assets.length === 0) return []
const schedules = await loadPostedSchedules(supabase, companyId)
const figures = computeAssetNoteFigures({
assets,
postedSchedules: schedules,
fiscalPeriods: allPeriods,
currentPeriodId: fiscalPeriodId,
})
return assets.map((a) => ({
category: a.category,
acquisition_date: a.acquisition_date,
acquisition_cost: a.acquisition_cost,
salvage_value: a.salvage_value,
useful_life_months: a.useful_life_months,
disposed_at: a.disposed_at,
figures: figures.get(a.id) ?? { ibAck: 0, aretsAvskrivning: 0, avgaendeAck: 0 },
}))
}
/**
* Cross-check the roll-forward note's closing book value against the
* balansräkning (full TB net of accounts 1000-1299: immateriella +
* materiella anläggningstillgångar; 13xx financial assets are outside the
* note). Returns a user-facing warning when they diverge by more than 1 kr,
* which is the exact inconsistency ÅRL 5:8 § forbids in a filed document.
*/
function rollforwardTieOutWarning(
rollforwardAssets: AnlaggningAsset[],
tbFullRows: TrialBalanceRow[],
periodStart: string,
periodEnd: string,
): string | null {
const totals = computeRollforwardTotals(rollforwardAssets, periodStart, periodEnd)
const tbNet = tbFullRows
.filter((r) => r.account_number >= '1000' && r.account_number < '1300')
.reduce((sum, r) => sum + (r.closing_debit || 0) - (r.closing_credit || 0), 0)
if (Math.abs(totals.ubRedovisat - tbNet) <= 1) return null
const fmtKr = (n: number) => Math.round(n).toLocaleString('sv-SE')
return `Anläggningsnotens utgående redovisade värde (${fmtKr(totals.ubRedovisat)} kr) stämmer inte med balansräkningens bokförda värde för konto 1000-1299 (${fmtKr(tbNet)} kr). Kontrollera att anläggningsregistret är komplett och att årets avskrivningar är bokförda.`
}
async function buildK2Noter(
supabase: SupabaseClient,
companyId: string,
entityType: string,
periodStart: string,
periodEnd: string,
narrative: NarrativeRow | null,
tbFullRows: TrialBalanceRow[],
fiscalPeriodId: string,
allPeriods: PeriodRow[],
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
const notes: NoteEntry[] = []
const warnings: string[] = []
// Note 1: framework. Only claim K2 explicitly when we know the company is
// an AB and using K2: otherwise emit a generic principles note so the
// ÅR doesn't falsely assert a framework the company isn't on.
// K3 election isn't yet tracked separately; we treat any non-AB as not-K2.
const isAbK2 = entityType === 'aktiebolag'
notes.push({
number: 1,
title: 'Redovisnings- och värderingsprinciper',
body: isAbK2
? 'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen och Bokföringsnämndens allmänna råd BFNAR 2016:10 Årsredovisning i mindre företag (K2).'
: 'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen och Bokföringsnämndens allmänna råd.',
})
// Note: aktiekapital. K2 punkt 18.x requires AB to disclose share-capital
// structure. Read from company_settings when present; surface a warning
// when missing so the user knows to fill it in. We also surface the
// warning when entityType is 'unknown' since the company may in fact be
// an AB the user just hasn't configured yet: staying silent would let
// them download an incomplete K2 ÅR without realising.
const maybeAb = isAbK2 || entityType === 'unknown'
// The three reads feeding the notes below (aktiekapital settings, asset
// register, employee windows) are independent, so they share one parallel
// round trip instead of three sequential ones. Note bodies, push order,
// and numbering (notes.length + 1) are unchanged.
const [settingsResult, assets, employeesResult] = await Promise.all([
maybeAb
? supabase
.from('company_settings')
.select('aktiekapital, antal_aktier')
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
listAssets(supabase, companyId),
supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId),
])
if (maybeAb) {
type AktiekapitalShape = { aktiekapital?: number | null; antal_aktier?: number | null }
const ak = (settingsResult.data ?? null) as AktiekapitalShape | null
const aktiekapital = ak?.aktiekapital ?? null
const antalAktier = ak?.antal_aktier ?? null
// Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier;
// deriving it here keeps the filed note internally consistent. ÅRL
// 5 kap 14 § requires BOTH the registered amount and the number of
// shares, so a partial pair is treated as missing (warn, no note).
if (aktiekapital && antalAktier) {
const kvotvarde = roundOre(aktiekapital / antalAktier)
notes.push({
number: notes.length + 1,
title: 'Aktiekapital',
body: [
`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE', { maximumFractionDigits: 0 })} kr.`,
`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`,
`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`,
].join(' '),
})
} else {
// Don't write a "saknas: komplettera" placeholder into the PDF body: // that text would land in the Bolagsverket-filed document as a user-
// facing error string and the filing would be K2-non-compliant
// (BFNAR 2016:10 punkt 5.4 / ÅRL 5 kap 14 § require the actual
// registered amount). Omit the note entirely and surface a warning so
// the UI can flag this pre-download.
warnings.push(
'Aktiekapitalnoten saknas eftersom uppgifter om aktiekapital inte finns i Inställningar → Företag. K2 / ÅRL kräver att noten innehåller registrerat belopp innan inlämning till Bolagsverket.',
)
}
}
// Avskrivningstider: derive from asset register (supplementary
// disclosure; the statutory ÅRL 5:8 § roll-forward follows below).
if (assets.length > 0) {
const byCategory = new Map<string, Set<number>>()
for (const a of assets) {
if (a.disposed_at) continue
const years = Math.round(a.useful_life_months / 12)
if (!byCategory.has(a.category)) byCategory.set(a.category, new Set())
byCategory.get(a.category)!.add(years)
}
if (byCategory.size > 0) {
const lines: string[] = ['Avskrivningar görs linjärt över bedömd nyttjandeperiod:']
const categoryLabels: Record<string, string> = {
immaterial: 'Immateriella anläggningstillgångar',
building: 'Byggnader',
land_improvement: 'Markanläggningar',
machinery: 'Maskiner',
equipment: 'Inventarier',
vehicle: 'Fordon',
computer: 'Datorer',
other_tangible: 'Övriga materiella anläggningstillgångar',
}
for (const [cat, yearsSet] of byCategory.entries()) {
const yrs = Array.from(yearsSet).sort((a, b) => a - b)
const yrsLabel = yrs.length === 1 ? `${yrs[0]} år` : `${yrs[0]}-${yrs[yrs.length - 1]} år`
lines.push(`• ${categoryLabels[cat] ?? cat}: ${yrsLabel}`)
}
notes.push({
number: notes.length + 1,
title: 'Avskrivningar',
body: lines.join('\n'),
})
}
}
// Anläggningstillgångar roll-forward (ÅRL 5:8 §). Per-category IB →
// tillkommande → avgående → UB anskaffningsvärde, same for ackumulerade
// avskrivningar, ending in utgående redovisat värde. Hard ÅR requirement
// for any company with assets on the books. Depreciation figures come
// from posted schedules so the note ties to the balansräkning.
const rollforwardAssets = await buildRollforwardAssets(
supabase,
companyId,
assets,
allPeriods,
fiscalPeriodId,
)
const rollforwardNote = buildAnlaggningstillgangarNote({
noteNumber: notes.length + 1,
assets: rollforwardAssets,
periodStart,
periodEnd,
})
if (rollforwardNote) {
notes.push(rollforwardNote)
const tieOut = rollforwardTieOutWarning(
rollforwardAssets,
tbFullRows,
periodStart,
periodEnd,
)
if (tieOut) warnings.push(tieOut)
}
// Medelantal anställda: FTE-weighted average per ÅRL 5:20 §. We fetch the
// full employment-window data because the column 'is_active' doesn't exist
// on the employees table; a count() filtered by it would always return 0.
// Å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(
(employeesResult.data ?? []) as Array<{
employment_start: string
employment_end: string | null
employment_degree: number
}>,
periodStart,
periodEnd,
)
if (medelantal > 0 || entityType === 'aktiebolag') {
notes.push({
number: notes.length + 1,
title: 'Medelantal anställda',
body:
medelantal > 0
? `Under räkenskapsåret har medeltalet anställda uppgått till ${medelantal}.`
: 'Bolaget har inte haft några anställda under räkenskapsåret.',
})
}
// Långfristiga skulder förfallande efter mer än fem år (ÅRL 5:13 §).
// Disclosed amount lives on arsredovisning_narratives as a manual entry;
// loan-maturity data isn't tagged in journal lines so we can't derive it.
// A null/zero value defaults to "Inga." per Swedish ÅR convention.
const longTermDebtAmount = narrative?.long_term_debt_over_five_years ?? null
notes.push({
number: notes.length + 1,
title: 'Långfristiga skulder',
body:
longTermDebtAmount && longTermDebtAmount > 0
? `Av långfristiga skulder förfaller ${longTermDebtAmount.toLocaleString('sv-SE')} kr till betalning senare än fem år efter balansdagen.`
: 'Inga skulder förfaller till betalning senare än fem år efter balansdagen.',
})
// Ställda säkerheter (ÅRL 5:14 §): separate disclosure from
// eventualförpliktelser. Manual override on arsredovisning_narratives,
// defaulting to "Inga.".
notes.push({
number: notes.length + 1,
title: 'Ställda säkerheter',
body: narrative?.securities_pledged?.trim() || 'Inga.',
})
// Eventualförpliktelser (ÅRL 5:15 §)
notes.push({
number: notes.length + 1,
title: 'Eventualförpliktelser',
body: narrative?.contingent_liabilities?.trim() || 'Inga.',
})
// Koncernförhållanden (BFNAR 2016:10 kap. 19). Emitted only when a parent
// company is configured: companies without a parent skip this note.
const parentName = narrative?.parent_company_name?.trim()
if (parentName) {
const parts: string[] = [`Moderföretag: ${parentName}.`]
if (narrative?.parent_company_org_number)
parts.push(`Organisationsnummer: ${narrative.parent_company_org_number}.`)
if (narrative?.parent_company_city)
parts.push(`Säte: ${narrative.parent_company_city}.`)
notes.push({
number: notes.length + 1,
title: 'Koncernförhållanden',
body: parts.join(' '),
})
}
return { notes, warnings }
}
interface LatentTaxMovement {
/** Opening balance on 2240 (credit-normal, so positive = liability). */
opening: number
/** Year movement, taken from 8940 when the account was used. */
change: number
/** Closing balance on 2240. */
closing: number
}
/**
* Derive the uppskjuten-skatt movement from the current-period full trial
* balance: 2240 (latent tax liability) and 8940 (latent tax expense).
*
* Returns `movement: null` when the company has no such activity at all,
* which is the normal case: a 2240 balance only appears when the K3
* uppskjuten-skatt disposition was posted, or from legacy postings and
* imported history. Both the redovisningsprinciper paragraph and the
* "Uppskjutna skatter" note are driven by this one result, so they can never
* disagree about whether a deferred tax on obeskattade reserver is
* separately recognized.
*
* `ok: false` means the figures could not be read at all; the caller warns
* rather than blocking the document.
*/
function deriveLatentTaxMovement(
tbFullRows: TrialBalanceRow[],
): { ok: true; movement: LatentTaxMovement | null } | { ok: false } {
try {
const row2240 = tbFullRows.find((r) => r.account_number === '2240')
const row8940 = tbFullRows.find((r) => r.account_number === '8940')
// 2240 is credit-normal liability: opening = opening_credit - opening_debit
const opening2240 = row2240
? (row2240.opening_credit || 0) - (row2240.opening_debit || 0)
: 0
const closing2240 = row2240
? (row2240.closing_credit || 0) - (row2240.closing_debit || 0)
: 0
// 8940 is an expense (debit-normal): movement = period_debit - period_credit
// A positive movement = additional avsättning (cost incurred = liability
// grew). The 2240 balance moves by the same magnitude (with opposite
// sign convention since 2240 is on the credit side).
const change8940 = row8940
? (row8940.period_debit || 0) - (row8940.period_credit || 0)
: closing2240 - opening2240
if (opening2240 === 0 && closing2240 === 0 && change8940 === 0) {
return { ok: true, movement: null }
}
return {
ok: true,
movement: {
opening: opening2240,
change: change8940,
closing: closing2240,
},
}
} catch {
return { ok: false }
}
}
/**
* True when the balansräkning carries an anläggningstillgång that the
* company's OWN chart of accounts names as leased.
*
* The previous rule tested for account 1260 or 1269, which was wrong in both
* directions. It missed every other account a finance-leased asset can land
* on (the K2 mapper folds 1220-1279 into one BR post, and the shipped BAS
* chart carries 1217 Finansiellt leasade maskiner and 1227 Finansiellt
* leasade inventarier), and on that same chart 1260 is "(Fritt konto för
* Inventarier, verktyg och installationer)" and 1269 is "Ack. avskrivningar
* på datorer": an owned laptop would have been reported as a leased asset.
*
* Numbers cannot separate an owned 1220 inventarie from a leased one, so ask
* the names instead. A kontogrupp-12 account (BAS keeps capitalized leases
* there: .claude/skills/swedish-asset-accounting/references/
* leasing-and-disposal.md:28) whose name contains "leas" is a leased asset or
* its accumulated depreciation; "Inventarier, verktyg och installationer"
* never matches. Names are the right source because they travel with the
* balance: the asset register cannot even reach 1260-1279 (its per-category
* ranges stop at 1259 and resume at 1280), so such a balance always arrives
* by SIE import or a manual voucher, carrying the originating chart's name.
*
* The scan stops at 1299 on purpose: 1720 Förutbetalda leasingavgifter is
* part of the OPERATIONAL treatment (leasing-and-disposal.md:14) and must not
* flip the paragraph.
*/
function hasCapitalizedLeaseAsset(tbFullRows: TrialBalanceRow[]): boolean {
return tbFullRows.some(
(r) =>
r.account_number >= '1200' &&
r.account_number < '1300' &&
/leas/i.test(r.account_name ?? '') &&
// closing_debit/closing_credit are cumulative per-side totals, not a
// net balance, so a leased asset acquired earlier and disposed this
// year has both sides non-zero while the balansrakning carries
// nothing. Compare the NET balance so a disposed lease stops
// triggering the paragraph.
Math.abs((r.closing_debit || 0) - (r.closing_credit || 0)) > 0.005,
)
}
/**
* Build the K3 note set (BFNAR 2012:1). Differs from K2 in:
* - Verbose redovisningsprinciper covering all K3 measurement principles
* - A separate "Uppskjutna skatter" note showing 2240 movement
* - "Materiella anläggningstillgångar" with per-component breakdown when
* komponentavskrivning is used
* - Standard K3 placeholders for händelser efter balansdagen +
* eventualförpliktelser
*
* The aktiekapital note is shared with K2 logic: K3 punkt 18.x also
* mandates the share-capital disclosure for AB.
*
* tbFullRows MUST be the FULL current-period trial balance (tbFull.rows:
* opening balances included, year-end closing entries NOT excluded). The
* uppskjutna-skatter note derives its BFNAR 2012:1 ch.29 opening balance,
* movement, and closing balance for 2240/8940 from these rows; passing
* tbPreClosing.rows would zero the opening balance and misstate the note.
* The K3 multiyear snapshot test pins a non-zero 2240 opening balance to
* guard this contract.
*/
async function buildK3Noter(
supabase: SupabaseClient,
companyId: string,
entityType: string,
periodStartIso: string,
periodEndIso: string,
narrative: NarrativeRow | null,
tbFullRows: TrialBalanceRow[],
fiscalPeriodId: string,
allPeriods: PeriodRow[],
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
const notes: NoteEntry[] = []
const warnings: string[] = []
const isAb = entityType === 'aktiebolag'
const maybeAb = isAb || entityType === 'unknown'
// The three reads feeding the notes below (asset register, aktiekapital
// settings, employee windows) are independent, so they share one parallel
// round trip instead of three sequential ones. Note bodies, push order,
// and numbering (notes.length + 1) are unchanged.
const [assetsResult, settingsResult, employeesResult] = await Promise.all([
listAssets(supabase, companyId),
maybeAb
? supabase
.from('company_settings')
.select('aktiekapital, antal_aktier')
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId),
])
// 1. Redovisningsprinciper. We check whether any asset has K3 components
// configured so the principles paragraph only mentions komponentavskrivning
// when it's actually in use.
//
// The stored K3 component shape on assets is
// { name, cost, useful_life_months, salvage_value? }
// (per migration 20260526122000_k3_component_depreciation.sql), but the
// note builder consumes
// { name, acquisition_cost, accumulated_depreciation, useful_life_months }
// We compute accumulated_depreciation here using a linear approximation
// (months elapsed / useful life) which matches what the per-component
// depreciation engine (computeComponentDepreciation) produces over a year.
// The fiscal period end is the as-of date for the depreciation snapshot.
const assets = assetsResult as Asset[]
const monthsBetween = (fromIso: string, toIso: string): number => {
const from = new Date(`${fromIso}T00:00:00Z`)
const to = new Date(`${toIso}T00:00:00Z`)
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return 0
const years = to.getUTCFullYear() - from.getUTCFullYear()
const months = to.getUTCMonth() - from.getUTCMonth()
const days = to.getUTCDate() - from.getUTCDate()
let total = years * 12 + months
if (days < 0) total -= 1
return total
}
const adaptAsset = (a: Asset) => ({
name: a.name,
category: a.category,
acquisition_date: a.acquisition_date,
acquisition_cost: a.acquisition_cost,
k3_components: Array.isArray(a.k3_components)
? a.k3_components.map((c) => {
const cost = Number(c.cost) || 0
const salvage = Number(c.salvage_value ?? 0) || 0
const life = Number(c.useful_life_months) || 0
const elapsed = Math.max(
0,
Math.min(life, monthsBetween(a.acquisition_date, periodEndIso)),
)
const accumulated = life > 0
? Math.round(((cost - salvage) * elapsed) / life)
: 0
return {
name: c.name,
acquisition_cost: cost,
accumulated_depreciation: accumulated,
useful_life_months: life,
}
})
: null,
disposed_at: a.disposed_at,
useful_life_months: a.useful_life_months,
})
const adaptedAssets = assets.map(adaptAsset)
const hasComponents = anyAssetHasComponents(adaptedAssets)
// The deferred-tax figures are derived BEFORE note 1 is built, even though
// the "Uppskjutna skatter" note is emitted further down as note 4: the
// redovisningsprinciper paragraph has to describe the same reality that
// note discloses. One derivation feeds both, so no code path can produce a
// policy paragraph denying a split the following note then discloses.
const latentTax = deriveLatentTaxMovement(tbFullRows)
if (!latentTax.ok) {
warnings.push(
'Uppskjutna skatter-noten kunde inte beräknas automatiskt. Kontrollera kontot 2240 och kör om bokslutet.',
)
}
const latentTaxMovement = latentTax.ok ? latentTax.movement : null
// Leased assets on the balance sheet contradict the blanket "all leases are
// operational" simplification, so the same trial balance that decides the
// deferred-tax wording also decides the leasing wording.
const hasCapitalizedLease = hasCapitalizedLeaseAsset(tbFullRows)
notes.push(
buildK3RedovisningsPrinciper({
hasComponents,
// A read failure may not print an affirmative denial: it degrades to
// the going-forward policy, and the warning above tells the user.
deferredTax: !latentTax.ok
? 'unknown'
: latentTaxMovement !== null
? 'recognized'
: 'none',
hasCapitalizedLease,
}),
)
// 2. Aktiekapital (shared with K2 logic: K3 punkt 18.x mandates the same
// disclosure for AB).
if (maybeAb) {
type AktiekapitalShape = {
aktiekapital?: number | null
antal_aktier?: number | null
}
const ak = (settingsResult.data ?? null) as AktiekapitalShape | null
const aktiekapital = ak?.aktiekapital ?? null
const antalAktier = ak?.antal_aktier ?? null
// Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier;
// deriving it here keeps the filed note internally consistent. ÅRL
// 5 kap 14 § requires BOTH the registered amount and the number of
// shares, so a partial pair is treated as missing (warn, no note).
if (aktiekapital && antalAktier) {
const kvotvarde = roundOre(aktiekapital / antalAktier)
notes.push({
number: notes.length + 1,
title: 'Aktiekapital',
body: [
`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE', { maximumFractionDigits: 0 })} kr.`,
`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`,
`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`,
].join(' '),
})
} else if (isAb) {
warnings.push(
'Aktiekapitalnoten saknas eftersom uppgifter om aktiekapital inte finns i Inställningar → Företag. K3 / ÅRL kräver att noten innehåller registrerat belopp innan inlämning till Bolagsverket.',
)
}
}
// 3. Materiella anläggningstillgångar: with optional per-component
// breakdown. The note is omitted when no tangible assets exist. Uses the
// adapted asset list computed above so the K3-component shape matches what
// the builder's type guard expects.
const materialiNote = buildMateriellaAnlaggningsNot({
noteNumber: notes.length + 1,
assets: adaptedAssets,
})
if (materialiNote) notes.push(materialiNote)
// 3b. Anläggningstillgångar roll-forward (ÅRL 5:8 §). Required even under
// K3: K3 ch.17 layers component depreciation on top, but the basic
// per-category roll-forward of anskaffningsvärde + ackumulerade
// avskrivningar is the statutory baseline. Depreciation figures come
// from posted schedules so the note ties to the balansräkning.
const rollforwardAssets = await buildRollforwardAssets(
supabase,
companyId,
assets,
allPeriods,
fiscalPeriodId,
)
const rollforwardNote = buildAnlaggningstillgangarNote({
noteNumber: notes.length + 1,
assets: rollforwardAssets,
periodStart: periodStartIso,
periodEnd: periodEndIso,
})
if (rollforwardNote) {
notes.push(rollforwardNote)
const tieOut = rollforwardTieOutWarning(
rollforwardAssets,
tbFullRows,
periodStartIso,
periodEndIso,
)
if (tieOut) warnings.push(tieOut)
}
// 4. Uppskjutna skatter. K3 ch.29 requires disclosure of opening,
// movement, and closing balance of uppskjuten skatteskuld. The figures
// were derived above (deriveLatentTaxMovement) so that note 1 and this
// note tell the same story; a null movement means no 2240/8940 activity
// exists and the note is omitted.
if (latentTaxMovement) {
notes.push(
buildUppskjutenSkattNot({
noteNumber: notes.length + 1,
latentTaxOpening: latentTaxMovement.opening,
latentTaxChange: latentTaxMovement.change,
latentTaxClosing: latentTaxMovement.closing,
}),
)
}
// 5. Medelantal anställda: FTE-weighted average per ÅRL 5:20 §. 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(
(employeesResult.data ?? []) as Array<{
employment_start: string
employment_end: string | null
employment_degree: number
}>,
periodStartIso,
periodEndIso,
)
if (medelantal > 0 || entityType === 'aktiebolag') {
notes.push({
number: notes.length + 1,
title: 'Medelantal anställda',
body:
medelantal > 0
? `Under räkenskapsåret har medeltalet anställda uppgått till ${medelantal}.`
: 'Bolaget har inte haft några anställda under räkenskapsåret.',
})
}
// 6. Långfristiga skulder förfallande efter mer än fem år (ÅRL 5:13 §).
const longTermDebtAmount = narrative?.long_term_debt_over_five_years ?? null
notes.push({
number: notes.length + 1,
title: 'Långfristiga skulder',
body:
longTermDebtAmount && longTermDebtAmount > 0
? `Av långfristiga skulder förfaller ${longTermDebtAmount.toLocaleString('sv-SE')} kr till betalning senare än fem år efter balansdagen.`
: 'Inga skulder förfaller till betalning senare än fem år efter balansdagen.',
})
// 7. Eventualförpliktelser (K3 punkt 21: separate disclosure).
notes.push({
number: notes.length + 1,
title: 'Eventualförpliktelser',
body: narrative?.contingent_liabilities?.trim() || 'Inga.',
})
// 8. Ställda säkerheter (ÅRL 5:14 §).
notes.push({
number: notes.length + 1,
title: 'Ställda säkerheter',
body: narrative?.securities_pledged?.trim() || 'Inga.',
})
// 9. Koncernförhållanden (BFNAR 2012:1 kap. 8: moderföretagets namn,
// organisationsnummer och säte). Emitted only when configured.
const parentName = narrative?.parent_company_name?.trim()
if (parentName) {
const parts: string[] = [`Moderföretag: ${parentName}.`]
if (narrative?.parent_company_org_number)
parts.push(`Organisationsnummer: ${narrative.parent_company_org_number}.`)
if (narrative?.parent_company_city)
parts.push(`Säte: ${narrative.parent_company_city}.`)
notes.push({
number: notes.length + 1,
title: 'Koncernförhållanden',
body: parts.join(' '),
})
}
// 10. Väsentliga händelser efter balansdagen (K3 ch.32)
notes.push({
number: notes.length + 1,
title: 'Väsentliga händelser efter balansdagen',
body: 'Inga väsentliga händelser har inträffat efter räkenskapsårets utgång som påverkar bedömningen av företagets ställning och resultat.',
})
return { notes, warnings }
}
/**
* K3 separate "Förändring av eget kapital" statement, derived from the
* post-level mapping. With a previous fiscal year the opening balances are
* the REAL prior-year UB values (mapping .previous), and the year's
* movements are derived so the roll-forward ties exactly to the booked UB:
* bundet-EK growth is presented as nyemission, a fritt-EK shortfall beyond
* årets resultat as utdelning (the overwhelmingly common cases; a positive
* fritt residual, e.g. aktieägartillskott, is folded into nyemission
* rather than invent an unbookable row). First fiscal year falls back to
* opening = closing - årets resultat.
*/
function buildK3EquityChangesStatement(
mapping: K2MappingResult,
): { rows: EgenKapitalRow[]; closing_total: number } {
const cur = (concept: string): number => mapping.br[concept]?.current ?? 0
const prev = (concept: string): number => mapping.br[concept]?.previous ?? 0
const aretsResultat = cur('AretsResultatEgetKapital')
const aktiekapitalClosing = cur('Aktiekapital') + cur('EjRegistreratAktiekapital')
const bundnaClosing = mapping.totals.bundetEgetKapital.current - aktiekapitalClosing
const frittClosing = mapping.totals.frittEgetKapital.current
const hasPrevious = mapping.totals.egetKapital.previous !== null
let opening: { aktiekapital: number; bundna_reserver: number; balanserade_vinstmedel: number }
let nyemission = 0
let utdelning = 0
if (hasPrevious) {
const aktiekapitalOpening = prev('Aktiekapital') + prev('EjRegistreratAktiekapital')
const bundnaOpening =
(mapping.totals.bundetEgetKapital.previous ?? 0) - aktiekapitalOpening
const frittOpening = mapping.totals.frittEgetKapital.previous ?? 0
opening = {
aktiekapital: aktiekapitalOpening,
bundna_reserver: bundnaOpening,
balanserade_vinstmedel: frittOpening,
}
nyemission =
aktiekapitalClosing - aktiekapitalOpening + (bundnaClosing - bundnaOpening)
const frittResidual = frittClosing - frittOpening - aretsResultat
if (frittResidual < 0) utdelning = frittResidual
else nyemission += frittResidual
} else {
opening = {
aktiekapital: aktiekapitalClosing,
bundna_reserver: bundnaClosing,
balanserade_vinstmedel: frittClosing - aretsResultat,
}
}
return buildEquityChangesNote({
opening,
changes: { nyemission, utdelning, arets_resultat: aretsResultat },
})
}