Files
accounted/extensions/general/bolagsverket/__tests__/submission-service.test.ts
T
MattssonandClaude Fable 5 db8983ba9e 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>
2026-06-12 16:35:30 +02:00

416 lines
14 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) => 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' })
})
})