* 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
454 lines
17 KiB
TypeScript
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
|