Files
accounted/extensions/general/bolagsverket/lib/submission-service.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

940 lines
35 KiB
TypeScript

/**
* 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 and never persisted.
*/
import { createHash, createHmac, 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 { generateK2IxbrlDocument, embedKontrollsumma } from '@/lib/bokslut/ixbrl/document/k2-document'
import { runPreflightChecks } from '@/lib/bokslut/ixbrl/validate/rules'
import { validateIxbrlWithArelle } from '@/lib/bokslut/ixbrl/validate/arelle-client'
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
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 = [
'sending',
'uploaded',
'unknown',
'inkommen',
'forelagd',
'komplettering',
] as const
export function hashPnr(companyId: string, pnr: string, secret: string): string {
return createHmac('sha256', secret)
.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. No personnummer-derived value is needed for the
* filing audit trail; hashPnr exists only for integrations that need a keyed,
* non-enumerable correlation reference.
*/
export interface SubmitParams {
companyId: string
userId: string
fiscalPeriodId: string
annualReportVersionId: 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[]
/** 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[]
}
| {
outcome: 'state_unknown'
submissionId: string
idnummer: string | null
url: string | null
message: string
}
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,
remoteUploadStarted = false,
): Promise<void> {
const message = err instanceof Error ? err.message : String(err)
const { error } = await supabase
.from('arsredovisning_submissions')
.update({
status: remoteUploadStarted ? 'unknown' : '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, error } = await supabase
.from('company_settings')
.select('org_number')
.eq('company_id', 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)
}
export async function submitArsredovisning(
deps: ServiceDeps,
params: SubmitParams,
): Promise<SubmitResult> {
const { supabase, client, log } = deps
const orgnr = await getOrgnr(supabase, params.companyId)
const { data: versionRow, error: versionError } = await supabase
.from('annual_report_versions')
.select(
'id, status, content_hash, ixbrl_data, entry_point, taxonomy_version, validation_summary',
)
.eq('id', params.annualReportVersionId)
.eq('company_id', params.companyId)
.eq('fiscal_period_id', params.fiscalPeriodId)
.maybeSingle()
if (versionError || !versionRow) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_VERSION_NOT_FOUND',
'The annual report version does not exist for this company and period.',
)
}
if (versionRow.status !== 'signed' || !versionRow.ixbrl_data) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_VERSION_NOT_SIGNED',
'The annual report version must be signed and contain iXBRL before submission.',
{ annual_report_version_id: versionRow.id, status: versionRow.status },
)
}
const validationSnapshot = versionRow.validation_summary as {
digital_filing_eligible?: boolean
digital_issues?: unknown[]
}
if (validationSnapshot.digital_filing_eligible !== true) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_DIGITAL_INELIGIBLE',
'The immutable annual report version is not eligible for connected filing.',
{ issues: validationSnapshot.digital_issues ?? [] },
)
}
// 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. A pre-upload retry reuses its idempotency row.
const { data: activeRows, error: activeSubmissionError } = 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)
if (activeSubmissionError) {
throw new Error(`Kunde inte kontrollera tidigare inlämningar: ${activeSubmissionError.message}`)
}
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, error: acceptanceError } = await supabase
.from('bolagsverket_avtal_acceptances')
.select('id')
.eq('company_id', params.companyId)
.eq('user_id', params.userId)
.eq('avtalstext_andrad', token.avtalstextAndrad)
.maybeSingle()
if (acceptanceError) {
throw new Error(`Kunde inte kontrollera avtalsgodkännandet: ${acceptanceError.message}`)
}
const acceptedNow = params.acceptedAvtalstextAndrad === token.avtalstextAndrad
if (!acceptance && !acceptedNow) {
return {
outcome: 'avtal_required',
avtalstext: token.avtalstext,
avtalstextAndrad: token.avtalstextAndrad,
}
}
if (!acceptance && acceptedNow) {
const { error: acceptanceInsertError } = await supabase.from('bolagsverket_avtal_acceptances').insert({
company_id: params.companyId,
user_id: params.userId,
avtalstext_andrad: token.avtalstextAndrad,
})
if (acceptanceInsertError) {
throw new Error(`Kunde inte spara avtalsgodkännandet: ${acceptanceInsertError.message}`)
}
}
// 3. Render from the immutable, signed version. Signature evidence is
// stored separately from the content snapshot and overlaid here.
const input = structuredClone(versionRow.ixbrl_data) as IxbrlArsredovisningInput
const { data: signatureRows, error: signatureError } = await supabase
.from('arsredovisning_signature_requests')
.select('signer_name, role, signed_at, status, signing_method, evidence_reference')
.eq('company_id', params.companyId)
.eq('fiscal_period_id', params.fiscalPeriodId)
.eq('annual_report_version_id', params.annualReportVersionId)
if (signatureError) {
throw new Error(`Failed to load signature evidence: ${signatureError.message}`)
}
const signatures = (signatureRows ?? []) as Array<{
signer_name: string
role: string
signed_at: string | null
status: string
signing_method: string | null
evidence_reference: string | null
}>
if (
signatures.length === 0 ||
signatures.some(
(signature) =>
signature.status !== 'signed' ||
!signature.signed_at ||
!signature.signing_method ||
!signature.evidence_reference,
)
) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_SIGNATURE_EVIDENCE_INCOMPLETE',
'Every required signer must have version-bound signature evidence.',
{ annual_report_version_id: params.annualReportVersionId },
)
}
input.underskrifter.signers = signatures.map((signature) => {
const parts = signature.signer_name.trim().split(/\s+/)
return {
firstName: parts.length > 1 ? parts.slice(0, -1).join(' ') : parts[0],
lastName: parts.length > 1 ? parts.at(-1) ?? parts[0] : parts[0],
role: signature.role,
signedDate: signature.signed_at?.slice(0, 10) ?? null,
}
})
input.underskrifter.harVd = input.underskrifter.signers.some((signer) =>
/verkställande direktör|^vd$/i.test(signer.role ?? ''),
)
input.underskrifter.dateringsdatum = input.underskrifter.signers
.map((signer) => signer.signedDate)
.filter((date): date is string => date !== null)
.sort()
.at(-1) ?? null
const normalizeSignerText = (value: string) => value.trim().replace(/\s+/g, ' ').toLocaleLowerCase('sv')
if (
normalizeSignerText(input.faststallelseintyg.signerFirstName) !==
normalizeSignerText(params.undertecknare.fornamn) ||
normalizeSignerText(input.faststallelseintyg.signerLastName) !==
normalizeSignerText(params.undertecknare.efternamn) ||
normalizeSignerText(input.faststallelseintyg.signerRole) !==
normalizeSignerText(params.undertecknare.roll)
) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_CERTIFICATE_SIGNER_MISMATCH',
'The certificate signer must match the signer locked into the annual report version.',
{ annual_report_version_id: params.annualReportVersionId },
)
}
input.faststallelseintyg.genereratDatum = new Date().toISOString().slice(0, 10)
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),
})
}
// Validate the final bytes after the optional checksum was embedded. The
// recorded artifact hash must identify the same XHTML that is archived and
// uploaded, otherwise the validation evidence cannot prove what was filed.
const artifactHash = createHash('sha256').update(xhtml).digest('hex')
const { error: localValidationError } = await supabase
.from('annual_report_validation_runs')
.insert({
company_id: params.companyId,
fiscal_period_id: params.fiscalPeriodId,
version_id: params.annualReportVersionId,
user_id: params.userId,
validation_layer: 'local',
status: 'passed',
validator_version: 'accounted-preflight-1',
artifact_hash: artifactHash,
issues: preflight.issues,
})
if (localValidationError) {
throw new Error(`Kunde inte spara lokal validering: ${localValidationError.message}`)
}
const arelle = await validateIxbrlWithArelle(xhtml)
const { error: arelleValidationError } = await supabase
.from('annual_report_validation_runs')
.insert({
company_id: params.companyId,
fiscal_period_id: params.fiscalPeriodId,
version_id: params.annualReportVersionId,
user_id: params.userId,
validation_layer: 'arelle',
status: arelle.status,
validator_version: arelle.validator_version,
artifact_hash: artifactHash,
issues: arelle.issues,
})
if (arelleValidationError) {
throw new Error(`Kunde inte spara Arelle-validering: ${arelleValidationError.message}`)
}
if (arelle.status === 'unavailable') {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_ARELLE_UNAVAILABLE',
'Arelle validation is unavailable. Connected filing is blocked.',
{ issues: arelle.issues },
)
}
if (arelle.status === 'failed') {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_ARELLE_FAILED',
'Arelle found blocking taxonomy validation errors.',
{ issues: arelle.issues },
)
}
const fileBase64 = Buffer.from(xhtml, 'utf8').toString('base64')
const requestKey = createHash('sha256')
.update(
`${params.companyId}:${params.annualReportVersionId}:${client.environment}:arsredovisning_komplett`,
)
.digest('hex')
const { data: existingRequest, error: existingRequestError } = await supabase
.from('arsredovisning_submissions')
.select('id, status')
.eq('company_id', params.companyId)
.eq('environment', client.environment)
.eq('request_key', requestKey)
.maybeSingle()
if (existingRequestError) {
throw new Error(`Kunde inte kontrollera inlämningens idempotensnyckel: ${existingRequestError.message}`)
}
const submissionPayload = {
company_id: params.companyId,
user_id: params.userId,
fiscal_period_id: params.fiscalPeriodId,
annual_report_version_id: params.annualReportVersionId,
request_key: requestKey,
handling_typ: 'arsredovisning_komplett',
taxonomy_version: versionRow.taxonomy_version,
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: null,
avsandare_pnr_hash: null,
kontrollsumma,
error_message: null,
}
// 5. Persist before talking to Bolagsverket. A stopped kontrollera or a
// pre-upload error reuses the same idempotency row, which makes the
// explicit "continue despite warnings" action work without creating a
// second filing attempt for identical content.
let submissionRow: { id: string } | null = null
if (existingRequest) {
if (!['draft', 'kontrollerad', 'error'].includes(existingRequest.status)) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_SUBMISSION_EXISTS',
'This exact annual report version already has a submission attempt.',
{ submission_id: existingRequest.id, status: existingRequest.status },
)
}
const { data, error } = await supabase
.from('arsredovisning_submissions')
.update({ ...submissionPayload, status: 'draft' })
.eq('id', existingRequest.id)
.eq('company_id', params.companyId)
.select('id')
.single()
if (error || !data) {
throw new Error(`Kunde inte återuppta inlämningsförsöket: ${error?.message ?? 'okänt fel'}`)
}
submissionRow = data as { id: string }
} else {
const { data, error } = await supabase
.from('arsredovisning_submissions')
.insert({ ...submissionPayload, status: 'draft' })
.select('id')
.single()
if (error || !data) {
throw new Error(`Kunde inte spara inlämningsförsöket: ${error?.message ?? 'okänt fel'}`)
}
submissionRow = data as { id: string }
}
const submissionId = submissionRow.id
// Steps 6-8 talk to Bolagsverket with a persisted row in play. Failures
// before the external upload become error. Once upload starts, uncertain
// outcomes become unknown and retries are blocked until reconciled.
let svar: Awaited<ReturnType<BolagsverketClient['lamnaIn']>>
let utfall: KontrolleraUtfall[]
let remoteUploadStarted = false
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 ?? []
const { error: kontrollUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({ status: 'kontrollerad', kontrollera_utfall: utfall })
.eq('id', submissionId)
if (kontrollUpdateError) {
throw new Error(`Kunde inte spara Bolagsverkets kontrollresultat: ${kontrollUpdateError.message}`)
}
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.
const buffer = Buffer.from(xhtml, 'utf8')
let doc: Awaited<ReturnType<typeof uploadDocument>>
try {
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' },
)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
await supabase
.from('arsredovisning_submissions')
.update({ archive_status: 'failed', error_message: message.slice(0, 2_000) })
.eq('id', submissionId)
throw new Error(`Dokumentarkivering misslyckades: ${message}`)
}
const dokumentId = doc.id
const { error: archiveUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({ dokument_id: dokumentId, archive_status: 'stored' })
.eq('id', submissionId)
if (archiveUpdateError) {
throw new Error(`Kunde inte koppla det arkiverade dokumentet: ${archiveUpdateError.message}`)
}
// 8. Lämna in till eget utrymme.
const uploadStartedAt = new Date().toISOString()
const { error: sendingUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({ status: 'sending', upload_started_at: uploadStartedAt })
.eq('id', submissionId)
if (sendingUpdateError) {
throw new Error(`Kunde inte låsa inlämningsförsöket före uppladdning: ${sendingUpdateError.message}`)
}
remoteUploadStarted = true
try {
svar = await client.lamnaIn(token.token, {
undertecknare: params.undertecknare.pnr,
epostadresser: [params.undertecknare.epost],
kvittensepostadresser: params.kvittensEpost,
fileBase64,
typ: 'arsredovisning_komplett',
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
await supabase
.from('arsredovisning_submissions')
.update({ status: 'unknown', error_message: message.slice(0, 2_000) })
.eq('id', submissionId)
log.error('Bolagsverket upload outcome is unknown: retry blocked', {
submissionId,
companyId: params.companyId,
error: message,
})
return {
outcome: 'state_unknown',
submissionId,
idnummer: null,
url: null,
message:
'Bolagsverkets mottagande kunde inte bekräftas. Försök inte igen innan status har stämts av.',
}
}
const uploadedAt = new Date().toISOString()
const externalReceipt = {
idnummer: svar.handlingsinfo.idnummer,
sha256_checksumma: svar.handlingsinfo.sha256checksumma,
url: svar.url,
received_at: uploadedAt,
}
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,
external_receipt: externalReceipt,
uploaded_at: uploadedAt,
})
.eq('id', submissionId)
if (uploadUpdateError) {
log.error('failed to persist uploaded state after successful inlämning: marking unknown', {
submissionId,
idnummer: svar.handlingsinfo.idnummer,
dbError: uploadUpdateError.message,
})
const serviceClient = createServiceClientNoCookies()
const { error: recoveryError } = await serviceClient
.from('arsredovisning_submissions')
.update({
status: 'unknown',
idnummer: svar.handlingsinfo.idnummer,
sha256_checksumma: svar.handlingsinfo.sha256checksumma,
bolagsverket_url: svar.url,
dokument_id: dokumentId,
external_receipt: externalReceipt,
uploaded_at: uploadedAt,
error_message: `Uppladdningen lyckades men lokal status kunde inte bekräftas: ${uploadUpdateError.message}`.slice(0, 2_000),
})
.eq('id', submissionId)
if (recoveryError) {
log.error('could not persist unknown state after successful remote upload', {
submissionId,
dbError: recoveryError.message,
})
}
return {
outcome: 'state_unknown',
submissionId,
idnummer: svar.handlingsinfo.idnummer,
url: svar.url,
message:
'Bolagsverket tog emot dokumentet men Accounted kunde inte bekräfta lokal status. Skicka inte igen.',
}
}
const { error: versionStatusError } = await supabase
.from('annual_report_versions')
.update({ status: 'filed' })
.eq('id', params.annualReportVersionId)
.eq('company_id', params.companyId)
.eq('status', 'signed')
if (versionStatusError) {
log.error('failed to mark annual report version as filed', {
submissionId,
annualReportVersionId: params.annualReportVersionId,
dbError: versionStatusError.message,
})
}
} catch (err) {
await markSubmissionError(supabase, log, submissionId, err, remoteUploadStarted)
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, annual_report_version_id')
.eq('company_id', companyId)
const filtered =
idnummerList.length > 0
? base.in('idnummer', idnummerList)
: base.in('status', ['unknown', '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'
| 'annual_report_version_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') {
if (submission.annual_report_version_id) {
const { error: versionError } = await serviceClient
.from('annual_report_versions')
.update({ status: 'registered' })
.eq('id', submission.annual_report_version_id)
.eq('company_id', companyId)
.eq('status', 'filed')
if (versionError) {
log?.warn('registered submission did not update annual report version', {
submissionId: submission.id,
versionId: submission.annual_report_version_id,
dbError: versionError.message,
})
}
}
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 } }
}