Add/bokslut (#718)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8e8b63a200
commit
db8983ba9e
@@ -12,7 +12,10 @@ import {
|
||||
deleteConsent,
|
||||
resolveConsent,
|
||||
fetchCompanyInfoDirect,
|
||||
ProviderTokenInvalidError,
|
||||
ConsentNotFoundError,
|
||||
} from './lib/provider-client'
|
||||
import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher'
|
||||
import { mapCompanyInfo } from './lib/entity-mapper'
|
||||
import { executeMigration } from './lib/migration-orchestrator'
|
||||
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
|
||||
@@ -22,7 +25,6 @@ import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
|
||||
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
|
||||
import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||||
import { FortnoxClient } from '@/lib/providers/fortnox/client'
|
||||
import type { ProviderName } from '@/lib/providers/types'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { classifyProviderError } from '@/lib/providers/with-provider-call'
|
||||
@@ -30,11 +32,6 @@ import { createLogger } from '@/lib/logger'
|
||||
|
||||
const moduleLog = createLogger('extensions/arcim-migration')
|
||||
|
||||
/** Fiscal years we support importing — older data is not needed */
|
||||
const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026])
|
||||
|
||||
const fortnoxClient = new FortnoxClient()
|
||||
|
||||
/**
|
||||
* Map known OAuth error codes from providers (Fortnox, Visma) to actionable
|
||||
* Swedish guidance. Falls back to the raw provider message so we never hide
|
||||
@@ -306,7 +303,12 @@ export const arcimMigrationExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { consentId, provider, apiToken, companyId } = await request.json() as {
|
||||
// The caller's tenant — NOT the provider-side company id below.
|
||||
const ownerCompanyId = ctx?.companyId ?? user.id
|
||||
|
||||
// `companyId` in the body is the PROVIDER-side company identifier
|
||||
// (BL User-Key / Briox account ID / Bokio company GUID).
|
||||
const { consentId, provider, apiToken, companyId: providerCompanyId } = await request.json() as {
|
||||
consentId: string
|
||||
provider: ArcimProvider
|
||||
apiToken: string
|
||||
@@ -326,17 +328,38 @@ export const arcimMigrationExtension: Extension = {
|
||||
})
|
||||
}
|
||||
|
||||
if ((provider === 'bokio' || provider === 'bjornlunden') && !companyId) {
|
||||
// Briox needs the account ID (the /token clientid param) alongside
|
||||
// the application token; Bokio/BL need their company GUID.
|
||||
if ((provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox') && !providerCompanyId) {
|
||||
return errorResponseFromCode('PROVIDER_COMPANY_ID_REQUIRED', moduleLog, {
|
||||
details: { provider },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await submitProviderToken(consentId, provider, apiToken || 'client_credentials', companyId)
|
||||
await submitProviderToken(
|
||||
consentId,
|
||||
provider,
|
||||
apiToken || 'client_credentials',
|
||||
providerCompanyId,
|
||||
ownerCompanyId,
|
||||
)
|
||||
return NextResponse.json({ success: true, consentId })
|
||||
} catch (error) {
|
||||
log.error('arcim submit-token failed', error as Error, { provider })
|
||||
// Consent missing or owned by another company — same 404 either way.
|
||||
if (error instanceof ConsentNotFoundError) {
|
||||
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, {
|
||||
details: { consentId },
|
||||
})
|
||||
}
|
||||
// Wrong credentials (provider actively rejected them) — tell the
|
||||
// user to re-check the pasted values instead of a generic 500.
|
||||
if (error instanceof ProviderTokenInvalidError) {
|
||||
return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, {
|
||||
details: { provider, reason: error.message },
|
||||
})
|
||||
}
|
||||
return errorResponseFromCode('PROVIDER_TOKEN_SUBMIT_FAILED', moduleLog, {
|
||||
details: { reason: error instanceof Error ? error.message : 'unknown' },
|
||||
})
|
||||
@@ -513,46 +536,27 @@ export const arcimMigrationExtension: Extension = {
|
||||
log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
|
||||
// Try to fetch SIE data (Fortnox has native SIE export)
|
||||
// Try to fetch SIE data (Fortnox and Briox serve SIE over the API)
|
||||
let sieAvailable = false
|
||||
let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null
|
||||
|
||||
if (provider === 'fortnox') {
|
||||
if (providerSupportsSie(provider)) {
|
||||
try {
|
||||
log.info(`Fetching SIE export from Fortnox for consent ${consentId}...`)
|
||||
// Fortnox SIE export endpoint: /3/sie/{type}?financialyear={id}
|
||||
// First get financial years
|
||||
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
|
||||
log.info(`Fetching SIE export from ${provider} for consent ${consentId}...`)
|
||||
// Fetch SIE type 4 for the most recent allowed year to get stats
|
||||
const { files, availableYears } = await fetchProviderSieFiles(
|
||||
provider,
|
||||
resolved.accessToken,
|
||||
'/financialyears'
|
||||
resolved.providerCompanyId,
|
||||
{ latestOnly: true },
|
||||
)
|
||||
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
|
||||
const allowedYears = years
|
||||
.map(fy => ({
|
||||
id: fy['Id'] as number,
|
||||
fromDate: fy['FromDate'] as string,
|
||||
toDate: fy['ToDate'] as string,
|
||||
}))
|
||||
.filter(fy => {
|
||||
const year = new Date(fy.fromDate).getFullYear()
|
||||
return ALLOWED_FISCAL_YEARS.has(year)
|
||||
})
|
||||
|
||||
if (allowedYears.length > 0) {
|
||||
// Fetch SIE type 4 for the most recent allowed year to get stats
|
||||
const latestYear = allowedYears[allowedYears.length - 1]
|
||||
const sieContent = await fortnoxClient.getText(
|
||||
resolved.accessToken,
|
||||
`/sie/4?financialyear=${latestYear.id}`
|
||||
)
|
||||
if (sieContent) {
|
||||
const parsed = parseSIEFile(sieContent)
|
||||
sieAvailable = true
|
||||
sieStats = {
|
||||
accountCount: parsed.accounts.length,
|
||||
transactionCount: parsed.vouchers.length,
|
||||
fiscalYears: allowedYears.map(fy => new Date(fy.fromDate).getFullYear()),
|
||||
}
|
||||
if (files.length > 0) {
|
||||
const parsed = parseSIEFile(files[files.length - 1].rawContent)
|
||||
sieAvailable = true
|
||||
sieStats = {
|
||||
accountCount: parsed.accounts.length,
|
||||
transactionCount: parsed.vouchers.length,
|
||||
fiscalYears: availableYears,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -621,54 +625,30 @@ export const arcimMigrationExtension: Extension = {
|
||||
const resolved = await resolveConsent(companyId, consentId)
|
||||
const provider = resolved.consent.provider as ProviderName
|
||||
|
||||
if (provider !== 'fortnox') {
|
||||
return errorResponseFromCode('PROVIDER_SIE_ONLY_FORTNOX', moduleLog, {
|
||||
if (!providerSupportsSie(provider)) {
|
||||
return errorResponseFromCode('PROVIDER_SIE_NOT_SUPPORTED', moduleLog, {
|
||||
details: { provider },
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch financial years from Fortnox
|
||||
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
|
||||
// Fetch SIE type 4 for each allowed fiscal year
|
||||
const { files: sieFiles, failedYears } = await fetchProviderSieFiles(
|
||||
provider,
|
||||
resolved.accessToken,
|
||||
'/financialyears'
|
||||
resolved.providerCompanyId,
|
||||
)
|
||||
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
|
||||
const allowedYears = years
|
||||
.map(fy => ({
|
||||
id: fy['Id'] as number,
|
||||
fromDate: fy['FromDate'] as string,
|
||||
toDate: fy['ToDate'] as string,
|
||||
}))
|
||||
.filter(fy => {
|
||||
const year = new Date(fy.fromDate).getFullYear()
|
||||
return ALLOWED_FISCAL_YEARS.has(year)
|
||||
})
|
||||
|
||||
if (allowedYears.length === 0) {
|
||||
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog)
|
||||
}
|
||||
|
||||
// Fetch SIE type 4 for each allowed year
|
||||
const sieFiles: { fiscalYear: number; rawContent: string }[] = []
|
||||
for (const fy of allowedYears) {
|
||||
try {
|
||||
const sieContent = await fortnoxClient.getText(
|
||||
resolved.accessToken,
|
||||
`/sie/4?financialyear=${fy.id}`
|
||||
)
|
||||
if (sieContent) {
|
||||
sieFiles.push({
|
||||
fiscalYear: new Date(fy.fromDate).getFullYear(),
|
||||
rawContent: sieContent,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.info(`Failed to fetch SIE for year ${fy.id}:`, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
if (sieFiles.length === 0) {
|
||||
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog)
|
||||
// The allowed window is rolling (current year and the two before
|
||||
// it) — interpolate the actual range instead of the static
|
||||
// registry message so the text never goes stale.
|
||||
const allowedYears = [...getAllowedFiscalYears()].sort((a, b) => a - b)
|
||||
const range = `${allowedYears[0]}–${allowedYears[allowedYears.length - 1]}`
|
||||
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog, {
|
||||
messageSv: `Inga räkenskapsår ${range} hittades hos leverantören.`,
|
||||
messageEn: `No fiscal years available for ${range}.`,
|
||||
...(failedYears.length > 0 ? { details: { failedYears } } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// Parse most recent file for preview/validation
|
||||
@@ -677,6 +657,10 @@ export const arcimMigrationExtension: Extension = {
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
if (!validation.valid) {
|
||||
log.warn(
|
||||
`arcim sie-data validation failed for ${provider} fiscal year ${sieFile.fiscalYear}: ` +
|
||||
`${validation.errors.length} error(s) — ${validation.errors.slice(0, 3).join(' | ')}`,
|
||||
)
|
||||
return NextResponse.json({
|
||||
error: 'validation',
|
||||
message: 'SIE file validation failed',
|
||||
@@ -725,7 +709,7 @@ export const arcimMigrationExtension: Extension = {
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
// Detect prior imports by *fiscal period overlap*, not file hash.
|
||||
// Fortnox embeds the export-time #GEN date in every SIE export so
|
||||
// Providers embed the export-time #GEN date in every SIE export so
|
||||
// the hash always changes between syncs; only the period stays
|
||||
// stable. A re-sync replaces the prior import for the same period.
|
||||
const fileStatuses: {
|
||||
@@ -794,6 +778,9 @@ export const arcimMigrationExtension: Extension = {
|
||||
allImported: false,
|
||||
newFileCount: fileStatuses.length - replacedFileCount,
|
||||
replacedFileCount,
|
||||
// Allowed years whose provider export failed — the wizard warns
|
||||
// the user before proceeding so an IB/UB gap cannot slip through.
|
||||
failedYears,
|
||||
basAccounts: BAS_REFERENCE,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -871,7 +858,7 @@ export const arcimMigrationExtension: Extension = {
|
||||
// Default ON: re-syncs keep account names current with the source
|
||||
// system (idempotent — equal names are a no-op in the rename pass).
|
||||
updateAccountNames: options.updateAccountNames ?? true,
|
||||
// Fortnox re-sync semantics: a prior completed import for the
|
||||
// Provider re-sync semantics: a prior completed import for the
|
||||
// same fiscal year is automatically replaced (its imported
|
||||
// entries are cancelled) so the user can pull updated data
|
||||
// without manual cleanup. Manual SIE upload keeps default
|
||||
@@ -946,15 +933,19 @@ export const arcimMigrationExtension: Extension = {
|
||||
}
|
||||
|
||||
// ── Guard: a completed SIE import is required before entity import ──
|
||||
// Every provider except Fortnox exposes ONLY entity data (customers,
|
||||
// suppliers, invoices) via API — never the general ledger. Fortnox pulls
|
||||
// the GL itself via SIE-over-API. Importing entities without the
|
||||
// SIE-derived ledger (kontoplan, ingående balanser, verifikationer)
|
||||
// would leave an incomplete bokföring under BFL: a subledger with no
|
||||
// chart of accounts and no opening balances, so every subsequent posting
|
||||
// and balance is wrong. The wizard surfaces this as an advisory banner,
|
||||
// but it must be enforced here so the rule cannot be bypassed by a direct
|
||||
// API call, a skipped wizard step, or a stale client.
|
||||
// Most providers expose ONLY entity data (customers, suppliers,
|
||||
// invoices) via API — never the general ledger. Fortnox pulls the GL
|
||||
// itself via SIE-over-API and is exempt. Briox and Björn Lundén also
|
||||
// serve SIE over the API, but the wizard runs /import-sie before
|
||||
// /migrate, so this guard stays satisfied — and keeps protecting
|
||||
// against a skipped SIE step. Importing entities without the
|
||||
// SIE-derived ledger (kontoplan,
|
||||
// ingående balanser, verifikationer) would leave an incomplete
|
||||
// bokföring under BFL: a subledger with no chart of accounts and no
|
||||
// opening balances, so every subsequent posting and balance is wrong.
|
||||
// The wizard surfaces this as an advisory banner, but it must be
|
||||
// enforced here so the rule cannot be bypassed by a direct API call,
|
||||
// a skipped wizard step, or a stale client.
|
||||
if (consent.provider !== 'fortnox') {
|
||||
const { count: completedSieImports } = await supabase
|
||||
.from('sie_imports')
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { mockBlGet } = vi.hoisted(() => ({ mockBlGet: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn(),
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/providers/bjornlunden/oauth', () => ({
|
||||
refreshBjornLundenToken: vi.fn().mockResolvedValue({
|
||||
access_token: 'bl-app-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Keep the real BjornLundenApiError (instanceof checks in provider-client)
|
||||
// but replace the client so the /details probe is controllable per test.
|
||||
vi.mock('@/lib/providers/bjornlunden/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/providers/bjornlunden/client')>()
|
||||
return {
|
||||
...actual,
|
||||
// Must be a `function` (not an arrow) so `new BjornLundenClient()` works.
|
||||
BjornLundenClient: vi.fn().mockImplementation(function mockClient() {
|
||||
return { get: mockBlGet }
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
|
||||
import {
|
||||
submitProviderToken,
|
||||
ProviderTokenInvalidError,
|
||||
ConsentNotFoundError,
|
||||
} from '../provider-client'
|
||||
|
||||
describe('submitProviderToken', () => {
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mock = createQueuedMockSupabase()
|
||||
vi.mocked(createServiceClient).mockReturnValue(mock.supabase as never)
|
||||
})
|
||||
|
||||
const tablesTouched = () => vi.mocked(mock.supabase.from).mock.calls.map((c) => c[0])
|
||||
|
||||
// ── Consent ownership (IDOR guard) ────────────────────────────────
|
||||
|
||||
it('throws ConsentNotFoundError and writes NOTHING when the consent belongs to another company', async () => {
|
||||
// Ownership check finds no row for (consentId, ownerCompanyId) — the same
|
||||
// result whether the consent does not exist or belongs to another tenant.
|
||||
mock.enqueue({ data: [] })
|
||||
|
||||
await expect(
|
||||
submitProviderToken('consent-other-tenant', 'bokio', 'tok', 'bokio-guid', 'company-A'),
|
||||
).rejects.toBeInstanceOf(ConsentNotFoundError)
|
||||
|
||||
// Only the ownership read happened — no token upsert, no consent update.
|
||||
expect(tablesTouched()).toEqual(['provider_consents'])
|
||||
})
|
||||
|
||||
it('stores tokens when the consent belongs to the caller company', async () => {
|
||||
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
||||
mock.enqueue({ data: null }) // token upsert
|
||||
|
||||
const result = await submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A')
|
||||
|
||||
expect(result).toEqual({ success: true, consentId: 'consent-1' })
|
||||
expect(tablesTouched()).toEqual(['provider_consents', 'provider_consent_tokens'])
|
||||
})
|
||||
|
||||
// ── BL /details probe error classification ────────────────────────
|
||||
|
||||
it('does NOT map a 429 from the BL probe to ProviderTokenInvalidError', async () => {
|
||||
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
||||
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 429', 429))
|
||||
|
||||
const err: unknown = await submitProviderToken(
|
||||
'consent-1',
|
||||
'bjornlunden',
|
||||
'client_credentials',
|
||||
'user-key-guid',
|
||||
'company-A',
|
||||
).catch((e: unknown) => e)
|
||||
|
||||
expect(err).toBeInstanceOf(BjornLundenApiError)
|
||||
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
|
||||
// The transient failure must not store the unverified key either.
|
||||
expect(tablesTouched()).not.toContain('provider_consent_tokens')
|
||||
})
|
||||
|
||||
it('does NOT map gateway-style 5xx (503) from the BL probe to ProviderTokenInvalidError', async () => {
|
||||
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
||||
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 503', 503))
|
||||
|
||||
const err: unknown = await submitProviderToken(
|
||||
'consent-1',
|
||||
'bjornlunden',
|
||||
'client_credentials',
|
||||
'user-key-guid',
|
||||
'company-A',
|
||||
).catch((e: unknown) => e)
|
||||
|
||||
expect(err).toBeInstanceOf(BjornLundenApiError)
|
||||
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
|
||||
})
|
||||
|
||||
it('maps 500 from the BL probe to invalid credentials (sandbox-verified bad-key signal) and disables probe retries', async () => {
|
||||
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
||||
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 500', 500))
|
||||
|
||||
await expect(
|
||||
submitProviderToken('consent-1', 'bjornlunden', 'client_credentials', 'user-key-guid', 'company-A'),
|
||||
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
|
||||
|
||||
// The probe must fail fast: a typo'd key answers 500, which the client's
|
||||
// retry policy treats as retryable — retry is disabled per call.
|
||||
expect(mockBlGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockBlGet).toHaveBeenCalledWith('bl-app-token', 'user-key-guid', '/details', {
|
||||
retry: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps 404 from the BL probe to invalid credentials', async () => {
|
||||
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
||||
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 404', 404))
|
||||
|
||||
await expect(
|
||||
submitProviderToken('consent-1', 'bjornlunden', 'client_credentials', 'user-key-guid', 'company-A'),
|
||||
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
|
||||
})
|
||||
|
||||
it('stores BL tokens (and labels the consent) when the probe succeeds', async () => {
|
||||
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
||||
mock.enqueue({ data: null }) // consent company_name update
|
||||
mock.enqueue({ data: null }) // token upsert
|
||||
mockBlGet.mockResolvedValueOnce({ name: 'Testbolaget AB' })
|
||||
|
||||
const result = await submitProviderToken(
|
||||
'consent-1',
|
||||
'bjornlunden',
|
||||
'client_credentials',
|
||||
'user-key-guid',
|
||||
'company-A',
|
||||
)
|
||||
|
||||
expect(result).toEqual({ success: true, consentId: 'consent-1' })
|
||||
expect(tablesTouched()).toEqual([
|
||||
'provider_consents',
|
||||
'provider_consents',
|
||||
'provider_consent_tokens',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
providerSupportsSie,
|
||||
fetchProviderSieFiles,
|
||||
getAllowedFiscalYears,
|
||||
} from '../sie-fetcher'
|
||||
|
||||
// The allowed window is rolling (current year and the two before it) — derive
|
||||
// fixture years from the clock so these tests never go stale at new year.
|
||||
const CY = new Date().getFullYear()
|
||||
|
||||
/**
|
||||
* Routes mocked fetch responses by URL substring. The fetcher goes through
|
||||
* the real provider clients (rate limiter in in-memory mode, retry on), so
|
||||
* these tests exercise the full request path including auth headers and the
|
||||
* encoding-detection decode of binary SIE payloads.
|
||||
*/
|
||||
function routeFetch(
|
||||
fetchSpy: ReturnType<typeof vi.spyOn>,
|
||||
routes: { match: string; respond: () => Response }[],
|
||||
) {
|
||||
fetchSpy.mockImplementation(((input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
const route = routes.find((r) => url.includes(r.match))
|
||||
if (!route) {
|
||||
return Promise.resolve(new Response(`no mock for ${url}`, { status: 404 }))
|
||||
}
|
||||
return Promise.resolve(route.respond())
|
||||
}) as typeof fetch)
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Build a CP437-encoded SIE snippet containing "Företagskonto" (ö = 0x94). */
|
||||
function cp437SieBytes(label: string): Uint8Array {
|
||||
const prefix = `#FLAGGA 0\n#KONTO 1930 "F`
|
||||
const suffix = `retagskonto ${label}"\n`
|
||||
const bytes: number[] = []
|
||||
for (const c of prefix) bytes.push(c.charCodeAt(0))
|
||||
bytes.push(0x94) // 'ö' in CP437
|
||||
for (const c of suffix) bytes.push(c.charCodeAt(0))
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function octetResponse(bytes: Uint8Array): Response {
|
||||
return new Response(bytes.buffer as ArrayBuffer, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('getAllowedFiscalYears', () => {
|
||||
it('is a rolling three-year window ending at the current year', () => {
|
||||
const years = getAllowedFiscalYears(new Date('2031-06-15'))
|
||||
expect([...years].sort((a, b) => a - b)).toEqual([2029, 2030, 2031])
|
||||
})
|
||||
|
||||
it('defaults to the real current year', () => {
|
||||
const years = getAllowedFiscalYears()
|
||||
expect(years.has(CY)).toBe(true)
|
||||
expect(years.has(CY - 2)).toBe(true)
|
||||
expect(years.has(CY - 3)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerSupportsSie', () => {
|
||||
it('is true for providers with SIE-over-API, false otherwise', () => {
|
||||
expect(providerSupportsSie('fortnox')).toBe(true)
|
||||
expect(providerSupportsSie('briox')).toBe(true)
|
||||
expect(providerSupportsSie('bjornlunden')).toBe(true)
|
||||
expect(providerSupportsSie('visma')).toBe(false)
|
||||
expect(providerSupportsSie('bokio')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchProviderSieFiles', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects providers without SIE support', async () => {
|
||||
await expect(fetchProviderSieFiles('visma', 't', undefined)).rejects.toThrow(
|
||||
/does not support SIE over API/,
|
||||
)
|
||||
})
|
||||
|
||||
describe('briox', () => {
|
||||
const yearRoutes = {
|
||||
match: '/financialyear',
|
||||
respond: () =>
|
||||
jsonResponse({
|
||||
data: {
|
||||
financialyears: [
|
||||
{ id: '8', fromdate: `${CY - 3}-01-01`, todate: `${CY - 3}-12-31` },
|
||||
{ id: '9', fromdate: `${CY - 2}-01-01`, todate: `${CY - 2}-12-31` },
|
||||
{ id: '10', fromdate: `${CY - 1}-01-01`, todate: `${CY - 1}-12-31` },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
it('fetches one decoded SIE file per allowed fiscal year', async () => {
|
||||
routeFetch(fetchSpy, [
|
||||
yearRoutes,
|
||||
{ match: '/sie/9/4', respond: () => octetResponse(cp437SieBytes(String(CY - 2))) },
|
||||
{ match: '/sie/10/4', respond: () => octetResponse(cp437SieBytes(String(CY - 1))) },
|
||||
])
|
||||
|
||||
const result = await fetchProviderSieFiles('briox', 'token', undefined)
|
||||
|
||||
// CY-3 is outside the allowed window and must not be fetched
|
||||
expect(getAllowedFiscalYears().has(CY - 3)).toBe(false)
|
||||
expect(result.availableYears).toEqual([CY - 2, CY - 1])
|
||||
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 2, CY - 1])
|
||||
expect(result.failedYears).toEqual([])
|
||||
// CP437 bytes decoded into proper Swedish characters
|
||||
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 2}`)
|
||||
expect(result.files[1].rawContent).toContain(`Företagskonto ${CY - 1}`)
|
||||
expect(fetchSpy.mock.calls.map((c: unknown[]) => String(c[0]))).not.toContainEqual(
|
||||
expect.stringContaining('/sie/8/4'),
|
||||
)
|
||||
})
|
||||
|
||||
it('latestOnly fetches just the most recent year but reports all years', async () => {
|
||||
routeFetch(fetchSpy, [
|
||||
yearRoutes,
|
||||
{ match: '/sie/10/4', respond: () => octetResponse(cp437SieBytes(String(CY - 1))) },
|
||||
])
|
||||
|
||||
const result = await fetchProviderSieFiles('briox', 'token', undefined, { latestOnly: true })
|
||||
|
||||
expect(result.files).toHaveLength(1)
|
||||
expect(result.files[0].fiscalYear).toBe(CY - 1)
|
||||
expect(result.availableYears).toEqual([CY - 2, CY - 1])
|
||||
const sieCalls = fetchSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes('/sie/'))
|
||||
expect(sieCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports a year whose export fails in failedYears and keeps the rest', async () => {
|
||||
routeFetch(fetchSpy, [
|
||||
yearRoutes,
|
||||
// 404 is non-retryable, so the bad year fails fast
|
||||
{ match: '/sie/9/4', respond: () => new Response('gone', { status: 404 }) },
|
||||
{ match: '/sie/10/4', respond: () => octetResponse(cp437SieBytes(String(CY - 1))) },
|
||||
])
|
||||
|
||||
const result = await fetchProviderSieFiles('briox', 'token', undefined)
|
||||
|
||||
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 1])
|
||||
expect(result.availableYears).toEqual([CY - 2, CY - 1])
|
||||
// The failed year must NOT be dropped silently — importing only the
|
||||
// surviving years would break IB/UB continuity unnoticed.
|
||||
expect(result.failedYears).toEqual([
|
||||
{ year: CY - 2, error: expect.stringContaining('404') },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fortnox', () => {
|
||||
const yearRoutes = {
|
||||
match: '/financialyears',
|
||||
respond: () =>
|
||||
jsonResponse({
|
||||
FinancialYears: [
|
||||
{ Id: 5, FromDate: `${CY - 2}-01-01`, ToDate: `${CY - 2}-12-31` },
|
||||
{ Id: 6, FromDate: `${CY - 1}-01-01`, ToDate: `${CY - 1}-12-31` },
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
it('fetches SIE via /sie/4?financialyear={id} and decodes UTF-8 bodies', async () => {
|
||||
routeFetch(fetchSpy, [
|
||||
yearRoutes,
|
||||
{
|
||||
match: '/sie/4?financialyear=',
|
||||
respond: () => new Response('#FLAGGA 0\n#KONTO 1930 "Företagskonto"\n', { status: 200 }),
|
||||
},
|
||||
])
|
||||
|
||||
const result = await fetchProviderSieFiles('fortnox', 'token', undefined)
|
||||
|
||||
expect(result.availableYears).toEqual([CY - 2, CY - 1])
|
||||
expect(result.files).toHaveLength(2)
|
||||
expect(result.failedYears).toEqual([])
|
||||
expect(result.files[0].rawContent).toContain('#KONTO 1930 "Företagskonto"')
|
||||
const sieUrls = fetchSpy.mock.calls
|
||||
.map((c: unknown[]) => String(c[0]))
|
||||
.filter((u: string) => u.includes('/sie/4'))
|
||||
expect(sieUrls[0]).toContain('financialyear=5')
|
||||
expect(sieUrls[1]).toContain('financialyear=6')
|
||||
})
|
||||
|
||||
it('decodes CP437 bytes from the Fortnox SIE endpoint (no blind UTF-8 text())', async () => {
|
||||
// Some Fortnox endpoint variants serve the SIE body in CP437 (the SIE
|
||||
// spec encoding). A blind response.text() would turn å/ä/ö into U+FFFD
|
||||
// irrecoverably — the byte-level path must detect-decode like Briox/BL.
|
||||
routeFetch(fetchSpy, [
|
||||
yearRoutes,
|
||||
{
|
||||
match: '/sie/4?financialyear=',
|
||||
respond: () => octetResponse(cp437SieBytes(String(CY - 1))),
|
||||
},
|
||||
])
|
||||
|
||||
const result = await fetchProviderSieFiles('fortnox', 'token', undefined)
|
||||
|
||||
expect(result.files).toHaveLength(2)
|
||||
expect(result.files[1].rawContent).toContain(`Företagskonto ${CY - 1}`)
|
||||
expect(result.files[1].rawContent).not.toContain('�')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bjornlunden', () => {
|
||||
it('requires the company User-Key (providerCompanyId)', async () => {
|
||||
await expect(fetchProviderSieFiles('bjornlunden', 't', undefined)).rejects.toThrow(/User-Key/)
|
||||
})
|
||||
|
||||
it('fetches date-ranged exports with the User-Key header', async () => {
|
||||
routeFetch(fetchSpy, [
|
||||
{
|
||||
match: `/sie/export/${CY - 1}-01-01/${CY - 1}-12-31`,
|
||||
respond: () => octetResponse(cp437SieBytes(String(CY - 1))),
|
||||
},
|
||||
{
|
||||
match: '/financialyear',
|
||||
respond: () =>
|
||||
jsonResponse([
|
||||
{
|
||||
entityId: 1,
|
||||
id: `${CY - 1}01`,
|
||||
fromDate: `${CY - 1}-01-01`,
|
||||
toDate: `${CY - 1}-12-31`,
|
||||
open: true,
|
||||
},
|
||||
]),
|
||||
},
|
||||
])
|
||||
|
||||
const result = await fetchProviderSieFiles('bjornlunden', 'token', 'user-key-guid')
|
||||
|
||||
expect(result.files).toHaveLength(1)
|
||||
expect(result.files[0].fiscalYear).toBe(CY - 1)
|
||||
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 1}`)
|
||||
|
||||
const exportCall = fetchSpy.mock.calls.find((c: unknown[]) => String(c[0]).includes('/sie/export/'))
|
||||
expect(exportCall).toBeDefined()
|
||||
expect((exportCall![1] as RequestInit).headers).toMatchObject({
|
||||
'User-Key': 'user-key-guid',
|
||||
Authorization: 'Bearer token',
|
||||
})
|
||||
})
|
||||
|
||||
it('also decodes the swagger-declared base64 body shape', async () => {
|
||||
// BL's swagger declares the export as a base64 string; the live API
|
||||
// sends raw bytes. Guard the contingency path: a JSON-quoted base64
|
||||
// payload of CP437 SIE bytes must decode to the same content.
|
||||
const sieBytes = cp437SieBytes(String(CY - 1))
|
||||
const base64 = Buffer.from(sieBytes).toString('base64')
|
||||
routeFetch(fetchSpy, [
|
||||
{
|
||||
match: `/sie/export/${CY - 1}-01-01/${CY - 1}-12-31`,
|
||||
respond: () =>
|
||||
new Response(JSON.stringify(base64), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: '/financialyear',
|
||||
respond: () =>
|
||||
jsonResponse([
|
||||
{
|
||||
entityId: 1,
|
||||
id: `${CY - 1}01`,
|
||||
fromDate: `${CY - 1}-01-01`,
|
||||
toDate: `${CY - 1}-12-31`,
|
||||
open: true,
|
||||
},
|
||||
]),
|
||||
},
|
||||
])
|
||||
|
||||
const result = await fetchProviderSieFiles('bjornlunden', 'token', 'user-key-guid')
|
||||
|
||||
expect(result.files).toHaveLength(1)
|
||||
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 1}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,40 @@ import { buildFortnoxAuthUrl } from '@/lib/providers/fortnox/oauth'
|
||||
import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth'
|
||||
import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth'
|
||||
import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
|
||||
import { BjornLundenClient, BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
|
||||
import { exchangeBrioxCode } from '@/lib/providers/briox/oauth'
|
||||
import { BrioxApiError } from '@/lib/providers/briox/client'
|
||||
import type { ConsentRecord, OtcResponse } from '../types'
|
||||
|
||||
// Singleton (holds the rate limiter) — used to validate BL User-Keys at submit
|
||||
const bjornLundenClient = new BjornLundenClient()
|
||||
|
||||
/**
|
||||
* Thrown by submitProviderToken when the provider actively rejects the
|
||||
* submitted credentials (as opposed to a transient failure). The route maps
|
||||
* this to the PROVIDER_TOKEN_INVALID structured error so the wizard can tell
|
||||
* the user to re-check what they pasted.
|
||||
*/
|
||||
export class ProviderTokenInvalidError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ProviderTokenInvalidError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a consent does not exist OR does not belong to the caller's
|
||||
* company. The two cases are deliberately indistinguishable so a caller
|
||||
* cannot probe whether other tenants' consent IDs exist. The route maps this
|
||||
* to PROVIDER_CONSENT_NOT_FOUND (404).
|
||||
*/
|
||||
export class ConsentNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('Consent not found')
|
||||
this.name = 'ConsentNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export data fetching functions from the provider layer
|
||||
export { resolveConsent } from '@/lib/providers/resolve-consent'
|
||||
export {
|
||||
@@ -226,18 +258,103 @@ export async function submitProviderToken(
|
||||
consentId: string,
|
||||
provider: ProviderName,
|
||||
apiToken: string,
|
||||
companyId?: string,
|
||||
providerCompanyId: string | undefined,
|
||||
ownerCompanyId: string,
|
||||
): Promise<{ success: boolean; consentId: string }> {
|
||||
const supabase = createServiceClient()
|
||||
|
||||
// Ownership guard (IDOR): the consent must belong to the caller's company
|
||||
// before ANY write — this module runs on the service client, which bypasses
|
||||
// RLS, so this check is the only tenant boundary. Mirrors resolveConsent()
|
||||
// in lib/providers/resolve-consent.ts. A consent that exists but belongs to
|
||||
// another company throws the same not-found error as a nonexistent one.
|
||||
const { data: ownedRows } = await supabase
|
||||
.from('provider_consents')
|
||||
.select('id')
|
||||
.eq('id', consentId)
|
||||
.eq('company_id', ownerCompanyId)
|
||||
.limit(1)
|
||||
|
||||
if (!ownedRows || ownedRows.length === 0) {
|
||||
throw new ConsentNotFoundError()
|
||||
}
|
||||
|
||||
let accessToken = apiToken
|
||||
let refreshToken: string | null = null
|
||||
let tokenExpiresAt: string | null = null
|
||||
|
||||
// BL uses client credentials — get a real token
|
||||
// BL uses app-level client credentials — get a real token, then prove the
|
||||
// pasted User-Key actually opens a company before storing anything.
|
||||
if (provider === 'bjornlunden') {
|
||||
if (!providerCompanyId) {
|
||||
throw new ProviderTokenInvalidError('Björn Lundén requires a company key (User-Key)')
|
||||
}
|
||||
const tokenResponse = await refreshBjornLundenToken()
|
||||
accessToken = tokenResponse.access_token
|
||||
tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
|
||||
|
||||
// Sandbox-verified: an unknown User-Key makes /details answer 500 (BL
|
||||
// fails to bind the company database), not 401/403. Without this probe a
|
||||
// typo'd GUID is stored silently and only surfaces as a confusing failure
|
||||
// at preview. retry:false makes a bad key fail fast instead of burning
|
||||
// the client's full retry budget on the "retryable" 500.
|
||||
try {
|
||||
const details = await bjornLundenClient.get<Record<string, unknown>>(
|
||||
accessToken,
|
||||
providerCompanyId,
|
||||
'/details',
|
||||
{ retry: false },
|
||||
)
|
||||
// Bonus from the probe: label the consent with the company name so the
|
||||
// wizard's connection list shows which BL company was linked.
|
||||
const blCompanyName = typeof details?.['name'] === 'string' ? (details['name'] as string).trim() : ''
|
||||
if (blCompanyName) {
|
||||
await supabase
|
||||
.from('provider_consents')
|
||||
.update({ company_name: blCompanyName })
|
||||
.eq('id', consentId)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BjornLundenApiError) {
|
||||
// 429 and gateway-style 5xx (502/503/504) are transient provider
|
||||
// failures, not a verdict on the key — rethrow so the route reports a
|
||||
// generic submit failure instead of "your key is wrong". 500 stays
|
||||
// mapped to invalid credentials: per the sandbox finding above, 500
|
||||
// IS the bad-key signal at BL. Tradeoff: a genuine BL 500 outage also
|
||||
// reads as a rejected key.
|
||||
if (error.statusCode === 429 || error.statusCode >= 501) {
|
||||
throw error
|
||||
}
|
||||
throw new ProviderTokenInvalidError(
|
||||
`Björn Lundén rejected the company key (HTTP ${error.statusCode})`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Briox: the user pastes an application token + account ID, which we
|
||||
// exchange ONCE for an access/refresh token pair. Storing the raw
|
||||
// application token would fail on every data call.
|
||||
if (provider === 'briox') {
|
||||
if (!providerCompanyId) {
|
||||
throw new ProviderTokenInvalidError('Briox requires an account ID (clientid)')
|
||||
}
|
||||
try {
|
||||
const tokenResponse = await exchangeBrioxCode(providerCompanyId, apiToken)
|
||||
accessToken = tokenResponse.access_token
|
||||
refreshToken = tokenResponse.refresh_token
|
||||
tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
|
||||
} catch (error) {
|
||||
// /token answers 400/401/404 for a wrong account ID or application
|
||||
// token — surface as invalid credentials, not a server error.
|
||||
if (error instanceof BrioxApiError && error.statusCode < 500 && error.statusCode !== 429) {
|
||||
throw new ProviderTokenInvalidError(
|
||||
`Briox rejected the credentials (HTTP ${error.statusCode})`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Store tokens — consent stays at status 0 until migration/SIE import completes
|
||||
@@ -247,9 +364,9 @@ export async function submitProviderToken(
|
||||
consent_id: consentId,
|
||||
provider,
|
||||
access_token: accessToken,
|
||||
refresh_token: null,
|
||||
refresh_token: refreshToken,
|
||||
token_expires_at: tokenExpiresAt,
|
||||
provider_company_id: companyId,
|
||||
provider_company_id: providerCompanyId,
|
||||
})
|
||||
|
||||
return { success: true, consentId }
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Per-provider SIE-over-API fetcher.
|
||||
*
|
||||
* Providers that expose their general ledger as a SIE export over the API get
|
||||
* the "Fortnox-grade" migration experience: the wizard pulls the GL itself
|
||||
* instead of requiring a manual SIE upload. Everything downstream (parsing,
|
||||
* validation, account mapping, replace-mode import) is provider-agnostic —
|
||||
* this module's only job is to produce raw SIE file contents per fiscal year.
|
||||
*
|
||||
* Shared by /preview (latest year, for stats) and /sie-data (all years).
|
||||
*/
|
||||
|
||||
import { FortnoxClient } from '@/lib/providers/fortnox/client'
|
||||
import { BrioxClient } from '@/lib/providers/briox/client'
|
||||
import { BjornLundenClient } from '@/lib/providers/bjornlunden/client'
|
||||
import type { ProviderName } from '@/lib/providers/types'
|
||||
import { detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('extensions/arcim-migration/sie-fetcher')
|
||||
|
||||
/**
|
||||
* Fiscal years we support importing — the current year and the two before it.
|
||||
* Derived at call time (not a module constant) so the window rolls forward
|
||||
* automatically at new year without a code change.
|
||||
*/
|
||||
export function getAllowedFiscalYears(now: Date = new Date()): Set<number> {
|
||||
const currentYear = now.getFullYear()
|
||||
return new Set([currentYear - 2, currentYear - 1, currentYear])
|
||||
}
|
||||
|
||||
export interface ProviderSieFile {
|
||||
fiscalYear: number
|
||||
rawContent: string
|
||||
}
|
||||
|
||||
export interface ProviderSieFetchResult {
|
||||
files: ProviderSieFile[]
|
||||
/**
|
||||
* Every fiscal year available at the provider within the allowed window —
|
||||
* also populated when latestOnly fetched just one file, so /preview can show
|
||||
* the full year list without a second round-trip.
|
||||
*/
|
||||
availableYears: number[]
|
||||
/**
|
||||
* Allowed years whose export failed (or came back empty). Callers MUST
|
||||
* surface these to the user: silently importing e.g. 2024+2026 without 2025
|
||||
* breaks IB/UB continuity between the years without anyone noticing.
|
||||
*/
|
||||
failedYears: { year: number; error: string }[]
|
||||
}
|
||||
|
||||
// Singleton clients (they hold rate limiters)
|
||||
const fortnoxClient = new FortnoxClient()
|
||||
const brioxClient = new BrioxClient()
|
||||
const bjornLundenClient = new BjornLundenClient()
|
||||
|
||||
/** True when the provider's API can serve the GL as SIE (no manual upload). */
|
||||
export function providerSupportsSie(provider: ProviderName): boolean {
|
||||
return provider === 'fortnox' || provider === 'briox' || provider === 'bjornlunden'
|
||||
}
|
||||
|
||||
interface FiscalYearRef {
|
||||
id: string | number
|
||||
year: number
|
||||
/** Period bounds — required by BL, whose export URL is date-ranged. */
|
||||
fromDate?: string
|
||||
toDate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch SIE type-4 exports from the provider, one file per allowed fiscal
|
||||
* year (oldest first). Years whose export fails do not block the rest of the
|
||||
* migration, but they are reported in `failedYears` so the caller can warn
|
||||
* the user before importing a gap (IB/UB continuity).
|
||||
*/
|
||||
export async function fetchProviderSieFiles(
|
||||
provider: ProviderName,
|
||||
accessToken: string,
|
||||
providerCompanyId: string | undefined,
|
||||
opts?: { latestOnly?: boolean },
|
||||
): Promise<ProviderSieFetchResult> {
|
||||
const fetcher = getSieFetcher(provider, providerCompanyId)
|
||||
if (!fetcher) {
|
||||
throw new Error(`Provider ${provider} does not support SIE over API`)
|
||||
}
|
||||
|
||||
const allowedFiscalYears = getAllowedFiscalYears()
|
||||
const allYears = await fetcher.listYears(accessToken)
|
||||
const allowedYears = allYears
|
||||
.filter((fy) => allowedFiscalYears.has(fy.year))
|
||||
.sort((a, b) => a.year - b.year)
|
||||
|
||||
const availableYears = allowedYears.map((fy) => fy.year)
|
||||
const toFetch = opts?.latestOnly ? allowedYears.slice(-1) : allowedYears
|
||||
|
||||
const files: ProviderSieFile[] = []
|
||||
const failedYears: { year: number; error: string }[] = []
|
||||
for (const fy of toFetch) {
|
||||
try {
|
||||
const rawContent = await fetcher.fetchSie(accessToken, fy)
|
||||
if (rawContent) {
|
||||
files.push({ fiscalYear: fy.year, rawContent })
|
||||
} else {
|
||||
failedYears.push({ year: fy.year, error: 'Provider returned an empty SIE export' })
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
log.warn(`Failed to fetch SIE for ${provider} fiscal year ${fy.year} (id ${fy.id})`, {
|
||||
reason,
|
||||
})
|
||||
failedYears.push({ year: fy.year, error: reason })
|
||||
}
|
||||
}
|
||||
|
||||
return { files, availableYears, failedYears }
|
||||
}
|
||||
|
||||
interface SieFetcher {
|
||||
listYears(accessToken: string): Promise<FiscalYearRef[]>
|
||||
fetchSie(accessToken: string, fy: FiscalYearRef): Promise<string>
|
||||
}
|
||||
|
||||
function getSieFetcher(
|
||||
provider: ProviderName,
|
||||
providerCompanyId: string | undefined,
|
||||
): SieFetcher | null {
|
||||
if (provider === 'fortnox') {
|
||||
return {
|
||||
async listYears(accessToken) {
|
||||
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
|
||||
accessToken,
|
||||
'/financialyears',
|
||||
)
|
||||
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
|
||||
return years.map((fy) => ({
|
||||
id: fy['Id'] as number,
|
||||
year: new Date(fy['FromDate'] as string).getFullYear(),
|
||||
}))
|
||||
},
|
||||
async fetchSie(accessToken, fy) {
|
||||
// Fortnox normally serves the SIE body as UTF-8, but endpoint variants
|
||||
// have been seen answering CP437 (the SIE spec encoding) — a blind
|
||||
// response.text() would turn å/ä/ö into U+FFFD irrecoverably. Fetch
|
||||
// raw bytes and detect-decode like the Briox/BL paths.
|
||||
const buffer = await fortnoxClient.getBytes(accessToken, `/sie/4?financialyear=${fy.id}`)
|
||||
return decodeBuffer(buffer, detectEncoding(buffer))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'briox') {
|
||||
return {
|
||||
async listYears(accessToken) {
|
||||
const years = await brioxClient.listFinancialYears(accessToken)
|
||||
return years.map((fy) => ({
|
||||
id: fy.id,
|
||||
year: new Date(fy.fromdate).getFullYear(),
|
||||
}))
|
||||
},
|
||||
async fetchSie(accessToken, fy) {
|
||||
// Briox serves SIE as an octet-stream whose encoding varies
|
||||
// (CP437/Windows-1252/UTF-8) — fetch bytes and detect-decode.
|
||||
const buffer = await brioxClient.getBytes(accessToken, `/sie/${fy.id}/4`)
|
||||
return decodeBuffer(buffer, detectEncoding(buffer))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'bjornlunden') {
|
||||
// providerCompanyId carries the per-company User-Key header value.
|
||||
const userKey = providerCompanyId
|
||||
if (!userKey) {
|
||||
throw new Error('Björn Lundén requires a company User-Key — reconnect the provider')
|
||||
}
|
||||
return {
|
||||
async listYears(accessToken) {
|
||||
const years = await bjornLundenClient.listFinancialYears(accessToken, userKey)
|
||||
return years.map((fy) => ({
|
||||
id: fy.id ?? fy.entityId,
|
||||
year: new Date(fy.fromDate).getFullYear(),
|
||||
fromDate: fy.fromDate,
|
||||
toDate: fy.toDate,
|
||||
}))
|
||||
},
|
||||
async fetchSie(accessToken, fy) {
|
||||
// BL's export is date-ranged rather than year-id based. Sandbox-
|
||||
// verified: the body is RAW SIE bytes (CP437, Content-Type
|
||||
// text/vnd.sie-gruppen.si) even though the swagger declares a base64
|
||||
// string — decodeSieBytes handles both shapes.
|
||||
const buffer = await bjornLundenClient.getBytes(
|
||||
accessToken,
|
||||
userKey,
|
||||
`/sie/export/${fy.fromDate}/${fy.toDate}`,
|
||||
)
|
||||
return decodeSieBytes(buffer)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a SIE payload that may arrive either as raw SIE bytes or as a
|
||||
* base64 string (optionally JSON-quoted). BL's swagger declares base64 but
|
||||
* the live API sends raw CP437 — handle both so a future API change doesn't
|
||||
* silently break the import.
|
||||
*/
|
||||
function decodeSieBytes(buffer: ArrayBuffer): string {
|
||||
const direct = decodeBuffer(buffer, detectEncoding(buffer))
|
||||
if (looksLikeSie(direct)) return direct
|
||||
|
||||
const candidate = direct.trim().replace(/^"|"$/g, '')
|
||||
if (/^[A-Za-z0-9+/=\s]+$/.test(candidate)) {
|
||||
try {
|
||||
const bytes = Buffer.from(candidate, 'base64')
|
||||
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
const decoded = decodeBuffer(ab, detectEncoding(ab))
|
||||
if (looksLikeSie(decoded)) return decoded
|
||||
} catch {
|
||||
// fall through to returning the direct decode
|
||||
}
|
||||
}
|
||||
|
||||
// Neither shape matched — return the direct decode and let the SIE parser
|
||||
// produce its own diagnostics instead of failing silently here.
|
||||
return direct
|
||||
}
|
||||
|
||||
/** SIE files start with a #-record (#FLAGGA per spec; be lenient about order). */
|
||||
function looksLikeSie(text: string): boolean {
|
||||
return text.trimStart().startsWith('#')
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
"VISMA_CLIENT_ID",
|
||||
"VISMA_CLIENT_SECRET",
|
||||
"VISMA_REDIRECT_URI",
|
||||
"BRIOX_CLIENT_ID",
|
||||
"BJORN_LUNDEN_CLIENT_ID",
|
||||
"BJORN_LUNDEN_CLIENT_SECRET",
|
||||
"UPSTASH_REDIS_REST_URL",
|
||||
|
||||
@@ -34,12 +34,16 @@ export type { CustomerType as ArcimCustomerType } from '@/lib/providers/dto'
|
||||
|
||||
export type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
|
||||
|
||||
export const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [
|
||||
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth' },
|
||||
{ id: 'visma', name: 'Visma eEkonomi', authType: 'oauth' },
|
||||
{ id: 'bokio', name: 'Bokio', authType: 'token' },
|
||||
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' },
|
||||
{ id: 'briox', name: 'Briox', authType: 'token' },
|
||||
// `sieViaApi`: the provider serves its general ledger as SIE over the API, so
|
||||
// the wizard imports bookkeeping automatically — no manual SIE upload needed.
|
||||
// Mirrored in ArcimMigrationWorkspace.tsx (deliberate duplication: core code
|
||||
// must not import from @/extensions/ — CI enforces it). Keep both in sync.
|
||||
export const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token'; sieViaApi: boolean }[] = [
|
||||
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth', sieViaApi: true },
|
||||
{ id: 'visma', name: 'Visma eEkonomi', authType: 'oauth', sieViaApi: false },
|
||||
{ id: 'bokio', name: 'Bokio', authType: 'token', sieViaApi: false },
|
||||
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token', sieViaApi: true },
|
||||
{ id: 'briox', name: 'Briox', authType: 'token', sieViaApi: true },
|
||||
]
|
||||
|
||||
// ── Migration state ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import {
|
||||
BolagsverketClient,
|
||||
BolagsverketApiError,
|
||||
BOLAGSVERKET_ERROR_MESSAGES,
|
||||
configFromEnv,
|
||||
extractFelkod,
|
||||
isBolagsverketEnvironment,
|
||||
} from '../lib/client'
|
||||
|
||||
describe('configFromEnv', () => {
|
||||
const originalEnv = process.env.BOLAGSVERKET_ENV
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) delete process.env.BOLAGSVERKET_ENV
|
||||
else process.env.BOLAGSVERKET_ENV = originalEnv
|
||||
})
|
||||
|
||||
it('defaults to the test environment', () => {
|
||||
delete process.env.BOLAGSVERKET_ENV
|
||||
const config = configFromEnv()
|
||||
expect(config.environment).toBe('test')
|
||||
})
|
||||
|
||||
it('rejects an invalid environment override with a clear error', () => {
|
||||
expect(() => configFromEnv({ environment: 'banana' as never })).toThrow(
|
||||
/Ogiltig Bolagsverket-miljö/,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a garbage BOLAGSVERKET_ENV env var with a clear error', () => {
|
||||
process.env.BOLAGSVERKET_ENV = 'production' // common typo for 'prod'
|
||||
expect(() => configFromEnv()).toThrow(/Ogiltig Bolagsverket-miljö/)
|
||||
})
|
||||
|
||||
it('isBolagsverketEnvironment validates the three known environments', () => {
|
||||
expect(isBolagsverketEnvironment('test')).toBe(true)
|
||||
expect(isBolagsverketEnvironment('accept')).toBe(true)
|
||||
expect(isBolagsverketEnvironment('prod')).toBe(true)
|
||||
expect(isBolagsverketEnvironment('production')).toBe(false)
|
||||
expect(isBolagsverketEnvironment(undefined)).toBe(false)
|
||||
expect(isBolagsverketEnvironment(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('decodes base64-wrapped PEM material', () => {
|
||||
const pem = '-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----'
|
||||
const config = configFromEnv({
|
||||
environment: 'accept',
|
||||
clientCertPem: pem,
|
||||
clientKeyPem: pem,
|
||||
})
|
||||
expect(config.clientCertPem).toContain('-----BEGIN CERTIFICATE-----')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BolagsverketClient', () => {
|
||||
it('refuses accept/prod calls without an organisationscertifikat', async () => {
|
||||
const client = new BolagsverketClient({ environment: 'accept' })
|
||||
await expect(client.getGrunduppgifter('5560001111')).rejects.toThrow(
|
||||
/Organisationscertifikat saknas/,
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes the configured environment', () => {
|
||||
expect(new BolagsverketClient({ environment: 'test' }).environment).toBe('test')
|
||||
expect(new BolagsverketClient({ environment: 'prod' }).environment).toBe('prod')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFelkod (error-body anchoring)', () => {
|
||||
it('extracts the felkod from JSON bodies', () => {
|
||||
expect(extractFelkod('{"felkod":"4001","text":"fel"}')).toBe('4001')
|
||||
expect(extractFelkod('{"kod":7003}')).toBe('7003')
|
||||
})
|
||||
|
||||
it('extracts the felkod from "NNNN=text" bodies', () => {
|
||||
expect(extractFelkod('4008=Filen innehåller tekniska fel')).toBe('4008')
|
||||
expect(extractFelkod(' \n 5006 = Dokumentet är för stort')).toBe('5006')
|
||||
})
|
||||
|
||||
it('does not false-positive on years or stray four-digit numbers in prose', () => {
|
||||
expect(extractFelkod('Räkenskapsåret 2026 kunde inte hanteras just nu')).toBeNull()
|
||||
expect(extractFelkod('{"message":"taxonomin för 2024 hittades inte (id 5008)"}')).toBeNull()
|
||||
expect(extractFelkod('Internt fel 9003 uppstod')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('felkodskarta (GUIDE Appendix A §6.2)', () => {
|
||||
it('covers the documented API error codes with Swedish messages', () => {
|
||||
for (const code of ['4001', '4003', '4005', '4008', '4010', '5006', '7003', '7006', '9003']) {
|
||||
expect(BOLAGSVERKET_ERROR_MESSAGES[code]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('BolagsverketApiError carries status + truncated body for logging', () => {
|
||||
const err = new BolagsverketApiError('Testfel', 400, 'body')
|
||||
expect(err.name).toBe('BolagsverketApiError')
|
||||
expect(err.status).toBe(400)
|
||||
expect(err.body).toBe('body')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
vi.mock('../lib/submission-service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/submission-service')>()
|
||||
return {
|
||||
...actual,
|
||||
submitArsredovisning: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { bolagsverketExtension } from '../index'
|
||||
import { BolagsverketSubmissionError, submitArsredovisning } from '../lib/submission-service'
|
||||
|
||||
function route(method: string, path: string) {
|
||||
const found = bolagsverketExtension.apiRoutes?.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
)
|
||||
if (!found) throw new Error(`route ${method} ${path} not found`)
|
||||
return found
|
||||
}
|
||||
|
||||
function makeCtx(supabase: unknown): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'bolagsverket',
|
||||
requestId: 'req-1',
|
||||
supabase,
|
||||
emit: vi.fn(),
|
||||
settings: { get: vi.fn(), set: vi.fn(), clear: vi.fn() },
|
||||
storage: {},
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
services: {},
|
||||
} as unknown as ExtensionContext
|
||||
}
|
||||
|
||||
const validBody = {
|
||||
fiscal_period_id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
avsandare_pnr: '198001019876',
|
||||
undertecknare: {
|
||||
pnr: '198001019876',
|
||||
fornamn: 'Anna',
|
||||
efternamn: 'Svensson',
|
||||
roll: 'VD',
|
||||
epost: 'anna@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
function makePost(path: string, body: unknown = validBody): Request {
|
||||
return new Request(`http://localhost/api/extensions/ext/bolagsverket${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
interface ErrorEnvelope {
|
||||
error: { code: string; message: string; message_en?: string }
|
||||
}
|
||||
|
||||
const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL
|
||||
const ORIGINAL_BV_ENV = process.env.BOLAGSVERKET_ENV
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
delete process.env.BOLAGSVERKET_ENV
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL
|
||||
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
|
||||
if (ORIGINAL_BV_ENV === undefined) delete process.env.BOLAGSVERKET_ENV
|
||||
else process.env.BOLAGSVERKET_ENV = ORIGINAL_BV_ENV
|
||||
})
|
||||
|
||||
describe('POST /submissions — write-role enforcement', () => {
|
||||
it('rejects viewer members with 403 BOLAGSVERKET_FORBIDDEN', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'viewer' }, error: null }) // company_members
|
||||
|
||||
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
|
||||
})
|
||||
|
||||
it('rejects non-members (no role row) with 403', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // no membership
|
||||
|
||||
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
|
||||
})
|
||||
|
||||
it('rejects viewer members on POST /poll-events too', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'viewer' }, error: null })
|
||||
|
||||
const res = await route('POST', '/poll-events').handler(makePost('/poll-events', {}), makeCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /submissions — environment validation + ceiling', () => {
|
||||
it('rejects an invalid environment setting with 400 BOLAGSVERKET_INVALID_ENVIRONMENT', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'member' }, error: null }) // company_members
|
||||
enqueue({ data: { value: { environment: 'banana' } }, error: null }) // settings blob
|
||||
|
||||
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('BOLAGSVERKET_INVALID_ENVIRONMENT')
|
||||
})
|
||||
|
||||
it('rejects an environment above the BOLAGSVERKET_ENV ceiling (unset → test)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'member' }, error: null })
|
||||
enqueue({ data: { value: { environment: 'prod' } }, error: null })
|
||||
|
||||
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('BOLAGSVERKET_ENV_NOT_ALLOWED')
|
||||
})
|
||||
|
||||
it('allows an environment at or below the ceiling', async () => {
|
||||
process.env.BOLAGSVERKET_ENV = 'accept'
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'member' }, error: null })
|
||||
enqueue({ data: { value: { environment: 'test' } }, error: null })
|
||||
vi.mocked(submitArsredovisning).mockResolvedValue({
|
||||
outcome: 'uploaded',
|
||||
submissionId: 'sub-1',
|
||||
idnummer: '49679',
|
||||
sha256: 'sha',
|
||||
url: 'https://ext.bolagsverket.se/eu/49679',
|
||||
utfall: [],
|
||||
})
|
||||
|
||||
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ data: { outcome: string } }>(res as Response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.outcome).toBe('uploaded')
|
||||
// The service got a client pinned to the validated environment.
|
||||
const deps = vi.mocked(submitArsredovisning).mock.calls[0][0]
|
||||
expect(deps.client.environment).toBe('test')
|
||||
expect(deps.appUrl).toBe('http://localhost:3000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /submissions — config + error mapping', () => {
|
||||
it('fails fast with 503 BOLAGSVERKET_CONFIG_MISSING when NEXT_PUBLIC_APP_URL is unset', async () => {
|
||||
delete process.env.NEXT_PUBLIC_APP_URL
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' }, error: null })
|
||||
|
||||
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
|
||||
expect(status).toBe(503)
|
||||
expect(body.error.code).toBe('BOLAGSVERKET_CONFIG_MISSING')
|
||||
})
|
||||
|
||||
it('maps BolagsverketSubmissionError to its structured code (409 double submission)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'member' }, error: null })
|
||||
enqueue({ data: { value: { environment: 'test' } }, error: null })
|
||||
vi.mocked(submitArsredovisning).mockRejectedValue(
|
||||
new BolagsverketSubmissionError('BOLAGSVERKET_SUBMISSION_EXISTS', 'already active', {
|
||||
submission_id: 'sub-0',
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('BOLAGSVERKET_SUBMISSION_EXISTS')
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR for a malformed body', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'member' }, error: null })
|
||||
|
||||
const res = await route('POST', '/submissions').handler(
|
||||
makePost('/submissions', { fiscal_period_id: 'not-a-uuid' }),
|
||||
makeCtx(supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /status', () => {
|
||||
it('reports resolved environment, ceiling, and env-derived certificate presence', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { value: { environment: 'test' } }, error: null })
|
||||
|
||||
const res = await route('GET', '/status').handler(
|
||||
new Request('http://localhost/api/extensions/ext/bolagsverket/status'),
|
||||
makeCtx(supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { environment: string; environment_ceiling: string; has_certificate: boolean }
|
||||
}>(res as Response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.environment).toBe('test')
|
||||
expect(body.data.environment_ceiling).toBe('test')
|
||||
// No BOLAGSVERKET_CLIENT_CERT/_KEY in the test env.
|
||||
expect(body.data.has_certificate).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/bokslut/ixbrl/build-input', () => ({
|
||||
buildIxbrlInput: vi.fn(async () => ({
|
||||
entryPointId: 'k2-ab-risbs-2024-09-12',
|
||||
period: { start: '2025-01-01', end: '2025-12-31' },
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/bokslut/ixbrl/document/k2-document', () => ({
|
||||
generateK2IxbrlDocument: vi.fn(() => ({ xhtml: '<?xml version="1.0"?><html></html>' })),
|
||||
embedKontrollsumma: vi.fn((xhtml: string) => xhtml),
|
||||
}))
|
||||
vi.mock('@/lib/bokslut/ixbrl/validate/rules', () => ({
|
||||
runPreflightChecks: vi.fn(() => ({ ok: true, issues: [] })),
|
||||
}))
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: vi.fn(async () => ({ id: 'doc-1' })),
|
||||
}))
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: vi.fn(() => {
|
||||
throw new Error('service client not expected in these tests')
|
||||
}),
|
||||
}))
|
||||
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import {
|
||||
applyHandelse,
|
||||
BolagsverketSubmissionError,
|
||||
handleWebhook,
|
||||
hashPnr,
|
||||
normalizeOrgnr,
|
||||
submitArsredovisning,
|
||||
} from '../lib/submission-service'
|
||||
import type { HandelseMeddelande } from '../types'
|
||||
|
||||
function makeLog() {
|
||||
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording mock: chainable builder consuming queued results per terminal
|
||||
* (single/maybeSingle/await), capturing update payloads per table so tests
|
||||
* can assert what was written.
|
||||
*/
|
||||
function makeRecordingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
|
||||
let idx = 0
|
||||
const updates: Array<{ table: string; payload: Record<string, unknown> }> = []
|
||||
const next = () => results[idx++] ?? { data: null, error: null }
|
||||
const makeBuilder = (table: string) => {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'limit', 'insert']) {
|
||||
b[m] = () => b
|
||||
}
|
||||
b.update = (payload: Record<string, unknown>) => {
|
||||
updates.push({ table, payload })
|
||||
return b
|
||||
}
|
||||
b.single = async () => next()
|
||||
b.maybeSingle = async () => next()
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(next())
|
||||
return b
|
||||
}
|
||||
return { supabase: { from: (table: string) => makeBuilder(table) } as never, updates }
|
||||
}
|
||||
|
||||
function makeClientMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
environment: 'test',
|
||||
createInlamningToken: vi.fn(async () => ({
|
||||
token: 'tok-1',
|
||||
avtalstext: 'Avtalstext',
|
||||
avtalstextAndrad: '2017-12-06',
|
||||
})),
|
||||
createChecksumToken: vi.fn(async () => ({ token: 'tok-2', avtalstext: '', avtalstextAndrad: '' })),
|
||||
createChecksum: vi.fn(async () => ({ kontrollsumma: 'ksum', algoritm: 'SHA-256' })),
|
||||
kontrollera: vi.fn(async () => ({ orgnr: '5560001111', utfall: [] })),
|
||||
lamnaIn: vi.fn(async () => ({
|
||||
orgnr: '5560001111',
|
||||
avsandare: 'avs',
|
||||
undertecknare: 'und',
|
||||
handlingsinfo: {
|
||||
typ: 'arsredovisning_komplett',
|
||||
dokumentlangd: 1,
|
||||
idnummer: '49679',
|
||||
sha256checksumma: 'sha256',
|
||||
},
|
||||
url: 'https://ext.bolagsverket.se/eu/49679',
|
||||
})),
|
||||
...overrides,
|
||||
} as never
|
||||
}
|
||||
|
||||
const submitParams = {
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
fiscalPeriodId: 'period-1',
|
||||
avsandarePnr: '198001019876',
|
||||
undertecknare: {
|
||||
pnr: '198001019876',
|
||||
fornamn: 'Anna',
|
||||
efternamn: 'Svensson',
|
||||
roll: 'VD',
|
||||
epost: 'anna@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
function message(overrides: Partial<HandelseMeddelande> = {}): HandelseMeddelande {
|
||||
return {
|
||||
typ: 'AR-v2',
|
||||
id: '5560001111',
|
||||
nr: 3,
|
||||
tid: '2026-06-01T10:00:00.000+02:00',
|
||||
data: {
|
||||
version: '2.0',
|
||||
handlingsinfo: [{ handling: 'arsredovisning', idnummer: '49679' }],
|
||||
status: 'arsred_inkommen',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('normalizeOrgnr / hashPnr', () => {
|
||||
it('normalizes 12-digit and dashed org numbers to the 10-digit API form', () => {
|
||||
expect(normalizeOrgnr('556000-1111')).toBe('5560001111')
|
||||
expect(normalizeOrgnr('165560001111')).toBe('5560001111')
|
||||
expect(normalizeOrgnr('5560001111')).toBe('5560001111')
|
||||
})
|
||||
|
||||
it('hashes personnummer with company salt — never the raw value', () => {
|
||||
const hash = hashPnr('company-1', '19830101-9876')
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(hash).not.toContain('9876')
|
||||
expect(hashPnr('company-2', '198301019876')).not.toBe(hash)
|
||||
// Same pnr + company → stable.
|
||||
expect(hashPnr('company-1', '198301019876')).toBe(hash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleWebhook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('rejects messages without a matching auth header (401)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ company_id: 'company-1', auth_secret: 'right-secret' }], error: null })
|
||||
|
||||
const result = await handleWebhook(supabase as never, message(), 'wrong-secret')
|
||||
expect(result.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects messages for unknown orgnr (401)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await handleWebhook(supabase as never, message(), 'any')
|
||||
expect(result.status).toBe(401)
|
||||
})
|
||||
|
||||
it('acks the subscription test message without touching submissions', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ company_id: 'company-1', auth_secret: 's3cret' }], error: null })
|
||||
|
||||
const result = await handleWebhook(
|
||||
supabase as never,
|
||||
message({ nr: -1, data: { version: '2.0', status: 'test' } }),
|
||||
's3cret',
|
||||
)
|
||||
expect(result.status).toBe(200)
|
||||
expect(result.body.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('applies a real status event to the matching submission and emits events', async () => {
|
||||
const emitted: string[] = []
|
||||
eventBus.on('arsredovisning.status_changed', (payload) => {
|
||||
emitted.push(`changed:${payload.status}`)
|
||||
})
|
||||
eventBus.on('arsredovisning.registered', () => {
|
||||
emitted.push('registered')
|
||||
})
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// 1) subscription lookup
|
||||
enqueue({ data: [{ company_id: 'company-1', auth_secret: 's3cret' }], error: null })
|
||||
// 2) submission lookup by idnummer
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sub-1',
|
||||
status: 'uploaded',
|
||||
fiscal_period_id: 'period-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// 3) update
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await handleWebhook(
|
||||
supabase as never,
|
||||
message({ data: { version: '2.0', handlingsinfo: [{ handling: 'arsredovisning', idnummer: '49679' }], status: 'arsred_registrerad' } }),
|
||||
's3cret',
|
||||
)
|
||||
expect(result.status).toBe(200)
|
||||
expect(emitted).toContain('changed:registrerad')
|
||||
expect(emitted).toContain('registered')
|
||||
})
|
||||
|
||||
it('rejects malformed payloads (400)', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await handleWebhook(
|
||||
supabase as never,
|
||||
{} as never,
|
||||
's3cret',
|
||||
)
|
||||
expect(result.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyHandelse', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('emits forelagd event on föreläggande and skips unknown statuses', async () => {
|
||||
const emitted: string[] = []
|
||||
eventBus.on('arsredovisning.forelagd', () => {
|
||||
emitted.push('forelagd')
|
||||
})
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sub-1',
|
||||
status: 'inkommen',
|
||||
fiscal_period_id: 'period-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
await applyHandelse(
|
||||
supabase as never,
|
||||
message({ data: { version: '2.0', handlingsinfo: [{ handling: 'arsredovisning', idnummer: '49679' }], status: 'arsred_forelaggande_skickat' } }),
|
||||
['company-1'],
|
||||
)
|
||||
expect(emitted).toEqual(['forelagd'])
|
||||
|
||||
// Unknown status: nothing should be queried or emitted.
|
||||
await applyHandelse(
|
||||
supabase as never,
|
||||
message({ data: { version: '2.0', status: 'test' } }),
|
||||
['company-1'],
|
||||
)
|
||||
expect(emitted).toEqual(['forelagd'])
|
||||
})
|
||||
|
||||
it('does not emit when the stored status already matches', async () => {
|
||||
const emitted: string[] = []
|
||||
eventBus.on('arsredovisning.status_changed', () => {
|
||||
emitted.push('changed')
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sub-1',
|
||||
status: 'inkommen',
|
||||
fiscal_period_id: 'period-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
await applyHandelse(supabase as never, message(), ['company-1'])
|
||||
expect(emitted).toEqual([])
|
||||
})
|
||||
|
||||
it('logs rejected transitions instead of silently continuing', async () => {
|
||||
const emitted: string[] = []
|
||||
eventBus.on('arsredovisning.status_changed', () => {
|
||||
emitted.push('changed')
|
||||
})
|
||||
const log = makeLog()
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Stored status differs from the incoming one, but the DB trigger
|
||||
// rejects the transition.
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sub-1',
|
||||
status: 'registrerad',
|
||||
fiscal_period_id: 'period-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: { message: 'Ogiltig statusövergång: registrerad → inkommen' } })
|
||||
|
||||
await applyHandelse(supabase as never, message(), ['company-1'], log)
|
||||
expect(log.warn).toHaveBeenCalledTimes(1)
|
||||
expect(log.warn.mock.calls[0][0]).toMatch(/rejected/)
|
||||
expect(emitted).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitArsredovisning', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
vi.mocked(uploadDocument).mockResolvedValue({ id: 'doc-1' } as never)
|
||||
})
|
||||
|
||||
it('refuses when an active submission already exists for the fiscal period', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { org_number: '556000-1111' }, error: null }) // company_settings
|
||||
enqueue({ data: [{ id: 'sub-0', status: 'uploaded' }], error: null }) // active submissions
|
||||
|
||||
await expect(
|
||||
submitArsredovisning(
|
||||
{ supabase: supabase as never, client: makeClientMock(), appUrl: 'https://app.test', log: makeLog() },
|
||||
submitParams,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
name: 'BolagsverketSubmissionError',
|
||||
code: 'BOLAGSVERKET_SUBMISSION_EXISTS',
|
||||
})
|
||||
})
|
||||
|
||||
it('marks the submission row as error and rethrows when inlämning fails', async () => {
|
||||
const { supabase, updates } = makeRecordingSupabase([
|
||||
{ data: { org_number: '5560001111' } }, // getOrgnr
|
||||
{ data: [] }, // no active submission
|
||||
{ data: { id: 'acc-1' } }, // avtal acceptance exists
|
||||
{ data: { id: 'sub-1' } }, // insert submission row
|
||||
{}, // update → kontrollerad
|
||||
{}, // markSubmissionError update
|
||||
])
|
||||
const client = makeClientMock({
|
||||
lamnaIn: vi.fn(async () => {
|
||||
throw new Error('inlamning exploded')
|
||||
}),
|
||||
})
|
||||
const log = makeLog()
|
||||
|
||||
await expect(
|
||||
submitArsredovisning({ supabase, client, appUrl: 'https://app.test', log }, submitParams),
|
||||
).rejects.toThrow('inlamning exploded')
|
||||
|
||||
const errorUpdate = updates.find((u) => u.payload.status === 'error')
|
||||
expect(errorUpdate).toBeDefined()
|
||||
expect(errorUpdate!.table).toBe('arsredovisning_submissions')
|
||||
expect(errorUpdate!.payload.error_message).toContain('inlamning exploded')
|
||||
})
|
||||
|
||||
it('logs and persists a document-archive failure without blocking the filing', async () => {
|
||||
vi.mocked(uploadDocument).mockRejectedValueOnce(new Error('magic bytes rejected'))
|
||||
const { supabase, updates } = makeRecordingSupabase([
|
||||
{ data: { org_number: '5560001111' } }, // getOrgnr
|
||||
{ data: [] }, // no active submission
|
||||
{ data: { id: 'acc-1' } }, // avtal acceptance exists
|
||||
{ data: { id: 'sub-1' } }, // insert submission row
|
||||
{}, // update → kontrollerad
|
||||
{}, // error_message update (doc failure)
|
||||
{}, // update → uploaded
|
||||
// ensureSubscription throws on the empty appUrl before any query.
|
||||
])
|
||||
const log = makeLog()
|
||||
|
||||
const result = await submitArsredovisning(
|
||||
{ supabase, client: makeClientMock(), appUrl: '', log },
|
||||
submitParams,
|
||||
)
|
||||
expect(result.outcome).toBe('uploaded')
|
||||
|
||||
// The failure is logged AND visible on the row.
|
||||
expect(log.error).toHaveBeenCalledTimes(1)
|
||||
expect(log.error.mock.calls[0][0]).toMatch(/archive/)
|
||||
const docFailureUpdate = updates.find(
|
||||
(u) => typeof u.payload.error_message === 'string' && !u.payload.status,
|
||||
)
|
||||
expect(docFailureUpdate).toBeDefined()
|
||||
expect(docFailureUpdate!.payload.error_message).toContain('magic bytes rejected')
|
||||
|
||||
// The filing itself still went through with dokument_id null.
|
||||
const uploadedUpdate = updates.find((u) => u.payload.status === 'uploaded')
|
||||
expect(uploadedUpdate).toBeDefined()
|
||||
expect(uploadedUpdate!.payload.dokument_id).toBeNull()
|
||||
|
||||
// Subscription failure (invalid appUrl) is logged, not swallowed.
|
||||
expect(log.warn.mock.calls.some(([msg]) => /prenumeration/.test(String(msg)))).toBe(true)
|
||||
})
|
||||
|
||||
it('exports BolagsverketSubmissionError with a stable code', () => {
|
||||
const err = new BolagsverketSubmissionError('BOLAGSVERKET_SUBMISSION_EXISTS', 'exists', {
|
||||
submission_id: 'sub-1',
|
||||
})
|
||||
expect(err.code).toBe('BOLAGSVERKET_SUBMISSION_EXISTS')
|
||||
expect(err.details).toEqual({ submission_id: 'sub-1' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } = await ctx.supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.eq('extension_id', 'general/bolagsverket')
|
||||
.eq('key', 'settings')
|
||||
.maybeSingle()
|
||||
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 } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('org_number')
|
||||
.eq('company_id', ctx.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 SubmitSchema = z.object({
|
||||
fiscal_period_id: z.string().uuid(),
|
||||
avsandare_pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 10–12 siffror'),
|
||||
undertecknare: z.object({
|
||||
pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 10–12 siffror'),
|
||||
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(),
|
||||
utdelning: z.number().min(0).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),
|
||||
},
|
||||
})
|
||||
} 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, handling_typ, taxonomy_version, entry_point, environment, status, undertecknare_namn, undertecknare_epost, idnummer, 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()
|
||||
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,
|
||||
avsandarePnr: parsed.avsandare_pnr,
|
||||
undertecknare: parsed.undertecknare,
|
||||
kvittensEpost: parsed.kvittens_epost,
|
||||
proposedDividend: parsed.utdelning,
|
||||
acceptedAvtalstextAndrad: parsed.accepted_avtalstext_andrad,
|
||||
ignoreWarnings: parsed.ignore_warnings,
|
||||
},
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Typed HTTP client for Bolagsverket's digital-inlämning REST services.
|
||||
*
|
||||
* Environments + endpoints per Anslutningsanvisning v1.7 §3:
|
||||
* - test → https://api-accept2.bolagsverket.se/testapi/… (no client cert;
|
||||
* static data, orgnr 1234567890/1234567891; requires firewall
|
||||
* opening ordered via api@bolagsverket.se)
|
||||
* - accept → https://api-accept2.bolagsverket.se/… (mTLS)
|
||||
* - prod → https://api.bolagsverket.se/… (mTLS)
|
||||
*
|
||||
* mTLS uses an Expisoft/Steria organisationscertifikat whose SERIALNUMBER is
|
||||
* `16` + the supplier's 10-digit orgnr (ANSLUTNINGSANVISNING §5.3). Cert/key
|
||||
* come from env vars ONLY (BOLAGSVERKET_CLIENT_CERT/_KEY/_CA) — never from
|
||||
* extension settings; extension_data is readable by every company member, so
|
||||
* private-key material must not be stored there. Implemented with node:https
|
||||
* (undici fetch has no portable client-cert support inside Next.js route
|
||||
* handlers).
|
||||
*/
|
||||
|
||||
import { request as httpsRequest, type RequestOptions } from 'node:https'
|
||||
import { URL } from 'node:url'
|
||||
import type { ZodType } from 'zod'
|
||||
import {
|
||||
InlamningSvarSchema,
|
||||
InlamningTokenSvarSchema,
|
||||
KontrolleraSvarSchema,
|
||||
KontrollsummaSvarSchema,
|
||||
} from './schemas'
|
||||
import type {
|
||||
ArendestatusSvar,
|
||||
BolagsverketEnvironment,
|
||||
GrunduppgifterSvar,
|
||||
HamtaHandelserSvar,
|
||||
HandlingTyp,
|
||||
InlamningSvar,
|
||||
InlamningTokenSvar,
|
||||
KontrolleraSvar,
|
||||
KontrollsummaSvar,
|
||||
} from '../types'
|
||||
|
||||
export const BOLAGSVERKET_ENVIRONMENTS = ['test', 'accept', 'prod'] as const
|
||||
|
||||
export function isBolagsverketEnvironment(value: unknown): value is BolagsverketEnvironment {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(BOLAGSVERKET_ENVIRONMENTS as readonly string[]).includes(value)
|
||||
)
|
||||
}
|
||||
|
||||
const HOSTS: Record<BolagsverketEnvironment, { base: string; prefix: string }> = {
|
||||
test: { base: 'https://api-accept2.bolagsverket.se', prefix: '/testapi' },
|
||||
accept: { base: 'https://api-accept2.bolagsverket.se', prefix: '' },
|
||||
prod: { base: 'https://api.bolagsverket.se', prefix: '' },
|
||||
}
|
||||
|
||||
/** API felkoder (GUIDE Appendix A §6.2) → user-facing Swedish messages. */
|
||||
export const BOLAGSVERKET_ERROR_MESSAGES: Record<string, string> = {
|
||||
'4001': 'Dokumentet är inte en giltig iXBRL-fil.',
|
||||
'4002': 'Programvaruversionen stöds inte längre av Bolagsverkets tjänst.',
|
||||
'4003': 'Ogiltigt organisationsnummer.',
|
||||
'4004': 'Organisationsnumret avser inte ett aktiebolag.',
|
||||
'4005': 'Ingen träff på organisationsnumret hos Bolagsverket.',
|
||||
'4007': 'Ogiltigt personnummer.',
|
||||
'4008': 'Filen innehåller ett eller flera tekniska fel.',
|
||||
'4010': 'Årsredovisningen är upprättad i en taxonomiversion som Bolagsverket inte längre stödjer.',
|
||||
'4011': 'Tjänsten stödjer inte den här företagsformen.',
|
||||
'5001': 'Dokumentet saknar eller har tom title-tagg.',
|
||||
'5002': 'Dokumentet är inte en iXBRL-fil.',
|
||||
'5006': 'Dokumentet överstiger tillåten maxstorlek (5 MB).',
|
||||
'5008': 'Dokumentet är inte kodat i UTF-8.',
|
||||
'5009': 'Dokumentet saknar taggning av programvara och/eller programversion.',
|
||||
'7003': 'Felaktig token — skapa en ny inlämningstoken och försök igen.',
|
||||
'7004': 'Dokumentet innehåller skadlig kod.',
|
||||
'7006': 'Årsredovisningen kan inte skickas in eftersom företaget är avvecklat.',
|
||||
'7007': 'Tjänsten stödjer inte digital inlämning för den här företagsformen.',
|
||||
'9003': 'Icke godkänd användare av tjänsten — kontrollera certifikat och avtal med Bolagsverket.',
|
||||
}
|
||||
|
||||
export class BolagsverketApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly body: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'BolagsverketApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface BolagsverketClientConfig {
|
||||
environment: BolagsverketEnvironment
|
||||
/** PEM strings — required for accept/prod, ignored for test. */
|
||||
clientCertPem?: string | null
|
||||
clientKeyPem?: string | null
|
||||
/** Extra CA chain (TeliaSonera root is normally in the system store). */
|
||||
caPem?: string | null
|
||||
/** Socket inactivity timeout. */
|
||||
timeoutMs?: number
|
||||
/** Overall per-request deadline (covers slow trickling bodies too). */
|
||||
deadlineMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve config from env vars; the extension may override the environment
|
||||
* per install (validated + capped against BOLAGSVERKET_ENV in index.ts).
|
||||
* Certificate material is ENV-ONLY — never read from extension settings.
|
||||
*/
|
||||
export function configFromEnv(
|
||||
overrides: Partial<BolagsverketClientConfig> = {},
|
||||
): BolagsverketClientConfig {
|
||||
const rawEnvironment = overrides.environment ?? process.env.BOLAGSVERKET_ENV ?? 'test'
|
||||
if (!isBolagsverketEnvironment(rawEnvironment)) {
|
||||
// Fail fast with a clear message instead of HOSTS[env] → opaque TypeError.
|
||||
throw new BolagsverketApiError(
|
||||
`Ogiltig Bolagsverket-miljö '${String(rawEnvironment)}' — tillåtna värden: ${BOLAGSVERKET_ENVIRONMENTS.join(', ')}.`,
|
||||
0,
|
||||
'',
|
||||
)
|
||||
}
|
||||
const environment = rawEnvironment
|
||||
const decode = (value: string | undefined | null): string | null => {
|
||||
if (!value) return null
|
||||
// Allow base64-wrapped PEM in env vars (newline-hostile platforms).
|
||||
return value.includes('-----BEGIN')
|
||||
? value
|
||||
: Buffer.from(value, 'base64').toString('utf8')
|
||||
}
|
||||
return {
|
||||
environment,
|
||||
clientCertPem: overrides.clientCertPem ?? decode(process.env.BOLAGSVERKET_CLIENT_CERT),
|
||||
clientKeyPem: overrides.clientKeyPem ?? decode(process.env.BOLAGSVERKET_CLIENT_KEY),
|
||||
caPem: overrides.caPem ?? decode(process.env.BOLAGSVERKET_CA),
|
||||
timeoutMs: overrides.timeoutMs ?? 30_000,
|
||||
deadlineMs: overrides.deadlineMs ?? 90_000,
|
||||
}
|
||||
}
|
||||
|
||||
interface HttpResponse {
|
||||
status: number
|
||||
body: string
|
||||
}
|
||||
|
||||
function rawRequest(
|
||||
config: BolagsverketClientConfig,
|
||||
method: 'GET' | 'POST' | 'DELETE',
|
||||
url: string,
|
||||
jsonBody?: unknown,
|
||||
): Promise<HttpResponse> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const parsed = new URL(url)
|
||||
const payload = jsonBody === undefined ? null : JSON.stringify(jsonBody)
|
||||
const options: RequestOptions = {
|
||||
method,
|
||||
hostname: parsed.hostname,
|
||||
path: parsed.pathname + parsed.search,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
timeout: config.timeoutMs ?? 30_000,
|
||||
}
|
||||
// mTLS for accept/prod (the test env is plain TLS behind a firewall).
|
||||
if (config.environment !== 'test') {
|
||||
if (!config.clientCertPem || !config.clientKeyPem) {
|
||||
rejectPromise(
|
||||
new BolagsverketApiError(
|
||||
'Organisationscertifikat saknas — miljövariablerna BOLAGSVERKET_CLIENT_CERT/BOLAGSVERKET_CLIENT_KEY krävs för acceptans- och produktionsmiljön.',
|
||||
0,
|
||||
'',
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
options.cert = config.clientCertPem
|
||||
options.key = config.clientKeyPem
|
||||
if (config.caPem) options.ca = config.caPem
|
||||
}
|
||||
let settled = false
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(deadline)
|
||||
fn()
|
||||
}
|
||||
// Overall deadline: the socket-inactivity `timeout` above never fires for
|
||||
// a server that trickles bytes forever — destroy the request outright.
|
||||
const deadline = setTimeout(() => {
|
||||
req.destroy(new Error('tidsgränsen för hela anropet överskreds'))
|
||||
}, config.deadlineMs ?? 90_000)
|
||||
const req = httpsRequest(options, (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
settle(() =>
|
||||
resolvePromise({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
|
||||
)
|
||||
})
|
||||
// Mid-body connection failure: without this the promise never settles
|
||||
// (the request-level 'error' handler does not fire once headers landed).
|
||||
res.on('error', (err: Error) => {
|
||||
settle(() =>
|
||||
rejectPromise(
|
||||
new BolagsverketApiError(
|
||||
`Anslutningen till Bolagsverket bröts mitt i svaret (${err.message}). Försök igen.`,
|
||||
0,
|
||||
'',
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error('timeout'))
|
||||
})
|
||||
req.on('error', (err) => {
|
||||
settle(() =>
|
||||
rejectPromise(
|
||||
new BolagsverketApiError(
|
||||
`Kunde inte nå Bolagsverket (${err.message}). Kontrollera brandväggsöppning och certifikat.`,
|
||||
0,
|
||||
'',
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
if (payload) req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a felkod out of an error body. Error bodies carry "NNNN=text" lines or
|
||||
* {"felkod":"NNNN", ...}-style JSON (GUIDE Appendix A §6.2). Anchored to those
|
||||
* two shapes — a bare \bNNNN\b match would false-positive on years and other
|
||||
* four-digit numbers inside prose.
|
||||
*/
|
||||
export function extractFelkod(body: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
for (const field of ['felkod', 'kod']) {
|
||||
const value = parsed[field]
|
||||
const asString = typeof value === 'number' ? String(value) : value
|
||||
if (typeof asString === 'string' && /^[4579]\d{3}$/.test(asString)) return asString
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to the "NNNN=text" shape
|
||||
}
|
||||
const match = body.match(/(?:^|[\r\n])\s*([4579]\d{3})\s*=/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function mapError(status: number, body: string): BolagsverketApiError {
|
||||
const felkod = extractFelkod(body)
|
||||
const known = felkod ? BOLAGSVERKET_ERROR_MESSAGES[felkod] : null
|
||||
const fallback =
|
||||
status === 404
|
||||
? 'Ingen träff hos Bolagsverket (404).'
|
||||
: status === 503 || status === 504
|
||||
? 'Bolagsverkets tjänst är tillfälligt otillgänglig — försök igen om en stund.'
|
||||
: `Bolagsverket svarade med fel (HTTP ${status}).`
|
||||
return new BolagsverketApiError(known ?? fallback, status, body.slice(0, 2_000))
|
||||
}
|
||||
|
||||
interface RequestJsonOptions {
|
||||
/**
|
||||
* Validate the response shape at the boundary (Zod). Schemas are loose
|
||||
* (passthrough) and only pin the fields downstream code dereferences, so
|
||||
* the parsed value is returned as the caller's declared DTO type.
|
||||
*/
|
||||
schema?: ZodType<unknown>
|
||||
/** Human-readable endpoint name for error messages. */
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
config: BolagsverketClientConfig,
|
||||
method: 'GET' | 'POST' | 'DELETE',
|
||||
url: string,
|
||||
jsonBody?: unknown,
|
||||
options: RequestJsonOptions = {},
|
||||
): Promise<T> {
|
||||
let res = await rawRequest(config, method, url, jsonBody)
|
||||
// One retry for idempotent GETs when the service is momentarily unavailable.
|
||||
if (method === 'GET' && (res.status === 503 || res.status === 504)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
res = await rawRequest(config, method, url, jsonBody)
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) throw mapError(res.status, res.body)
|
||||
if (res.body.trim().length === 0) return undefined as T
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(res.body)
|
||||
} catch {
|
||||
throw new BolagsverketApiError('Oväntat svar från Bolagsverket (inte JSON).', res.status, res.body.slice(0, 500))
|
||||
}
|
||||
if (options.schema) {
|
||||
const result = options.schema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||
.join('; ')
|
||||
throw new BolagsverketApiError(
|
||||
`Oväntat svarsformat från Bolagsverket (${options.endpoint ?? url}): ${issues}`,
|
||||
res.status,
|
||||
res.body.slice(0, 500),
|
||||
)
|
||||
}
|
||||
return result.data as T
|
||||
}
|
||||
return parsed as T
|
||||
}
|
||||
|
||||
export class BolagsverketClient {
|
||||
constructor(private readonly config: BolagsverketClientConfig) {}
|
||||
|
||||
get environment(): BolagsverketEnvironment {
|
||||
return this.config.environment
|
||||
}
|
||||
|
||||
private url(service: string, path: string): string {
|
||||
const { base, prefix } = HOSTS[this.config.environment]
|
||||
return `${base}${prefix}/${service}${path}`
|
||||
}
|
||||
|
||||
// ---- informationstjänster (GUIDE §5.2) ----------------------------------
|
||||
|
||||
getGrunduppgifter(orgnr: string): Promise<GrunduppgifterSvar> {
|
||||
return requestJson(this.config, 'GET', this.url('hamta-arsredovisningsinformation/v1.4', `/grunduppgifter/${encodeURIComponent(orgnr)}`))
|
||||
}
|
||||
|
||||
getArendestatus(orgnr: string): Promise<ArendestatusSvar> {
|
||||
return requestJson(this.config, 'GET', this.url('hamta-arsredovisningsinformation/v1.4', `/arendestatus/${encodeURIComponent(orgnr)}`))
|
||||
}
|
||||
|
||||
/** Token for skapa-kontrollsumma (information service, v1.1). */
|
||||
createChecksumToken(pnr: string, orgnr: string): Promise<InlamningTokenSvar> {
|
||||
return requestJson(this.config, 'POST', this.url('hamta-arsredovisningsinformation/v1.1', '/skapa-inlamningtoken/'), { pnr, orgnr }, {
|
||||
schema: InlamningTokenSvarSchema,
|
||||
endpoint: 'skapa-inlamningtoken (kontrollsumma)',
|
||||
})
|
||||
}
|
||||
|
||||
createChecksum(token: string, fileBase64: string): Promise<KontrollsummaSvar> {
|
||||
return requestJson(this.config, 'POST', this.url('hamta-arsredovisningsinformation/v1.1', `/skapa-kontrollsumma/${encodeURIComponent(token)}`), { fil: fileBase64 }, {
|
||||
schema: KontrollsummaSvarSchema,
|
||||
endpoint: 'skapa-kontrollsumma',
|
||||
})
|
||||
}
|
||||
|
||||
// ---- inlämning (GUIDE §5.3) ----------------------------------------------
|
||||
|
||||
createInlamningToken(pnr: string, orgnr: string): Promise<InlamningTokenSvar> {
|
||||
return requestJson(this.config, 'POST', this.url('lamna-in-arsredovisning/v2.1', '/skapa-inlamningtoken/'), { pnr, orgnr }, {
|
||||
schema: InlamningTokenSvarSchema,
|
||||
endpoint: 'skapa-inlamningtoken',
|
||||
})
|
||||
}
|
||||
|
||||
kontrollera(token: string, fileBase64: string, typ: HandlingTyp): Promise<KontrolleraSvar> {
|
||||
return requestJson(this.config, 'POST', this.url('lamna-in-arsredovisning/v2.1', `/kontrollera/${encodeURIComponent(token)}`), {
|
||||
handling: { fil: fileBase64, typ },
|
||||
}, {
|
||||
schema: KontrolleraSvarSchema,
|
||||
endpoint: 'kontrollera',
|
||||
})
|
||||
}
|
||||
|
||||
lamnaIn(
|
||||
token: string,
|
||||
body: {
|
||||
undertecknare: string
|
||||
epostadresser: string[]
|
||||
kvittensepostadresser?: string[]
|
||||
notifieringEpostadresser?: string[]
|
||||
fileBase64: string
|
||||
typ: HandlingTyp
|
||||
},
|
||||
): Promise<InlamningSvar> {
|
||||
return requestJson(this.config, 'POST', this.url('lamna-in-arsredovisning/v2.1', `/inlamning/${encodeURIComponent(token)}`), {
|
||||
undertecknare: body.undertecknare,
|
||||
epostadresser: body.epostadresser,
|
||||
...(body.kvittensepostadresser?.length ? { kvittensepostadresser: body.kvittensepostadresser } : {}),
|
||||
...(body.notifieringEpostadresser?.length ? { notifieringEpostadresser: body.notifieringEpostadresser } : {}),
|
||||
handling: { fil: body.fileBase64, typ: body.typ },
|
||||
}, {
|
||||
schema: InlamningSvarSchema,
|
||||
endpoint: 'inlamning',
|
||||
})
|
||||
}
|
||||
|
||||
// ---- händelser (GUIDE §5.4) ----------------------------------------------
|
||||
|
||||
/** Idempotent: existing (url, orgnr) pair gets its TTL extended 6 months. */
|
||||
async createSubscription(url: string, orgnr: string, auth: string): Promise<void> {
|
||||
await requestJson(this.config, 'POST', this.url('hantera-arsredovisningsprenumerationer/v2.0', '/handelseprenumeration/'), {
|
||||
prenumerationer: [{ url, orgnr, auth }],
|
||||
})
|
||||
}
|
||||
|
||||
async deleteSubscription(url: string, orgnr: string): Promise<void> {
|
||||
await requestJson(this.config, 'DELETE', this.url('hantera-arsredovisningsprenumerationer/v2.0', '/handelseprenumeration/'), { url, orgnr })
|
||||
}
|
||||
|
||||
/** Polling fallback for missed webhooks (events kept ~1 year). */
|
||||
hamtaHandelser(body: {
|
||||
url: string
|
||||
orgnr: string[]
|
||||
fromtidpunkt?: string
|
||||
tomtidpunkt?: string
|
||||
}): Promise<HamtaHandelserSvar> {
|
||||
return requestJson(this.config, 'POST', this.url('hamta-arsredovisningshandelser/v2.0', '/handelser/'), body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Zod schemas for the Bolagsverket response payloads we actually dereference.
|
||||
* Parsed at the HTTP-client boundary (lib/client.ts) so a contract drift on
|
||||
* Bolagsverket's side surfaces as a clear "unexpected response shape from
|
||||
* <endpoint>" error instead of an opaque TypeError deep inside the
|
||||
* submission flow.
|
||||
*
|
||||
* Schemas are deliberately loose (`.passthrough()`): we only pin the fields
|
||||
* we read — see types.ts for the full hand-written DTOs.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
// skapa-inlamningtoken (v2.1 + v1.1) — token + avtalstext gate fields.
|
||||
export const InlamningTokenSvarSchema = z
|
||||
.object({
|
||||
token: z.string().min(1),
|
||||
avtalstext: z.string(),
|
||||
avtalstextAndrad: z.string(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// kontrollera (v2.1) — utfall drives the warn/error gate.
|
||||
export const KontrolleraSvarSchema = z
|
||||
.object({
|
||||
orgnr: z.string().optional(),
|
||||
utfall: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
kod: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
typ: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// inlamning (v2.1) — handlingsinfo.idnummer correlates webhooks; sha256 and
|
||||
// url are persisted on the submission row.
|
||||
export const InlamningSvarSchema = z
|
||||
.object({
|
||||
handlingsinfo: z
|
||||
.object({
|
||||
idnummer: z.string().min(1),
|
||||
sha256checksumma: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
url: z.string(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// skapa-kontrollsumma (v1.1).
|
||||
export const KontrollsummaSvarSchema = z
|
||||
.object({
|
||||
kontrollsumma: z.string().min(1),
|
||||
algoritm: z.string(),
|
||||
})
|
||||
.passthrough()
|
||||
@@ -0,0 +1,621 @@
|
||||
/**
|
||||
* 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 } }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "bolagsverket",
|
||||
"sector": "general",
|
||||
"exportName": "bolagsverketExtension",
|
||||
"entryPoint": "@/extensions/general/bolagsverket",
|
||||
"workspace": null,
|
||||
"requiredEnvVars": [],
|
||||
"optionalEnvVars": [
|
||||
"BOLAGSVERKET_ENV",
|
||||
"BOLAGSVERKET_CLIENT_CERT",
|
||||
"BOLAGSVERKET_CLIENT_KEY",
|
||||
"BOLAGSVERKET_CA"
|
||||
],
|
||||
"npmDependencies": [],
|
||||
"definition": {
|
||||
"name": "Bolagsverket — digital årsredovisning",
|
||||
"category": "operations",
|
||||
"icon": "Landmark",
|
||||
"dataPattern": "core",
|
||||
"description": "Lämna in årsredovisningen digitalt till Bolagsverket (iXBRL).",
|
||||
"longDescription": "Ladda upp årsredovisningen som iXBRL till Bolagsverkets eget utrymme, låt undertecknaren signera fastställelseintyget med e-legitimation hos Bolagsverket, och följ ärendet i appen via händelseaviseringar. Kräver avtal med Bolagsverket och organisationscertifikat för acceptans- och produktionsmiljön; testmiljön kan användas utan certifikat. Certifikatet läses enbart från miljövariablerna BOLAGSVERKET_CLIENT_CERT/BOLAGSVERKET_CLIENT_KEY (aldrig från inställningar), och BOLAGSVERKET_ENV sätter taket (test < accept < prod) för vilken miljö ett företag kan välja i inställningarna — utan miljövariabeln gäller endast testmiljön."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* DTOs for Bolagsverket's REST services for digital inlämning av
|
||||
* årsredovisning, hand-written from Teknisk guide v3.4 (dev_docs/bokslut/
|
||||
* GUIDE.md §5 + §8 trafikexempel) and Anslutningsanvisning v1.7.
|
||||
*
|
||||
* NOTE: the official OpenAPI 2.0 servicespecifikationer are still pending
|
||||
* manual download (dev_docs/bokslut/INDEX.md checklist → api/). When they
|
||||
* land, reconcile these types against the specs — the specs win on conflict
|
||||
* (GUIDE §5.1).
|
||||
*/
|
||||
|
||||
export type BolagsverketEnvironment = 'test' | 'accept' | 'prod'
|
||||
|
||||
export type HandlingTyp =
|
||||
| 'arsredovisning_komplett'
|
||||
| 'arsredovisning'
|
||||
| 'revisionsberattelse'
|
||||
|
||||
// ---- hamta-arsredovisningsinformation/v1.4 ---------------------------------
|
||||
|
||||
export interface GrunduppgifterRakenskapsperiod {
|
||||
from: string
|
||||
tom: string
|
||||
kravPaRevisionsberattelse: 'ja' | 'nej' | 'uppgift_saknas'
|
||||
revisorsplikt: 'ja' | 'nej' | 'uppgift_saknas'
|
||||
}
|
||||
|
||||
export interface GrunduppgifterForetradare {
|
||||
fornamn: string
|
||||
namn: string
|
||||
personnummer: string | null
|
||||
annanIdentitet: string | null
|
||||
funktioner: Array<{ kod: string; text: string }>
|
||||
}
|
||||
|
||||
export interface GrunduppgifterSvar {
|
||||
orgnr: string
|
||||
lopnummer: number | null
|
||||
namn: string
|
||||
status: Array<{ kod?: string; text?: string }>
|
||||
rakenskapsperioder: GrunduppgifterRakenskapsperiod[]
|
||||
foretradare: GrunduppgifterForetradare[]
|
||||
}
|
||||
|
||||
export type ArendestatusTyp =
|
||||
| 'arsred_inkommen'
|
||||
| 'arsred_forelaggande_skickat'
|
||||
| 'arsred_komplettering_inkommen'
|
||||
| 'arsred_registrerad'
|
||||
| 'arsred_avslutad_ej_registrerad'
|
||||
| 'arsred_saknas'
|
||||
|
||||
export interface ArendestatusSvar {
|
||||
orgnr: string
|
||||
namn: string
|
||||
hamtat: string
|
||||
tidpunkt: string | null
|
||||
typ: ArendestatusTyp
|
||||
arendenummer: string | null
|
||||
rakenskapsperiod: { from: string; tom: string } | null
|
||||
}
|
||||
|
||||
// ---- skapa-inlamningtoken (v2.1) -------------------------------------------
|
||||
|
||||
export interface InlamningTokenSvar {
|
||||
token: string
|
||||
/** MUST be shown to and accepted by the user per company; re-show when
|
||||
* avtalstextAndrad changes (GUIDE §4.2, §5.3.1). */
|
||||
avtalstext: string
|
||||
avtalstextAndrad: string
|
||||
}
|
||||
|
||||
// ---- kontrollera (v2.1) ----------------------------------------------------
|
||||
|
||||
export interface KontrolleraUtfall {
|
||||
kod: string
|
||||
text: string
|
||||
typ: string
|
||||
tekniskinformation: Array<{
|
||||
meddelande: string | null
|
||||
element: string | null
|
||||
varde: string | null
|
||||
}> | null
|
||||
}
|
||||
|
||||
export interface KontrolleraSvar {
|
||||
orgnr: string
|
||||
utfall: KontrolleraUtfall[] | null
|
||||
}
|
||||
|
||||
// ---- inlamning (v2.1) ------------------------------------------------------
|
||||
|
||||
export interface InlamningBegaran {
|
||||
undertecknare: string
|
||||
epostadresser: string[]
|
||||
kvittensepostadresser?: string[]
|
||||
notifieringEpostadresser?: string[]
|
||||
handling: { fil: string; typ: HandlingTyp }
|
||||
}
|
||||
|
||||
export interface InlamningSvar {
|
||||
orgnr: string
|
||||
avsandare: string
|
||||
undertecknare: string
|
||||
handlingsinfo: {
|
||||
typ: HandlingTyp
|
||||
dokumentlangd: number
|
||||
idnummer: string
|
||||
sha256checksumma: string
|
||||
}
|
||||
url: string
|
||||
}
|
||||
|
||||
// ---- skapa-kontrollsumma (v1.1) --------------------------------------------
|
||||
|
||||
export interface KontrollsummaSvar {
|
||||
kontrollsumma: string
|
||||
algoritm: string
|
||||
}
|
||||
|
||||
// ---- handelser (v2.0) ------------------------------------------------------
|
||||
|
||||
export interface HandelseMeddelande {
|
||||
typ: string
|
||||
/** Orgnr of the company the event concerns. */
|
||||
id: string
|
||||
/** Per-company sequence number; -1 for the subscription test message. */
|
||||
nr: number
|
||||
tid: string
|
||||
data: {
|
||||
version: string
|
||||
handlingsinfo?: Array<{
|
||||
handling: 'arsredovisning' | 'revisionsberattelse'
|
||||
idnummer: string
|
||||
kontrollsumma?: { digest: string; algoritm: string; upplysning: string | null } | null
|
||||
}>
|
||||
status: ArendestatusTyp | 'test'
|
||||
}
|
||||
}
|
||||
|
||||
export interface HamtaHandelserSvar {
|
||||
meddelanden: HandelseMeddelande[]
|
||||
}
|
||||
|
||||
// ---- submission rows (DB) ---------------------------------------------------
|
||||
|
||||
export type SubmissionStatus =
|
||||
| 'draft'
|
||||
| 'kontrollerad'
|
||||
| 'uploaded'
|
||||
| 'inkommen'
|
||||
| 'forelagd'
|
||||
| 'komplettering'
|
||||
| 'registrerad'
|
||||
| 'avslutad'
|
||||
| 'error'
|
||||
|
||||
export interface ArsredovisningSubmission {
|
||||
id: string
|
||||
company_id: string
|
||||
user_id: string
|
||||
fiscal_period_id: string
|
||||
handling_typ: HandlingTyp
|
||||
taxonomy_version: string
|
||||
entry_point: string
|
||||
environment: BolagsverketEnvironment
|
||||
status: SubmissionStatus
|
||||
undertecknare_namn: string | null
|
||||
undertecknare_epost: string | null
|
||||
idnummer: string | null
|
||||
sha256_checksumma: string | null
|
||||
kontrollsumma: string | null
|
||||
bolagsverket_url: string | null
|
||||
kontrollera_utfall: KontrolleraUtfall[] | null
|
||||
dokument_id: string | null
|
||||
error_message: string | null
|
||||
uploaded_at: string | null
|
||||
registered_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
composeInboxAddress,
|
||||
} from './lib/inbox-provisioning'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
@@ -1648,6 +1650,31 @@ export const invoiceInboxExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Periodisering requires faktureringsmetoden — mirror the main
|
||||
// /api/supplier-invoices guard so kontantmetod companies never store
|
||||
// accrual fields the booking would silently ignore.
|
||||
const hasAccrualItems = body.items.some(
|
||||
(bodyItem) => bodyItem.accrual_period_start && bodyItem.accrual_period_end,
|
||||
)
|
||||
if (hasAccrualItems && body.reverse_charge) {
|
||||
// Omvänd skattskyldighet: the expense line carries the VAT base for
|
||||
// rutor 20–32 — deferring the net to a 17xx interim account would
|
||||
// corrupt the momsdeklaration. Same guard as /api/supplier-invoices.
|
||||
return errorResponseFromCode('SI_CREATE_ACCRUAL_REVERSE_CHARGE', ctx.log)
|
||||
}
|
||||
if (hasAccrualItems) {
|
||||
const { data: methodSettings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', ctx.log, {
|
||||
details: { reason: 'periodisering requires faktureringsmetoden (accrual)' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { data: arrivalNum, error: arrivalError } = await ctx.supabase
|
||||
.rpc('get_next_arrival_number', { p_company_id: ctx.companyId })
|
||||
|
||||
@@ -1675,6 +1702,21 @@ export const invoiceInboxExtension: Extension = {
|
||||
// Self-assessed RC rate (0.06/0.12/0.25) or null — engine defaults
|
||||
// to 25% huvudregeln when null for a reverse-charge invoice.
|
||||
reverse_charge_rate: body.reverse_charge ? (bodyItem.reverse_charge_rate ?? null) : null,
|
||||
// Periodisering: frozen onto the line; the balance account
|
||||
// defaults from the cost account's BAS convention.
|
||||
accrual_period_start:
|
||||
bodyItem.accrual_period_start && bodyItem.accrual_period_end
|
||||
? bodyItem.accrual_period_start
|
||||
: null,
|
||||
accrual_period_end:
|
||||
bodyItem.accrual_period_start && bodyItem.accrual_period_end
|
||||
? bodyItem.accrual_period_end
|
||||
: null,
|
||||
accrual_balance_account:
|
||||
bodyItem.accrual_period_start && bodyItem.accrual_period_end
|
||||
? (bodyItem.accrual_balance_account ??
|
||||
suggestBalanceAccount('expense', bodyItem.account_number))
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1788,9 +1830,10 @@ export const invoiceInboxExtension: Extension = {
|
||||
...lineItem,
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await ctx.supabase
|
||||
const { data: insertedItems, error: itemsError } = await ctx.supabase
|
||||
.from('supplier_invoice_items')
|
||||
.insert(itemInserts)
|
||||
.select('id, sort_order')
|
||||
|
||||
if (itemsError) {
|
||||
await ctx.supabase.from('supplier_invoices').delete().eq('id', invoice.id)
|
||||
@@ -1832,6 +1875,35 @@ export const invoiceInboxExtension: Extension = {
|
||||
.eq('id', item.document_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
}
|
||||
|
||||
if (hasAccrualItems) {
|
||||
// Schedules + catch-up dissolutions for deferred lines. Never
|
||||
// fatal — the registration entry is committed; failures are
|
||||
// retried/surfaced via the periodiseringar page.
|
||||
const idBySortOrder = new Map(
|
||||
((insertedItems ?? []) as Array<{ id: string; sort_order: number }>).map(
|
||||
(row) => [row.sort_order, row.id],
|
||||
),
|
||||
)
|
||||
const itemsWithIds = items.map((lineItem) => ({
|
||||
...lineItem,
|
||||
id: idBySortOrder.get(lineItem.sort_order) ?? null,
|
||||
}))
|
||||
const scheduleResult = await createSchedulesForSupplierInvoice(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
invoice as SupplierInvoice,
|
||||
itemsWithIds as unknown as SupplierInvoiceItem[],
|
||||
journalEntry.id,
|
||||
)
|
||||
if (scheduleResult.failed > 0) {
|
||||
ctx.log.error('accrual schedule creation failed on inbox convert', {
|
||||
supplierInvoiceId: invoice.id,
|
||||
failed: scheduleResult.failed,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
|
||||
// fiscal period covers invoice_date (every other failure throws).
|
||||
|
||||
@@ -69,6 +69,12 @@ export const ExtractionSchema = z.object({
|
||||
dueDate: z.string().nullable(),
|
||||
paymentReference: z.string().nullable(),
|
||||
currency: z.string(),
|
||||
// Service/coverage window the invoice charges for (insurance period,
|
||||
// license term, "avtalsperiod"). Drives the periodisering prefill in the
|
||||
// supplier-invoice form. Optional so cached raw outputs from before this
|
||||
// field still validate.
|
||||
servicePeriodStart: z.string().nullable().optional(),
|
||||
servicePeriodEnd: z.string().nullable().optional(),
|
||||
}),
|
||||
lineItems: z.array(
|
||||
z.object({
|
||||
@@ -122,7 +128,9 @@ Return ONLY a single JSON object that matches this schema exactly. No prose, no
|
||||
"invoiceDate": string | null, // ISO date YYYY-MM-DD
|
||||
"dueDate": string | null, // ISO date YYYY-MM-DD
|
||||
"paymentReference": string | null, // OCR / payment reference
|
||||
"currency": string // ISO 4217 (SEK, USD, EUR, ...). Default "SEK" only if truly indeterminate.
|
||||
"currency": string, // ISO 4217 (SEK, USD, EUR, ...). Default "SEK" only if truly indeterminate.
|
||||
"servicePeriodStart": string | null, // ISO date — start of the service/coverage window the invoice charges for
|
||||
"servicePeriodEnd": string | null // ISO date — end of that window
|
||||
},
|
||||
"lineItems": [
|
||||
{
|
||||
@@ -151,6 +159,7 @@ Rules:
|
||||
- Currency: detect from the document (symbol $/€/kr or explicit code). Use the ISO 4217 code. Do NOT default to SEK if the document clearly shows another currency.
|
||||
- "total" is the amount the buyer must pay (look for "Att betala", "Total", "Amount paid", "Amount due", "Balance"). Prefer this over Subtotal.
|
||||
- Dates: convert any format to YYYY-MM-DD. If the document only shows month/year, leave null.
|
||||
- servicePeriodStart/servicePeriodEnd: only when the document explicitly states the period the charge covers ("Avtalsperiod", "Period", "Försäkringstid", "Subscription period", coverage dates). Never infer from invoice/due dates. Month-only boundaries map to the first resp. last day of the month.
|
||||
- Bankgiro/Plusgiro: only set when the document is for a Swedish supplier on a Swedish bank rail. Do not invent.
|
||||
- Org.nr: only set when it is an actual Swedish organisation number (10 digits, Luhn-valid). For US/EU companies leave null even if they list an EIN/VAT number.
|
||||
- VAT number: include the country prefix.
|
||||
@@ -175,6 +184,8 @@ export function emptyResult(): InvoiceExtractionResult {
|
||||
dueDate: null,
|
||||
paymentReference: null,
|
||||
currency: 'SEK',
|
||||
servicePeriodStart: null,
|
||||
servicePeriodEnd: null,
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: null, vatAmount: null, total: null },
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Pin tests for gnubok_list_accrual_schedules.
|
||||
*
|
||||
* The scope mapping is load-bearing: tools missing from TOOL_SCOPE_MAP are
|
||||
* usable by ANY API key, so the reports:read pin guards against the tool
|
||||
* silently becoming scope-less again.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
|
||||
describe('gnubok_list_accrual_schedules — registration', () => {
|
||||
it('is registered and read-only', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_accrual_schedules')
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool?.annotations.readOnlyHint).toBe(true)
|
||||
expect(tool?.annotations.destructiveHint).toBe(false)
|
||||
})
|
||||
|
||||
it('is mapped to reports:read scope', () => {
|
||||
expect(TOOL_SCOPE_MAP.gnubok_list_accrual_schedules).toBe('reports:read')
|
||||
})
|
||||
})
|
||||
@@ -100,6 +100,10 @@ describe('forbiddenOriginResponse', () => {
|
||||
})
|
||||
|
||||
describe('mcp-server apiRoutes origin enforcement', () => {
|
||||
// The dynamic import pulls in the full 9k-line server module; that parse
|
||||
// alone takes ~4s and flirts with the 5s default timeout under full-suite
|
||||
// parallel load. The test is import-bound, not logic-bound — give it
|
||||
// explicit headroom instead of letting machine load decide the outcome.
|
||||
it('rejects foreign-Origin requests on every /mcp method before dispatch', async () => {
|
||||
const { mcpServerExtension } = await import('../index')
|
||||
const routes = (mcpServerExtension.apiRoutes ?? []).filter((r) => r.path === '/mcp')
|
||||
@@ -114,5 +118,5 @@ describe('mcp-server apiRoutes origin enforcement', () => {
|
||||
)
|
||||
expect(res.status, `${route.method} /mcp`).toBe(403)
|
||||
}
|
||||
})
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
@@ -43,6 +43,9 @@ describe('tools/list payload size guard', () => {
|
||||
// 92 tools for Connectors Directory readiness; the ~10 longest descriptions
|
||||
// were trimmed toward 180–200 chars to partly offset. Headroom reserved for
|
||||
// the upcoming Skatteverket tools.
|
||||
// * Held at 36K when gnubok_list_accrual_schedules (add/bokslut) merged with
|
||||
// the categorize vat_amount override (#717): the combination crossed the
|
||||
// ceiling by ~75, offset by trimming the 8 longest descriptions to ~200 chars.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
TOOL_SCOPE_MAP,
|
||||
} from '@/lib/auth/api-keys'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre, sumOre } from '@/lib/money'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
@@ -2060,7 +2061,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_get_agent_briefing',
|
||||
title: 'Get Agent Briefing',
|
||||
description: 'Bootstrap this company\'s specialized accountant context in one call: profile_summary, the atoms loaded for the company (metadata only — call gnubok_load_skill for bodies), and the top-30 active memories. Call once at session start.',
|
||||
description: 'Bootstrap this company\'s accountant context in one call: profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories. Call once at session start.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -4291,7 +4292,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_query_journal',
|
||||
title: 'Query Journal Lines',
|
||||
description: "Flexible journal-line query — replaces chained ledger calls for ad-hoc questions. Filters: accounts, date range, amount range, voucher series/number, source type, status, project, cost center, free-text. Returns lines with parent voucher metadata + totals.",
|
||||
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher series/number, source type, status, project, cost center, free-text. Returns lines with voucher metadata + totals.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -4666,7 +4667,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_match_transaction_to_invoice',
|
||||
title: 'Match Transaction to Invoice',
|
||||
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer match before staging — preview mirrors what you pass. Supports partial payments and auto-storno of prior categorization.',
|
||||
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer before staging. Supports partial payments and auto-storno of prior categorization.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -5273,7 +5274,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_find_voucher_candidates_for_invoice',
|
||||
title: 'Find Voucher Candidates (Invoice)',
|
||||
description: "List posted verifikat that could be this invoice's payment (faktureringsmetoden: credit 1510; kontantmetoden: debit a bank/cash account 19xx). Call before gnubok_link_invoice_to_voucher to mark a faktura paid against an existing verifikation (no new bokföring).",
|
||||
description: "List posted verifikat that could be this invoice's payment (faktureringsmetoden: credit 1510; kontantmetoden: debit 19xx). Call before gnubok_link_invoice_to_voucher to mark the faktura paid (no new bokföring).",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -5339,7 +5340,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_link_invoice_to_voucher',
|
||||
title: 'Link Invoice to Voucher',
|
||||
description: 'Markera en faktura som betald genom att länka till en befintlig bokförd verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar likvidkonto 19xx). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_invoice först.',
|
||||
description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_invoice först.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -5426,7 +5427,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_find_voucher_candidates_for_supplier_invoice',
|
||||
title: 'Find Voucher Candidates (Supplier Invoice)',
|
||||
description: 'List posted verifikat that debit leverantörsskuld (2440) and could be the payment for this supplier invoice. Use before gnubok_link_supplier_invoice_to_voucher when marking a leverantörsfaktura paid against an existing verifikation (no new bokföring).',
|
||||
description: 'List posted verifikat that debit leverantörsskuld (2440) and could be this supplier invoice\'s payment. Call before gnubok_link_supplier_invoice_to_voucher to mark the leverantörsfaktura paid (no new bokföring).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -5492,7 +5493,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_link_supplier_invoice_to_voucher',
|
||||
title: 'Link Supplier Invoice to Voucher',
|
||||
description: 'Markera en leverantörsfaktura som betald genom att länka till en befintlig verifikation som redan debiterar leverantörsskuld (2440). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_supplier_invoice först.',
|
||||
description: 'Markera en leverantörsfaktura som betald via länk till en befintlig verifikation som debiterar leverantörsskuld (2440). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_supplier_invoice först.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -5955,7 +5956,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_list_inbox_items',
|
||||
title: 'List Inbox Items',
|
||||
description: 'List document inbox items. Each has a `processed` flag covering all terminal links (transaction match, supplier invoice, or journal entry), so a booked receipt counts as done. unprocessed_only=true returns only docs still needing handling.',
|
||||
description: 'List document inbox items. `processed` covers all terminal links (transaction, supplier invoice, journal entry); booked receipts count as done. unprocessed_only=true returns docs still needing handling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -6090,7 +6091,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_create_supplier_invoice_from_inbox',
|
||||
title: 'Create Supplier Invoice from Inbox',
|
||||
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier (matched or via org_number/name), assembles line items from extracted_data, applies VAT + FX, attaches the source document. Stages for human review; honors dry_run.",
|
||||
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX, attaches the document. Stages for human review; honors dry_run.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -8279,7 +8280,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_undo_sie_import',
|
||||
title: 'Undo SIE Import',
|
||||
description: 'Stage undo of a completed SIE import: hard-deletes its entries (transactions + opening balance), detaches docs, resets voucher_sequences, marks the row \'undone\' so the file can be re-imported. Use after a botched import. Period must be open. HIGH risk.',
|
||||
description: 'Stage undo of a completed SIE import: hard-deletes its entries, detaches docs, resets voucher_sequences, marks the import \'undone\' for re-import. Use after a botched import. Period must be open. HIGH risk.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -8380,7 +8381,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_create_voucher',
|
||||
title: 'Create Manual Voucher (Verifikation)',
|
||||
description: 'Stage a manual verifikation with arbitrary balanced lines. Use for capitalization (1010), period-end accruals, FX adjustments, rättelseposter outside categorize_transaction. Pass inbox_item_id to book a kvitto direct — links inbox + attaches doc. HIGH risk.',
|
||||
description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Pass inbox_item_id to book a kvitto direct (links + attaches doc). HIGH risk.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -8778,7 +8779,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_reverse_journal_entry',
|
||||
title: 'Reverse Journal Entry (Storno)',
|
||||
description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Use only when the affärshändelse should never have been booked (duplicate, ghost, test). If booked wrong, use gnubok_correct_entry; for refunds, gnubok_credit_invoice. HIGH risk.',
|
||||
description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Only when the affärshändelse should never have been booked (duplicate, ghost, test). Booked wrong → gnubok_correct_entry; refund → gnubok_credit_invoice. HIGH risk.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -8977,6 +8978,56 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_accrual_schedules',
|
||||
title: 'List Periodiseringar',
|
||||
description:
|
||||
'Löpande periodiseringar (17xx/29xx): monthly installments, dissolved and remaining amounts.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['active', 'completed', 'cancelled', 'all'],
|
||||
description: "Default 'active'.",
|
||||
},
|
||||
},
|
||||
},
|
||||
outputSchema: { type: 'object', additionalProperties: true },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
async execute(args, companyId, _userId, supabase, _actor) {
|
||||
const status = (args.status as string) || 'active'
|
||||
let query = supabase
|
||||
.from('accrual_schedules')
|
||||
.select('*, installments:accrual_schedule_installments(*)')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
if (status !== 'all') query = query.eq('status', status)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(error.message)
|
||||
|
||||
type InstallmentRow = { period_month: string; amount: number; status: string }
|
||||
const schedules = ((data ?? []) as Array<Record<string, unknown>>).map((schedule) => {
|
||||
const installments = ([...((schedule.installments as InstallmentRow[]) ?? [])]).sort(
|
||||
(a, b) => a.period_month.localeCompare(b.period_month),
|
||||
)
|
||||
const dissolved = sumOre(
|
||||
installments.filter((i) => i.status === 'posted').map((i) => Number(i.amount)),
|
||||
)
|
||||
const total = Number(schedule.total_amount)
|
||||
return {
|
||||
...schedule,
|
||||
installments,
|
||||
dissolved_amount: dissolved,
|
||||
remaining_amount:
|
||||
schedule.status === 'cancelled' ? 0 : roundOre(total - dissolved),
|
||||
}
|
||||
})
|
||||
return { schedules, count: schedules.length }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_propose_annual_depreciation',
|
||||
title: 'Propose Annual Depreciation (Avskrivning)',
|
||||
@@ -9213,7 +9264,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_approve_pending_operation',
|
||||
title: 'Approve Pending Operation',
|
||||
description: "Commit a staged pending_operation when the user has explicitly authorised the operation_id. risk_level=high requires confirmed=true — surface the BFL 5 kap 5§ irreversibility to the user first. The /pending web UI offers an equivalent commit path.",
|
||||
description: "Commit a staged pending_operation the user has explicitly authorised. risk_level=high requires confirmed=true — surface the BFL 5 kap 5§ irreversibility first. The /pending web UI offers an equivalent commit path.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
||||
Reference in New Issue
Block a user