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
@@ -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('.', ',')
|
||||
}
|
||||
Reference in New Issue
Block a user