Add/bokslut (#718)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Add tests for bookkeeping accruals dissolution and supplier invoices

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-12 16:35:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8e8b63a200
commit db8983ba9e
131 changed files with 33796 additions and 340 deletions
+349
View File
@@ -0,0 +1,349 @@
/**
* Inline-XBRL fact emission primitives.
*
* A FactWriter instance is scoped to one document. It
* - validates every emitted fact against the generated taxonomy registry
* (unknown concept / wrong periodType / wrong datatype throws at
* generation time instead of earning a 4001/4008 from Bolagsverket),
* - tracks which contexts and units were actually referenced so the
* ix:header only declares what the document uses (TA §2.17),
* - collects ix:hidden facts (vallistor per TA §2.15/§3.9.3).
*
* Naming follows TA §2.16: contexts period0/period1/…, balans0/balans1/…,
* units SEK / procent / antal-anstallda.
*/
import type { TaxonomyEntryPoint } from '../taxonomy/entry-points'
import {
mustGetConcept,
type TaxonomyRegistry,
type TaxonomyConcept,
} from '../taxonomy/registry'
import { el, escapeText, formatPercentAbs, formatSekAbs, selfClosing, type Attrs } from './xml'
interface ContextDef {
id: string
kind: 'duration' | 'instant'
startDate?: string
endDate?: string
instant?: string
}
const UNIT_MEASURES: Record<string, string> = {
SEK: 'iso4217:SEK',
procent: 'xbrli:pure',
'antal-anstallda': 'se-k2-type:AntalAnstallda',
}
export interface MoneyOptions {
/** Render a presentational minus before the element (costs in RR). The
* fact value itself stays oriented to the concept's natural balance. */
displayMinus?: boolean
/** Show the amount wrapped in a span with this class (sum/total styling). */
spanClass?: string
id?: string
tupleRef?: string
order?: string
}
export class FactWriter {
private readonly contexts = new Map<string, ContextDef>()
private readonly usedContexts = new Set<string>()
private readonly usedUnits = new Set<string>()
private readonly hiddenFacts: string[] = []
private tupleCounter = 0
constructor(
private readonly entryPoint: TaxonomyEntryPoint,
private readonly registry: TaxonomyRegistry,
private readonly entityOrgNumber: string,
) {}
// ---- contexts -----------------------------------------------------------
addDurationContext(id: string, startDate: string, endDate: string): void {
this.contexts.set(id, { id, kind: 'duration', startDate, endDate })
}
addInstantContext(id: string, instant: string): void {
this.contexts.set(id, { id, kind: 'instant', instant })
}
hasContext(id: string): boolean {
return this.contexts.has(id)
}
private resolveContext(id: string, concept: TaxonomyConcept, name: string): void {
const ctx = this.contexts.get(id)
if (!ctx) throw new Error(`Fact ${name}: context "${id}" is not declared`)
if (concept.periodType === 'duration' && ctx.kind !== 'duration') {
throw new Error(`Fact ${name}: duration concept tagged with instant context "${id}"`)
}
if (concept.periodType === 'instant' && ctx.kind !== 'instant') {
throw new Error(`Fact ${name}: instant concept tagged with duration context "${id}"`)
}
this.usedContexts.add(id)
}
private qname(concept: TaxonomyConcept, name: string): string {
if (!this.entryPoint.namespaces[concept.ns]) {
throw new Error(`Fact ${name}: namespace prefix "${concept.ns}" missing from entry point`)
}
return `${concept.ns}:${name}`
}
// ---- numeric facts ------------------------------------------------------
/**
* Whole-SEK monetary fact. `value` is oriented to the concept's natural
* balance (credit-positive for credit concepts, debit-positive for debit
* concepts); negative values get the `sign="-"` attribute per TA §2.10.6.
*/
money(name: string, contextRef: string, value: number, opts: MoneyOptions = {}): string {
const concept = mustGetConcept(this.registry, name)
if (concept.dataType !== 'xbrli:monetaryItemType') {
throw new Error(`Fact ${name}: money() used on ${concept.dataType}`)
}
this.resolveContext(contextRef, concept, name)
this.usedUnits.add('SEK')
const rounded = Math.round(value)
const attrs: Attrs = {
contextRef,
name: this.qname(concept, name),
unitRef: 'SEK',
decimals: '0',
scale: '0',
format: 'ixt:numspacecomma',
sign: rounded < 0 ? '-' : null,
id: opts.id ?? null,
tupleRef: opts.tupleRef ?? null,
order: opts.order ?? null,
}
let markup = el('ix:nonFraction', attrs, formatSekAbs(rounded))
if (opts.spanClass) markup = el('span', { class: opts.spanClass }, markup)
// Presentational minus is an XOR: a cost row (displayMinus) with its
// natural sign shows "−X", but a DEVIATING cost (negative fact value,
// sign="-" — i.e. net income on a cost line) displays positive per the
// RR convention; conversely a deviating income row displays "−X".
if ((opts.displayMinus ?? false) !== rounded < 0) markup = `−${markup}`
return markup
}
/** Percent fact (xbrli:pure) per TA §2.12 — text "35,5", scale −2. */
percent(name: string, contextRef: string, valuePct: number): string {
const concept = mustGetConcept(this.registry, name)
if (concept.dataType !== 'xbrli:pureItemType') {
throw new Error(`Fact ${name}: percent() used on ${concept.dataType}`)
}
this.resolveContext(contextRef, concept, name)
this.usedUnits.add('procent')
const attrs: Attrs = {
contextRef,
name: this.qname(concept, name),
unitRef: 'procent',
decimals: '3',
scale: '-2',
format: 'ixt:numspacecomma',
sign: valuePct < 0 ? '-' : null,
}
const markup = el('ix:nonFraction', attrs, formatPercentAbs(valuePct))
return valuePct < 0 ? `−${markup}` : markup
}
/** Antal-fact (medelantal anställda) per TA §2.14, one decimal. */
antalAnstallda(name: string, contextRef: string, value: number): string {
const concept = mustGetConcept(this.registry, name)
this.resolveContext(contextRef, concept, name)
this.usedUnits.add('antal-anstallda')
const isWhole = Number.isInteger(value)
return el(
'ix:nonFraction',
{
contextRef,
name: this.qname(concept, name),
unitRef: 'antal-anstallda',
decimals: isWhole ? '0' : '1',
scale: '0',
format: 'ixt:numspacecomma',
},
isWhole ? String(value) : value.toFixed(1).replace('.', ','),
)
}
// ---- non-numeric facts --------------------------------------------------
/** Plain-text fact; content is escaped. */
textPlain(
name: string,
contextRef: string,
content: string,
opts: { id?: string; tupleRef?: string; order?: string; continuedAt?: string } = {},
): string {
return this.nonNumeric(name, contextRef, escapeText(content), opts)
}
/** Fact wrapping pre-built XHTML (e.g. <p>…</p> paragraphs). */
textHtml(
name: string,
contextRef: string,
innerXhtml: string,
opts: { id?: string; continuedAt?: string } = {},
): string {
return this.nonNumeric(name, contextRef, innerXhtml, opts)
}
/** ISO date fact (TA §2.11 format YYYY-MM-DD — no format attribute). */
date(
name: string,
contextRef: string,
isoDate: string,
opts: { id?: string; tupleRef?: string; order?: string } = {},
): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) {
throw new Error(`Fact ${name}: "${isoDate}" is not an ISO date`)
}
return this.nonNumeric(name, contextRef, isoDate, opts)
}
private nonNumeric(
name: string,
contextRef: string,
inner: string,
opts: { id?: string; tupleRef?: string; order?: string; continuedAt?: string },
): string {
const concept = mustGetConcept(this.registry, name)
if (concept.kind !== 'item') throw new Error(`Fact ${name}: is a tuple, not an item`)
this.resolveContext(contextRef, concept, name)
return el(
'ix:nonNumeric',
{
contextRef,
name: this.qname(concept, name),
id: opts.id ?? null,
tupleRef: opts.tupleRef ?? null,
order: opts.order ?? null,
continuedAt: opts.continuedAt ?? null,
},
inner,
)
}
// ---- vallistor (hidden enumeration facts, TA §2.15 / §3.9.3) ------------
hiddenEnum(name: string, contextRef: string, memberQName: string): void {
const concept = mustGetConcept(this.registry, name)
this.resolveContext(contextRef, concept, name)
const memberLocal = memberQName.split(':')[1]
if (memberLocal) {
// Members live in the registry too (se-mem-base) — validate when known.
const member = this.registry.concepts[memberLocal]
if (!member) throw new Error(`Vallista ${name}: unknown member ${memberQName}`)
}
this.hiddenFacts.push(
el('ix:nonNumeric', { name: this.qname(concept, name), contextRef }, escapeText(memberQName)),
)
}
/** Hidden plain fact (räkenskapsårets första/sista dag in allmän info). */
hiddenDate(name: string, contextRef: string, isoDate: string): void {
this.hiddenFacts.push(this.date(name, contextRef, isoDate))
}
/** Hidden boolean fact (e.g. ArsredovisningEjTaggadInformation, TA §2.22). */
hiddenBoolean(name: string, contextRef: string, value: boolean): void {
this.hiddenFacts.push(this.nonNumeric(name, contextRef, value ? 'true' : 'false', {}))
}
/** Hidden tuple + members (avskrivningsprincip notes etc.). */
hiddenTuple(tupleName: string, members: Array<{ name: string; context: string; value: string }>): void {
const tupleId = this.declareTupleId(tupleName)
const parts: string[] = [this.tupleDeclaration(tupleName, tupleId)]
members.forEach((member, index) => {
parts.push(
this.textPlain(member.name, member.context, member.value, {
tupleRef: tupleId,
order: `${index + 1}.0`,
}),
)
})
this.hiddenFacts.push(parts.join('\n'))
}
// ---- tuples --------------------------------------------------------------
declareTupleId(tupleName: string): string {
const tuple = this.registry.tuples[tupleName]
if (!tuple) throw new Error(`Tuple ${tupleName} not in taxonomy registry`)
this.tupleCounter += 1
return `${tupleName}${this.tupleCounter}`
}
tupleDeclaration(tupleName: string, tupleId: string): string {
const tuple = this.registry.tuples[tupleName]
if (!tuple) throw new Error(`Tuple ${tupleName} not in taxonomy registry`)
return selfClosing('ix:tuple', { name: `${tuple.ns}:${tupleName}`, tupleID: tupleId })
}
// ---- header assembly -----------------------------------------------------
/**
* Render the full ix:header (hidden + references + resources). Call after
* the body has been generated so only referenced contexts/units exist.
*/
renderHeader(): string {
const hidden =
this.hiddenFacts.length > 0 ? el('ix:hidden', {}, this.hiddenFacts.join('\n')) : ''
const references = el(
'ix:references',
{},
this.entryPoint.schemaRefs
.map((href) => selfClosing('link:schemaRef', { 'xlink:type': 'simple', 'xlink:href': href }))
.join('\n'),
)
const contextXml: string[] = []
for (const id of [...this.usedContexts].sort()) {
const ctx = this.contexts.get(id)
if (!ctx) continue
const period =
ctx.kind === 'duration'
? el(
'xbrli:period',
{},
el('xbrli:startDate', {}, ctx.startDate ?? '') +
el('xbrli:endDate', {}, ctx.endDate ?? ''),
)
: el('xbrli:period', {}, el('xbrli:instant', {}, ctx.instant ?? ''))
contextXml.push(
el(
'xbrli:context',
{ id },
el(
'xbrli:entity',
{},
el(
'xbrli:identifier',
{ scheme: 'http://www.bolagsverket.se' },
escapeText(this.entityOrgNumber),
),
) + period,
),
)
}
const unitXml: string[] = []
for (const unitId of [...this.usedUnits].sort()) {
unitXml.push(
el('xbrli:unit', { id: unitId }, el('xbrli:measure', {}, UNIT_MEASURES[unitId])),
)
}
const resources = el('ix:resources', {}, contextXml.join('\n') + '\n' + unitXml.join('\n'))
return el(
'div',
{ style: 'display:none' },
el('ix:header', {}, [hidden, references, resources].filter(Boolean).join('\n')),
)
}
}
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
/**
* Minimal XML/XHTML emission helpers for the iXBRL generator.
*
* Open decision #3 in the implementation plan (React renderToStaticMarkup vs
* dedicated builder) is resolved in favour of a dedicated builder: TA §3.2
* requires *valid XHTML* with only the five XML escape entities, and React's
* HTML serializer makes no such guarantee (named entities, void-element
* forms, attribute quirks). A hand-rolled escaper keeps the output auditable
* byte-for-byte against the official examples.
*/
/** Escape text content using only the five XML entities (TA §3.2.4). */
export function escapeText(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/** Escape an attribute value (double-quoted attributes). */
export function escapeAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export type Attrs = Record<string, string | number | null | undefined>
export function attrString(attrs: Attrs): string {
const parts: string[] = []
for (const [key, value] of Object.entries(attrs)) {
if (value === null || value === undefined) continue
parts.push(`${key}="${escapeAttr(String(value))}"`)
}
return parts.length > 0 ? ' ' + parts.join(' ') : ''
}
export function el(tag: string, attrs: Attrs, children: string): string {
return `<${tag}${attrString(attrs)}>${children}</${tag}>`
}
export function selfClosing(tag: string, attrs: Attrs): string {
return `<${tag}${attrString(attrs)}/>`
}
/**
* Turn user-authored multi-line text into XHTML paragraphs. Blank lines split
* paragraphs; single newlines become <br/>. All content is escaped.
*/
export function paragraphs(text: string, className?: string): string {
const classAttr = className ? ` class="${escapeAttr(className)}"` : ''
return text
.split(/\r?\n\s*\r?\n/)
.map((block) => block.trim())
.filter((block) => block.length > 0)
.map(
(block) =>
`<p${classAttr}>${block
.split(/\r?\n/)
.map((line) => escapeText(line))
.join('<br/>')}</p>`,
)
.join('\n')
}
/**
* Format a whole-SEK amount for ixt:numspacecomma — groups of three digits
* separated by REGULAR spaces (U+0020; NBSP fails the transform regex).
* The sign is never part of the transformed text — negative handling lives
* on the ix:nonFraction `sign` attribute / presentational minus outside.
*/
export function formatSekAbs(value: number): string {
const abs = Math.abs(Math.round(value))
return abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
}
/** Percent with one decimal for ixt:numspacecomma ("35,5"). */
export function formatPercentAbs(value: number): string {
const abs = Math.abs(value)
return abs.toFixed(1).replace('.', ',')
}