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
+109
-1
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
|
||||
import { isSaneDateString } from '@/lib/utils'
|
||||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||||
|
||||
// ============================================================
|
||||
// Shared primitives
|
||||
@@ -39,6 +40,61 @@ const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.lit
|
||||
/** Time string (HH:MM or HH:MM:SS) */
|
||||
const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format')
|
||||
|
||||
/** Periodisering: interim accounts. Förutbetalda kostnader live on 17xx. */
|
||||
const prepaidExpenseAccount = z
|
||||
.string()
|
||||
.regex(/^17\d{2}$/, 'Balanskonto för periodiserad kostnad måste vara ett 17xx-konto')
|
||||
|
||||
/** Periodisering: förutbetalda intäkter live on 29xx. */
|
||||
const deferredRevenueAccount = z
|
||||
.string()
|
||||
.regex(/^29\d{2}$/, 'Balanskonto för periodiserad intäkt måste vara ett 29xx-konto')
|
||||
|
||||
/**
|
||||
* Shared periodisering period rules for invoice line items: both dates or
|
||||
* neither, end after start, and a 2–120 calendar month span. The amount-side
|
||||
* rules differ per item shape and stay in each schema's superRefine.
|
||||
*/
|
||||
function validateAccrualPeriod(
|
||||
item: { accrual_period_start?: string | null; accrual_period_end?: string | null },
|
||||
ctx: z.RefinementCtx,
|
||||
): void {
|
||||
const start = item.accrual_period_start
|
||||
const end = item.accrual_period_end
|
||||
if (!start && !end) return
|
||||
if (!start || !end) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['accrual_period_start'],
|
||||
message: 'Ange både periodens start och slut för periodisering',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (end < start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['accrual_period_end'],
|
||||
message: 'Periodens slut måste vara efter dess start',
|
||||
})
|
||||
return
|
||||
}
|
||||
const months = countCalendarMonths(start, end)
|
||||
if (months < 2) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['accrual_period_end'],
|
||||
message: 'Periodisering kräver minst 2 kalendermånader',
|
||||
})
|
||||
}
|
||||
if (months > 120) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['accrual_period_end'],
|
||||
message: 'Periodisering kan omfatta högst 120 månader',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Enum schemas (matching types/index.ts)
|
||||
// ============================================================
|
||||
@@ -145,6 +201,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
|
||||
'supplier_credit_note',
|
||||
'currency_revaluation',
|
||||
'reminder_fee',
|
||||
'accrual',
|
||||
])
|
||||
|
||||
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
|
||||
@@ -225,8 +282,39 @@ export const CreateInvoiceItemSchema = z
|
||||
work_type: z.string().max(64).nullable().optional(),
|
||||
housing_designation: z.string().max(128).nullable().optional(),
|
||||
apartment_number: z.string().max(32).nullable().optional(),
|
||||
// Periodisering (förutbetald intäkt): defer the line's net revenue over
|
||||
// the service period. The revenue entry credits the 29xx interim account
|
||||
// instead of the revenue account; output VAT is never deferred.
|
||||
accrual_period_start: isoDate.nullable().optional(),
|
||||
accrual_period_end: isoDate.nullable().optional(),
|
||||
accrual_balance_account: deferredRevenueAccount.nullable().optional(),
|
||||
})
|
||||
.superRefine((item, ctx) => {
|
||||
validateAccrualPeriod(item, ctx)
|
||||
const hasAccrual = Boolean(item.accrual_period_start || item.accrual_period_end)
|
||||
if (hasAccrual) {
|
||||
if (item.line_type === 'text') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['accrual_period_start'],
|
||||
message: 'Textrader kan inte periodiseras',
|
||||
})
|
||||
}
|
||||
if (item.deduction_type) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['accrual_period_start'],
|
||||
message: 'ROT/RUT-rader kan inte periodiseras',
|
||||
})
|
||||
}
|
||||
if (item.quantity * item.unit_price <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['accrual_period_start'],
|
||||
message: 'Endast rader med positivt belopp kan periodiseras',
|
||||
})
|
||||
}
|
||||
}
|
||||
// Free-text rows skip the product-line requirements (description may be
|
||||
// empty for a spacer; quantity/unit/price are ignored).
|
||||
if (item.line_type === 'text') return
|
||||
@@ -485,6 +573,12 @@ export const CreateSupplierInvoiceItemSchema = z.object({
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
unit_price: z.number().optional(),
|
||||
// Periodisering (förutbetald kostnad): defer the line's net cost over the
|
||||
// service period. The registration entry debits the 17xx interim account
|
||||
// instead of account_number; input VAT is never deferred.
|
||||
accrual_period_start: isoDate.nullable().optional(),
|
||||
accrual_period_end: isoDate.nullable().optional(),
|
||||
accrual_balance_account: prepaidExpenseAccount.nullable().optional(),
|
||||
}).refine(
|
||||
(item) => {
|
||||
if (item.vat_amount == null) return true
|
||||
@@ -501,7 +595,21 @@ export const CreateSupplierInvoiceItemSchema = z.object({
|
||||
message: 'vat_amount cannot exceed line_total × vat_rate',
|
||||
path: ['vat_amount'],
|
||||
},
|
||||
)
|
||||
).superRefine((item, ctx) => {
|
||||
validateAccrualPeriod(item, ctx)
|
||||
if (item.accrual_period_start || item.accrual_period_end) {
|
||||
const lineTotal = item.amount != null
|
||||
? item.amount
|
||||
: (item.quantity ?? 1) * (item.unit_price ?? 0)
|
||||
if (lineTotal <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['accrual_period_start'],
|
||||
message: 'Endast rader med positivt belopp kan periodiseras',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const CreateSupplierInvoiceSchema = z.object({
|
||||
supplier_id: uuid,
|
||||
|
||||
@@ -202,6 +202,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_get_supplier_ledger: 'reports:read',
|
||||
gnubok_list_fiscal_periods: 'reports:read',
|
||||
gnubok_get_reconciliation_status: 'reports:read',
|
||||
gnubok_list_accrual_schedules: 'reports:read',
|
||||
// Document inbox
|
||||
gnubok_upload_document: 'transactions:write',
|
||||
gnubok_list_inbox_items: 'transactions:read',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -76,7 +76,7 @@ describe('engine.pg — triggers & RPCs that mocks cannot catch', () => {
|
||||
// the voucher_sequences user_id NOT NULL check before ON CONFLICT could
|
||||
// arbitrate (commit_journal_entry got the fallback in 20260421170500;
|
||||
// next_voucher_number — the storno/correction path — did not until
|
||||
// 20260611130000).
|
||||
// 20260623130000).
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
const first = await getPool().query<{ n: number }>(
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
SupplierInvoice,
|
||||
SupplierInvoiceItem,
|
||||
} from '@/types'
|
||||
import { makeSupplierInvoice } from '@/tests/helpers'
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
// Periodisering booking behaviour: lines with an accrual period book their
|
||||
// net to the 17xx/29xx interim account instead of the P&L account, while
|
||||
// VAT and AR/AP lines stay untouched.
|
||||
|
||||
vi.mock('../engine', () => ({
|
||||
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
|
||||
createJournalEntry: vi.fn().mockImplementation(
|
||||
async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
|
||||
id: 'entry-1',
|
||||
...input,
|
||||
lines: input.lines,
|
||||
})
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../vat-entries', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../vat-entries')>()
|
||||
return {
|
||||
...actual,
|
||||
generateSalesVatLines: vi.fn().mockImplementation(
|
||||
({ vatTreatment, baseAmount }: { vatTreatment: string; baseAmount: number }) => {
|
||||
const rate = vatTreatment === 'standard_25' ? 0.25
|
||||
: vatTreatment === 'reduced_12' ? 0.12
|
||||
: vatTreatment === 'reduced_6' ? 0.06 : 0
|
||||
if (rate === 0) return []
|
||||
const account = vatTreatment === 'standard_25' ? '2611'
|
||||
: vatTreatment === 'reduced_12' ? '2621' : '2631'
|
||||
return [{
|
||||
account_number: account,
|
||||
debit_amount: 0,
|
||||
credit_amount: roundOre(baseAmount * rate),
|
||||
line_description: 'Utgående moms',
|
||||
}]
|
||||
},
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const { createJournalEntry } = await import('../engine')
|
||||
const mockedCreateEntry = vi.mocked(createJournalEntry)
|
||||
|
||||
const { createSupplierInvoiceRegistrationEntry, createSupplierCreditNoteEntry } =
|
||||
await import('../supplier-invoice-entries')
|
||||
const { createInvoiceJournalEntry, createCreditNoteJournalEntry } =
|
||||
await import('../invoice-entries')
|
||||
|
||||
function makeSupplierItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
|
||||
const lineTotal = overrides.line_total ?? 12000
|
||||
const vatRate = overrides.vat_rate ?? 0.25
|
||||
return {
|
||||
id: 'si-item-1',
|
||||
supplier_invoice_id: 'si-1',
|
||||
sort_order: 0,
|
||||
description: 'Företagsförsäkring 2026',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: lineTotal,
|
||||
line_total: lineTotal,
|
||||
account_number: '6310',
|
||||
vat_code: null,
|
||||
vat_rate: vatRate,
|
||||
vat_amount: overrides.vat_amount ?? roundOre(lineTotal * vatRate),
|
||||
reverse_charge_rate: null,
|
||||
created_at: '2026-01-15T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCustomerInvoice(overrides: Partial<Invoice> & { items?: InvoiceItem[] }): Invoice {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
user_id: 'user-1',
|
||||
customer_id: 'cust-1',
|
||||
invoice_number: 'F-100',
|
||||
invoice_date: '2026-01-15',
|
||||
due_date: '2026-02-14',
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
subtotal: 12000,
|
||||
subtotal_sek: null,
|
||||
vat_amount: 3000,
|
||||
vat_amount_sek: null,
|
||||
total: 15000,
|
||||
total_sek: null,
|
||||
vat_treatment: 'standard_25',
|
||||
vat_rate: 25,
|
||||
moms_ruta: '05',
|
||||
status: 'sent',
|
||||
paid_at: null,
|
||||
credited_invoice_id: null,
|
||||
document_type: 'invoice',
|
||||
created_at: '2026-01-15T00:00:00Z',
|
||||
updated_at: '2026-01-15T00:00:00Z',
|
||||
items: [],
|
||||
...overrides,
|
||||
} as Invoice
|
||||
}
|
||||
|
||||
function makeCustomerItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
sort_order: 0,
|
||||
description: 'Serviceavtal 2026',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 12000,
|
||||
line_total: 12000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 3000,
|
||||
created_at: '2026-01-15T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function entryLines() {
|
||||
return mockedCreateEntry.mock.calls[0][3].lines
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('supplier invoice registration with periodisering', () => {
|
||||
it('debits the 17xx interim account instead of the cost account', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
subtotal: 12000,
|
||||
vat_amount: 3000,
|
||||
total: 15000,
|
||||
}) as SupplierInvoice
|
||||
const items = [
|
||||
makeSupplierItem({
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '1730',
|
||||
}),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
{} as never, 'company-1', 'user-1', invoice, items, 'swedish_business', 'Försäkrings AB',
|
||||
)
|
||||
|
||||
const lines = entryLines()
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '1730', debit_amount: 12000 }),
|
||||
)
|
||||
expect(lines.some((l) => l.account_number === '6310')).toBe(false)
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2641', debit_amount: 3000 }),
|
||||
)
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2440', credit_amount: 15000 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the BAS-conventional interim account when none is set', async () => {
|
||||
const invoice = makeSupplierInvoice({ id: 'si-1' }) as SupplierInvoice
|
||||
const items = [
|
||||
makeSupplierItem({
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: null,
|
||||
}),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
{} as never, 'company-1', 'user-1', invoice, items, 'swedish_business',
|
||||
)
|
||||
|
||||
// 6310 försäkring → 1730 Förutbetalda försäkringspremier
|
||||
expect(entryLines()).toContainEqual(
|
||||
expect.objectContaining({ account_number: '1730', debit_amount: 12000 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('books mixed invoices with deferred and ordinary lines side by side', async () => {
|
||||
const invoice = makeSupplierInvoice({ id: 'si-1' }) as SupplierInvoice
|
||||
const items = [
|
||||
makeSupplierItem({
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '1730',
|
||||
}),
|
||||
makeSupplierItem({
|
||||
id: 'si-item-2',
|
||||
sort_order: 1,
|
||||
description: 'Kontorsmaterial',
|
||||
line_total: 500,
|
||||
account_number: '6110',
|
||||
}),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
{} as never, 'company-1', 'user-1', invoice, items, 'swedish_business',
|
||||
)
|
||||
|
||||
const lines = entryLines()
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '1730', debit_amount: 12000 }),
|
||||
)
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '6110', debit_amount: 500 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supplier credit note with periodisering', () => {
|
||||
it('credits the interim account when given the original deferred items', async () => {
|
||||
const creditNote = makeSupplierInvoice({
|
||||
id: 'si-credit-1',
|
||||
is_credit_note: true,
|
||||
}) as SupplierInvoice
|
||||
const originalItems = [
|
||||
makeSupplierItem({
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '1730',
|
||||
}),
|
||||
]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
{} as never, 'company-1', 'user-1', creditNote, originalItems, 'swedish_business',
|
||||
)
|
||||
|
||||
const lines = entryLines()
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '1730', credit_amount: 12000 }),
|
||||
)
|
||||
expect(lines.some((l) => l.account_number === '6310')).toBe(false)
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2440', debit_amount: 15000 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('customer invoice with periodisering', () => {
|
||||
it('credits 29xx instead of revenue; output VAT untouched', async () => {
|
||||
const invoice = makeCustomerInvoice({
|
||||
items: [
|
||||
makeCustomerItem({
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceJournalEntry({} as never, 'company-1', 'user-1', invoice, 'aktiebolag')
|
||||
|
||||
const lines = entryLines()
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '1510', debit_amount: 15000 }),
|
||||
)
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2970', credit_amount: 12000 }),
|
||||
)
|
||||
expect(lines.some((l) => l.account_number === '3001')).toBe(false)
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2611', credit_amount: 3000 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps ordinary lines on revenue accounts next to deferred lines', async () => {
|
||||
const invoice = makeCustomerInvoice({
|
||||
subtotal: 13000,
|
||||
vat_amount: 3250,
|
||||
total: 16250,
|
||||
items: [
|
||||
makeCustomerItem({
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
}),
|
||||
makeCustomerItem({
|
||||
id: 'item-2',
|
||||
sort_order: 1,
|
||||
description: 'Konsulttimmar',
|
||||
unit_price: 1000,
|
||||
line_total: 1000,
|
||||
vat_amount: 250,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceJournalEntry({} as never, 'company-1', 'user-1', invoice, 'aktiebolag')
|
||||
|
||||
const lines = entryLines()
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2970', credit_amount: 12000 }),
|
||||
)
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '3001', credit_amount: 1000 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('never defers reverse-charge lines (3308 keeps the full amount)', async () => {
|
||||
const invoice = makeCustomerInvoice({
|
||||
vat_treatment: 'reverse_charge',
|
||||
vat_amount: 0,
|
||||
total: 12000,
|
||||
items: [
|
||||
makeCustomerItem({
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceJournalEntry({} as never, 'company-1', 'user-1', invoice, 'aktiebolag')
|
||||
|
||||
const lines = entryLines()
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '3308', credit_amount: 12000 }),
|
||||
)
|
||||
expect(lines.some((l) => l.account_number === '2970')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('customer credit note with periodisering', () => {
|
||||
it('debits the interim account via the copied accrual fields', async () => {
|
||||
const creditNote = makeCustomerInvoice({
|
||||
id: 'kr-1',
|
||||
invoice_number: 'KR-F-100',
|
||||
subtotal: -12000,
|
||||
vat_amount: -3000,
|
||||
total: -15000,
|
||||
credited_invoice_id: 'inv-1',
|
||||
items: [
|
||||
makeCustomerItem({
|
||||
id: 'kr-item-1',
|
||||
invoice_id: 'kr-1',
|
||||
quantity: -1,
|
||||
line_total: -12000,
|
||||
vat_amount: -3000,
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
await createCreditNoteJournalEntry(
|
||||
{} as never, 'company-1', 'user-1', creditNote, 'aktiebolag', 'Kund AB', 'A-42',
|
||||
)
|
||||
|
||||
const lines = entryLines()
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2970', debit_amount: 12000 }),
|
||||
)
|
||||
expect(lines.some((l) => l.account_number === '3001')).toBe(false)
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '1510', credit_amount: 15000 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isValidBalanceAccount,
|
||||
suggestBalanceAccount,
|
||||
} from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
|
||||
describe('suggestBalanceAccount', () => {
|
||||
it('maps cost accounts to the BAS-conventional interim account', () => {
|
||||
expect(suggestBalanceAccount('expense', '5010')).toBe('1710') // lokalhyra
|
||||
expect(suggestBalanceAccount('expense', '5220')).toBe('1720') // hyra inventarier
|
||||
expect(suggestBalanceAccount('expense', '5615')).toBe('1720') // leasing personbil
|
||||
expect(suggestBalanceAccount('expense', '6310')).toBe('1730') // försäkring
|
||||
expect(suggestBalanceAccount('expense', '8410')).toBe('1740') // ränta
|
||||
expect(suggestBalanceAccount('expense', '6540')).toBe('1790') // IT-tjänster -> övrigt
|
||||
})
|
||||
|
||||
it('always suggests 2970 for revenue', () => {
|
||||
expect(suggestBalanceAccount('revenue', '3001')).toBe('2970')
|
||||
expect(suggestBalanceAccount('revenue', '3041')).toBe('2970')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidBalanceAccount', () => {
|
||||
it('mirrors the DB CHECK ranges', () => {
|
||||
expect(isValidBalanceAccount('expense', '1730')).toBe(true)
|
||||
expect(isValidBalanceAccount('expense', '1790')).toBe(true)
|
||||
expect(isValidBalanceAccount('expense', '2970')).toBe(false)
|
||||
expect(isValidBalanceAccount('expense', '5010')).toBe(false)
|
||||
expect(isValidBalanceAccount('revenue', '2970')).toBe(true)
|
||||
expect(isValidBalanceAccount('revenue', '2990')).toBe(true)
|
||||
expect(isValidBalanceAccount('revenue', '1790')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
computeInstallmentAmounts,
|
||||
computeInstallments,
|
||||
countCalendarMonths,
|
||||
dayAfter,
|
||||
firstOfMonth,
|
||||
listCalendarMonths,
|
||||
maxIsoDate,
|
||||
} from '@/lib/bookkeeping/accruals/compute'
|
||||
import { sumOre } from '@/lib/money'
|
||||
|
||||
describe('firstOfMonth', () => {
|
||||
it('truncates to the first of the month', () => {
|
||||
expect(firstOfMonth('2026-01-15')).toBe('2026-01-01')
|
||||
expect(firstOfMonth('2026-12-31')).toBe('2026-12-01')
|
||||
expect(firstOfMonth('2026-06-01')).toBe('2026-06-01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countCalendarMonths', () => {
|
||||
it('counts months touched, inclusive', () => {
|
||||
expect(countCalendarMonths('2026-01-01', '2026-01-31')).toBe(1)
|
||||
expect(countCalendarMonths('2026-01-10', '2026-12-31')).toBe(12)
|
||||
// Mid-month start still touches both end months.
|
||||
expect(countCalendarMonths('2026-01-31', '2026-02-01')).toBe(2)
|
||||
})
|
||||
|
||||
it('handles year boundaries and brutet räkenskapsår', () => {
|
||||
expect(countCalendarMonths('2026-11-15', '2027-02-14')).toBe(4)
|
||||
expect(countCalendarMonths('2026-07-01', '2027-06-30')).toBe(12)
|
||||
})
|
||||
|
||||
it('throws when the end month precedes the start month', () => {
|
||||
expect(() => countCalendarMonths('2026-06-01', '2026-01-31')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listCalendarMonths', () => {
|
||||
it('lists first-of-month dates across a year boundary', () => {
|
||||
expect(listCalendarMonths('2026-11-15', '2027-02-14')).toEqual([
|
||||
'2026-11-01',
|
||||
'2026-12-01',
|
||||
'2027-01-01',
|
||||
'2027-02-01',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeInstallmentAmounts', () => {
|
||||
it('splits evenly when the amount divides cleanly', () => {
|
||||
expect(computeInstallmentAmounts(12000, 12)).toEqual(Array(12).fill(1000))
|
||||
})
|
||||
|
||||
it('distributes remainder öre from the first month and sums exactly', () => {
|
||||
const amounts = computeInstallmentAmounts(10000, 12)
|
||||
expect(amounts.slice(0, 4)).toEqual([833.34, 833.34, 833.34, 833.34])
|
||||
expect(amounts.slice(4)).toEqual(Array(8).fill(833.33))
|
||||
const sum = Math.round(amounts.reduce((a, b) => a + b, 0) * 100) / 100
|
||||
expect(sum).toBe(10000)
|
||||
})
|
||||
|
||||
it('sums exactly for awkward totals (property check)', () => {
|
||||
const cases: Array<[number, number]> = [
|
||||
[0.13, 12],
|
||||
[1, 3],
|
||||
[99.99, 7],
|
||||
[12345.67, 11],
|
||||
[50000, 36],
|
||||
[3333.33, 2],
|
||||
]
|
||||
for (const [total, months] of cases) {
|
||||
const amounts = computeInstallmentAmounts(total, months)
|
||||
expect(amounts).toHaveLength(months)
|
||||
for (const amount of amounts) {
|
||||
expect(amount).toBeGreaterThan(0)
|
||||
}
|
||||
const sum = sumOre(amounts)
|
||||
expect(sum).toBe(total)
|
||||
// No installment differs by more than 1 öre from any other.
|
||||
const min = Math.min(...amounts)
|
||||
const max = Math.max(...amounts)
|
||||
expect(Math.round((max - min) * 100)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects totals too small to give every month an öre', () => {
|
||||
expect(() => computeInstallmentAmounts(0.05, 12)).toThrow(/too small/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeInstallments', () => {
|
||||
it('pairs months with amounts', () => {
|
||||
const plan = computeInstallments(12000, '2026-01-15', '2026-12-31')
|
||||
expect(plan).toHaveLength(12)
|
||||
expect(plan[0]).toEqual({ period_month: '2026-01-01', amount: 1000 })
|
||||
expect(plan[11]).toEqual({ period_month: '2026-12-01', amount: 1000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('maxIsoDate / dayAfter', () => {
|
||||
it('returns the latest date and ignores null/undefined', () => {
|
||||
expect(maxIsoDate('2026-01-01', '2026-03-15', null, undefined)).toBe('2026-03-15')
|
||||
expect(maxIsoDate('2026-01-01')).toBe('2026-01-01')
|
||||
expect(() => maxIsoDate(null, undefined)).toThrow()
|
||||
})
|
||||
|
||||
it('computes the day after across month and year boundaries', () => {
|
||||
expect(dayAfter('2026-03-31')).toBe('2026-04-01')
|
||||
expect(dayAfter('2026-12-31')).toBe('2027-01-01')
|
||||
expect(dayAfter('2026-02-28')).toBe('2026-03-01')
|
||||
expect(dayAfter('2028-02-28')).toBe('2028-02-29')
|
||||
expect(dayAfter('2026-06-14')).toBe('2026-06-15')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,569 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
cancelSchedulesForSource,
|
||||
createAccrualSchedule,
|
||||
dissolveScheduleNow,
|
||||
postDueInstallments,
|
||||
} from '@/lib/bookkeeping/accruals/service'
|
||||
import {
|
||||
createJournalEntry,
|
||||
findFiscalPeriod,
|
||||
reverseEntry,
|
||||
} from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
CannotReverseNonPostedError,
|
||||
EntryAlreadyReversedError,
|
||||
} from '@/lib/bookkeeping/errors'
|
||||
import {
|
||||
ACCRUAL_NOTHING_TO_DISSOLVE,
|
||||
ACCRUAL_SCHEDULE_NOT_ACTIVE,
|
||||
ACCRUAL_SCHEDULE_NOT_FOUND,
|
||||
} from '@/lib/bookkeeping/accruals/errors'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(),
|
||||
findFiscalPeriod: vi.fn(),
|
||||
reverseEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateJournalEntry = vi.mocked(createJournalEntry)
|
||||
const mockFindFiscalPeriod = vi.mocked(findFiscalPeriod)
|
||||
const mockReverseEntry = vi.mocked(reverseEntry)
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const USER = 'user-1'
|
||||
|
||||
function makeSchedule(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'sched-1',
|
||||
user_id: USER,
|
||||
company_id: COMPANY,
|
||||
direction: 'expense',
|
||||
supplier_invoice_id: 'si-1',
|
||||
supplier_invoice_item_id: 'sii-1',
|
||||
invoice_id: null,
|
||||
invoice_item_id: null,
|
||||
balance_account: '1730',
|
||||
target_account: '6310',
|
||||
total_amount: 12000,
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
months: 12,
|
||||
origin_journal_entry_id: 'je-origin',
|
||||
posting_floor_date: '2026-01-15',
|
||||
status: 'active',
|
||||
description: 'Försäkring 2026',
|
||||
created_at: '2026-01-15T00:00:00Z',
|
||||
updated_at: '2026-01-15T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeInstallment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'inst-1',
|
||||
user_id: USER,
|
||||
company_id: COMPANY,
|
||||
schedule_id: 'sched-1',
|
||||
period_month: '2026-01-01',
|
||||
amount: 1000,
|
||||
status: 'pending',
|
||||
journal_entry_id: null,
|
||||
posted_at: null,
|
||||
last_error: null,
|
||||
created_at: '2026-01-15T00:00:00Z',
|
||||
updated_at: '2026-01-15T00:00:00Z',
|
||||
schedule: makeSchedule(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-new' } as never)
|
||||
mockReverseEntry.mockResolvedValue({ id: 'je-storno' } as never)
|
||||
})
|
||||
|
||||
describe('postDueInstallments', () => {
|
||||
it('posts a due expense installment with Dr target / Cr balance', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeInstallment()] }, // due installments
|
||||
{ data: { bookkeeping_locked_through: null } }, // company_settings
|
||||
{ data: [{ id: 'inst-1' }] }, // CAS claim
|
||||
{ count: 0 }, // remaining pending
|
||||
{ data: null }, // schedule -> completed
|
||||
])
|
||||
|
||||
const result = await postDueInstallments(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
{ userId: USER, today: '2026-01-20' },
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ posted: 1, failed: 0, skipped: 0 })
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
|
||||
const input = mockCreateJournalEntry.mock.calls[0][3]
|
||||
expect(input.source_type).toBe('accrual')
|
||||
expect(input.source_id).toBe('sched-1')
|
||||
// Floor: period month 2026-01-01 but origin entry dated 2026-01-15.
|
||||
expect(input.entry_date).toBe('2026-01-15')
|
||||
expect(input.lines).toEqual([
|
||||
expect.objectContaining({ account_number: '6310', debit_amount: 1000, credit_amount: 0 }),
|
||||
expect.objectContaining({ account_number: '1730', debit_amount: 0, credit_amount: 1000 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('flips the lines for revenue schedules (Dr 29xx / Cr 3xxx)', async () => {
|
||||
const revenueSchedule = makeSchedule({
|
||||
id: 'sched-2',
|
||||
direction: 'revenue',
|
||||
supplier_invoice_id: null,
|
||||
invoice_id: 'inv-1',
|
||||
balance_account: '2970',
|
||||
target_account: '3001',
|
||||
posting_floor_date: '2026-01-01',
|
||||
})
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeInstallment({ schedule_id: 'sched-2', schedule: revenueSchedule })] },
|
||||
{ data: { bookkeeping_locked_through: null } },
|
||||
{ data: [{ id: 'inst-1' }] },
|
||||
{ count: 1 }, // still pending months -> schedule stays active
|
||||
])
|
||||
|
||||
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-01-20',
|
||||
})
|
||||
|
||||
const input = mockCreateJournalEntry.mock.calls[0][3]
|
||||
expect(input.lines).toEqual([
|
||||
expect.objectContaining({ account_number: '2970', debit_amount: 1000, credit_amount: 0 }),
|
||||
expect.objectContaining({ account_number: '3001', debit_amount: 0, credit_amount: 1000 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('shifts the entry date past the company lock date', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeInstallment({ period_month: '2026-02-01' })] },
|
||||
{ data: { bookkeeping_locked_through: '2026-03-31' } },
|
||||
{ data: [{ id: 'inst-1' }] },
|
||||
{ count: 0 },
|
||||
{ data: null },
|
||||
])
|
||||
|
||||
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-04-10',
|
||||
})
|
||||
|
||||
expect(mockCreateJournalEntry.mock.calls[0][3].entry_date).toBe('2026-04-01')
|
||||
})
|
||||
|
||||
it('skips months that have not begun yet', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [] }, // the .lte filter excludes future months server-side
|
||||
])
|
||||
|
||||
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-01-20',
|
||||
})
|
||||
|
||||
expect(result.posted).toBe(0)
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reverses its own entry when the CAS claim is lost', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeInstallment()] },
|
||||
{ data: { bookkeeping_locked_through: null } },
|
||||
{ data: [] }, // CAS claim lost (another runner won)
|
||||
])
|
||||
|
||||
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-01-20',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ posted: 0, skipped: 1, failed: 0 })
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY,
|
||||
USER,
|
||||
'je-new',
|
||||
)
|
||||
})
|
||||
|
||||
it('reverses its own entry when the CAS claim UPDATE itself errors', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeInstallment()] },
|
||||
{ data: { bookkeeping_locked_through: null } },
|
||||
{ data: null, error: { message: 'connection reset' } }, // claim errored
|
||||
{ data: null }, // last_error update in the catch
|
||||
])
|
||||
|
||||
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-01-20',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ posted: 0, skipped: 0, failed: 1 })
|
||||
expect(result.errors[0].message).toMatch(/Failed to mark installment posted/)
|
||||
// Without the storno the next cron run would double-book the month.
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY,
|
||||
USER,
|
||||
'je-new',
|
||||
)
|
||||
})
|
||||
|
||||
it('records the reversal failure too when storno after claim error fails', async () => {
|
||||
mockReverseEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeInstallment()] },
|
||||
{ data: { bookkeeping_locked_through: null } },
|
||||
{ data: null, error: { message: 'connection reset' } }, // claim errored
|
||||
{ data: null }, // last_error update in the catch
|
||||
])
|
||||
|
||||
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-01-20',
|
||||
})
|
||||
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.errors[0].message).toMatch(/misslyckades också/)
|
||||
expect(result.errors[0].message).toMatch(/Bokföringen är låst/)
|
||||
})
|
||||
|
||||
it('clamps the posting date to the next open fiscal period when the date falls in a closed one', async () => {
|
||||
mockFindFiscalPeriod.mockResolvedValueOnce(null) // 2026 period is closed
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeInstallment()] },
|
||||
{ data: { bookkeeping_locked_through: null } },
|
||||
{ data: { id: 'fp-next', period_start: '2027-01-01' } }, // next open period
|
||||
{ data: [{ id: 'inst-1' }] }, // CAS claim
|
||||
{ count: 0 },
|
||||
{ data: null },
|
||||
])
|
||||
|
||||
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-01-20',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ posted: 1, failed: 0 })
|
||||
const input = mockCreateJournalEntry.mock.calls[0][3]
|
||||
expect(input.entry_date).toBe('2027-01-01')
|
||||
expect(input.fiscal_period_id).toBe('fp-next')
|
||||
})
|
||||
|
||||
it('records an actionable last_error when no open period exists at or after the date', async () => {
|
||||
mockFindFiscalPeriod.mockResolvedValueOnce(null)
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeInstallment()] },
|
||||
{ data: { bookkeeping_locked_through: null } },
|
||||
{ data: null }, // no open period after the date either
|
||||
{ data: null }, // last_error update
|
||||
])
|
||||
|
||||
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-01-20',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ posted: 0, failed: 1 })
|
||||
expect(result.errors[0].message).toMatch(/eller senare/)
|
||||
expect(result.errors[0].message).toMatch(/skapa nästa räkenskapsår/)
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records last_error and continues when posting fails', async () => {
|
||||
mockCreateJournalEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{
|
||||
data: [
|
||||
makeInstallment({ id: 'inst-1' }),
|
||||
makeInstallment({ id: 'inst-2', period_month: '2026-02-01' }),
|
||||
],
|
||||
},
|
||||
{ data: { bookkeeping_locked_through: null } },
|
||||
{ data: null }, // last_error update for inst-1
|
||||
{ data: [{ id: 'inst-2' }] }, // CAS claim for inst-2
|
||||
{ count: 0 },
|
||||
{ data: null },
|
||||
])
|
||||
|
||||
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-02-20',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ posted: 1, failed: 1 })
|
||||
expect(result.errors[0]).toMatchObject({ installmentId: 'inst-1' })
|
||||
})
|
||||
|
||||
it('ignores installments whose schedule is not active', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{
|
||||
data: [
|
||||
makeInstallment({ schedule: makeSchedule({ status: 'cancelled' }) }),
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
||||
userId: USER,
|
||||
today: '2026-01-20',
|
||||
})
|
||||
|
||||
expect(result.posted).toBe(0)
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAccrualSchedule', () => {
|
||||
const spec = {
|
||||
direction: 'expense' as const,
|
||||
supplierInvoiceId: 'si-1',
|
||||
supplierInvoiceItemId: 'sii-1',
|
||||
balanceAccount: '1730',
|
||||
targetAccount: '6310',
|
||||
totalAmountSek: 12000,
|
||||
periodStart: '2026-01-01',
|
||||
periodEnd: '2026-12-31',
|
||||
description: 'Försäkring 2026',
|
||||
}
|
||||
|
||||
it('creates the schedule and 12 installments without posting when postCatchUp=false', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: makeSchedule() }, // schedule insert
|
||||
{ data: null }, // installments insert
|
||||
])
|
||||
|
||||
const schedule = await createAccrualSchedule(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
USER,
|
||||
spec,
|
||||
{ originJournalEntryId: 'je-origin', postingFloorDate: '2026-01-15', postCatchUp: false },
|
||||
)
|
||||
|
||||
expect(schedule.id).toBe('sched-1')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up the schedule when installment insert fails', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: makeSchedule() },
|
||||
{ data: null, error: { message: 'boom' } },
|
||||
{ data: null }, // cleanup delete
|
||||
])
|
||||
|
||||
await expect(
|
||||
createAccrualSchedule(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
USER,
|
||||
spec,
|
||||
{ postingFloorDate: '2026-01-15', postCatchUp: false },
|
||||
),
|
||||
).rejects.toThrow(/installments/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dissolveScheduleNow', () => {
|
||||
it('posts one combined entry for the remaining months', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: makeSchedule() }, // schedule fetch
|
||||
{
|
||||
data: [
|
||||
makeInstallment({ id: 'inst-11', period_month: '2026-11-01', amount: 1000 }),
|
||||
makeInstallment({ id: 'inst-12', period_month: '2026-12-01', amount: 1000 }),
|
||||
],
|
||||
},
|
||||
{ data: { bookkeeping_locked_through: null } },
|
||||
{ data: [{ id: 'inst-11' }, { id: 'inst-12' }] }, // claim both
|
||||
{ data: null }, // schedule completed
|
||||
])
|
||||
|
||||
const result = await dissolveScheduleNow(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
USER,
|
||||
'sched-1',
|
||||
{ today: '2026-10-15' },
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ journalEntryId: 'je-new', amount: 2000 })
|
||||
const input = mockCreateJournalEntry.mock.calls[0][3]
|
||||
expect(input.entry_date).toBe('2026-10-15')
|
||||
expect(input.lines[0]).toMatchObject({ account_number: '6310', debit_amount: 2000 })
|
||||
})
|
||||
|
||||
it('reverses the combined entry when the claim is incomplete', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: makeSchedule() },
|
||||
{
|
||||
data: [
|
||||
makeInstallment({ id: 'inst-11', period_month: '2026-11-01', amount: 1000 }),
|
||||
makeInstallment({ id: 'inst-12', period_month: '2026-12-01', amount: 1000 }),
|
||||
],
|
||||
},
|
||||
{ data: { bookkeeping_locked_through: null } },
|
||||
{ data: [{ id: 'inst-11' }] }, // only one claimed
|
||||
])
|
||||
|
||||
await expect(
|
||||
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1', {
|
||||
today: '2026-10-15',
|
||||
}),
|
||||
).rejects.toThrow(/ändrades samtidigt/i)
|
||||
expect(mockReverseEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws a typed not-found error with a stable code', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([{ data: null, error: { message: 'No rows' } }])
|
||||
|
||||
await expect(
|
||||
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'missing'),
|
||||
).rejects.toMatchObject({
|
||||
code: ACCRUAL_SCHEDULE_NOT_FOUND,
|
||||
message: 'Periodiseringen hittades inte',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws a typed not-active error with a stable code', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([{ data: makeSchedule({ status: 'completed' }) }])
|
||||
|
||||
await expect(
|
||||
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1'),
|
||||
).rejects.toMatchObject({
|
||||
code: ACCRUAL_SCHEDULE_NOT_ACTIVE,
|
||||
message: 'Periodiseringen är inte aktiv',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws a typed nothing-to-dissolve error with a stable code', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: makeSchedule() }, // schedule fetch
|
||||
{ data: [] }, // nothing pending
|
||||
{ count: 0 }, // completeScheduleIfDone: remaining pending
|
||||
{ data: null }, // schedule -> completed
|
||||
])
|
||||
|
||||
await expect(
|
||||
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1'),
|
||||
).rejects.toMatchObject({
|
||||
code: ACCRUAL_NOTHING_TO_DISSOLVE,
|
||||
message: 'Det finns inget kvar att lösa upp',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelSchedulesForSource', () => {
|
||||
it('cancels pending months, reverses posted ones, and cancels the schedule', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeSchedule()] }, // schedules for source
|
||||
{ data: null }, // pending -> cancelled
|
||||
{ data: [{ id: 'inst-1', journal_entry_id: 'je-jan' }] }, // posted rows
|
||||
{ data: null }, // schedule -> cancelled
|
||||
])
|
||||
|
||||
const result = await cancelSchedulesForSource(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
USER,
|
||||
{ supplierInvoiceId: 'si-1' },
|
||||
{ reversalDate: '2026-05-01' },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ cancelledSchedules: 1, reversedEntries: 1, failedReversals: 0 })
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY,
|
||||
USER,
|
||||
'je-jan',
|
||||
'2026-05-01',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the schedule active and reports the failure when a storno fails', async () => {
|
||||
mockReverseEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeSchedule()] }, // schedules for source
|
||||
{ data: null }, // pending -> cancelled
|
||||
{ data: [{ id: 'inst-1', journal_entry_id: 'je-jan' }] }, // posted rows
|
||||
{ data: null }, // last_error update on the stuck installment
|
||||
// NOTE: no schedule -> cancelled update is queued; the schedule must
|
||||
// stay 'active' so the UI keeps showing the un-reversed remainder.
|
||||
])
|
||||
|
||||
const result = await cancelSchedulesForSource(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
USER,
|
||||
{ supplierInvoiceId: 'si-1' },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ cancelledSchedules: 0, reversedEntries: 0, failedReversals: 1 })
|
||||
})
|
||||
|
||||
it('treats already-reversed dissolutions as success and still cancels the schedule', async () => {
|
||||
mockReverseEntry
|
||||
.mockRejectedValueOnce(new EntryAlreadyReversedError())
|
||||
.mockRejectedValueOnce(new CannotReverseNonPostedError('reversed'))
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: [makeSchedule()] },
|
||||
{ data: null }, // pending -> cancelled
|
||||
{
|
||||
data: [
|
||||
{ id: 'inst-1', journal_entry_id: 'je-jan' },
|
||||
{ id: 'inst-2', journal_entry_id: 'je-feb' },
|
||||
],
|
||||
},
|
||||
{ data: null }, // schedule -> cancelled
|
||||
])
|
||||
|
||||
const result = await cancelSchedulesForSource(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
USER,
|
||||
{ supplierInvoiceId: 'si-1' },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ cancelledSchedules: 1, reversedEntries: 0, failedReversals: 0 })
|
||||
})
|
||||
|
||||
it('requires a source id', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
cancelSchedulesForSource(supabase as unknown as SupabaseClient, COMPANY, USER, {}),
|
||||
).rejects.toThrow(/source invoice id/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Default interim (balance) account per P&L account for periodisering.
|
||||
*
|
||||
* Förutbetalda kostnader sit on 17xx interimsfordringar; the specific account
|
||||
* follows BAS convention by cost type. Förutbetalda intäkter sit on 2970
|
||||
* (29xx interimsskulder). The user can always override in the form — these
|
||||
* are suggestions, and the DB CHECK only enforces the 17xx/29xx range.
|
||||
*/
|
||||
|
||||
import type { AccrualDirection } from '@/types'
|
||||
|
||||
export const DEFAULT_PREPAID_EXPENSE_ACCOUNT = '1790' // Övriga förutbetalda kostnader och upplupna intäkter
|
||||
export const DEFAULT_DEFERRED_REVENUE_ACCOUNT = '2970' // Förutbetalda intäkter
|
||||
|
||||
/** Suggest the interim account for a deferred line booked to targetAccount. */
|
||||
export function suggestBalanceAccount(
|
||||
direction: AccrualDirection,
|
||||
targetAccount: string,
|
||||
): string {
|
||||
if (direction === 'revenue') {
|
||||
return DEFAULT_DEFERRED_REVENUE_ACCOUNT
|
||||
}
|
||||
// Hyror (lokalkostnader 50xx) → 1710 Förutbetalda hyreskostnader
|
||||
if (targetAccount.startsWith('50')) return '1710'
|
||||
// Hyra/leasing av anläggningstillgångar (52xx) and leasing personbilar
|
||||
// (5615) → 1720 Förutbetalda leasingavgifter
|
||||
if (targetAccount.startsWith('52') || targetAccount === '5615') return '1720'
|
||||
// Försäkringar (63xx, primarily 6310) → 1730 Förutbetalda försäkringspremier
|
||||
if (targetAccount.startsWith('63')) return '1730'
|
||||
// Räntekostnader (84xx) → 1740 Förutbetalda räntekostnader
|
||||
if (targetAccount.startsWith('84')) return '1740'
|
||||
return DEFAULT_PREPAID_EXPENSE_ACCOUNT
|
||||
}
|
||||
|
||||
/** DB CHECK mirror: 17xx for expense schedules, 29xx for revenue schedules. */
|
||||
export function isValidBalanceAccount(
|
||||
direction: AccrualDirection,
|
||||
account: string,
|
||||
): boolean {
|
||||
return direction === 'expense' ? /^17\d{2}$/.test(account) : /^29\d{2}$/.test(account)
|
||||
}
|
||||
|
||||
interface AccrualItemFields {
|
||||
accrual_period_start?: string | null
|
||||
accrual_period_end?: string | null
|
||||
accrual_balance_account?: string | null
|
||||
}
|
||||
|
||||
/** True when the line carries a complete periodisering period. */
|
||||
export function itemHasAccrual(item: AccrualItemFields): boolean {
|
||||
return Boolean(item.accrual_period_start && item.accrual_period_end)
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a line's net amount is actually booked to: the interim account
|
||||
* for deferred lines, otherwise the line's own P&L account. Used by the
|
||||
* entry generators so VAT/AP/AR lines stay untouched while the net moves to
|
||||
* 17xx/29xx.
|
||||
*/
|
||||
export function resolveBookingAccount(
|
||||
direction: AccrualDirection,
|
||||
item: AccrualItemFields,
|
||||
plAccount: string,
|
||||
): string {
|
||||
if (!itemHasAccrual(item)) return plAccount
|
||||
return item.accrual_balance_account ?? suggestBalanceAccount(direction, plAccount)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Pure date/amount math for periodisering (accrual schedules).
|
||||
*
|
||||
* All dates are ISO strings (YYYY-MM-DD) handled numerically — never via
|
||||
* `new Date()` — so the result is independent of server timezone. Amounts
|
||||
* round via `roundOre` from `@/lib/money`, never `toFixed()`.
|
||||
*/
|
||||
|
||||
export interface InstallmentPlan {
|
||||
/** First day of the calendar month, ISO date. */
|
||||
period_month: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
function parseIso(date: string): { year: number; month: number } {
|
||||
const year = Number(date.slice(0, 4))
|
||||
const month = Number(date.slice(5, 7))
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
|
||||
throw new Error(`Invalid ISO date: ${date}`)
|
||||
}
|
||||
return { year, month }
|
||||
}
|
||||
|
||||
function toMonthIso(year: number, month: number): string {
|
||||
return `${year}-${String(month).padStart(2, '0')}-01`
|
||||
}
|
||||
|
||||
/** '2026-01-15' → '2026-01-01' */
|
||||
export function firstOfMonth(date: string): string {
|
||||
const { year, month } = parseIso(date)
|
||||
return toMonthIso(year, month)
|
||||
}
|
||||
|
||||
/** Number of calendar months touched by [periodStart, periodEnd], inclusive. */
|
||||
export function countCalendarMonths(periodStart: string, periodEnd: string): number {
|
||||
const start = parseIso(periodStart)
|
||||
const end = parseIso(periodEnd)
|
||||
const months = (end.year - start.year) * 12 + (end.month - start.month) + 1
|
||||
if (months < 1) {
|
||||
throw new Error(`Period end ${periodEnd} precedes period start ${periodStart}`)
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
/** First-of-month ISO dates for every calendar month in the period. */
|
||||
export function listCalendarMonths(periodStart: string, periodEnd: string): string[] {
|
||||
const months = countCalendarMonths(periodStart, periodEnd)
|
||||
const start = parseIso(periodStart)
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < months; i++) {
|
||||
const total = start.year * 12 + (start.month - 1) + i
|
||||
result.push(toMonthIso(Math.floor(total / 12), (total % 12) + 1))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a total over N months so the installments sum to the total EXACTLY.
|
||||
* Even split in öre; the remainder öre are distributed one per month from
|
||||
* the first month, so no installment differs by more than 1 öre.
|
||||
*
|
||||
* Throws when the total is too small to give every month at least 1 öre —
|
||||
* the DB CHECK requires every installment amount > 0.
|
||||
*/
|
||||
export function computeInstallmentAmounts(totalAmount: number, months: number): number[] {
|
||||
if (!Number.isInteger(months) || months < 1) {
|
||||
throw new Error(`Invalid month count: ${months}`)
|
||||
}
|
||||
const totalOre = Math.round(totalAmount * 100)
|
||||
if (totalOre < months) {
|
||||
throw new Error(
|
||||
`Amount ${totalAmount} is too small to spread over ${months} months`,
|
||||
)
|
||||
}
|
||||
const baseOre = Math.floor(totalOre / months)
|
||||
const remainder = totalOre - baseOre * months
|
||||
const amounts: number[] = []
|
||||
for (let i = 0; i < months; i++) {
|
||||
amounts.push((baseOre + (i < remainder ? 1 : 0)) / 100)
|
||||
}
|
||||
return amounts
|
||||
}
|
||||
|
||||
/** Full plan: one installment per calendar month in the period. */
|
||||
export function computeInstallments(
|
||||
totalAmount: number,
|
||||
periodStart: string,
|
||||
periodEnd: string,
|
||||
): InstallmentPlan[] {
|
||||
const monthList = listCalendarMonths(periodStart, periodEnd)
|
||||
const amounts = computeInstallmentAmounts(totalAmount, monthList.length)
|
||||
return monthList.map((period_month, i) => ({ period_month, amount: amounts[i] }))
|
||||
}
|
||||
|
||||
/** Latest of any number of ISO dates (lexicographic compare is safe). */
|
||||
export function maxIsoDate(...dates: Array<string | null | undefined>): string {
|
||||
const present = dates.filter((d): d is string => Boolean(d))
|
||||
if (present.length === 0) throw new Error('maxIsoDate requires at least one date')
|
||||
return present.reduce((a, b) => (a >= b ? a : b))
|
||||
}
|
||||
|
||||
/** '2026-03-31' → '2026-04-01' (day after, calendar-correct). */
|
||||
export function dayAfter(date: string): string {
|
||||
const year = Number(date.slice(0, 4))
|
||||
const month = Number(date.slice(5, 7))
|
||||
const day = Number(date.slice(8, 10))
|
||||
// Date.UTC handles month/year rollover; we only ever format back to ISO.
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Typed domain errors for the periodisering (accrual schedule) service.
|
||||
*
|
||||
* Mirrors the lib/bookkeeping/errors.ts pattern: each class carries a stable
|
||||
* `code` so API routes dispatch on the code instead of matching Swedish prose.
|
||||
* The Swedish messages are user-facing and intentionally unchanged from the
|
||||
* original plain-Error throws.
|
||||
*/
|
||||
|
||||
export const ACCRUAL_SCHEDULE_NOT_FOUND = 'ACCRUAL_SCHEDULE_NOT_FOUND' as const
|
||||
export const ACCRUAL_SCHEDULE_NOT_ACTIVE = 'ACCRUAL_SCHEDULE_NOT_ACTIVE' as const
|
||||
export const ACCRUAL_NOTHING_TO_DISSOLVE = 'ACCRUAL_NOTHING_TO_DISSOLVE' as const
|
||||
|
||||
export class AccrualScheduleNotFoundError extends Error {
|
||||
readonly code = ACCRUAL_SCHEDULE_NOT_FOUND
|
||||
constructor() {
|
||||
super('Periodiseringen hittades inte')
|
||||
this.name = 'AccrualScheduleNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
export class AccrualScheduleNotActiveError extends Error {
|
||||
readonly code = ACCRUAL_SCHEDULE_NOT_ACTIVE
|
||||
constructor(public readonly currentStatus: string) {
|
||||
super('Periodiseringen är inte aktiv')
|
||||
this.name = 'AccrualScheduleNotActiveError'
|
||||
}
|
||||
}
|
||||
|
||||
export class AccrualNothingToDissolveError extends Error {
|
||||
readonly code = ACCRUAL_NOTHING_TO_DISSOLVE
|
||||
constructor() {
|
||||
super('Det finns inget kvar att lösa upp')
|
||||
this.name = 'AccrualNothingToDissolveError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isAccrualError(
|
||||
err: unknown,
|
||||
): err is
|
||||
| AccrualScheduleNotFoundError
|
||||
| AccrualScheduleNotActiveError
|
||||
| AccrualNothingToDissolveError {
|
||||
return (
|
||||
err instanceof AccrualScheduleNotFoundError ||
|
||||
err instanceof AccrualScheduleNotActiveError ||
|
||||
err instanceof AccrualNothingToDissolveError
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Bridges invoice flows to accrual schedules: after the registration/revenue
|
||||
* entry is committed, every line that carries a periodisering period gets a
|
||||
* schedule (+ synchronous catch-up posting for months that already began).
|
||||
*
|
||||
* Idempotent per line: lines already covered by a schedule are skipped, so
|
||||
* event replays (supplier_invoice.confirmed) can never double-schedule.
|
||||
* Failures are logged and counted, never thrown — the origin entry is already
|
||||
* committed and must not be rolled back by a schedule hiccup; the caller
|
||||
* surfaces a warning instead.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Invoice, InvoiceItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { createAccrualSchedule } from '@/lib/bookkeeping/accruals/service'
|
||||
import {
|
||||
itemHasAccrual,
|
||||
suggestBalanceAccount,
|
||||
} from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { getRevenueAccount } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import type { EntityType } from '@/types'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('bookkeeping.accruals.from-invoices')
|
||||
|
||||
export interface ScheduleCreationResult {
|
||||
created: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export async function createSchedulesForSupplierInvoice(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
invoice: SupplierInvoice,
|
||||
items: SupplierInvoiceItem[],
|
||||
originJournalEntryId: string,
|
||||
): Promise<ScheduleCreationResult> {
|
||||
const accrualItems = items.filter(itemHasAccrual)
|
||||
const result: ScheduleCreationResult = { created: 0, failed: 0 }
|
||||
if (accrualItems.length === 0) return result
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('accrual_schedules')
|
||||
.select('supplier_invoice_item_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('supplier_invoice_id', invoice.id)
|
||||
const covered = new Set(
|
||||
((existing ?? []) as Array<{ supplier_invoice_item_id: string | null }>).map(
|
||||
(row) => row.supplier_invoice_item_id,
|
||||
),
|
||||
)
|
||||
|
||||
for (const item of accrualItems) {
|
||||
if (item.id && covered.has(item.id)) continue
|
||||
try {
|
||||
const totalSek =
|
||||
Math.round(
|
||||
resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate) *
|
||||
100,
|
||||
) / 100
|
||||
await createAccrualSchedule(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
{
|
||||
direction: 'expense',
|
||||
supplierInvoiceId: invoice.id,
|
||||
supplierInvoiceItemId: item.id ?? null,
|
||||
balanceAccount:
|
||||
item.accrual_balance_account ??
|
||||
suggestBalanceAccount('expense', item.account_number),
|
||||
targetAccount: item.account_number,
|
||||
totalAmountSek: totalSek,
|
||||
periodStart: item.accrual_period_start as string,
|
||||
periodEnd: item.accrual_period_end as string,
|
||||
description: `${item.description} (leverantörsfaktura ${invoice.supplier_invoice_number})`,
|
||||
},
|
||||
{
|
||||
originJournalEntryId,
|
||||
// The registration entry is dated invoice_date — dissolutions may
|
||||
// never precede it.
|
||||
postingFloorDate: invoice.invoice_date,
|
||||
},
|
||||
)
|
||||
result.created++
|
||||
} catch (error) {
|
||||
result.failed++
|
||||
log.error('failed to create accrual schedule for supplier invoice line', error, {
|
||||
companyId,
|
||||
entityId: invoice.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function createSchedulesForCustomerInvoice(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
items: InvoiceItem[],
|
||||
originJournalEntryId: string,
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
): Promise<ScheduleCreationResult> {
|
||||
const accrualItems = items.filter(
|
||||
(item) => itemHasAccrual(item) && item.line_type !== 'text' && !item.deduction_type,
|
||||
)
|
||||
const result: ScheduleCreationResult = { created: 0, failed: 0 }
|
||||
if (accrualItems.length === 0) return result
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('accrual_schedules')
|
||||
.select('invoice_item_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('invoice_id', invoice.id)
|
||||
const covered = new Set(
|
||||
((existing ?? []) as Array<{ invoice_item_id: string | null }>).map(
|
||||
(row) => row.invoice_item_id,
|
||||
),
|
||||
)
|
||||
|
||||
for (const item of accrualItems) {
|
||||
if (item.id && covered.has(item.id)) continue
|
||||
const target = resolveRevenueTarget(item, invoice.vat_treatment, entityType)
|
||||
// reverse_charge/export lines keep their statutory account (3308/3305) so
|
||||
// ruta 39/40 in the momsdeklaration stay correct — never deferred. The
|
||||
// generator applies the same exclusion, so the net stays on 3308/3305.
|
||||
if (target.special) continue
|
||||
try {
|
||||
const targetAccount = target.account
|
||||
const totalSek =
|
||||
Math.round(
|
||||
resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate) *
|
||||
100,
|
||||
) / 100
|
||||
await createAccrualSchedule(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
{
|
||||
direction: 'revenue',
|
||||
invoiceId: invoice.id,
|
||||
invoiceItemId: item.id ?? null,
|
||||
balanceAccount:
|
||||
item.accrual_balance_account ?? suggestBalanceAccount('revenue', targetAccount),
|
||||
targetAccount,
|
||||
totalAmountSek: totalSek,
|
||||
periodStart: item.accrual_period_start as string,
|
||||
periodEnd: item.accrual_period_end as string,
|
||||
description: `${item.description} (faktura ${invoice.invoice_number ?? ''})`.trim(),
|
||||
},
|
||||
{
|
||||
originJournalEntryId,
|
||||
postingFloorDate: invoice.invoice_date,
|
||||
},
|
||||
)
|
||||
result.created++
|
||||
} catch (error) {
|
||||
result.failed++
|
||||
log.error('failed to create accrual schedule for invoice line', error, {
|
||||
companyId,
|
||||
entityId: invoice.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a line's revenue account exactly the way generatePerRateLines does:
|
||||
* per-line override only for ordinary domestic rates; reverse_charge/export
|
||||
* force the statutory account and are flagged `special` (never deferrable).
|
||||
*/
|
||||
function resolveRevenueTarget(
|
||||
item: InvoiceItem,
|
||||
invoiceTreatment: Invoice['vat_treatment'],
|
||||
entityType: EntityType,
|
||||
): { account: string; special: boolean } {
|
||||
const rate = item.vat_rate ?? 0
|
||||
const treatment =
|
||||
rate === 0 && (invoiceTreatment === 'reverse_charge' || invoiceTreatment === 'export')
|
||||
? invoiceTreatment
|
||||
: getVatTreatmentForRate(rate)
|
||||
const special = treatment === 'reverse_charge' || treatment === 'export'
|
||||
const account =
|
||||
!special && item.revenue_account
|
||||
? item.revenue_account
|
||||
: getRevenueAccount(treatment, entityType)
|
||||
return { account, special }
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* Periodisering (accrual schedule) service.
|
||||
*
|
||||
* A schedule spreads one invoice line's net amount over the calendar months
|
||||
* of a service period. The origin entry (supplier invoice registration /
|
||||
* customer invoice revenue entry) books the net to an interim account
|
||||
* (17xx/29xx); this service creates and posts the monthly dissolution
|
||||
* entries:
|
||||
*
|
||||
* expense: Dr target (5xxx/6xxx) / Cr balance (17xx)
|
||||
* revenue: Dr balance (29xx) / Cr target (3xxx)
|
||||
*
|
||||
* All entries go through the engine (source_type 'accrual') — never direct
|
||||
* inserts. Posting dates are max(period_month, posting_floor_date,
|
||||
* company lock date + 1) so catch-up months book correctly and the interim
|
||||
* account never goes negative.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type {
|
||||
AccrualDirection,
|
||||
AccrualSchedule,
|
||||
AccrualScheduleInstallment,
|
||||
CreateJournalEntryLineInput,
|
||||
} from '@/types'
|
||||
import {
|
||||
createJournalEntry,
|
||||
findFiscalPeriod,
|
||||
reverseEntry,
|
||||
} from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
CannotReverseNonPostedError,
|
||||
EntryAlreadyReversedError,
|
||||
} from '@/lib/bookkeeping/errors'
|
||||
import {
|
||||
AccrualNothingToDissolveError,
|
||||
AccrualScheduleNotActiveError,
|
||||
AccrualScheduleNotFoundError,
|
||||
} from '@/lib/bookkeeping/accruals/errors'
|
||||
import {
|
||||
computeInstallments,
|
||||
dayAfter,
|
||||
firstOfMonth,
|
||||
maxIsoDate,
|
||||
} from '@/lib/bookkeeping/accruals/compute'
|
||||
import { roundOre, sumOre } from '@/lib/money'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('bookkeeping.accruals')
|
||||
|
||||
export interface AccrualScheduleSpec {
|
||||
direction: AccrualDirection
|
||||
supplierInvoiceId?: string
|
||||
supplierInvoiceItemId?: string | null
|
||||
invoiceId?: string
|
||||
invoiceItemId?: string | null
|
||||
/** Interim account: 17xx (expense) / 29xx (revenue). */
|
||||
balanceAccount: string
|
||||
/** The P&L account the amount dissolves to. */
|
||||
targetAccount: string
|
||||
/** Net amount in SEK as booked on the origin entry (ex VAT). */
|
||||
totalAmountSek: number
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PostDueResult {
|
||||
posted: number
|
||||
failed: number
|
||||
skipped: number
|
||||
errors: Array<{ installmentId: string; message: string }>
|
||||
}
|
||||
|
||||
type ScheduleRow = AccrualSchedule
|
||||
type InstallmentRow = AccrualScheduleInstallment & { schedule?: ScheduleRow | null }
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
async function fetchLockDateFloor(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<string | null> {
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const lockedThrough = (data as { bookkeeping_locked_through?: string | null } | null)
|
||||
?.bookkeeping_locked_through
|
||||
return lockedThrough ? dayAfter(lockedThrough) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Earliest OPEN fiscal period starting after `date`. Used to clamp a posting
|
||||
* date forward when the computed date falls inside a closed period (bokslut
|
||||
* done) — same spirit as the company lock-date floor.
|
||||
*/
|
||||
async function findNextOpenPeriodStart(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
date: string,
|
||||
): Promise<{ fiscalPeriodId: string; periodStart: string } | null> {
|
||||
const { data } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_closed', false)
|
||||
.gt('period_start', date)
|
||||
.order('period_start', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
const row = data as { id: string; period_start: string } | null
|
||||
return row ? { fiscalPeriodId: row.id, periodStart: row.period_start } : null
|
||||
}
|
||||
|
||||
function dissolutionLines(
|
||||
schedule: Pick<ScheduleRow, 'direction' | 'balance_account' | 'target_account'>,
|
||||
amount: number,
|
||||
lineDescription: string,
|
||||
): CreateJournalEntryLineInput[] {
|
||||
if (schedule.direction === 'expense') {
|
||||
return [
|
||||
{
|
||||
account_number: schedule.target_account,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
{
|
||||
account_number: schedule.balance_account,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
account_number: schedule.balance_account,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
{
|
||||
account_number: schedule.target_account,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a schedule + its monthly installments for one deferred invoice
|
||||
* line, then immediately post every installment whose month has already
|
||||
* begun (catch-up). Catch-up failures do not throw — they are recorded on
|
||||
* the installment (last_error) and retried by the daily cron.
|
||||
*/
|
||||
export async function createAccrualSchedule(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
spec: AccrualScheduleSpec,
|
||||
options: {
|
||||
originJournalEntryId?: string | null
|
||||
/** Entry date of the origin entry; floors all dissolution dates. */
|
||||
postingFloorDate: string
|
||||
/** Skip synchronous catch-up posting (tests / bulk imports). */
|
||||
postCatchUp?: boolean
|
||||
today?: string
|
||||
},
|
||||
): Promise<AccrualSchedule> {
|
||||
const plan = computeInstallments(spec.totalAmountSek, spec.periodStart, spec.periodEnd)
|
||||
|
||||
const { data: schedule, error: scheduleError } = await supabase
|
||||
.from('accrual_schedules')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
direction: spec.direction,
|
||||
supplier_invoice_id: spec.supplierInvoiceId ?? null,
|
||||
supplier_invoice_item_id: spec.supplierInvoiceItemId ?? null,
|
||||
invoice_id: spec.invoiceId ?? null,
|
||||
invoice_item_id: spec.invoiceItemId ?? null,
|
||||
balance_account: spec.balanceAccount,
|
||||
target_account: spec.targetAccount,
|
||||
total_amount: roundOre(spec.totalAmountSek),
|
||||
period_start: spec.periodStart,
|
||||
period_end: spec.periodEnd,
|
||||
months: plan.length,
|
||||
origin_journal_entry_id: options.originJournalEntryId ?? null,
|
||||
posting_floor_date: options.postingFloorDate,
|
||||
status: 'active',
|
||||
description: spec.description,
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (scheduleError || !schedule) {
|
||||
throw new Error(
|
||||
`Failed to create accrual schedule: ${scheduleError?.message ?? 'no row returned'}`,
|
||||
)
|
||||
}
|
||||
|
||||
const scheduleRow = schedule as ScheduleRow
|
||||
|
||||
const { error: installmentError } = await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.insert(
|
||||
plan.map((installment) => ({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
schedule_id: scheduleRow.id,
|
||||
period_month: installment.period_month,
|
||||
amount: installment.amount,
|
||||
status: 'pending',
|
||||
})),
|
||||
)
|
||||
|
||||
if (installmentError) {
|
||||
// Schedule without installments is inert but confusing — clean it up.
|
||||
await supabase.from('accrual_schedules').delete().eq('id', scheduleRow.id)
|
||||
throw new Error(`Failed to create accrual installments: ${installmentError.message}`)
|
||||
}
|
||||
|
||||
if (options.postCatchUp !== false) {
|
||||
const result = await postDueInstallments(supabase, companyId, {
|
||||
userId,
|
||||
scheduleId: scheduleRow.id,
|
||||
today: options.today,
|
||||
})
|
||||
if (result.failed > 0) {
|
||||
log.warn('accrual catch-up posting failed for some installments', {
|
||||
companyId,
|
||||
scheduleId: scheduleRow.id,
|
||||
failed: result.failed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return scheduleRow
|
||||
}
|
||||
|
||||
/**
|
||||
* Post every pending installment whose calendar month has begun.
|
||||
* Used by the daily cron, the manual "Bokför förfallna" action, and the
|
||||
* synchronous catch-up at schedule creation (scheduleId filter).
|
||||
*
|
||||
* Each installment books independently; one failure never blocks the rest.
|
||||
*/
|
||||
export async function postDueInstallments(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
options: {
|
||||
/** Falls back to each schedule's creator for cron runs. */
|
||||
userId?: string
|
||||
scheduleId?: string
|
||||
today?: string
|
||||
} = {},
|
||||
): Promise<PostDueResult> {
|
||||
const today = options.today ?? todayIso()
|
||||
const result: PostDueResult = { posted: 0, failed: 0, skipped: 0, errors: [] }
|
||||
|
||||
let query = supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.select('*, schedule:accrual_schedules(*)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending')
|
||||
.lte('period_month', firstOfMonth(today))
|
||||
.order('period_month', { ascending: true })
|
||||
|
||||
if (options.scheduleId) {
|
||||
query = query.eq('schedule_id', options.scheduleId)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) {
|
||||
throw new Error(`Failed to load due accrual installments: ${error.message}`)
|
||||
}
|
||||
|
||||
const due = ((data ?? []) as InstallmentRow[]).filter(
|
||||
(installment) => installment.schedule?.status === 'active',
|
||||
)
|
||||
if (due.length === 0) return result
|
||||
|
||||
const lockFloor = await fetchLockDateFloor(supabase, companyId)
|
||||
const touchedSchedules = new Set<string>()
|
||||
|
||||
for (const installment of due) {
|
||||
const schedule = installment.schedule as ScheduleRow
|
||||
try {
|
||||
let entryDate = maxIsoDate(
|
||||
installment.period_month,
|
||||
schedule.posting_floor_date,
|
||||
lockFloor,
|
||||
)
|
||||
let fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
|
||||
if (!fiscalPeriodId) {
|
||||
// The computed date can fall inside a CLOSED fiscal period (bokslut
|
||||
// done while the company lock date lags behind). Clamp forward to the
|
||||
// start of the earliest open period — same spirit as the lockFloor —
|
||||
// instead of retrying the same impossible date forever.
|
||||
const clamped = await findNextOpenPeriodStart(supabase, companyId, entryDate)
|
||||
if (!clamped) {
|
||||
throw new Error(
|
||||
`Ingen öppen räkenskapsperiod för ${entryDate} eller senare — ` +
|
||||
'skapa nästa räkenskapsår för att kunna bokföra periodiseringen',
|
||||
)
|
||||
}
|
||||
entryDate = clamped.periodStart
|
||||
fiscalPeriodId = clamped.fiscalPeriodId
|
||||
}
|
||||
|
||||
const monthLabel = installment.period_month.slice(0, 7)
|
||||
const description = schedule.description
|
||||
? `Periodisering ${monthLabel}: ${schedule.description}`
|
||||
: `Periodisering ${monthLabel}`
|
||||
|
||||
const entry = await createJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
options.userId ?? schedule.user_id,
|
||||
{
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: entryDate,
|
||||
description,
|
||||
source_type: 'accrual',
|
||||
source_id: schedule.id,
|
||||
lines: dissolutionLines(schedule, installment.amount, description),
|
||||
},
|
||||
)
|
||||
|
||||
// CAS claim: only the runner that flips pending→posted keeps its entry.
|
||||
// A concurrent runner (cron + manual button) loses the race and stornos
|
||||
// its own entry so the ledger nets to a single dissolution.
|
||||
const { data: claimed, error: claimError } = await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.update({
|
||||
status: 'posted',
|
||||
journal_entry_id: entry.id,
|
||||
posted_at: new Date().toISOString(),
|
||||
last_error: null,
|
||||
})
|
||||
.eq('id', installment.id)
|
||||
.eq('status', 'pending')
|
||||
.select('id')
|
||||
|
||||
if (claimError) {
|
||||
// The journal entry is already committed but the installment was NOT
|
||||
// flipped to posted — without a storno the next run would book the
|
||||
// same month twice. Mirror the dissolveScheduleNow handling: reverse
|
||||
// our own entry best-effort, then record the failure (the catch below
|
||||
// writes last_error on the installment).
|
||||
let reversalNote = ''
|
||||
try {
|
||||
await reverseEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
options.userId ?? schedule.user_id,
|
||||
entry.id,
|
||||
)
|
||||
} catch (reversalError) {
|
||||
log.error('failed to reverse accrual entry after claim error', reversalError, {
|
||||
companyId,
|
||||
installmentId: installment.id,
|
||||
entryId: entry.id,
|
||||
})
|
||||
reversalNote =
|
||||
`; storno av verifikat ${entry.id} misslyckades också: ` +
|
||||
getErrorMessage(reversalError)
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to mark installment posted: ${claimError.message}${reversalNote}`,
|
||||
)
|
||||
}
|
||||
if (!claimed || claimed.length === 0) {
|
||||
log.warn('accrual installment claimed concurrently; reversing duplicate entry', {
|
||||
companyId,
|
||||
installmentId: installment.id,
|
||||
entryId: entry.id,
|
||||
})
|
||||
await reverseEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
options.userId ?? schedule.user_id,
|
||||
entry.id,
|
||||
)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
touchedSchedules.add(schedule.id)
|
||||
result.posted++
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error)
|
||||
log.error('failed to post accrual installment', error, {
|
||||
companyId,
|
||||
installmentId: installment.id,
|
||||
scheduleId: schedule.id,
|
||||
})
|
||||
result.failed++
|
||||
result.errors.push({ installmentId: installment.id, message })
|
||||
await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.update({ last_error: message })
|
||||
.eq('id', installment.id)
|
||||
.eq('status', 'pending')
|
||||
}
|
||||
}
|
||||
|
||||
for (const scheduleId of touchedSchedules) {
|
||||
await completeScheduleIfDone(supabase, companyId, scheduleId)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function completeScheduleIfDone(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
scheduleId: string,
|
||||
): Promise<void> {
|
||||
const { count, error } = await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('schedule_id', scheduleId)
|
||||
.eq('status', 'pending')
|
||||
|
||||
if (error || count === null || count > 0) return
|
||||
|
||||
await supabase
|
||||
.from('accrual_schedules')
|
||||
.update({ status: 'completed' })
|
||||
.eq('id', scheduleId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'active')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dissolve everything that remains on a schedule in one entry, dated today
|
||||
* (clamped by lock date / posting floor). Used when the service period ends
|
||||
* early or the user simply wants the rest expensed now.
|
||||
*/
|
||||
export async function dissolveScheduleNow(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
scheduleId: string,
|
||||
options: { today?: string } = {},
|
||||
): Promise<{ journalEntryId: string; amount: number }> {
|
||||
const today = options.today ?? todayIso()
|
||||
|
||||
const { data: scheduleData, error: scheduleError } = await supabase
|
||||
.from('accrual_schedules')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', scheduleId)
|
||||
.single()
|
||||
|
||||
if (scheduleError || !scheduleData) {
|
||||
throw new AccrualScheduleNotFoundError()
|
||||
}
|
||||
const schedule = scheduleData as ScheduleRow
|
||||
if (schedule.status !== 'active') {
|
||||
throw new AccrualScheduleNotActiveError(schedule.status)
|
||||
}
|
||||
|
||||
const { data: pendingData, error: pendingError } = await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('schedule_id', scheduleId)
|
||||
.eq('status', 'pending')
|
||||
|
||||
if (pendingError) {
|
||||
throw new Error(`Failed to load installments: ${pendingError.message}`)
|
||||
}
|
||||
const pending = (pendingData ?? []) as InstallmentRow[]
|
||||
if (pending.length === 0) {
|
||||
await completeScheduleIfDone(supabase, companyId, scheduleId)
|
||||
throw new AccrualNothingToDissolveError()
|
||||
}
|
||||
|
||||
const amount = sumOre(pending.map((installment) => installment.amount))
|
||||
|
||||
const lockFloor = await fetchLockDateFloor(supabase, companyId)
|
||||
const entryDate = maxIsoDate(today, schedule.posting_floor_date, lockFloor)
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
|
||||
if (!fiscalPeriodId) {
|
||||
throw new Error(`Ingen öppen räkenskapsperiod för ${entryDate}`)
|
||||
}
|
||||
|
||||
const description = schedule.description
|
||||
? `Periodisering, slutupplösning: ${schedule.description}`
|
||||
: 'Periodisering, slutupplösning'
|
||||
|
||||
const entry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: entryDate,
|
||||
description,
|
||||
source_type: 'accrual',
|
||||
source_id: schedule.id,
|
||||
lines: dissolutionLines(schedule, amount, description),
|
||||
})
|
||||
|
||||
const pendingIds = pending.map((installment) => installment.id)
|
||||
const { data: claimed, error: claimError } = await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.update({
|
||||
status: 'posted',
|
||||
journal_entry_id: entry.id,
|
||||
posted_at: new Date().toISOString(),
|
||||
last_error: null,
|
||||
})
|
||||
.in('id', pendingIds)
|
||||
.eq('status', 'pending')
|
||||
.select('id')
|
||||
|
||||
if (claimError || !claimed || claimed.length !== pendingIds.length) {
|
||||
// Concurrent posting changed the set under us — undo our combined entry
|
||||
// and let the caller retry against the new state. If the storno itself
|
||||
// fails, the combined entry stands while some installments point at the
|
||||
// cron's entries — the interim account would dissolve twice. Surface
|
||||
// both failures and pin the alert on the installments so the
|
||||
// periodiseringar UI shows the stuck state instead of nothing.
|
||||
try {
|
||||
await reverseEntry(supabase, companyId, userId, entry.id)
|
||||
} catch (reversalError) {
|
||||
const message = getErrorMessage(reversalError)
|
||||
log.error('failed to reverse combined dissolution entry after lost claim race', reversalError, {
|
||||
companyId,
|
||||
scheduleId,
|
||||
entryId: entry.id,
|
||||
})
|
||||
await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.update({
|
||||
last_error: `Storno av samlad upplösning (verifikat ${entry.id}) misslyckades — kontrollera interimskontot: ${message}`.slice(0, 2_000),
|
||||
})
|
||||
.in('id', pendingIds)
|
||||
.eq('company_id', companyId)
|
||||
throw new Error(
|
||||
`Periodiseringen ändrades samtidigt och vändningen av det samlade verifikatet misslyckades: ${message}`,
|
||||
)
|
||||
}
|
||||
throw new Error('Periodiseringen ändrades samtidigt — försök igen')
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('accrual_schedules')
|
||||
.update({ status: 'completed' })
|
||||
.eq('id', scheduleId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'active')
|
||||
|
||||
return { journalEntryId: entry.id, amount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all schedules belonging to a credited/cancelled invoice:
|
||||
* pending installments are cancelled, already-posted dissolutions are
|
||||
* reversed (storno), and the schedule is marked cancelled. The caller's
|
||||
* credit-note entry reverses the interim account at its full original
|
||||
* amount, so the net of origin + dissolutions + stornos + credit is zero.
|
||||
*
|
||||
* A schedule is only marked cancelled when ALL its posted dissolutions
|
||||
* reversed cleanly — otherwise it stays 'active' (so the UI keeps showing
|
||||
* the un-reversed remainder instead of remaining=0 while 17xx/29xx is still
|
||||
* unbalanced) and the stuck installments get a descriptive last_error.
|
||||
* Callers should surface `failedReversals > 0` as a warning.
|
||||
*/
|
||||
export async function cancelSchedulesForSource(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
source: { supplierInvoiceId?: string; invoiceId?: string },
|
||||
options: { reversalDate?: string } = {},
|
||||
): Promise<{
|
||||
cancelledSchedules: number
|
||||
reversedEntries: number
|
||||
failedReversals: number
|
||||
}> {
|
||||
if (!source.supplierInvoiceId && !source.invoiceId) {
|
||||
throw new Error('cancelSchedulesForSource requires a source invoice id')
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('accrual_schedules')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.neq('status', 'cancelled')
|
||||
query = source.supplierInvoiceId
|
||||
? query.eq('supplier_invoice_id', source.supplierInvoiceId)
|
||||
: query.eq('invoice_id', source.invoiceId as string)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) {
|
||||
throw new Error(`Failed to load accrual schedules: ${error.message}`)
|
||||
}
|
||||
const schedules = (data ?? []) as ScheduleRow[]
|
||||
|
||||
let cancelledSchedules = 0
|
||||
let reversedEntries = 0
|
||||
let failedReversals = 0
|
||||
|
||||
for (const schedule of schedules) {
|
||||
// Cancel pending months first so the posting cron cannot book new
|
||||
// dissolutions for a credited invoice while (or after) we storno the
|
||||
// already-posted ones.
|
||||
await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('company_id', companyId)
|
||||
.eq('schedule_id', schedule.id)
|
||||
.eq('status', 'pending')
|
||||
|
||||
const { data: postedData } = await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('schedule_id', schedule.id)
|
||||
.eq('status', 'posted')
|
||||
|
||||
let scheduleFailures = 0
|
||||
for (const installment of (postedData ?? []) as Array<{
|
||||
id: string
|
||||
journal_entry_id: string | null
|
||||
}>) {
|
||||
if (!installment.journal_entry_id) continue
|
||||
try {
|
||||
await reverseEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
installment.journal_entry_id,
|
||||
options.reversalDate,
|
||||
)
|
||||
reversedEntries++
|
||||
} catch (error) {
|
||||
// A dissolution stornoed by an earlier (partially failed) cancel run
|
||||
// is fine — idempotent re-credit, treated as success.
|
||||
if (
|
||||
error instanceof EntryAlreadyReversedError ||
|
||||
(error instanceof CannotReverseNonPostedError &&
|
||||
error.currentStatus === 'reversed')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
scheduleFailures++
|
||||
const message = getErrorMessage(error)
|
||||
log.warn('could not reverse accrual dissolution during cancel', {
|
||||
companyId,
|
||||
scheduleId: schedule.id,
|
||||
journalEntryId: installment.journal_entry_id,
|
||||
message,
|
||||
})
|
||||
// Posted installments freeze their financial fields, but last_error
|
||||
// stays writable — surface the stuck storno in the periodiseringar UI.
|
||||
await supabase
|
||||
.from('accrual_schedule_installments')
|
||||
.update({
|
||||
last_error: `Storno vid kreditering misslyckades: ${message}`,
|
||||
})
|
||||
.eq('id', installment.id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduleFailures > 0) {
|
||||
failedReversals += scheduleFailures
|
||||
continue
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('accrual_schedules')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', schedule.id)
|
||||
.eq('company_id', companyId)
|
||||
cancelledSchedules++
|
||||
}
|
||||
|
||||
return { cancelledSchedules, reversedEntries, failedReversals }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { eventBus } from '@/lib/events/bus'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
@@ -83,6 +84,23 @@ async function handleSupplierInvoiceConfirmed(
|
||||
.from('supplier_invoices')
|
||||
.update({ registration_journal_entry_id: journalEntry.id })
|
||||
.eq('id', supplierInvoice.id)
|
||||
|
||||
// Lines with a periodisering period get their schedule + catch-up
|
||||
// dissolutions. Idempotent per line, so a replayed event is safe.
|
||||
const scheduleResult = await createSchedulesForSupplierInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
supplierInvoice,
|
||||
items as SupplierInvoiceItem[],
|
||||
journalEntry.id,
|
||||
)
|
||||
if (scheduleResult.failed > 0) {
|
||||
log.error('accrual schedule creation failed for confirmed supplier invoice', {
|
||||
supplierInvoiceId: supplierInvoice.id,
|
||||
failed: scheduleResult.failed,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to create registration journal entry:', err)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
|
||||
import { resolveBookingAccount } from './accruals/account-suggestions'
|
||||
import { generateSalesVatLines } from './vat-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
|
||||
@@ -48,6 +49,12 @@ function buildInvoiceDescription(
|
||||
/**
|
||||
* Group invoice items by VAT rate and generate per-rate revenue + VAT lines.
|
||||
* Returns credit lines only (revenue + VAT). The caller adds the debit side.
|
||||
*
|
||||
* options.deferAccruals: substitute the 29xx interim account for lines with a
|
||||
* periodisering period. Only the callers that also create/cancel accrual
|
||||
* schedules may pass true (invoice entry + credit note) — the cash-method
|
||||
* entry books revenue directly even if a line carries stale accrual fields,
|
||||
* since no schedule would ever dissolve the interim balance.
|
||||
*/
|
||||
function generatePerRateLines(
|
||||
items: InvoiceItem[],
|
||||
@@ -55,7 +62,8 @@ function generatePerRateLines(
|
||||
entityType: EntityType,
|
||||
invoiceTagText: string,
|
||||
currency?: string | null,
|
||||
exchangeRate?: number | null
|
||||
exchangeRate?: number | null,
|
||||
options?: { deferAccruals?: boolean }
|
||||
): CreateJournalEntryLineInput[] {
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const isForeign = currency != null && currency !== 'SEK'
|
||||
@@ -132,9 +140,16 @@ function generatePerRateLines(
|
||||
// a per-line override only applies to ordinary domestic rates so EU/export
|
||||
// sales keep landing in the right VAT-declaration ruta.
|
||||
const isSpecialTreatment = treatment === 'reverse_charge' || treatment === 'export'
|
||||
const account = !isSpecialTreatment && item.revenue_account
|
||||
const plAccount = !isSpecialTreatment && item.revenue_account
|
||||
? item.revenue_account
|
||||
: getRevenueAccount(treatment, entityType)
|
||||
// Periodiserade lines credit the 29xx interim account (förutbetalda
|
||||
// intäkter) instead of revenue; the schedule dissolves it monthly. Output
|
||||
// VAT below is untouched — moms is never deferred. Special treatments are
|
||||
// never deferred (ruta 39/40 must reflect the full period's sales).
|
||||
const account = isSpecialTreatment || !options?.deferAccruals
|
||||
? plAccount
|
||||
: resolveBookingAccount('revenue', item, plAccount)
|
||||
|
||||
const group = rateGroups.get(rate) ?? { vatAmount: 0, byAccount: new Map<string, number>() }
|
||||
group.vatAmount += item.vat_amount || 0
|
||||
@@ -297,7 +312,10 @@ export async function createInvoiceJournalEntry(
|
||||
if (invoice.items && invoice.items.length > 0) {
|
||||
creditLines.push(...generatePerRateLines(
|
||||
invoice.items, invoice.vat_treatment, entityType, tag,
|
||||
invoice.currency, invoice.exchange_rate
|
||||
invoice.currency, invoice.exchange_rate,
|
||||
// Schedules are created right after this entry commits (send/mark-sent
|
||||
// flows), so deferring to 29xx here is safe.
|
||||
{ deferAccruals: true }
|
||||
))
|
||||
} else {
|
||||
// Fallback: no items available, use invoice-level amounts
|
||||
@@ -523,7 +541,11 @@ export async function createCreditNoteJournalEntry(
|
||||
// Use absolute items for generatePerRateLines, then swap debit/credit
|
||||
const creditLines = generatePerRateLines(
|
||||
creditNote.items, creditNote.vat_treatment, entityType, tag,
|
||||
creditNote.currency, creditNote.exchange_rate
|
||||
creditNote.currency, creditNote.exchange_rate,
|
||||
// Credit-note items carry the original's accrual fields so the reversal
|
||||
// hits the same 29xx interim account; the original's schedule is
|
||||
// cancelled/stornoed by the credit flow.
|
||||
{ deferAccruals: true }
|
||||
)
|
||||
for (const line of creditLines) {
|
||||
debitLines.push({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
|
||||
import { resolveBookingAccount } from './accruals/account-suggestions'
|
||||
import {
|
||||
generateReverseChargeLines,
|
||||
generateReverseChargeBasisLines,
|
||||
@@ -68,12 +69,16 @@ export async function createSupplierInvoiceRegistrationEntry(
|
||||
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
|
||||
const isForeign = invoice.currency !== 'SEK'
|
||||
|
||||
// Aggregate expense amounts by account number and convert to SEK
|
||||
// Aggregate expense amounts by account number and convert to SEK.
|
||||
// Periodiserade lines book their net to the 17xx interim account instead
|
||||
// of the cost account (resolveBookingAccount); VAT and 2440 are untouched —
|
||||
// moms is never deferred (redovisas på fakturadatum).
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
|
||||
const current = expenseByAccount.get(bookingAccount) || 0
|
||||
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
|
||||
expenseByAccount.set(item.account_number, current + itemSek)
|
||||
expenseByAccount.set(bookingAccount, current + itemSek)
|
||||
}
|
||||
|
||||
// Debit: Expense accounts (in SEK)
|
||||
@@ -538,13 +543,17 @@ export async function createSupplierCreditNoteEntry(
|
||||
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomstnr ${creditNote.arrival_number})`)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Credit: Expense accounts (reverse, in SEK)
|
||||
// Credit: Expense accounts (reverse, in SEK). The caller passes the
|
||||
// ORIGINAL invoice's items so deferred lines reverse against the same 17xx
|
||||
// interim account they were registered on (the schedule's posted
|
||||
// dissolutions are stornoed separately by cancelSchedulesForSource).
|
||||
const creditLines: CreateJournalEntryLineInput[] = []
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
|
||||
const current = expenseByAccount.get(bookingAccount) || 0
|
||||
const itemSek = Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate))
|
||||
expenseByAccount.set(item.account_number, current + itemSek)
|
||||
expenseByAccount.set(bookingAccount, current + itemSek)
|
||||
}
|
||||
|
||||
for (const [accountNumber, amount] of expenseByAccount) {
|
||||
|
||||
@@ -47,7 +47,13 @@ vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: vi.fn(() => makeClient()),
|
||||
}))
|
||||
|
||||
import { uploadDocument, createNewVersion, verifyIntegrity, _resetBucketVerified } from '../document-service'
|
||||
import {
|
||||
uploadDocument,
|
||||
createNewVersion,
|
||||
verifyIntegrity,
|
||||
validateDocumentMagicBytes,
|
||||
_resetBucketVerified,
|
||||
} from '../document-service'
|
||||
|
||||
// A minimal valid PDF byte sequence (header + EOF) — passes magic-byte check.
|
||||
function pdfBuffer(payload = 'test'): ArrayBuffer {
|
||||
@@ -62,6 +68,54 @@ beforeEach(() => {
|
||||
results = []
|
||||
})
|
||||
|
||||
describe('validateDocumentMagicBytes — application/xhtml+xml', () => {
|
||||
const toBuffer = (text: string, bom = false): ArrayBuffer => {
|
||||
const bytes = new TextEncoder().encode(bom ? `${text}` : text)
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
it('accepts content starting with an XML declaration', () => {
|
||||
const xhtml = '<?xml version="1.0" encoding="UTF-8"?>\n<html xmlns="http://www.w3.org/1999/xhtml"></html>'
|
||||
expect(validateDocumentMagicBytes(toBuffer(xhtml), 'application/xhtml+xml')).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts content starting with an HTML doctype or <html>, case-insensitively', () => {
|
||||
expect(
|
||||
validateDocumentMagicBytes(toBuffer('<!DOCTYPE html>\n<html></html>'), 'application/xhtml+xml'),
|
||||
).toBeNull()
|
||||
expect(
|
||||
validateDocumentMagicBytes(toBuffer('<!doctype HTML><html></html>'), 'application/xhtml+xml'),
|
||||
).toBeNull()
|
||||
expect(
|
||||
validateDocumentMagicBytes(toBuffer('<HTML xmlns="http://www.w3.org/1999/xhtml"></HTML>'), 'application/xhtml+xml'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a UTF-8 BOM and leading whitespace before the marker', () => {
|
||||
expect(
|
||||
validateDocumentMagicBytes(toBuffer('\n <?xml version="1.0"?><html></html>', true), 'application/xhtml+xml'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects content that is not XHTML/XML', () => {
|
||||
expect(validateDocumentMagicBytes(toBuffer('just some text'), 'application/xhtml+xml')).toMatch(
|
||||
/kunde inte verifieras/,
|
||||
)
|
||||
expect(validateDocumentMagicBytes(pdfBuffer(), 'application/xhtml+xml')).toMatch(
|
||||
/kunde inte verifieras/,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not loosen validation for other declared types', () => {
|
||||
// XHTML bytes declared as PDF must still be rejected.
|
||||
expect(validateDocumentMagicBytes(toBuffer('<?xml version="1.0"?>'), 'application/pdf')).toMatch(
|
||||
/kunde inte verifieras/,
|
||||
)
|
||||
// And a real PDF still passes as PDF.
|
||||
expect(validateDocumentMagicBytes(pdfBuffer(), 'application/pdf')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadDocument', () => {
|
||||
it('computes SHA-256 hash, stores metadata, emits document.uploaded', async () => {
|
||||
const doc = makeDocumentAttachment({
|
||||
|
||||
@@ -88,6 +88,23 @@ function detectFileMagic(bytes: Uint8Array): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* XHTML/XML has no binary magic number. For the declared type
|
||||
* application/xhtml+xml (system-generated iXBRL årsredovisningar) we instead
|
||||
* require the content to start with an XML declaration, an HTML doctype, or
|
||||
* an <html> root element (after an optional UTF-8 BOM and leading
|
||||
* whitespace). This branch is consulted ONLY for that declared type — it
|
||||
* never loosens detection for PDF/PNG/JPEG/WEBP uploads.
|
||||
*/
|
||||
function looksLikeXhtml(bytes: Uint8Array): boolean {
|
||||
const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
|
||||
const head = Buffer.from(bytes.slice(offset, offset + 256))
|
||||
.toString('utf8')
|
||||
.replace(/^[\s]+/, '')
|
||||
.toLowerCase()
|
||||
return head.startsWith('<?xml') || head.startsWith('<!doctype html') || head.startsWith('<html')
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the buffer actually contains a file of the declared type.
|
||||
* Returns an error string or null if valid. HEIC has many ftyp brands so
|
||||
@@ -96,6 +113,10 @@ function detectFileMagic(bytes: Uint8Array): string | null {
|
||||
*/
|
||||
export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType: string): string | null {
|
||||
if (declaredMimeType === 'image/heic') return null
|
||||
if (declaredMimeType === 'application/xhtml+xml') {
|
||||
if (looksLikeXhtml(new Uint8Array(buffer))) return null
|
||||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett XHTML/XML-dokument.`
|
||||
}
|
||||
const detected = detectFileMagic(new Uint8Array(buffer))
|
||||
if (!detected) {
|
||||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar vara skadad eller inte en riktig binärfil — vid uppladdning via API, kontrollera att file_content_base64 är base64-kodade råbytes, inte en textrepresentation.`
|
||||
|
||||
@@ -179,6 +179,13 @@ interface ErrorResponseContext {
|
||||
details?: unknown
|
||||
/** When known, override the http status from the registry entry. */
|
||||
status?: number
|
||||
/**
|
||||
* Override the registry messages when the route computes a dynamic message
|
||||
* (e.g. interpolating a rolling year range). Provide both or neither so the
|
||||
* sv/en pair never drifts apart.
|
||||
*/
|
||||
messageSv?: string
|
||||
messageEn?: string
|
||||
}
|
||||
|
||||
interface MinimalLogger {
|
||||
@@ -404,5 +411,15 @@ export function errorResponseFromCode(
|
||||
const entry = entryFor(code)
|
||||
log.error(code, ctx.reason ?? entry.message_en, { requestId: ctx.requestId })
|
||||
const status = ctx.status ?? entry.httpStatus
|
||||
return buildResponse(code, { ...entry, httpStatus: status }, ctx.requestId, ctx.details)
|
||||
return buildResponse(
|
||||
code,
|
||||
{
|
||||
...entry,
|
||||
httpStatus: status,
|
||||
...(ctx.messageSv ? { message_sv: ctx.messageSv } : {}),
|
||||
...(ctx.messageEn ? { message_en: ctx.messageEn } : {}),
|
||||
},
|
||||
ctx.requestId,
|
||||
ctx.details,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -597,6 +597,31 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'ROT/RUT-avdraget kunde inte valideras. Kontrollera personnummer och fastighetsbeteckning.',
|
||||
message_en: 'ROT/RUT deduction failed validation. Check personnummer and housing designation.',
|
||||
},
|
||||
INVOICE_CREATE_ACCRUAL_INVALID: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Periodisering kan inte användas här. Den kräver faktureringsmetoden och stöds inte för omvänd skattskyldighet, export eller proforma.',
|
||||
message_en: 'Periodisering cannot be used here. It requires the accrual method and is not supported for reverse charge, export, or proforma documents.',
|
||||
},
|
||||
ACCRUAL_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Periodiseringen kunde inte hittas.',
|
||||
message_en: 'Accrual schedule not found.',
|
||||
},
|
||||
ACCRUAL_DISSOLVE_FAILED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Periodiseringen kunde inte lösas upp.',
|
||||
message_en: 'The accrual schedule could not be dissolved.',
|
||||
},
|
||||
ACCRUAL_NOT_ACTIVE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Periodiseringen är inte aktiv.',
|
||||
message_en: 'The accrual schedule is not active.',
|
||||
},
|
||||
ACCRUAL_NOTHING_TO_DISSOLVE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Det finns inget kvar att lösa upp.',
|
||||
message_en: 'There is nothing left to dissolve on this accrual schedule.',
|
||||
},
|
||||
INVOICE_CREATE_ROT_RUT_PERSONNUMMER_INVALID: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Personnumret för ROT/RUT-avdraget är ogiltigt.',
|
||||
@@ -1298,6 +1323,17 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Tokensubmissionen misslyckades.',
|
||||
message_en: 'Failed to submit provider token.',
|
||||
},
|
||||
PROVIDER_TOKEN_INVALID: {
|
||||
// 422 (not 401): the UPSTREAM provider rejected the pasted credentials.
|
||||
// The caller's own session is fine — a 401 here can trip client-side auth
|
||||
// interceptors into logging the user out. Clients must dispatch on the
|
||||
// error code, never on the HTTP status.
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
'Leverantören avvisade uppgifterna. Kontrollera att konto-ID och applikationstoken stämmer och försök igen.',
|
||||
message_en:
|
||||
'The provider rejected the credentials. Check that the account ID and application token are correct and try again.',
|
||||
},
|
||||
PROVIDER_PREVIEW_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Förhandsgranskningen från leverantören misslyckades.',
|
||||
@@ -1309,14 +1345,19 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
|
||||
message_en: 'Failed to fetch SIE data from the provider.',
|
||||
},
|
||||
PROVIDER_SIE_NO_YEARS: {
|
||||
// The supported window is rolling (current year and the two before it) —
|
||||
// the route interpolates the actual range via the messageSv/messageEn
|
||||
// overrides on errorResponseFromCode(); this entry is the static fallback.
|
||||
httpStatus: 404,
|
||||
message_sv: 'Inga räkenskapsår 2024–2026 hittades hos leverantören.',
|
||||
message_en: 'No fiscal years available for 2024–2026.',
|
||||
message_sv: 'Inga räkenskapsår inom det stödda intervallet hittades hos leverantören.',
|
||||
message_en: 'No fiscal years available within the supported range.',
|
||||
},
|
||||
PROVIDER_SIE_ONLY_FORTNOX: {
|
||||
PROVIDER_SIE_NOT_SUPPORTED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'SIE-export stöds för närvarande endast för Fortnox.',
|
||||
message_en: 'SIE export is currently only supported for Fortnox.',
|
||||
message_sv:
|
||||
'Den här leverantören stöder inte SIE-hämtning via API. Ladda upp en SIE-fil manuellt istället.',
|
||||
message_en:
|
||||
'This provider does not support fetching SIE via API. Upload a SIE file manually instead.',
|
||||
},
|
||||
PROVIDER_SIE_IMPORT_REQUIRED: {
|
||||
httpStatus: 409,
|
||||
@@ -1538,6 +1579,20 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'No fiscal year covers the invoice date. Create the fiscal year first, or change the invoice date.',
|
||||
},
|
||||
SI_CREATE_ACCRUAL_REVERSE_CHARGE: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Periodisering kan inte kombineras med omvänd skattskyldighet. Kostnadsraden utgör momsunderlaget i momsdeklarationen (ruta 20–32), så nettobeloppet kan inte skjutas upp till ett interimskonto.',
|
||||
message_en:
|
||||
'Periodisering cannot be combined with reverse charge. The expense line carries the VAT base for the VAT declaration (boxes 20–32), so the net amount cannot be deferred to an interim account.',
|
||||
},
|
||||
SI_DELETE_HAS_BOOKING: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Leverantörsfakturan är bokförd eller har en periodisering och kan inte tas bort. Skapa en kreditfaktura i stället för att återställa bokföringen.',
|
||||
message_en:
|
||||
'The supplier invoice has a posted journal entry or an accrual schedule and cannot be deleted. Create a credit note instead to reverse the bookkeeping.',
|
||||
},
|
||||
SI_PAID_ALREADY: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Leverantörsfakturan är redan betald eller krediterad.',
|
||||
@@ -2279,6 +2334,56 @@ const SKATTEVERKET: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Bolagsverket filing codes (digital inlämning av årsredovisning)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOLAGSVERKET: Record<string, StructuredErrorEntry> = {
|
||||
BOLAGSVERKET_API_ERROR: {
|
||||
httpStatus: 502,
|
||||
message_sv: 'Bolagsverkets tjänst svarade med ett fel. Se detaljerna och försök igen.',
|
||||
message_en: 'The Bolagsverket API returned an error. See details for the upstream message.',
|
||||
},
|
||||
BOLAGSVERKET_SUBMISSION_EXISTS: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Det finns redan en aktiv inlämning av årsredovisningen för räkenskapsåret. Invänta Bolagsverkets besked innan du lämnar in på nytt.',
|
||||
message_en:
|
||||
'An active årsredovisning submission already exists for this fiscal period. Wait for Bolagsverket to resolve it before submitting again.',
|
||||
},
|
||||
BOLAGSVERKET_FORBIDDEN: {
|
||||
httpStatus: 403,
|
||||
message_sv: 'Otillräcklig behörighet för att lämna in årsredovisning för det här företaget.',
|
||||
message_en:
|
||||
'Insufficient role to file an årsredovisning for this company (viewer members cannot submit).',
|
||||
},
|
||||
BOLAGSVERKET_INVALID_ENVIRONMENT: {
|
||||
httpStatus: 400,
|
||||
message_sv: "Ogiltig Bolagsverket-miljö. Tillåtna värden: 'test', 'accept', 'prod'.",
|
||||
message_en: "Invalid Bolagsverket environment. Allowed values: 'test', 'accept', 'prod'.",
|
||||
},
|
||||
BOLAGSVERKET_ENV_NOT_ALLOWED: {
|
||||
httpStatus: 403,
|
||||
message_sv:
|
||||
'Den valda Bolagsverket-miljön är inte tillåten i den här installationen. Plattformens BOLAGSVERKET_ENV sätter taket.',
|
||||
message_en:
|
||||
'The selected Bolagsverket environment exceeds the platform ceiling set by BOLAGSVERKET_ENV (order: test < accept < prod; unset means test).',
|
||||
},
|
||||
BOLAGSVERKET_CONFIG_MISSING: {
|
||||
httpStatus: 503,
|
||||
message_sv:
|
||||
'Serverkonfiguration saknas för Bolagsverket-integrationen. Kontakta administratören.',
|
||||
message_en:
|
||||
'Server configuration required by the Bolagsverket integration is missing (see details).',
|
||||
},
|
||||
BOLAGSVERKET_NO_SUBSCRIPTION: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Ingen händelseprenumeration finns för företaget ännu.',
|
||||
message_en:
|
||||
'No Bolagsverket event subscription exists for this company yet. One is created on the first submission.',
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Combined registry
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -2320,6 +2425,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...API_KEY,
|
||||
...PROVIDER,
|
||||
...SKATTEVERKET,
|
||||
...BOLAGSVERKET,
|
||||
}
|
||||
|
||||
export function getErrorEntry(code: string): StructuredErrorEntry | undefined {
|
||||
|
||||
@@ -126,6 +126,14 @@ export type CoreEvent =
|
||||
| { type: 'salary_run.booked'; payload: { salaryRunId: string; entryIds: string[]; userId: string; companyId: string } }
|
||||
| { type: 'agi.generated'; payload: { agiId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
||||
| { type: 'agi.submitted'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
||||
// Bolagsverket — digital inlämning av årsredovisning. Status values follow
|
||||
// GUIDE §5.2.2 (arsred_inkommen → … → arsred_registrerad). `uploaded` fires
|
||||
// when the iXBRL lands in eget utrymme; the undertecknare then signs the
|
||||
// fastställelseintyg at Bolagsverket and the webhook drives the rest.
|
||||
| { type: 'arsredovisning.uploaded'; payload: { submissionId: string; fiscalPeriodId: string; idnummer: string; environment: 'test' | 'accept' | 'prod'; userId: string; companyId: string } }
|
||||
| { type: 'arsredovisning.status_changed'; payload: { submissionId: string; fiscalPeriodId: string | null; previousStatus: string; status: string; bolagsverketStatus: string; userId: string; companyId: string } }
|
||||
| { type: 'arsredovisning.registered'; payload: { submissionId: string; fiscalPeriodId: string | null; userId: string; companyId: string } }
|
||||
| { type: 'arsredovisning.forelagd'; payload: { submissionId: string; fiscalPeriodId: string | null; userId: string; companyId: string } }
|
||||
// Skatteverket — Skattekonto sync
|
||||
| { type: 'skattekonto.synced'; payload: { booked: number; upcoming: number; balanceSkv: number; balanceKfm: number; userId: string; companyId: string } }
|
||||
| { type: 'skattekonto.balance.changed'; payload: { previousBalance: number; currentBalance: number; userId: string; companyId: string } }
|
||||
|
||||
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should have 11 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(12)
|
||||
it('should have 13 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(13)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('general')
|
||||
expect(extensions.length).toBe(12)
|
||||
expect(extensions.length).toBe(13)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers';
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/providers/briox/oauth', () => ({
|
||||
refreshBrioxToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createServiceClient } from '@/lib/supabase/server';
|
||||
import { refreshBrioxToken } from '@/lib/providers/briox/oauth';
|
||||
import { resolveConsent } from '../resolve-consent';
|
||||
|
||||
const consentRow = { id: 'c1', company_id: 'co1', provider: 'briox', status: 1 };
|
||||
|
||||
const expiredTokens = {
|
||||
access_token: 'old-access',
|
||||
refresh_token: 'old-refresh',
|
||||
token_expires_at: '2020-01-01T00:00:00.000Z',
|
||||
provider_company_id: 'acct-1',
|
||||
};
|
||||
|
||||
describe('resolveConsent — Briox token refresh concurrency', () => {
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mock = createQueuedMockSupabase();
|
||||
vi.mocked(createServiceClient).mockReturnValue(mock.supabase as never);
|
||||
vi.mocked(refreshBrioxToken).mockResolvedValue({
|
||||
access_token: 'new-access',
|
||||
refresh_token: 'new-refresh',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the stored token without refreshing when not expired', async () => {
|
||||
mock.enqueue({ data: [consentRow] });
|
||||
mock.enqueue({
|
||||
data: [{ ...expiredTokens, token_expires_at: new Date(Date.now() + 3_600_000).toISOString() }],
|
||||
});
|
||||
|
||||
const result = await resolveConsent('co1', 'c1');
|
||||
|
||||
expect(result.accessToken).toBe('old-access');
|
||||
expect(refreshBrioxToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists the rotated pair when the guarded update wins the race', async () => {
|
||||
mock.enqueue({ data: [consentRow] }); // consent lookup
|
||||
mock.enqueue({ data: [expiredTokens] }); // expired token row
|
||||
mock.enqueue({ data: [{ id: 't1' }] }); // guarded update matched 1 row
|
||||
|
||||
const result = await resolveConsent('co1', 'c1');
|
||||
|
||||
expect(result.accessToken).toBe('new-access');
|
||||
expect(refreshBrioxToken).toHaveBeenCalledTimes(1);
|
||||
expect(refreshBrioxToken).toHaveBeenCalledWith('old-refresh', 'old-access');
|
||||
});
|
||||
|
||||
it('adopts the concurrent winner\'s tokens when the guarded update matches 0 rows (lost race)', async () => {
|
||||
mock.enqueue({ data: [consentRow] }); // consent lookup
|
||||
mock.enqueue({ data: [expiredTokens] }); // expired token row (both requests read this)
|
||||
mock.enqueue({ data: [] }); // guarded update: another request already rotated
|
||||
mock.enqueue({
|
||||
// re-read returns the winner's freshly persisted pair
|
||||
data: [
|
||||
{
|
||||
access_token: 'winner-access',
|
||||
refresh_token: 'winner-refresh',
|
||||
token_expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
provider_company_id: 'acct-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await resolveConsent('co1', 'c1');
|
||||
|
||||
// Must use the persisted fresh tokens, NOT call Briox /tokenrefresh again
|
||||
// — a second rotation would invalidate the winner's pair.
|
||||
expect(result.accessToken).toBe('winner-access');
|
||||
expect(result.providerCompanyId).toBe('acct-1');
|
||||
expect(refreshBrioxToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails loudly with re-enter guidance when the rotated pair cannot be persisted', async () => {
|
||||
mock.enqueue({ data: [consentRow] }); // consent lookup
|
||||
mock.enqueue({ data: [expiredTokens] }); // expired token row
|
||||
mock.enqueue({ data: null, error: { message: 'connection reset' } }); // update failed
|
||||
|
||||
// Briox has already rotated the tokens at this point — the stored pair is
|
||||
// dead, so the user must reconnect with fresh credentials.
|
||||
await expect(resolveConsent('co1', 'c1')).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: expect.stringContaining('re-enter the credentials'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { BjornLundenClient, BjornLundenApiError } from '../client'
|
||||
|
||||
/**
|
||||
* Mocked-fetch tests for the BL HTTP client. Sandbox-verified behaviors under
|
||||
* guard here:
|
||||
* - pagination uses `page` + `rows` (a `pageRequested` request param is
|
||||
* IGNORED by the API — sending it loops on page 1 forever)
|
||||
* - every call carries Authorization Bearer + the per-company User-Key
|
||||
* - 429/5xx retry, 401/403/404 fail fast (a wrong User-Key surfaces as 500)
|
||||
*/
|
||||
|
||||
const TOKEN = 'access-token'
|
||||
const USER_KEY = '69f15a2d-0000-0000-0000-000000000000'
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('BjornLundenClient', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>
|
||||
let client: BjornLundenClient
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
client = new BjornLundenClient()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('headers', () => {
|
||||
it('sends Authorization Bearer and User-Key on get()', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ name: 'Arcim TEST' }))
|
||||
|
||||
await client.get(TOKEN, USER_KEY, '/details')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
const [url, opts] = fetchSpy.mock.calls[0]
|
||||
expect(String(url)).toBe('https://apigateway.blinfo.se/bla-api/v1/sp/details')
|
||||
expect((opts as RequestInit).headers).toMatchObject({
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'User-Key': USER_KEY,
|
||||
Accept: 'application/json',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the same auth headers on getBytes()', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(new Uint8Array([0x23, 0x46]).buffer as ArrayBuffer, { status: 200 }))
|
||||
|
||||
const buf = await client.getBytes(TOKEN, USER_KEY, '/sie/export/2025-01-01/2025-12-31')
|
||||
|
||||
expect(new Uint8Array(buf)).toEqual(new Uint8Array([0x23, 0x46]))
|
||||
const [, opts] = fetchSpy.mock.calls[0]
|
||||
expect((opts as RequestInit).headers).toMatchObject({
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'User-Key': USER_KEY,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination params', () => {
|
||||
it('getPage sends page + rows and NOT pageRequested/rowsRequested', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ pageRequested: 2, totalPages: 5, totalRows: 230, data: [{ id: 1 }] }),
|
||||
)
|
||||
|
||||
const result = await client.getPage(TOKEN, USER_KEY, '/customerinvoice/batch', {
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
})
|
||||
|
||||
const url = new URL(String(fetchSpy.mock.calls[0][0]))
|
||||
expect(url.searchParams.get('page')).toBe('2')
|
||||
expect(url.searchParams.get('rows')).toBe('50')
|
||||
expect(url.searchParams.has('pageRequested')).toBe(false)
|
||||
expect(url.searchParams.has('rowsRequested')).toBe(false)
|
||||
|
||||
expect(result.page).toBe(2)
|
||||
expect(result.totalPages).toBe(5)
|
||||
expect(result.totalCount).toBe(230)
|
||||
expect(result.items).toEqual([{ id: 1 }])
|
||||
})
|
||||
|
||||
it('getPage tolerates a missing data array', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ pageRequested: 99, totalPages: 5, totalRows: 230 }))
|
||||
|
||||
const result = await client.getPage(TOKEN, USER_KEY, '/customerinvoice/batch', { page: 99 })
|
||||
expect(result.items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAll', () => {
|
||||
it('accepts a plain array response (e.g. /customer, /financialyear)', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse([{ id: '1000' }, { id: '1001' }]))
|
||||
|
||||
const items = await client.getAll(TOKEN, USER_KEY, '/customer')
|
||||
expect(items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('unwraps a paginated envelope when one is returned', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ pageRequested: 1, totalPages: 1, totalRows: 1, data: [{ id: 'x' }] }),
|
||||
)
|
||||
|
||||
const items = await client.getAll(TOKEN, USER_KEY, '/whatever')
|
||||
expect(items).toEqual([{ id: 'x' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('listFinancialYears', () => {
|
||||
it('returns the plain /financialyear array', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse([
|
||||
{ entityId: 9, id: '202501', fromDate: '2025-01-01', toDate: '2025-12-31', open: true },
|
||||
]),
|
||||
)
|
||||
|
||||
const years = await client.listFinancialYears(TOKEN, USER_KEY)
|
||||
expect(years).toHaveLength(1)
|
||||
expect(years[0].fromDate).toBe('2025-01-01')
|
||||
expect(String(fetchSpy.mock.calls[0][0])).toContain('/financialyear')
|
||||
})
|
||||
})
|
||||
|
||||
describe('retry semantics', () => {
|
||||
it('retries on 429 and succeeds', async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response('slow down', { status: 429 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true }))
|
||||
|
||||
const result = await client.get<{ ok: boolean }>(TOKEN, USER_KEY, '/details')
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
}, 10_000)
|
||||
|
||||
it('does NOT retry on 401', async () => {
|
||||
fetchSpy.mockResolvedValue(new Response('unauthorized', { status: 401 }))
|
||||
|
||||
await expect(client.get(TOKEN, USER_KEY, '/details')).rejects.toMatchObject({
|
||||
name: 'BjornLundenApiError',
|
||||
statusCode: 401,
|
||||
})
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does NOT retry on 404', async () => {
|
||||
fetchSpy.mockResolvedValue(new Response('not found', { status: 404 }))
|
||||
|
||||
await expect(client.get(TOKEN, USER_KEY, '/nope')).rejects.toBeInstanceOf(BjornLundenApiError)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('exposes status and body on the error for upstream classification', async () => {
|
||||
// Fresh Response per attempt — a Response body can only be read once
|
||||
fetchSpy.mockImplementation(() =>
|
||||
Promise.resolve(new Response('{"status":"INTERNAL_SERVER_ERROR"}', { status: 500 })),
|
||||
)
|
||||
|
||||
// 500 IS retried (transient by contract) — all attempts exhausted here.
|
||||
// A wrong User-Key also answers 500; submit-token validation relies on
|
||||
// the BjornLundenApiError shape to classify it.
|
||||
await expect(client.get(TOKEN, USER_KEY, '/details')).rejects.toMatchObject({
|
||||
statusCode: 500,
|
||||
body: '{"status":"INTERNAL_SERVER_ERROR"}',
|
||||
})
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3)
|
||||
}, 15_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
mapBLToSalesInvoice,
|
||||
mapBLToSupplierInvoice,
|
||||
mapBLToCustomer,
|
||||
mapBLToSupplier,
|
||||
mapBLToJournal,
|
||||
mapBLToAccountingAccount,
|
||||
mapBLToCompanyInformation,
|
||||
} from '../mapper'
|
||||
|
||||
/**
|
||||
* Fixture-driven tests against payload shapes captured from the BL sandbox
|
||||
* (dev_docs/bjorn_lunden/fixtures/, 2026-06). The shapes matter more than the
|
||||
* values: `status` is an ARRAY of numeric codes, customer and supplier use
|
||||
* different field names for the same concepts, and journal `amount` carries
|
||||
* the debit/credit sign.
|
||||
*/
|
||||
|
||||
// Trimmed + anonymized /customerinvoice/batch item
|
||||
function salesInvoiceRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
entityId: 1,
|
||||
amount: 1250.0,
|
||||
amountInOriginalCurrency: 1250.0,
|
||||
amountInLocalCurrency: 1250.0,
|
||||
amountPaidInOriginalCurrency: 0.0,
|
||||
amountPaidInLocalCurrency: 0.0,
|
||||
currency: 'SEK',
|
||||
customerId: '1000',
|
||||
customerName: 'Test Kund AB',
|
||||
dueDate: '2026-03-13',
|
||||
invoiceNumber: 1, // BL sends a NUMBER on the customer side
|
||||
invoiceDate: '2026-02-11',
|
||||
ocrRef: '133',
|
||||
paid: false,
|
||||
preliminary: false,
|
||||
status: [1],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
// Trimmed + anonymized /supplierinvoice/batch item
|
||||
function supplierInvoiceRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
entityId: 7,
|
||||
amountInLocalCurrency: 1000.0,
|
||||
amountPaidInLocalCurrency: 0.0,
|
||||
amountRemainingInLocalCurrency: 1000.0,
|
||||
currency: 'SEK',
|
||||
dueDate: '2025-12-01',
|
||||
invoiceDate: '2025-11-01',
|
||||
invoiceNumber: '1', // string on the supplier side
|
||||
paid: false,
|
||||
preliminary: false,
|
||||
supplierId: '1000',
|
||||
supplierName: 'Leverantör AB',
|
||||
status: [1],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('deriveBLInvoiceStatus via mapBLToSalesInvoice', () => {
|
||||
it('paid=false with overdue code [1] → overdue', () => {
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw()).status).toBe('overdue')
|
||||
})
|
||||
|
||||
it('unpaid without codes → booked', () => {
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [0] })).status).toBe('booked')
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [] })).status).toBe('booked')
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: undefined })).status).toBe('booked')
|
||||
})
|
||||
|
||||
it('paid=true → paid', () => {
|
||||
const dto = mapBLToSalesInvoice(salesInvoiceRaw({ paid: true, amountPaidInLocalCurrency: 1250.0 }))
|
||||
expect(dto.status).toBe('paid')
|
||||
expect(dto.paymentStatus.paid).toBe(true)
|
||||
expect(dto.paymentStatus.balance.value).toBe(0)
|
||||
})
|
||||
|
||||
it('fully-paid code [2] → paid even when the paid flag lags', () => {
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [2] })).status).toBe('paid')
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [4] })).status).toBe('paid') // overpaid
|
||||
})
|
||||
|
||||
it('deleted [5] and customer loss [6] are terminal — win over paid', () => {
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [5], paid: true })).status).toBe('cancelled')
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [6] })).status).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('collection codes [7]/[8] → overdue', () => {
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [7] })).status).toBe('overdue')
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [8] })).status).toBe('overdue')
|
||||
})
|
||||
|
||||
it('preliminary → draft', () => {
|
||||
expect(mapBLToSalesInvoice(salesInvoiceRaw({ preliminary: true, status: [0] })).status).toBe('draft')
|
||||
})
|
||||
|
||||
it('maps amounts, customer identification and number-typed invoiceNumber', () => {
|
||||
const dto = mapBLToSalesInvoice(salesInvoiceRaw({ amountPaidInLocalCurrency: 250.0 }))
|
||||
expect(dto.invoiceNumber).toBe('1')
|
||||
expect(dto.id).toBe('1')
|
||||
expect(dto.issueDate).toBe('2026-02-11')
|
||||
expect(dto.dueDate).toBe('2026-03-13')
|
||||
expect(dto.legalMonetaryTotal.payableAmount.value).toBe(1250)
|
||||
expect(dto.paymentStatus.balance.value).toBe(1000) // 1250 - 250
|
||||
expect(dto.customer.name).toBe('Test Kund AB')
|
||||
expect(dto.customer.identifications).toEqual([{ id: '1000', schemeId: 'BL:CUSTOMER_ID' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapBLToSupplierInvoice', () => {
|
||||
it('balance comes from amountRemainingInLocalCurrency', () => {
|
||||
const dto = mapBLToSupplierInvoice(
|
||||
supplierInvoiceRaw({ amountPaidInLocalCurrency: 400.0, amountRemainingInLocalCurrency: 600.0 }),
|
||||
)
|
||||
expect(dto.paymentStatus.balance.value).toBe(600)
|
||||
expect(dto.paymentStatus.paid).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to total − paid when amountRemaining is absent', () => {
|
||||
const dto = mapBLToSupplierInvoice(
|
||||
supplierInvoiceRaw({ amountPaidInLocalCurrency: 400.0, amountRemainingInLocalCurrency: undefined }),
|
||||
)
|
||||
expect(dto.paymentStatus.balance.value).toBe(600)
|
||||
})
|
||||
|
||||
it('maps supplier identification and entityId as id', () => {
|
||||
const dto = mapBLToSupplierInvoice(supplierInvoiceRaw())
|
||||
expect(dto.id).toBe('7')
|
||||
expect(dto.invoiceNumber).toBe('1')
|
||||
expect(dto.supplier.name).toBe('Leverantör AB')
|
||||
expect(dto.supplier.identifications).toEqual([{ id: '1000', schemeId: 'BL:SUPPLIER_ID' }])
|
||||
expect(dto.status).toBe('overdue') // status [1] + unpaid
|
||||
})
|
||||
|
||||
it('deleted code [5] → cancelled', () => {
|
||||
expect(mapBLToSupplierInvoice(supplierInvoiceRaw({ status: [5] })).status).toBe('cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapBLToCustomer — customer-side field names', () => {
|
||||
// Trimmed /customer item: organisationNumber + zip (NOT organisationId/zipCode)
|
||||
const raw: Record<string, unknown> = {
|
||||
entityId: 1,
|
||||
id: '1000',
|
||||
name: 'test gubbe',
|
||||
organisationNumber: '1234567890',
|
||||
street: '',
|
||||
box: 'Chillsgatan 26A',
|
||||
zip: '11539',
|
||||
city: 'Stockholm',
|
||||
country: 'Sverige',
|
||||
phone: '0700000000',
|
||||
email: 'kund@example.com',
|
||||
currency: 'SEK',
|
||||
vatNumber: '',
|
||||
paymentTerms: '30', // string in BL
|
||||
closed: false,
|
||||
}
|
||||
|
||||
it('maps id, orgnr, address, contact and payment terms', () => {
|
||||
const dto = mapBLToCustomer(raw)
|
||||
expect(dto.id).toBe('1000')
|
||||
expect(dto.customerNumber).toBe('1000')
|
||||
expect(dto.party.name).toBe('test gubbe')
|
||||
expect(dto.party.identifications).toEqual([{ id: '1234567890', schemeId: 'SE:ORGNR' }])
|
||||
expect(dto.party.postalAddress?.postalZone).toBe('11539')
|
||||
expect(dto.party.postalAddress?.additionalStreetName).toBe('Chillsgatan 26A')
|
||||
expect(dto.party.contact?.email).toBe('kund@example.com')
|
||||
expect(dto.defaultPaymentTermsDays).toBe(30)
|
||||
expect(dto.active).toBe(true)
|
||||
})
|
||||
|
||||
it('closed → inactive', () => {
|
||||
expect(mapBLToCustomer({ ...raw, closed: true }).active).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapBLToSupplier — supplier-side field names', () => {
|
||||
// Trimmed /supplier item: organisationId + zipCode + vatNr (NOT organisationNumber/zip/vatNumber)
|
||||
const raw: Record<string, unknown> = {
|
||||
entityId: 1,
|
||||
id: '1000',
|
||||
name: 'Leverantör AB',
|
||||
organisationId: '5512345678',
|
||||
address1: 'Östermalmsgatan 26 A',
|
||||
address2: 'C/O Test',
|
||||
zipCode: '11426',
|
||||
city: 'STOCKHOLM',
|
||||
countryCode: 'SE',
|
||||
email: 'leverantor@example.com',
|
||||
bg: '55555555',
|
||||
pg: null,
|
||||
iban: null,
|
||||
vatNr: 'SE551234567801',
|
||||
paymentTerms: '30',
|
||||
closed: false,
|
||||
}
|
||||
|
||||
it('maps orgnr, address, giro and VAT number from supplier-flavored fields', () => {
|
||||
const dto = mapBLToSupplier(raw)
|
||||
expect(dto.id).toBe('1000')
|
||||
expect(dto.party.identifications).toEqual([{ id: '5512345678', schemeId: 'SE:ORGNR' }])
|
||||
expect(dto.party.postalAddress?.streetName).toBe('Östermalmsgatan 26 A')
|
||||
expect(dto.party.postalAddress?.postalZone).toBe('11426')
|
||||
expect(dto.party.postalAddress?.countryCode).toBe('SE')
|
||||
expect(dto.vatNumber).toBe('SE551234567801')
|
||||
expect(dto.bankGiro).toBe('55555555')
|
||||
expect(dto.plusGiro).toBeNull()
|
||||
expect(dto.defaultPaymentTermsDays).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapBLToJournal — amount sign convention', () => {
|
||||
it('positive amount → debit, negative → credit', () => {
|
||||
const dto = mapBLToJournal({
|
||||
entityId: 40748,
|
||||
journalId: 'B',
|
||||
journalEntryId: 3,
|
||||
journalEntryDate: '2025-01-08',
|
||||
journalEntryText: 'Banköverföring',
|
||||
financialYearId: 9,
|
||||
ledgerEntries: [
|
||||
{ accountId: '1930', amount: -21600.0, text: '' },
|
||||
{ accountId: '1681', amount: 21600.0, text: '' },
|
||||
],
|
||||
totalDebitSum: 21600.0,
|
||||
totalCreditSum: 21600.0,
|
||||
})
|
||||
|
||||
expect(dto.entries).toHaveLength(2)
|
||||
expect(dto.entries[0]).toMatchObject({ accountNumber: '1930', debit: 0, credit: 21600 })
|
||||
expect(dto.entries[1]).toMatchObject({ accountNumber: '1681', debit: 21600, credit: 0 })
|
||||
expect(dto.totalDebit?.value).toBe(21600)
|
||||
expect(dto.totalCredit?.value).toBe(21600)
|
||||
})
|
||||
|
||||
it('tolerates empty ledgerEntries (sandbox has bare journal entries)', () => {
|
||||
const dto = mapBLToJournal({
|
||||
entityId: 1,
|
||||
journalEntryId: 206,
|
||||
journalEntryDate: '2025-02-11',
|
||||
ledgerEntries: [],
|
||||
})
|
||||
expect(dto.entries).toEqual([])
|
||||
expect(dto.totalDebit?.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapBLToAccountingAccount', () => {
|
||||
it("prefers BL's own type field (income→revenue, cost→expense)", () => {
|
||||
expect(mapBLToAccountingAccount({ id: '3010', name: 'Försäljning', type: 'income' }).type).toBe('revenue')
|
||||
expect(mapBLToAccountingAccount({ id: '8999', name: 'Årets resultat', type: 'cost' }).type).toBe('expense')
|
||||
expect(mapBLToAccountingAccount({ id: '1930', name: 'Företagskonto', type: 'asset' }).type).toBe('asset')
|
||||
expect(mapBLToAccountingAccount({ id: '2440', name: 'Leverantörsskulder', type: 'liability' }).type).toBe('liability')
|
||||
})
|
||||
|
||||
it('covers off-plan accounts via the type field where ranges cannot', () => {
|
||||
// 0099 "Konvertering" is below the 1xxx asset range
|
||||
expect(mapBLToAccountingAccount({ id: '0099', name: 'Konvertering', type: 'asset' }).type).toBe('asset')
|
||||
})
|
||||
|
||||
it('falls back to BAS ranges when type is missing', () => {
|
||||
expect(mapBLToAccountingAccount({ id: '1510', name: 'Kundfordringar' }).type).toBe('asset')
|
||||
expect(mapBLToAccountingAccount({ id: '2610', name: 'Utgående moms' }).type).toBe('liability')
|
||||
expect(mapBLToAccountingAccount({ id: '3010', name: 'Försäljning' }).type).toBe('revenue')
|
||||
expect(mapBLToAccountingAccount({ id: '6570', name: 'Bankkostnader' }).type).toBe('expense')
|
||||
})
|
||||
|
||||
it('maps vatCode, sruCode and closed flag', () => {
|
||||
const dto = mapBLToAccountingAccount({
|
||||
id: '1930',
|
||||
name: 'Företagskonto',
|
||||
vatCode: '0',
|
||||
sruCode: '7281',
|
||||
closed: true,
|
||||
type: 'asset',
|
||||
})
|
||||
expect(dto.accountNumber).toBe('1930')
|
||||
expect(dto.vatCode).toBe('0')
|
||||
expect(dto.sruCode).toBe('7281')
|
||||
expect(dto.active).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapBLToCompanyInformation — /details shape', () => {
|
||||
it('maps name, orgnr and preferredSettings.currency', () => {
|
||||
const dto = mapBLToCompanyInformation({
|
||||
entityId: 1,
|
||||
name: 'Arcim TEST',
|
||||
orgNumber: '5595386219',
|
||||
street: '',
|
||||
box: 'Östermalmsgatan 26A',
|
||||
zip: '114 26',
|
||||
city: 'Stockholm',
|
||||
country: 'Sverige',
|
||||
email: 'info@example.com',
|
||||
vatNumber: '',
|
||||
preferredSettings: { currency: 'SEK', activeFinancialYear: '202501' },
|
||||
})
|
||||
|
||||
expect(dto.companyName).toBe('Arcim TEST')
|
||||
expect(dto.organizationNumber).toBe('5595386219')
|
||||
expect(dto.baseCurrency).toBe('SEK')
|
||||
expect(dto.address?.postalZone).toBe('114 26')
|
||||
expect(dto.legalEntity?.companyIdSchemeId).toBe('SE:ORGNR')
|
||||
})
|
||||
|
||||
it('defaults currency to SEK when preferredSettings is absent', () => {
|
||||
expect(mapBLToCompanyInformation({ name: 'X' }).baseCurrency).toBe('SEK')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,9 @@ import { BL_BASE_URL, BL_RATE_LIMIT } from './config';
|
||||
import { isTimeoutError } from '@/lib/http/fetch-with-timeout';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
// The SIE export renders a whole fiscal year server-side (megabytes for an
|
||||
// active company) — give it more room than ordinary CRUD reads.
|
||||
const SIE_FETCH_TIMEOUT_MS = 60_000;
|
||||
|
||||
export class BjornLundenApiError extends Error {
|
||||
constructor(
|
||||
@@ -34,6 +37,15 @@ interface BLPaginatedResponse<T> {
|
||||
data: T[];
|
||||
}
|
||||
|
||||
export interface BLFinancialYear {
|
||||
entityId: number;
|
||||
/** BL's period key, e.g. "202501" */
|
||||
id?: string;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export class BjornLundenClient {
|
||||
private readonly rateLimiter: TokenBucketRateLimiter;
|
||||
private readonly baseUrl: string;
|
||||
@@ -43,7 +55,18 @@ export class BjornLundenClient {
|
||||
this.rateLimiter = new TokenBucketRateLimiter(BL_RATE_LIMIT, 'ratelimit:bjornlunden');
|
||||
}
|
||||
|
||||
async get<T>(accessToken: string, userKey: string, path: string): Promise<T> {
|
||||
/**
|
||||
* @param options.retry Set to false to fail fast on the first error instead
|
||||
* of retrying. Used by credential probes, where a bad User-Key answers
|
||||
* HTTP 500 (a "retryable" status) and would otherwise burn the full retry
|
||||
* budget with backoff before reporting the bad key.
|
||||
*/
|
||||
async get<T>(
|
||||
accessToken: string,
|
||||
userKey: string,
|
||||
path: string,
|
||||
options?: { retry?: boolean },
|
||||
): Promise<T> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
@@ -69,7 +92,7 @@ export class BjornLundenClient {
|
||||
return response.json() as Promise<T>;
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
maxAttempts: options?.retry === false ? 1 : 3,
|
||||
initialDelayMs: 1000,
|
||||
shouldRetry: isRetryableError,
|
||||
},
|
||||
@@ -82,9 +105,12 @@ export class BjornLundenClient {
|
||||
relativePath: string,
|
||||
options?: { page?: number; pageSize?: number },
|
||||
): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
|
||||
// Sandbox-verified: the batch endpoints honor `page` and `rows`. The
|
||||
// response envelope echoes `pageRequested`, but a `pageRequested` REQUEST
|
||||
// param is silently ignored (as is `rowsRequested`) — sending those would
|
||||
// re-fetch page 1 forever.
|
||||
const params = new URLSearchParams();
|
||||
params.set('pageRequested', String(options?.page ?? 1));
|
||||
params.set('rowsRequested', String(options?.pageSize ?? 50));
|
||||
params.set('page', String(options?.page ?? 1));
|
||||
params.set('rows', String(options?.pageSize ?? 50));
|
||||
|
||||
const path = `${relativePath}?${params.toString()}`;
|
||||
@@ -106,6 +132,50 @@ export class BjornLundenClient {
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a binary resource with the same rate-limit/retry behavior as get().
|
||||
* Used for the SIE export, which BL serves as raw bytes
|
||||
* (Content-Type: text/vnd.sie-gruppen.si, typically CP437-encoded) despite
|
||||
* the swagger declaring a base64 string — callers must run the bytes
|
||||
* through detectEncoding()/decodeBuffer().
|
||||
*/
|
||||
async getBytes(accessToken: string, userKey: string, path: string): Promise<ArrayBuffer> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Key': userKey,
|
||||
},
|
||||
signal: AbortSignal.timeout(SIE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new BjornLundenApiError(
|
||||
`Björn Lunden API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000,
|
||||
shouldRetry: isRetryableError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** All financial years registered in BL for the company behind the User-Key. */
|
||||
async listFinancialYears(accessToken: string, userKey: string): Promise<BLFinancialYear[]> {
|
||||
return this.getAll<BLFinancialYear>(accessToken, userKey, '/financialyear');
|
||||
}
|
||||
|
||||
async getDetail<T>(accessToken: string, userKey: string, path: string): Promise<T> {
|
||||
return this.get<T>(accessToken, userKey, path);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
|
||||
SalesInvoiceDto, InvoiceStatusCode,
|
||||
LegalMonetaryTotalDto, PaymentStatusDto,
|
||||
SupplierInvoiceDto,
|
||||
CustomerDto,
|
||||
@@ -14,10 +14,30 @@ function amount(value: number | undefined | null, currency: string = 'SEK'): Amo
|
||||
return { value: value ?? 0, currencyCode: currency };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an invoice lifecycle status from BL's fields.
|
||||
*
|
||||
* Sandbox-verified: `status` is an ARRAY of numeric codes. Documented for
|
||||
* customer invoices (supplier invoices observed to follow the same scheme):
|
||||
* 0 Unpaid, 1 Overdue, 2 Fully paid, 3 Partially paid, 4 Overpaid,
|
||||
* 5 Deleted, 6 Customer loss, 7 Marked for collection, 8 Sent for collection.
|
||||
* Codes ≥40 are ROT/RUT, factoring ("Invoier") and e-invoice transport noise —
|
||||
* ignored here. The `paid`/`preliminary` booleans are the primary signals;
|
||||
* the codes refine terminal states the booleans can't express.
|
||||
*/
|
||||
function deriveBLInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
|
||||
const codes = Array.isArray(raw['status'])
|
||||
? (raw['status'] as unknown[]).map(Number).filter(Number.isFinite)
|
||||
: [];
|
||||
// Terminal states win over payment flags: a deleted (makulerad) or
|
||||
// written-off (kundförlust) invoice must not be imported as open/paid.
|
||||
if (codes.includes(5) || codes.includes(6)) return 'cancelled';
|
||||
if (raw['paid'] === true) return 'paid';
|
||||
if (raw['preliminary'] === true) return 'draft';
|
||||
const status = raw['status'] != null ? String(raw['status']).toLowerCase() : undefined;
|
||||
if (codes.includes(2) || codes.includes(4)) return 'paid';
|
||||
if (codes.includes(1) || codes.includes(7) || codes.includes(8)) return 'overdue';
|
||||
// Defensive: handle a plain string status should BL ever send one
|
||||
const status = typeof raw['status'] === 'string' ? raw['status'].toLowerCase() : undefined;
|
||||
if (status === 'cancelled') return 'cancelled';
|
||||
if (status === 'credited') return 'credited';
|
||||
if (status === 'sent') return 'sent';
|
||||
@@ -238,20 +258,32 @@ export function mapBLToJournal(raw: Record<string, unknown>): JournalDto {
|
||||
};
|
||||
}
|
||||
|
||||
/** BL's own account `type` values (sandbox-verified) → our AccountType. */
|
||||
const BL_ACCOUNT_TYPE_MAP: Record<string, AccountType> = {
|
||||
asset: 'asset',
|
||||
liability: 'liability',
|
||||
income: 'revenue',
|
||||
cost: 'expense',
|
||||
};
|
||||
|
||||
/**
|
||||
* Map BL Account to AccountingAccountDto.
|
||||
*
|
||||
* BL fields: entityId, id (account number), name, vatCode, sruCode, closed, type
|
||||
* Type derived from BAS plan number ranges.
|
||||
* BL sends an explicit `type` (asset|liability|income|cost) — prefer it, since
|
||||
* it also covers off-plan accounts like 0099 "Konvertering"; fall back to BAS
|
||||
* number ranges when absent.
|
||||
*/
|
||||
export function mapBLToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
|
||||
const num = Number(raw['id']);
|
||||
|
||||
let type: AccountType | undefined;
|
||||
if (num >= 1000 && num < 2000) type = 'asset';
|
||||
else if (num >= 2000 && num < 3000) type = 'liability';
|
||||
else if (num >= 3000 && num < 4000) type = 'revenue';
|
||||
else if (num >= 4000 && num < 9000) type = 'expense';
|
||||
let type: AccountType | undefined = BL_ACCOUNT_TYPE_MAP[String(raw['type'] ?? '').toLowerCase()];
|
||||
if (!type) {
|
||||
if (num >= 1000 && num < 2000) type = 'asset';
|
||||
else if (num >= 2000 && num < 3000) type = 'liability';
|
||||
else if (num >= 3000 && num < 4000) type = 'revenue';
|
||||
else if (num >= 4000 && num < 9000) type = 'expense';
|
||||
}
|
||||
|
||||
return {
|
||||
accountNumber: String(raw['id'] ?? ''),
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { BrioxClient, BrioxApiError } from '../client';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('BrioxClient', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
let setTimeoutSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
setTimeoutSpy?.mockRestore();
|
||||
setTimeoutSpy = null;
|
||||
});
|
||||
|
||||
/** Short-circuit withRetry backoff delays so retry tests run instantly. */
|
||||
function interceptRetryDelays() {
|
||||
const realSetTimeout = globalThis.setTimeout;
|
||||
setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((
|
||||
fn: () => void,
|
||||
ms?: number,
|
||||
) => {
|
||||
if (ms && ms >= 500 && ms < 120_000) {
|
||||
if (typeof fn === 'function') fn();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}
|
||||
return realSetTimeout(fn, ms);
|
||||
}) as typeof setTimeout);
|
||||
}
|
||||
|
||||
describe('auth header', () => {
|
||||
it('sends the RAW access token in Authorization — no Bearer prefix', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ data: {} }));
|
||||
|
||||
const client = new BrioxClient();
|
||||
await client.get('raw-token-123', '/account');
|
||||
|
||||
const [, init] = fetchSpy.mock.calls[0];
|
||||
expect((init as RequestInit).headers).toMatchObject({
|
||||
Authorization: 'raw-token-123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('getPaginated loops all pages via data.metainformation', async () => {
|
||||
const page = (n: number, totalPages: number, items: unknown[]) =>
|
||||
jsonResponse({
|
||||
data: {
|
||||
invoices: items,
|
||||
metainformation: { total_pages: totalPages, current_page: n, total_count: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(page(1, 2, [{ id: 1 }, { id: 2 }]))
|
||||
.mockResolvedValueOnce(page(2, 2, [{ id: 3 }]));
|
||||
|
||||
const client = new BrioxClient();
|
||||
const items = await client.getPaginated<{ id: number }>('t', '/customerinvoice', 'invoices');
|
||||
|
||||
expect(items.map((i) => i.id)).toEqual([1, 2, 3]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(String(fetchSpy.mock.calls[0][0])).toContain('/customerinvoice?page=1');
|
||||
expect(String(fetchSpy.mock.calls[1][0])).toContain('/customerinvoice?page=2');
|
||||
});
|
||||
|
||||
it('getPage forwards limit and frommodifieddate params', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: { customers: [], metainformation: { total_pages: 1, current_page: 1, total_count: 0 } },
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new BrioxClient();
|
||||
await client.getPage('t', '/customer', 'customers', {
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
fromModifiedDate: '2026-01-01',
|
||||
});
|
||||
|
||||
const url = String(fetchSpy.mock.calls[0][0]);
|
||||
expect(url).toContain('page=2');
|
||||
expect(url).toContain('limit=50');
|
||||
expect(url).toContain('frommodifieddate=2026-01-01');
|
||||
});
|
||||
|
||||
it('getPage tolerates a missing metainformation block', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ data: { customers: [{ id: 1 }] } }));
|
||||
|
||||
const client = new BrioxClient();
|
||||
const result = await client.getPage<{ id: number }>('t', '/customer', 'customers');
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry', () => {
|
||||
it('retries on 500 and succeeds', async () => {
|
||||
interceptRetryDelays();
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response('boom', { status: 500 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { ok: true } }));
|
||||
|
||||
const client = new BrioxClient();
|
||||
const result = await client.get<{ data: { ok: boolean } }>('t', '/account');
|
||||
|
||||
expect(result.data.ok).toBe(true);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries on 429 and succeeds', async () => {
|
||||
interceptRetryDelays();
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response('slow down', { status: 429 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { ok: true } }));
|
||||
|
||||
const client = new BrioxClient();
|
||||
const result = await client.get<{ data: { ok: boolean } }>('t', '/account');
|
||||
|
||||
expect(result.data.ok).toBe(true);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does NOT retry on 401', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }));
|
||||
|
||||
const client = new BrioxClient();
|
||||
const err = await client.get('t', '/account').catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(BrioxApiError);
|
||||
expect((err as BrioxApiError).statusCode).toBe(401);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT retry on 404', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('not found', { status: 404 }));
|
||||
|
||||
const client = new BrioxClient();
|
||||
const err = await client.get('t', '/account').catch((e: unknown) => e);
|
||||
|
||||
expect((err as BrioxApiError).statusCode).toBe(404);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBytes (SIE octet-stream)', () => {
|
||||
it('returns the raw bytes and authenticates with the raw token', async () => {
|
||||
const bytes = new Uint8Array([0x23, 0x46, 0x4c, 0x41, 0x47, 0x47, 0x41]); // "#FLAGGA"
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(bytes, { status: 200, headers: { 'Content-Type': 'application/octet-stream' } }),
|
||||
);
|
||||
|
||||
const client = new BrioxClient();
|
||||
const buffer = await client.getBytes('raw-token-123', '/sie/10/4');
|
||||
|
||||
expect(new Uint8Array(buffer)).toEqual(bytes);
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(String(url)).toContain('/sie/10/4');
|
||||
expect((init as RequestInit).headers).toMatchObject({ Authorization: 'raw-token-123' });
|
||||
});
|
||||
|
||||
it('throws BrioxApiError with status on failure', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('nope', { status: 404 }));
|
||||
|
||||
const client = new BrioxClient();
|
||||
const err = await client.getBytes('t', '/sie/99/4').catch((e: unknown) => e);
|
||||
|
||||
expect((err as BrioxApiError).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('financial years', () => {
|
||||
it('listFinancialYears unwraps data.financialyears', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
financialyears: [
|
||||
{ id: '9', fromdate: '2024-01-01', todate: '2024-12-31' },
|
||||
{ id: '10', fromdate: '2025-01-01', todate: '2025-12-31' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new BrioxClient();
|
||||
const years = await client.listFinancialYears('t');
|
||||
|
||||
expect(years).toHaveLength(2);
|
||||
expect(years[1]).toEqual({ id: '10', fromdate: '2025-01-01', todate: '2025-12-31' });
|
||||
});
|
||||
|
||||
it('getCurrentFinancialYear picks the last completed year', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: {
|
||||
financialyears: [
|
||||
{ id: '9', fromdate: '2024-01-01', todate: '2024-12-31' },
|
||||
{ id: '10', fromdate: '2025-01-01', todate: '2025-12-31' },
|
||||
{ id: '11', fromdate: '2099-01-01', todate: '2099-12-31' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new BrioxClient();
|
||||
await expect(client.getCurrentFinancialYear('t')).resolves.toBe('10');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
mapBrioxToSalesInvoice,
|
||||
mapBrioxToSupplierInvoice,
|
||||
mapBrioxToCustomer,
|
||||
mapBrioxToAccountingAccount,
|
||||
mapBrioxToCompanyInformation,
|
||||
} from '../mapper';
|
||||
|
||||
/**
|
||||
* Mirrors lib/providers/fortnox/__tests__/mapper-payment-status.test.ts:
|
||||
* deriveInvoiceStatus and paymentStatus.paid share one isFullyPaid() source
|
||||
* of truth, so status === 'paid' iff paymentStatus.paid. An ABSENT balance
|
||||
* must never be read as paid. Field names follow the Briox docs (snake_case)
|
||||
* — re-verify against sandbox payloads (plan Phase 3).
|
||||
*/
|
||||
|
||||
function salesRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 7,
|
||||
invoice_number: '1001',
|
||||
invoice_date: '2026-01-10',
|
||||
due_date: '2026-02-10',
|
||||
total_amount: 1000,
|
||||
net_amount: 800,
|
||||
customer_name: 'Kund AB',
|
||||
booked: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function supplierRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 9,
|
||||
invoice_number: 'L-55',
|
||||
invoice_date: '2026-01-10',
|
||||
total_amount: 1000,
|
||||
supplier_name: 'Leverantör AB',
|
||||
booked: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Briox mapper — paid-status consistency', () => {
|
||||
it('sales: absent balance is NOT paid (defaults to full total, not 0)', () => {
|
||||
const dto = mapBrioxToSalesInvoice(salesRaw({})); // no balance key
|
||||
expect(dto.status).toBe('booked');
|
||||
expect(dto.paymentStatus.paid).toBe(false);
|
||||
expect(dto.paymentStatus.balance.value).toBe(1000);
|
||||
});
|
||||
|
||||
it('sales: balance 0 with positive total → paid and status paid', () => {
|
||||
const dto = mapBrioxToSalesInvoice(salesRaw({ balance: 0 }));
|
||||
expect(dto.status).toBe('paid');
|
||||
expect(dto.paymentStatus.paid).toBe(true);
|
||||
expect(dto.paymentStatus.balance.value).toBe(0);
|
||||
});
|
||||
|
||||
it('sales: positive balance → unpaid', () => {
|
||||
const dto = mapBrioxToSalesInvoice(salesRaw({ balance: 250 }));
|
||||
expect(dto.status).toBe('booked');
|
||||
expect(dto.paymentStatus.paid).toBe(false);
|
||||
expect(dto.paymentStatus.balance.value).toBe(250);
|
||||
});
|
||||
|
||||
it('sales: string amounts ("0.00", "250.00") are coerced', () => {
|
||||
const paid = mapBrioxToSalesInvoice(salesRaw({ total_amount: '1000.00', balance: '0.00' }));
|
||||
expect(paid.status).toBe('paid');
|
||||
expect(paid.paymentStatus.paid).toBe(true);
|
||||
|
||||
const open = mapBrioxToSalesInvoice(salesRaw({ total_amount: '1000.00', balance: '250.00' }));
|
||||
expect(open.paymentStatus.paid).toBe(false);
|
||||
expect(open.paymentStatus.balance.value).toBe(250);
|
||||
expect(open.legalMonetaryTotal.taxInclusiveAmount?.value).toBe(1000);
|
||||
});
|
||||
|
||||
it('sales: fully_paid flag with absent balance keeps status and paid consistent', () => {
|
||||
const dto = mapBrioxToSalesInvoice(salesRaw({ fully_paid: true }));
|
||||
expect(dto.status).toBe('paid');
|
||||
expect(dto.paymentStatus.paid).toBe(true);
|
||||
// paid ⇒ no outstanding balance even though the payload omits balance
|
||||
expect(dto.paymentStatus.balance.value).toBe(0);
|
||||
});
|
||||
|
||||
it('sales: explicit status "paid" wins even without amounts', () => {
|
||||
const dto = mapBrioxToSalesInvoice(salesRaw({ status: 'paid' }));
|
||||
expect(dto.status).toBe('paid');
|
||||
expect(dto.paymentStatus.paid).toBe(true);
|
||||
});
|
||||
|
||||
it('sales: zero-total invoice with balance 0 is NOT marked paid', () => {
|
||||
const dto = mapBrioxToSalesInvoice(salesRaw({ total_amount: 0, balance: 0, booked: false }));
|
||||
expect(dto.status).toBe('draft');
|
||||
expect(dto.paymentStatus.paid).toBe(false);
|
||||
});
|
||||
|
||||
it('sales: cancelled/credited status outranks settled amounts', () => {
|
||||
expect(mapBrioxToSalesInvoice(salesRaw({ status: 'cancelled', balance: 0 })).status).toBe('cancelled');
|
||||
expect(mapBrioxToSalesInvoice(salesRaw({ status: 'credited', balance: 0 })).status).toBe('credited');
|
||||
});
|
||||
|
||||
it('sales: sent and overdue statuses derive when unpaid', () => {
|
||||
expect(mapBrioxToSalesInvoice(salesRaw({ booked: false, sent: true, balance: 1000 })).status).toBe('sent');
|
||||
expect(mapBrioxToSalesInvoice(salesRaw({ booked: false, status: 'overdue', balance: 1000 })).status).toBe('overdue');
|
||||
});
|
||||
|
||||
it('supplier: absent balance is NOT paid (no false-paid on the supplier path)', () => {
|
||||
const dto = mapBrioxToSupplierInvoice(supplierRaw({}));
|
||||
expect(dto.status).toBe('booked');
|
||||
expect(dto.paymentStatus.paid).toBe(false);
|
||||
expect(dto.paymentStatus.balance.value).toBe(1000);
|
||||
});
|
||||
|
||||
it('supplier: balance 0 → paid with zero balance', () => {
|
||||
const dto = mapBrioxToSupplierInvoice(supplierRaw({ balance: 0 }));
|
||||
expect(dto.status).toBe('paid');
|
||||
expect(dto.paymentStatus.paid).toBe(true);
|
||||
expect(dto.paymentStatus.balance.value).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Briox mapper — line mapping', () => {
|
||||
it('maps invoice rows to lines with stringified account numbers', () => {
|
||||
const dto = mapBrioxToSalesInvoice(salesRaw({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
description: 'Konsulttimmar',
|
||||
quantity: 10,
|
||||
unit: 'h',
|
||||
price: 80,
|
||||
total: 800,
|
||||
vat_rate: 25,
|
||||
account_number: 3041,
|
||||
article_number: 'A-1',
|
||||
},
|
||||
{ description: 'Frakt', total: 200 },
|
||||
],
|
||||
}));
|
||||
|
||||
expect(dto.lines).toHaveLength(2);
|
||||
expect(dto.lines[0]).toMatchObject({
|
||||
id: '1',
|
||||
description: 'Konsulttimmar',
|
||||
quantity: 10,
|
||||
unitCode: 'h',
|
||||
taxPercent: 25,
|
||||
accountNumber: '3041',
|
||||
articleNumber: 'A-1',
|
||||
});
|
||||
expect(dto.lines[0].lineExtensionAmount.value).toBe(800);
|
||||
// Row without id falls back to its 1-based index
|
||||
expect(dto.lines[1].id).toBe('2');
|
||||
expect(dto.lines[1].accountNumber).toBeUndefined();
|
||||
});
|
||||
|
||||
it('coerces string line-level price/total/quantity/vat_rate (Briox serializes numbers as strings)', () => {
|
||||
const dto = mapBrioxToSalesInvoice(salesRaw({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
description: 'Konsulttimmar',
|
||||
quantity: '10',
|
||||
price: '250.00',
|
||||
total: '2500.00',
|
||||
vat_rate: '25',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
expect(dto.lines[0].quantity).toBe(10);
|
||||
expect(dto.lines[0].unitPrice?.value).toBe(250);
|
||||
expect(dto.lines[0].lineExtensionAmount.value).toBe(2500);
|
||||
expect(dto.lines[0].taxPercent).toBe(25);
|
||||
});
|
||||
|
||||
it('supplier: coerces string line-level amounts too', () => {
|
||||
const dto = mapBrioxToSupplierInvoice(supplierRaw({
|
||||
rows: [
|
||||
{ id: 1, description: 'Material', quantity: '2', price: '125.50', total: '251.00' },
|
||||
],
|
||||
}));
|
||||
|
||||
expect(dto.lines[0].quantity).toBe(2);
|
||||
expect(dto.lines[0].unitPrice?.value).toBe(125.5);
|
||||
expect(dto.lines[0].lineExtensionAmount.value).toBe(251);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Briox mapper — customers', () => {
|
||||
it('maps org number, payment terms and address', () => {
|
||||
const dto = mapBrioxToCustomer({
|
||||
id: 12,
|
||||
customer_number: 'K100',
|
||||
name: 'Kund AB',
|
||||
org_number: '5560000000',
|
||||
payment_terms_days: '30',
|
||||
address1: 'Storgatan 1',
|
||||
zip_code: '111 22',
|
||||
city: 'Stockholm',
|
||||
email: 'kund@example.se',
|
||||
modified_date: '2026-01-01',
|
||||
});
|
||||
|
||||
expect(dto.customerNumber).toBe('K100');
|
||||
expect(dto.party.identifications[0]).toEqual({ id: '5560000000', schemeId: 'SE:ORGNR' });
|
||||
expect(dto.defaultPaymentTermsDays).toBe(30);
|
||||
expect(dto.party.postalAddress?.streetName).toBe('Storgatan 1');
|
||||
expect(dto.active).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Briox mapper — accounts', () => {
|
||||
it('uses id as account number, description as name, incoming_balance as opening balance', () => {
|
||||
const dto = mapBrioxToAccountingAccount({
|
||||
id: 1930,
|
||||
description: 'Företagskonto',
|
||||
active: '1',
|
||||
incoming_balance: '12500.50',
|
||||
});
|
||||
|
||||
expect(dto.accountNumber).toBe('1930');
|
||||
expect(dto.name).toBe('Företagskonto');
|
||||
expect(dto.type).toBe('asset');
|
||||
expect(dto.active).toBe(true);
|
||||
expect(dto.balanceCarriedForward).toBe(12500.5);
|
||||
});
|
||||
|
||||
it('treats active "0" (string) as inactive', () => {
|
||||
expect(mapBrioxToAccountingAccount({ id: 3041, description: 'Försäljning', active: '0' }).active).toBe(false);
|
||||
expect(mapBrioxToAccountingAccount({ id: 3041, description: 'Försäljning', active: '1' }).active).toBe(true);
|
||||
expect(mapBrioxToAccountingAccount({ id: 3041, description: 'Försäljning', active: false }).active).toBe(false);
|
||||
});
|
||||
|
||||
it('derives the account type from the BAS class', () => {
|
||||
expect(mapBrioxToAccountingAccount({ id: 1510, description: 'Kundfordringar' }).type).toBe('asset');
|
||||
expect(mapBrioxToAccountingAccount({ id: 2440, description: 'Leverantörsskulder' }).type).toBe('liability');
|
||||
expect(mapBrioxToAccountingAccount({ id: 3041, description: 'Försäljning' }).type).toBe('revenue');
|
||||
expect(mapBrioxToAccountingAccount({ id: 6570, description: 'Bankkostnader' }).type).toBe('expense');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Briox mapper — company information', () => {
|
||||
it('unwraps the /user/info envelope and reads accounts[0]', () => {
|
||||
const dto = mapBrioxToCompanyInformation({
|
||||
info: {
|
||||
company_name: 'Testbolaget AB',
|
||||
accounts: [
|
||||
{
|
||||
database_label: 'Testbolaget AB',
|
||||
organization_number: '5560000000',
|
||||
email: 'info@testbolaget.se',
|
||||
address: {
|
||||
addressline1: 'Storgatan 1',
|
||||
zip: '111 22',
|
||||
city: 'Stockholm',
|
||||
countrycode: 'SE',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(dto.companyName).toBe('Testbolaget AB');
|
||||
expect(dto.organizationNumber).toBe('5560000000');
|
||||
expect(dto.address?.streetName).toBe('Storgatan 1');
|
||||
expect(dto.address?.postalZone).toBe('111 22');
|
||||
expect(dto.address?.countryCode).toBe('SE');
|
||||
expect(dto.contact?.email).toBe('info@testbolaget.se');
|
||||
});
|
||||
|
||||
it('falls back to database_label when company_name is missing', () => {
|
||||
const dto = mapBrioxToCompanyInformation({
|
||||
info: { accounts: [{ database_label: 'Mitt Bolag', organization_number: '5561111111' }] },
|
||||
});
|
||||
expect(dto.companyName).toBe('Mitt Bolag');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { exchangeBrioxCode, refreshBrioxToken } from '../oauth';
|
||||
import { BrioxApiError } from '../client';
|
||||
|
||||
/**
|
||||
* Guards the token-exchange contract against the Briox swagger:
|
||||
* POST /token?clientid={accountId}&token={applicationToken}
|
||||
* POST /tokenrefresh?refreshtoken={refreshToken}&token={currentAccessToken}
|
||||
*
|
||||
* The refresh test pins the exact param mapping — the original implementation
|
||||
* sent the refresh token for BOTH params (and took the account id as an
|
||||
* unused first argument), which Briox rejects.
|
||||
*/
|
||||
|
||||
function tokenResponse(over: Record<string, unknown> = {}): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
client_id: '35649125',
|
||||
access_token: 'access-1',
|
||||
refresh_token: 'refresh-1',
|
||||
expire_date: '2026-06-10 12:00:00',
|
||||
// Swagger declares expire_timestamp as a STRING
|
||||
expire_timestamp: String(Math.floor(Date.now() / 1000) + 7200),
|
||||
...over,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
describe('briox oauth', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('exchange POSTs /token?clientid={accountId}&token={applicationToken}', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(tokenResponse());
|
||||
|
||||
const result = await exchangeBrioxCode('35649125', 'app-token-åäö');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(String(url)).toBe(
|
||||
'https://api-se.briox.services/v2/token?clientid=35649125&token=app-token-%C3%A5%C3%A4%C3%B6',
|
||||
);
|
||||
expect((init as RequestInit).method).toBe('POST');
|
||||
expect(result.access_token).toBe('access-1');
|
||||
expect(result.refresh_token).toBe('refresh-1');
|
||||
});
|
||||
|
||||
it('refresh POSTs /tokenrefresh?refreshtoken={refreshToken}&token={currentAccessToken}', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
tokenResponse({ access_token: 'access-2', refresh_token: 'refresh-2' }),
|
||||
);
|
||||
|
||||
const result = await refreshBrioxToken('refresh-1', 'expired-access-1');
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(String(url)).toBe(
|
||||
'https://api-se.briox.services/v2/tokenrefresh?refreshtoken=refresh-1&token=expired-access-1',
|
||||
);
|
||||
expect((init as RequestInit).method).toBe('POST');
|
||||
// Briox rotates both tokens — the new pair must be surfaced so the
|
||||
// caller persists the rotated refresh token.
|
||||
expect(result.access_token).toBe('access-2');
|
||||
expect(result.refresh_token).toBe('refresh-2');
|
||||
});
|
||||
|
||||
it('derives expires_in from a string expire_timestamp', async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
fetchSpy.mockResolvedValueOnce(tokenResponse({ expire_timestamp: String(now + 1800) }));
|
||||
|
||||
const result = await exchangeBrioxCode('1', 't');
|
||||
|
||||
expect(result.expires_in).toBeGreaterThan(1700);
|
||||
expect(result.expires_in).toBeLessThanOrEqual(1800);
|
||||
});
|
||||
|
||||
it('falls back to 3600 when expire_timestamp is in the past or unparseable', async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(tokenResponse({ expire_timestamp: String(now - 60) }));
|
||||
expect((await exchangeBrioxCode('1', 't')).expires_in).toBe(3600);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(tokenResponse({ expire_timestamp: 'not-a-number' }));
|
||||
expect((await exchangeBrioxCode('1', 't')).expires_in).toBe(3600);
|
||||
});
|
||||
|
||||
it('exchange throws BrioxApiError carrying the HTTP status on rejection', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));
|
||||
|
||||
const err = await exchangeBrioxCode('1', 'wrong-token').catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(BrioxApiError);
|
||||
expect((err as BrioxApiError).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('refresh throws BrioxApiError carrying the HTTP status on rejection', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('Bad request', { status: 400 }));
|
||||
|
||||
const err = await refreshBrioxToken('r', 'a').catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(BrioxApiError);
|
||||
expect((err as BrioxApiError).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,12 @@ interface BrioxListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface BrioxFinancialYear {
|
||||
id: string;
|
||||
fromdate: string;
|
||||
todate: string;
|
||||
}
|
||||
|
||||
export class BrioxClient {
|
||||
private readonly rateLimiter: TokenBucketRateLimiter;
|
||||
private readonly baseUrl: string;
|
||||
@@ -46,17 +52,24 @@ export class BrioxClient {
|
||||
this.rateLimiter = new TokenBucketRateLimiter(BRIOX_RATE_LIMIT, 'ratelimit:briox');
|
||||
}
|
||||
|
||||
// The Authorization header carries the RAW access token — no "Bearer "
|
||||
// prefix. The swagger securityScheme is `apiKey` in header "Authorization"
|
||||
// (the Swagger UI pastes the bare token), not an OAuth bearer scheme.
|
||||
private authHeaders(accessToken: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: accessToken,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async get<T>(accessToken: string, path: string): Promise<T> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: accessToken,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: this.authHeaders(accessToken),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
@@ -79,6 +92,41 @@ export class BrioxClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a binary resource (e.g. the SIE export, served as an octet-stream)
|
||||
* with the same rate-limit/retry behavior as get(). Returns the raw bytes —
|
||||
* SIE files must go through detectEncoding()/decodeBuffer() since Briox may
|
||||
* emit CP437, Windows-1252 or UTF-8.
|
||||
*/
|
||||
async getBytes(accessToken: string, path: string): Promise<ArrayBuffer> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: accessToken },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new BrioxApiError(
|
||||
`Briox API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000,
|
||||
shouldRetry: isRetryableError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getPage<T>(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
@@ -146,14 +194,19 @@ export class BrioxClient {
|
||||
return allItems;
|
||||
}
|
||||
|
||||
async getCurrentFinancialYear(accessToken: string): Promise<string> {
|
||||
/** All financial years registered in Briox, oldest first (API order). */
|
||||
async listFinancialYears(accessToken: string): Promise<BrioxFinancialYear[]> {
|
||||
const response = await this.get<{
|
||||
data: {
|
||||
financialyears: { id: string; fromdate: string; todate: string }[];
|
||||
financialyears: BrioxFinancialYear[];
|
||||
};
|
||||
}>(accessToken, '/financialyear');
|
||||
|
||||
const years = response.data?.financialyears ?? [];
|
||||
return response.data?.financialyears ?? [];
|
||||
}
|
||||
|
||||
async getCurrentFinancialYear(accessToken: string): Promise<string> {
|
||||
const years = await this.listFinancialYears(accessToken);
|
||||
if (years.length === 0) {
|
||||
throw new BrioxApiError('No financial years found in Briox', 404);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,32 @@ function amount(value: number | undefined | null, currency: string = 'SEK'): Amo
|
||||
return { value: value ?? 0, currencyCode: currency };
|
||||
}
|
||||
|
||||
/** Briox often serializes numbers as strings ("250.00") — coerce defensively. */
|
||||
function num(value: unknown): number | undefined {
|
||||
if (value == null || value === '') return undefined;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for "is this invoice fully settled?", used by BOTH
|
||||
* deriveInvoiceStatus and the paymentStatus.paid flag so they can never
|
||||
* diverge (mirrors the Fortnox mapper). An ABSENT balance is treated as NOT
|
||||
* paid — only an explicit paid status/flag, or a present non-positive balance
|
||||
* on a positive-total invoice, counts as paid.
|
||||
*/
|
||||
function isFullyPaid(raw: Record<string, unknown>): boolean {
|
||||
if (raw['status'] === 'paid' || raw['fully_paid'] === true) return true;
|
||||
const total = num(raw['total_amount']);
|
||||
const balance = num(raw['balance']);
|
||||
return total != null && total > 0 && balance != null && balance <= 0;
|
||||
}
|
||||
|
||||
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
|
||||
const status = raw['status'] as string | undefined;
|
||||
if (status === 'cancelled') return 'cancelled';
|
||||
if (status === 'credited') return 'credited';
|
||||
if (status === 'paid' || raw['fully_paid'] === true) return 'paid';
|
||||
if (isFullyPaid(raw)) return 'paid';
|
||||
if (status === 'booked' || raw['booked'] === true) return 'booked';
|
||||
if (status === 'sent' || raw['sent'] === true) return 'sent';
|
||||
if (status === 'overdue') return 'overdue';
|
||||
@@ -49,31 +70,37 @@ function buildParty(name: string, orgNumber?: string, raw?: Record<string, unkno
|
||||
|
||||
export function mapBrioxToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
|
||||
const currency = (raw['currency_code'] as string) ?? 'SEK';
|
||||
const total = raw['total_amount'] as number ?? 0;
|
||||
const balance = raw['balance'] as number ?? 0;
|
||||
const total = num(raw['total_amount']) ?? 0;
|
||||
// Default an ABSENT balance to the full total (= fully unpaid), never 0, so
|
||||
// a missing balance never silently reads as paid. When paid, force balance
|
||||
// to 0 so the DTO is internally consistent (paid ⇒ nothing outstanding).
|
||||
const paid = isFullyPaid(raw);
|
||||
const balance = paid ? 0 : (num(raw['balance']) ?? total);
|
||||
|
||||
const rows = (raw['rows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
// Line-level amounts arrive from the same string-serializing API as the
|
||||
// header amounts — coerce ALL numerics through num(), never blind casts.
|
||||
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
|
||||
id: String(row['id'] ?? idx + 1),
|
||||
description: row['description'] as string | undefined,
|
||||
quantity: row['quantity'] as number | undefined,
|
||||
quantity: num(row['quantity']),
|
||||
unitCode: row['unit'] as string | undefined,
|
||||
unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined,
|
||||
lineExtensionAmount: amount(row['total'] as number ?? 0, currency),
|
||||
taxPercent: row['vat_rate'] as number | undefined,
|
||||
unitPrice: row['price'] != null ? amount(num(row['price']), currency) : undefined,
|
||||
lineExtensionAmount: amount(num(row['total']), currency),
|
||||
taxPercent: num(row['vat_rate']),
|
||||
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
|
||||
articleNumber: row['article_number'] as string | undefined,
|
||||
itemName: row['description'] as string | undefined,
|
||||
}));
|
||||
|
||||
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
||||
lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency),
|
||||
lineExtensionAmount: amount(num(raw['net_amount']) ?? total, currency),
|
||||
taxInclusiveAmount: amount(total, currency),
|
||||
payableAmount: amount(total, currency),
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: balance === 0 && total > 0,
|
||||
paid,
|
||||
balance: amount(balance, currency),
|
||||
};
|
||||
|
||||
@@ -103,27 +130,32 @@ export function mapBrioxToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
|
||||
|
||||
export function mapBrioxToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
|
||||
const currency = (raw['currency_code'] as string) ?? 'SEK';
|
||||
const total = raw['total_amount'] as number ?? 0;
|
||||
const balance = raw['balance'] as number ?? 0;
|
||||
const total = num(raw['total_amount']) ?? 0;
|
||||
// Same absent-balance hardening as the sales path: missing balance reads as
|
||||
// fully unpaid, paid forces balance to 0.
|
||||
const paid = isFullyPaid(raw);
|
||||
const balance = paid ? 0 : (num(raw['balance']) ?? total);
|
||||
|
||||
const rows = (raw['rows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
// Same string-coercion hardening as the sales path (Briox serializes
|
||||
// numbers as strings) — route every numeric line field through num().
|
||||
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
|
||||
id: String(row['id'] ?? idx + 1),
|
||||
description: row['description'] as string | undefined,
|
||||
quantity: row['quantity'] as number | undefined,
|
||||
unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined,
|
||||
lineExtensionAmount: amount(row['total'] as number ?? 0, currency),
|
||||
quantity: num(row['quantity']),
|
||||
unitPrice: row['price'] != null ? amount(num(row['price']), currency) : undefined,
|
||||
lineExtensionAmount: amount(num(row['total']), currency),
|
||||
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
|
||||
}));
|
||||
|
||||
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
||||
lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency),
|
||||
lineExtensionAmount: amount(num(raw['net_amount']) ?? total, currency),
|
||||
taxInclusiveAmount: amount(total, currency),
|
||||
payableAmount: amount(total, currency),
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: balance === 0 && total > 0,
|
||||
paid,
|
||||
balance: amount(balance, currency),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BRIOX_TOKEN_URL, BRIOX_REFRESH_URL } from './config';
|
||||
import { BrioxApiError } from './client';
|
||||
import type { TokenResponse } from '../types';
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
@@ -8,9 +9,11 @@ import {
|
||||
interface BrioxTokenData {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
client_id: number;
|
||||
// Swagger declares both as strings ("35649125", "1640772931") but be
|
||||
// tolerant of numbers — toTokenResponse coerces before arithmetic.
|
||||
client_id: string | number;
|
||||
expire_date: string;
|
||||
expire_timestamp: number;
|
||||
expire_timestamp: string | number;
|
||||
}
|
||||
|
||||
interface BrioxTokenApiResponse {
|
||||
@@ -18,63 +21,62 @@ interface BrioxTokenApiResponse {
|
||||
}
|
||||
|
||||
function toTokenResponse(brioxData: BrioxTokenData): TokenResponse {
|
||||
const expiresIn = brioxData.expire_timestamp - Math.floor(Date.now() / 1000);
|
||||
const expiresIn = Number(brioxData.expire_timestamp) - Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
access_token: brioxData.access_token,
|
||||
refresh_token: brioxData.refresh_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: expiresIn > 0 ? expiresIn : 3600,
|
||||
expires_in: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600,
|
||||
};
|
||||
}
|
||||
|
||||
async function postForToken(url: string, description: string): Promise<TokenResponse> {
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
{ timeoutMs: OAUTH_TIMEOUT_MS, description },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
// BrioxApiError carries statusCode so callers can tell wrong credentials
|
||||
// (400/401/404 from /token) apart from transient upstream failures.
|
||||
throw new BrioxApiError(`${description} failed: ${response.status} ${body}`, response.status, body);
|
||||
}
|
||||
|
||||
const result = await response.json() as BrioxTokenApiResponse;
|
||||
return toTokenResponse(result.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the user's account ID + application token for an access/refresh
|
||||
* token pair. The "clientid" is the user's Briox account ID (the long number
|
||||
* next to the company name under "Your Account"), NOT an app-level credential
|
||||
* — Briox has no developer client id/secret on our side.
|
||||
*/
|
||||
export async function exchangeBrioxCode(
|
||||
clientId: string,
|
||||
accountId: string,
|
||||
applicationToken: string,
|
||||
): Promise<TokenResponse> {
|
||||
const url = `${BRIOX_TOKEN_URL}?clientid=${encodeURIComponent(clientId)}&token=${encodeURIComponent(applicationToken)}`;
|
||||
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Briox token exchange' },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Briox token exchange failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as BrioxTokenApiResponse;
|
||||
return toTokenResponse(result.data);
|
||||
const url = `${BRIOX_TOKEN_URL}?clientid=${encodeURIComponent(accountId)}&token=${encodeURIComponent(applicationToken)}`;
|
||||
return postForToken(url, 'Briox token exchange');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an expired access token. Per the swagger, /tokenrefresh takes the
|
||||
* refresh token as `refreshtoken` and the CURRENT (expired) access token as
|
||||
* `token`. Briox rotates both tokens — the caller must persist the new
|
||||
* refresh_token from the response or the next refresh will fail.
|
||||
*/
|
||||
export async function refreshBrioxToken(
|
||||
clientId: string,
|
||||
refreshToken: string,
|
||||
currentAccessToken: string,
|
||||
): Promise<TokenResponse> {
|
||||
const url = `${BRIOX_REFRESH_URL}?refreshtoken=${encodeURIComponent(refreshToken)}&token=${encodeURIComponent(refreshToken)}`;
|
||||
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Briox token refresh' },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Briox token refresh failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as BrioxTokenApiResponse;
|
||||
return toTokenResponse(result.data);
|
||||
const url = `${BRIOX_REFRESH_URL}?refreshtoken=${encodeURIComponent(refreshToken)}&token=${encodeURIComponent(currentAccessToken)}`;
|
||||
return postForToken(url, 'Briox token refresh');
|
||||
}
|
||||
|
||||
@@ -127,6 +127,56 @@ export class FortnoxClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a binary resource with the same rate-limit/retry behavior as get().
|
||||
* Used for the SIE export: response.text() would blind-decode as UTF-8 and
|
||||
* irrecoverably turn CP437 å/ä/ö into U+FFFD, so callers must run the raw
|
||||
* bytes through detectEncoding()/decodeBuffer() (mirrors Briox/BL clients).
|
||||
*/
|
||||
async getBytes(accessToken: string, path: string): Promise<ArrayBuffer> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
let retryAfterMs: number | undefined;
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
|
||||
}
|
||||
throw new FortnoxApiError(
|
||||
`Fortnox API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
retryAfterMs,
|
||||
);
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
},
|
||||
{
|
||||
maxAttempts: 6,
|
||||
initialDelayMs: 2000,
|
||||
maxDelayMs: 60_000,
|
||||
shouldRetry: isRetryableError,
|
||||
getDelayMs: (error) => {
|
||||
if (error instanceof FortnoxApiError && error.retryAfterMs) {
|
||||
return error.retryAfterMs;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getPage<T>(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
|
||||
@@ -16,8 +16,10 @@ export function getOAuthConfig(provider: string): OAuthConfig {
|
||||
};
|
||||
}
|
||||
if (provider === 'briox') {
|
||||
// No app-level credentials: the user's account ID + application token are
|
||||
// exchanged per consent (exchangeBrioxCode). No env vars needed.
|
||||
return {
|
||||
clientId: process.env.BRIOX_CLIENT_ID ?? '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
redirectUri: '',
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@ import { refreshFortnoxToken } from './fortnox/oauth';
|
||||
import { refreshVismaToken } from './visma/oauth';
|
||||
import { refreshBrioxToken } from './briox/oauth';
|
||||
import { refreshBjornLundenToken } from './bjornlunden/oauth';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
|
||||
const log = createLogger('providers/resolve-consent');
|
||||
|
||||
export interface ResolvedConsent {
|
||||
consent: Record<string, unknown>;
|
||||
@@ -93,30 +96,90 @@ export async function resolveConsent(companyId: string, consentId: string): Prom
|
||||
throw { status: 401, message: 'Access token expired and no refresh token available' };
|
||||
}
|
||||
|
||||
const config = getOAuthConfig(consent.provider as string);
|
||||
let refreshed: TokenResponse;
|
||||
|
||||
if (consent.provider === 'fortnox') {
|
||||
refreshed = await refreshFortnoxToken(config, tokens.refresh_token as string);
|
||||
refreshed = await refreshFortnoxToken(getOAuthConfig('fortnox'), tokens.refresh_token as string);
|
||||
} else if (consent.provider === 'briox') {
|
||||
refreshed = await refreshBrioxToken(config.clientId, tokens.refresh_token as string);
|
||||
// Briox /tokenrefresh wants the (expired) access token alongside the
|
||||
// refresh token; no app-level config involved. Both tokens rotate —
|
||||
// the new refresh_token is persisted below.
|
||||
refreshed = await refreshBrioxToken(tokens.refresh_token as string, tokens.access_token as string);
|
||||
} else {
|
||||
refreshed = await refreshVismaToken(config, tokens.refresh_token as string);
|
||||
refreshed = await refreshVismaToken(getOAuthConfig(consent.provider as string), tokens.refresh_token as string);
|
||||
}
|
||||
|
||||
const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString();
|
||||
|
||||
await supabase
|
||||
// Optimistic concurrency guard: providers like Briox and Fortnox rotate
|
||||
// BOTH tokens on refresh, so two concurrent requests refreshing the same
|
||||
// expired pair must not both persist — the second write would overwrite
|
||||
// the pair the first request just stored with a possibly-dead one. Key
|
||||
// the UPDATE on the token_expires_at we read above: if another request
|
||||
// already rotated, zero rows match and we adopt the stored fresh tokens.
|
||||
const { data: updatedRows, error: updateError } = await supabase
|
||||
.from('provider_consent_tokens')
|
||||
.update({
|
||||
access_token: refreshed.access_token,
|
||||
refresh_token: refreshed.refresh_token,
|
||||
token_expires_at: newExpiresAt,
|
||||
})
|
||||
.eq('consent_id', consentId);
|
||||
.eq('consent_id', consentId)
|
||||
.eq('token_expires_at', tokens.token_expires_at as string)
|
||||
.select('id');
|
||||
|
||||
return { consent, accessToken: refreshed.access_token };
|
||||
if (updateError) {
|
||||
// The provider has ALREADY rotated the tokens but we failed to persist
|
||||
// the new pair — the stored pair is now dead and every later call on
|
||||
// this consent will fail. Log loudly; the only recovery is to
|
||||
// disconnect and re-enter the provider credentials.
|
||||
log.error(
|
||||
`Failed to persist rotated ${consent.provider} tokens for consent ${consentId} — ` +
|
||||
'the stored credentials are now invalid and the consent will break',
|
||||
{ reason: updateError.message },
|
||||
);
|
||||
throw {
|
||||
status: 500,
|
||||
message:
|
||||
'Token refresh succeeded at the provider but the rotated tokens could not be saved. ' +
|
||||
'The stored credentials are no longer valid — disconnect the provider and re-enter the credentials.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
// Lost the refresh race: a concurrent request already rotated and
|
||||
// persisted a fresh pair. Use those tokens as-is — calling the provider
|
||||
// refresh endpoint again here would invalidate the winner's pair.
|
||||
const { data: freshRows } = await supabase
|
||||
.from('provider_consent_tokens')
|
||||
.select('*')
|
||||
.eq('consent_id', consentId)
|
||||
.limit(1);
|
||||
|
||||
const fresh = freshRows?.[0];
|
||||
if (fresh?.access_token) {
|
||||
return {
|
||||
consent,
|
||||
accessToken: fresh.access_token as string,
|
||||
providerCompanyId: (fresh.provider_company_id ?? tokens.provider_company_id) as
|
||||
| string
|
||||
| undefined,
|
||||
};
|
||||
}
|
||||
// Token row vanished mid-flight (disconnect?) — fall through to our own
|
||||
// refreshed pair, which the provider still considers the latest one.
|
||||
}
|
||||
|
||||
return {
|
||||
consent,
|
||||
accessToken: refreshed.access_token,
|
||||
providerCompanyId: tokens.provider_company_id as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { consent, accessToken: tokens.access_token as string };
|
||||
return {
|
||||
consent,
|
||||
accessToken: tokens.access_token as string,
|
||||
providerCompanyId: tokens.provider_company_id as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user