Files
accounted/extensions/general/bolagsverket/lib/client.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

416 lines
16 KiB
TypeScript

/**
* 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)
}
}