288915c152
* fix(invoices): return attachment filename in delivery history summaries The 20260723003000 hardening dropped attachment_filename from list_invoice_delivery_summaries, so the delivery history UI always fell back to the generic "faktura.pdf" label. Recreate the RPC with the filename included: it is derived from company name, customer name, invoice number, and date, all already visible to every company member, so the minimization boundary is unchanged. Addresses stay masked and message content, BCC, and checksums stay server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): surface own-account transfer legs in match-to-voucher by default The second (incoming) leg of a transfer between two of the company's own bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog because the voucher counted as 'already matched' once its outgoing leg was linked, even though the incoming account's line had no settling transaction. Users read the empty default list as 'the app won't let me link this'. get_account_gl_lines_for_matching now counts links per settlement account: a transaction provably on another cash account no longer marks the voucher as matched for the requested account, so the unsettled transfer leg surfaces by default (and auto-selects on an exact match). Same-account N:1 stays behind the 'Visa aven matchade verifikationer' opt-in, and transactions without a resolvable cash account conservatively keep counting everywhere. get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile). Companion guard: mark_entry_as_opening_balance now refuses entries with linked bank transactions, since half-settled transfer vouchers became reachable in the reconciliation view's unmatched table where 'Mark som IB' renders; re-tagging one would strand its transaction against a movement- excluded entry. getReconciliationStatus counts unmatched GL lines with the account-scoped RPC so the status card agrees with the table. Fixes #1026 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of requests over 300ms. Target: p95 under 300ms. - requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of a second network getUser per request; getUser fallback keeps HS256 self-hosted and existing test mocks working; middleware still revocation-checks every /api request - resolve_active_company RPC (20260723161000): one round trip replaces 2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall back to the legacy query path - arsredovisning build-data: ~33 sequential round trips down to ~7, output byte-identical (snapshot-proven) - currency rate route: stop bypassing the exchange_rates cache (missing supabase arg caused an external Riksbanken call on every request) - document.get: parallelize row fetch, signed URL and audit event - list_company_accounts RPC (20260723170000): accounts list in one round trip instead of paging past PostgREST's 1000-row cap - vat-declaration route: drop a dead sequential company_settings query - get_kpi_report_aggregates RPC (20260723180000): KPI report's three full-period line scans collapsed into one aggregate call; dimension- filtered path unchanged - lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to warn, zero the eslint baseline ratchet All four gates green: lint 0 errors, 9163 tests, check:guards, build. Migrations applied idempotently to staging only; prod receives them via Supabase branching on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): resolve PR review findings across auth, VAT declaration, and IB retag - requireAuth getClaims fast path: pin iss (project URL) and aud ('authenticated'), log every fallback to getUser (ASVS V9.1 finding) - remove the ignored accountingMethod parameter from calculateVatDeclaration and the dead company_settings.accounting_method reads in xlsx/pdf/eskd routes; v1 API keeps accepting the query param but documents it as a no-op - close the mark_entry_as_opening_balance TOCTOU race with a transactions trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests; applied to staging and smoke-verified both directions - re-add the 42501 tenant guard to branch-local migration 20260723160000 (function body had silently reverted to the pre-20260619130100 definition) - document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt opening balance per BFNAR 2012:1 ch.29) - add KPI VAT-liability test covering reduced-rate output accounts 2621/2631 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard The re-added tenant guard carried the pre-20260703180000 raw NOT IN (SELECT user_company_ids()) pattern, which the null-safe-tenant-guards ratchet blocks. Staging re-synced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1073 lines
46 KiB
TypeScript
1073 lines
46 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 } from './anlaggningstillgangar-note'
|
|
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),
|
|
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: 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
|
|
|
|
// 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),
|
|
generateTrialBalance(supabase, companyId, p.id, { excludeFinalClosingEntry: true }),
|
|
])
|
|
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.',
|
|
)
|
|
}
|
|
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,
|
|
),
|
|
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,
|
|
)
|
|
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.
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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'
|
|
|
|
// 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.
|
|
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 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 }
|
|
}
|
|
|
|
/**
|
|
* 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[],
|
|
): 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)
|
|
notes.push(buildK3RedovisningsPrinciper(hasComponents))
|
|
|
|
// 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.
|
|
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 current-period full trial balance (passed in by the
|
|
// caller, which already fetched it for the statements) for 2240 (latent
|
|
// tax liability) and 8940 (latent tax expense).
|
|
try {
|
|
const rows = tbFullRows
|
|
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 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 },
|
|
})
|
|
}
|
|
|