Files
accounted/extensions/general/bolagsverket/__tests__/index.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

220 lines
8.8 KiB
TypeScript

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)
})
})