* 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
1022 lines
43 KiB
TypeScript
1022 lines
43 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
|
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 {
|
|
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 } from './anlaggningstillgangar-note'
|
|
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
|
|
import type {
|
|
ArsredovisningData,
|
|
EgenKapitalRow,
|
|
FlerarsoversiktRow,
|
|
NoteEntry,
|
|
KassaflodesAnalysisSummary,
|
|
} from './types'
|
|
import type { AccountingFramework, Asset } 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),
|
|
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
|
|
// 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
|
|
let previousTb: TrialBalancePair | null = null
|
|
if (prevPeriodRow) {
|
|
try {
|
|
const [prevFull, prevPreClosing] = await Promise.all([
|
|
generateTrialBalance(supabase, companyId, prevPeriodRow.id),
|
|
generateTrialBalance(supabase, companyId, prevPeriodRow.id, {
|
|
excludeYearEndClosing: true,
|
|
}),
|
|
])
|
|
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
|
|
} catch {
|
|
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.',
|
|
)
|
|
}
|
|
}
|
|
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 = await buildFlerarsoversikt(
|
|
supabase,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
(periodList ?? []) as Array<{ id: string; name: string; period_start: string; period_end: string }>,
|
|
accountingFramework,
|
|
)
|
|
|
|
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.
|
|
const { notes: noter, warnings: noterWarnings } =
|
|
accountingFramework === 'k3'
|
|
? await buildK3Noter(
|
|
supabase,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
entityType,
|
|
period.period_start,
|
|
period.period_end,
|
|
narrative,
|
|
)
|
|
: await buildK2Noter(
|
|
supabase,
|
|
companyId,
|
|
entityType,
|
|
period.period_start,
|
|
period.period_end,
|
|
narrative,
|
|
)
|
|
|
|
// 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.
|
|
let kassaflodesanalys: KassaflodesAnalysisSummary | undefined
|
|
let equity_changes_statement:
|
|
| { rows: EgenKapitalRow[]; closing_total: number }
|
|
| undefined
|
|
if (accountingFramework === 'k3') {
|
|
try {
|
|
const cashFlow = await generateKassaflodesanalys(
|
|
supabase,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
)
|
|
// 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,
|
|
}
|
|
} catch {
|
|
// 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)
|
|
}
|
|
|
|
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.
|
|
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 kassaflödesanalys, 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
|
|
}
|
|
|
|
async function buildFlerarsoversikt(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
currentPeriodId: string,
|
|
allPeriods: PeriodRow[],
|
|
accountingFramework: AccountingFramework,
|
|
): Promise<FlerarsoversiktRow[]> {
|
|
// Take the current period + 3 prior (oldest first).
|
|
const sorted = [...allPeriods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
|
const currentIdx = sorted.findIndex((p) => p.id === currentPeriodId)
|
|
if (currentIdx === -1) return []
|
|
const slice = sorted.slice(Math.max(0, currentIdx - 3), currentIdx + 1)
|
|
|
|
const rows: FlerarsoversiktRow[] = []
|
|
for (const p of slice) {
|
|
try {
|
|
const [is, tb] = await Promise.all([
|
|
generateIncomeStatement(supabase, companyId, p.id),
|
|
generateTrialBalance(supabase, companyId, p.id),
|
|
])
|
|
// Nettoomsättning = sum of revenue sections (revenue is normally credit).
|
|
const netRevenue = is.total_revenue
|
|
const resultAfterFinancial = is.total_revenue - is.total_expenses + is.total_financial
|
|
const totalAssets = tb.rows
|
|
.filter((r) => r.account_class === 1)
|
|
.reduce((s, r) => s + (r.closing_debit - r.closing_credit), 0)
|
|
const eqLiab = tb.rows
|
|
.filter((r) => r.account_class === 2)
|
|
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
|
// Soliditet differs by framework:
|
|
// K2 (ÅRL / BFNAR 2016:10): 20xx only. 21xx (periodiseringsfonder,
|
|
// överavskrivningar) are obeskattade reserver: partially deferred
|
|
// tax, not equity. Including 21xx would inflate soliditet for any AB
|
|
// that posts dispositions.
|
|
//
|
|
// K3 (BFNAR 2012:1) splits 21xx into 79,4 % equity + 20,6 % latent
|
|
// skatteskuld. Account 2240 holds the latent tax liability and is
|
|
// already classified as a liability via class 2 / account_group 22,
|
|
// so the soliditet add-on is just the equity portion of 21xx. (We
|
|
// do NOT double-count 2240 here: the trial balance row for 2240
|
|
// already lives in eqLiab as a liability.)
|
|
const baseEquity = tb.rows
|
|
.filter((r) => r.account_number.startsWith('20'))
|
|
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
|
let equity = baseEquity
|
|
if (accountingFramework === 'k3') {
|
|
const obeskattadeReserver = tb.rows
|
|
.filter((r) => r.account_number.startsWith('21'))
|
|
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
|
equity += obeskattadeReserver * (1 - LATENT_TAX_DEFAULT_RATE)
|
|
}
|
|
const soliditet =
|
|
totalAssets > 0 ? Math.round((equity / totalAssets) * 1000) / 10 : null
|
|
// Avoid the unused-variable warning while leaving eqLiab computed for
|
|
// future "Skulder" column expansion.
|
|
void eqLiab
|
|
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
|
|
}
|
|
|
|
async function buildK2Noter(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
entityType: string,
|
|
periodStart: string,
|
|
periodEnd: string,
|
|
narrative: NarrativeRow | null,
|
|
): 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'
|
|
if (maybeAb) {
|
|
const { data: settings } = await supabase
|
|
.from('company_settings')
|
|
.select('aktiekapital, antal_aktier, kvotvarde')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
type AktiekapitalShape = { aktiekapital?: number | null; antal_aktier?: number | null; kvotvarde?: number | null }
|
|
const ak = settings as AktiekapitalShape | null
|
|
const aktiekapital = ak?.aktiekapital ?? null
|
|
const antalAktier = ak?.antal_aktier ?? null
|
|
const kvotvarde = ak?.kvotvarde ?? null
|
|
if (aktiekapital || antalAktier) {
|
|
const parts: string[] = []
|
|
if (aktiekapital) parts.push(`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE')} kr.`)
|
|
if (antalAktier) parts.push(`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`)
|
|
if (kvotvarde) parts.push(`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`)
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Aktiekapital',
|
|
body: parts.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).
|
|
const assets = await listAssets(supabase, companyId)
|
|
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.
|
|
const rollforwardNote = buildAnlaggningstillgangarNote({
|
|
noteNumber: notes.length + 1,
|
|
assets: 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,
|
|
})),
|
|
periodStart,
|
|
periodEnd,
|
|
})
|
|
if (rollforwardNote) notes.push(rollforwardNote)
|
|
|
|
// 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 { data: employeeRows } = await supabase
|
|
.from('employees')
|
|
.select('employment_start, employment_end, employment_degree')
|
|
.eq('company_id', companyId)
|
|
const medelantal = computeMedelantalAnstallda(
|
|
(employeeRows ?? []) 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 }
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async function buildK3Noter(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
fiscalPeriodId: string,
|
|
entityType: string,
|
|
periodStartIso: string,
|
|
periodEndIso: string,
|
|
narrative: NarrativeRow | null,
|
|
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
|
|
const notes: NoteEntry[] = []
|
|
const warnings: string[] = []
|
|
|
|
// 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 = (await listAssets(supabase, companyId)) 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)
|
|
notes.push(buildK3RedovisningsPrinciper(hasComponents))
|
|
|
|
// 2. Aktiekapital (shared with K2 logic: K3 punkt 18.x mandates the same
|
|
// disclosure for AB).
|
|
const isAb = entityType === 'aktiebolag'
|
|
const maybeAb = isAb || entityType === 'unknown'
|
|
if (maybeAb) {
|
|
const { data: settings } = await supabase
|
|
.from('company_settings')
|
|
.select('aktiekapital, antal_aktier, kvotvarde')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
type AktiekapitalShape = {
|
|
aktiekapital?: number | null
|
|
antal_aktier?: number | null
|
|
kvotvarde?: number | null
|
|
}
|
|
const ak = settings as AktiekapitalShape | null
|
|
const aktiekapital = ak?.aktiekapital ?? null
|
|
const antalAktier = ak?.antal_aktier ?? null
|
|
const kvotvarde = ak?.kvotvarde ?? null
|
|
if (aktiekapital || antalAktier) {
|
|
const parts: string[] = []
|
|
if (aktiekapital) parts.push(`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE')} kr.`)
|
|
if (antalAktier) parts.push(`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`)
|
|
if (kvotvarde) parts.push(`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`)
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Aktiekapital',
|
|
body: parts.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.
|
|
const rollforwardNote = buildAnlaggningstillgangarNote({
|
|
noteNumber: notes.length + 1,
|
|
assets: 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,
|
|
})),
|
|
periodStart: periodStartIso,
|
|
periodEnd: periodEndIso,
|
|
})
|
|
if (rollforwardNote) notes.push(rollforwardNote)
|
|
|
|
// 4. Uppskjutna skatter. K3 ch.29 requires disclosure of opening,
|
|
// movement, and closing balance of uppskjuten skatteskuld. We derive
|
|
// these from the trial balance for 2240 (latent tax liability) and
|
|
// 8940 (latent tax expense).
|
|
try {
|
|
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
|
const row2240 = rows.find((r) => r.account_number === '2240')
|
|
const row8940 = rows.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) {
|
|
notes.push(
|
|
buildUppskjutenSkattNot({
|
|
noteNumber: notes.length + 1,
|
|
latentTaxOpening: opening2240,
|
|
latentTaxChange: change8940,
|
|
latentTaxClosing: closing2240,
|
|
}),
|
|
)
|
|
}
|
|
} catch {
|
|
// Trial-balance failure should not block the document; flag as warning.
|
|
warnings.push(
|
|
'Uppskjutna skatter-noten kunde inte beräknas automatiskt. Kontrollera kontot 2240 och kör om bokslutet.',
|
|
)
|
|
}
|
|
|
|
// 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 { data: employeeRows } = await supabase
|
|
.from('employees')
|
|
.select('employment_start, employment_end, employment_degree')
|
|
.eq('company_id', companyId)
|
|
const medelantal = computeMedelantalAnstallda(
|
|
(employeeRows ?? []) 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 },
|
|
})
|
|
}
|
|
|