Add/bokslut (#718)

* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-12 16:35:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8e8b63a200
commit db8983ba9e
131 changed files with 33796 additions and 340 deletions
@@ -26,6 +26,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
// 2) invoices (none)
mock.enqueue({ data: [], error: null })
// 3) supplier_invoices — one 12-month annual license invoice
@@ -69,6 +71,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
// Customer invoice for an annual subscription billed Dec 1 2025 covering
// Jan 1 2026 → Dec 31 2026 entirely. Entire amount belongs to next year.
mock.enqueue({
@@ -106,6 +110,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
mock.enqueue({ data: [], error: null }) // invoices
mock.enqueue({
data: [
@@ -143,6 +149,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
mock.enqueue({ data: [], error: null }) // invoices
mock.enqueue({
data: [
@@ -172,6 +180,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
mock.enqueue({ data: [], error: null })
mock.enqueue({
data: [
@@ -201,6 +211,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
mock.enqueue({ data: [], error: null })
mock.enqueue({
data: [
@@ -241,4 +253,39 @@ describe('detectPeriodisering', () => {
expect(result[0].source_invoice_id).toBe('sup-high')
expect(result[1].source_invoice_id).toBe('sup-medium')
})
it('excludes invoices already covered by a löpande accrual schedule', async () => {
mock.enqueue({
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — sup-inv-1 is already deferred line-by-line
mock.enqueue({
data: [{ supplier_invoice_id: 'sup-inv-1', invoice_id: null }],
error: null,
})
mock.enqueue({ data: [], error: null }) // invoices
mock.enqueue({
data: [
{
id: 'sup-inv-1',
supplier_invoice_number: 'LF-100',
invoice_date: '2025-07-01',
subtotal: 12000,
notes: 'Mjukvarulicens period: 2025-07-01 till 2026-06-30',
suppliers: { name: 'Acme SaaS AB' },
supplier_invoice_items: [{ description: 'Årslicens', account_number: '5800' }],
},
],
error: null,
})
const result = await detectPeriodisering(
mock.supabase as never,
'company-1',
'period-1',
)
// Suggesting it again would periodisera the same belopp twice.
expect(result).toEqual([])
})
})
+25 -1
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { parseInvoiceDateRange } from './date-range-parser'
export type PeriodiseringSource = 'invoice' | 'supplier_invoice'
@@ -122,7 +123,7 @@ function buildSuggestion(args: {
if (daysAfterPeriodEnd <= 0) return null
const ratio = daysAfterPeriodEnd / totalDays
const periodisationAmount = Math.round(netAmount * ratio * 100) / 100
const periodisationAmount = roundOre(netAmount * ratio)
if (periodisationAmount <= 0) return null
@@ -181,6 +182,27 @@ export async function detectPeriodisering(
const periodStart = period.period_start as string
const periodEnd = period.period_end as string
// Invoices already covered by a löpande accrual schedule (periodisering
// skapad på fakturaraden) are handled month by month and must never be
// suggested again at year-end — that would periodisera the same belopp
// twice. Cancelled schedules don't exclude: their invoice was credited and
// the status filters below drop it anyway.
const { data: scheduleRows } = await supabase
.from('accrual_schedules')
.select('supplier_invoice_id, invoice_id')
.eq('company_id', companyId)
.neq('status', 'cancelled')
const coveredSupplierInvoices = new Set(
((scheduleRows ?? []) as Array<{ supplier_invoice_id: string | null }>)
.map((row) => row.supplier_invoice_id)
.filter(Boolean),
)
const coveredInvoices = new Set(
((scheduleRows ?? []) as Array<{ invoice_id: string | null }>)
.map((row) => row.invoice_id)
.filter(Boolean),
)
// Customer invoices — only "real" ones (sent/paid). Drafts and overdue
// get skipped: drafts haven't moved through the engine, overdue is just a
// status label that overlaps with sent here.
@@ -206,6 +228,7 @@ export async function detectPeriodisering(
const suggestions: PeriodiseringSuggestion[] = []
for (const row of (invoiceRows ?? []) as unknown as InvoiceRow[]) {
if (coveredInvoices.has(row.id)) continue
const itemDescs = (row.invoice_items ?? []).map((i) => i.description).filter(Boolean)
const customerName = row.customers?.name ?? 'Okänd kund'
const sourceLabel = row.invoice_number
@@ -225,6 +248,7 @@ export async function detectPeriodisering(
}
for (const row of (supplierRows ?? []) as unknown as SupplierInvoiceRow[]) {
if (coveredSupplierInvoices.has(row.id)) continue
const itemDescs = (row.supplier_invoice_items ?? []).map((i) => i.description).filter(Boolean)
const firstAccount = row.supplier_invoice_items?.[0]?.account_number ?? null
const supplierName = row.suppliers?.name ?? 'Okänd leverantör'
+6 -5
View File
@@ -60,7 +60,7 @@ export async function buildArsredovisningData(
.single(),
supabase
.from('company_settings')
.select('company_name, org_number, address, entity_type')
.select('company_name, org_number, city, entity_type')
.eq('company_id', companyId)
.maybeSingle(),
// Source-of-truth for entity_type and accounting_framework lives on
@@ -111,10 +111,11 @@ export async function buildArsredovisningData(
const accountingFramework: AccountingFramework =
companyRow?.accounting_framework === 'k3' ? 'k3' : 'k2'
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
// 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
// Merge precedence: caller overrides → persisted narrative → boilerplate
const persistedDescription = narrative?.description ?? undefined
@@ -0,0 +1,88 @@
/**
* Mechanical guards for TA §2.7.3 (repeated facts must be value-identical —
* Bolagsverket rejects inconsistent duplicates) and TA §3.2 (valid XHTML with
* only the five XML escape entities).
*/
import { describe, expect, it } from 'vitest'
import { XMLValidator } from 'fast-xml-parser'
import { generateK2IxbrlDocument } from '../document/k2-document'
import { makeInput } from './fixtures'
import type { IxbrlArsredovisningInput } from '../types'
/** Parse every ix:nonFraction into { name, contextRef, value } where value
* includes the sign attribute (the rendered text is always absolute). */
function numericFacts(
xhtml: string,
): Array<{ name: string; contextRef: string; value: string }> {
const facts: Array<{ name: string; contextRef: string; value: string }> = []
for (const match of xhtml.matchAll(/<ix:nonFraction ([^>]*)>([^<]*)<\/ix:nonFraction>/g)) {
const attrs = match[1]
const name = attrs.match(/name="([^"]+)"/)?.[1] ?? ''
const contextRef = attrs.match(/contextRef="([^"]+)"/)?.[1] ?? ''
const sign = /sign="-"/.test(attrs) ? '-' : ''
facts.push({ name, contextRef, value: `${sign}${match[2]}` })
}
return facts
}
function assertNoConflictingDuplicates(input: IxbrlArsredovisningInput): void {
const { xhtml } = generateK2IxbrlDocument(input)
const groups = new Map<string, Set<string>>()
for (const fact of numericFacts(xhtml)) {
const key = `${fact.name}@${fact.contextRef}`
const values = groups.get(key) ?? new Set<string>()
values.add(fact.value)
groups.set(key, values)
}
expect(groups.size).toBeGreaterThan(0)
const conflicts = [...groups.entries()]
.filter(([, values]) => values.size > 1)
.map(([key, values]) => `${key}: ${[...values].join(' vs ')}`)
expect(conflicts).toEqual([])
}
describe('duplicate-fact consistency (TA §2.7.3)', () => {
it('every repeated name+context fact has exactly one distinct value (base fixture)', () => {
assertNoConflictingDuplicates(makeInput())
})
it('… with a proposed dividend', () => {
const input = makeInput()
input.forvaltningsberattelse.resultatdisposition.utdelning = 50_000
input.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 170_000
input.forvaltningsberattelse.egetKapital.utdelning = 50_000
assertNoConflictingDuplicates(input)
})
it('… with fri överkursfond in BR, eget kapital and resultatdisposition', () => {
const input = makeInput()
input.br['Overkursfond'] = { current: 50_000, previous: 50_000 }
// FrittEgetKapital is tagged both as the BR subtotal and the disposition
// "Summa" — keep the single source consistent like build-input does.
input.totals.frittEgetKapital = { current: 270_000, previous: 150_000 }
input.forvaltningsberattelse.resultatdisposition.overkursfond = 50_000
input.forvaltningsberattelse.resultatdisposition.summa = 270_000
input.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 270_000
assertNoConflictingDuplicates(input)
})
})
describe('XML escaping (TA §3.2)', () => {
it('company name and note bodies with <, & and " produce well-formed XML', () => {
const input = makeInput()
input.company.name = 'Müller & Söner <Test> "AB"'
input.forvaltningsberattelse.allmantOmVerksamheten =
'Handel med <komponenter> & "specialverktyg" där 1 < 2.'
input.noter.push({
number: 4,
title: 'Övrigt & "annat" <viktigt>',
body: 'Villkor: a < b & c > d, citerat som "fritt".',
})
const { xhtml } = generateK2IxbrlDocument(input)
expect(XMLValidator.validate(xhtml)).toBe(true)
expect(xhtml).toContain('Müller &amp; Söner &lt;Test&gt; "AB"')
// No raw < survives inside text nodes (only as markup).
expect(xhtml).not.toContain('<komponenter>')
})
})
+234
View File
@@ -0,0 +1,234 @@
/**
* Shared deterministic fixture for iXBRL generator tests: a small AB with a
* balanced BR (380 000 kr) and an RR netting to 120 000 kr, mapped through
* the real k2-mapper so concept amounts stay consistent with the mapping
* rules under test.
*
* Each year is a realistic post-bokslut TrialBalancePair:
* - `full` — the booked state AFTER the year-end closing entry: every
* class 3–8 account is zeroed (equal debit/credit churn) and 2099
* carries the year's result.
* - `preClosing` — the same year WITHOUT the closing entry
* (excludeYearEndClosing): RR accounts still open, 2099 only carries
* the prior-year churn from the resultatdisposition entry.
*/
import { mapTrialBalancesToK2, type TrialBalancePair, type TrialBalanceRowLike } from '../k2-mapper'
import type { IxbrlArsredovisningInput } from '../types'
const row = (
account: string,
name: string,
debit: number,
credit: number,
): TrialBalanceRowLike => ({
account_number: account,
account_name: name,
closing_debit: debit,
closing_credit: credit,
})
/** Current year WITHOUT the closing entry — RR accounts open. 2099 nets to 0
* (prior-year result IB balanced away by the disposition entry). */
const CURRENT_PRE_CLOSING: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 20_000),
row('1510', 'Kundfordringar', 50_000, 0),
row('1930', 'Bank', 270_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 100_000),
row('2099', 'Årets resultat', 40_000, 40_000),
row('2110', 'Periodiseringsfond', 0, 40_000),
row('2440', 'Leverantörsskulder', 0, 30_000),
row('2510', 'Skatteskulder', 0, 35_000),
row('2610', 'Utgående moms', 0, 20_000),
row('2941', 'Upplupna sociala avgifter', 0, 10_000),
row('3010', 'Försäljning', 0, 1_000_000),
row('4010', 'Inköp', 200_000, 0),
row('5010', 'Lokalhyra', 100_000, 0),
row('7010', 'Löner', 400_000, 0),
row('7510', 'Arbetsgivaravgifter', 125_660, 0),
row('7832', 'Avskrivningar', 20_000, 0),
row('8310', 'Ränteintäkter', 0, 1_000),
row('8410', 'Räntekostnader', 4_000, 0),
row('8811', 'Avsättning periodiseringsfond', 10_000, 0),
row('8910', 'Skatt', 21_340, 0),
]
/** Current year WITH the closing entry — class 3–8 zeroed, 2099 = 120 000. */
const CURRENT_FULL: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 20_000),
row('1510', 'Kundfordringar', 50_000, 0),
row('1930', 'Bank', 270_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 100_000),
row('2099', 'Årets resultat', 40_000, 160_000),
row('2110', 'Periodiseringsfond', 0, 40_000),
row('2440', 'Leverantörsskulder', 0, 30_000),
row('2510', 'Skatteskulder', 0, 35_000),
row('2610', 'Utgående moms', 0, 20_000),
row('2941', 'Upplupna sociala avgifter', 0, 10_000),
row('3010', 'Försäljning', 1_000_000, 1_000_000),
row('4010', 'Inköp', 200_000, 200_000),
row('5010', 'Lokalhyra', 100_000, 100_000),
row('7010', 'Löner', 400_000, 400_000),
row('7510', 'Arbetsgivaravgifter', 125_660, 125_660),
row('7832', 'Avskrivningar', 20_000, 20_000),
row('8310', 'Ränteintäkter', 1_000, 1_000),
row('8410', 'Räntekostnader', 4_000, 4_000),
row('8811', 'Avsättning periodiseringsfond', 10_000, 10_000),
row('8910', 'Skatt', 21_340, 21_340),
]
const PREVIOUS_PRE_CLOSING: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 12_000),
row('1930', 'Bank', 185_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 60_000),
row('2110', 'Periodiseringsfond', 0, 30_000),
row('2440', 'Leverantörsskulder', 0, 25_000),
row('2510', 'Skatteskulder', 0, 15_000),
row('2610', 'Utgående moms', 0, 8_000),
row('2941', 'Upplupna sociala avgifter', 0, 50_000),
row('3010', 'Försäljning', 0, 500_000),
row('4010', 'Inköp', 200_000, 0),
row('5010', 'Lokalhyra', 80_000, 0),
row('7010', 'Löner', 150_000, 0),
row('7832', 'Avskrivningar', 8_000, 0),
row('8410', 'Räntekostnader', 2_000, 0),
row('8910', 'Skatt', 20_000, 0),
]
const PREVIOUS_FULL: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 12_000),
row('1930', 'Bank', 185_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 60_000),
row('2099', 'Årets resultat', 0, 40_000),
row('2110', 'Periodiseringsfond', 0, 30_000),
row('2440', 'Leverantörsskulder', 0, 25_000),
row('2510', 'Skatteskulder', 0, 15_000),
row('2610', 'Utgående moms', 0, 8_000),
row('2941', 'Upplupna sociala avgifter', 0, 50_000),
row('3010', 'Försäljning', 500_000, 500_000),
row('4010', 'Inköp', 200_000, 200_000),
row('5010', 'Lokalhyra', 80_000, 80_000),
row('7010', 'Löner', 150_000, 150_000),
row('7832', 'Avskrivningar', 8_000, 8_000),
row('8410', 'Räntekostnader', 2_000, 2_000),
row('8910', 'Skatt', 20_000, 20_000),
]
export const CURRENT: TrialBalancePair = {
full: CURRENT_FULL,
preClosing: CURRENT_PRE_CLOSING,
}
export const PREVIOUS: TrialBalancePair = {
full: PREVIOUS_FULL,
preClosing: PREVIOUS_PRE_CLOSING,
}
export function makeInput(): IxbrlArsredovisningInput {
const mapping = mapTrialBalancesToK2(CURRENT, PREVIOUS)
return {
company: { name: 'Testbolaget AB', orgNumber: '556999-9999', city: 'Sundsvall' },
period: { start: '2025-01-01', end: '2025-12-31' },
previousPeriod: { start: '2024-01-01', end: '2024-12-31' },
isFirstFiscalYear: false,
rr: mapping.rr,
br: mapping.br,
totals: mapping.totals,
forvaltningsberattelse: {
allmantOmVerksamheten:
'Bolaget bedriver konsultverksamhet inom IT.\n\nBolaget har sitt säte i Sundsvall.',
vasentligaHandelser: 'Inga väsentliga händelser har inträffat under räkenskapsåret.',
// Rows 0/1 mirror the mapper outputs (duplicate facts with the RR must
// be value-identical, TA §2.7.3) — same override build-input applies.
flerarsoversikt: [
{
year: '2025',
nettoomsattning: mapping.rr['Nettoomsattning'].current,
resultatEfterFinansiellaPoster: mapping.totals.resultatEfterFinansiellaPoster.current,
soliditetPct: 64.5,
},
{
year: '2024',
nettoomsattning: mapping.rr['Nettoomsattning'].previous ?? 0,
resultatEfterFinansiellaPoster:
mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
soliditetPct: 49.4,
},
{
year: '2023',
nettoomsattning: 300_000,
resultatEfterFinansiellaPoster: 25_000,
soliditetPct: 41.0,
},
],
flerarsPerioder: [
{ start: '2025-01-01', end: '2025-12-31' },
{ start: '2024-01-01', end: '2024-12-31' },
{ start: '2023-01-01', end: '2023-12-31' },
],
egetKapital: {
aktiekapital: { ib: 25_000, ub: 25_000 },
balanseratResultat: { ib: 60_000, ub: 100_000 },
aretsResultat: { ib: 40_000, ub: 120_000 },
totalt: { ib: 125_000, ub: 245_000 },
ovrigaPoster: { ib: 0, ub: 0 },
balanserasINyRakning: 40_000,
utdelning: 0,
forandringAktiekapital: 0,
ovrigForandringBalanserat: 0,
aretsResultatRorelse: 120_000,
},
resultatdisposition: {
balanseratResultat: 100_000,
overkursfond: 0,
aretsResultat: 120_000,
summa: 220_000,
utdelning: 0,
balanserasINyRakning: 220_000,
kommentar: 'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
},
},
noter: [
{
number: 1,
title: 'Redovisnings- och värderingsprinciper',
body: 'Å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).',
},
{ number: 2, title: 'Medelantal anställda', body: 'Medelantalet anställda har uppgått till 2.' },
{ number: 3, title: 'Långfristiga skulder', body: 'Inga skulder förfaller senare än fem år efter balansdagen.' },
],
medelantalAnstallda: { current: 2, previous: 1 },
underskrifter: {
ort: 'Sundsvall',
dateringsdatum: '2026-02-20',
signers: [
{ firstName: 'Karl', lastName: 'Karlsson', role: 'Styrelseledamot', signedDate: '2026-02-20' },
{
firstName: 'Karin',
lastName: 'Olsson',
role: 'Verkställande direktör',
signedDate: '2026-02-21',
},
],
harVd: true,
},
faststallelseintyg: {
arsstammaDatum: '2026-03-15',
signerFirstName: 'Karl',
signerLastName: 'Karlsson',
signerRole: 'Styrelseledamot',
genereratDatum: '2026-02-25',
},
programvara: { namn: 'Accounted - Accounted', version: '2026.1' },
entryPointId: 'k2-ab-risbs-2024-09-12',
warnings: [],
}
}
@@ -0,0 +1,132 @@
/**
* Golden tests against the official Bolagsverket/taxonomier.se example
* documents in dev_docs/bokslut/exempel/. They pin our generator's
* conventions (context naming, entity scheme, fact attributes, hidden
* vallistor, fastställelseintyg structure) to what a known-accepted filing
* actually looks like.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { getRegistry } from '../taxonomy/registry'
import { generateK2IxbrlDocument } from '../document/k2-document'
import { makeInput } from './fixtures'
const EXEMPEL_PATH = resolve(
__dirname,
'../../../../dev_docs/bokslut/exempel/k2/faststalld-arsredovisning-exempel-1-rev20240214.xhtml',
)
const official = readFileSync(EXEMPEL_PATH, 'utf8')
const generated = generateK2IxbrlDocument(makeInput()).xhtml
function factNames(xhtml: string): Set<string> {
const names = new Set<string>()
for (const match of xhtml.matchAll(/<ix:(?:nonFraction|nonNumeric|tuple) [^>]*name="([\w:-]+)"/g)) {
names.add(match[1])
}
return names
}
describe('golden: official K2 exempel-1', () => {
it('every concept the official example tags exists in our 2024-09-12 registry, modulo known removals', () => {
const registry = getRegistry('k2-ab-2024-09-12')
const missing: string[] = []
for (const qname of factNames(official)) {
const local = qname.split(':')[1]
if (!registry.concepts[local]) missing.push(qname)
}
// The official example targets the 2021-10-31 taxonomy. Exactly two of
// its concepts were removed in 2024-09-12 (verified against the element
// list): the equity-change total now reuses EgetKapital, and the
// underskrift-ort concept was dropped with BFN's updated signing rules.
// Our generator accounts for both. Anything beyond these two means the
// registry lost coverage — investigate before widening this list.
expect(missing.sort()).toEqual([
'se-gen-base:ForandringEgetKapitalTotalt',
'se-gen-base:UndertecknandeArsredovisningOrt',
])
})
it('our generated document follows the official context conventions', () => {
for (const fragment of [
'<xbrli:identifier scheme="http://www.bolagsverket.se">',
'<xbrli:context id="period0">',
'<xbrli:context id="balans0">',
'<xbrli:unit id="SEK">',
'<xbrli:measure>iso4217:SEK</xbrli:measure>',
]) {
expect(official).toContain(fragment)
expect(generated).toContain(fragment)
}
})
it('our hidden vallista set matches the official example for a standard AB', () => {
const officialHidden =
official.match(/<ix:hidden>([\s\S]*?)<\/ix:hidden>/)?.[1] ?? ''
for (const concept of [
'se-cd-base:SprakHandlingUpprattadList',
'se-cd-base:LandForetagetsSateList',
'se-cd-base:RedovisningsvalutaHandlingList',
'se-cd-base:BeloppsformatList',
'se-gen-base:FinansiellRapportList',
'se-cd-base:RakenskapsarForstaDag',
'se-cd-base:RakenskapsarSistaDag',
]) {
expect(officialHidden).toContain(concept)
expect(generated).toContain(concept)
}
})
it('our fastställelseintyg structure mirrors the official one', () => {
for (const fragment of [
'se-bol-base:ArsstammaIntygande',
'se-bol-base:FaststallelseResultatBalansrakning',
'se-bol-base:Arsstamma',
'se-bol-base:IntygandeOriginalInnehall',
'se-bol-base:UnderskriftFaststallelseintygElektroniskt',
'se-bol-base:UnderskriftFaststallelseintygForetradareTilltalsnamn',
'se-bol-base:UnderskriftFastallelseintygDatum',
'ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG',
]) {
expect(official).toContain(fragment)
expect(generated).toContain(fragment)
}
// Same continuation pattern joining intygande + original-innehåll.
expect(official).toMatch(/continuedAt="intygande_forts"/)
expect(generated).toMatch(/continuedAt="intygande_forts"/)
})
it('shared monetary facts carry the same attribute conventions as the official file', () => {
// Official: <ix:nonFraction contextRef=… name=… unitRef="SEK" … format="ixt:numspacecomma">
const officialNetto = official.match(
/<ix:nonFraction[^>]*name="se-gen-base:Nettoomsattning"[^>]*>/,
)?.[0]
const generatedNetto = generated.match(
/<ix:nonFraction[^>]*name="se-gen-base:Nettoomsattning"[^>]*>/,
)?.[0]
expect(officialNetto).toBeDefined()
expect(generatedNetto).toBeDefined()
for (const attr of ['unitRef="SEK"', 'format="ixt:numspacecomma"']) {
expect(officialNetto).toContain(attr)
expect(generatedNetto).toContain(attr)
}
// The official RR shows costs with the minus OUTSIDE the fact element and
// no sign attribute — verify we do the same for Personalkostnader.
expect(official).toMatch(/-<ix:nonFraction[^>]*name="se-gen-base:Personalkostnader"/)
expect(generated).toMatch(/−<ix:nonFraction[^>]*name="se-gen-base:Personalkostnader"/)
expect(generated).not.toMatch(/name="se-gen-base:Personalkostnader"[^>]*sign=/)
})
it('underskrifter use the same tuple + member concepts as the official file', () => {
for (const fragment of [
'se-gaap-ext:UnderskriftArsredovisningForetradareTuple',
'se-gen-base:UnderskriftHandlingTilltalsnamn',
'se-gen-base:UnderskriftHandlingEfternamn',
'se-gen-base:UnderskriftHandlingRoll',
]) {
expect(official).toContain(fragment)
expect(generated).toContain(fragment)
}
})
})
@@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest'
import { XMLValidator } from 'fast-xml-parser'
import { mapTrialBalancesToK2 } from '../k2-mapper'
import { generateK2IxbrlDocument, embedKontrollsumma } from '../document/k2-document'
import { makeInput, CURRENT } from './fixtures'
describe('generateK2IxbrlDocument', () => {
const { xhtml, warnings } = generateK2IxbrlDocument(makeInput())
it('produces well-formed XML (XHTML)', () => {
const validation = XMLValidator.validate(xhtml)
expect(validation).toBe(true)
})
it('declares the container per TA §3/§4: UTF-8, title, programvara meta, no scripts', () => {
expect(xhtml.startsWith('<?xml version="1.0" encoding="utf-8"?>')).toBe(true)
expect(xhtml).toContain('<title>')
expect(xhtml).toContain('<meta name="programvara" content="Accounted - Accounted"/>')
expect(xhtml).toContain('<meta name="programversion" content="2026.1"/>')
expect(xhtml).not.toContain('<script')
expect(xhtml).not.toContain('&nbsp;')
expect(xhtml).toContain('xmlns="http://www.w3.org/1999/xhtml"')
})
it('references the K2 2024-09-12 risbs entry point + COA (fastställelseintyg) schema', () => {
expect(xhtml).toContain(
'http://xbrl.taxonomier.se/se/fr/gaap/k2-all/ab/risbs/2024-09-12/se-k2-ab-risbs-2024-09-12.xsd',
)
expect(xhtml).toContain(
'http://xbrl.taxonomier.se/se/fr/gaap/coa/rplc/2020-12-01/se-coa-rplc-2020-12-01.xsd',
)
})
it('declares contexts per TA §2.16 with the bolagsverket entity scheme', () => {
for (const ctxId of ['period0', 'period1', 'period2', 'balans0', 'balans1', 'balans2']) {
expect(xhtml).toContain(`<xbrli:context id="${ctxId}">`)
}
expect(xhtml).toContain('scheme="http://www.bolagsverket.se"')
expect(xhtml).toContain('<xbrli:startDate>2025-01-01</xbrli:startDate>')
expect(xhtml).toContain('<xbrli:instant>2025-12-31</xbrli:instant>')
expect(xhtml).toContain('<xbrli:measure>iso4217:SEK</xbrli:measure>')
})
it('hides vallistor + räkenskapsårets dagar in ix:hidden (TA §2.15/§3.9.3)', () => {
expect(xhtml).toContain('<ix:hidden>')
expect(xhtml).toContain('se-mem-base:SprakSvenskaMember')
expect(xhtml).toContain('se-mem-base:LandSverigeMember')
expect(xhtml).toContain('se-mem-base:ValutaSvenskaKronorMember')
expect(xhtml).toContain(
'se-mem-base:FinansiellRapportStyrelsenVerkstallandeDirektorenAvgerArsredovisningMember',
)
expect(xhtml).toContain('se-cd-base:RakenskapsarForstaDag')
expect(xhtml).toContain('se-cd-base:RakenskapsarSistaDag')
})
it('emits the fastställelseintyg with checksum-exclusion wrapper and the magic date id (TA §4.4–4.5)', () => {
expect(xhtml).toContain('id="id-innehall-faststallelseintyg"')
expect(xhtml).toContain('id="ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG"')
expect(xhtml).toContain('se-bol-base:ArsstammaIntygande')
expect(xhtml).toContain('se-bol-base:IntygandeOriginalInnehall')
expect(xhtml).toMatch(/<ix:continuation id="intygande_forts">/)
// AGM date tagged + generation date in the signing-date element.
expect(xhtml).toContain('>2026-03-15</ix:nonNumeric>')
expect(xhtml).toMatch(/ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG">2026-02-25</)
})
it('tags RR and BR amounts with correct attributes and presentational minus', () => {
// Nettoomsättning current year, whole kronor.
expect(xhtml).toMatch(
/<ix:nonFraction contextRef="period0" name="se-gen-base:Nettoomsattning" unitRef="SEK" decimals="0" scale="0" format="ixt:numspacecomma">1 000 000<\/ix:nonFraction>/,
)
// Costs: minus outside the element, positive fact value (no sign attr).
expect(xhtml).toMatch(
/−<ix:nonFraction contextRef="period0" name="se-gen-base:Personalkostnader"[^>]*>525 660<\/ix:nonFraction>/,
)
expect(xhtml).not.toMatch(/name="se-gen-base:Personalkostnader"[^>]*sign="-"/)
// Deviating sign: avsättning till periodiseringsfond (credit concept, debit value).
expect(xhtml).toMatch(/name="se-gen-base:ForandringPeriodiseringsfond"[^>]*sign="-"/)
// BR totals present in both years (3001/3002 kontrollera requirements).
expect(xhtml).toMatch(/name="se-gen-base:Tillgangar"[^>]*>380 000/)
expect(xhtml).toMatch(/name="se-gen-base:EgetKapitalSkulder"[^>]*>380 000/)
expect(xhtml).toMatch(/contextRef="balans1" name="se-gen-base:Tillgangar"[^>]*>253 000/)
})
it('tags the underskrifter tuple with per-signer dates (TA §2.9.1)', () => {
expect(xhtml).toContain('se-gaap-ext:UnderskriftArsredovisningForetradareTuple')
const tilltalsnamn = xhtml.match(/name="se-gen-base:UnderskriftHandlingTilltalsnamn"/g) ?? []
expect(tilltalsnamn).toHaveLength(2)
const dates = xhtml.match(/name="se-gen-base:UndertecknandeDatum"[^>]*tupleRef="/g) ?? []
expect(dates).toHaveLength(2)
expect(xhtml).toContain('>2026-02-21</ix:nonNumeric>')
// Datering av årsredovisning tagged (FY starts after 2024-07-01).
expect(xhtml).toContain('se-gen-base:UndertecknandeArsredovisningDatum')
})
it('tags flerårsöversikt incl. soliditet as procent with scale -2 (TA §2.12)', () => {
expect(xhtml).toMatch(
/<ix:nonFraction contextRef="balans0" name="se-gen-base:Soliditet" unitRef="procent" decimals="3" scale="-2" format="ixt:numspacecomma">64,5<\/ix:nonFraction>/,
)
expect(xhtml).toMatch(/contextRef="period2" name="se-gen-base:Nettoomsattning"/)
})
it('tags medelantal anställda with the custom unit (TA §2.14)', () => {
expect(xhtml).toMatch(
/<ix:nonFraction contextRef="period0" name="se-gen-base:MedelantaletAnstallda" unitRef="antal-anstallda"/,
)
expect(xhtml).toContain('<xbrli:measure>se-k2-type:AntalAnstallda</xbrli:measure>')
})
it('tags resultatdisposition (BÖR: förslag) consistently with BR', () => {
expect(xhtml).toMatch(/name="se-gen-base:ForslagDispositionBalanserasINyRakning"[^>]*>220 000/)
expect(xhtml).toMatch(/name="se-gen-base:ForslagDisposition"[^>]*>220 000/)
expect(xhtml).toMatch(/name="se-gen-base:FrittEgetKapital"[^>]*>220 000/)
})
it('does not tag utdelning when none is proposed', () => {
expect(xhtml).not.toContain('ForslagDispositionUtdelning')
})
it('reports no warnings for the happy path', () => {
expect(warnings).toEqual([])
})
it('embedKontrollsumma inserts the meta tags into head (TA §4.5.2)', () => {
const withChecksum = embedKontrollsumma(xhtml, 'abc123==', 'SHA-256')
expect(withChecksum).toContain(
'<meta name="ixbrl.innehall.kontrollsumman" content="abc123=="/>',
)
expect(withChecksum).toContain(
'<meta name="ixbrl.innehall.kontrollsumman.algoritm" content="SHA-256"/>',
)
expect(XMLValidator.validate(withChecksum)).toBe(true)
})
it('keeps the document under the 5 MB limit (TA §4.2.1)', () => {
expect(Buffer.byteLength(xhtml, 'utf8')).toBeLessThan(5 * 1024 * 1024)
})
})
describe('generateK2IxbrlDocument — dividend + first year variants', () => {
it('tags förslag till utdelning when proposed (TA §2.9.2 BÖR)', () => {
const input = makeInput()
input.forvaltningsberattelse.resultatdisposition.utdelning = 50_000
input.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 170_000
const { xhtml } = generateK2IxbrlDocument(input)
expect(xhtml).toMatch(/name="se-gen-base:ForslagDispositionUtdelning"[^>]*>50 000/)
})
it('tags fri överkursfond as its own concept in the disposition — identical to BR (TA §2.7.3)', () => {
const input = makeInput()
input.br['Overkursfond'] = { current: 50_000, previous: 50_000 }
input.forvaltningsberattelse.resultatdisposition.overkursfond = 50_000
input.forvaltningsberattelse.resultatdisposition.summa = 270_000
input.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 270_000
const { xhtml } = generateK2IxbrlDocument(input)
// BR row + disposition row: same concept, same context, same value.
const facts =
xhtml.match(
/<ix:nonFraction contextRef="balans0" name="se-gen-base:Overkursfond"[^>]*>50 000<\/ix:nonFraction>/g,
) ?? []
expect(facts).toHaveLength(2)
// BalanseratResultat stays strictly balanserat (not balanserat + 2097).
const balanserat =
xhtml.match(
/<ix:nonFraction contextRef="balans0" name="se-gen-base:BalanseratResultat"[^>]*>([\d ]+)</g,
) ?? []
expect(balanserat.length).toBeGreaterThan(0)
for (const fact of balanserat) expect(fact).toContain('>100 000<')
})
it('renders a deviating cost row WITHOUT the presentational minus (sign="-" carries the deviation)', () => {
const input = makeInput()
// Net income on a cost line (credit balance on 5xxx) — deviating sign.
input.rr['OvrigaExternaKostnader'] = { current: -5_000, previous: null }
const { xhtml } = generateK2IxbrlDocument(input)
expect(xhtml).toMatch(/name="se-gen-base:OvrigaExternaKostnader"[^>]*sign="-"/)
expect(xhtml).not.toMatch(/−<ix:nonFraction[^>]*name="se-gen-base:OvrigaExternaKostnader"/)
})
it('omits the per-signer date fact for unsigned requests instead of fabricating one', () => {
const input = makeInput()
input.underskrifter.signers[1].signedDate = null
const { xhtml } = generateK2IxbrlDocument(input)
const dates = xhtml.match(/name="se-gen-base:UndertecknandeDatum"/g) ?? []
expect(dates).toHaveLength(1)
// Both signers still appear with their names.
const names = xhtml.match(/name="se-gen-base:UnderskriftHandlingTilltalsnamn"/g) ?? []
expect(names).toHaveLength(2)
expect(XMLValidator.validate(xhtml)).toBe(true)
})
it('renders a placeholder instead of a fabricated AGM date when none is recorded', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = null
const { xhtml } = generateK2IxbrlDocument(input)
expect(xhtml).not.toContain('name="se-bol-base:Arsstamma"')
expect(xhtml).toContain('[datum för årsstämma saknas]')
expect(XMLValidator.validate(xhtml)).toBe(true)
})
it('omits comparison columns for the first fiscal year', () => {
const mapping = mapTrialBalancesToK2(CURRENT, null)
const input = makeInput()
input.previousPeriod = null
input.isFirstFiscalYear = true
input.rr = mapping.rr
input.br = mapping.br
input.totals = mapping.totals
input.forvaltningsberattelse.flerarsoversikt = input.forvaltningsberattelse.flerarsoversikt.slice(0, 1)
input.forvaltningsberattelse.flerarsPerioder = input.forvaltningsberattelse.flerarsPerioder.slice(0, 1)
input.forvaltningsberattelse.egetKapital.balanserasINyRakning = 0
const { xhtml } = generateK2IxbrlDocument(input)
expect(xhtml).not.toContain('<xbrli:context id="period1">')
expect(xhtml).not.toContain('<xbrli:context id="balans1">')
expect(XMLValidator.validate(xhtml)).toBe(true)
})
it('throws on unknown concepts instead of emitting invalid facts', () => {
const input = makeInput()
input.rr['PahittatBegrepp'] = { current: 1, previous: null }
// Unknown keys in rr are ignored (only mapped posts are rendered) — the
// throw-path is covered via the writer itself in fact-writer tests.
expect(() => generateK2IxbrlDocument(input)).not.toThrow()
})
})
@@ -0,0 +1,219 @@
import { describe, expect, it } from 'vitest'
import { mapTrialBalancesToK2, type TrialBalanceRowLike } from '../k2-mapper'
import { CURRENT, PREVIOUS } from './fixtures'
const row = (
account: string,
name: string,
debit: number,
credit: number,
): TrialBalanceRowLike => ({
account_number: account,
account_name: name,
closing_debit: debit,
closing_credit: credit,
})
describe('mapTrialBalancesToK2', () => {
// Realistic post-bokslut pairs: full TB has class 3–8 zeroed + 2099 booked;
// preClosing TB has the RR accounts open (see fixtures.ts).
const result = mapTrialBalancesToK2(CURRENT, PREVIOUS)
it('maps RR posts with natural orientation for both years', () => {
expect(result.rr['Nettoomsattning']).toEqual({ current: 1_000_000, previous: 500_000 })
expect(result.rr['RavarorFornodenheterKostnader']).toEqual({
current: 200_000,
previous: 200_000,
})
expect(result.rr['OvrigaExternaKostnader']).toEqual({ current: 100_000, previous: 80_000 })
expect(result.rr['Personalkostnader']).toEqual({ current: 525_660, previous: 150_000 })
expect(
result.rr['AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar'],
).toEqual({ current: 20_000, previous: 8_000 })
expect(result.rr['OvrigaRanteintakterLiknandeResultatposter']).toEqual({
current: 1_000,
previous: 0,
})
expect(result.rr['RantekostnaderLiknandeResultatposter']).toEqual({
current: 4_000,
previous: 2_000,
})
// 8811 avsättning = debit → credit-oriented concept goes negative.
expect(result.rr['ForandringPeriodiseringsfond']).toEqual({ current: -10_000, previous: 0 })
expect(result.rr['SkattAretsResultat']).toEqual({ current: 21_340, previous: 20_000 })
})
it('computes RR subtotals down to årets resultat', () => {
expect(result.totals.rorelseintakter.current).toBe(1_000_000)
expect(result.totals.rorelsekostnader.current).toBe(845_660)
expect(result.totals.rorelseresultat.current).toBe(154_340)
expect(result.totals.finansiellaPoster.current).toBe(-3_000)
expect(result.totals.resultatEfterFinansiellaPoster.current).toBe(151_340)
expect(result.totals.bokslutsdispositioner.current).toBe(-10_000)
expect(result.totals.resultatForeSkatt.current).toBe(141_340)
expect(result.totals.aretsResultat.current).toBe(120_000)
expect(result.totals.aretsResultat.previous).toBe(40_000)
})
it('maps BR posts and nets contra accounts (ack. avskrivningar)', () => {
expect(result.br['InventarierVerktygInstallationer']).toEqual({
current: 60_000,
previous: 68_000,
})
expect(result.br['Kundfordringar']).toEqual({ current: 50_000, previous: 0 })
expect(result.br['KassaBankExklRedovisningsmedel']).toEqual({
current: 270_000,
previous: 185_000,
})
expect(result.br['Aktiekapital']).toEqual({ current: 25_000, previous: 25_000 })
expect(result.br['BalanseratResultat']).toEqual({ current: 100_000, previous: 60_000 })
expect(result.br['AretsResultatEgetKapital']).toEqual({ current: 120_000, previous: 40_000 })
expect(result.br['Periodiseringsfonder']).toEqual({ current: 40_000, previous: 30_000 })
expect(result.br['Leverantorsskulder']).toEqual({ current: 30_000, previous: 25_000 })
expect(result.br['Skatteskulder']).toEqual({ current: 35_000, previous: 15_000 })
// Moms (2610) lands in övriga kortfristiga skulder.
expect(result.br['OvrigaKortfristigaSkulder']).toEqual({ current: 20_000, previous: 8_000 })
expect(result.br['UpplupnaKostnaderForutbetaldaIntakter']).toEqual({
current: 10_000,
previous: 50_000,
})
})
it('balances: Summa tillgångar == Summa eget kapital och skulder (3005)', () => {
expect(result.totals.tillgangar.current).toBe(380_000)
expect(result.totals.egetKapitalSkulder.current).toBe(380_000)
expect(result.totals.tillgangar.previous).toBe(253_000)
expect(result.totals.egetKapitalSkulder.previous).toBe(253_000)
expect(result.warnings).toEqual([])
expect(result.unmappedAccounts).toEqual([])
})
it('reconciles RR-result against BR 2099', () => {
expect(result.totals.aretsResultat.current).toBe(result.br['AretsResultatEgetKapital'].current)
})
// Regression for the year-end-closing split: a realistic post-bokslut TB
// pair must yield NON-ZERO RR concepts (from the pre-closing TB) AND a BR
// that ties (from the full TB). Mapping a single TB can never do both: the
// closing entry zeroes class 3–8, so RR concepts would collapse to 0.
it('regression: post-bokslut pair gives non-zero RR and a balancing BR', () => {
const res = mapTrialBalancesToK2(CURRENT, PREVIOUS)
expect(res.rr['Nettoomsattning'].current).toBe(1_000_000)
expect(res.totals.aretsResultat.current).toBe(120_000)
expect(res.totals.aretsResultat.current).toBe(res.br['AretsResultatEgetKapital'].current)
expect(res.totals.tillgangar.current).toBe(res.totals.egetKapitalSkulder.current)
expect(res.warnings).toEqual([])
// Sanity: the full TB really has the RR accounts zeroed — mapping it as
// the RR source would produce an all-zero resultaträkning.
const wrong = mapTrialBalancesToK2(
{ full: CURRENT.full, preClosing: CURRENT.full },
null,
)
expect(wrong.rr['Nettoomsattning'].current).toBe(0)
})
it('handles first fiscal year (no previous trial balance)', () => {
const firstYear = mapTrialBalancesToK2(CURRENT, null)
expect(firstYear.rr['Nettoomsattning']).toEqual({ current: 1_000_000, previous: null })
expect(firstYear.totals.tillgangar.previous).toBeNull()
})
it('flags unmapped accounts (their balance never reaches the BR)', () => {
const broken = {
full: CURRENT.full,
preClosing: [...CURRENT.preClosing, row('9999', 'Internkonto', 5_000, 0)],
}
const res = mapTrialBalancesToK2(broken, null)
expect(res.unmappedAccounts).toHaveLength(1)
expect(res.unmappedAccounts[0].account).toBe('9999')
expect(res.warnings.some((w) => w.includes('9999'))).toBe(true)
})
it('warns when the mapped balance sheet does not balance (3005)', () => {
const brokenFull = CURRENT.full.map((r2) =>
r2.account_number === '1930' ? { ...r2, closing_debit: 275_000 } : r2,
)
const res = mapTrialBalancesToK2({ full: brokenFull, preClosing: CURRENT.preClosing }, null)
expect(res.warnings.some((w) => w.includes('3005'))).toBe(true)
})
it('warns when 2099 is not booked (RR ≠ BR result)', () => {
// "Bokslut not run" = the full TB equals the pre-closing TB (no closing
// entry exists), so 2099 carries no result.
const res = mapTrialBalancesToK2(
{ full: CURRENT.preClosing, preClosing: CURRENT.preClosing },
null,
)
expect(res.warnings.some((w) => w.includes('2099'))).toBe(true)
})
it('routes lagerförändringar per K2 split (4910 → råvaror, 4960 → handelsvaror, 4940 → förändring av lager)', () => {
const rows = [
row('3010', 'Försäljning', 0, 100_000),
row('4910', 'Förändring lager råvaror', 0, 5_000),
row('4940', 'Förändring produkter i arbete', 0, 7_000),
row('4960', 'Förändring lager handelsvaror', 3_000, 0),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.rr['RavarorFornodenheterKostnader'].current).toBe(-5_000)
expect(
res.rr['ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning'].current,
).toBe(7_000)
expect(res.rr['HandelsvarorKostnader'].current).toBe(3_000)
})
})
describe('mapTrialBalancesToK2 — öre-rounding residual smoothing', () => {
it('absorbs a ±1 kr BR residual into the largest equity/liability post', () => {
// Assets round UP twice (.50 each), liabilities round once up once down:
// rounded Tillgångar 202 vs rounded EK+skulder 201 although the TB ties
// exactly at 201,00. The +1 residual lands in the largest post on the
// equity/liabilities side (Leverantörsskulder).
const rows = [
row('1510', 'Kundfordringar', 100.5, 0),
row('1930', 'Bank', 100.5, 0),
row('2440', 'Leverantörsskulder', 0, 100.75),
row('2510', 'Skatteskulder', 0, 100.25),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.totals.tillgangar.current).toBe(202)
expect(res.totals.egetKapitalSkulder.current).toBe(202)
expect(res.br['Leverantorsskulder'].current).toBe(102)
expect(res.br['Skatteskulder'].current).toBe(100)
expect(res.warnings).toEqual([])
})
it('absorbs a ±1 kr RR residual so the RR result equals 2099 exactly', () => {
// Two revenue posts of 100,25 each round to 100 + 100 = 200, while the
// booked 2099 (200,50) rounds to 201. The −1 residual is absorbed by the
// largest RR post (Nettoomsättning).
const preClosing = [
row('1930', 'Bank', 200.5, 0),
row('3010', 'Försäljning', 0, 100.25),
row('3990', 'Övriga intäkter', 0, 100.25),
]
const full = [
row('1930', 'Bank', 200.5, 0),
row('2099', 'Årets resultat', 0, 200.5),
row('3010', 'Försäljning', 100.25, 100.25),
row('3990', 'Övriga intäkter', 100.25, 100.25),
]
const res = mapTrialBalancesToK2({ full, preClosing }, null)
expect(res.rr['Nettoomsattning'].current).toBe(101)
expect(res.totals.aretsResultat.current).toBe(201)
expect(res.br['AretsResultatEgetKapital'].current).toBe(201)
expect(res.totals.tillgangar.current).toBe(res.totals.egetKapitalSkulder.current)
expect(res.warnings).toEqual([])
})
it('leaves residuals beyond ±1 kr alone and reports them', () => {
const rows = [
row('1930', 'Bank', 1_000, 0),
row('2440', 'Leverantörsskulder', 0, 990),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['Leverantorsskulder'].current).toBe(990)
expect(res.warnings.some((w) => w.includes('3005'))).toBe(true)
})
})
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest'
import { runPreflightChecks } from '../validate/rules'
import { makeInput } from './fixtures'
import type { IxbrlArsredovisningInput } from '../types'
const TODAY = '2026-06-10'
const codes = (input: IxbrlArsredovisningInput): string[] =>
runPreflightChecks(input, TODAY).issues.map((issue) => issue.code)
describe('runPreflightChecks', () => {
it('passes the happy-path fixture with no errors', () => {
const result = runPreflightChecks(makeInput(), TODAY)
expect(result.errors).toEqual([])
expect(result.ok).toBe(true)
})
it('1020/1035 — company identity', () => {
const input = makeInput()
input.company.name = ' '
input.company.orgNumber = '12345'
const found = codes(input)
expect(found).toContain('1020')
expect(found).toContain('1035')
})
it('1051 — förvaltningsberättelse missing', () => {
const input = makeInput()
input.forvaltningsberattelse.allmantOmVerksamheten = ''
expect(codes(input)).toContain('1051')
})
it('1107/1201/1214 — underskrifter completeness', () => {
const noSigners = makeInput()
noSigners.underskrifter.signers = []
expect(codes(noSigners)).toContain('1107')
const nameless = makeInput()
nameless.underskrifter.signers[0].lastName = ''
expect(codes(nameless)).toContain('1201')
const dateless = makeInput()
dateless.underskrifter.signers[0].signedDate = ''
expect(codes(dateless)).toContain('1214')
})
it('1214 — an unsigned signature request (signedDate null) BLOCKS filing', () => {
// build-input never fabricates a signing date: an unsigned request keeps
// signedDate null, and that must surface as a blocking error, not a warn.
const input = makeInput()
input.underskrifter.signers[1].signedDate = null
const result = runPreflightChecks(input, TODAY)
const hit = result.issues.find((issue) => issue.code === '1214')
expect(hit?.severity).toBe('error')
expect(result.ok).toBe(false)
})
it('1103/1169 — fastställelseintyg completeness', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = null
input.faststallelseintyg.signerLastName = ''
const found = codes(input)
expect(found).toContain('1103')
expect(found).toContain('1169')
})
it('1103 — missing AGM date is a blocking error (no today-fallback)', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = null
const result = runPreflightChecks(input, TODAY)
const hit = result.issues.find((issue) => issue.code === '1103')
expect(hit?.severity).toBe('error')
expect(result.ok).toBe(false)
// The date-ordering rules must not crash or misfire on the null date.
const found = result.issues.map((issue) => issue.code)
expect(found).not.toContain('1101')
expect(found).not.toContain('1178')
})
it('1015 — fiscal year not yet ended', () => {
const input = makeInput()
input.period = { start: '2026-01-01', end: '2026-12-31' }
expect(codes(input)).toContain('1015')
})
it('1046 — fiscal year longer than 18 months', () => {
const input = makeInput()
input.period = { start: '2024-01-01', end: '2025-12-31' }
expect(codes(input)).toContain('1046')
})
it('1101 — AGM on or before period end', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = '2025-12-31'
expect(codes(input)).toContain('1101')
})
it('1178 — AGM in the future', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = '2026-09-01'
expect(codes(input)).toContain('1178')
})
it('1114 — signature date inside the fiscal year', () => {
const input = makeInput()
input.underskrifter.signers[0].signedDate = '2025-12-30'
expect(codes(input)).toContain('1114')
})
it('1183 — AGM before board signatures', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = '2026-02-20'
input.underskrifter.signers[1].signedDate = '2026-02-21'
expect(codes(input)).toContain('1183')
})
it('1165 — FI generated before AGM is warn-level only', () => {
const input = makeInput()
input.faststallelseintyg.genereratDatum = '2026-03-01' // AGM is 2026-03-15
const result = runPreflightChecks(input, TODAY)
const hit = result.issues.find((issue) => issue.code === '1165')
expect(hit?.severity).toBe('warn')
expect(result.ok).toBe(true)
})
it('3005 — unbalanced balance sheet blocks', () => {
const input = makeInput()
input.totals.tillgangar = { current: 100, previous: null }
const result = runPreflightChecks(input, TODAY)
expect(result.issues.map((issue) => issue.code)).toContain('3005')
expect(result.ok).toBe(false)
})
it('3006/3007 — comparison figures required except first year', () => {
const input = makeInput()
input.totals.tillgangar = { ...input.totals.tillgangar, previous: null }
input.totals.aretsResultat = { ...input.totals.aretsResultat, previous: null }
const found = codes(input)
expect(found).toContain('3006')
expect(found).toContain('3007')
const firstYear = makeInput()
firstYear.isFirstFiscalYear = true
firstYear.totals.tillgangar = { ...firstYear.totals.tillgangar, previous: null }
firstYear.totals.aretsResultat = { ...firstYear.totals.aretsResultat, previous: null }
const firstYearCodes = codes(firstYear)
expect(firstYearCodes).not.toContain('3006')
expect(firstYearCodes).not.toContain('3007')
})
it('ACC-2099 — unbooked result blocks', () => {
const input = makeInput()
input.br['AretsResultatEgetKapital'] = { current: 0, previous: null }
expect(codes(input)).toContain('ACC-2099')
})
it('ACC-DISP/ACC-UTD — resultatdisposition consistency', () => {
const broken = makeInput()
broken.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 1
expect(codes(broken)).toContain('ACC-DISP')
const negative = makeInput()
negative.forvaltningsberattelse.resultatdisposition.summa = -5_000
negative.forvaltningsberattelse.resultatdisposition.utdelning = 1_000
negative.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = -6_000
expect(codes(negative)).toContain('ACC-UTD')
})
it('carries mapper warnings as warn-level issues', () => {
const input = makeInput()
input.warnings = ['Konto 9999 täcks inte av K2-mappningen']
const result = runPreflightChecks(input, TODAY)
expect(result.warnings.some((issue) => issue.message.includes('9999'))).toBe(true)
expect(result.ok).toBe(true)
})
})
+386
View File
@@ -0,0 +1,386 @@
/**
* Assemble IxbrlArsredovisningInput for a fiscal period.
*
* Reuses the same sources as the PDF builder (buildArsredovisningData) for
* narrative texts, noter and flerårsöversikt, and adds what iXBRL needs on
* top: trial balances for BOTH years mapped to risbs concepts
* (jämförelsesiffror — kontrollera 3006/3007), per-signer dates from the
* signature flow, and the fastställelseintyg undertecknare.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { listSignatureRequests } from '@/lib/bokslut/arsredovisning/signature-service'
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
import { mapTrialBalancesToK2, type TrialBalancePair } from './k2-mapper'
import { resolveEntryPoint } from './taxonomy/entry-points'
import type {
EgetKapitalForandring,
FlerarsRow,
IxbrlArsredovisningInput,
IxbrlSigner,
Resultatdisposition,
} from './types'
/** TA §4.3.4–4.3.5: "<leverantör> - <produkt>", version "<huvud>.<revision>". */
export const PROGRAMVARA_NAMN = 'Accounted - Accounted'
export const PROGRAMVARA_VERSION = '2026.1'
export interface BuildIxbrlOptions {
/** Undertecknare of the fastställelseintyg (chosen in the wizard). When
* omitted, the first signed board member is used. */
undertecknare?: { firstName: string; lastName: string; role: string }
/** Proposed dividend in SEK (0 = balansera allt). */
proposedDividend?: number
/** Override "today" for deterministic tests (ISO date). */
todayIso?: string
}
function splitName(fullName: string): { firstName: string; lastName: string } {
const parts = fullName.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: parts[0] }
return { firstName: parts.slice(0, -1).join(' '), lastName: parts[parts.length - 1] }
}
export async function buildIxbrlInput(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: BuildIxbrlOptions = {},
): Promise<IxbrlArsredovisningInput> {
const warnings: string[] = []
// Two TB variants per year (see TrialBalancePair): the FULL trial balance
// (year-end closing included → 2099 booked, class 3–8 zeroed) drives the
// BR; the PRE-CLOSING trial balance (excludeYearEndClosing — the same split
// lib/reports' generateIncomeStatement uses) drives the RR. A single TB can
// never serve both: with bokslut booked every RR concept would map to 0,
// without it the BR would not tie.
const [pdfData, periodRow, currentTbFull, currentTbPreClosing, signatureRequests] =
await Promise.all([
buildArsredovisningData(supabase, companyId, fiscalPeriodId),
supabase
.from('fiscal_periods')
.select('id, period_start, period_end, previous_period_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
listSignatureRequests(supabase, companyId, fiscalPeriodId),
])
if (periodRow.error || !periodRow.data) throw new Error('Fiscal period not found')
const period = periodRow.data
if (pdfData.accounting_framework !== 'k2') {
throw new Error(
'Digital inlämning stöds ännu inte för K3 — generera PDF eller vänta på K3-stödet.',
)
}
const entryPoint = resolveEntryPoint('k2')
// Previous period: trial balances for jämförelsesiffror (same full/
// pre-closing split as the current year).
let previousPeriod: { start: string; end: string } | null = null
let previousTb: TrialBalancePair | null = null
if (period.previous_period_id) {
const { data: prev } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('id', period.previous_period_id)
.eq('company_id', companyId)
.maybeSingle()
if (prev) {
previousPeriod = { start: prev.period_start, end: prev.period_end }
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prev.id),
generateTrialBalance(supabase, companyId, prev.id, { excludeYearEndClosing: true }),
])
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
} catch {
warnings.push(
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår — balans- och resultaträkning visas utan jämförelseår (kontrollera-kod 3006/3007 kan utlösas).',
)
previousPeriod = null
}
}
}
const mapping = mapTrialBalancesToK2(
{ full: currentTbFull.rows, preClosing: currentTbPreClosing.rows },
previousTb,
)
warnings.push(...mapping.warnings)
// ---- flerårsöversikt (reuse PDF rows; whole SEK) -------------------------
// PDF rows are oldest-first; iXBRL columns newest-first. Each row needs the
// matching fiscal-period range so the document can declare period2/3 +
// balans2/3 contexts explicitly.
const { data: allPeriods } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
const periodByName = new Map(
((allPeriods ?? []) as Array<{ name: string; period_start: string; period_end: string }>).map(
(p) => [p.name, p],
),
)
const flerarsoversikt: FlerarsRow[] = []
const flerarsPerioder: Array<{ start: string; end: string }> = []
for (const row of [...pdfData.forvaltningsberattelse.flerarsoversikt].reverse()) {
const match = periodByName.get(row.year)
if (!match) continue // can't tag a year without a known period range
flerarsoversikt.push({
year: row.year,
nettoomsattning: Math.round(row.net_revenue),
resultatEfterFinansiellaPoster: Math.round(row.result_after_financial),
soliditetPct: row.soliditet_pct,
})
flerarsPerioder.push({ start: match.period_start, end: match.period_end })
}
// Column 0 must be the current period and column 1 the previous one —
// the document reuses period0/period1 contexts for them. Anything else
// (e.g. a missed periodByName lookup shifting the rows) means the period
// chain is inconsistent; drop the table rather than tag amounts against
// the wrong period.
const flerarsMisaligned =
(flerarsPerioder.length > 0 && flerarsPerioder[0].start !== period.period_start) ||
(flerarsPerioder.length > 1 &&
previousPeriod !== null &&
flerarsPerioder[1].start !== previousPeriod.start)
if (flerarsMisaligned) {
warnings.push(
'Flerårsöversikten kunde inte knytas till räkenskapsperioderna — tabellen utelämnas ur iXBRL-dokumentet.',
)
flerarsoversikt.length = 0
flerarsPerioder.length = 0
}
// Duplicate-fact consistency (TA §2.7.3): the flerårsöversikt repeats
// Nettoomsattning / ResultatEfterFinansiellaPoster in the same contexts
// (period0/period1) as the RR, and repeated facts must be value-identical
// or Bolagsverket rejects the filing. The PDF rows are computed from the
// income statement (ALL class-3 revenue), while nettoomsättning per ÅRL is
// strictly 3000–3799 — so the current and previous year columns are
// overridden with the mapper outputs. Older years have no RR facts and
// keep the PDF values.
if (flerarsoversikt.length > 0) {
flerarsoversikt[0] = {
...flerarsoversikt[0],
nettoomsattning: mapping.rr['Nettoomsattning']?.current ?? 0,
resultatEfterFinansiellaPoster: mapping.totals.resultatEfterFinansiellaPoster.current,
}
if (flerarsoversikt.length > 1 && previousPeriod !== null) {
flerarsoversikt[1] = {
...flerarsoversikt[1],
nettoomsattning: mapping.rr['Nettoomsattning']?.previous ?? 0,
resultatEfterFinansiellaPoster:
mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
}
}
}
// ---- eget kapital-förändring ---------------------------------------------
const br = mapping.br
const at = (concept: string): { ib: number; ub: number } => ({
ib: br[concept]?.previous ?? 0,
ub: br[concept]?.current ?? 0,
})
const aktiekapital = at('Aktiekapital')
const balanserat = at('BalanseratResultat')
const aretsRes = at('AretsResultatEgetKapital')
const totalt = {
ib: mapping.totals.egetKapital.previous ?? 0,
ub: mapping.totals.egetKapital.current,
}
const ovrigaPoster = {
ib:
totalt.ib - aktiekapital.ib - balanserat.ib - aretsRes.ib,
ub:
totalt.ub - aktiekapital.ub - balanserat.ub - aretsRes.ub,
}
// Movement derivation: föregående års resultat balanseras; vad som därutöver
// lämnat balanserat resultat antas vara utdelning (vanligaste fallet).
const balanserasINyRakning = aretsRes.ib
const balanseratResidual = balanserat.ub - (balanserat.ib + balanserasINyRakning)
const utdelning = balanseratResidual < 0 ? -balanseratResidual : 0
const ovrigForandringBalanserat = balanseratResidual > 0 ? balanseratResidual : 0
const egetKapital: EgetKapitalForandring = {
aktiekapital,
balanseratResultat: balanserat,
aretsResultat: aretsRes,
totalt,
ovrigaPoster,
balanserasINyRakning,
utdelning,
forandringAktiekapital: aktiekapital.ub - aktiekapital.ib,
ovrigForandringBalanserat,
aretsResultatRorelse: aretsRes.ub,
}
// ---- resultatdisposition --------------------------------------------------
// BalanseratResultat is tagged in BR and the eget kapital-table for the
// same context — the disposition row must carry the identical value
// (TA §2.7.3), so fri överkursfond (2097) is its own row tagged with the
// separate Overkursfond concept instead of being folded into balanserat.
const proposedDividend = Math.max(0, Math.round(options.proposedDividend ?? 0))
const dispBalanserat = br['BalanseratResultat']?.current ?? 0
const dispOverkursfond = br['Overkursfond']?.current ?? 0
const dispArets = br['AretsResultatEgetKapital']?.current ?? 0
const dispSumma = mapping.totals.frittEgetKapital.current
if (proposedDividend > dispSumma) {
warnings.push(
`Föreslagen utdelning (${proposedDividend} kr) överstiger fritt eget kapital (${dispSumma} kr).`,
)
}
const resultatdisposition: Resultatdisposition = {
balanseratResultat: dispBalanserat,
overkursfond: dispOverkursfond,
aretsResultat: dispArets,
summa: dispSumma,
utdelning: proposedDividend,
balanserasINyRakning: dispSumma - proposedDividend,
kommentar: pdfData.forvaltningsberattelse.resultatdisposition || null,
}
// ---- underskrifter ---------------------------------------------------------
// Every signature request becomes a signer row (the board must appear in
// the document), but ONLY actually-signed requests get a date — an unsigned
// request keeps signedDate null. Legal dates are never fabricated: the
// missing date renders as an omitted fact in the preview and preflight 1214
// blocks the submission path until everyone has signed.
const signedRequests = signatureRequests.filter((request) => request.status === 'signed')
const today = options.todayIso ?? new Date().toISOString().slice(0, 10)
const signers: IxbrlSigner[] = signatureRequests.map((request) => {
const { firstName, lastName } = splitName(request.signer_name)
return {
firstName,
lastName,
role: request.role || null,
signedDate: request.signed_at ? request.signed_at.slice(0, 10) : null,
}
})
if (signers.length === 0) {
warnings.push(
'Inga underskrifter är registrerade — årsredovisningen måste skrivas under av styrelsen (och ev. VD) innan inlämning (kontrollera-kod 1107/1201).',
)
}
if (signedRequests.length !== signatureRequests.length) {
warnings.push('Alla underskriftsförfrågningar är inte signerade ännu.')
}
const harVd = signers.some((signer) => /verkställande direktör|^vd$/i.test(signer.role ?? ''))
const latestSignatureDate = signers.reduce<string | null>(
(latest, signer) =>
signer.signedDate !== null && (latest === null || signer.signedDate > latest)
? signer.signedDate
: latest,
null,
)
// ---- fastställelseintyg ----------------------------------------------------
// A missing AGM date is NEVER replaced with today's date — it stays null,
// the document renders a placeholder and preflight 1103 blocks filing
// (mirrors Bolagsverket kontrollera 1103).
const agmDate = pdfData.forvaltningsberattelse.agm_date
if (!agmDate) {
warnings.push(
'Datum för årsstämma saknas — fastställelseintyget kan inte fyllas i (kontrollera-kod 1103).',
)
}
const fallbackSigner = signers[0] ?? { firstName: '', lastName: '', role: null }
const undertecknare = options.undertecknare ?? {
firstName: fallbackSigner.firstName,
lastName: fallbackSigner.lastName,
role: fallbackSigner.role ?? 'Styrelseledamot',
}
if (!undertecknare.firstName) {
warnings.push('Undertecknare av fastställelseintyget saknas (kontrollera-kod 1169).')
}
// ---- allmänt om verksamheten: ensure säte is mentioned ---------------------
let allmant = pdfData.forvaltningsberattelse.description
if (pdfData.company.city && !/säte/i.test(allmant)) {
allmant = `${allmant}\n\nBolaget har sitt säte i ${pdfData.company.city}.`
}
// ---- medelantal anställda ---------------------------------------------------
// Compute BOTH years with the real FTE helper (the same one the PDF note
// uses) over the employees table. The note-prose regex stays only as a
// last-resort fallback when the employees query fails.
let medelantalAnstallda: { current: number; previous: number | null }
const { data: employeeRows, error: employeesError } = await supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId)
if (employeesError) {
medelantalAnstallda = extractMedelantal(pdfData.noter, null)
} else {
const employees = (employeeRows ?? []) as Array<{
employment_start: string
employment_end: string | null
employment_degree: number
}>
medelantalAnstallda = {
current: computeMedelantalAnstallda(employees, period.period_start, period.period_end),
previous: previousPeriod
? computeMedelantalAnstallda(employees, previousPeriod.start, previousPeriod.end)
: null,
}
}
return {
company: {
name: pdfData.company.name,
orgNumber: pdfData.company.org_number,
city: pdfData.company.city,
},
period: { start: period.period_start, end: period.period_end },
previousPeriod,
isFirstFiscalYear: previousPeriod === null,
rr: mapping.rr,
br: mapping.br,
totals: mapping.totals,
forvaltningsberattelse: {
allmantOmVerksamheten: allmant,
vasentligaHandelser: pdfData.forvaltningsberattelse.important_events,
flerarsoversikt,
flerarsPerioder,
egetKapital,
resultatdisposition,
},
noter: pdfData.noter.map((note) => ({ number: note.number, title: note.title, body: note.body })),
medelantalAnstallda,
underskrifter: {
ort: pdfData.company.city ?? '',
dateringsdatum: latestSignatureDate,
signers,
harVd,
},
faststallelseintyg: {
arsstammaDatum: agmDate ?? null,
signerFirstName: undertecknare.firstName,
signerLastName: undertecknare.lastName,
signerRole: undertecknare.role,
genereratDatum: today,
},
programvara: { namn: PROGRAMVARA_NAMN, version: PROGRAMVARA_VERSION },
entryPointId: entryPoint.id,
warnings: [...pdfData.warnings, ...warnings],
}
}
/** Pull the FTE figure out of the medelantal note body ("…uppgått till X."). */
function extractMedelantal(
noter: Array<{ title: string; body: string }>,
previous: number | null,
): { current: number; previous: number | null } {
const note = noter.find((n) => /medelantal.*anst/i.test(n.title))
if (!note) return { current: 0, previous }
const match = note.body.match(/uppgått till\s+([\d,.]+)/i)
if (!match) return { current: 0, previous }
const value = Number(match[1].replace(',', '.'))
return { current: Number.isFinite(value) ? value : 0, previous }
}
+349
View File
@@ -0,0 +1,349 @@
/**
* Inline-XBRL fact emission primitives.
*
* A FactWriter instance is scoped to one document. It
* - validates every emitted fact against the generated taxonomy registry
* (unknown concept / wrong periodType / wrong datatype throws at
* generation time instead of earning a 4001/4008 from Bolagsverket),
* - tracks which contexts and units were actually referenced so the
* ix:header only declares what the document uses (TA §2.17),
* - collects ix:hidden facts (vallistor per TA §2.15/§3.9.3).
*
* Naming follows TA §2.16: contexts period0/period1/…, balans0/balans1/…,
* units SEK / procent / antal-anstallda.
*/
import type { TaxonomyEntryPoint } from '../taxonomy/entry-points'
import {
mustGetConcept,
type TaxonomyRegistry,
type TaxonomyConcept,
} from '../taxonomy/registry'
import { el, escapeText, formatPercentAbs, formatSekAbs, selfClosing, type Attrs } from './xml'
interface ContextDef {
id: string
kind: 'duration' | 'instant'
startDate?: string
endDate?: string
instant?: string
}
const UNIT_MEASURES: Record<string, string> = {
SEK: 'iso4217:SEK',
procent: 'xbrli:pure',
'antal-anstallda': 'se-k2-type:AntalAnstallda',
}
export interface MoneyOptions {
/** Render a presentational minus before the element (costs in RR). The
* fact value itself stays oriented to the concept's natural balance. */
displayMinus?: boolean
/** Show the amount wrapped in a span with this class (sum/total styling). */
spanClass?: string
id?: string
tupleRef?: string
order?: string
}
export class FactWriter {
private readonly contexts = new Map<string, ContextDef>()
private readonly usedContexts = new Set<string>()
private readonly usedUnits = new Set<string>()
private readonly hiddenFacts: string[] = []
private tupleCounter = 0
constructor(
private readonly entryPoint: TaxonomyEntryPoint,
private readonly registry: TaxonomyRegistry,
private readonly entityOrgNumber: string,
) {}
// ---- contexts -----------------------------------------------------------
addDurationContext(id: string, startDate: string, endDate: string): void {
this.contexts.set(id, { id, kind: 'duration', startDate, endDate })
}
addInstantContext(id: string, instant: string): void {
this.contexts.set(id, { id, kind: 'instant', instant })
}
hasContext(id: string): boolean {
return this.contexts.has(id)
}
private resolveContext(id: string, concept: TaxonomyConcept, name: string): void {
const ctx = this.contexts.get(id)
if (!ctx) throw new Error(`Fact ${name}: context "${id}" is not declared`)
if (concept.periodType === 'duration' && ctx.kind !== 'duration') {
throw new Error(`Fact ${name}: duration concept tagged with instant context "${id}"`)
}
if (concept.periodType === 'instant' && ctx.kind !== 'instant') {
throw new Error(`Fact ${name}: instant concept tagged with duration context "${id}"`)
}
this.usedContexts.add(id)
}
private qname(concept: TaxonomyConcept, name: string): string {
if (!this.entryPoint.namespaces[concept.ns]) {
throw new Error(`Fact ${name}: namespace prefix "${concept.ns}" missing from entry point`)
}
return `${concept.ns}:${name}`
}
// ---- numeric facts ------------------------------------------------------
/**
* Whole-SEK monetary fact. `value` is oriented to the concept's natural
* balance (credit-positive for credit concepts, debit-positive for debit
* concepts); negative values get the `sign="-"` attribute per TA §2.10.6.
*/
money(name: string, contextRef: string, value: number, opts: MoneyOptions = {}): string {
const concept = mustGetConcept(this.registry, name)
if (concept.dataType !== 'xbrli:monetaryItemType') {
throw new Error(`Fact ${name}: money() used on ${concept.dataType}`)
}
this.resolveContext(contextRef, concept, name)
this.usedUnits.add('SEK')
const rounded = Math.round(value)
const attrs: Attrs = {
contextRef,
name: this.qname(concept, name),
unitRef: 'SEK',
decimals: '0',
scale: '0',
format: 'ixt:numspacecomma',
sign: rounded < 0 ? '-' : null,
id: opts.id ?? null,
tupleRef: opts.tupleRef ?? null,
order: opts.order ?? null,
}
let markup = el('ix:nonFraction', attrs, formatSekAbs(rounded))
if (opts.spanClass) markup = el('span', { class: opts.spanClass }, markup)
// Presentational minus is an XOR: a cost row (displayMinus) with its
// natural sign shows "−X", but a DEVIATING cost (negative fact value,
// sign="-" — i.e. net income on a cost line) displays positive per the
// RR convention; conversely a deviating income row displays "−X".
if ((opts.displayMinus ?? false) !== rounded < 0) markup = `−${markup}`
return markup
}
/** Percent fact (xbrli:pure) per TA §2.12 — text "35,5", scale −2. */
percent(name: string, contextRef: string, valuePct: number): string {
const concept = mustGetConcept(this.registry, name)
if (concept.dataType !== 'xbrli:pureItemType') {
throw new Error(`Fact ${name}: percent() used on ${concept.dataType}`)
}
this.resolveContext(contextRef, concept, name)
this.usedUnits.add('procent')
const attrs: Attrs = {
contextRef,
name: this.qname(concept, name),
unitRef: 'procent',
decimals: '3',
scale: '-2',
format: 'ixt:numspacecomma',
sign: valuePct < 0 ? '-' : null,
}
const markup = el('ix:nonFraction', attrs, formatPercentAbs(valuePct))
return valuePct < 0 ? `−${markup}` : markup
}
/** Antal-fact (medelantal anställda) per TA §2.14, one decimal. */
antalAnstallda(name: string, contextRef: string, value: number): string {
const concept = mustGetConcept(this.registry, name)
this.resolveContext(contextRef, concept, name)
this.usedUnits.add('antal-anstallda')
const isWhole = Number.isInteger(value)
return el(
'ix:nonFraction',
{
contextRef,
name: this.qname(concept, name),
unitRef: 'antal-anstallda',
decimals: isWhole ? '0' : '1',
scale: '0',
format: 'ixt:numspacecomma',
},
isWhole ? String(value) : value.toFixed(1).replace('.', ','),
)
}
// ---- non-numeric facts --------------------------------------------------
/** Plain-text fact; content is escaped. */
textPlain(
name: string,
contextRef: string,
content: string,
opts: { id?: string; tupleRef?: string; order?: string; continuedAt?: string } = {},
): string {
return this.nonNumeric(name, contextRef, escapeText(content), opts)
}
/** Fact wrapping pre-built XHTML (e.g. <p>…</p> paragraphs). */
textHtml(
name: string,
contextRef: string,
innerXhtml: string,
opts: { id?: string; continuedAt?: string } = {},
): string {
return this.nonNumeric(name, contextRef, innerXhtml, opts)
}
/** ISO date fact (TA §2.11 format YYYY-MM-DD — no format attribute). */
date(
name: string,
contextRef: string,
isoDate: string,
opts: { id?: string; tupleRef?: string; order?: string } = {},
): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) {
throw new Error(`Fact ${name}: "${isoDate}" is not an ISO date`)
}
return this.nonNumeric(name, contextRef, isoDate, opts)
}
private nonNumeric(
name: string,
contextRef: string,
inner: string,
opts: { id?: string; tupleRef?: string; order?: string; continuedAt?: string },
): string {
const concept = mustGetConcept(this.registry, name)
if (concept.kind !== 'item') throw new Error(`Fact ${name}: is a tuple, not an item`)
this.resolveContext(contextRef, concept, name)
return el(
'ix:nonNumeric',
{
contextRef,
name: this.qname(concept, name),
id: opts.id ?? null,
tupleRef: opts.tupleRef ?? null,
order: opts.order ?? null,
continuedAt: opts.continuedAt ?? null,
},
inner,
)
}
// ---- vallistor (hidden enumeration facts, TA §2.15 / §3.9.3) ------------
hiddenEnum(name: string, contextRef: string, memberQName: string): void {
const concept = mustGetConcept(this.registry, name)
this.resolveContext(contextRef, concept, name)
const memberLocal = memberQName.split(':')[1]
if (memberLocal) {
// Members live in the registry too (se-mem-base) — validate when known.
const member = this.registry.concepts[memberLocal]
if (!member) throw new Error(`Vallista ${name}: unknown member ${memberQName}`)
}
this.hiddenFacts.push(
el('ix:nonNumeric', { name: this.qname(concept, name), contextRef }, escapeText(memberQName)),
)
}
/** Hidden plain fact (räkenskapsårets första/sista dag in allmän info). */
hiddenDate(name: string, contextRef: string, isoDate: string): void {
this.hiddenFacts.push(this.date(name, contextRef, isoDate))
}
/** Hidden boolean fact (e.g. ArsredovisningEjTaggadInformation, TA §2.22). */
hiddenBoolean(name: string, contextRef: string, value: boolean): void {
this.hiddenFacts.push(this.nonNumeric(name, contextRef, value ? 'true' : 'false', {}))
}
/** Hidden tuple + members (avskrivningsprincip notes etc.). */
hiddenTuple(tupleName: string, members: Array<{ name: string; context: string; value: string }>): void {
const tupleId = this.declareTupleId(tupleName)
const parts: string[] = [this.tupleDeclaration(tupleName, tupleId)]
members.forEach((member, index) => {
parts.push(
this.textPlain(member.name, member.context, member.value, {
tupleRef: tupleId,
order: `${index + 1}.0`,
}),
)
})
this.hiddenFacts.push(parts.join('\n'))
}
// ---- tuples --------------------------------------------------------------
declareTupleId(tupleName: string): string {
const tuple = this.registry.tuples[tupleName]
if (!tuple) throw new Error(`Tuple ${tupleName} not in taxonomy registry`)
this.tupleCounter += 1
return `${tupleName}${this.tupleCounter}`
}
tupleDeclaration(tupleName: string, tupleId: string): string {
const tuple = this.registry.tuples[tupleName]
if (!tuple) throw new Error(`Tuple ${tupleName} not in taxonomy registry`)
return selfClosing('ix:tuple', { name: `${tuple.ns}:${tupleName}`, tupleID: tupleId })
}
// ---- header assembly -----------------------------------------------------
/**
* Render the full ix:header (hidden + references + resources). Call after
* the body has been generated so only referenced contexts/units exist.
*/
renderHeader(): string {
const hidden =
this.hiddenFacts.length > 0 ? el('ix:hidden', {}, this.hiddenFacts.join('\n')) : ''
const references = el(
'ix:references',
{},
this.entryPoint.schemaRefs
.map((href) => selfClosing('link:schemaRef', { 'xlink:type': 'simple', 'xlink:href': href }))
.join('\n'),
)
const contextXml: string[] = []
for (const id of [...this.usedContexts].sort()) {
const ctx = this.contexts.get(id)
if (!ctx) continue
const period =
ctx.kind === 'duration'
? el(
'xbrli:period',
{},
el('xbrli:startDate', {}, ctx.startDate ?? '') +
el('xbrli:endDate', {}, ctx.endDate ?? ''),
)
: el('xbrli:period', {}, el('xbrli:instant', {}, ctx.instant ?? ''))
contextXml.push(
el(
'xbrli:context',
{ id },
el(
'xbrli:entity',
{},
el(
'xbrli:identifier',
{ scheme: 'http://www.bolagsverket.se' },
escapeText(this.entityOrgNumber),
),
) + period,
),
)
}
const unitXml: string[] = []
for (const unitId of [...this.usedUnits].sort()) {
unitXml.push(
el('xbrli:unit', { id: unitId }, el('xbrli:measure', {}, UNIT_MEASURES[unitId])),
)
}
const resources = el('ix:resources', {}, contextXml.join('\n') + '\n' + unitXml.join('\n'))
return el(
'div',
{ style: 'display:none' },
el('ix:header', {}, [hidden, references, resources].filter(Boolean).join('\n')),
)
}
}
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
/**
* Minimal XML/XHTML emission helpers for the iXBRL generator.
*
* Open decision #3 in the implementation plan (React renderToStaticMarkup vs
* dedicated builder) is resolved in favour of a dedicated builder: TA §3.2
* requires *valid XHTML* with only the five XML escape entities, and React's
* HTML serializer makes no such guarantee (named entities, void-element
* forms, attribute quirks). A hand-rolled escaper keeps the output auditable
* byte-for-byte against the official examples.
*/
/** Escape text content using only the five XML entities (TA §3.2.4). */
export function escapeText(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/** Escape an attribute value (double-quoted attributes). */
export function escapeAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export type Attrs = Record<string, string | number | null | undefined>
export function attrString(attrs: Attrs): string {
const parts: string[] = []
for (const [key, value] of Object.entries(attrs)) {
if (value === null || value === undefined) continue
parts.push(`${key}="${escapeAttr(String(value))}"`)
}
return parts.length > 0 ? ' ' + parts.join(' ') : ''
}
export function el(tag: string, attrs: Attrs, children: string): string {
return `<${tag}${attrString(attrs)}>${children}</${tag}>`
}
export function selfClosing(tag: string, attrs: Attrs): string {
return `<${tag}${attrString(attrs)}/>`
}
/**
* Turn user-authored multi-line text into XHTML paragraphs. Blank lines split
* paragraphs; single newlines become <br/>. All content is escaped.
*/
export function paragraphs(text: string, className?: string): string {
const classAttr = className ? ` class="${escapeAttr(className)}"` : ''
return text
.split(/\r?\n\s*\r?\n/)
.map((block) => block.trim())
.filter((block) => block.length > 0)
.map(
(block) =>
`<p${classAttr}>${block
.split(/\r?\n/)
.map((line) => escapeText(line))
.join('<br/>')}</p>`,
)
.join('\n')
}
/**
* Format a whole-SEK amount for ixt:numspacecomma — groups of three digits
* separated by REGULAR spaces (U+0020; NBSP fails the transform regex).
* The sign is never part of the transformed text — negative handling lives
* on the ix:nonFraction `sign` attribute / presentational minus outside.
*/
export function formatSekAbs(value: number): string {
const abs = Math.abs(Math.round(value))
return abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
}
/** Percent with one decimal for ixt:numspacecomma ("35,5"). */
export function formatPercentAbs(value: number): string {
const abs = Math.abs(value)
return abs.toFixed(1).replace('.', ',')
}
+840
View File
@@ -0,0 +1,840 @@
/**
* BAS trial balance → K2 risbs concept amounts.
*
* Maps account-level closing balances (current + previous fiscal year) onto
* the K2 AB `risbs` uppställningsform (full kostnadsslagsindelad RR + full
* BR). Account ranges follow BAS 2025/2026 as shipped in
* lib/bookkeeping/bas-data/ and are cross-checked against the INK2R mappings
* in lib/reports/ink2/ink2-engine.ts (same ÅRL structure, coarser posts).
*
* Sign conventions: every produced amount is oriented to the concept's
* natural balance — credit-balance concepts are positive when the underlying
* accounts carry a net credit; debit concepts positive on net debit. The
* document layer adds presentational minuses for cost rows and `sign="-"`
* for genuinely deviating values (TA §2.10.6).
*/
import type { ConceptAmount, ConceptAmounts } from './types'
export interface TrialBalanceRowLike {
account_number: string
account_name: string
closing_debit: number
closing_credit: number
}
/**
* Per-year trial balance pair. The year-end closing entry (source_type
* 'year_end') zeroes every class 3–8 account into 2099, so a single TB can
* never serve both statements:
* - `full` (including the closing entry) carries the booked 2099 and the
* correct equity — it drives the BR concepts.
* - `preClosing` (generateTrialBalance with excludeYearEndClosing: true)
* still has the RR accounts open — it drives the RR concepts.
* Mirrors how lib/reports' generateIncomeStatement/generateBalanceSheet split
* the same source.
*/
export interface TrialBalancePair {
full: TrialBalanceRowLike[]
preClosing: TrialBalanceRowLike[]
}
interface Range {
start: string
end: string
}
interface PostMapping {
concept: string
/** Orientation of the produced amount. */
balance: 'debit' | 'credit'
ranges: Range[]
}
const r = (start: string, end: string): Range => ({ start, end })
/** RR — kostnadsslagsindelad (risbs), in uppställningsform order. */
export const K2_RR_MAPPINGS: PostMapping[] = [
{ concept: 'Nettoomsattning', balance: 'credit', ranges: [r('3000', '3799')] },
{
concept: 'ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning',
balance: 'credit',
// Lagerförändring for own production + pågående arbeten. Changes in
// råvarulager (4910–4929) belong to RavarorFornodenheterKostnader and
// handelsvaror (4960–4969) to HandelsvarorKostnader per K2 RR.
ranges: [r('4930', '4959'), r('4970', '4999')],
},
{ concept: 'AktiveratArbeteEgenRakning', balance: 'credit', ranges: [r('3800', '3899')] },
{ concept: 'OvrigaRorelseintakter', balance: 'credit', ranges: [r('3900', '3999')] },
{
concept: 'RavarorFornodenheterKostnader',
balance: 'debit',
ranges: [r('4000', '4599'), r('4700', '4899'), r('4910', '4929')],
},
{
concept: 'HandelsvarorKostnader',
balance: 'debit',
ranges: [r('4600', '4699'), r('4960', '4969')],
},
{ concept: 'OvrigaExternaKostnader', balance: 'debit', ranges: [r('5000', '6999')] },
{ concept: 'Personalkostnader', balance: 'debit', ranges: [r('7000', '7699')] },
{
concept: 'AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('7800', '7899')],
},
{
concept: 'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
balance: 'debit',
ranges: [r('7700', '7799')],
},
{ concept: 'OvrigaRorelsekostnader', balance: 'debit', ranges: [r('7900', '7999')] },
{ concept: 'ResultatAndelarKoncernforetag', balance: 'credit', ranges: [r('8000', '8099')] },
{
concept: 'ResultatAndelarIntresseforetagGemensamtStyrda',
balance: 'credit',
ranges: [r('8100', '8199')],
},
{
concept: 'ResultatOvrigaforetagAgarintresse',
balance: 'credit',
ranges: [r('8200', '8269')],
},
{
concept: 'ResultatOvrigaFinansiellaAnlaggningstillgangar',
balance: 'credit',
ranges: [r('8270', '8299')],
},
{
concept: 'OvrigaRanteintakterLiknandeResultatposter',
balance: 'credit',
ranges: [r('8300', '8399')],
},
{
concept: 'NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar',
balance: 'debit',
ranges: [r('8500', '8599')],
},
{
concept: 'RantekostnaderLiknandeResultatposter',
balance: 'debit',
ranges: [r('8400', '8499')],
},
{ concept: 'ErhallnaKoncernbidrag', balance: 'credit', ranges: [r('8820', '8829')] },
{ concept: 'LamnadeKoncernbidrag', balance: 'debit', ranges: [r('8830', '8839')] },
{ concept: 'ForandringPeriodiseringsfond', balance: 'credit', ranges: [r('8810', '8819')] },
{ concept: 'ForandringOveravskrivningar', balance: 'credit', ranges: [r('8850', '8859')] },
{
concept: 'OvrigaBokslutsdispositioner',
balance: 'credit',
ranges: [r('8840', '8849'), r('8860', '8899')],
},
{ concept: 'SkattAretsResultat', balance: 'debit', ranges: [r('8900', '8949')] },
{ concept: 'OvrigaSkatter', balance: 'debit', ranges: [r('8950', '8989')] },
]
/** BR — full balansräkning (risbs), in uppställningsform order. */
export const K2_BR_MAPPINGS: PostMapping[] = [
{ concept: 'TecknatEjInbetaltKapital', balance: 'debit', ranges: [r('1690', '1699')] },
// Immateriella anläggningstillgångar
{
concept: 'KoncessionerPatentLicenserVarumarkenLiknandeRattigheter',
balance: 'debit',
ranges: [r('1000', '1059'), r('1090', '1099')],
},
{ concept: 'HyresratterLiknandeRattigheter', balance: 'debit', ranges: [r('1060', '1069')] },
{ concept: 'Goodwill', balance: 'debit', ranges: [r('1070', '1079')] },
{
concept: 'ForskottImmateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('1080', '1089')],
},
// Materiella anläggningstillgångar
{
concept: 'ByggnaderMark',
balance: 'debit',
ranges: [r('1100', '1119'), r('1130', '1179'), r('1190', '1199')],
},
{
concept: 'MaskinerAndraTekniskaAnlaggningar',
balance: 'debit',
ranges: [r('1210', '1219')],
},
{
concept: 'InventarierVerktygInstallationer',
balance: 'debit',
ranges: [r('1220', '1279')],
},
{
concept: 'ForbattringsutgifterAnnansFastighet',
balance: 'debit',
ranges: [r('1120', '1129')],
},
{
concept: 'OvrigaMateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('1290', '1299')],
},
{
concept: 'PagaendeNyanlaggningarForskottMateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('1180', '1189'), r('1280', '1289')],
},
// Finansiella anläggningstillgångar
{ concept: 'AndelarKoncernforetag', balance: 'debit', ranges: [r('1310', '1319')] },
{
concept: 'FordringarKoncernforetagLangfristiga',
balance: 'debit',
ranges: [r('1320', '1329')],
},
{
concept: 'AndelarIntresseforetagGemensamtStyrdaForetag',
balance: 'debit',
ranges: [r('1330', '1335'), r('1338', '1339')],
},
{
concept: 'FordringarIntresseforetagGemensamtStyrdaForetagLangfristiga',
balance: 'debit',
ranges: [r('1340', '1345'), r('1348', '1349')],
},
{ concept: 'AgarintressenOvrigaForetag', balance: 'debit', ranges: [r('1336', '1337')] },
{
concept: 'FordringarOvrigaForetagAgarintresseLangfristiga',
balance: 'debit',
ranges: [r('1346', '1347')],
},
{
concept: 'AndraLangfristigaVardepappersinnehav',
balance: 'debit',
ranges: [r('1350', '1359'), r('1380', '1389')],
},
{ concept: 'LanDelagareNarstaende', balance: 'debit', ranges: [r('1360', '1369')] },
{
concept: 'AndraLangfristigaFordringar',
balance: 'debit',
ranges: [r('1370', '1379'), r('1390', '1399')],
},
// Varulager m.m.
{ concept: 'LagerRavarorFornodenheter', balance: 'debit', ranges: [r('1400', '1439')] },
{ concept: 'LagerVarorUnderTillverkning', balance: 'debit', ranges: [r('1440', '1449')] },
{ concept: 'LagerFardigaVarorHandelsvaror', balance: 'debit', ranges: [r('1450', '1469')] },
{
concept: 'PagaendeArbetenAnnansRakningOmsattningstillgangar',
balance: 'debit',
ranges: [r('1470', '1479')],
},
{ concept: 'ForskottTillLeverantorer', balance: 'debit', ranges: [r('1480', '1489')] },
{ concept: 'OvrigaLagertillgangar', balance: 'debit', ranges: [r('1490', '1499')] },
// Kortfristiga fordringar
{
concept: 'Kundfordringar',
balance: 'debit',
ranges: [r('1500', '1559'), r('1590', '1599')],
},
{
concept: 'FordringarKoncernforetagKortfristiga',
balance: 'debit',
ranges: [r('1560', '1569'), r('1660', '1669')],
},
{
concept: 'FordringarIntresseforetagGemensamtStyrdaForetagKortfristiga',
balance: 'debit',
ranges: [r('1570', '1572'), r('1670', '1672')],
},
{
concept: 'FordringarOvrigaforetagAgarintresseKortfristiga',
balance: 'debit',
ranges: [r('1573', '1579'), r('1673', '1679')],
},
{
concept: 'OvrigaFordringarKortfristiga',
balance: 'debit',
ranges: [r('1580', '1589'), r('1600', '1619'), r('1630', '1659'), r('1680', '1689')],
},
{ concept: 'UpparbetadEjFaktureradIntakt', balance: 'debit', ranges: [r('1620', '1629')] },
{
concept: 'ForutbetaldaKostnaderUpplupnaIntakter',
balance: 'debit',
ranges: [r('1700', '1799')],
},
// Kortfristiga placeringar
{
concept: 'AndelarKoncernforetagKortfristiga',
balance: 'debit',
ranges: [r('1860', '1869')],
},
{
concept: 'OvrigaKortfristigaPlaceringar',
balance: 'debit',
ranges: [r('1800', '1859'), r('1870', '1899')],
},
// Kassa och bank
{ concept: 'KassaBankExklRedovisningsmedel', balance: 'debit', ranges: [r('1900', '1989')] },
{ concept: 'Redovisningsmedel', balance: 'debit', ranges: [r('1990', '1999')] },
// Eget kapital
{ concept: 'Aktiekapital', balance: 'credit', ranges: [r('2080', '2081')] },
{ concept: 'EjRegistreratAktiekapital', balance: 'credit', ranges: [r('2082', '2082')] },
{ concept: 'OverkursfondBunden', balance: 'credit', ranges: [r('2087', '2087')] },
{ concept: 'Uppskrivningsfond', balance: 'credit', ranges: [r('2085', '2085')] },
// 2083/2084 (medlems-/förlagsinsatser) and 2088/2089 (övriga bundna fonder)
// lack own risbs posts for AB — closest bundet-EK post is Reservfond; the
// mapper flags them for review when present.
{
concept: 'Reservfond',
balance: 'credit',
ranges: [r('2083', '2084'), r('2086', '2086'), r('2088', '2089')],
},
{ concept: 'Overkursfond', balance: 'credit', ranges: [r('2097', '2097')] },
{
concept: 'BalanseratResultat',
balance: 'credit',
ranges: [r('2090', '2096'), r('2098', '2098')],
},
{ concept: 'AretsResultatEgetKapital', balance: 'credit', ranges: [r('2099', '2099')] },
// Obeskattade reserver
{ concept: 'Periodiseringsfonder', balance: 'credit', ranges: [r('2100', '2129')] },
{ concept: 'AckumuleradeOveravskrivningar', balance: 'credit', ranges: [r('2150', '2159')] },
{
concept: 'OvrigaObeskattadeReserver',
balance: 'credit',
ranges: [r('2130', '2149'), r('2160', '2199')],
},
// Avsättningar
{
concept: 'AvsattningarPensionerLiknandeForpliktelserEnligtLag',
balance: 'credit',
ranges: [r('2210', '2219')],
},
{
concept: 'OvrigaAvsattningarPensionerLiknandeForpliktelser',
balance: 'credit',
ranges: [r('2220', '2229')],
},
{ concept: 'OvrigaAvsattningar', balance: 'credit', ranges: [r('2230', '2299')] },
// Långfristiga skulder
{ concept: 'Obligationslan', balance: 'credit', ranges: [r('2300', '2329')] },
{ concept: 'CheckrakningskreditLangfristig', balance: 'credit', ranges: [r('2330', '2339')] },
{
concept: 'OvrigaLangfristigaSkulderKreditinstitut',
balance: 'credit',
ranges: [r('2340', '2359')],
},
{ concept: 'SkulderKoncernforetagLangfristiga', balance: 'credit', ranges: [r('2360', '2369')] },
{
concept: 'SkulderIntresseforetagGemensamtStyrdaForetagLangfristiga',
balance: 'credit',
ranges: [r('2370', '2372')],
},
{
concept: 'SkulderOvrigaForetagAgarintresseLangfristiga',
balance: 'credit',
ranges: [r('2373', '2379')],
},
{ concept: 'OvrigaLangfristigaSkulder', balance: 'credit', ranges: [r('2380', '2399')] },
// Kortfristiga skulder — ranges per BAS 2025/2026 as shipped in
// lib/bookkeeping/bas-data/class-2-equity-liabilities.ts (2410 = andra
// kortfristiga låneskulder, 2420 = förskott från kunder, 2430 = pågående
// arbeten, 2450 = fakturerad ej upparbetad, 2460 = koncern, 2470 =
// intresse/gem styrda/ägarintresse, 2480 = kontokredit, 2492 = växelskulder).
{ concept: 'ForskottFranKunder', balance: 'credit', ranges: [r('2420', '2429')] },
{ concept: 'CheckrakningskreditKortfristig', balance: 'credit', ranges: [r('2480', '2489')] },
{
concept: 'OvrigaKortfristigaSkulderKreditinstitut',
balance: 'credit',
ranges: [r('2410', '2419')],
},
{
concept: 'PagaendeArbetenAnnansRakningKortfristigaSkulder',
balance: 'credit',
ranges: [r('2430', '2439')],
},
{ concept: 'FaktureradEjUpparbetadIntakt', balance: 'credit', ranges: [r('2450', '2459')] },
{ concept: 'Leverantorsskulder', balance: 'credit', ranges: [r('2440', '2449')] },
{ concept: 'Vaxelskulder', balance: 'credit', ranges: [r('2492', '2492')] },
{ concept: 'SkulderKoncernforetagKortfristiga', balance: 'credit', ranges: [r('2460', '2469')] },
{
concept: 'SkulderIntresseforetagGemensamtStyrdaForetagKortfristiga',
balance: 'credit',
ranges: [r('2470', '2472')],
},
{
concept: 'SkulderOvrigaForetagAgarintresseKortfristiga',
balance: 'credit',
ranges: [r('2473', '2479')],
},
{ concept: 'Skatteskulder', balance: 'credit', ranges: [r('2500', '2599')] },
{
concept: 'OvrigaKortfristigaSkulder',
balance: 'credit',
ranges: [r('2400', '2409'), r('2490', '2491'), r('2493', '2499'), r('2600', '2899')],
},
{
concept: 'UpplupnaKostnaderForutbetaldaIntakter',
balance: 'credit',
ranges: [r('2900', '2999')],
},
]
/** Accounts that map to a "nearest" post and deserve a manual-review nudge. */
const RECLASSIFIED_ACCOUNTS: Record<string, string> = {
'2083': 'Medlemsinsatser (2083) redovisas under Reservfond — granska klassificeringen.',
'2084': 'Förlagsinsatser (2084) redovisas under Reservfond — granska klassificeringen.',
'2088': 'Fond för yttre underhåll (2088) redovisas under Reservfond — granska klassificeringen.',
'2089': 'Fond för utvecklingsutgifter (2089) redovisas under Reservfond — granska klassificeringen (K2 tillåter inte aktivering av egenupparbetade utgifter).',
}
export interface K2MappingResult {
rr: ConceptAmounts
br: ConceptAmounts
/** Computed RR subtotals + BR totals, same orientation rules. */
totals: {
rorelseintakter: ConceptAmount
rorelsekostnader: ConceptAmount
rorelseresultat: ConceptAmount
finansiellaPoster: ConceptAmount
resultatEfterFinansiellaPoster: ConceptAmount
bokslutsdispositioner: ConceptAmount
resultatForeSkatt: ConceptAmount
aretsResultat: ConceptAmount
anlaggningstillgangar: ConceptAmount
immateriellaAnlaggningstillgangar: ConceptAmount
materiellaAnlaggningstillgangar: ConceptAmount
finansiellaAnlaggningstillgangar: ConceptAmount
varulager: ConceptAmount
kortfristigaFordringar: ConceptAmount
kortfristigaPlaceringar: ConceptAmount
kassaBank: ConceptAmount
omsattningstillgangar: ConceptAmount
tillgangar: ConceptAmount
bundetEgetKapital: ConceptAmount
frittEgetKapital: ConceptAmount
egetKapital: ConceptAmount
obeskattadeReserver: ConceptAmount
avsattningar: ConceptAmount
langfristigaSkulder: ConceptAmount
kortfristigaSkulder: ConceptAmount
egetKapitalSkulder: ConceptAmount
}
warnings: string[]
/** Accounts with balances that no mapping covered (should be none). */
unmappedAccounts: Array<{ account: string; name: string; balance: number }>
}
function netBalance(row: TrialBalanceRowLike, orientation: 'debit' | 'credit'): number {
const net = row.closing_debit - row.closing_credit
return orientation === 'debit' ? net : -net
}
function inRanges(account: string, ranges: Range[]): boolean {
return ranges.some((range) => account >= range.start && account <= range.end)
}
function sumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
let total = 0
for (const row of rows) {
if (inRanges(row.account_number, mapping.ranges)) {
total += netBalance(row, mapping.balance)
}
}
return Math.round(total)
}
function amount(
mapping: PostMapping,
current: TrialBalanceRowLike[],
previous: TrialBalanceRowLike[] | null,
): ConceptAmount {
return {
current: sumForMapping(current, mapping),
previous: previous ? sumForMapping(previous, mapping) : null,
}
}
function add(a: ConceptAmount, b: ConceptAmount, sign = 1): ConceptAmount {
return {
current: a.current + sign * b.current,
previous:
a.previous === null && b.previous === null
? null
: (a.previous ?? 0) + sign * (b.previous ?? 0),
}
}
const ZERO: ConceptAmount = { current: 0, previous: null }
function sumConcepts(amounts: ConceptAmounts, concepts: string[], signs?: number[]): ConceptAmount {
let total: ConceptAmount = { current: 0, previous: null }
concepts.forEach((concept, index) => {
total = add(total, amounts[concept] ?? ZERO, signs?.[index] ?? 1)
})
return total
}
/**
* Map current + previous trial balance pairs onto the K2 risbs posts.
*
* RR concepts come from the pre-closing TB (year-end closing excluded — the
* closing entry zeroes class 3–8); BR concepts come from the full TB (the
* closing entry books 2099). See TrialBalancePair.
*
* `previous = null` → first fiscal year (jämförelsesiffror omitted,
* which kontrollera 3006/3007 accepts only for year one).
*/
export function mapTrialBalancesToK2(
current: TrialBalancePair,
previous: TrialBalancePair | null,
): K2MappingResult {
const warnings: string[] = []
const rr: ConceptAmounts = {}
const br: ConceptAmounts = {}
for (const mapping of K2_RR_MAPPINGS) {
rr[mapping.concept] = amount(mapping, current.preClosing, previous?.preClosing ?? null)
}
for (const mapping of K2_BR_MAPPINGS) {
br[mapping.concept] = amount(mapping, current.full, previous?.full ?? null)
}
// Reclassification + unmapped sweep over balance-carrying accounts. Both TB
// variants are swept: the full TB exposes unmapped BR accounts, the
// pre-closing TB exposes unmapped RR accounts (zeroed in the full TB).
const allMappings = [...K2_RR_MAPPINGS, ...K2_BR_MAPPINGS]
const unmappedAccounts: K2MappingResult['unmappedAccounts'] = []
const seenReclass = new Set<string>()
for (const rows of [
current.full,
current.preClosing,
previous?.full ?? [],
previous?.preClosing ?? [],
]) {
for (const row of rows) {
const balance = Math.round(row.closing_debit - row.closing_credit)
if (balance === 0) continue
const reclass = RECLASSIFIED_ACCOUNTS[row.account_number]
if (reclass && !seenReclass.has(row.account_number)) {
seenReclass.add(row.account_number)
warnings.push(reclass)
}
const covered = allMappings.some((mapping) => inRanges(row.account_number, mapping.ranges))
if (!covered && !unmappedAccounts.some((u) => u.account === row.account_number)) {
unmappedAccounts.push({ account: row.account_number, name: row.account_name, balance })
}
}
}
for (const u of unmappedAccounts) {
warnings.push(
`Konto ${u.account} (${u.name}) med saldo ${u.balance} kr täcks inte av K2-mappningen — beloppet saknas i årsredovisningen.`,
)
}
let totals = computeTotals(rr, br)
// ---- öre-rounding residual smoothing ------------------------------------
// Every tagged post is independently rounded to whole SEK, so the sum of
// rounded posts can drift by ±1 kr from the rounded exact total even though
// the underlying trial balance ties to the öre. Bolagsverket compares the
// tagged totals exactly (kontrollera 3005), so a ±1 kr residual is
// distributed back into a line item instead of tolerated. Deterministic
// rule, per year:
// - BR: the residual (Tillgångar − Eget kapital och skulder) is added to
// the largest post (by absolute value) on the equity/liabilities side,
// excluding AretsResultatEgetKapital, whose value must stay equal to
// the booked 2099 / RR result (ties broken toward the LATER post in
// the uppställningsform, so liabilities win over aktiekapital).
// - RR: the residual (RR-resultat − konto 2099) is absorbed by the
// largest RR post: cost posts are increased by the residual, income
// posts decreased (ties broken toward the EARLIER post).
// Residuals beyond ±1 kr are real bookkeeping errors and are left for the
// exact balance checks below.
let smoothedAny = false
for (const field of ['current', 'previous'] as const) {
if (field === 'previous' && previous === null) continue
const rrSmoothed = smoothRrResidual(rr, br, totals, field)
const brSmoothed = smoothBrResidual(br, totals, field)
smoothedAny = smoothedAny || rrSmoothed || brSmoothed
}
if (smoothedAny) totals = computeTotals(rr, br)
// Internal consistency: the RR result must equal BR 2099 (årets resultat)
// EXACTLY — if the year-end closing hasn't booked the result yet, warn
// (the BR will not balance against RR otherwise). Rounding residuals were
// smoothed above, so any remaining difference is a data problem.
const brResult = br['AretsResultatEgetKapital'] ?? ZERO
if (totals.aretsResultat.current !== brResult.current) {
warnings.push(
`Årets resultat enligt resultaträkningen (${totals.aretsResultat.current} kr) stämmer inte med konto 2099 (${brResult.current} kr). Kontrollera att bokslutet är genomfört (resultatdisposition bokad).`,
)
}
if (totals.tillgangar.current !== totals.egetKapitalSkulder.current) {
warnings.push(
`Balansräkningen balanserar inte: Summa tillgångar ${totals.tillgangar.current} kr ≠ Summa eget kapital och skulder ${totals.egetKapitalSkulder.current} kr (kontrollera-kod 3005).`,
)
}
return { rr, br, totals, warnings, unmappedAccounts }
}
function pickLargestConcept(
amounts: ConceptAmounts,
mappings: PostMapping[],
field: 'current' | 'previous',
exclude: ReadonlySet<string>,
tieBreak: 'first' | 'last',
): string | null {
let best: string | null = null
let bestAbs = -1
for (const mapping of mappings) {
if (exclude.has(mapping.concept)) continue
const value = amounts[mapping.concept]?.[field]
if (value === null || value === undefined || value === 0) continue
const abs = Math.abs(value)
if (abs > bestAbs || (abs === bestAbs && tieBreak === 'last')) {
best = mapping.concept
bestAbs = abs
}
}
return best
}
function adjustConcept(
amounts: ConceptAmounts,
concept: string,
field: 'current' | 'previous',
delta: number,
): void {
const existing = amounts[concept] ?? { current: 0, previous: null }
amounts[concept] = { ...existing, [field]: (existing[field] ?? 0) + delta }
}
/** Absorb a ±1 kr rounding residual between the RR result and BR 2099. */
function smoothRrResidual(
rr: ConceptAmounts,
br: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
const target = br['AretsResultatEgetKapital']?.[field]
const result = totals.aretsResultat[field]
if (target === null || target === undefined || result === null) return false
const diff = result - target
if (diff === 0 || Math.abs(diff) > 1) return false
const concept = pickLargestConcept(rr, K2_RR_MAPPINGS, field, new Set(), 'first')
if (!concept) return false
const balance = K2_RR_MAPPINGS.find((mapping) => mapping.concept === concept)?.balance
// Debit (cost) posts enter the result with weight −1, credit (income)
// posts with +1 — adjust so the recomputed result lands on the 2099 value.
adjustConcept(rr, concept, field, balance === 'debit' ? diff : -diff)
return true
}
/** Equity/liability-side posts (everything from Aktiekapital onwards). */
const EQ_LIAB_MAPPINGS = K2_BR_MAPPINGS.slice(
K2_BR_MAPPINGS.findIndex((mapping) => mapping.concept === 'Aktiekapital'),
)
/** Absorb a ±1 kr rounding residual between the two BR sides. */
function smoothBrResidual(
br: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
const assets = totals.tillgangar[field]
const eqLiab = totals.egetKapitalSkulder[field]
if (assets === null || eqLiab === null) return false
const diff = assets - eqLiab
if (diff === 0 || Math.abs(diff) > 1) return false
const concept =
pickLargestConcept(br, EQ_LIAB_MAPPINGS, field, new Set(['AretsResultatEgetKapital']), 'last') ??
'BalanseratResultat'
// All equity/liability posts are credit-oriented: adding the residual
// raises the eget kapital och skulder side to match Tillgångar.
adjustConcept(br, concept, field, diff)
return true
}
function computeTotals(rr: ConceptAmounts, br: ConceptAmounts): K2MappingResult['totals'] {
// ---- RR subtotals (credit-positive orientation) ----
const rorelseintakter = sumConcepts(rr, [
'Nettoomsattning',
'ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning',
'AktiveratArbeteEgenRakning',
'OvrigaRorelseintakter',
])
const rorelsekostnader = sumConcepts(rr, [
'RavarorFornodenheterKostnader',
'HandelsvarorKostnader',
'OvrigaExternaKostnader',
'Personalkostnader',
'AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar',
'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
'OvrigaRorelsekostnader',
])
const rorelseresultat = add(rorelseintakter, rorelsekostnader, -1)
const finansiellaPoster = sumConcepts(
rr,
[
'ResultatAndelarKoncernforetag',
'ResultatAndelarIntresseforetagGemensamtStyrda',
'ResultatOvrigaforetagAgarintresse',
'ResultatOvrigaFinansiellaAnlaggningstillgangar',
'OvrigaRanteintakterLiknandeResultatposter',
'NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar',
'RantekostnaderLiknandeResultatposter',
],
[1, 1, 1, 1, 1, -1, -1],
)
const resultatEfterFinansiellaPoster = add(rorelseresultat, finansiellaPoster)
const bokslutsdispositioner = sumConcepts(
rr,
[
'ErhallnaKoncernbidrag',
'LamnadeKoncernbidrag',
'ForandringPeriodiseringsfond',
'ForandringOveravskrivningar',
'OvrigaBokslutsdispositioner',
],
[1, -1, 1, 1, 1],
)
const resultatForeSkatt = add(resultatEfterFinansiellaPoster, bokslutsdispositioner)
const skatter = sumConcepts(rr, ['SkattAretsResultat', 'OvrigaSkatter'])
const aretsResultat = add(resultatForeSkatt, skatter, -1)
// ---- BR totals ----
const immateriella = sumConcepts(br, [
'KoncessionerPatentLicenserVarumarkenLiknandeRattigheter',
'HyresratterLiknandeRattigheter',
'Goodwill',
'ForskottImmateriellaAnlaggningstillgangar',
])
const materiella = sumConcepts(br, [
'ByggnaderMark',
'MaskinerAndraTekniskaAnlaggningar',
'InventarierVerktygInstallationer',
'ForbattringsutgifterAnnansFastighet',
'OvrigaMateriellaAnlaggningstillgangar',
'PagaendeNyanlaggningarForskottMateriellaAnlaggningstillgangar',
])
const finansiella = sumConcepts(br, [
'AndelarKoncernforetag',
'FordringarKoncernforetagLangfristiga',
'AndelarIntresseforetagGemensamtStyrdaForetag',
'FordringarIntresseforetagGemensamtStyrdaForetagLangfristiga',
'AgarintressenOvrigaForetag',
'FordringarOvrigaForetagAgarintresseLangfristiga',
'AndraLangfristigaVardepappersinnehav',
'LanDelagareNarstaende',
'AndraLangfristigaFordringar',
])
const anlaggningstillgangar = add(add(immateriella, materiella), finansiella)
const varulager = sumConcepts(br, [
'LagerRavarorFornodenheter',
'LagerVarorUnderTillverkning',
'LagerFardigaVarorHandelsvaror',
'PagaendeArbetenAnnansRakningOmsattningstillgangar',
'ForskottTillLeverantorer',
'OvrigaLagertillgangar',
])
const kortfristigaFordringar = sumConcepts(br, [
'Kundfordringar',
'FordringarKoncernforetagKortfristiga',
'FordringarIntresseforetagGemensamtStyrdaForetagKortfristiga',
'FordringarOvrigaforetagAgarintresseKortfristiga',
'OvrigaFordringarKortfristiga',
'UpparbetadEjFaktureradIntakt',
'ForutbetaldaKostnaderUpplupnaIntakter',
])
const kortfristigaPlaceringar = sumConcepts(br, [
'AndelarKoncernforetagKortfristiga',
'OvrigaKortfristigaPlaceringar',
])
const kassaBank = sumConcepts(br, ['KassaBankExklRedovisningsmedel', 'Redovisningsmedel'])
const omsattningstillgangar = add(
add(varulager, kortfristigaFordringar),
add(kortfristigaPlaceringar, kassaBank),
)
const tillgangar = add(
add(br['TecknatEjInbetaltKapital'] ?? ZERO, anlaggningstillgangar),
omsattningstillgangar,
)
const bundetEgetKapital = sumConcepts(br, [
'Aktiekapital',
'EjRegistreratAktiekapital',
'OverkursfondBunden',
'Uppskrivningsfond',
'Reservfond',
])
const frittEgetKapital = sumConcepts(br, [
'Overkursfond',
'BalanseratResultat',
'AretsResultatEgetKapital',
])
const egetKapital = add(bundetEgetKapital, frittEgetKapital)
const obeskattadeReserver = sumConcepts(br, [
'Periodiseringsfonder',
'AckumuleradeOveravskrivningar',
'OvrigaObeskattadeReserver',
])
const avsattningar = sumConcepts(br, [
'AvsattningarPensionerLiknandeForpliktelserEnligtLag',
'OvrigaAvsattningarPensionerLiknandeForpliktelser',
'OvrigaAvsattningar',
])
const langfristigaSkulder = sumConcepts(br, [
'Obligationslan',
'CheckrakningskreditLangfristig',
'OvrigaLangfristigaSkulderKreditinstitut',
'SkulderKoncernforetagLangfristiga',
'SkulderIntresseforetagGemensamtStyrdaForetagLangfristiga',
'SkulderOvrigaForetagAgarintresseLangfristiga',
'OvrigaLangfristigaSkulder',
])
const kortfristigaSkulder = sumConcepts(br, [
'ForskottFranKunder',
'CheckrakningskreditKortfristig',
'OvrigaKortfristigaSkulderKreditinstitut',
'PagaendeArbetenAnnansRakningKortfristigaSkulder',
'FaktureradEjUpparbetadIntakt',
'Leverantorsskulder',
'Vaxelskulder',
'SkulderKoncernforetagKortfristiga',
'SkulderIntresseforetagGemensamtStyrdaForetagKortfristiga',
'SkulderOvrigaForetagAgarintresseKortfristiga',
'Skatteskulder',
'OvrigaKortfristigaSkulder',
'UpplupnaKostnaderForutbetaldaIntakter',
])
const egetKapitalSkulder = add(
add(add(egetKapital, obeskattadeReserver), add(avsattningar, langfristigaSkulder)),
kortfristigaSkulder,
)
return {
rorelseintakter,
rorelsekostnader,
rorelseresultat,
finansiellaPoster,
resultatEfterFinansiellaPoster,
bokslutsdispositioner,
resultatForeSkatt,
aretsResultat,
anlaggningstillgangar,
immateriellaAnlaggningstillgangar: immateriella,
materiellaAnlaggningstillgangar: materiella,
finansiellaAnlaggningstillgangar: finansiella,
varulager,
kortfristigaFordringar,
kortfristigaPlaceringar,
kassaBank,
omsattningstillgangar,
tillgangar,
bundetEgetKapital,
frittEgetKapital,
egetKapital,
obeskattadeReserver,
avsattningar,
langfristigaSkulder,
kortfristigaSkulder,
egetKapitalSkulder,
}
}
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import { getRegistry, getConcept, mustGetConcept } from '../registry'
import { getEntryPoint, resolveEntryPoint, K2_AB_RISBS_2024_09_12 } from '../entry-points'
describe('taxonomy registry (k2-ab-2024-09-12)', () => {
const registry = getRegistry('k2-ab-2024-09-12')
it('loads with the expected shape', () => {
expect(registry._meta.taxonomy).toBe('k2-ab')
expect(registry._meta.version).toBe('2024-09-12')
expect(registry._meta.conceptCount).toBeGreaterThan(1000)
expect(registry._meta.tupleCount).toBeGreaterThan(10)
})
it('exposes the RR risbs concepts with correct balance/period attributes', () => {
const netto = mustGetConcept(registry, 'Nettoomsattning')
expect(netto.ns).toBe('se-gen-base')
expect(netto.balance).toBe('credit')
expect(netto.periodType).toBe('duration')
expect(netto.dataType).toBe('xbrli:monetaryItemType')
const personal = mustGetConcept(registry, 'Personalkostnader')
expect(personal.balance).toBe('debit')
const aretsResultat = mustGetConcept(registry, 'AretsResultat')
expect(aretsResultat.balance).toBe('credit')
expect(aretsResultat.sections).toContain('rr-kostnadsslagsindelad')
})
it('exposes the BR totals used by kontrollera rules 3001/3002', () => {
const tillgangar = mustGetConcept(registry, 'Tillgangar')
expect(tillgangar.periodType).toBe('instant')
expect(tillgangar.balance).toBe('debit')
const ekSkulder = mustGetConcept(registry, 'EgetKapitalSkulder')
expect(ekSkulder.periodType).toBe('instant')
expect(ekSkulder.balance).toBe('credit')
})
it('includes the fastställelseintyg concepts from comp-base (se-bol-base)', () => {
for (const name of [
'ArsstammaIntygande',
'IntygandeOriginalInnehall',
'UnderskriftFastallelseintygDatum',
'Arsstamma',
'FaststallelseResultatBalansrakning',
]) {
const concept = mustGetConcept(registry, name)
expect(concept.ns).toBe('se-bol-base')
}
// The signing-date element is instant (tagged against balans0).
expect(mustGetConcept(registry, 'UnderskriftFastallelseintygDatum').periodType).toBe(
'instant',
)
})
it('models the underskrifter tuple with the per-signer date member', () => {
const tuple = registry.tuples['UnderskriftArsredovisningForetradareTuple']
expect(tuple).toBeDefined()
expect(tuple.ns).toBe('se-gaap-ext')
const memberNames = tuple.members.map((m) => m.name)
expect(memberNames).toContain('UnderskriftHandlingTilltalsnamn')
expect(memberNames).toContain('UnderskriftHandlingEfternamn')
expect(memberNames).toContain('UnderskriftHandlingRoll')
// TA §2.9.1: DatumForUndertecknande per signer — element name UndertecknandeDatum.
expect(memberNames).toContain('UndertecknandeDatum')
})
it('exposes vallista concepts and their members', () => {
expect(mustGetConcept(registry, 'SprakHandlingUpprattadList').dataType).toBe(
'enum:enumerationItemType',
)
expect(getConcept(registry, 'SprakSvenskaMember')?.ns).toBe('se-mem-base')
})
it('throws on unknown concepts and registries', () => {
expect(() => mustGetConcept(registry, 'PåhittatBegrepp')).toThrow(/not in taxonomy/)
expect(() => getRegistry('k9-hund')).toThrow(/Unknown taxonomy registry/)
expect(getConcept(registry, 'PåhittatBegrepp')).toBeNull()
})
})
describe('entry points', () => {
it('resolves the K2 AB risbs MVP entry point', () => {
const ep = resolveEntryPoint('k2')
expect(ep).toBe(K2_AB_RISBS_2024_09_12)
expect(ep.schemaRefs[0]).toContain('k2-all/ab/risbs/2024-09-12')
// Fastställelseintyg (certificate of adoption) schema must ride along.
expect(ep.schemaRefs[1]).toContain('coa/rplc/2020-12-01')
expect(getEntryPoint(ep.id)).toBe(ep)
})
it('keeps base-concept namespaces on the 2021-10-31 generation', () => {
const ns = K2_AB_RISBS_2024_09_12.namespaces
expect(ns['se-gen-base']).toBe('http://www.taxonomier.se/se/fr/gen-base/2021-10-31')
expect(ns['se-bol-base']).toBe('http://www.bolagsverket.se/se/fr/comp-base/2020-12-01')
expect(ns['se-gaap-ext']).toBe('http://www.taxonomier.se/se/fr/gaap/gaap-ext/2024-09-12')
})
it('rejects K3 with an actionable message', () => {
expect(() => resolveEntryPoint('k3')).toThrow(/K3/)
})
})
@@ -0,0 +1,90 @@
/**
* Taxonomy entry points accepted by Bolagsverket's inlämningstjänst.
*
* An entry point fixes three things for a generated instance document:
* 1. The schemaRefs (the årsredovisning report + the fastställelseintyg
* "COA" report — certificate of adoption — exactly as in the official
* examples; allowed ÅR/FI/RB combinations per
* dev_docs/bokslut/kombinationer-taxonomirapporter-v1.4-screenshot.png).
* 2. The xmlns prefix → namespace URI map. The 2024-09-12 K2 generation
* still uses the 2021-10-31 base-concept namespaces (se-gen-base,
* se-cd-base, se-mem-base) — verified against the entry-point XSD import
* chain in taxonomi-paket-2024-09-12_rev20250312.zip.
* 3. Which generated concept registry validates the emitted facts.
*
* MVP ships K2 AB `risbs` (full RR + full BR — matches the current PDF
* layout). The other K2 forms and K3 land in M6.
*/
export type Uppstallningsform = 'risbs' | 'risab' | 'raibs' | 'raiab' | 'full'
export interface TaxonomyEntryPoint {
id: string
regelverk: 'K2' | 'K3'
foretagsform: 'AB'
uppstallningsform: Uppstallningsform
/** Version directory of the ÅR taxonomy, e.g. "2024-09-12". */
taxonomyVersion: string
/** Version of the fastställelseintyg (COA) taxonomy. */
faststallelseintygVersion: string
/** link:schemaRef hrefs, in document order. */
schemaRefs: string[]
/** xmlns declarations for the <html> root, prefix → URI. */
namespaces: Record<string, string>
/** Registry id resolved via getRegistry(). */
registryId: string
}
export const K2_AB_RISBS_2024_09_12: TaxonomyEntryPoint = {
id: 'k2-ab-risbs-2024-09-12',
regelverk: 'K2',
foretagsform: 'AB',
uppstallningsform: 'risbs',
taxonomyVersion: '2024-09-12',
faststallelseintygVersion: '2020-12-01',
schemaRefs: [
'http://xbrl.taxonomier.se/se/fr/gaap/k2-all/ab/risbs/2024-09-12/se-k2-ab-risbs-2024-09-12.xsd',
'http://xbrl.taxonomier.se/se/fr/gaap/coa/rplc/2020-12-01/se-coa-rplc-2020-12-01.xsd',
],
namespaces: {
ix: 'http://www.xbrl.org/2013/inlineXBRL',
xbrli: 'http://www.xbrl.org/2003/instance',
link: 'http://www.xbrl.org/2003/linkbase',
xlink: 'http://www.w3.org/1999/xlink',
iso4217: 'http://www.xbrl.org/2003/iso4217',
ixt: 'http://www.xbrl.org/inlineXBRL/transformation/2010-04-20',
'se-gen-base': 'http://www.taxonomier.se/se/fr/gen-base/2021-10-31',
'se-cd-base': 'http://www.taxonomier.se/se/fr/cd-base/2021-10-31',
'se-mem-base': 'http://www.taxonomier.se/se/fr/mem-base/2021-10-31',
'se-bol-base': 'http://www.bolagsverket.se/se/fr/comp-base/2020-12-01',
'se-gaap-ext': 'http://www.taxonomier.se/se/fr/gaap/gaap-ext/2024-09-12',
'se-k2-type': 'http://www.taxonomier.se/se/fr/k2/datatype',
},
registryId: 'k2-ab-2024-09-12',
}
const ENTRY_POINTS: Record<string, TaxonomyEntryPoint> = {
[K2_AB_RISBS_2024_09_12.id]: K2_AB_RISBS_2024_09_12,
}
export function getEntryPoint(id: string): TaxonomyEntryPoint {
const ep = ENTRY_POINTS[id]
if (!ep) {
throw new Error(
`Unknown taxonomy entry point "${id}" — known: ${Object.keys(ENTRY_POINTS).join(', ')}`,
)
}
return ep
}
/**
* Resolve the entry point for a company's filing. Today only K2 AB risbs is
* supported; K3 callers get a descriptive error the UI can surface instead of
* a generated-but-rejectable document.
*/
export function resolveEntryPoint(framework: 'k2' | 'k3'): TaxonomyEntryPoint {
if (framework === 'k2') return K2_AB_RISBS_2024_09_12
throw new Error(
'Digital inlämning stöds ännu inte för K3 — generera PDF eller vänta på K3-stödet (M6).',
)
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
/**
* Typed access to the generated taxonomy concept registry.
*
* The registry JSON is generated from the official element lists by
* scripts/generate-taxonomy-registry.ts — never edit it by hand. CI guards
* staleness via `npm run taxonomy:check`.
*
* Taxonomy versions are data, not code: each version is its own generated
* file selected through lib/bokslut/ixbrl/taxonomy/entry-points.ts, so the
* September 2026 generation (dimensions instead of tuples) can ship as a new
* registry + emitter without touching the K2 2024-09-12 path.
*/
import k2Ab20240912 from './generated/k2-ab-2024-09-12.json'
export interface TaxonomyConcept {
/** Namespace prefix (e.g. "se-gen-base"); URI resolved per entry point. */
ns: string
/** Official Standardrubrik presentation label. */
label: string
abstract: boolean
dataType: string | null
balance: 'debit' | 'credit' | null
periodType: 'duration' | 'instant' | null
kind: 'item' | 'tuple'
sections: string[]
}
export interface TaxonomyTupleMember {
name: string
ns: string
required: boolean
}
export interface TaxonomyRegistry {
_meta: {
taxonomy: string
version: string
revision: string
conceptCount: number
tupleCount: number
}
concepts: Record<string, TaxonomyConcept>
tuples: Record<string, { ns: string; members: TaxonomyTupleMember[] }>
}
const REGISTRIES: Record<string, TaxonomyRegistry> = {
'k2-ab-2024-09-12': k2Ab20240912 as unknown as TaxonomyRegistry,
}
export function getRegistry(id: string): TaxonomyRegistry {
const registry = REGISTRIES[id]
if (!registry) {
throw new Error(
`Unknown taxonomy registry "${id}" — known: ${Object.keys(REGISTRIES).join(', ')}`,
)
}
return registry
}
export function getConcept(
registry: TaxonomyRegistry,
name: string,
): TaxonomyConcept | null {
return registry.concepts[name] ?? null
}
/**
* Lookup that throws on unknown concepts. The document builder uses this for
* every fact it emits, so a typo'd element name fails generation instead of
* producing an instance Bolagsverket rejects with 4001/4008.
*/
export function mustGetConcept(
registry: TaxonomyRegistry,
name: string,
): TaxonomyConcept {
const concept = registry.concepts[name]
if (!concept) {
throw new Error(`Concept "${name}" not in taxonomy ${registry._meta.taxonomy} ${registry._meta.version}`)
}
return concept
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Input model for the iXBRL årsredovisning generator.
*
* This is a separate shape from ArsredovisningData (the PDF model) because
* iXBRL needs concept-keyed amounts for BOTH years (jämförelsesiffror are
* mandatory — kontrollera codes 3006/3007), while the PDF model carries
* label-based single-year lines. The mapper (k2-mapper.ts) produces the
* concept-keyed parts from trial balances; build-input.ts assembles the rest
* from the same sources the PDF uses.
*/
/** Amounts in whole SEK, oriented to the concept's natural balance:
* credit-balance concepts are positive when credit, debit-balance concepts
* positive when debit. Negative = deviates from natural sign (`sign="-"`). */
export interface ConceptAmount {
current: number
previous: number | null
}
export type ConceptAmounts = Record<string, ConceptAmount>
export interface FlerarsRow {
/** Label for the column, e.g. "2025". */
year: string
nettoomsattning: number
resultatEfterFinansiellaPoster: number
/** Percent with one decimal, e.g. 35.5 — null when not computable. */
soliditetPct: number | null
}
export interface EgetKapitalForandring {
/** IB/UB per tagged column, whole SEK (credit-positive). */
aktiekapital: { ib: number; ub: number }
balanseratResultat: { ib: number; ub: number }
aretsResultat: { ib: number; ub: number }
totalt: { ib: number; ub: number }
/** Untagged residual columns (reservfond, överkursfond …), 0 when absent. */
ovrigaPoster: { ib: number; ub: number }
/** Movement rows (whole SEK). */
balanserasINyRakning: number
utdelning: number
forandringAktiekapital: number
/** Balanserat-column residual that is not utdelning/balansering. */
ovrigForandringBalanserat: number
aretsResultatRorelse: number
}
export interface Resultatdisposition {
/** Balanserat resultat ONLY (2090–2096 + 2098) — must stay value-identical
* to the BalanseratResultat fact in BR/eget kapital (TA §2.7.3). */
balanseratResultat: number
/** Fri överkursfond (2097), shown as its own row tagged Overkursfond. */
overkursfond: number
aretsResultat: number
summa: number
utdelning: number
balanserasINyRakning: number
/** Optional styrelsens kommentar (free text from the narrative editor). */
kommentar: string | null
}
export interface IxbrlNote {
number: number
title: string
body: string
}
export interface IxbrlSigner {
firstName: string
lastName: string
/** Visible role label, e.g. "Styrelseledamot", "Verkställande direktör". */
role: string | null
/** ISO date for DatumForUndertecknande (per-signer, TA §2.9.1).
* Null when the signature request has not been signed yet — the date fact
* is then omitted (never fabricated) and preflight 1214 blocks filing. */
signedDate: string | null
}
export interface IxbrlArsredovisningInput {
company: {
name: string
/** Formatted with dash, e.g. "556999-9999". */
orgNumber: string
/** Säte (city) — used in underskrifter and allmänt om verksamheten. */
city: string | null
}
period: { start: string; end: string }
previousPeriod: { start: string; end: string } | null
/** True when this is the company's first fiscal year — jämförelsesiffror
* may then legitimately be absent (3006/3007 exemption). */
isFirstFiscalYear: boolean
/** RR concept-keyed amounts (kostnadsslagsindelad, risbs posts). */
rr: ConceptAmounts
/** BR concept-keyed amounts (risbs posts). */
br: ConceptAmounts
/** Computed subtotals/totals from the mapper (same orientation rules). */
totals: import('./k2-mapper').K2MappingResult['totals']
forvaltningsberattelse: {
allmantOmVerksamheten: string
vasentligaHandelser: string
flerarsoversikt: FlerarsRow[]
/** Period ranges aligned 1:1 with flerarsoversikt rows (newest first).
* Index 0/1 reuse period0/period1; 2/3 get their own contexts. */
flerarsPerioder: Array<{ start: string; end: string }>
egetKapital: EgetKapitalForandring
resultatdisposition: Resultatdisposition
}
noter: IxbrlNote[]
/** Medelantal anställda (FTE) for current + previous year. */
medelantalAnstallda: { current: number; previous: number | null }
underskrifter: {
ort: string
/** Datering av årsredovisning (the day the board fixed the content).
* Tagged only for fiscal years beginning 2024-07-01 or later (element
* list note on UndertecknandeArsredovisningDatum). */
dateringsdatum: string | null
signers: IxbrlSigner[]
/** True when a VD is among the signers — drives FinansiellRapportList. */
harVd: boolean
}
faststallelseintyg: {
/** AGM date — must be > räkenskapsårets sista dag (kontrollera 1101).
* Null when no AGM date is recorded: the document renders a visible
* placeholder instead of a fabricated date and preflight 1103 blocks
* filing (Bolagsverket kontrollera 1103 semantics). */
arsstammaDatum: string | null
/** The företrädare who will sign at Bolagsverket. */
signerFirstName: string
signerLastName: string
signerRole: string
/** Document generation date — Bolagsverket overwrites at actual signing
* (TA §4.4: set today's date). */
genereratDatum: string
}
/** TA §4.3 head metadata. Name standard "<leverantör> - <produkt>". */
programvara: { namn: string; version: string }
/** Entry point id resolved via getEntryPoint (taxonomy version is data). */
entryPointId: string
/** Non-blocking issues collected while building (unmapped accounts etc.). */
warnings: string[]
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Pre-flight validation of an iXBRL årsredovisning — local mirror of the
* Bolagsverket `kontrollera` service (GUIDE.md Appendix E codes).
*
* Runs on the assembled IxbrlArsredovisningInput BEFORE generation/upload so
* the wizard can surface actionable issues without an API round-trip, and so
* self-hosted installs without Bolagsverket credentials still get the checks.
*
* Severity:
* - 'error' → Bolagsverket would reject or föreläggande is near-certain;
* the wizard blocks Skicka in.
* - 'warn' → kontrollera warn-level utfall; filing is allowed
* (GUIDE §4.2.2) but the user should review.
*/
import type { IxbrlArsredovisningInput } from '../types'
export interface PreflightIssue {
/** Bolagsverket kontrollera code where one exists, else our own ACC-xxx. */
code: string
severity: 'error' | 'warn'
message: string
}
export interface PreflightResult {
issues: PreflightIssue[]
errors: PreflightIssue[]
warnings: PreflightIssue[]
ok: boolean
}
type Rule = (input: IxbrlArsredovisningInput, today: string) => PreflightIssue | null
const issue = (code: string, severity: 'error' | 'warn', message: string): PreflightIssue => ({
code,
severity,
message,
})
function monthsBetween(startIso: string, endIso: string): number {
const start = new Date(`${startIso}T00:00:00Z`)
const end = new Date(`${endIso}T00:00:00Z`)
return (
(end.getUTCFullYear() - start.getUTCFullYear()) * 12 +
(end.getUTCMonth() - start.getUTCMonth()) +
(end.getUTCDate() >= start.getUTCDate() ? 0 : -1)
)
}
const RULES: Rule[] = [
// ---- completeness -------------------------------------------------------
(input) =>
input.company.name.trim().length === 0
? issue('1020', 'error', 'Företagsnamnet saknas i årsredovisningen.')
: null,
(input) =>
/^\d{6}-?\d{4}$/.test(input.company.orgNumber.trim())
? null
: issue('1035', 'error', `Organisationsnumret "${input.company.orgNumber}" är inte giltigt (förväntat format NNNNNN-NNNN).`),
(input) =>
input.forvaltningsberattelse.allmantOmVerksamheten.trim().length === 0
? issue('1051', 'error', 'Förvaltningsberättelsen saknas (Allmänt om verksamheten är tom).')
: null,
(input) => {
const hasRr =
Object.values(input.rr).some((a) => a.current !== 0) ||
input.totals.aretsResultat.current !== 0
return hasRr
? null
: issue('1060', 'warn', 'Resultaträkningen verkar sakna belopp — kontrollera att räkenskapsåret innehåller bokförda transaktioner.')
},
(input) => {
const hasBr = input.totals.tillgangar.current !== 0
return hasBr
? null
: issue('1064', 'warn', 'Balansräkningen verkar sakna belopp (Summa tillgångar är 0).')
},
(input) =>
input.underskrifter.signers.length === 0
? issue('1107', 'error', 'Underskrifter saknas — årsredovisningen måste skrivas under av styrelsen (och ev. VD).')
: null,
(input) =>
input.underskrifter.signers.some(
(signer) => !signer.firstName.trim() || !signer.lastName.trim(),
)
? issue('1201', 'error', 'Det saknas för- eller efternamn på den eller de som skrivit under årsredovisningen.')
: null,
(input) =>
input.underskrifter.signers.some((signer) => !signer.signedDate)
? issue('1214', 'error', 'Datum för underskrifter saknas — alla underskrifter måste ha ett datum.')
: null,
(input) =>
!input.faststallelseintyg.signerFirstName.trim() ||
!input.faststallelseintyg.signerLastName.trim()
? issue('1169', 'error', 'Namnförtydligandet saknas i fastställelseintyget (välj undertecknare).')
: null,
(input) =>
input.faststallelseintyg.arsstammaDatum
? null
: issue('1103', 'error', 'Datum för årsstämman saknas i fastställelseintyget.'),
// ---- date ordering ------------------------------------------------------
(input, today) =>
input.period.end >= today
? issue('1015', 'error', `Räkenskapsårets sista dag (${input.period.end}) har inte passerats ännu.`)
: null,
(input) =>
monthsBetween(input.period.start, input.period.end) >= 18
? issue('1046', 'error', `Räkenskapsåret ${input.period.start} – ${input.period.end} är längre än 18 månader.`)
: null,
(input) =>
input.faststallelseintyg.arsstammaDatum &&
input.faststallelseintyg.arsstammaDatum <= input.period.end
? issue('1101', 'error', `Datum för årsstämman (${input.faststallelseintyg.arsstammaDatum}) får inte vara tidigare än eller samma som räkenskapsårets sista dag (${input.period.end}).`)
: null,
(input, today) =>
input.faststallelseintyg.arsstammaDatum !== null &&
input.faststallelseintyg.arsstammaDatum > today
? issue('1178', 'error', `Datum för årsstämman (${input.faststallelseintyg.arsstammaDatum}) får inte vara senare än dagens datum — håll årsstämman innan inlämning.`)
: null,
(input) => {
const bad = input.underskrifter.signers.find(
(signer) => signer.signedDate && signer.signedDate <= input.period.end,
)
return bad
? issue('1114', 'error', `Datum för underskrift (${bad.signedDate}) får inte vara tidigare än eller samma som räkenskapsårets sista dag (${input.period.end}).`)
: null
},
(input) => {
const agm = input.faststallelseintyg.arsstammaDatum
if (!agm) return null
const late = input.underskrifter.signers.find(
(signer) => signer.signedDate && signer.signedDate > agm,
)
return late
? issue('1183', 'error', `Datum för årsstämman (${agm}) är tidigare än styrelsens underskrift (${late.signedDate}).`)
: null
},
(input) => {
const datering = input.underskrifter.dateringsdatum
if (!datering) return null
const earliest = input.underskrifter.signers.reduce<string | null>(
(min, signer) =>
signer.signedDate !== null && (min === null || signer.signedDate < min)
? signer.signedDate
: min,
null,
)
return earliest && datering > earliest
? issue('1232', 'warn', `Datum för årsredovisningen (${datering}) är senare än styrelsens tidigaste underskrift (${earliest}).`)
: null
},
(input) =>
input.faststallelseintyg.arsstammaDatum &&
input.faststallelseintyg.genereratDatum < input.faststallelseintyg.arsstammaDatum
? issue('1165', 'warn', 'Datum för underskrift av fastställelseintyget sätts till genereringsdagen, som ligger före årsstämman — Bolagsverket skriver över datumet vid signering.')
: null,
// ---- balance checks -----------------------------------------------------
// Exact comparisons: Bolagsverket compares the tagged totals exactly, and
// the mapper already absorbs legitimate ±1 kr rounding residuals.
(input) => {
const assets = input.totals.tillgangar.current
const eqLiab = input.totals.egetKapitalSkulder.current
return assets !== eqLiab
? issue('3005', 'error', `"Summa tillgångar" (${assets} kr) och "Summa eget kapital och skulder" (${eqLiab} kr) stämmer inte överens.`)
: null
},
(input) => {
if (input.isFirstFiscalYear) return null
const prev = input.totals.tillgangar.previous
return prev === null
? issue('3006', 'error', 'Jämförelsesiffror saknas i balansräkningen. De behövs om det inte är företagets första räkenskapsår.')
: null
},
(input) => {
if (input.isFirstFiscalYear) return null
const prev = input.totals.aretsResultat.previous
return prev === null
? issue('3007', 'error', 'Jämförelsesiffror saknas i resultaträkningen. De behövs om det inte är företagets första räkenskapsår.')
: null
},
(input) => {
const rrResult = input.totals.aretsResultat.current
const brResult = input.br['AretsResultatEgetKapital']?.current ?? 0
return rrResult !== brResult
? issue('ACC-2099', 'error', `Årets resultat enligt resultaträkningen (${rrResult} kr) stämmer inte med eget kapital-posten Årets resultat (${brResult} kr) — kör bokslutet (resultatdisposition) innan inlämning.`)
: null
},
// ---- resultatdisposition ------------------------------------------------
(input) => {
const rd = input.forvaltningsberattelse.resultatdisposition
return Math.abs(rd.utdelning + rd.balanserasINyRakning - rd.summa) > 1
? issue('ACC-DISP', 'error', `Resultatdispositionen går inte ihop: utdelning (${rd.utdelning}) + balanseras (${rd.balanserasINyRakning}) ≠ summa (${rd.summa}).`)
: null
},
(input) => {
const rd = input.forvaltningsberattelse.resultatdisposition
return rd.summa < 0 && rd.utdelning > 0
? issue('ACC-UTD', 'error', 'Utdelning kan inte föreslås när fritt eget kapital är negativt.')
: null
},
]
export function runPreflightChecks(
input: IxbrlArsredovisningInput,
todayIso?: string,
): PreflightResult {
const today = todayIso ?? new Date().toISOString().slice(0, 10)
const issues: PreflightIssue[] = []
for (const rule of RULES) {
const result = rule(input, today)
if (result) issues.push(result)
}
// Mapper warnings (unmapped accounts, reclassifications) ride along as warn.
for (const warning of input.warnings) {
issues.push(issue('ACC-WARN', 'warn', warning))
}
const errors = issues.filter((item) => item.severity === 'error')
const warnings = issues.filter((item) => item.severity === 'warn')
return { issues, errors, warnings, ok: errors.length === 0 }
}