Files
accounted/extensions/general/bolagsverket/index.ts
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

454 lines
17 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { validateSwedishPersonalNumber } from '@/lib/extensions/validation'
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'
}
function filingReleaseEnabled(): boolean {
return process.env.BOLAGSVERKET_FILING_ENABLED === 'true'
}
/**
* 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, error } = await ctx.supabase
.from('extension_data')
.select('value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'general/bolagsverket')
.eq('key', 'settings')
.maybeSingle()
if (error) {
throw new Error(`Failed to resolve Bolagsverket environment: ${error.message}`)
}
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, error } = await ctx.supabase
.from('company_settings')
.select('org_number')
.eq('company_id', ctx.companyId)
.maybeSingle()
if (error) throw new Error(`Kunde inte läsa organisationsnummer: ${error.message}`)
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 PersonnummerSchema = z
.string()
.regex(/^\d{12}$/, 'Personnummer ska normaliseras till 12 siffror')
.refine((value) => validateSwedishPersonalNumber(value) === null, 'Ogiltigt personnummer')
.refine((value) => {
const year = Number(value.slice(0, 4))
const month = Number(value.slice(4, 6))
const day = Number(value.slice(6, 8))
const date = new Date(Date.UTC(year, month - 1, day))
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
)
}, 'Ogiltigt födelsedatum')
const SubmitSchema = z.object({
fiscal_period_id: z.string().uuid(),
annual_report_version_id: z.string().uuid(),
avsandare_pnr: PersonnummerSchema,
undertecknare: z.object({
pnr: PersonnummerSchema,
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(),
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),
filing_enabled: filingReleaseEnabled(),
},
})
} 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, annual_report_version_id, handling_typ, taxonomy_version, entry_point, environment, status, archive_status, undertecknare_namn, undertecknare_epost, 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()
if (!filingReleaseEnabled()) {
return errorResponseFromCode('BOLAGSVERKET_NOT_RELEASED', ctx.log, {
requestId: ctx.requestId,
})
}
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,
annualReportVersionId: parsed.annual_report_version_id,
avsandarePnr: parsed.avsandare_pnr,
undertecknare: parsed.undertecknare,
kvittensEpost: parsed.kvittens_epost,
acceptedAvtalstextAndrad: parsed.accepted_avtalstext_andrad,
ignoreWarnings: parsed.ignore_warnings,
},
)
// Bolagsverket marks idnummer as a technical correlation value that
// must not be shown to end users. Keep it in the server-side filing
// record for webhook matching, but omit it from browser responses.
if (result.outcome === 'uploaded' || result.outcome === 'state_unknown') {
const { idnummer: _idnummer, ...publicResult } = result
return NextResponse.json({ data: publicResult })
}
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