Files
accounted/lib/bokslut/arsredovisning/build-data.ts
T
Jakob WennbergandClaude Opus 4.7 f98ffee145 feat(bokslut): Phase 8 — make årsredovisning Bolagsverket-fileable (#511)
* feat(bokslut): Phase 8 — make årsredovisning Bolagsverket-fileable

Closes most of the deferred items from PR #509 review. The ÅR PDF is now
an honest draft a user can take to Bolagsverket: it includes the fastställe-
intyg page ÅRL 8 kap 3 § requires, the K2-mandatory aktiekapital note, and
the narrative edits actually survive a refresh.

Narrative persistence (replaces the round-1 URL-query-param carry)
- New table arsredovisning_narratives (UNIQUE per fiscal_period_id, length
  caps matching the API schema, RLS + updated_at trigger).
- narrative-service.ts: getNarrative / upsertNarrative.
- /api/.../arsredovisning/narrative GET + POST. POST does an explicit
  period-ownership pre-check before the upsert.
- buildArsredovisningData loads persisted narrative as override layer
  (caller-supplied overrides → persisted → boilerplate).
- ÅR page replaces the URL-query-param hack with a Spara button + saved
  indicator. The PDF download URL is plain again — no narrative content
  in access logs, browser history, or CDN logs.
- PDF route stops parsing description/events/disposition query params.
  Also closes the GDPR Art.25(1) finding the bot flagged in PR #509.

Fastställelseintyg PDF page
- New 7th page in ArsredovisningPDF after Underskrifter. Carries the ÅRL
  8 kap 3 § attestation text + the resultatdisposition + a signature slot.
- Without this page Bolagsverket rejects the filing — flagged in the round-2
  Swedish review on PR #509.

K2 aktiekapital note + framework guard
- buildK2Noter now takes entityType. Note 1 only claims K2 when the
  company is an AB; non-AB gets a generic principles statement so we
  don't falsely assert a framework. Future K3 election will flip this
  branch when it lands.
- New aktiekapital note (required K2 note for AB per BFNAR 2016:10 ch.18).
  Reads aktiekapital / antal_aktier / kvotvärde from company_settings;
  emits a "saknas — komplettera under Inställningar" placeholder when
  missing.

Manual "Mark as signed" PATCH + UI button
- New PATCH /signatures/[signatureId] — flips pending → signed (manual
  / paper flow) or pending → declined. Real BankID wiring is Phase 9 and
  will use the same markSignatureSigned helper with the BankID callback
  as the trigger.
- ÅR page renders a "Markera som signerad" button on every pending row.

Small cleanups all flagged in PR #509 reviews
- AccrualProposal.reverses_on type: '' → null. The future accrual-reversal
  cron will filter `reverses_on IS NOT NULL`; an empty string would
  silently match.
- ArsredovisningData.company.sate → city. The typo carried into the type
  in earlier phases; renaming now before any external consumer takes a
  dependency.
- signer_name CHECK length 200 at storage layer (matches the API .max(200)
  added in PR #509 round-2 — GDPR Art.25.2 belt-and-braces).
- Soliditet equity filter now has a code comment explaining the K2 vs K3
  branch the bot wanted documented for the future K3 migration.

Explicit follow-ups (each merits its own focused PR):
- Real BankID signing — needs provider choice + polling + QR. Phase 9.
- Accrual reversal cron — auto-flip 17xx/29xx accruals on Jan 1 of next FY.
- Medelantal anställda proper annual average — needs salary-run aggregation.
- Vacation avgifter age-tier split (10.21 % for 67+) — needs upstream
  vacation-liability report to expose age.
- K2 noter expansion (lån till närstående, eventualförpliktelser detail).

Verification
- 94 unit tests pass (bokslut + MCP subsets)
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517140000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #511 round-1 — 3 P1s + 3 real concerns

3 P1s from Greptile (all real bugs):

- entityType default reintroduced the K2 false-assertion. build-data.ts
  defaulted `entity_type ?? 'aktiebolag'`, which means every unconfigured
  company would still claim K2 in Note 1 — exactly the false-assertion the
  framework guard was added to prevent. Now defaults to 'unknown' and the
  guard treats that as not-K2. New warning surfaces in the data so the UI
  can prompt the user to fill in företagsform.

- Signatures PATCH ignored the URL fiscal-period id. The route destructured
  `id` from params but never used it as a filter, so PATCH /periods/A/
  signatures/SIG_FROM_B succeeded silently — broken REST contract + IDOR
  across periods. Rewrote the handler to do a single UPDATE with all four
  filters: id, company_id, fiscal_period_id, status='pending'. Missing row
  returns 409 SIGNATURE_INVALID_TRANSITION instead of silent 200.

- Signatures state-machine guard was missing. Without status='pending' in
  the WHERE clause, an already-signed signature could be flipped back to
  declined (or vice-versa). Now part of the consolidated UPDATE above.

3 real concerns:

- Narrative GET lacked ownership pre-check. POST already had it; mirroring
  on GET so a valid JWT for company A can't probe / enumerate company B's
  period IDs through the narrative endpoint.

- Narrative POST lacked period-lock check. BFL 5 kap 5 § makes
  räkenskapsinformation immutable after filing — editing the
  förvaltningsberättelse on a closed/locked period now returns
  PERIOD_LOCKED.

- Aktiekapital placeholder text would land in Bolagsverket-filed PDF body.
  When aktiekapital fields are missing, the note now omits entirely and a
  warning surfaces in the ArsredovisningData.warnings array — the UI flags
  it pre-download with a "Innan inlämning till Bolagsverket" list. Same
  surface picks up the entityType=unknown and entityType=non-AB warnings.

Plus 2 schema improvements from Swedish review:

- AGM date persistence. Fastställelseintyg date was a literal "____" blank,
  defeating the point of a generated PDF. New agm_date column on
  arsredovisning_narratives + UI date input + PDF now renders the saved
  date. When missing, the warning surface flags it.

- Composite UNIQUE constraint on (company_id, fiscal_period_id) instead of
  just fiscal_period_id. UUIDs don't collide across tenants in practice
  but the constraint should match the tenant boundary so a logic error in
  onConflict resolution can't write to another company's row. Migration
  20260517160000 drops the old constraint and adds the composite.

Verification
- 94 unit tests pass
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517160000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #511 round-2 — 5 real concerns + BFL/GDPR conflict

5 real concerns from the round-1 bot re-eval:

- Narrative SELECT * leaked user_id to the frontend. getNarrative and
  upsertNarrative now project an explicit NARRATIVE_API_COLUMNS list
  (id, company_id, fiscal_period_id, narrative fields, agm_date, updated_at).
  user_id and created_at stay server-side. NarrativeRow type updated to
  match. Closes Art.25.2 + 2× A.8.3.

- agm_date validated only as YYYY-MM-DD regex. '2024-13-99' passed Zod
  and surfaced as a Postgres 500 instead of a 400. Added a refine() that
  parses with new Date() and confirms ISO round-trip equality, so invalid
  calendar dates return a clean structured-error.

- agm_date had no range check. ÅRL 8:3 → 7:10 §§ requires the AGM to be
  held after period end and within 6 months for privat AB; build-data
  warnings now flag agm_date <= period_end (impossible) and agm_date >
  period_end + 6 months (deadline). Warning surface in the UI already
  picks these up from the existing list.

- Fastställelseintyg signer label "Styrelseledamot / VD" conflated
  legally distinct roles per ÅRL 8:3 → 6:6-7 §§ — a VD without board
  membership cannot sign. Label is now "Styrelseledamot (närvarande vid
  stämman)" and the body text references the AGM's resolution
  ("stämmobeslutet") rather than the board's proposal — the AGM votes,
  and it is the vote that must be certified.

- Aktiekapital warning suppressed for entityType='unknown'. The maybeAb
  branch in buildK2Noter now fires for both 'aktiebolag' and 'unknown'
  so an unconfigured company that's actually an AB still gets prompted
  to fill in aktiekapital before filing. Note body stays omitted when
  fields are missing; only the warning surfaces.

BFL × GDPR conflict (new migration 20260517180000):

- Both arsredovisning_narratives and arsredovisning_signature_requests had
  user_id with ON DELETE CASCADE → auth.users. BFL 7 kap 1 § requires
  räkenskapsinformation to be retained for 7 years; GDPR Art.17 erasure
  or membership revocation would silently delete filed årsredovisning
  narrative + BankID signature evidence. BFL wins for filed financial
  records — user_id is now nullable with ON DELETE SET NULL on both
  tables. The company FK keeps its CASCADE (company deletion takes its
  räkenskapsinformation with it; that's a separate workflow).

Deliberately not chasing on this round:
- ISO A.8.12 historical PDF query-param logs — process item for the risk
  register, not code (the leak path is closed in this PR's first commit).
- "Collapse the two narrative migrations" — both already shipped to
  remote and merged; the interim window is in the past.
- "user_id on row vs separate audit log" — architectural debate; tracked
  but out of scope for this PR.
- Multi-signer fastställelseintyg + DB-level period-lock trigger — bigger
  scope, each merits a focused follow-up.

Verification
- 89 unit tests pass
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517180000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:38:36 +02:00

523 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getNarrative } from './narrative-service'
import type {
ArsredovisningData,
EgenKapitalRow,
FlerarsoversiktRow,
IncomeStatementLine,
BalanceSheetLine,
NoteEntry,
} from './types'
import type { BalanceSheetSection, IncomeStatementSection } 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> {
const [periodResult, settingsResult, periodList, incomeStatement, balanceSheet, 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, address, entity_type')
.eq('company_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),
),
generateIncomeStatement(supabase, companyId, fiscalPeriodId),
generateBalanceSheet(supabase, companyId, fiscalPeriodId),
// 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 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.
const entityType = (settings as { entity_type?: string } | null)?.entity_type ?? 'unknown'
type AddressShape = { city?: string | null; postal_city?: string | null } | null
const addressUnknown = (settings as { address?: AddressShape } | null)?.address ?? null
const city =
(addressUnknown && (addressUnknown.city ?? addressUnknown.postal_city)) || 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 }>,
)
const egen_kapital_changes = buildEquityChanges(balanceSheet.equity_liability_sections)
const { notes: noter, warnings: noterWarnings } = await buildK2Noter(
supabase,
companyId,
entityType,
)
const resultatrakning = flattenIncomeStatement(incomeStatement)
const balansrakning = flattenBalanceSheet(balanceSheet)
const warnings: string[] = [...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 === '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,
city,
},
fiscal_period: {
id: period.id,
name: period.name,
period_start: period.period_start,
period_end: period.period_end,
},
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.',
agm_date: persistedAgmDate,
},
resultatrakning,
warnings,
balansrakning,
noter,
signatures: [], // populated by signature-flow service in a later phase step
}
}
interface PeriodRow {
id: string
name: string
period_start: string
period_end: string
}
async function buildFlerarsoversikt(
supabase: SupabaseClient,
companyId: string,
currentPeriodId: string,
allPeriods: PeriodRow[],
): 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: eget kapital uses 20xx ONLY. 21xx (periodiseringsfonder,
// överavskrivningar) are obeskattade reserver — partially deferred tax,
// not equity. K2 / ÅRL splits them out. Including 21xx here would
// inflate soliditet for any AB that posts dispositions.
//
// K3 NOTE: K3 (BFNAR 2012:1) requires the 79,4% equity portion of
// obeskattade reserver to be folded into eget kapital and the 20,6%
// latent skatteskuld to be split out separately. When K3 support
// lands this filter must branch on the company's framework — for now
// we treat every entity as K2 / consistent-with-K2.
const equity = tb.rows
.filter((r) => r.account_number.startsWith('20'))
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
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
}
function buildEquityChanges(sections: BalanceSheetSection[]): EgenKapitalRow[] {
const equity: EgenKapitalRow[] = []
for (const section of sections) {
for (const row of section.rows) {
if (
row.account_number.startsWith('20') ||
row.account_number.startsWith('21')
) {
equity.push({
label: `${row.account_number} ${row.account_name}`,
amount: row.amount,
})
}
}
}
return equity
}
async function buildK2Noter(
supabase: SupabaseClient,
companyId: string,
entityType: string,
): 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
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: 2,
title: 'Avskrivningar',
body: lines.join('\n'),
})
}
}
// Medelantal anställda — count active employees as a proxy
const { count: employeeCount } = await supabase
.from('employees')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_active', true)
if ((employeeCount ?? 0) > 0) {
notes.push({
number: notes.length + 1,
title: 'Medelantal anställda',
body: `Under räkenskapsåret har medeltalet anställda uppgått till ${employeeCount}.`,
})
}
notes.push({
number: notes.length + 1,
title: 'Ställda säkerheter och eventualförpliktelser',
body: 'Inga.',
})
return { notes, warnings }
}
function flattenIncomeStatement(is: {
revenue_sections: IncomeStatementSection[]
total_revenue: number
expense_sections: IncomeStatementSection[]
total_expenses: number
financial_sections: IncomeStatementSection[]
total_financial: number
net_result: number
}): IncomeStatementLine[] {
const lines: IncomeStatementLine[] = []
for (const s of is.revenue_sections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
lines.push({ label: 'Summa rörelseintäkter', amount: is.total_revenue, is_total: true })
for (const s of is.expense_sections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: -r.amount })
}
}
lines.push({
label: 'Rörelseresultat',
amount: is.total_revenue - is.total_expenses,
is_total: true,
})
// Split financial sections so the RR follows the K2 / ÅRL 3:2 structure:
// financial items (80–87) → "Resultat efter finansiella poster" →
// bokslutsdispositioner (88) → "Resultat före skatt" → skatt (89) →
// "Årets resultat". Without the dispositioner + skatt rows the document
// is non-compliant for any AB that posted bolagsskatt or
// periodiseringsfond, and the RR doesn't reconcile to BS 2099.
const finItems = is.financial_sections.filter(
(s) => !/bokslutsdisposition|skatter och årets resultat/i.test(s.title),
)
const dispositionsSections = is.financial_sections.filter((s) =>
/bokslutsdisposition/i.test(s.title),
)
const skattSections = is.financial_sections.filter((s) =>
/skatter och årets resultat/i.test(s.title),
)
for (const s of finItems) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
const finSubtotal = finItems.reduce((sum, s) => sum + s.subtotal, 0)
const resAfterFinancial = is.total_revenue - is.total_expenses + finSubtotal
lines.push({
label: 'Resultat efter finansiella poster',
amount: Math.round(resAfterFinancial * 100) / 100,
is_total: true,
})
if (dispositionsSections.length > 0) {
for (const s of dispositionsSections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
const dispositionsSubtotal = dispositionsSections.reduce((sum, s) => sum + s.subtotal, 0)
lines.push({
label: 'Resultat före skatt',
amount: Math.round((resAfterFinancial + dispositionsSubtotal) * 100) / 100,
is_total: true,
})
} else {
// No dispositioner posted — keep the simpler "Resultat före skatt" row
// immediately after the finansnetto totals so the RR still has the
// pre-tax subtotal expected by ÅRL.
lines.push({
label: 'Resultat före skatt',
amount: Math.round(resAfterFinancial * 100) / 100,
is_total: true,
})
}
if (skattSections.length > 0) {
for (const s of skattSections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
}
lines.push({ label: 'Årets resultat', amount: is.net_result, is_total: true })
return lines
}
function flattenBalanceSheet(bs: {
asset_sections: BalanceSheetSection[]
total_assets: number
equity_liability_sections: BalanceSheetSection[]
total_equity_liabilities: number
}): {
assets: BalanceSheetLine[]
total_assets: number
equity_liabilities: BalanceSheetLine[]
total_equity_liabilities: number
} {
const assetLines: BalanceSheetLine[] = []
for (const s of bs.asset_sections) {
assetLines.push({ label: s.title, amount: s.subtotal, is_total: true, indent: 0 })
for (const r of s.rows) {
assetLines.push({
label: `${r.account_number} ${r.account_name}`,
amount: r.amount,
indent: 1,
})
}
}
const eqLines: BalanceSheetLine[] = []
for (const s of bs.equity_liability_sections) {
eqLines.push({ label: s.title, amount: s.subtotal, is_total: true, indent: 0 })
for (const r of s.rows) {
eqLines.push({
label: `${r.account_number} ${r.account_name}`,
amount: r.amount,
indent: 1,
})
}
}
return {
assets: assetLines,
total_assets: bs.total_assets,
equity_liabilities: eqLines,
total_equity_liabilities: bs.total_equity_liabilities,
}
}