* 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>
622 lines
23 KiB
TypeScript
622 lines
23 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, 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 } }
|
||
}
|