Add/bokslut (#718)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Add tests for bookkeeping accruals dissolution and supplier invoices

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-12 16:35:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8e8b63a200
commit db8983ba9e
131 changed files with 33796 additions and 340 deletions
+85 -94
View File
@@ -12,7 +12,10 @@ import {
deleteConsent,
resolveConsent,
fetchCompanyInfoDirect,
ProviderTokenInvalidError,
ConsentNotFoundError,
} from './lib/provider-client'
import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher'
import { mapCompanyInfo } from './lib/entity-mapper'
import { executeMigration } from './lib/migration-orchestrator'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
@@ -22,7 +25,6 @@ import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import { FortnoxClient } from '@/lib/providers/fortnox/client'
import type { ProviderName } from '@/lib/providers/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { classifyProviderError } from '@/lib/providers/with-provider-call'
@@ -30,11 +32,6 @@ import { createLogger } from '@/lib/logger'
const moduleLog = createLogger('extensions/arcim-migration')
/** Fiscal years we support importing — older data is not needed */
const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026])
const fortnoxClient = new FortnoxClient()
/**
* Map known OAuth error codes from providers (Fortnox, Visma) to actionable
* Swedish guidance. Falls back to the raw provider message so we never hide
@@ -306,7 +303,12 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { consentId, provider, apiToken, companyId } = await request.json() as {
// The caller's tenant — NOT the provider-side company id below.
const ownerCompanyId = ctx?.companyId ?? user.id
// `companyId` in the body is the PROVIDER-side company identifier
// (BL User-Key / Briox account ID / Bokio company GUID).
const { consentId, provider, apiToken, companyId: providerCompanyId } = await request.json() as {
consentId: string
provider: ArcimProvider
apiToken: string
@@ -326,17 +328,38 @@ export const arcimMigrationExtension: Extension = {
})
}
if ((provider === 'bokio' || provider === 'bjornlunden') && !companyId) {
// Briox needs the account ID (the /token clientid param) alongside
// the application token; Bokio/BL need their company GUID.
if ((provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox') && !providerCompanyId) {
return errorResponseFromCode('PROVIDER_COMPANY_ID_REQUIRED', moduleLog, {
details: { provider },
})
}
try {
await submitProviderToken(consentId, provider, apiToken || 'client_credentials', companyId)
await submitProviderToken(
consentId,
provider,
apiToken || 'client_credentials',
providerCompanyId,
ownerCompanyId,
)
return NextResponse.json({ success: true, consentId })
} catch (error) {
log.error('arcim submit-token failed', error as Error, { provider })
// Consent missing or owned by another company — same 404 either way.
if (error instanceof ConsentNotFoundError) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, {
details: { consentId },
})
}
// Wrong credentials (provider actively rejected them) — tell the
// user to re-check the pasted values instead of a generic 500.
if (error instanceof ProviderTokenInvalidError) {
return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, {
details: { provider, reason: error.message },
})
}
return errorResponseFromCode('PROVIDER_TOKEN_SUBMIT_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
@@ -513,46 +536,27 @@ export const arcimMigrationExtension: Extension = {
log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err))
}
// Try to fetch SIE data (Fortnox has native SIE export)
// Try to fetch SIE data (Fortnox and Briox serve SIE over the API)
let sieAvailable = false
let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null
if (provider === 'fortnox') {
if (providerSupportsSie(provider)) {
try {
log.info(`Fetching SIE export from Fortnox for consent ${consentId}...`)
// Fortnox SIE export endpoint: /3/sie/{type}?financialyear={id}
// First get financial years
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
log.info(`Fetching SIE export from ${provider} for consent ${consentId}...`)
// Fetch SIE type 4 for the most recent allowed year to get stats
const { files, availableYears } = await fetchProviderSieFiles(
provider,
resolved.accessToken,
'/financialyears'
resolved.providerCompanyId,
{ latestOnly: true },
)
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
const allowedYears = years
.map(fy => ({
id: fy['Id'] as number,
fromDate: fy['FromDate'] as string,
toDate: fy['ToDate'] as string,
}))
.filter(fy => {
const year = new Date(fy.fromDate).getFullYear()
return ALLOWED_FISCAL_YEARS.has(year)
})
if (allowedYears.length > 0) {
// Fetch SIE type 4 for the most recent allowed year to get stats
const latestYear = allowedYears[allowedYears.length - 1]
const sieContent = await fortnoxClient.getText(
resolved.accessToken,
`/sie/4?financialyear=${latestYear.id}`
)
if (sieContent) {
const parsed = parseSIEFile(sieContent)
sieAvailable = true
sieStats = {
accountCount: parsed.accounts.length,
transactionCount: parsed.vouchers.length,
fiscalYears: allowedYears.map(fy => new Date(fy.fromDate).getFullYear()),
}
if (files.length > 0) {
const parsed = parseSIEFile(files[files.length - 1].rawContent)
sieAvailable = true
sieStats = {
accountCount: parsed.accounts.length,
transactionCount: parsed.vouchers.length,
fiscalYears: availableYears,
}
}
} catch (err) {
@@ -621,54 +625,30 @@ export const arcimMigrationExtension: Extension = {
const resolved = await resolveConsent(companyId, consentId)
const provider = resolved.consent.provider as ProviderName
if (provider !== 'fortnox') {
return errorResponseFromCode('PROVIDER_SIE_ONLY_FORTNOX', moduleLog, {
if (!providerSupportsSie(provider)) {
return errorResponseFromCode('PROVIDER_SIE_NOT_SUPPORTED', moduleLog, {
details: { provider },
})
}
// Fetch financial years from Fortnox
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
// Fetch SIE type 4 for each allowed fiscal year
const { files: sieFiles, failedYears } = await fetchProviderSieFiles(
provider,
resolved.accessToken,
'/financialyears'
resolved.providerCompanyId,
)
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
const allowedYears = years
.map(fy => ({
id: fy['Id'] as number,
fromDate: fy['FromDate'] as string,
toDate: fy['ToDate'] as string,
}))
.filter(fy => {
const year = new Date(fy.fromDate).getFullYear()
return ALLOWED_FISCAL_YEARS.has(year)
})
if (allowedYears.length === 0) {
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog)
}
// Fetch SIE type 4 for each allowed year
const sieFiles: { fiscalYear: number; rawContent: string }[] = []
for (const fy of allowedYears) {
try {
const sieContent = await fortnoxClient.getText(
resolved.accessToken,
`/sie/4?financialyear=${fy.id}`
)
if (sieContent) {
sieFiles.push({
fiscalYear: new Date(fy.fromDate).getFullYear(),
rawContent: sieContent,
})
}
} catch (err) {
log.info(`Failed to fetch SIE for year ${fy.id}:`, err instanceof Error ? err.message : String(err))
}
}
if (sieFiles.length === 0) {
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog)
// The allowed window is rolling (current year and the two before
// it) — interpolate the actual range instead of the static
// registry message so the text never goes stale.
const allowedYears = [...getAllowedFiscalYears()].sort((a, b) => a - b)
const range = `${allowedYears[0]}–${allowedYears[allowedYears.length - 1]}`
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog, {
messageSv: `Inga räkenskapsår ${range} hittades hos leverantören.`,
messageEn: `No fiscal years available for ${range}.`,
...(failedYears.length > 0 ? { details: { failedYears } } : {}),
})
}
// Parse most recent file for preview/validation
@@ -677,6 +657,10 @@ export const arcimMigrationExtension: Extension = {
const validation = validateSIEFile(parsed)
if (!validation.valid) {
log.warn(
`arcim sie-data validation failed for ${provider} fiscal year ${sieFile.fiscalYear}: ` +
`${validation.errors.length} error(s) — ${validation.errors.slice(0, 3).join(' | ')}`,
)
return NextResponse.json({
error: 'validation',
message: 'SIE file validation failed',
@@ -725,7 +709,7 @@ export const arcimMigrationExtension: Extension = {
const preview = generateImportPreview(parsed, mappings)
// Detect prior imports by *fiscal period overlap*, not file hash.
// Fortnox embeds the export-time #GEN date in every SIE export so
// Providers embed the export-time #GEN date in every SIE export so
// the hash always changes between syncs; only the period stays
// stable. A re-sync replaces the prior import for the same period.
const fileStatuses: {
@@ -794,6 +778,9 @@ export const arcimMigrationExtension: Extension = {
allImported: false,
newFileCount: fileStatuses.length - replacedFileCount,
replacedFileCount,
// Allowed years whose provider export failed — the wizard warns
// the user before proceeding so an IB/UB gap cannot slip through.
failedYears,
basAccounts: BAS_REFERENCE,
})
} catch (error) {
@@ -871,7 +858,7 @@ export const arcimMigrationExtension: Extension = {
// Default ON: re-syncs keep account names current with the source
// system (idempotent — equal names are a no-op in the rename pass).
updateAccountNames: options.updateAccountNames ?? true,
// Fortnox re-sync semantics: a prior completed import for the
// Provider re-sync semantics: a prior completed import for the
// same fiscal year is automatically replaced (its imported
// entries are cancelled) so the user can pull updated data
// without manual cleanup. Manual SIE upload keeps default
@@ -946,15 +933,19 @@ export const arcimMigrationExtension: Extension = {
}
// ── Guard: a completed SIE import is required before entity import ──
// Every provider except Fortnox exposes ONLY entity data (customers,
// suppliers, invoices) via API — never the general ledger. Fortnox pulls
// the GL itself via SIE-over-API. Importing entities without the
// SIE-derived ledger (kontoplan, ingående balanser, verifikationer)
// would leave an incomplete bokföring under BFL: a subledger with no
// chart of accounts and no opening balances, so every subsequent posting
// and balance is wrong. The wizard surfaces this as an advisory banner,
// but it must be enforced here so the rule cannot be bypassed by a direct
// API call, a skipped wizard step, or a stale client.
// Most providers expose ONLY entity data (customers, suppliers,
// invoices) via API — never the general ledger. Fortnox pulls the GL
// itself via SIE-over-API and is exempt. Briox and Björn Lundén also
// serve SIE over the API, but the wizard runs /import-sie before
// /migrate, so this guard stays satisfied — and keeps protecting
// against a skipped SIE step. Importing entities without the
// SIE-derived ledger (kontoplan,
// ingående balanser, verifikationer) would leave an incomplete
// bokföring under BFL: a subledger with no chart of accounts and no
// opening balances, so every subsequent posting and balance is wrong.
// The wizard surfaces this as an advisory banner, but it must be
// enforced here so the rule cannot be bypassed by a direct API call,
// a skipped wizard step, or a stale client.
if (consent.provider !== 'fortnox') {
const { count: completedSieImports } = await supabase
.from('sie_imports')
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { mockBlGet } = vi.hoisted(() => ({ mockBlGet: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
createClient: vi.fn(),
}))
vi.mock('@/lib/providers/bjornlunden/oauth', () => ({
refreshBjornLundenToken: vi.fn().mockResolvedValue({
access_token: 'bl-app-token',
token_type: 'Bearer',
expires_in: 3600,
}),
}))
// Keep the real BjornLundenApiError (instanceof checks in provider-client)
// but replace the client so the /details probe is controllable per test.
vi.mock('@/lib/providers/bjornlunden/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/providers/bjornlunden/client')>()
return {
...actual,
// Must be a `function` (not an arrow) so `new BjornLundenClient()` works.
BjornLundenClient: vi.fn().mockImplementation(function mockClient() {
return { get: mockBlGet }
}),
}
})
import { createServiceClient } from '@/lib/supabase/server'
import { BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
import {
submitProviderToken,
ProviderTokenInvalidError,
ConsentNotFoundError,
} from '../provider-client'
describe('submitProviderToken', () => {
let mock: ReturnType<typeof createQueuedMockSupabase>
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
vi.mocked(createServiceClient).mockReturnValue(mock.supabase as never)
})
const tablesTouched = () => vi.mocked(mock.supabase.from).mock.calls.map((c) => c[0])
// ── Consent ownership (IDOR guard) ────────────────────────────────
it('throws ConsentNotFoundError and writes NOTHING when the consent belongs to another company', async () => {
// Ownership check finds no row for (consentId, ownerCompanyId) — the same
// result whether the consent does not exist or belongs to another tenant.
mock.enqueue({ data: [] })
await expect(
submitProviderToken('consent-other-tenant', 'bokio', 'tok', 'bokio-guid', 'company-A'),
).rejects.toBeInstanceOf(ConsentNotFoundError)
// Only the ownership read happened — no token upsert, no consent update.
expect(tablesTouched()).toEqual(['provider_consents'])
})
it('stores tokens when the consent belongs to the caller company', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mock.enqueue({ data: null }) // token upsert
const result = await submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A')
expect(result).toEqual({ success: true, consentId: 'consent-1' })
expect(tablesTouched()).toEqual(['provider_consents', 'provider_consent_tokens'])
})
// ── BL /details probe error classification ────────────────────────
it('does NOT map a 429 from the BL probe to ProviderTokenInvalidError', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 429', 429))
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(BjornLundenApiError)
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
// The transient failure must not store the unverified key either.
expect(tablesTouched()).not.toContain('provider_consent_tokens')
})
it('does NOT map gateway-style 5xx (503) from the BL probe to ProviderTokenInvalidError', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 503', 503))
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(BjornLundenApiError)
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
})
it('maps 500 from the BL probe to invalid credentials (sandbox-verified bad-key signal) and disables probe retries', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 500', 500))
await expect(
submitProviderToken('consent-1', 'bjornlunden', 'client_credentials', 'user-key-guid', 'company-A'),
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
// The probe must fail fast: a typo'd key answers 500, which the client's
// retry policy treats as retryable — retry is disabled per call.
expect(mockBlGet).toHaveBeenCalledTimes(1)
expect(mockBlGet).toHaveBeenCalledWith('bl-app-token', 'user-key-guid', '/details', {
retry: false,
})
})
it('maps 404 from the BL probe to invalid credentials', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 404', 404))
await expect(
submitProviderToken('consent-1', 'bjornlunden', 'client_credentials', 'user-key-guid', 'company-A'),
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
})
it('stores BL tokens (and labels the consent) when the probe succeeds', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mock.enqueue({ data: null }) // consent company_name update
mock.enqueue({ data: null }) // token upsert
mockBlGet.mockResolvedValueOnce({ name: 'Testbolaget AB' })
const result = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
)
expect(result).toEqual({ success: true, consentId: 'consent-1' })
expect(tablesTouched()).toEqual([
'provider_consents',
'provider_consents',
'provider_consent_tokens',
])
})
})
@@ -0,0 +1,300 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
providerSupportsSie,
fetchProviderSieFiles,
getAllowedFiscalYears,
} from '../sie-fetcher'
// The allowed window is rolling (current year and the two before it) — derive
// fixture years from the clock so these tests never go stale at new year.
const CY = new Date().getFullYear()
/**
* Routes mocked fetch responses by URL substring. The fetcher goes through
* the real provider clients (rate limiter in in-memory mode, retry on), so
* these tests exercise the full request path including auth headers and the
* encoding-detection decode of binary SIE payloads.
*/
function routeFetch(
fetchSpy: ReturnType<typeof vi.spyOn>,
routes: { match: string; respond: () => Response }[],
) {
fetchSpy.mockImplementation(((input: RequestInfo | URL) => {
const url = String(input)
const route = routes.find((r) => url.includes(r.match))
if (!route) {
return Promise.resolve(new Response(`no mock for ${url}`, { status: 404 }))
}
return Promise.resolve(route.respond())
}) as typeof fetch)
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
/** Build a CP437-encoded SIE snippet containing "Företagskonto" (ö = 0x94). */
function cp437SieBytes(label: string): Uint8Array {
const prefix = `#FLAGGA 0\n#KONTO 1930 "F`
const suffix = `retagskonto ${label}"\n`
const bytes: number[] = []
for (const c of prefix) bytes.push(c.charCodeAt(0))
bytes.push(0x94) // 'ö' in CP437
for (const c of suffix) bytes.push(c.charCodeAt(0))
return new Uint8Array(bytes)
}
function octetResponse(bytes: Uint8Array): Response {
return new Response(bytes.buffer as ArrayBuffer, {
status: 200,
headers: { 'Content-Type': 'application/octet-stream' },
})
}
describe('getAllowedFiscalYears', () => {
it('is a rolling three-year window ending at the current year', () => {
const years = getAllowedFiscalYears(new Date('2031-06-15'))
expect([...years].sort((a, b) => a - b)).toEqual([2029, 2030, 2031])
})
it('defaults to the real current year', () => {
const years = getAllowedFiscalYears()
expect(years.has(CY)).toBe(true)
expect(years.has(CY - 2)).toBe(true)
expect(years.has(CY - 3)).toBe(false)
})
})
describe('providerSupportsSie', () => {
it('is true for providers with SIE-over-API, false otherwise', () => {
expect(providerSupportsSie('fortnox')).toBe(true)
expect(providerSupportsSie('briox')).toBe(true)
expect(providerSupportsSie('bjornlunden')).toBe(true)
expect(providerSupportsSie('visma')).toBe(false)
expect(providerSupportsSie('bokio')).toBe(false)
})
})
describe('fetchProviderSieFiles', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch')
})
afterEach(() => {
fetchSpy.mockRestore()
})
it('rejects providers without SIE support', async () => {
await expect(fetchProviderSieFiles('visma', 't', undefined)).rejects.toThrow(
/does not support SIE over API/,
)
})
describe('briox', () => {
const yearRoutes = {
match: '/financialyear',
respond: () =>
jsonResponse({
data: {
financialyears: [
{ id: '8', fromdate: `${CY - 3}-01-01`, todate: `${CY - 3}-12-31` },
{ id: '9', fromdate: `${CY - 2}-01-01`, todate: `${CY - 2}-12-31` },
{ id: '10', fromdate: `${CY - 1}-01-01`, todate: `${CY - 1}-12-31` },
],
},
}),
}
it('fetches one decoded SIE file per allowed fiscal year', async () => {
routeFetch(fetchSpy, [
yearRoutes,
{ match: '/sie/9/4', respond: () => octetResponse(cp437SieBytes(String(CY - 2))) },
{ match: '/sie/10/4', respond: () => octetResponse(cp437SieBytes(String(CY - 1))) },
])
const result = await fetchProviderSieFiles('briox', 'token', undefined)
// CY-3 is outside the allowed window and must not be fetched
expect(getAllowedFiscalYears().has(CY - 3)).toBe(false)
expect(result.availableYears).toEqual([CY - 2, CY - 1])
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 2, CY - 1])
expect(result.failedYears).toEqual([])
// CP437 bytes decoded into proper Swedish characters
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 2}`)
expect(result.files[1].rawContent).toContain(`Företagskonto ${CY - 1}`)
expect(fetchSpy.mock.calls.map((c: unknown[]) => String(c[0]))).not.toContainEqual(
expect.stringContaining('/sie/8/4'),
)
})
it('latestOnly fetches just the most recent year but reports all years', async () => {
routeFetch(fetchSpy, [
yearRoutes,
{ match: '/sie/10/4', respond: () => octetResponse(cp437SieBytes(String(CY - 1))) },
])
const result = await fetchProviderSieFiles('briox', 'token', undefined, { latestOnly: true })
expect(result.files).toHaveLength(1)
expect(result.files[0].fiscalYear).toBe(CY - 1)
expect(result.availableYears).toEqual([CY - 2, CY - 1])
const sieCalls = fetchSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes('/sie/'))
expect(sieCalls).toHaveLength(1)
})
it('reports a year whose export fails in failedYears and keeps the rest', async () => {
routeFetch(fetchSpy, [
yearRoutes,
// 404 is non-retryable, so the bad year fails fast
{ match: '/sie/9/4', respond: () => new Response('gone', { status: 404 }) },
{ match: '/sie/10/4', respond: () => octetResponse(cp437SieBytes(String(CY - 1))) },
])
const result = await fetchProviderSieFiles('briox', 'token', undefined)
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 1])
expect(result.availableYears).toEqual([CY - 2, CY - 1])
// The failed year must NOT be dropped silently — importing only the
// surviving years would break IB/UB continuity unnoticed.
expect(result.failedYears).toEqual([
{ year: CY - 2, error: expect.stringContaining('404') },
])
})
})
describe('fortnox', () => {
const yearRoutes = {
match: '/financialyears',
respond: () =>
jsonResponse({
FinancialYears: [
{ Id: 5, FromDate: `${CY - 2}-01-01`, ToDate: `${CY - 2}-12-31` },
{ Id: 6, FromDate: `${CY - 1}-01-01`, ToDate: `${CY - 1}-12-31` },
],
}),
}
it('fetches SIE via /sie/4?financialyear={id} and decodes UTF-8 bodies', async () => {
routeFetch(fetchSpy, [
yearRoutes,
{
match: '/sie/4?financialyear=',
respond: () => new Response('#FLAGGA 0\n#KONTO 1930 "Företagskonto"\n', { status: 200 }),
},
])
const result = await fetchProviderSieFiles('fortnox', 'token', undefined)
expect(result.availableYears).toEqual([CY - 2, CY - 1])
expect(result.files).toHaveLength(2)
expect(result.failedYears).toEqual([])
expect(result.files[0].rawContent).toContain('#KONTO 1930 "Företagskonto"')
const sieUrls = fetchSpy.mock.calls
.map((c: unknown[]) => String(c[0]))
.filter((u: string) => u.includes('/sie/4'))
expect(sieUrls[0]).toContain('financialyear=5')
expect(sieUrls[1]).toContain('financialyear=6')
})
it('decodes CP437 bytes from the Fortnox SIE endpoint (no blind UTF-8 text())', async () => {
// Some Fortnox endpoint variants serve the SIE body in CP437 (the SIE
// spec encoding). A blind response.text() would turn å/ä/ö into U+FFFD
// irrecoverably — the byte-level path must detect-decode like Briox/BL.
routeFetch(fetchSpy, [
yearRoutes,
{
match: '/sie/4?financialyear=',
respond: () => octetResponse(cp437SieBytes(String(CY - 1))),
},
])
const result = await fetchProviderSieFiles('fortnox', 'token', undefined)
expect(result.files).toHaveLength(2)
expect(result.files[1].rawContent).toContain(`Företagskonto ${CY - 1}`)
expect(result.files[1].rawContent).not.toContain('�')
})
})
describe('bjornlunden', () => {
it('requires the company User-Key (providerCompanyId)', async () => {
await expect(fetchProviderSieFiles('bjornlunden', 't', undefined)).rejects.toThrow(/User-Key/)
})
it('fetches date-ranged exports with the User-Key header', async () => {
routeFetch(fetchSpy, [
{
match: `/sie/export/${CY - 1}-01-01/${CY - 1}-12-31`,
respond: () => octetResponse(cp437SieBytes(String(CY - 1))),
},
{
match: '/financialyear',
respond: () =>
jsonResponse([
{
entityId: 1,
id: `${CY - 1}01`,
fromDate: `${CY - 1}-01-01`,
toDate: `${CY - 1}-12-31`,
open: true,
},
]),
},
])
const result = await fetchProviderSieFiles('bjornlunden', 'token', 'user-key-guid')
expect(result.files).toHaveLength(1)
expect(result.files[0].fiscalYear).toBe(CY - 1)
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 1}`)
const exportCall = fetchSpy.mock.calls.find((c: unknown[]) => String(c[0]).includes('/sie/export/'))
expect(exportCall).toBeDefined()
expect((exportCall![1] as RequestInit).headers).toMatchObject({
'User-Key': 'user-key-guid',
Authorization: 'Bearer token',
})
})
it('also decodes the swagger-declared base64 body shape', async () => {
// BL's swagger declares the export as a base64 string; the live API
// sends raw bytes. Guard the contingency path: a JSON-quoted base64
// payload of CP437 SIE bytes must decode to the same content.
const sieBytes = cp437SieBytes(String(CY - 1))
const base64 = Buffer.from(sieBytes).toString('base64')
routeFetch(fetchSpy, [
{
match: `/sie/export/${CY - 1}-01-01/${CY - 1}-12-31`,
respond: () =>
new Response(JSON.stringify(base64), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
},
{
match: '/financialyear',
respond: () =>
jsonResponse([
{
entityId: 1,
id: `${CY - 1}01`,
fromDate: `${CY - 1}-01-01`,
toDate: `${CY - 1}-12-31`,
open: true,
},
]),
},
])
const result = await fetchProviderSieFiles('bjornlunden', 'token', 'user-key-guid')
expect(result.files).toHaveLength(1)
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 1}`)
})
})
})
@@ -13,8 +13,40 @@ import { buildFortnoxAuthUrl } from '@/lib/providers/fortnox/oauth'
import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth'
import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth'
import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { BjornLundenClient, BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
import { exchangeBrioxCode } from '@/lib/providers/briox/oauth'
import { BrioxApiError } from '@/lib/providers/briox/client'
import type { ConsentRecord, OtcResponse } from '../types'
// Singleton (holds the rate limiter) — used to validate BL User-Keys at submit
const bjornLundenClient = new BjornLundenClient()
/**
* Thrown by submitProviderToken when the provider actively rejects the
* submitted credentials (as opposed to a transient failure). The route maps
* this to the PROVIDER_TOKEN_INVALID structured error so the wizard can tell
* the user to re-check what they pasted.
*/
export class ProviderTokenInvalidError extends Error {
constructor(message: string) {
super(message)
this.name = 'ProviderTokenInvalidError'
}
}
/**
* Thrown when a consent does not exist OR does not belong to the caller's
* company. The two cases are deliberately indistinguishable so a caller
* cannot probe whether other tenants' consent IDs exist. The route maps this
* to PROVIDER_CONSENT_NOT_FOUND (404).
*/
export class ConsentNotFoundError extends Error {
constructor() {
super('Consent not found')
this.name = 'ConsentNotFoundError'
}
}
// Re-export data fetching functions from the provider layer
export { resolveConsent } from '@/lib/providers/resolve-consent'
export {
@@ -226,18 +258,103 @@ export async function submitProviderToken(
consentId: string,
provider: ProviderName,
apiToken: string,
companyId?: string,
providerCompanyId: string | undefined,
ownerCompanyId: string,
): Promise<{ success: boolean; consentId: string }> {
const supabase = createServiceClient()
// Ownership guard (IDOR): the consent must belong to the caller's company
// before ANY write — this module runs on the service client, which bypasses
// RLS, so this check is the only tenant boundary. Mirrors resolveConsent()
// in lib/providers/resolve-consent.ts. A consent that exists but belongs to
// another company throws the same not-found error as a nonexistent one.
const { data: ownedRows } = await supabase
.from('provider_consents')
.select('id')
.eq('id', consentId)
.eq('company_id', ownerCompanyId)
.limit(1)
if (!ownedRows || ownedRows.length === 0) {
throw new ConsentNotFoundError()
}
let accessToken = apiToken
let refreshToken: string | null = null
let tokenExpiresAt: string | null = null
// BL uses client credentials — get a real token
// BL uses app-level client credentials — get a real token, then prove the
// pasted User-Key actually opens a company before storing anything.
if (provider === 'bjornlunden') {
if (!providerCompanyId) {
throw new ProviderTokenInvalidError('Björn Lundén requires a company key (User-Key)')
}
const tokenResponse = await refreshBjornLundenToken()
accessToken = tokenResponse.access_token
tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
// Sandbox-verified: an unknown User-Key makes /details answer 500 (BL
// fails to bind the company database), not 401/403. Without this probe a
// typo'd GUID is stored silently and only surfaces as a confusing failure
// at preview. retry:false makes a bad key fail fast instead of burning
// the client's full retry budget on the "retryable" 500.
try {
const details = await bjornLundenClient.get<Record<string, unknown>>(
accessToken,
providerCompanyId,
'/details',
{ retry: false },
)
// Bonus from the probe: label the consent with the company name so the
// wizard's connection list shows which BL company was linked.
const blCompanyName = typeof details?.['name'] === 'string' ? (details['name'] as string).trim() : ''
if (blCompanyName) {
await supabase
.from('provider_consents')
.update({ company_name: blCompanyName })
.eq('id', consentId)
}
} catch (error) {
if (error instanceof BjornLundenApiError) {
// 429 and gateway-style 5xx (502/503/504) are transient provider
// failures, not a verdict on the key — rethrow so the route reports a
// generic submit failure instead of "your key is wrong". 500 stays
// mapped to invalid credentials: per the sandbox finding above, 500
// IS the bad-key signal at BL. Tradeoff: a genuine BL 500 outage also
// reads as a rejected key.
if (error.statusCode === 429 || error.statusCode >= 501) {
throw error
}
throw new ProviderTokenInvalidError(
`Björn Lundén rejected the company key (HTTP ${error.statusCode})`,
)
}
throw error
}
}
// Briox: the user pastes an application token + account ID, which we
// exchange ONCE for an access/refresh token pair. Storing the raw
// application token would fail on every data call.
if (provider === 'briox') {
if (!providerCompanyId) {
throw new ProviderTokenInvalidError('Briox requires an account ID (clientid)')
}
try {
const tokenResponse = await exchangeBrioxCode(providerCompanyId, apiToken)
accessToken = tokenResponse.access_token
refreshToken = tokenResponse.refresh_token
tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
} catch (error) {
// /token answers 400/401/404 for a wrong account ID or application
// token — surface as invalid credentials, not a server error.
if (error instanceof BrioxApiError && error.statusCode < 500 && error.statusCode !== 429) {
throw new ProviderTokenInvalidError(
`Briox rejected the credentials (HTTP ${error.statusCode})`,
)
}
throw error
}
}
// Store tokens — consent stays at status 0 until migration/SIE import completes
@@ -247,9 +364,9 @@ export async function submitProviderToken(
consent_id: consentId,
provider,
access_token: accessToken,
refresh_token: null,
refresh_token: refreshToken,
token_expires_at: tokenExpiresAt,
provider_company_id: companyId,
provider_company_id: providerCompanyId,
})
return { success: true, consentId }
@@ -0,0 +1,234 @@
/**
* Per-provider SIE-over-API fetcher.
*
* Providers that expose their general ledger as a SIE export over the API get
* the "Fortnox-grade" migration experience: the wizard pulls the GL itself
* instead of requiring a manual SIE upload. Everything downstream (parsing,
* validation, account mapping, replace-mode import) is provider-agnostic —
* this module's only job is to produce raw SIE file contents per fiscal year.
*
* Shared by /preview (latest year, for stats) and /sie-data (all years).
*/
import { FortnoxClient } from '@/lib/providers/fortnox/client'
import { BrioxClient } from '@/lib/providers/briox/client'
import { BjornLundenClient } from '@/lib/providers/bjornlunden/client'
import type { ProviderName } from '@/lib/providers/types'
import { detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { createLogger } from '@/lib/logger'
const log = createLogger('extensions/arcim-migration/sie-fetcher')
/**
* Fiscal years we support importing — the current year and the two before it.
* Derived at call time (not a module constant) so the window rolls forward
* automatically at new year without a code change.
*/
export function getAllowedFiscalYears(now: Date = new Date()): Set<number> {
const currentYear = now.getFullYear()
return new Set([currentYear - 2, currentYear - 1, currentYear])
}
export interface ProviderSieFile {
fiscalYear: number
rawContent: string
}
export interface ProviderSieFetchResult {
files: ProviderSieFile[]
/**
* Every fiscal year available at the provider within the allowed window —
* also populated when latestOnly fetched just one file, so /preview can show
* the full year list without a second round-trip.
*/
availableYears: number[]
/**
* Allowed years whose export failed (or came back empty). Callers MUST
* surface these to the user: silently importing e.g. 2024+2026 without 2025
* breaks IB/UB continuity between the years without anyone noticing.
*/
failedYears: { year: number; error: string }[]
}
// Singleton clients (they hold rate limiters)
const fortnoxClient = new FortnoxClient()
const brioxClient = new BrioxClient()
const bjornLundenClient = new BjornLundenClient()
/** True when the provider's API can serve the GL as SIE (no manual upload). */
export function providerSupportsSie(provider: ProviderName): boolean {
return provider === 'fortnox' || provider === 'briox' || provider === 'bjornlunden'
}
interface FiscalYearRef {
id: string | number
year: number
/** Period bounds — required by BL, whose export URL is date-ranged. */
fromDate?: string
toDate?: string
}
/**
* Fetch SIE type-4 exports from the provider, one file per allowed fiscal
* year (oldest first). Years whose export fails do not block the rest of the
* migration, but they are reported in `failedYears` so the caller can warn
* the user before importing a gap (IB/UB continuity).
*/
export async function fetchProviderSieFiles(
provider: ProviderName,
accessToken: string,
providerCompanyId: string | undefined,
opts?: { latestOnly?: boolean },
): Promise<ProviderSieFetchResult> {
const fetcher = getSieFetcher(provider, providerCompanyId)
if (!fetcher) {
throw new Error(`Provider ${provider} does not support SIE over API`)
}
const allowedFiscalYears = getAllowedFiscalYears()
const allYears = await fetcher.listYears(accessToken)
const allowedYears = allYears
.filter((fy) => allowedFiscalYears.has(fy.year))
.sort((a, b) => a.year - b.year)
const availableYears = allowedYears.map((fy) => fy.year)
const toFetch = opts?.latestOnly ? allowedYears.slice(-1) : allowedYears
const files: ProviderSieFile[] = []
const failedYears: { year: number; error: string }[] = []
for (const fy of toFetch) {
try {
const rawContent = await fetcher.fetchSie(accessToken, fy)
if (rawContent) {
files.push({ fiscalYear: fy.year, rawContent })
} else {
failedYears.push({ year: fy.year, error: 'Provider returned an empty SIE export' })
}
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
log.warn(`Failed to fetch SIE for ${provider} fiscal year ${fy.year} (id ${fy.id})`, {
reason,
})
failedYears.push({ year: fy.year, error: reason })
}
}
return { files, availableYears, failedYears }
}
interface SieFetcher {
listYears(accessToken: string): Promise<FiscalYearRef[]>
fetchSie(accessToken: string, fy: FiscalYearRef): Promise<string>
}
function getSieFetcher(
provider: ProviderName,
providerCompanyId: string | undefined,
): SieFetcher | null {
if (provider === 'fortnox') {
return {
async listYears(accessToken) {
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
accessToken,
'/financialyears',
)
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
return years.map((fy) => ({
id: fy['Id'] as number,
year: new Date(fy['FromDate'] as string).getFullYear(),
}))
},
async fetchSie(accessToken, fy) {
// Fortnox normally serves the SIE body as UTF-8, but endpoint variants
// have been seen answering CP437 (the SIE spec encoding) — a blind
// response.text() would turn å/ä/ö into U+FFFD irrecoverably. Fetch
// raw bytes and detect-decode like the Briox/BL paths.
const buffer = await fortnoxClient.getBytes(accessToken, `/sie/4?financialyear=${fy.id}`)
return decodeBuffer(buffer, detectEncoding(buffer))
},
}
}
if (provider === 'briox') {
return {
async listYears(accessToken) {
const years = await brioxClient.listFinancialYears(accessToken)
return years.map((fy) => ({
id: fy.id,
year: new Date(fy.fromdate).getFullYear(),
}))
},
async fetchSie(accessToken, fy) {
// Briox serves SIE as an octet-stream whose encoding varies
// (CP437/Windows-1252/UTF-8) — fetch bytes and detect-decode.
const buffer = await brioxClient.getBytes(accessToken, `/sie/${fy.id}/4`)
return decodeBuffer(buffer, detectEncoding(buffer))
},
}
}
if (provider === 'bjornlunden') {
// providerCompanyId carries the per-company User-Key header value.
const userKey = providerCompanyId
if (!userKey) {
throw new Error('Björn Lundén requires a company User-Key — reconnect the provider')
}
return {
async listYears(accessToken) {
const years = await bjornLundenClient.listFinancialYears(accessToken, userKey)
return years.map((fy) => ({
id: fy.id ?? fy.entityId,
year: new Date(fy.fromDate).getFullYear(),
fromDate: fy.fromDate,
toDate: fy.toDate,
}))
},
async fetchSie(accessToken, fy) {
// BL's export is date-ranged rather than year-id based. Sandbox-
// verified: the body is RAW SIE bytes (CP437, Content-Type
// text/vnd.sie-gruppen.si) even though the swagger declares a base64
// string — decodeSieBytes handles both shapes.
const buffer = await bjornLundenClient.getBytes(
accessToken,
userKey,
`/sie/export/${fy.fromDate}/${fy.toDate}`,
)
return decodeSieBytes(buffer)
},
}
}
return null
}
/**
* Decode a SIE payload that may arrive either as raw SIE bytes or as a
* base64 string (optionally JSON-quoted). BL's swagger declares base64 but
* the live API sends raw CP437 — handle both so a future API change doesn't
* silently break the import.
*/
function decodeSieBytes(buffer: ArrayBuffer): string {
const direct = decodeBuffer(buffer, detectEncoding(buffer))
if (looksLikeSie(direct)) return direct
const candidate = direct.trim().replace(/^"|"$/g, '')
if (/^[A-Za-z0-9+/=\s]+$/.test(candidate)) {
try {
const bytes = Buffer.from(candidate, 'base64')
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
const decoded = decodeBuffer(ab, detectEncoding(ab))
if (looksLikeSie(decoded)) return decoded
} catch {
// fall through to returning the direct decode
}
}
// Neither shape matched — return the direct decode and let the SIE parser
// produce its own diagnostics instead of failing silently here.
return direct
}
/** SIE files start with a #-record (#FLAGGA per spec; be lenient about order). */
function looksLikeSie(text: string): boolean {
return text.trimStart().startsWith('#')
}
@@ -12,7 +12,6 @@
"VISMA_CLIENT_ID",
"VISMA_CLIENT_SECRET",
"VISMA_REDIRECT_URI",
"BRIOX_CLIENT_ID",
"BJORN_LUNDEN_CLIENT_ID",
"BJORN_LUNDEN_CLIENT_SECRET",
"UPSTASH_REDIS_REST_URL",
+10 -6
View File
@@ -34,12 +34,16 @@ export type { CustomerType as ArcimCustomerType } from '@/lib/providers/dto'
export type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
export const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth' },
{ id: 'visma', name: 'Visma eEkonomi', authType: 'oauth' },
{ id: 'bokio', name: 'Bokio', authType: 'token' },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' },
{ id: 'briox', name: 'Briox', authType: 'token' },
// `sieViaApi`: the provider serves its general ledger as SIE over the API, so
// the wizard imports bookkeeping automatically — no manual SIE upload needed.
// Mirrored in ArcimMigrationWorkspace.tsx (deliberate duplication: core code
// must not import from @/extensions/ — CI enforces it). Keep both in sync.
export const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token'; sieViaApi: boolean }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth', sieViaApi: true },
{ id: 'visma', name: 'Visma eEkonomi', authType: 'oauth', sieViaApi: false },
{ id: 'bokio', name: 'Bokio', authType: 'token', sieViaApi: false },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token', sieViaApi: true },
{ id: 'briox', name: 'Briox', authType: 'token', sieViaApi: true },
]
// ── Migration state ─────────────────────────────────────────────────
@@ -0,0 +1,100 @@
import { describe, it, expect, afterEach } from 'vitest'
import {
BolagsverketClient,
BolagsverketApiError,
BOLAGSVERKET_ERROR_MESSAGES,
configFromEnv,
extractFelkod,
isBolagsverketEnvironment,
} from '../lib/client'
describe('configFromEnv', () => {
const originalEnv = process.env.BOLAGSVERKET_ENV
afterEach(() => {
if (originalEnv === undefined) delete process.env.BOLAGSVERKET_ENV
else process.env.BOLAGSVERKET_ENV = originalEnv
})
it('defaults to the test environment', () => {
delete process.env.BOLAGSVERKET_ENV
const config = configFromEnv()
expect(config.environment).toBe('test')
})
it('rejects an invalid environment override with a clear error', () => {
expect(() => configFromEnv({ environment: 'banana' as never })).toThrow(
/Ogiltig Bolagsverket-miljö/,
)
})
it('rejects a garbage BOLAGSVERKET_ENV env var with a clear error', () => {
process.env.BOLAGSVERKET_ENV = 'production' // common typo for 'prod'
expect(() => configFromEnv()).toThrow(/Ogiltig Bolagsverket-miljö/)
})
it('isBolagsverketEnvironment validates the three known environments', () => {
expect(isBolagsverketEnvironment('test')).toBe(true)
expect(isBolagsverketEnvironment('accept')).toBe(true)
expect(isBolagsverketEnvironment('prod')).toBe(true)
expect(isBolagsverketEnvironment('production')).toBe(false)
expect(isBolagsverketEnvironment(undefined)).toBe(false)
expect(isBolagsverketEnvironment(null)).toBe(false)
})
it('decodes base64-wrapped PEM material', () => {
const pem = '-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----'
const config = configFromEnv({
environment: 'accept',
clientCertPem: pem,
clientKeyPem: pem,
})
expect(config.clientCertPem).toContain('-----BEGIN CERTIFICATE-----')
})
})
describe('BolagsverketClient', () => {
it('refuses accept/prod calls without an organisationscertifikat', async () => {
const client = new BolagsverketClient({ environment: 'accept' })
await expect(client.getGrunduppgifter('5560001111')).rejects.toThrow(
/Organisationscertifikat saknas/,
)
})
it('exposes the configured environment', () => {
expect(new BolagsverketClient({ environment: 'test' }).environment).toBe('test')
expect(new BolagsverketClient({ environment: 'prod' }).environment).toBe('prod')
})
})
describe('extractFelkod (error-body anchoring)', () => {
it('extracts the felkod from JSON bodies', () => {
expect(extractFelkod('{"felkod":"4001","text":"fel"}')).toBe('4001')
expect(extractFelkod('{"kod":7003}')).toBe('7003')
})
it('extracts the felkod from "NNNN=text" bodies', () => {
expect(extractFelkod('4008=Filen innehåller tekniska fel')).toBe('4008')
expect(extractFelkod(' \n 5006 = Dokumentet är för stort')).toBe('5006')
})
it('does not false-positive on years or stray four-digit numbers in prose', () => {
expect(extractFelkod('Räkenskapsåret 2026 kunde inte hanteras just nu')).toBeNull()
expect(extractFelkod('{"message":"taxonomin för 2024 hittades inte (id 5008)"}')).toBeNull()
expect(extractFelkod('Internt fel 9003 uppstod')).toBeNull()
})
})
describe('felkodskarta (GUIDE Appendix A §6.2)', () => {
it('covers the documented API error codes with Swedish messages', () => {
for (const code of ['4001', '4003', '4005', '4008', '4010', '5006', '7003', '7006', '9003']) {
expect(BOLAGSVERKET_ERROR_MESSAGES[code]).toBeTruthy()
}
})
it('BolagsverketApiError carries status + truncated body for logging', () => {
const err = new BolagsverketApiError('Testfel', 400, 'body')
expect(err.name).toBe('BolagsverketApiError')
expect(err.status).toBe(400)
expect(err.body).toBe('body')
})
})
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
vi.mock('../lib/submission-service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/submission-service')>()
return {
...actual,
submitArsredovisning: vi.fn(),
}
})
import { bolagsverketExtension } from '../index'
import { BolagsverketSubmissionError, submitArsredovisning } from '../lib/submission-service'
function route(method: string, path: string) {
const found = bolagsverketExtension.apiRoutes?.find(
(r) => r.method === method && r.path === path,
)
if (!found) throw new Error(`route ${method} ${path} not found`)
return found
}
function makeCtx(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'bolagsverket',
requestId: 'req-1',
supabase,
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn(), clear: vi.fn() },
storage: {},
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
services: {},
} as unknown as ExtensionContext
}
const validBody = {
fiscal_period_id: '123e4567-e89b-12d3-a456-426614174000',
avsandare_pnr: '198001019876',
undertecknare: {
pnr: '198001019876',
fornamn: 'Anna',
efternamn: 'Svensson',
roll: 'VD',
epost: 'anna@example.com',
},
}
function makePost(path: string, body: unknown = validBody): Request {
return new Request(`http://localhost/api/extensions/ext/bolagsverket${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
interface ErrorEnvelope {
error: { code: string; message: string; message_en?: string }
}
const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL
const ORIGINAL_BV_ENV = process.env.BOLAGSVERKET_ENV
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
delete process.env.BOLAGSVERKET_ENV
})
afterEach(() => {
if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
if (ORIGINAL_BV_ENV === undefined) delete process.env.BOLAGSVERKET_ENV
else process.env.BOLAGSVERKET_ENV = ORIGINAL_BV_ENV
})
describe('POST /submissions — write-role enforcement', () => {
it('rejects viewer members with 403 BOLAGSVERKET_FORBIDDEN', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'viewer' }, error: null }) // company_members
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
})
it('rejects non-members (no role row) with 403', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // no membership
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
})
it('rejects viewer members on POST /poll-events too', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'viewer' }, error: null })
const res = await route('POST', '/poll-events').handler(makePost('/poll-events', {}), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
})
})
describe('POST /submissions — environment validation + ceiling', () => {
it('rejects an invalid environment setting with 400 BOLAGSVERKET_INVALID_ENVIRONMENT', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null }) // company_members
enqueue({ data: { value: { environment: 'banana' } }, error: null }) // settings blob
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(400)
expect(body.error.code).toBe('BOLAGSVERKET_INVALID_ENVIRONMENT')
})
it('rejects an environment above the BOLAGSVERKET_ENV ceiling (unset → test)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
enqueue({ data: { value: { environment: 'prod' } }, error: null })
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_ENV_NOT_ALLOWED')
})
it('allows an environment at or below the ceiling', async () => {
process.env.BOLAGSVERKET_ENV = 'accept'
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
enqueue({ data: { value: { environment: 'test' } }, error: null })
vi.mocked(submitArsredovisning).mockResolvedValue({
outcome: 'uploaded',
submissionId: 'sub-1',
idnummer: '49679',
sha256: 'sha',
url: 'https://ext.bolagsverket.se/eu/49679',
utfall: [],
})
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: { outcome: string } }>(res as Response)
expect(status).toBe(200)
expect(body.data.outcome).toBe('uploaded')
// The service got a client pinned to the validated environment.
const deps = vi.mocked(submitArsredovisning).mock.calls[0][0]
expect(deps.client.environment).toBe('test')
expect(deps.appUrl).toBe('http://localhost:3000')
})
})
describe('POST /submissions — config + error mapping', () => {
it('fails fast with 503 BOLAGSVERKET_CONFIG_MISSING when NEXT_PUBLIC_APP_URL is unset', async () => {
delete process.env.NEXT_PUBLIC_APP_URL
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'owner' }, error: null })
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(503)
expect(body.error.code).toBe('BOLAGSVERKET_CONFIG_MISSING')
})
it('maps BolagsverketSubmissionError to its structured code (409 double submission)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
enqueue({ data: { value: { environment: 'test' } }, error: null })
vi.mocked(submitArsredovisning).mockRejectedValue(
new BolagsverketSubmissionError('BOLAGSVERKET_SUBMISSION_EXISTS', 'already active', {
submission_id: 'sub-0',
}),
)
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(409)
expect(body.error.code).toBe('BOLAGSVERKET_SUBMISSION_EXISTS')
})
it('returns 400 VALIDATION_ERROR for a malformed body', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
const res = await route('POST', '/submissions').handler(
makePost('/submissions', { fiscal_period_id: 'not-a-uuid' }),
makeCtx(supabase),
)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
describe('GET /status', () => {
it('reports resolved environment, ceiling, and env-derived certificate presence', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { value: { environment: 'test' } }, error: null })
const res = await route('GET', '/status').handler(
new Request('http://localhost/api/extensions/ext/bolagsverket/status'),
makeCtx(supabase),
)
const { status, body } = await parseJsonResponse<{
data: { environment: string; environment_ceiling: string; has_certificate: boolean }
}>(res as Response)
expect(status).toBe(200)
expect(body.data.environment).toBe('test')
expect(body.data.environment_ceiling).toBe('test')
// No BOLAGSVERKET_CLIENT_CERT/_KEY in the test env.
expect(body.data.has_certificate).toBe(false)
})
})
@@ -0,0 +1,415 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase } from '@/tests/helpers'
vi.mock('@/lib/bokslut/ixbrl/build-input', () => ({
buildIxbrlInput: vi.fn(async () => ({
entryPointId: 'k2-ab-risbs-2024-09-12',
period: { start: '2025-01-01', end: '2025-12-31' },
})),
}))
vi.mock('@/lib/bokslut/ixbrl/document/k2-document', () => ({
generateK2IxbrlDocument: vi.fn(() => ({ xhtml: '<?xml version="1.0"?><html></html>' })),
embedKontrollsumma: vi.fn((xhtml: string) => xhtml),
}))
vi.mock('@/lib/bokslut/ixbrl/validate/rules', () => ({
runPreflightChecks: vi.fn(() => ({ ok: true, issues: [] })),
}))
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: vi.fn(async () => ({ id: 'doc-1' })),
}))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => {
throw new Error('service client not expected in these tests')
}),
}))
import { uploadDocument } from '@/lib/core/documents/document-service'
import {
applyHandelse,
BolagsverketSubmissionError,
handleWebhook,
hashPnr,
normalizeOrgnr,
submitArsredovisning,
} from '../lib/submission-service'
import type { HandelseMeddelande } from '../types'
function makeLog() {
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}
/**
* Recording mock: chainable builder consuming queued results per terminal
* (single/maybeSingle/await), capturing update payloads per table so tests
* can assert what was written.
*/
function makeRecordingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
let idx = 0
const updates: Array<{ table: string; payload: Record<string, unknown> }> = []
const next = () => results[idx++] ?? { data: null, error: null }
const makeBuilder = (table: string) => {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order', 'limit', 'insert']) {
b[m] = () => b
}
b.update = (payload: Record<string, unknown>) => {
updates.push({ table, payload })
return b
}
b.single = async () => next()
b.maybeSingle = async () => next()
b.then = (resolve: (v: unknown) => void) => resolve(next())
return b
}
return { supabase: { from: (table: string) => makeBuilder(table) } as never, updates }
}
function makeClientMock(overrides: Record<string, unknown> = {}) {
return {
environment: 'test',
createInlamningToken: vi.fn(async () => ({
token: 'tok-1',
avtalstext: 'Avtalstext',
avtalstextAndrad: '2017-12-06',
})),
createChecksumToken: vi.fn(async () => ({ token: 'tok-2', avtalstext: '', avtalstextAndrad: '' })),
createChecksum: vi.fn(async () => ({ kontrollsumma: 'ksum', algoritm: 'SHA-256' })),
kontrollera: vi.fn(async () => ({ orgnr: '5560001111', utfall: [] })),
lamnaIn: vi.fn(async () => ({
orgnr: '5560001111',
avsandare: 'avs',
undertecknare: 'und',
handlingsinfo: {
typ: 'arsredovisning_komplett',
dokumentlangd: 1,
idnummer: '49679',
sha256checksumma: 'sha256',
},
url: 'https://ext.bolagsverket.se/eu/49679',
})),
...overrides,
} as never
}
const submitParams = {
companyId: 'company-1',
userId: 'user-1',
fiscalPeriodId: 'period-1',
avsandarePnr: '198001019876',
undertecknare: {
pnr: '198001019876',
fornamn: 'Anna',
efternamn: 'Svensson',
roll: 'VD',
epost: 'anna@example.com',
},
}
function message(overrides: Partial<HandelseMeddelande> = {}): HandelseMeddelande {
return {
typ: 'AR-v2',
id: '5560001111',
nr: 3,
tid: '2026-06-01T10:00:00.000+02:00',
data: {
version: '2.0',
handlingsinfo: [{ handling: 'arsredovisning', idnummer: '49679' }],
status: 'arsred_inkommen',
},
...overrides,
}
}
describe('normalizeOrgnr / hashPnr', () => {
it('normalizes 12-digit and dashed org numbers to the 10-digit API form', () => {
expect(normalizeOrgnr('556000-1111')).toBe('5560001111')
expect(normalizeOrgnr('165560001111')).toBe('5560001111')
expect(normalizeOrgnr('5560001111')).toBe('5560001111')
})
it('hashes personnummer with company salt — never the raw value', () => {
const hash = hashPnr('company-1', '19830101-9876')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
expect(hash).not.toContain('9876')
expect(hashPnr('company-2', '198301019876')).not.toBe(hash)
// Same pnr + company → stable.
expect(hashPnr('company-1', '198301019876')).toBe(hash)
})
})
describe('handleWebhook', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('rejects messages without a matching auth header (401)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ company_id: 'company-1', auth_secret: 'right-secret' }], error: null })
const result = await handleWebhook(supabase as never, message(), 'wrong-secret')
expect(result.status).toBe(401)
})
it('rejects messages for unknown orgnr (401)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [], error: null })
const result = await handleWebhook(supabase as never, message(), 'any')
expect(result.status).toBe(401)
})
it('acks the subscription test message without touching submissions', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ company_id: 'company-1', auth_secret: 's3cret' }], error: null })
const result = await handleWebhook(
supabase as never,
message({ nr: -1, data: { version: '2.0', status: 'test' } }),
's3cret',
)
expect(result.status).toBe(200)
expect(result.body.ok).toBe(true)
})
it('applies a real status event to the matching submission and emits events', async () => {
const emitted: string[] = []
eventBus.on('arsredovisning.status_changed', (payload) => {
emitted.push(`changed:${payload.status}`)
})
eventBus.on('arsredovisning.registered', () => {
emitted.push('registered')
})
const { supabase, enqueue } = createQueuedMockSupabase()
// 1) subscription lookup
enqueue({ data: [{ company_id: 'company-1', auth_secret: 's3cret' }], error: null })
// 2) submission lookup by idnummer
enqueue({
data: [
{
id: 'sub-1',
status: 'uploaded',
fiscal_period_id: 'period-1',
user_id: 'user-1',
company_id: 'company-1',
},
],
error: null,
})
// 3) update
enqueue({ data: null, error: null })
const result = await handleWebhook(
supabase as never,
message({ data: { version: '2.0', handlingsinfo: [{ handling: 'arsredovisning', idnummer: '49679' }], status: 'arsred_registrerad' } }),
's3cret',
)
expect(result.status).toBe(200)
expect(emitted).toContain('changed:registrerad')
expect(emitted).toContain('registered')
})
it('rejects malformed payloads (400)', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await handleWebhook(
supabase as never,
{} as never,
's3cret',
)
expect(result.status).toBe(400)
})
})
describe('applyHandelse', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('emits forelagd event on föreläggande and skips unknown statuses', async () => {
const emitted: string[] = []
eventBus.on('arsredovisning.forelagd', () => {
emitted.push('forelagd')
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{
id: 'sub-1',
status: 'inkommen',
fiscal_period_id: 'period-1',
user_id: 'user-1',
company_id: 'company-1',
},
],
error: null,
})
enqueue({ data: null, error: null })
await applyHandelse(
supabase as never,
message({ data: { version: '2.0', handlingsinfo: [{ handling: 'arsredovisning', idnummer: '49679' }], status: 'arsred_forelaggande_skickat' } }),
['company-1'],
)
expect(emitted).toEqual(['forelagd'])
// Unknown status: nothing should be queried or emitted.
await applyHandelse(
supabase as never,
message({ data: { version: '2.0', status: 'test' } }),
['company-1'],
)
expect(emitted).toEqual(['forelagd'])
})
it('does not emit when the stored status already matches', async () => {
const emitted: string[] = []
eventBus.on('arsredovisning.status_changed', () => {
emitted.push('changed')
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{
id: 'sub-1',
status: 'inkommen',
fiscal_period_id: 'period-1',
user_id: 'user-1',
company_id: 'company-1',
},
],
error: null,
})
await applyHandelse(supabase as never, message(), ['company-1'])
expect(emitted).toEqual([])
})
it('logs rejected transitions instead of silently continuing', async () => {
const emitted: string[] = []
eventBus.on('arsredovisning.status_changed', () => {
emitted.push('changed')
})
const log = makeLog()
const { supabase, enqueue } = createQueuedMockSupabase()
// Stored status differs from the incoming one, but the DB trigger
// rejects the transition.
enqueue({
data: [
{
id: 'sub-1',
status: 'registrerad',
fiscal_period_id: 'period-1',
user_id: 'user-1',
company_id: 'company-1',
},
],
error: null,
})
enqueue({ data: null, error: { message: 'Ogiltig statusövergång: registrerad → inkommen' } })
await applyHandelse(supabase as never, message(), ['company-1'], log)
expect(log.warn).toHaveBeenCalledTimes(1)
expect(log.warn.mock.calls[0][0]).toMatch(/rejected/)
expect(emitted).toEqual([])
})
})
describe('submitArsredovisning', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
vi.mocked(uploadDocument).mockResolvedValue({ id: 'doc-1' } as never)
})
it('refuses when an active submission already exists for the fiscal period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { org_number: '556000-1111' }, error: null }) // company_settings
enqueue({ data: [{ id: 'sub-0', status: 'uploaded' }], error: null }) // active submissions
await expect(
submitArsredovisning(
{ supabase: supabase as never, client: makeClientMock(), appUrl: 'https://app.test', log: makeLog() },
submitParams,
),
).rejects.toMatchObject({
name: 'BolagsverketSubmissionError',
code: 'BOLAGSVERKET_SUBMISSION_EXISTS',
})
})
it('marks the submission row as error and rethrows when inlämning fails', async () => {
const { supabase, updates } = makeRecordingSupabase([
{ data: { org_number: '5560001111' } }, // getOrgnr
{ data: [] }, // no active submission
{ data: { id: 'acc-1' } }, // avtal acceptance exists
{ data: { id: 'sub-1' } }, // insert submission row
{}, // update → kontrollerad
{}, // markSubmissionError update
])
const client = makeClientMock({
lamnaIn: vi.fn(async () => {
throw new Error('inlamning exploded')
}),
})
const log = makeLog()
await expect(
submitArsredovisning({ supabase, client, appUrl: 'https://app.test', log }, submitParams),
).rejects.toThrow('inlamning exploded')
const errorUpdate = updates.find((u) => u.payload.status === 'error')
expect(errorUpdate).toBeDefined()
expect(errorUpdate!.table).toBe('arsredovisning_submissions')
expect(errorUpdate!.payload.error_message).toContain('inlamning exploded')
})
it('logs and persists a document-archive failure without blocking the filing', async () => {
vi.mocked(uploadDocument).mockRejectedValueOnce(new Error('magic bytes rejected'))
const { supabase, updates } = makeRecordingSupabase([
{ data: { org_number: '5560001111' } }, // getOrgnr
{ data: [] }, // no active submission
{ data: { id: 'acc-1' } }, // avtal acceptance exists
{ data: { id: 'sub-1' } }, // insert submission row
{}, // update → kontrollerad
{}, // error_message update (doc failure)
{}, // update → uploaded
// ensureSubscription throws on the empty appUrl before any query.
])
const log = makeLog()
const result = await submitArsredovisning(
{ supabase, client: makeClientMock(), appUrl: '', log },
submitParams,
)
expect(result.outcome).toBe('uploaded')
// The failure is logged AND visible on the row.
expect(log.error).toHaveBeenCalledTimes(1)
expect(log.error.mock.calls[0][0]).toMatch(/archive/)
const docFailureUpdate = updates.find(
(u) => typeof u.payload.error_message === 'string' && !u.payload.status,
)
expect(docFailureUpdate).toBeDefined()
expect(docFailureUpdate!.payload.error_message).toContain('magic bytes rejected')
// The filing itself still went through with dokument_id null.
const uploadedUpdate = updates.find((u) => u.payload.status === 'uploaded')
expect(uploadedUpdate).toBeDefined()
expect(uploadedUpdate!.payload.dokument_id).toBeNull()
// Subscription failure (invalid appUrl) is logged, not swallowed.
expect(log.warn.mock.calls.some(([msg]) => /prenumeration/.test(String(msg)))).toBe(true)
})
it('exports BolagsverketSubmissionError with a stable code', () => {
const err = new BolagsverketSubmissionError('BOLAGSVERKET_SUBMISSION_EXISTS', 'exists', {
submission_id: 'sub-1',
})
expect(err.code).toBe('BOLAGSVERKET_SUBMISSION_EXISTS')
expect(err.details).toEqual({ submission_id: 'sub-1' })
})
})
+415
View File
@@ -0,0 +1,415 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
import {
BolagsverketClient,
BolagsverketApiError,
configFromEnv,
isBolagsverketEnvironment,
} from './lib/client'
import {
applyHandelse,
BolagsverketSubmissionError,
handleWebhook,
normalizeOrgnr,
submitArsredovisning,
} from './lib/submission-service'
import type { BolagsverketEnvironment, HandelseMeddelande } from './types'
/**
* Bolagsverket integration — digital inlämning av årsredovisning.
*
* Generates iXBRL in core (lib/bokslut/ixbrl — works without this extension),
* and adds the Bolagsverket leg: grunduppgifter prefill, kontrollera,
* inlämning till eget utrymme, händelseprenumerationer + webhook receiver.
*
* Requires an avtal with Bolagsverket and an Expisoft/Steria
* organisationscertifikat for acceptans/produktion (ANSLUTNINGSANVISNING
* §5–6). The static test environment (BOLAGSVERKET_ENV=test) runs without a
* certificate but needs a firewall opening (orgnr 1234567890/1234567891).
*
* Environment variables (certificate material is ENV-ONLY — see clientFor):
* - BOLAGSVERKET_ENV test | accept | prod (default test). Also acts
* as the CEILING for the per-company `environment`
* setting: members may select an environment at or
* below it (test < accept < prod). Unset → ceiling
* is 'test', so settings alone can never reach the
* platform certificate's prod access.
* - BOLAGSVERKET_CLIENT_CERT PEM (or base64-PEM) organisationscertifikat
* - BOLAGSVERKET_CLIENT_KEY PEM (or base64-PEM) private key
* - BOLAGSVERKET_CA optional extra CA chain
*
* Self-hosted installs without a certificate can skip this extension entirely
* and file manually with the downloaded .xhtml.
*/
/** Roles allowed to file/poll — the dispatcher itself only authenticates. */
const WRITE_ROLES = new Set(['owner', 'admin', 'member'])
const ENV_ORDER: Record<BolagsverketEnvironment, number> = { test: 0, accept: 1, prod: 2 }
/** Logger for the unauthenticated webhook path (no ExtensionContext there). */
const webhookLog = createLogger('ext:bolagsverket')
/**
* Platform ceiling for the per-company environment setting. Operator-set
* BOLAGSVERKET_ENV caps what tenants may select; unset/invalid → 'test'.
*/
function environmentCeiling(): BolagsverketEnvironment {
const raw = process.env.BOLAGSVERKET_ENV
return isBolagsverketEnvironment(raw) ? raw : 'test'
}
/**
* Resolve the effective Bolagsverket environment for a company.
*
* The generic extension settings endpoint
* (app/api/extensions/[sector]/[slug]/settings) PATCHes ONE JSON blob into
* extension_data under extension_id 'general/bolagsverket', key 'settings' —
* not per-key rows under this extension's dispatcher id — so read that row
* directly rather than via ctx.settings.
*
* Validation: the value must be one of test|accept|prod and must not exceed
* the BOLAGSVERKET_ENV ceiling (a member with settings access must not be
* able to point a hosted tenant at prod and ride the platform certificate).
*/
async function resolveEnvironment(ctx: ExtensionContext): Promise<BolagsverketEnvironment> {
const { data } = await ctx.supabase
.from('extension_data')
.select('value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'general/bolagsverket')
.eq('key', 'settings')
.maybeSingle()
const configured = (data?.value as { environment?: unknown } | null)?.environment
const ceiling = environmentCeiling()
if (configured === undefined || configured === null || configured === '') {
return ceiling
}
if (!isBolagsverketEnvironment(configured)) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_INVALID_ENVIRONMENT',
`Invalid environment setting '${String(configured)}'.`,
{ configured: String(configured), allowed: ['test', 'accept', 'prod'] },
)
}
if (ENV_ORDER[configured] > ENV_ORDER[ceiling]) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_ENV_NOT_ALLOWED',
`Environment setting '${configured}' exceeds the BOLAGSVERKET_ENV ceiling '${ceiling}'.`,
{ configured, ceiling },
)
}
return configured
}
/**
* Build a client for the company's resolved environment.
*
* SECURITY: certificate material is ENV-ONLY (BOLAGSVERKET_CLIENT_CERT/_KEY/
* _CA). It must NEVER be read from extension settings — extension_data rows
* are readable by every company member through the extension_data SELECT RLS
* policy, which would hand the mTLS private key to any viewer.
*/
async function clientFor(ctx: ExtensionContext): Promise<BolagsverketClient> {
const environment = await resolveEnvironment(ctx)
return new BolagsverketClient(configFromEnv({ environment }))
}
async function companyOrgnr(ctx: ExtensionContext): Promise<string> {
const { data } = await ctx.supabase
.from('company_settings')
.select('org_number')
.eq('company_id', ctx.companyId)
.maybeSingle()
const orgNumber = (data as { org_number?: string } | null)?.org_number
if (!orgNumber) throw new Error('Organisationsnummer saknas i företagsinställningarna.')
return normalizeOrgnr(orgNumber)
}
/**
* Defense-in-depth RBAC for write endpoints. The extension dispatcher only
* authenticates and resolves a company; it does NOT check the member's role.
* Filing an årsredovisning is a write operation — viewer members are blocked.
* Mirrors requireAgiWriteRole in the skatteverket extension.
*
* Returns null on success, a 403/500 NextResponse on failure.
*/
async function requireWriteRole(ctx: ExtensionContext): Promise<NextResponse | null> {
const { data, error } = await ctx.supabase
.from('company_members')
.select('role')
.eq('company_id', ctx.companyId)
.eq('user_id', ctx.userId)
.maybeSingle()
if (error) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: `company_members role lookup failed: ${error.message}`,
})
}
if (!data?.role || !WRITE_ROLES.has(data.role as string)) {
return errorResponseFromCode('BOLAGSVERKET_FORBIDDEN', ctx.log, {
requestId: ctx.requestId,
})
}
return null
}
function apiErrorResponse(err: unknown, ctx: ExtensionContext): NextResponse {
if (err instanceof BolagsverketSubmissionError) {
return errorResponseFromCode(err.code, ctx.log, {
requestId: ctx.requestId,
reason: err.message,
details: err.details,
})
}
if (err instanceof BolagsverketApiError) {
return errorResponseFromCode('BOLAGSVERKET_API_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: err.message,
status: err.status >= 400 && err.status < 600 ? err.status : 502,
details: { upstream_message: err.message, upstream_status: err.status },
})
}
const message = err instanceof Error ? err.message : 'Okänt fel'
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: message,
details: { message },
})
}
const noContextResponse = () =>
NextResponse.json({ error: { code: 'NO_CONTEXT', message: 'Saknar kontext' } }, { status: 500 })
const SubmitSchema = z.object({
fiscal_period_id: z.string().uuid(),
avsandare_pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 10–12 siffror'),
undertecknare: z.object({
pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 10–12 siffror'),
fornamn: z.string().min(1).max(100),
efternamn: z.string().min(1).max(100),
roll: z.string().min(1).max(100),
epost: z.string().email(),
}),
kvittens_epost: z.array(z.string().email()).max(5).optional(),
utdelning: z.number().min(0).optional(),
accepted_avtalstext_andrad: z.string().optional(),
ignore_warnings: z.boolean().optional(),
})
const PollSchema = z.object({
fromtidpunkt: z.string().optional(),
})
export const bolagsverketExtension: Extension = {
id: 'bolagsverket',
name: 'Bolagsverket — digital årsredovisning',
version: '1.0.0',
settingsPanel: { label: 'Bolagsverket', path: '/settings/extensions' },
apiRoutes: [
{
method: 'GET',
path: '/status',
handler: async (_request, ctx) => {
if (!ctx) return noContextResponse()
try {
const environment = await resolveEnvironment(ctx)
const config = configFromEnv()
return NextResponse.json({
data: {
environment,
environment_ceiling: environmentCeiling(),
// Certificate material is env-only; settings can never carry it.
has_certificate: Boolean(config.clientCertPem && config.clientKeyPem),
},
})
} catch (err) {
return apiErrorResponse(err, ctx)
}
},
},
{
method: 'GET',
path: '/grunduppgifter',
handler: async (_request, ctx) => {
if (!ctx) return noContextResponse()
try {
const client = await clientFor(ctx)
const orgnr = await companyOrgnr(ctx)
const data = await client.getGrunduppgifter(orgnr)
return NextResponse.json({ data })
} catch (err) {
return apiErrorResponse(err, ctx)
}
},
},
{
method: 'GET',
path: '/arendestatus',
handler: async (_request, ctx) => {
if (!ctx) return noContextResponse()
try {
const client = await clientFor(ctx)
const orgnr = await companyOrgnr(ctx)
const data = await client.getArendestatus(orgnr)
return NextResponse.json({ data })
} catch (err) {
return apiErrorResponse(err, ctx)
}
},
},
{
method: 'GET',
path: '/submissions',
handler: async (request, ctx) => {
if (!ctx) return noContextResponse()
const url = new URL(request.url)
const fiscalPeriodId = url.searchParams.get('fiscal_period_id')
let query = ctx.supabase
.from('arsredovisning_submissions')
.select(
'id, fiscal_period_id, handling_typ, taxonomy_version, entry_point, environment, status, undertecknare_namn, undertecknare_epost, idnummer, sha256_checksumma, kontrollsumma, bolagsverket_url, kontrollera_utfall, error_message, uploaded_at, registered_at, created_at, updated_at',
)
.eq('company_id', ctx.companyId)
.order('created_at', { ascending: false })
.limit(50)
if (fiscalPeriodId) query = query.eq('fiscal_period_id', fiscalPeriodId)
const { data, error } = await query
if (error) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: `submissions list failed: ${error.message}`,
})
}
return NextResponse.json({ data })
},
},
{
method: 'POST',
path: '/submissions',
handler: async (request, ctx) => {
if (!ctx) return noContextResponse()
const forbidden = await requireWriteRole(ctx)
if (forbidden) return forbidden
let parsed: z.infer<typeof SubmitSchema>
try {
parsed = SubmitSchema.parse(await request.json())
} catch (err) {
const message =
err instanceof z.ZodError ? err.issues.map((issue) => issue.message).join('; ') : 'Ogiltig begäran'
return errorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: message,
details: { message },
})
}
// The webhook subscription registers this URL with Bolagsverket — a
// missing/relative base would register a broken endpoint externally.
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''
if (!/^https?:\/\//.test(appUrl)) {
return errorResponseFromCode('BOLAGSVERKET_CONFIG_MISSING', ctx.log, {
requestId: ctx.requestId,
reason: 'NEXT_PUBLIC_APP_URL is missing or not an absolute URL',
details: { missing: 'NEXT_PUBLIC_APP_URL' },
})
}
try {
const client = await clientFor(ctx)
const result = await submitArsredovisning(
{ supabase: ctx.supabase, client, appUrl, log: ctx.log },
{
companyId: ctx.companyId,
userId: ctx.userId,
fiscalPeriodId: parsed.fiscal_period_id,
avsandarePnr: parsed.avsandare_pnr,
undertecknare: parsed.undertecknare,
kvittensEpost: parsed.kvittens_epost,
proposedDividend: parsed.utdelning,
acceptedAvtalstextAndrad: parsed.accepted_avtalstext_andrad,
ignoreWarnings: parsed.ignore_warnings,
},
)
return NextResponse.json({ data: result })
} catch (err) {
ctx.log.error('bolagsverket submission failed', err)
return apiErrorResponse(err, ctx)
}
},
},
{
// Webhook receiver for händelsemeddelanden (GUIDE §5.4.5 + Appendix D).
// skipAuth: Bolagsverket authenticates with the `auth` header we set at
// subscription time; validated against bolagsverket_subscriptions.
method: 'POST',
path: '/webhook',
skipAuth: true,
handler: async (request) => {
let message: HandelseMeddelande
try {
message = (await request.json()) as HandelseMeddelande
} catch {
return NextResponse.json({ ok: false, reason: 'invalid json' }, { status: 400 })
}
const serviceClient = createServiceClientNoCookies()
const result = await handleWebhook(
serviceClient,
message,
request.headers.get('auth'),
webhookLog,
)
return NextResponse.json(result.body, { status: result.status })
},
},
{
// Polling fallback: fetch händelser kept by Bolagsverket (~1 year) in
// case webhook deliveries were missed (GUIDE §5.4.4).
method: 'POST',
path: '/poll-events',
handler: async (request, ctx) => {
if (!ctx) return noContextResponse()
const forbidden = await requireWriteRole(ctx)
if (forbidden) return forbidden
let parsed: z.infer<typeof PollSchema>
try {
parsed = PollSchema.parse(await request.json().catch(() => ({})))
} catch {
parsed = {}
}
try {
const client = await clientFor(ctx)
const orgnr = await companyOrgnr(ctx)
const { data: sub } = await ctx.supabase
.from('bolagsverket_subscriptions')
.select('url')
.eq('company_id', ctx.companyId)
.eq('orgnr', orgnr)
.eq('environment', client.environment)
.maybeSingle()
if (!sub) {
return errorResponseFromCode('BOLAGSVERKET_NO_SUBSCRIPTION', ctx.log, {
requestId: ctx.requestId,
})
}
const svar = await client.hamtaHandelser({
url: (sub as { url: string }).url,
orgnr: [orgnr],
...(parsed.fromtidpunkt ? { fromtidpunkt: parsed.fromtidpunkt } : {}),
})
for (const message of svar.meddelanden) {
await applyHandelse(ctx.supabase, message, [ctx.companyId], ctx.log)
}
return NextResponse.json({ data: { applied: svar.meddelanden.length } })
} catch (err) {
return apiErrorResponse(err, ctx)
}
},
},
],
}
export default bolagsverketExtension
@@ -0,0 +1,415 @@
/**
* Typed HTTP client for Bolagsverket's digital-inlämning REST services.
*
* Environments + endpoints per Anslutningsanvisning v1.7 §3:
* - test → https://api-accept2.bolagsverket.se/testapi/… (no client cert;
* static data, orgnr 1234567890/1234567891; requires firewall
* opening ordered via api@bolagsverket.se)
* - accept → https://api-accept2.bolagsverket.se/… (mTLS)
* - prod → https://api.bolagsverket.se/… (mTLS)
*
* mTLS uses an Expisoft/Steria organisationscertifikat whose SERIALNUMBER is
* `16` + the supplier's 10-digit orgnr (ANSLUTNINGSANVISNING §5.3). Cert/key
* come from env vars ONLY (BOLAGSVERKET_CLIENT_CERT/_KEY/_CA) — never from
* extension settings; extension_data is readable by every company member, so
* private-key material must not be stored there. Implemented with node:https
* (undici fetch has no portable client-cert support inside Next.js route
* handlers).
*/
import { request as httpsRequest, type RequestOptions } from 'node:https'
import { URL } from 'node:url'
import type { ZodType } from 'zod'
import {
InlamningSvarSchema,
InlamningTokenSvarSchema,
KontrolleraSvarSchema,
KontrollsummaSvarSchema,
} from './schemas'
import type {
ArendestatusSvar,
BolagsverketEnvironment,
GrunduppgifterSvar,
HamtaHandelserSvar,
HandlingTyp,
InlamningSvar,
InlamningTokenSvar,
KontrolleraSvar,
KontrollsummaSvar,
} from '../types'
export const BOLAGSVERKET_ENVIRONMENTS = ['test', 'accept', 'prod'] as const
export function isBolagsverketEnvironment(value: unknown): value is BolagsverketEnvironment {
return (
typeof value === 'string' &&
(BOLAGSVERKET_ENVIRONMENTS as readonly string[]).includes(value)
)
}
const HOSTS: Record<BolagsverketEnvironment, { base: string; prefix: string }> = {
test: { base: 'https://api-accept2.bolagsverket.se', prefix: '/testapi' },
accept: { base: 'https://api-accept2.bolagsverket.se', prefix: '' },
prod: { base: 'https://api.bolagsverket.se', prefix: '' },
}
/** API felkoder (GUIDE Appendix A §6.2) → user-facing Swedish messages. */
export const BOLAGSVERKET_ERROR_MESSAGES: Record<string, string> = {
'4001': 'Dokumentet är inte en giltig iXBRL-fil.',
'4002': 'Programvaruversionen stöds inte längre av Bolagsverkets tjänst.',
'4003': 'Ogiltigt organisationsnummer.',
'4004': 'Organisationsnumret avser inte ett aktiebolag.',
'4005': 'Ingen träff på organisationsnumret hos Bolagsverket.',
'4007': 'Ogiltigt personnummer.',
'4008': 'Filen innehåller ett eller flera tekniska fel.',
'4010': 'Årsredovisningen är upprättad i en taxonomiversion som Bolagsverket inte längre stödjer.',
'4011': 'Tjänsten stödjer inte den här företagsformen.',
'5001': 'Dokumentet saknar eller har tom title-tagg.',
'5002': 'Dokumentet är inte en iXBRL-fil.',
'5006': 'Dokumentet överstiger tillåten maxstorlek (5 MB).',
'5008': 'Dokumentet är inte kodat i UTF-8.',
'5009': 'Dokumentet saknar taggning av programvara och/eller programversion.',
'7003': 'Felaktig token — skapa en ny inlämningstoken och försök igen.',
'7004': 'Dokumentet innehåller skadlig kod.',
'7006': 'Årsredovisningen kan inte skickas in eftersom företaget är avvecklat.',
'7007': 'Tjänsten stödjer inte digital inlämning för den här företagsformen.',
'9003': 'Icke godkänd användare av tjänsten — kontrollera certifikat och avtal med Bolagsverket.',
}
export class BolagsverketApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly body: string,
) {
super(message)
this.name = 'BolagsverketApiError'
}
}
export interface BolagsverketClientConfig {
environment: BolagsverketEnvironment
/** PEM strings — required for accept/prod, ignored for test. */
clientCertPem?: string | null
clientKeyPem?: string | null
/** Extra CA chain (TeliaSonera root is normally in the system store). */
caPem?: string | null
/** Socket inactivity timeout. */
timeoutMs?: number
/** Overall per-request deadline (covers slow trickling bodies too). */
deadlineMs?: number
}
/**
* Resolve config from env vars; the extension may override the environment
* per install (validated + capped against BOLAGSVERKET_ENV in index.ts).
* Certificate material is ENV-ONLY — never read from extension settings.
*/
export function configFromEnv(
overrides: Partial<BolagsverketClientConfig> = {},
): BolagsverketClientConfig {
const rawEnvironment = overrides.environment ?? process.env.BOLAGSVERKET_ENV ?? 'test'
if (!isBolagsverketEnvironment(rawEnvironment)) {
// Fail fast with a clear message instead of HOSTS[env] → opaque TypeError.
throw new BolagsverketApiError(
`Ogiltig Bolagsverket-miljö '${String(rawEnvironment)}' — tillåtna värden: ${BOLAGSVERKET_ENVIRONMENTS.join(', ')}.`,
0,
'',
)
}
const environment = rawEnvironment
const decode = (value: string | undefined | null): string | null => {
if (!value) return null
// Allow base64-wrapped PEM in env vars (newline-hostile platforms).
return value.includes('-----BEGIN')
? value
: Buffer.from(value, 'base64').toString('utf8')
}
return {
environment,
clientCertPem: overrides.clientCertPem ?? decode(process.env.BOLAGSVERKET_CLIENT_CERT),
clientKeyPem: overrides.clientKeyPem ?? decode(process.env.BOLAGSVERKET_CLIENT_KEY),
caPem: overrides.caPem ?? decode(process.env.BOLAGSVERKET_CA),
timeoutMs: overrides.timeoutMs ?? 30_000,
deadlineMs: overrides.deadlineMs ?? 90_000,
}
}
interface HttpResponse {
status: number
body: string
}
function rawRequest(
config: BolagsverketClientConfig,
method: 'GET' | 'POST' | 'DELETE',
url: string,
jsonBody?: unknown,
): Promise<HttpResponse> {
return new Promise((resolvePromise, rejectPromise) => {
const parsed = new URL(url)
const payload = jsonBody === undefined ? null : JSON.stringify(jsonBody)
const options: RequestOptions = {
method,
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
headers: {
Accept: 'application/json',
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
},
timeout: config.timeoutMs ?? 30_000,
}
// mTLS for accept/prod (the test env is plain TLS behind a firewall).
if (config.environment !== 'test') {
if (!config.clientCertPem || !config.clientKeyPem) {
rejectPromise(
new BolagsverketApiError(
'Organisationscertifikat saknas — miljövariablerna BOLAGSVERKET_CLIENT_CERT/BOLAGSVERKET_CLIENT_KEY krävs för acceptans- och produktionsmiljön.',
0,
'',
),
)
return
}
options.cert = config.clientCertPem
options.key = config.clientKeyPem
if (config.caPem) options.ca = config.caPem
}
let settled = false
const settle = (fn: () => void) => {
if (settled) return
settled = true
clearTimeout(deadline)
fn()
}
// Overall deadline: the socket-inactivity `timeout` above never fires for
// a server that trickles bytes forever — destroy the request outright.
const deadline = setTimeout(() => {
req.destroy(new Error('tidsgränsen för hela anropet överskreds'))
}, config.deadlineMs ?? 90_000)
const req = httpsRequest(options, (res) => {
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
settle(() =>
resolvePromise({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
)
})
// Mid-body connection failure: without this the promise never settles
// (the request-level 'error' handler does not fire once headers landed).
res.on('error', (err: Error) => {
settle(() =>
rejectPromise(
new BolagsverketApiError(
`Anslutningen till Bolagsverket bröts mitt i svaret (${err.message}). Försök igen.`,
0,
'',
),
),
)
})
})
req.on('timeout', () => {
req.destroy(new Error('timeout'))
})
req.on('error', (err) => {
settle(() =>
rejectPromise(
new BolagsverketApiError(
`Kunde inte nå Bolagsverket (${err.message}). Kontrollera brandväggsöppning och certifikat.`,
0,
'',
),
),
)
})
if (payload) req.write(payload)
req.end()
})
}
/**
* Pull a felkod out of an error body. Error bodies carry "NNNN=text" lines or
* {"felkod":"NNNN", ...}-style JSON (GUIDE Appendix A §6.2). Anchored to those
* two shapes — a bare \bNNNN\b match would false-positive on years and other
* four-digit numbers inside prose.
*/
export function extractFelkod(body: string): string | null {
try {
const parsed = JSON.parse(body) as Record<string, unknown>
if (parsed && typeof parsed === 'object') {
for (const field of ['felkod', 'kod']) {
const value = parsed[field]
const asString = typeof value === 'number' ? String(value) : value
if (typeof asString === 'string' && /^[4579]\d{3}$/.test(asString)) return asString
}
}
} catch {
// not JSON — fall through to the "NNNN=text" shape
}
const match = body.match(/(?:^|[\r\n])\s*([4579]\d{3})\s*=/)
return match ? match[1] : null
}
function mapError(status: number, body: string): BolagsverketApiError {
const felkod = extractFelkod(body)
const known = felkod ? BOLAGSVERKET_ERROR_MESSAGES[felkod] : null
const fallback =
status === 404
? 'Ingen träff hos Bolagsverket (404).'
: status === 503 || status === 504
? 'Bolagsverkets tjänst är tillfälligt otillgänglig — försök igen om en stund.'
: `Bolagsverket svarade med fel (HTTP ${status}).`
return new BolagsverketApiError(known ?? fallback, status, body.slice(0, 2_000))
}
interface RequestJsonOptions {
/**
* Validate the response shape at the boundary (Zod). Schemas are loose
* (passthrough) and only pin the fields downstream code dereferences, so
* the parsed value is returned as the caller's declared DTO type.
*/
schema?: ZodType<unknown>
/** Human-readable endpoint name for error messages. */
endpoint?: string
}
async function requestJson<T>(
config: BolagsverketClientConfig,
method: 'GET' | 'POST' | 'DELETE',
url: string,
jsonBody?: unknown,
options: RequestJsonOptions = {},
): Promise<T> {
let res = await rawRequest(config, method, url, jsonBody)
// One retry for idempotent GETs when the service is momentarily unavailable.
if (method === 'GET' && (res.status === 503 || res.status === 504)) {
await new Promise((resolve) => setTimeout(resolve, 300))
res = await rawRequest(config, method, url, jsonBody)
}
if (res.status < 200 || res.status >= 300) throw mapError(res.status, res.body)
if (res.body.trim().length === 0) return undefined as T
let parsed: unknown
try {
parsed = JSON.parse(res.body)
} catch {
throw new BolagsverketApiError('Oväntat svar från Bolagsverket (inte JSON).', res.status, res.body.slice(0, 500))
}
if (options.schema) {
const result = options.schema.safeParse(parsed)
if (!result.success) {
const issues = result.error.issues
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('; ')
throw new BolagsverketApiError(
`Oväntat svarsformat från Bolagsverket (${options.endpoint ?? url}): ${issues}`,
res.status,
res.body.slice(0, 500),
)
}
return result.data as T
}
return parsed as T
}
export class BolagsverketClient {
constructor(private readonly config: BolagsverketClientConfig) {}
get environment(): BolagsverketEnvironment {
return this.config.environment
}
private url(service: string, path: string): string {
const { base, prefix } = HOSTS[this.config.environment]
return `${base}${prefix}/${service}${path}`
}
// ---- informationstjänster (GUIDE §5.2) ----------------------------------
getGrunduppgifter(orgnr: string): Promise<GrunduppgifterSvar> {
return requestJson(this.config, 'GET', this.url('hamta-arsredovisningsinformation/v1.4', `/grunduppgifter/${encodeURIComponent(orgnr)}`))
}
getArendestatus(orgnr: string): Promise<ArendestatusSvar> {
return requestJson(this.config, 'GET', this.url('hamta-arsredovisningsinformation/v1.4', `/arendestatus/${encodeURIComponent(orgnr)}`))
}
/** Token for skapa-kontrollsumma (information service, v1.1). */
createChecksumToken(pnr: string, orgnr: string): Promise<InlamningTokenSvar> {
return requestJson(this.config, 'POST', this.url('hamta-arsredovisningsinformation/v1.1', '/skapa-inlamningtoken/'), { pnr, orgnr }, {
schema: InlamningTokenSvarSchema,
endpoint: 'skapa-inlamningtoken (kontrollsumma)',
})
}
createChecksum(token: string, fileBase64: string): Promise<KontrollsummaSvar> {
return requestJson(this.config, 'POST', this.url('hamta-arsredovisningsinformation/v1.1', `/skapa-kontrollsumma/${encodeURIComponent(token)}`), { fil: fileBase64 }, {
schema: KontrollsummaSvarSchema,
endpoint: 'skapa-kontrollsumma',
})
}
// ---- inlämning (GUIDE §5.3) ----------------------------------------------
createInlamningToken(pnr: string, orgnr: string): Promise<InlamningTokenSvar> {
return requestJson(this.config, 'POST', this.url('lamna-in-arsredovisning/v2.1', '/skapa-inlamningtoken/'), { pnr, orgnr }, {
schema: InlamningTokenSvarSchema,
endpoint: 'skapa-inlamningtoken',
})
}
kontrollera(token: string, fileBase64: string, typ: HandlingTyp): Promise<KontrolleraSvar> {
return requestJson(this.config, 'POST', this.url('lamna-in-arsredovisning/v2.1', `/kontrollera/${encodeURIComponent(token)}`), {
handling: { fil: fileBase64, typ },
}, {
schema: KontrolleraSvarSchema,
endpoint: 'kontrollera',
})
}
lamnaIn(
token: string,
body: {
undertecknare: string
epostadresser: string[]
kvittensepostadresser?: string[]
notifieringEpostadresser?: string[]
fileBase64: string
typ: HandlingTyp
},
): Promise<InlamningSvar> {
return requestJson(this.config, 'POST', this.url('lamna-in-arsredovisning/v2.1', `/inlamning/${encodeURIComponent(token)}`), {
undertecknare: body.undertecknare,
epostadresser: body.epostadresser,
...(body.kvittensepostadresser?.length ? { kvittensepostadresser: body.kvittensepostadresser } : {}),
...(body.notifieringEpostadresser?.length ? { notifieringEpostadresser: body.notifieringEpostadresser } : {}),
handling: { fil: body.fileBase64, typ: body.typ },
}, {
schema: InlamningSvarSchema,
endpoint: 'inlamning',
})
}
// ---- händelser (GUIDE §5.4) ----------------------------------------------
/** Idempotent: existing (url, orgnr) pair gets its TTL extended 6 months. */
async createSubscription(url: string, orgnr: string, auth: string): Promise<void> {
await requestJson(this.config, 'POST', this.url('hantera-arsredovisningsprenumerationer/v2.0', '/handelseprenumeration/'), {
prenumerationer: [{ url, orgnr, auth }],
})
}
async deleteSubscription(url: string, orgnr: string): Promise<void> {
await requestJson(this.config, 'DELETE', this.url('hantera-arsredovisningsprenumerationer/v2.0', '/handelseprenumeration/'), { url, orgnr })
}
/** Polling fallback for missed webhooks (events kept ~1 year). */
hamtaHandelser(body: {
url: string
orgnr: string[]
fromtidpunkt?: string
tomtidpunkt?: string
}): Promise<HamtaHandelserSvar> {
return requestJson(this.config, 'POST', this.url('hamta-arsredovisningshandelser/v2.0', '/handelser/'), body)
}
}
@@ -0,0 +1,62 @@
/**
* Zod schemas for the Bolagsverket response payloads we actually dereference.
* Parsed at the HTTP-client boundary (lib/client.ts) so a contract drift on
* Bolagsverket's side surfaces as a clear "unexpected response shape from
* <endpoint>" error instead of an opaque TypeError deep inside the
* submission flow.
*
* Schemas are deliberately loose (`.passthrough()`): we only pin the fields
* we read — see types.ts for the full hand-written DTOs.
*/
import { z } from 'zod'
// skapa-inlamningtoken (v2.1 + v1.1) — token + avtalstext gate fields.
export const InlamningTokenSvarSchema = z
.object({
token: z.string().min(1),
avtalstext: z.string(),
avtalstextAndrad: z.string(),
})
.passthrough()
// kontrollera (v2.1) — utfall drives the warn/error gate.
export const KontrolleraSvarSchema = z
.object({
orgnr: z.string().optional(),
utfall: z
.array(
z
.object({
kod: z.string().optional(),
text: z.string().optional(),
typ: z.string().optional(),
})
.passthrough(),
)
.nullable()
.optional(),
})
.passthrough()
// inlamning (v2.1) — handlingsinfo.idnummer correlates webhooks; sha256 and
// url are persisted on the submission row.
export const InlamningSvarSchema = z
.object({
handlingsinfo: z
.object({
idnummer: z.string().min(1),
sha256checksumma: z.string(),
})
.passthrough(),
url: z.string(),
})
.passthrough()
// skapa-kontrollsumma (v1.1).
export const KontrollsummaSvarSchema = z
.object({
kontrollsumma: z.string().min(1),
algoritm: z.string(),
})
.passthrough()
@@ -0,0 +1,621 @@
/**
* Submission orchestration for digital inlämning av årsredovisning.
*
* Flow (GUIDE §4.2/§5.3): skapa-inlamningtoken → [avtalstext gate] →
* kontrollera → inlamning till eget utrymme → handelseprenumeration. The
* undertecknare then signs the fastställelseintyg with e-legitimation AT
* Bolagsverket (never in our app); webhooks/polling drive the status from
* there: uploaded → inkommen → (förelagd ↔ komplettering)* →
* registrerad | avslutad.
*
* Personnummer are transient: used for the API calls, persisted only as
* company-salted SHA-256 hashes.
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument, embedKontrollsumma } from '@/lib/bokslut/ixbrl/document/k2-document'
import { runPreflightChecks } from '@/lib/bokslut/ixbrl/validate/rules'
import { BolagsverketClient } from './client'
import type { ExtensionLogger } from '@/lib/extensions/types'
import type {
ArsredovisningSubmission,
HandelseMeddelande,
KontrolleraUtfall,
SubmissionStatus,
} from '../types'
/**
* Domain error carrying a structured-error registry code
* (lib/errors/structured-errors.ts). index.ts maps it through
* errorResponseFromCode for the canonical envelope.
*/
export class BolagsverketSubmissionError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly details?: Record<string, unknown>,
) {
super(message)
this.name = 'BolagsverketSubmissionError'
}
}
/** Statuses that mean "Bolagsverket currently holds an open filing for this period". */
export const ACTIVE_SUBMISSION_STATUSES = [
'uploaded',
'inkommen',
'forelagd',
'komplettering',
] as const
export function hashPnr(companyId: string, pnr: string): string {
return createHash('sha256').update(`${companyId}:${pnr.replace(/\D/g, '')}`).digest('hex')
}
/** Normalize to the 10-digit orgnr the API expects (no dash, no century). */
export function normalizeOrgnr(orgNumber: string): string {
const digits = orgNumber.replace(/\D/g, '')
return digits.length === 12 ? digits.slice(2) : digits
}
/**
* SECURITY: `avsandarePnr` and `undertecknare.pnr` are plaintext personnummer,
* needed only for the Bolagsverket API calls. They must NEVER reach a log sink
* — log structured fields (companyId, fiscalPeriodId, submissionId) and never
* the params object itself. At rest only company-salted SHA-256 hashes are
* stored (see hashPnr).
*/
export interface SubmitParams {
companyId: string
userId: string
fiscalPeriodId: string
/** Avsändarens personnummer (12 siffror) — required by skapa-inlamningtoken. */
avsandarePnr: string
/** Undertecknare of fastställelseintyget. */
undertecknare: {
pnr: string
fornamn: string
efternamn: string
roll: string
epost: string
}
kvittensEpost?: string[]
proposedDividend?: number
/** User accepted the current avtalstext (avtalstextAndrad value). */
acceptedAvtalstextAndrad?: string
/** Upload even when kontrollera returns warn-level utfall (GUIDE §4.2.2). */
ignoreWarnings?: boolean
}
export type SubmitResult =
| { outcome: 'avtal_required'; avtalstext: string; avtalstextAndrad: string }
| { outcome: 'preflight_failed'; issues: ReturnType<typeof runPreflightChecks>['issues'] }
| {
outcome: 'kontrollera_stopped'
submissionId: string
utfall: KontrolleraUtfall[]
}
| {
outcome: 'uploaded'
submissionId: string
idnummer: string
sha256: string
url: string
utfall: KontrolleraUtfall[]
}
interface ServiceDeps {
supabase: SupabaseClient
client: BolagsverketClient
/** Absolute base URL of this install, for the webhook subscription. */
appUrl: string
/** Extension logger — non-fatal failures must be visible, never swallowed. */
log: ExtensionLogger
}
/** Best-effort: persist a failure on the submission row so it is visible. */
async function markSubmissionError(
supabase: SupabaseClient,
log: ExtensionLogger,
submissionId: string,
err: unknown,
): Promise<void> {
const message = err instanceof Error ? err.message : String(err)
const { error } = await supabase
.from('arsredovisning_submissions')
.update({ status: 'error', error_message: message.slice(0, 2_000) })
.eq('id', submissionId)
if (error) {
log.error('could not mark submission as error', { submissionId, dbError: error.message })
}
}
async function getOrgnr(supabase: SupabaseClient, companyId: string): Promise<string> {
const { data } = await supabase
.from('company_settings')
.select('org_number')
.eq('company_id', companyId)
.maybeSingle()
const orgNumber = (data as { org_number?: string } | null)?.org_number
if (!orgNumber) throw new Error('Organisationsnummer saknas i företagsinställningarna.')
return normalizeOrgnr(orgNumber)
}
export async function submitArsredovisning(
deps: ServiceDeps,
params: SubmitParams,
): Promise<SubmitResult> {
const { supabase, client, log } = deps
const orgnr = await getOrgnr(supabase, params.companyId)
// 0. Double-submission guard: once an upload reached Bolagsverket, a retry
// would file a second handling (and store a second audit document).
// Refuse while a submission for this period is still open with the
// authority. Rows in draft/kontrollerad/error/registrerad/avslutad do
// not block — retries after failure create a fresh row.
const { data: activeRows } = await supabase
.from('arsredovisning_submissions')
.select('id, status')
.eq('company_id', params.companyId)
.eq('fiscal_period_id', params.fiscalPeriodId)
.in('status', [...ACTIVE_SUBMISSION_STATUSES])
.limit(1)
const active = (activeRows as Array<{ id: string; status: string }> | null)?.[0]
if (active) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_SUBMISSION_EXISTS',
`An active submission (${active.status}) already exists for this fiscal period.`,
{ submission_id: active.id, status: active.status },
)
}
// 1. Token (also carries the avtalstext we must gate on).
const token = await client.createInlamningToken(params.avsandarePnr, orgnr)
// 2. Avtalstext gate (GUIDE §4.2): the user must have accepted THIS version
// of the text for THIS company before kontrollera/inlämning may run.
const { data: acceptance } = await supabase
.from('bolagsverket_avtal_acceptances')
.select('id')
.eq('company_id', params.companyId)
.eq('user_id', params.userId)
.eq('avtalstext_andrad', token.avtalstextAndrad)
.maybeSingle()
const acceptedNow = params.acceptedAvtalstextAndrad === token.avtalstextAndrad
if (!acceptance && !acceptedNow) {
return {
outcome: 'avtal_required',
avtalstext: token.avtalstext,
avtalstextAndrad: token.avtalstextAndrad,
}
}
if (!acceptance && acceptedNow) {
await supabase.from('bolagsverket_avtal_acceptances').insert({
company_id: params.companyId,
user_id: params.userId,
avtalstext_andrad: token.avtalstextAndrad,
})
}
// 3. Generate the iXBRL + local pre-flight (layer 1) — cheaper than a
// kontrollera round-trip and catches data problems with better messages.
const input = await buildIxbrlInput(supabase, params.companyId, params.fiscalPeriodId, {
proposedDividend: params.proposedDividend,
undertecknare: {
firstName: params.undertecknare.fornamn,
lastName: params.undertecknare.efternamn,
role: params.undertecknare.roll,
},
})
const preflight = runPreflightChecks(input)
if (!preflight.ok) {
return { outcome: 'preflight_failed', issues: preflight.issues }
}
let { xhtml } = generateK2IxbrlDocument(input)
// 4. Kontrollsumma (TA §4.5, recommended): tag the checksum into <head> so
// the kvittens email carries a verifiable hash. Non-fatal on failure.
let kontrollsumma: string | null = null
try {
const checksumToken = await client.createChecksumToken(params.avsandarePnr, orgnr)
const checksum = await client.createChecksum(
checksumToken.token,
Buffer.from(xhtml, 'utf8').toString('base64'),
)
kontrollsumma = checksum.kontrollsumma
xhtml = embedKontrollsumma(xhtml, checksum.kontrollsumma, checksum.algoritm)
} catch (err) {
kontrollsumma = null
log.warn('kontrollsumma generation failed — continuing without embedded checksum', {
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
error: err instanceof Error ? err.message : String(err),
})
}
const fileBase64 = Buffer.from(xhtml, 'utf8').toString('base64')
// 5. Create the submission row (draft) before talking to Bolagsverket so
// every attempt is traceable.
const { data: submissionRow, error: insertError } = await supabase
.from('arsredovisning_submissions')
.insert({
company_id: params.companyId,
user_id: params.userId,
fiscal_period_id: params.fiscalPeriodId,
handling_typ: 'arsredovisning_komplett',
taxonomy_version: '2024-09-12',
entry_point: input.entryPointId,
environment: client.environment,
status: 'draft',
undertecknare_namn: `${params.undertecknare.fornamn} ${params.undertecknare.efternamn}`,
undertecknare_epost: params.undertecknare.epost,
undertecknare_pnr_hash: hashPnr(params.companyId, params.undertecknare.pnr),
avsandare_pnr_hash: hashPnr(params.companyId, params.avsandarePnr),
kontrollsumma,
})
.select('id')
.single()
if (insertError || !submissionRow) {
throw new Error(`Kunde inte spara inlämningsförsöket: ${insertError?.message ?? 'okänt fel'}`)
}
const submissionId = (submissionRow as { id: string }).id
// Steps 6–8 talk to Bolagsverket with a persisted row in play. Any failure
// here must flip the row to status='error' with the message — otherwise it
// sits in draft/kontrollerad forever and the failed attempt is invisible.
let svar: Awaited<ReturnType<BolagsverketClient['lamnaIn']>>
let utfall: KontrolleraUtfall[]
try {
// 6. Kontrollera (layer 3) — always run; surface utfall to the user.
const kontrollSvar = await client.kontrollera(token.token, fileBase64, 'arsredovisning_komplett')
utfall = kontrollSvar.utfall ?? []
await supabase
.from('arsredovisning_submissions')
.update({ status: 'kontrollerad', kontrollera_utfall: utfall })
.eq('id', submissionId)
const hasBlocking = utfall.some((item) => item.typ?.toLowerCase() === 'error')
if (utfall.length > 0 && (hasBlocking || !params.ignoreWarnings)) {
return { outcome: 'kontrollera_stopped', submissionId, utfall }
}
// 7. Store the exact uploaded bytes as räkenskapsinformation (7-year
// retention, Accounting Guard Rail #7) BEFORE upload.
let dokumentId: string | null = null
try {
const buffer = Buffer.from(xhtml, 'utf8')
const doc = await uploadDocument(
supabase,
params.userId,
params.companyId,
{
name: `arsredovisning-${input.period.end}-inlamnad.xhtml`,
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer,
type: 'application/xhtml+xml',
},
{ upload_source: 'system' },
)
dokumentId = doc.id
} catch (err) {
// Storage failure must not block the filing, but it MUST be visible:
// without the stored bytes the legally-filed document is not
// reproducible from our archive (Guard Rail #7).
dokumentId = null
const message = err instanceof Error ? err.message : String(err)
log.error('failed to archive the filed .xhtml as räkenskapsinformation', {
submissionId,
companyId: params.companyId,
error: message,
})
await supabase
.from('arsredovisning_submissions')
.update({ error_message: `Dokumentarkivering misslyckades: ${message}`.slice(0, 2_000) })
.eq('id', submissionId)
}
// 8. Lämna in till eget utrymme.
svar = await client.lamnaIn(token.token, {
undertecknare: params.undertecknare.pnr,
epostadresser: [params.undertecknare.epost],
kvittensepostadresser: params.kvittensEpost,
fileBase64,
typ: 'arsredovisning_komplett',
})
const { error: uploadUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({
status: 'uploaded',
idnummer: svar.handlingsinfo.idnummer,
sha256_checksumma: svar.handlingsinfo.sha256checksumma,
bolagsverket_url: svar.url,
dokument_id: dokumentId,
uploaded_at: new Date().toISOString(),
})
.eq('id', submissionId)
if (uploadUpdateError) {
log.error('failed to persist uploaded state after successful inlämning', {
submissionId,
idnummer: svar.handlingsinfo.idnummer,
dbError: uploadUpdateError.message,
})
}
} catch (err) {
await markSubmissionError(supabase, log, submissionId, err)
throw err // preserved for the route's error mapping (5xx / upstream status)
}
await eventBus.emit({
type: 'arsredovisning.uploaded',
payload: {
submissionId,
fiscalPeriodId: params.fiscalPeriodId,
idnummer: svar.handlingsinfo.idnummer,
environment: client.environment,
userId: params.userId,
companyId: params.companyId,
},
})
// 9. Subscribe to händelser (idempotent; extends TTL 6 months — GUIDE §4.3).
try {
await ensureSubscription(deps, params.companyId, params.userId, orgnr)
} catch (err) {
// Non-fatal: polling fallback (hamta-handelser) covers missed webhooks.
log.warn('handelseprenumeration could not be created/renewed — relying on polling fallback', {
submissionId,
companyId: params.companyId,
error: err instanceof Error ? err.message : String(err),
})
}
return {
outcome: 'uploaded',
submissionId,
idnummer: svar.handlingsinfo.idnummer,
sha256: svar.handlingsinfo.sha256checksumma,
url: svar.url,
utfall,
}
}
export async function ensureSubscription(
deps: ServiceDeps,
companyId: string,
userId: string,
orgnr: string,
): Promise<void> {
const { supabase, client, appUrl } = deps
if (!/^https?:\/\//.test(appUrl)) {
// A relative/empty base URL would register a broken webhook endpoint at
// Bolagsverket. Fail fast — the caller logs this as a subscription failure.
throw new Error(
'NEXT_PUBLIC_APP_URL saknas eller är inte en absolut URL — kan inte registrera webhook hos Bolagsverket.',
)
}
const url = `${appUrl.replace(/\/$/, '')}/api/extensions/ext/bolagsverket/webhook`
const { data: existing } = await supabase
.from('bolagsverket_subscriptions')
.select('id, auth_secret')
.eq('company_id', companyId)
.eq('orgnr', orgnr)
.eq('url', url)
.eq('environment', client.environment)
.maybeSingle()
// Org-number reuse is allowed in this product, and Bolagsverket dedupes
// subscriptions per (url, orgnr): registering a NEW secret here would
// overwrite the delivery auth another company sharing this orgnr already
// depends on, 401-ing their webhooks. Reuse any existing secret for the
// same (orgnr, url, environment) across ALL companies (service client —
// RLS would hide other tenants' rows) so everyone sharing the orgnr
// authenticates the same deliveries.
let sharedSecret: string | null = null
if (!existing) {
const serviceClient = createServiceClientNoCookies()
const { data: shared } = await serviceClient
.from('bolagsverket_subscriptions')
.select('auth_secret')
.eq('orgnr', orgnr)
.eq('url', url)
.eq('environment', client.environment)
.limit(1)
.maybeSingle()
sharedSecret = (shared as { auth_secret?: string } | null)?.auth_secret ?? null
}
const secret =
(existing as { auth_secret?: string } | null)?.auth_secret ??
sharedSecret ??
randomBytes(24).toString('base64url')
await client.createSubscription(url, orgnr, secret)
const expires = new Date()
expires.setMonth(expires.getMonth() + 6)
if (existing) {
await supabase
.from('bolagsverket_subscriptions')
.update({ subscribed_at: new Date().toISOString(), expires_at: expires.toISOString() })
.eq('id', (existing as { id: string }).id)
} else {
await supabase.from('bolagsverket_subscriptions').insert({
company_id: companyId,
user_id: userId,
orgnr,
url,
auth_secret: secret,
environment: client.environment,
expires_at: expires.toISOString(),
})
}
}
/** Bolagsverket ärendestatus → our submission status. */
const STATUS_MAP: Record<string, SubmissionStatus> = {
arsred_inkommen: 'inkommen',
arsred_forelaggande_skickat: 'forelagd',
arsred_komplettering_inkommen: 'komplettering',
arsred_registrerad: 'registrerad',
arsred_avslutad_ej_registrerad: 'avslutad',
}
export interface WebhookHandlingResult {
status: number
body: { ok: boolean; reason?: string }
}
/**
* Apply one händelsemeddelande to the submission rows. Used by both the
* webhook receiver and the polling fallback. `serviceClient` is the
* cookieless service-role client — all queries still filter by company.
*/
export async function applyHandelse(
serviceClient: SupabaseClient,
message: HandelseMeddelande,
matchedCompanyIds: string[],
log?: Pick<ExtensionLogger, 'warn' | 'error'>,
): Promise<void> {
const mapped = STATUS_MAP[message.data.status]
if (!mapped) return // 'test' or future statuses — nothing to apply
const idnummerList = (message.data.handlingsinfo ?? [])
.filter((info) => info.handling === 'arsredovisning')
.map((info) => info.idnummer)
for (const companyId of matchedCompanyIds) {
// Correlate by document idnummer when the message carries one (it does
// for arsredovisning events); otherwise fall back to the latest active
// submission for the company.
const base = serviceClient
.from('arsredovisning_submissions')
.select('id, status, fiscal_period_id, user_id, company_id')
.eq('company_id', companyId)
const filtered =
idnummerList.length > 0
? base.in('idnummer', idnummerList)
: base.in('status', ['uploaded', 'inkommen', 'forelagd', 'komplettering'])
const { data: rows } = await filtered.order('created_at', { ascending: false }).limit(1)
const submission = (rows as Pick<
ArsredovisningSubmission,
'id' | 'status' | 'fiscal_period_id' | 'user_id' | 'company_id'
>[] | null)?.[0]
if (!submission) continue
if (submission.status === mapped) continue
const update: Record<string, unknown> = { status: mapped }
if (mapped === 'registrerad') update.registered_at = new Date().toISOString()
const { error } = await serviceClient
.from('arsredovisning_submissions')
.update(update)
.eq('id', submission.id)
if (error) {
// Transition rejected by the DB state machine (or other write failure).
// Don't apply, but never silently — a divergence between our status and
// Bolagsverket's must be investigable.
log?.warn('handelse rejected — submission status not updated', {
submissionId: submission.id,
companyId,
fromStatus: submission.status,
toStatus: mapped,
bolagsverketStatus: message.data.status,
dbError: error.message,
})
continue
}
await eventBus.emit({
type: 'arsredovisning.status_changed',
payload: {
submissionId: submission.id,
fiscalPeriodId: submission.fiscal_period_id,
previousStatus: submission.status,
status: mapped,
bolagsverketStatus: message.data.status,
userId: submission.user_id,
companyId: submission.company_id,
},
})
if (mapped === 'registrerad') {
await eventBus.emit({
type: 'arsredovisning.registered',
payload: {
submissionId: submission.id,
fiscalPeriodId: submission.fiscal_period_id,
userId: submission.user_id,
companyId: submission.company_id,
},
})
}
if (mapped === 'forelagd') {
await eventBus.emit({
type: 'arsredovisning.forelagd',
payload: {
submissionId: submission.id,
fiscalPeriodId: submission.fiscal_period_id,
userId: submission.user_id,
companyId: submission.company_id,
},
})
}
}
}
/**
* Webhook entry: validate the `auth` header against stored subscriptions for
* the orgnr (GUIDE §5.4.5.2), ack test messages (status "test", nr -1), and
* apply real events.
*/
/**
* Constant-time secret comparison: hash both sides to equal-length digests
* first (timingSafeEqual requires equal lengths and a plain === leaks
* prefix-match timing).
*/
function secretMatches(expected: string, provided: string): boolean {
const a = createHash('sha256').update(expected).digest()
const b = createHash('sha256').update(provided).digest()
return timingSafeEqual(a, b)
}
export async function handleWebhook(
serviceClient: SupabaseClient,
message: HandelseMeddelande,
authHeader: string | null,
log?: Pick<ExtensionLogger, 'warn' | 'error'>,
): Promise<WebhookHandlingResult> {
if (!message || typeof message !== 'object' || !message.data) {
return { status: 400, body: { ok: false, reason: 'malformed' } }
}
// `message.id` is attacker-controllable until the auth check below, so it
// is used ONLY to look up candidate subscriptions; a delivery is accepted
// exclusively when its `auth` header matches a secret WE registered with
// Bolagsverket for exactly this orgnr (the eq below). A valid secret for a
// different orgnr can never authenticate a spoofed orgnr. Status payloads
// are still treated as untrusted: STATUS_MAP allowlists transitions and the
// DB status-machine trigger rejects illegal ones in applyHandelse.
const orgnr = String(message.id ?? '')
if (!/^\d{10}$/.test(orgnr)) {
return { status: 400, body: { ok: false, reason: 'malformed orgnr' } }
}
const { data: subs } = await serviceClient
.from('bolagsverket_subscriptions')
.select('company_id, auth_secret')
.eq('orgnr', orgnr)
const matching = ((subs as Array<{ company_id: string; auth_secret: string }> | null) ?? []).filter(
(sub) => authHeader !== null && secretMatches(sub.auth_secret, authHeader),
)
if (matching.length === 0) {
return { status: 401, body: { ok: false, reason: 'unknown subscription or bad auth' } }
}
if (message.data.status === 'test' || message.nr === -1) {
return { status: 200, body: { ok: true } }
}
await applyHandelse(
serviceClient,
message,
[...new Set(matching.map((sub) => sub.company_id))],
log,
)
return { status: 200, body: { ok: true } }
}
@@ -0,0 +1,23 @@
{
"id": "bolagsverket",
"sector": "general",
"exportName": "bolagsverketExtension",
"entryPoint": "@/extensions/general/bolagsverket",
"workspace": null,
"requiredEnvVars": [],
"optionalEnvVars": [
"BOLAGSVERKET_ENV",
"BOLAGSVERKET_CLIENT_CERT",
"BOLAGSVERKET_CLIENT_KEY",
"BOLAGSVERKET_CA"
],
"npmDependencies": [],
"definition": {
"name": "Bolagsverket — digital årsredovisning",
"category": "operations",
"icon": "Landmark",
"dataPattern": "core",
"description": "Lämna in årsredovisningen digitalt till Bolagsverket (iXBRL).",
"longDescription": "Ladda upp årsredovisningen som iXBRL till Bolagsverkets eget utrymme, låt undertecknaren signera fastställelseintyget med e-legitimation hos Bolagsverket, och följ ärendet i appen via händelseaviseringar. Kräver avtal med Bolagsverket och organisationscertifikat för acceptans- och produktionsmiljön; testmiljön kan användas utan certifikat. Certifikatet läses enbart från miljövariablerna BOLAGSVERKET_CLIENT_CERT/BOLAGSVERKET_CLIENT_KEY (aldrig från inställningar), och BOLAGSVERKET_ENV sätter taket (test < accept < prod) för vilken miljö ett företag kan välja i inställningarna — utan miljövariabeln gäller endast testmiljön."
}
}
+181
View File
@@ -0,0 +1,181 @@
/**
* DTOs for Bolagsverket's REST services for digital inlämning av
* årsredovisning, hand-written from Teknisk guide v3.4 (dev_docs/bokslut/
* GUIDE.md §5 + §8 trafikexempel) and Anslutningsanvisning v1.7.
*
* NOTE: the official OpenAPI 2.0 servicespecifikationer are still pending
* manual download (dev_docs/bokslut/INDEX.md checklist → api/). When they
* land, reconcile these types against the specs — the specs win on conflict
* (GUIDE §5.1).
*/
export type BolagsverketEnvironment = 'test' | 'accept' | 'prod'
export type HandlingTyp =
| 'arsredovisning_komplett'
| 'arsredovisning'
| 'revisionsberattelse'
// ---- hamta-arsredovisningsinformation/v1.4 ---------------------------------
export interface GrunduppgifterRakenskapsperiod {
from: string
tom: string
kravPaRevisionsberattelse: 'ja' | 'nej' | 'uppgift_saknas'
revisorsplikt: 'ja' | 'nej' | 'uppgift_saknas'
}
export interface GrunduppgifterForetradare {
fornamn: string
namn: string
personnummer: string | null
annanIdentitet: string | null
funktioner: Array<{ kod: string; text: string }>
}
export interface GrunduppgifterSvar {
orgnr: string
lopnummer: number | null
namn: string
status: Array<{ kod?: string; text?: string }>
rakenskapsperioder: GrunduppgifterRakenskapsperiod[]
foretradare: GrunduppgifterForetradare[]
}
export type ArendestatusTyp =
| 'arsred_inkommen'
| 'arsred_forelaggande_skickat'
| 'arsred_komplettering_inkommen'
| 'arsred_registrerad'
| 'arsred_avslutad_ej_registrerad'
| 'arsred_saknas'
export interface ArendestatusSvar {
orgnr: string
namn: string
hamtat: string
tidpunkt: string | null
typ: ArendestatusTyp
arendenummer: string | null
rakenskapsperiod: { from: string; tom: string } | null
}
// ---- skapa-inlamningtoken (v2.1) -------------------------------------------
export interface InlamningTokenSvar {
token: string
/** MUST be shown to and accepted by the user per company; re-show when
* avtalstextAndrad changes (GUIDE §4.2, §5.3.1). */
avtalstext: string
avtalstextAndrad: string
}
// ---- kontrollera (v2.1) ----------------------------------------------------
export interface KontrolleraUtfall {
kod: string
text: string
typ: string
tekniskinformation: Array<{
meddelande: string | null
element: string | null
varde: string | null
}> | null
}
export interface KontrolleraSvar {
orgnr: string
utfall: KontrolleraUtfall[] | null
}
// ---- inlamning (v2.1) ------------------------------------------------------
export interface InlamningBegaran {
undertecknare: string
epostadresser: string[]
kvittensepostadresser?: string[]
notifieringEpostadresser?: string[]
handling: { fil: string; typ: HandlingTyp }
}
export interface InlamningSvar {
orgnr: string
avsandare: string
undertecknare: string
handlingsinfo: {
typ: HandlingTyp
dokumentlangd: number
idnummer: string
sha256checksumma: string
}
url: string
}
// ---- skapa-kontrollsumma (v1.1) --------------------------------------------
export interface KontrollsummaSvar {
kontrollsumma: string
algoritm: string
}
// ---- handelser (v2.0) ------------------------------------------------------
export interface HandelseMeddelande {
typ: string
/** Orgnr of the company the event concerns. */
id: string
/** Per-company sequence number; -1 for the subscription test message. */
nr: number
tid: string
data: {
version: string
handlingsinfo?: Array<{
handling: 'arsredovisning' | 'revisionsberattelse'
idnummer: string
kontrollsumma?: { digest: string; algoritm: string; upplysning: string | null } | null
}>
status: ArendestatusTyp | 'test'
}
}
export interface HamtaHandelserSvar {
meddelanden: HandelseMeddelande[]
}
// ---- submission rows (DB) ---------------------------------------------------
export type SubmissionStatus =
| 'draft'
| 'kontrollerad'
| 'uploaded'
| 'inkommen'
| 'forelagd'
| 'komplettering'
| 'registrerad'
| 'avslutad'
| 'error'
export interface ArsredovisningSubmission {
id: string
company_id: string
user_id: string
fiscal_period_id: string
handling_typ: HandlingTyp
taxonomy_version: string
entry_point: string
environment: BolagsverketEnvironment
status: SubmissionStatus
undertecknare_namn: string | null
undertecknare_epost: string | null
idnummer: string | null
sha256_checksumma: string | null
kontrollsumma: string | null
bolagsverket_url: string | null
kontrollera_utfall: KontrolleraUtfall[] | null
dokument_id: string | null
error_message: string | null
uploaded_at: string | null
registered_at: string | null
created_at: string
updated_at: string
}
+73 -1
View File
@@ -18,6 +18,8 @@ import {
composeInboxAddress,
} from './lib/inbox-provisioning'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -1648,6 +1650,31 @@ export const invoiceInboxExtension: Extension = {
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
}
// Periodisering requires faktureringsmetoden — mirror the main
// /api/supplier-invoices guard so kontantmetod companies never store
// accrual fields the booking would silently ignore.
const hasAccrualItems = body.items.some(
(bodyItem) => bodyItem.accrual_period_start && bodyItem.accrual_period_end,
)
if (hasAccrualItems && body.reverse_charge) {
// Omvänd skattskyldighet: the expense line carries the VAT base for
// rutor 20–32 — deferring the net to a 17xx interim account would
// corrupt the momsdeklaration. Same guard as /api/supplier-invoices.
return errorResponseFromCode('SI_CREATE_ACCRUAL_REVERSE_CHARGE', ctx.log)
}
if (hasAccrualItems) {
const { data: methodSettings } = await ctx.supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', ctx.companyId)
.single()
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', ctx.log, {
details: { reason: 'periodisering requires faktureringsmetoden (accrual)' },
})
}
}
const { data: arrivalNum, error: arrivalError } = await ctx.supabase
.rpc('get_next_arrival_number', { p_company_id: ctx.companyId })
@@ -1675,6 +1702,21 @@ export const invoiceInboxExtension: Extension = {
// Self-assessed RC rate (0.06/0.12/0.25) or null — engine defaults
// to 25% huvudregeln when null for a reverse-charge invoice.
reverse_charge_rate: body.reverse_charge ? (bodyItem.reverse_charge_rate ?? null) : null,
// Periodisering: frozen onto the line; the balance account
// defaults from the cost account's BAS convention.
accrual_period_start:
bodyItem.accrual_period_start && bodyItem.accrual_period_end
? bodyItem.accrual_period_start
: null,
accrual_period_end:
bodyItem.accrual_period_start && bodyItem.accrual_period_end
? bodyItem.accrual_period_end
: null,
accrual_balance_account:
bodyItem.accrual_period_start && bodyItem.accrual_period_end
? (bodyItem.accrual_balance_account ??
suggestBalanceAccount('expense', bodyItem.account_number))
: null,
}
})
@@ -1788,9 +1830,10 @@ export const invoiceInboxExtension: Extension = {
...lineItem,
}))
const { error: itemsError } = await ctx.supabase
const { data: insertedItems, error: itemsError } = await ctx.supabase
.from('supplier_invoice_items')
.insert(itemInserts)
.select('id, sort_order')
if (itemsError) {
await ctx.supabase.from('supplier_invoices').delete().eq('id', invoice.id)
@@ -1832,6 +1875,35 @@ export const invoiceInboxExtension: Extension = {
.eq('id', item.document_id)
.eq('company_id', ctx.companyId)
}
if (hasAccrualItems) {
// Schedules + catch-up dissolutions for deferred lines. Never
// fatal — the registration entry is committed; failures are
// retried/surfaced via the periodiseringar page.
const idBySortOrder = new Map(
((insertedItems ?? []) as Array<{ id: string; sort_order: number }>).map(
(row) => [row.sort_order, row.id],
),
)
const itemsWithIds = items.map((lineItem) => ({
...lineItem,
id: idBySortOrder.get(lineItem.sort_order) ?? null,
}))
const scheduleResult = await createSchedulesForSupplierInvoice(
ctx.supabase,
ctx.companyId,
ctx.userId,
invoice as SupplierInvoice,
itemsWithIds as unknown as SupplierInvoiceItem[],
journalEntry.id,
)
if (scheduleResult.failed > 0) {
ctx.log.error('accrual schedule creation failed on inbox convert', {
supplierInvoiceId: invoice.id,
failed: scheduleResult.failed,
})
}
}
} else {
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws).
@@ -69,6 +69,12 @@ export const ExtractionSchema = z.object({
dueDate: z.string().nullable(),
paymentReference: z.string().nullable(),
currency: z.string(),
// Service/coverage window the invoice charges for (insurance period,
// license term, "avtalsperiod"). Drives the periodisering prefill in the
// supplier-invoice form. Optional so cached raw outputs from before this
// field still validate.
servicePeriodStart: z.string().nullable().optional(),
servicePeriodEnd: z.string().nullable().optional(),
}),
lineItems: z.array(
z.object({
@@ -122,7 +128,9 @@ Return ONLY a single JSON object that matches this schema exactly. No prose, no
"invoiceDate": string | null, // ISO date YYYY-MM-DD
"dueDate": string | null, // ISO date YYYY-MM-DD
"paymentReference": string | null, // OCR / payment reference
"currency": string // ISO 4217 (SEK, USD, EUR, ...). Default "SEK" only if truly indeterminate.
"currency": string, // ISO 4217 (SEK, USD, EUR, ...). Default "SEK" only if truly indeterminate.
"servicePeriodStart": string | null, // ISO date — start of the service/coverage window the invoice charges for
"servicePeriodEnd": string | null // ISO date — end of that window
},
"lineItems": [
{
@@ -151,6 +159,7 @@ Rules:
- Currency: detect from the document (symbol $/€/kr or explicit code). Use the ISO 4217 code. Do NOT default to SEK if the document clearly shows another currency.
- "total" is the amount the buyer must pay (look for "Att betala", "Total", "Amount paid", "Amount due", "Balance"). Prefer this over Subtotal.
- Dates: convert any format to YYYY-MM-DD. If the document only shows month/year, leave null.
- servicePeriodStart/servicePeriodEnd: only when the document explicitly states the period the charge covers ("Avtalsperiod", "Period", "Försäkringstid", "Subscription period", coverage dates). Never infer from invoice/due dates. Month-only boundaries map to the first resp. last day of the month.
- Bankgiro/Plusgiro: only set when the document is for a Swedish supplier on a Swedish bank rail. Do not invent.
- Org.nr: only set when it is an actual Swedish organisation number (10 digits, Luhn-valid). For US/EU companies leave null even if they list an EIN/VAT number.
- VAT number: include the country prefix.
@@ -175,6 +184,8 @@ export function emptyResult(): InvoiceExtractionResult {
dueDate: null,
paymentReference: null,
currency: 'SEK',
servicePeriodStart: null,
servicePeriodEnd: null,
},
lineItems: [],
totals: { subtotal: null, vatAmount: null, total: null },
@@ -0,0 +1,23 @@
/**
* Pin tests for gnubok_list_accrual_schedules.
*
* The scope mapping is load-bearing: tools missing from TOOL_SCOPE_MAP are
* usable by ANY API key, so the reports:read pin guards against the tool
* silently becoming scope-less again.
*/
import { describe, it, expect } from 'vitest'
import { tools } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
describe('gnubok_list_accrual_schedules — registration', () => {
it('is registered and read-only', () => {
const tool = tools.find((t) => t.name === 'gnubok_list_accrual_schedules')
expect(tool).toBeDefined()
expect(tool?.annotations.readOnlyHint).toBe(true)
expect(tool?.annotations.destructiveHint).toBe(false)
})
it('is mapped to reports:read scope', () => {
expect(TOOL_SCOPE_MAP.gnubok_list_accrual_schedules).toBe('reports:read')
})
})
@@ -100,6 +100,10 @@ describe('forbiddenOriginResponse', () => {
})
describe('mcp-server apiRoutes origin enforcement', () => {
// The dynamic import pulls in the full 9k-line server module; that parse
// alone takes ~4s and flirts with the 5s default timeout under full-suite
// parallel load. The test is import-bound, not logic-bound — give it
// explicit headroom instead of letting machine load decide the outcome.
it('rejects foreign-Origin requests on every /mcp method before dispatch', async () => {
const { mcpServerExtension } = await import('../index')
const routes = (mcpServerExtension.apiRoutes ?? []).filter((r) => r.path === '/mcp')
@@ -114,5 +118,5 @@ describe('mcp-server apiRoutes origin enforcement', () => {
)
expect(res.status, `${route.method} /mcp`).toBe(403)
}
})
}, 20_000)
})
@@ -43,6 +43,9 @@ describe('tools/list payload size guard', () => {
// 92 tools for Connectors Directory readiness; the ~10 longest descriptions
// were trimmed toward 180–200 chars to partly offset. Headroom reserved for
// the upcoming Skatteverket tools.
// * Held at 36K when gnubok_list_accrual_schedules (add/bokslut) merged with
// the categorize vat_amount override (#717): the combination crossed the
// ceiling by ~75, offset by trimming the 8 longest descriptions to ~200 chars.
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
+64 -13
View File
@@ -7,6 +7,7 @@ import {
TOOL_SCOPE_MAP,
} from '@/lib/auth/api-keys'
import { createLogger } from '@/lib/logger'
import { roundOre, sumOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
@@ -2060,7 +2061,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_get_agent_briefing',
title: 'Get Agent Briefing',
description: 'Bootstrap this company\'s specialized accountant context in one call: profile_summary, the atoms loaded for the company (metadata only — call gnubok_load_skill for bodies), and the top-30 active memories. Call once at session start.',
description: 'Bootstrap this company\'s accountant context in one call: profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories. Call once at session start.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4291,7 +4292,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_query_journal',
title: 'Query Journal Lines',
description: "Flexible journal-line query — replaces chained ledger calls for ad-hoc questions. Filters: accounts, date range, amount range, voucher series/number, source type, status, project, cost center, free-text. Returns lines with parent voucher metadata + totals.",
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher series/number, source type, status, project, cost center, free-text. Returns lines with voucher metadata + totals.",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4666,7 +4667,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_match_transaction_to_invoice',
title: 'Match Transaction to Invoice',
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer match before staging — preview mirrors what you pass. Supports partial payments and auto-storno of prior categorization.',
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer before staging. Supports partial payments and auto-storno of prior categorization.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5273,7 +5274,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_find_voucher_candidates_for_invoice',
title: 'Find Voucher Candidates (Invoice)',
description: "List posted verifikat that could be this invoice's payment (faktureringsmetoden: credit 1510; kontantmetoden: debit a bank/cash account 19xx). Call before gnubok_link_invoice_to_voucher to mark a faktura paid against an existing verifikation (no new bokföring).",
description: "List posted verifikat that could be this invoice's payment (faktureringsmetoden: credit 1510; kontantmetoden: debit 19xx). Call before gnubok_link_invoice_to_voucher to mark the faktura paid (no new bokföring).",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5339,7 +5340,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_link_invoice_to_voucher',
title: 'Link Invoice to Voucher',
description: 'Markera en faktura som betald genom att länka till en befintlig bokförd verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar likvidkonto 19xx). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_invoice först.',
description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_invoice först.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5426,7 +5427,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_find_voucher_candidates_for_supplier_invoice',
title: 'Find Voucher Candidates (Supplier Invoice)',
description: 'List posted verifikat that debit leverantörsskuld (2440) and could be the payment for this supplier invoice. Use before gnubok_link_supplier_invoice_to_voucher when marking a leverantörsfaktura paid against an existing verifikation (no new bokföring).',
description: 'List posted verifikat that debit leverantörsskuld (2440) and could be this supplier invoice\'s payment. Call before gnubok_link_supplier_invoice_to_voucher to mark the leverantörsfaktura paid (no new bokföring).',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5492,7 +5493,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_link_supplier_invoice_to_voucher',
title: 'Link Supplier Invoice to Voucher',
description: 'Markera en leverantörsfaktura som betald genom att länka till en befintlig verifikation som redan debiterar leverantörsskuld (2440). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_supplier_invoice först.',
description: 'Markera en leverantörsfaktura som betald via länk till en befintlig verifikation som debiterar leverantörsskuld (2440). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_supplier_invoice först.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5955,7 +5956,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_list_inbox_items',
title: 'List Inbox Items',
description: 'List document inbox items. Each has a `processed` flag covering all terminal links (transaction match, supplier invoice, or journal entry), so a booked receipt counts as done. unprocessed_only=true returns only docs still needing handling.',
description: 'List document inbox items. `processed` covers all terminal links (transaction, supplier invoice, journal entry); booked receipts count as done. unprocessed_only=true returns docs still needing handling.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -6090,7 +6091,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_supplier_invoice_from_inbox',
title: 'Create Supplier Invoice from Inbox',
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier (matched or via org_number/name), assembles line items from extracted_data, applies VAT + FX, attaches the source document. Stages for human review; honors dry_run.",
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX, attaches the document. Stages for human review; honors dry_run.",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8279,7 +8280,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_undo_sie_import',
title: 'Undo SIE Import',
description: 'Stage undo of a completed SIE import: hard-deletes its entries (transactions + opening balance), detaches docs, resets voucher_sequences, marks the row \'undone\' so the file can be re-imported. Use after a botched import. Period must be open. HIGH risk.',
description: 'Stage undo of a completed SIE import: hard-deletes its entries, detaches docs, resets voucher_sequences, marks the import \'undone\' for re-import. Use after a botched import. Period must be open. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8380,7 +8381,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_voucher',
title: 'Create Manual Voucher (Verifikation)',
description: 'Stage a manual verifikation with arbitrary balanced lines. Use for capitalization (1010), period-end accruals, FX adjustments, rättelseposter outside categorize_transaction. Pass inbox_item_id to book a kvitto direct — links inbox + attaches doc. HIGH risk.',
description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Pass inbox_item_id to book a kvitto direct (links + attaches doc). HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8778,7 +8779,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_reverse_journal_entry',
title: 'Reverse Journal Entry (Storno)',
description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Use only when the affärshändelse should never have been booked (duplicate, ghost, test). If booked wrong, use gnubok_correct_entry; for refunds, gnubok_credit_invoice. HIGH risk.',
description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Only when the affärshändelse should never have been booked (duplicate, ghost, test). Booked wrong → gnubok_correct_entry; refund → gnubok_credit_invoice. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8977,6 +8978,56 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_list_accrual_schedules',
title: 'List Periodiseringar',
description:
'Löpande periodiseringar (17xx/29xx): monthly installments, dissolved and remaining amounts.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
status: {
type: 'string',
enum: ['active', 'completed', 'cancelled', 'all'],
description: "Default 'active'.",
},
},
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const status = (args.status as string) || 'active'
let query = supabase
.from('accrual_schedules')
.select('*, installments:accrual_schedule_installments(*)')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
if (status !== 'all') query = query.eq('status', status)
const { data, error } = await query
if (error) throw new Error(error.message)
type InstallmentRow = { period_month: string; amount: number; status: string }
const schedules = ((data ?? []) as Array<Record<string, unknown>>).map((schedule) => {
const installments = ([...((schedule.installments as InstallmentRow[]) ?? [])]).sort(
(a, b) => a.period_month.localeCompare(b.period_month),
)
const dissolved = sumOre(
installments.filter((i) => i.status === 'posted').map((i) => Number(i.amount)),
)
const total = Number(schedule.total_amount)
return {
...schedule,
installments,
dissolved_amount: dissolved,
remaining_amount:
schedule.status === 'cancelled' ? 0 : roundOre(total - dissolved),
}
})
return { schedules, count: schedules.length }
},
},
{
name: 'gnubok_propose_annual_depreciation',
title: 'Propose Annual Depreciation (Avskrivning)',
@@ -9213,7 +9264,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_approve_pending_operation',
title: 'Approve Pending Operation',
description: "Commit a staged pending_operation when the user has explicitly authorised the operation_id. risk_level=high requires confirmed=true — surface the BFL 5 kap 5§ irreversibility to the user first. The /pending web UI offers an equivalent commit path.",
description: "Commit a staged pending_operation the user has explicitly authorised. risk_level=high requires confirmed=true — surface the BFL 5 kap 5§ irreversibility first. The /pending web UI offers an equivalent commit path.",
inputSchema: {
type: 'object',
additionalProperties: false,