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:
co-authored by
Claude Fable 5
parent
8e8b63a200
commit
db8983ba9e
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 & Söner <Test> "AB"')
|
||||
// No raw < survives inside text nodes (only as markup).
|
||||
expect(xhtml).not.toContain('<komponenter>')
|
||||
})
|
||||
})
|
||||
@@ -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(' ')
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** Escape an attribute value (double-quoted attributes). */
|
||||
export function escapeAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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('.', ',')
|
||||
}
|
||||
@@ -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).',
|
||||
)
|
||||
}
|
||||
+13572
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user