* refactor: optimize page loading and data fetching * fix: resolve recurring production runtime errors * feat: add MCP company and customer updates * fix: handle year-end tax adjustments * feat: harden annual report compliance * fix: expand invoice logo and font support * fix: sanitize API route error responses * fix: sanitize user-facing error messages * feat: persist onboarding and tax assessment notices * fix: reduce cloud backup audit churn * feat: refine invoice editor layout * fix: show saved tax adjustments in INK2 * fix: complete annual report API mappings * docs: record operational safeguards and decisions * fix: harden annual report review findings * fix: adjust column span for description based on VAT registration * New css class name
590 lines
19 KiB
TypeScript
590 lines
19 KiB
TypeScript
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, checksum: string) => `${xhtml}<!-- checksum:${checksum} -->`,
|
|
),
|
|
}))
|
|
vi.mock('@/lib/bokslut/ixbrl/validate/rules', () => ({
|
|
runPreflightChecks: vi.fn(() => ({ ok: true, issues: [] })),
|
|
}))
|
|
vi.mock('@/lib/bokslut/ixbrl/validate/arelle-client', () => ({
|
|
validateIxbrlWithArelle: vi.fn(async () => ({
|
|
status: 'passed',
|
|
validator_version: 'test',
|
|
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 { validateIxbrlWithArelle } from '@/lib/bokslut/ixbrl/validate/arelle-client'
|
|
import {
|
|
applyHandelse,
|
|
BolagsverketSubmissionError,
|
|
handleWebhook,
|
|
hashPnr,
|
|
normalizeOrgnr,
|
|
submitArsredovisning,
|
|
} from '../lib/submission-service'
|
|
import type { HandelseMeddelande } from '../types'
|
|
import { makeInput } from '@/lib/bokslut/ixbrl/__tests__/fixtures'
|
|
|
|
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',
|
|
annualReportVersionId: 'version-1',
|
|
avsandarePnr: '198001019879',
|
|
undertecknare: {
|
|
pnr: '198001019879',
|
|
fornamn: 'Karl',
|
|
efternamn: 'Karlsson',
|
|
roll: 'Styrelseledamot',
|
|
epost: 'anna@example.com',
|
|
},
|
|
}
|
|
|
|
const eligibleValidation = {
|
|
digital_filing_eligible: true,
|
|
digital_issues: [],
|
|
}
|
|
|
|
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', 'test-secret')
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
expect(hash).not.toContain('9876')
|
|
expect(hashPnr('company-2', '198301019876', 'test-secret')).not.toBe(hash)
|
|
// Same pnr + company → stable.
|
|
expect(hashPnr('company-1', '198301019876', 'test-secret')).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: 'version-1',
|
|
status: 'signed',
|
|
ixbrl_data: makeInput(),
|
|
validation_summary: eligibleValidation,
|
|
},
|
|
error: null,
|
|
})
|
|
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('blocks a signed version that is not eligible for connected filing', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { org_number: '556000-1111' }, error: null })
|
|
enqueue({
|
|
data: {
|
|
id: 'version-1',
|
|
status: 'signed',
|
|
ixbrl_data: makeInput(),
|
|
validation_summary: {
|
|
digital_filing_eligible: false,
|
|
digital_issues: [{ code: 'AR-DIGITAL-AUDIT' }],
|
|
},
|
|
},
|
|
error: null,
|
|
})
|
|
|
|
await expect(
|
|
submitArsredovisning(
|
|
{
|
|
supabase: supabase as never,
|
|
client: makeClientMock(),
|
|
appUrl: 'https://app.test',
|
|
log: makeLog(),
|
|
},
|
|
submitParams,
|
|
),
|
|
).rejects.toMatchObject({ code: 'BOLAGSVERKET_DIGITAL_INELIGIBLE' })
|
|
})
|
|
|
|
it('requires the certificate signer to match the immutable version', async () => {
|
|
const { supabase } = makeRecordingSupabase([
|
|
{ data: { org_number: '5560001111' } },
|
|
{
|
|
data: {
|
|
id: 'version-1',
|
|
status: 'signed',
|
|
ixbrl_data: makeInput(),
|
|
taxonomy_version: '2024-09-12',
|
|
validation_summary: eligibleValidation,
|
|
},
|
|
},
|
|
{ data: [] },
|
|
{ data: { id: 'acc-1' } },
|
|
{
|
|
data: [
|
|
{
|
|
signer_name: 'Anna Svensson',
|
|
role: 'VD',
|
|
signed_at: '2026-03-01T10:00:00Z',
|
|
status: 'signed',
|
|
signing_method: 'paper_original',
|
|
evidence_reference: 'Arkiv A-1',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
await expect(
|
|
submitArsredovisning(
|
|
{
|
|
supabase,
|
|
client: makeClientMock(),
|
|
appUrl: 'https://app.test',
|
|
log: makeLog(),
|
|
},
|
|
{
|
|
...submitParams,
|
|
undertecknare: { ...submitParams.undertecknare, fornamn: 'Någon annan' },
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'BOLAGSVERKET_CERTIFICATE_SIGNER_MISMATCH',
|
|
})
|
|
})
|
|
|
|
it('marks the submission as unknown and blocks an automatic retry when upload is uncertain', async () => {
|
|
const { supabase, updates } = makeRecordingSupabase([
|
|
{ data: { org_number: '5560001111' } }, // getOrgnr
|
|
{
|
|
data: {
|
|
id: 'version-1',
|
|
status: 'signed',
|
|
ixbrl_data: makeInput(),
|
|
taxonomy_version: '2024-09-12',
|
|
validation_summary: eligibleValidation,
|
|
},
|
|
},
|
|
{ data: [] }, // no active submission
|
|
{ data: { id: 'acc-1' } }, // avtal acceptance exists
|
|
{ data: [{ signer_name: 'Anna Svensson', role: 'VD', signed_at: '2026-03-01T10:00:00Z', status: 'signed', signing_method: 'paper_original', evidence_reference: 'Arkiv A-1' }] },
|
|
{}, // local validation run
|
|
{}, // Arelle validation run
|
|
{ data: null }, // no exact request
|
|
{ data: { id: 'sub-1' } }, // insert submission row
|
|
{}, // update → kontrollerad
|
|
{}, // archive link update
|
|
{}, // update → sending
|
|
{}, // update → unknown
|
|
])
|
|
const client = makeClientMock({
|
|
lamnaIn: vi.fn(async () => {
|
|
throw new Error('inlamning exploded')
|
|
}),
|
|
})
|
|
const log = makeLog()
|
|
|
|
const result = await submitArsredovisning(
|
|
{ supabase, client, appUrl: 'https://app.test', log },
|
|
submitParams,
|
|
)
|
|
|
|
expect(result.outcome).toBe('state_unknown')
|
|
const unknownUpdate = updates.find((u) => u.payload.status === 'unknown')
|
|
expect(unknownUpdate).toBeDefined()
|
|
expect(unknownUpdate!.payload.error_message).toContain('inlamning exploded')
|
|
})
|
|
|
|
it('blocks filing when the exact uploaded bytes cannot be archived first', async () => {
|
|
vi.mocked(uploadDocument).mockRejectedValueOnce(new Error('magic bytes rejected'))
|
|
const { supabase, updates } = makeRecordingSupabase([
|
|
{ data: { org_number: '5560001111' } }, // getOrgnr
|
|
{
|
|
data: {
|
|
id: 'version-1',
|
|
status: 'signed',
|
|
ixbrl_data: makeInput(),
|
|
taxonomy_version: '2024-09-12',
|
|
validation_summary: eligibleValidation,
|
|
},
|
|
},
|
|
{ data: [] }, // no active submission
|
|
{ data: { id: 'acc-1' } }, // avtal acceptance exists
|
|
{ data: [{ signer_name: 'Anna Svensson', role: 'VD', signed_at: '2026-03-01T10:00:00Z', status: 'signed', signing_method: 'paper_original', evidence_reference: 'Arkiv A-1' }] },
|
|
{}, // local validation run
|
|
{}, // Arelle validation run
|
|
{ data: null }, // no exact request
|
|
{ data: { id: 'sub-1' } }, // insert submission row
|
|
{}, // update → kontrollerad
|
|
{}, // archive failure update
|
|
{}, // mark submission error
|
|
])
|
|
const log = makeLog()
|
|
|
|
await expect(
|
|
submitArsredovisning(
|
|
{ supabase, client: makeClientMock(), appUrl: 'https://app.test', log },
|
|
submitParams,
|
|
),
|
|
).rejects.toThrow('Dokumentarkivering misslyckades')
|
|
|
|
// The failure is logged AND visible on the row.
|
|
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')
|
|
|
|
expect(updates.some((update) => update.payload.status === 'sending')).toBe(false)
|
|
})
|
|
|
|
it('reuses a stopped kontrollera row when the user explicitly accepts warnings', async () => {
|
|
const { supabase, updates } = makeRecordingSupabase([
|
|
{ data: { org_number: '5560001111' } },
|
|
{
|
|
data: {
|
|
id: 'version-1',
|
|
status: 'signed',
|
|
ixbrl_data: makeInput(),
|
|
taxonomy_version: '2024-09-12',
|
|
validation_summary: eligibleValidation,
|
|
},
|
|
},
|
|
{ data: [] },
|
|
{ data: { id: 'acc-1' } },
|
|
{
|
|
data: [
|
|
{
|
|
signer_name: 'Anna Svensson',
|
|
role: 'VD',
|
|
signed_at: '2026-03-01T10:00:00Z',
|
|
status: 'signed',
|
|
signing_method: 'paper_original',
|
|
evidence_reference: 'Arkiv A-1',
|
|
},
|
|
],
|
|
},
|
|
{},
|
|
{},
|
|
{ data: { id: 'sub-1', status: 'kontrollerad' } },
|
|
{ data: { id: 'sub-1' } },
|
|
{},
|
|
{},
|
|
{},
|
|
{},
|
|
{},
|
|
])
|
|
|
|
const result = await submitArsredovisning(
|
|
{ supabase, client: makeClientMock(), appUrl: '', log: makeLog() },
|
|
{ ...submitParams, ignoreWarnings: true },
|
|
)
|
|
|
|
expect(result.outcome).toBe('uploaded')
|
|
expect(updates.some((update) => update.payload.status === 'draft')).toBe(true)
|
|
expect(updates.some((update) => update.payload.status === 'uploaded')).toBe(true)
|
|
expect(validateIxbrlWithArelle).toHaveBeenCalledWith(
|
|
expect.stringContaining('checksum:ksum'),
|
|
)
|
|
const archivedFile = vi.mocked(uploadDocument).mock.calls[0][3]
|
|
expect(Buffer.from(archivedFile.buffer).toString('utf8')).toContain('checksum:ksum')
|
|
})
|
|
|
|
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' })
|
|
})
|
|
})
|