Bug/gh issues fiz (#1103)

* 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
This commit is contained in:
Mattsson
2026-07-21 23:00:15 +02:00
committed by GitHub
parent 702512437a
commit e11f70b347
467 changed files with 17569 additions and 2221 deletions
@@ -38,9 +38,10 @@ function makeCtx(supabase: unknown): ExtensionContext {
const validBody = {
fiscal_period_id: '123e4567-e89b-12d3-a456-426614174000',
avsandare_pnr: '198001019876',
annual_report_version_id: '123e4567-e89b-12d3-a456-426614174001',
avsandare_pnr: '198001019879',
undertecknare: {
pnr: '198001019876',
pnr: '198001019879',
fornamn: 'Anna',
efternamn: 'Svensson',
roll: 'VD',
@@ -62,10 +63,12 @@ interface ErrorEnvelope {
const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL
const ORIGINAL_BV_ENV = process.env.BOLAGSVERKET_ENV
const ORIGINAL_FILING_ENABLED = process.env.BOLAGSVERKET_FILING_ENABLED
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
process.env.BOLAGSVERKET_FILING_ENABLED = 'true'
delete process.env.BOLAGSVERKET_ENV
})
@@ -74,6 +77,8 @@ afterEach(() => {
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
if (ORIGINAL_FILING_ENABLED === undefined) delete process.env.BOLAGSVERKET_FILING_ENABLED
else process.env.BOLAGSVERKET_FILING_ENABLED = ORIGINAL_FILING_ENABLED
})
describe('POST /submissions: write-role enforcement', () => {
@@ -146,9 +151,12 @@ describe('POST /submissions: environment validation + ceiling', () => {
})
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: { outcome: string } }>(res as Response)
const { status, body } = await parseJsonResponse<{
data: { outcome: string; idnummer?: string }
}>(res as Response)
expect(status).toBe(200)
expect(body.data.outcome).toBe('uploaded')
expect(body.data.idnummer).toBeUndefined()
// 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')
@@ -196,6 +204,23 @@ describe('POST /submissions: config + error mapping', () => {
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects an invalid personnummer before calling the filing service', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
const res = await route('POST', '/submissions').handler(
makePost('/submissions', {
...validBody,
avsandare_pnr: '198002309879',
}),
makeCtx(supabase),
)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(submitArsredovisning).not.toHaveBeenCalled()
})
})
describe('GET /status', () => {
@@ -10,11 +10,20 @@ vi.mock('@/lib/bokslut/ixbrl/build-input', () => ({
}))
vi.mock('@/lib/bokslut/ixbrl/document/k2-document', () => ({
generateK2IxbrlDocument: vi.fn(() => ({ xhtml: '<?xml version="1.0"?><html></html>' })),
embedKontrollsumma: vi.fn((xhtml: string) => xhtml),
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' })),
}))
@@ -25,6 +34,7 @@ vi.mock('@/lib/auth/api-keys', () => ({
}))
import { uploadDocument } from '@/lib/core/documents/document-service'
import { validateIxbrlWithArelle } from '@/lib/bokslut/ixbrl/validate/arelle-client'
import {
applyHandelse,
BolagsverketSubmissionError,
@@ -34,6 +44,7 @@ import {
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() }
@@ -96,16 +107,22 @@ const submitParams = {
companyId: 'company-1',
userId: 'user-1',
fiscalPeriodId: 'period-1',
avsandarePnr: '198001019876',
annualReportVersionId: 'version-1',
avsandarePnr: '198001019879',
undertecknare: {
pnr: '198001019876',
fornamn: 'Anna',
efternamn: 'Svensson',
roll: 'VD',
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',
@@ -129,12 +146,12 @@ describe('normalizeOrgnr / hashPnr', () => {
})
it('hashes personnummer with company salt: never the raw value', () => {
const hash = hashPnr('company-1', '19830101-9876')
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')).not.toBe(hash)
expect(hashPnr('company-2', '198301019876', 'test-secret')).not.toBe(hash)
// Same pnr + company → stable.
expect(hashPnr('company-1', '198301019876')).toBe(hash)
expect(hashPnr('company-1', '198301019876', 'test-secret')).toBe(hash)
})
})
@@ -328,6 +345,15 @@ describe('submitArsredovisning', () => {
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(
@@ -341,14 +367,104 @@ describe('submitArsredovisning', () => {
})
})
it('marks the submission row as error and rethrows when inlämning fails', async () => {
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
{}, // markSubmissionError update
{}, // archive link update
{}, // update → sending
{}, // update → unknown
])
const client = makeClientMock({
lamnaIn: vi.fn(async () => {
@@ -357,52 +473,110 @@ describe('submitArsredovisning', () => {
})
const log = makeLog()
await expect(
submitArsredovisning({ supabase, client, appUrl: 'https://app.test', log }, submitParams),
).rejects.toThrow('inlamning exploded')
const result = await submitArsredovisning(
{ supabase, client, appUrl: 'https://app.test', log },
submitParams,
)
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')
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('logs and persists a document-archive failure without blocking the filing', async () => {
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
{}, // error_message update (doc failure)
{}, // update → uploaded
// ensureSubscription throws on the empty appUrl before any query.
{}, // archive failure update
{}, // mark submission error
])
const log = makeLog()
const result = await submitArsredovisning(
{ supabase, client: makeClientMock(), appUrl: '', log },
submitParams,
)
expect(result.outcome).toBe('uploaded')
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.
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()
expect(updates.some((update) => update.payload.status === 'sending')).toBe(false)
})
// Subscription failure (invalid appUrl) is logged, not swallowed.
expect(log.warn.mock.calls.some(([msg]) => /prenumeration/.test(String(msg)))).toBe(true)
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', () => {
+45 -7
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { validateSwedishPersonalNumber } from '@/lib/extensions/validation'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -63,6 +64,10 @@ function environmentCeiling(): BolagsverketEnvironment {
return isBolagsverketEnvironment(raw) ? raw : 'test'
}
function filingReleaseEnabled(): boolean {
return process.env.BOLAGSVERKET_FILING_ENABLED === 'true'
}
/**
* Resolve the effective Bolagsverket environment for a company.
*
@@ -77,13 +82,16 @@ function environmentCeiling(): BolagsverketEnvironment {
* 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
const { data, error } = await ctx.supabase
.from('extension_data')
.select('value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'general/bolagsverket')
.eq('key', 'settings')
.maybeSingle()
if (error) {
throw new Error(`Failed to resolve Bolagsverket environment: ${error.message}`)
}
const configured = (data?.value as { environment?: unknown } | null)?.environment
const ceiling = environmentCeiling()
if (configured === undefined || configured === null || configured === '') {
@@ -120,11 +128,12 @@ async function clientFor(ctx: ExtensionContext): Promise<BolagsverketClient> {
}
async function companyOrgnr(ctx: ExtensionContext): Promise<string> {
const { data } = await ctx.supabase
const { data, error } = await ctx.supabase
.from('company_settings')
.select('org_number')
.eq('company_id', ctx.companyId)
.maybeSingle()
if (error) throw new Error(`Kunde inte läsa organisationsnummer: ${error.message}`)
const orgNumber = (data as { org_number?: string } | null)?.org_number
if (!orgNumber) throw new Error('Organisationsnummer saknas i företagsinställningarna.')
return normalizeOrgnr(orgNumber)
@@ -186,18 +195,34 @@ function apiErrorResponse(err: unknown, ctx: ExtensionContext): NextResponse {
const noContextResponse = () =>
NextResponse.json({ error: { code: 'NO_CONTEXT', message: 'Saknar kontext' } }, { status: 500 })
const PersonnummerSchema = z
.string()
.regex(/^\d{12}$/, 'Personnummer ska normaliseras till 12 siffror')
.refine((value) => validateSwedishPersonalNumber(value) === null, 'Ogiltigt personnummer')
.refine((value) => {
const year = Number(value.slice(0, 4))
const month = Number(value.slice(4, 6))
const day = Number(value.slice(6, 8))
const date = new Date(Date.UTC(year, month - 1, day))
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
)
}, 'Ogiltigt födelsedatum')
const SubmitSchema = z.object({
fiscal_period_id: z.string().uuid(),
avsandare_pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 10-12 siffror'),
annual_report_version_id: z.string().uuid(),
avsandare_pnr: PersonnummerSchema,
undertecknare: z.object({
pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 10-12 siffror'),
pnr: PersonnummerSchema,
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(),
})
@@ -226,6 +251,7 @@ export const bolagsverketExtension: Extension = {
environment_ceiling: environmentCeiling(),
// Certificate material is env-only; settings can never carry it.
has_certificate: Boolean(config.clientCertPem && config.clientKeyPem),
filing_enabled: filingReleaseEnabled(),
},
})
} catch (err) {
@@ -273,7 +299,7 @@ export const bolagsverketExtension: Extension = {
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',
'id, fiscal_period_id, annual_report_version_id, handling_typ, taxonomy_version, entry_point, environment, status, archive_status, undertecknare_namn, undertecknare_epost, 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 })
@@ -294,6 +320,11 @@ export const bolagsverketExtension: Extension = {
path: '/submissions',
handler: async (request, ctx) => {
if (!ctx) return noContextResponse()
if (!filingReleaseEnabled()) {
return errorResponseFromCode('BOLAGSVERKET_NOT_RELEASED', ctx.log, {
requestId: ctx.requestId,
})
}
const forbidden = await requireWriteRole(ctx)
if (forbidden) return forbidden
let parsed: z.infer<typeof SubmitSchema>
@@ -326,14 +357,21 @@ export const bolagsverketExtension: Extension = {
companyId: ctx.companyId,
userId: ctx.userId,
fiscalPeriodId: parsed.fiscal_period_id,
annualReportVersionId: parsed.annual_report_version_id,
avsandarePnr: parsed.avsandare_pnr,
undertecknare: parsed.undertecknare,
kvittensEpost: parsed.kvittens_epost,
proposedDividend: parsed.utdelning,
acceptedAvtalstextAndrad: parsed.accepted_avtalstext_andrad,
ignoreWarnings: parsed.ignore_warnings,
},
)
// Bolagsverket marks idnummer as a technical correlation value that
// must not be shown to end users. Keep it in the server-side filing
// record for webhook matching, but omit it from browser responses.
if (result.outcome === 'uploaded' || result.outcome === 'state_unknown') {
const { idnummer: _idnummer, ...publicResult } = result
return NextResponse.json({ data: publicResult })
}
return NextResponse.json({ data: result })
} catch (err) {
ctx.log.error('bolagsverket submission failed', err)
@@ -8,18 +8,18 @@
* there: uploaded → inkommen → (förelagd ↔ komplettering)* →
* registrerad | avslutad.
*
* Personnummer are transient: used for the API calls, persisted only as
* company-salted SHA-256 hashes.
* Personnummer are transient: used for the API calls and never persisted.
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { createHash, createHmac, 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 { validateIxbrlWithArelle } from '@/lib/bokslut/ixbrl/validate/arelle-client'
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
import { BolagsverketClient } from './client'
import type { ExtensionLogger } from '@/lib/extensions/types'
import type {
@@ -47,14 +47,18 @@ export class BolagsverketSubmissionError extends Error {
/** Statuses that mean "Bolagsverket currently holds an open filing for this period". */
export const ACTIVE_SUBMISSION_STATUSES = [
'sending',
'uploaded',
'unknown',
'inkommen',
'forelagd',
'komplettering',
] as const
export function hashPnr(companyId: string, pnr: string): string {
return createHash('sha256').update(`${companyId}:${pnr.replace(/\D/g, '')}`).digest('hex')
export function hashPnr(companyId: string, pnr: string, secret: string): string {
return createHmac('sha256', secret)
.update(`${companyId}:${pnr.replace(/\D/g, '')}`)
.digest('hex')
}
/** Normalize to the 10-digit orgnr the API expects (no dash, no century). */
@@ -67,13 +71,15 @@ export function normalizeOrgnr(orgNumber: string): string {
* 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).
* the params object itself. No personnummer-derived value is needed for the
* filing audit trail; hashPnr exists only for integrations that need a keyed,
* non-enumerable correlation reference.
*/
export interface SubmitParams {
companyId: string
userId: string
fiscalPeriodId: string
annualReportVersionId: string
/** Avsändarens personnummer (12 siffror): required by skapa-inlamningtoken. */
avsandarePnr: string
/** Undertecknare of fastställelseintyget. */
@@ -85,7 +91,6 @@ export interface SubmitParams {
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). */
@@ -108,6 +113,13 @@ export type SubmitResult =
url: string
utfall: KontrolleraUtfall[]
}
| {
outcome: 'state_unknown'
submissionId: string
idnummer: string | null
url: string | null
message: string
}
interface ServiceDeps {
supabase: SupabaseClient
@@ -124,11 +136,15 @@ async function markSubmissionError(
log: ExtensionLogger,
submissionId: string,
err: unknown,
remoteUploadStarted = false,
): 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) })
.update({
status: remoteUploadStarted ? 'unknown' : '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 })
@@ -136,11 +152,12 @@ async function markSubmissionError(
}
async function getOrgnr(supabase: SupabaseClient, companyId: string): Promise<string> {
const { data } = await supabase
const { data, error } = await supabase
.from('company_settings')
.select('org_number')
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Kunde inte läsa organisationsnummer: ${error.message}`)
const orgNumber = (data as { org_number?: string } | null)?.org_number
if (!orgNumber) throw new Error('Organisationsnummer saknas i företagsinställningarna.')
return normalizeOrgnr(orgNumber)
@@ -153,18 +170,55 @@ export async function submitArsredovisning(
const { supabase, client, log } = deps
const orgnr = await getOrgnr(supabase, params.companyId)
const { data: versionRow, error: versionError } = await supabase
.from('annual_report_versions')
.select(
'id, status, content_hash, ixbrl_data, entry_point, taxonomy_version, validation_summary',
)
.eq('id', params.annualReportVersionId)
.eq('company_id', params.companyId)
.eq('fiscal_period_id', params.fiscalPeriodId)
.maybeSingle()
if (versionError || !versionRow) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_VERSION_NOT_FOUND',
'The annual report version does not exist for this company and period.',
)
}
if (versionRow.status !== 'signed' || !versionRow.ixbrl_data) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_VERSION_NOT_SIGNED',
'The annual report version must be signed and contain iXBRL before submission.',
{ annual_report_version_id: versionRow.id, status: versionRow.status },
)
}
const validationSnapshot = versionRow.validation_summary as {
digital_filing_eligible?: boolean
digital_issues?: unknown[]
}
if (validationSnapshot.digital_filing_eligible !== true) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_DIGITAL_INELIGIBLE',
'The immutable annual report version is not eligible for connected filing.',
{ issues: validationSnapshot.digital_issues ?? [] },
)
}
// 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
// not block. A pre-upload retry reuses its idempotency row.
const { data: activeRows, error: activeSubmissionError } = 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)
if (activeSubmissionError) {
throw new Error(`Kunde inte kontrollera tidigare inlämningar: ${activeSubmissionError.message}`)
}
const active = (activeRows as Array<{ id: string; status: string }> | null)?.[0]
if (active) {
throw new BolagsverketSubmissionError(
@@ -179,13 +233,16 @@ export async function submitArsredovisning(
// 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
const { data: acceptance, error: acceptanceError } = await supabase
.from('bolagsverket_avtal_acceptances')
.select('id')
.eq('company_id', params.companyId)
.eq('user_id', params.userId)
.eq('avtalstext_andrad', token.avtalstextAndrad)
.maybeSingle()
if (acceptanceError) {
throw new Error(`Kunde inte kontrollera avtalsgodkännandet: ${acceptanceError.message}`)
}
const acceptedNow = params.acceptedAvtalstextAndrad === token.avtalstextAndrad
if (!acceptance && !acceptedNow) {
return {
@@ -195,23 +252,85 @@ export async function submitArsredovisning(
}
}
if (!acceptance && acceptedNow) {
await supabase.from('bolagsverket_avtal_acceptances').insert({
const { error: acceptanceInsertError } = await supabase.from('bolagsverket_avtal_acceptances').insert({
company_id: params.companyId,
user_id: params.userId,
avtalstext_andrad: token.avtalstextAndrad,
})
if (acceptanceInsertError) {
throw new Error(`Kunde inte spara avtalsgodkännandet: ${acceptanceInsertError.message}`)
}
}
// 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,
},
// 3. Render from the immutable, signed version. Signature evidence is
// stored separately from the content snapshot and overlaid here.
const input = structuredClone(versionRow.ixbrl_data) as IxbrlArsredovisningInput
const { data: signatureRows, error: signatureError } = await supabase
.from('arsredovisning_signature_requests')
.select('signer_name, role, signed_at, status, signing_method, evidence_reference')
.eq('company_id', params.companyId)
.eq('fiscal_period_id', params.fiscalPeriodId)
.eq('annual_report_version_id', params.annualReportVersionId)
if (signatureError) {
throw new Error(`Failed to load signature evidence: ${signatureError.message}`)
}
const signatures = (signatureRows ?? []) as Array<{
signer_name: string
role: string
signed_at: string | null
status: string
signing_method: string | null
evidence_reference: string | null
}>
if (
signatures.length === 0 ||
signatures.some(
(signature) =>
signature.status !== 'signed' ||
!signature.signed_at ||
!signature.signing_method ||
!signature.evidence_reference,
)
) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_SIGNATURE_EVIDENCE_INCOMPLETE',
'Every required signer must have version-bound signature evidence.',
{ annual_report_version_id: params.annualReportVersionId },
)
}
input.underskrifter.signers = signatures.map((signature) => {
const parts = signature.signer_name.trim().split(/\s+/)
return {
firstName: parts.length > 1 ? parts.slice(0, -1).join(' ') : parts[0],
lastName: parts.length > 1 ? parts.at(-1) ?? parts[0] : parts[0],
role: signature.role,
signedDate: signature.signed_at?.slice(0, 10) ?? null,
}
})
input.underskrifter.harVd = input.underskrifter.signers.some((signer) =>
/verkställande direktör|^vd$/i.test(signer.role ?? ''),
)
input.underskrifter.dateringsdatum = input.underskrifter.signers
.map((signer) => signer.signedDate)
.filter((date): date is string => date !== null)
.sort()
.at(-1) ?? null
const normalizeSignerText = (value: string) => value.trim().replace(/\s+/g, ' ').toLocaleLowerCase('sv')
if (
normalizeSignerText(input.faststallelseintyg.signerFirstName) !==
normalizeSignerText(params.undertecknare.fornamn) ||
normalizeSignerText(input.faststallelseintyg.signerLastName) !==
normalizeSignerText(params.undertecknare.efternamn) ||
normalizeSignerText(input.faststallelseintyg.signerRole) !==
normalizeSignerText(params.undertecknare.roll)
) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_CERTIFICATE_SIGNER_MISMATCH',
'The certificate signer must match the signer locked into the annual report version.',
{ annual_report_version_id: params.annualReportVersionId },
)
}
input.faststallelseintyg.genereratDatum = new Date().toISOString().slice(0, 10)
const preflight = runPreflightChecks(input)
if (!preflight.ok) {
return { outcome: 'preflight_failed', issues: preflight.issues }
@@ -238,47 +357,148 @@ export async function submitArsredovisning(
})
}
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')
// Validate the final bytes after the optional checksum was embedded. The
// recorded artifact hash must identify the same XHTML that is archived and
// uploaded, otherwise the validation evidence cannot prove what was filed.
const artifactHash = createHash('sha256').update(xhtml).digest('hex')
const { error: localValidationError } = await supabase
.from('annual_report_validation_runs')
.insert({
company_id: params.companyId,
fiscal_period_id: params.fiscalPeriodId,
version_id: params.annualReportVersionId,
user_id: params.userId,
validation_layer: 'local',
status: 'passed',
validator_version: 'accounted-preflight-1',
artifact_hash: artifactHash,
issues: preflight.issues,
})
if (localValidationError) {
throw new Error(`Kunde inte spara lokal validering: ${localValidationError.message}`)
}
const arelle = await validateIxbrlWithArelle(xhtml)
const { error: arelleValidationError } = await supabase
.from('annual_report_validation_runs')
.insert({
company_id: params.companyId,
fiscal_period_id: params.fiscalPeriodId,
version_id: params.annualReportVersionId,
user_id: params.userId,
validation_layer: 'arelle',
status: arelle.status,
validator_version: arelle.validator_version,
artifact_hash: artifactHash,
issues: arelle.issues,
})
if (arelleValidationError) {
throw new Error(`Kunde inte spara Arelle-validering: ${arelleValidationError.message}`)
}
if (arelle.status === 'unavailable') {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_ARELLE_UNAVAILABLE',
'Arelle validation is unavailable. Connected filing is blocked.',
{ issues: arelle.issues },
)
}
if (arelle.status === 'failed') {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_ARELLE_FAILED',
'Arelle found blocking taxonomy validation errors.',
{ issues: arelle.issues },
)
}
const fileBase64 = Buffer.from(xhtml, 'utf8').toString('base64')
const requestKey = createHash('sha256')
.update(
`${params.companyId}:${params.annualReportVersionId}:${client.environment}:arsredovisning_komplett`,
)
.digest('hex')
const { data: existingRequest, error: existingRequestError } = await supabase
.from('arsredovisning_submissions')
.select('id, status')
.eq('company_id', params.companyId)
.eq('environment', client.environment)
.eq('request_key', requestKey)
.maybeSingle()
if (existingRequestError) {
throw new Error(`Kunde inte kontrollera inlämningens idempotensnyckel: ${existingRequestError.message}`)
}
const submissionPayload = {
company_id: params.companyId,
user_id: params.userId,
fiscal_period_id: params.fiscalPeriodId,
annual_report_version_id: params.annualReportVersionId,
request_key: requestKey,
handling_typ: 'arsredovisning_komplett',
taxonomy_version: '2024-09-12',
taxonomy_version: versionRow.taxonomy_version,
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),
undertecknare_pnr_hash: null,
avsandare_pnr_hash: null,
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
error_message: null,
}
// 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.
// 5. Persist before talking to Bolagsverket. A stopped kontrollera or a
// pre-upload error reuses the same idempotency row, which makes the
// explicit "continue despite warnings" action work without creating a
// second filing attempt for identical content.
let submissionRow: { id: string } | null = null
if (existingRequest) {
if (!['draft', 'kontrollerad', 'error'].includes(existingRequest.status)) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_SUBMISSION_EXISTS',
'This exact annual report version already has a submission attempt.',
{ submission_id: existingRequest.id, status: existingRequest.status },
)
}
const { data, error } = await supabase
.from('arsredovisning_submissions')
.update({ ...submissionPayload, status: 'draft' })
.eq('id', existingRequest.id)
.eq('company_id', params.companyId)
.select('id')
.single()
if (error || !data) {
throw new Error(`Kunde inte återuppta inlämningsförsöket: ${error?.message ?? 'okänt fel'}`)
}
submissionRow = data as { id: string }
} else {
const { data, error } = await supabase
.from('arsredovisning_submissions')
.insert({ ...submissionPayload, status: 'draft' })
.select('id')
.single()
if (error || !data) {
throw new Error(`Kunde inte spara inlämningsförsöket: ${error?.message ?? 'okänt fel'}`)
}
submissionRow = data as { id: string }
}
const submissionId = submissionRow.id
// Steps 6-8 talk to Bolagsverket with a persisted row in play. Failures
// before the external upload become error. Once upload starts, uncertain
// outcomes become unknown and retries are blocked until reconciled.
let svar: Awaited<ReturnType<BolagsverketClient['lamnaIn']>>
let utfall: KontrolleraUtfall[]
let remoteUploadStarted = false
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
const { error: kontrollUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({ status: 'kontrollerad', kontrollera_utfall: utfall })
.eq('id', submissionId)
if (kontrollUpdateError) {
throw new Error(`Kunde inte spara Bolagsverkets kontrollresultat: ${kontrollUpdateError.message}`)
}
const hasBlocking = utfall.some((item) => item.typ?.toLowerCase() === 'error')
if (utfall.length > 0 && (hasBlocking || !params.ignoreWarnings)) {
return { outcome: 'kontrollera_stopped', submissionId, utfall }
@@ -286,10 +506,10 @@ export async function submitArsredovisning(
// 7. Store the exact uploaded bytes as räkenskapsinformation (7-year
// retention, Accounting Guard Rail #7) BEFORE upload.
let dokumentId: string | null = null
const buffer = Buffer.from(xhtml, 'utf8')
let doc: Awaited<ReturnType<typeof uploadDocument>>
try {
const buffer = Buffer.from(xhtml, 'utf8')
const doc = await uploadDocument(
doc = await uploadDocument(
supabase,
params.userId,
params.companyId,
@@ -300,33 +520,69 @@ export async function submitArsredovisning(
},
{ 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', {
await supabase
.from('arsredovisning_submissions')
.update({ archive_status: 'failed', error_message: message.slice(0, 2_000) })
.eq('id', submissionId)
throw new Error(`Dokumentarkivering misslyckades: ${message}`)
}
const dokumentId = doc.id
const { error: archiveUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({ dokument_id: dokumentId, archive_status: 'stored' })
.eq('id', submissionId)
if (archiveUpdateError) {
throw new Error(`Kunde inte koppla det arkiverade dokumentet: ${archiveUpdateError.message}`)
}
// 8. Lämna in till eget utrymme.
const uploadStartedAt = new Date().toISOString()
const { error: sendingUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({ status: 'sending', upload_started_at: uploadStartedAt })
.eq('id', submissionId)
if (sendingUpdateError) {
throw new Error(`Kunde inte låsa inlämningsförsöket före uppladdning: ${sendingUpdateError.message}`)
}
remoteUploadStarted = true
try {
svar = await client.lamnaIn(token.token, {
undertecknare: params.undertecknare.pnr,
epostadresser: [params.undertecknare.epost],
kvittensepostadresser: params.kvittensEpost,
fileBase64,
typ: 'arsredovisning_komplett',
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
await supabase
.from('arsredovisning_submissions')
.update({ status: 'unknown', error_message: message.slice(0, 2_000) })
.eq('id', submissionId)
log.error('Bolagsverket upload outcome is unknown: retry blocked', {
submissionId,
companyId: params.companyId,
error: message,
})
await supabase
.from('arsredovisning_submissions')
.update({ error_message: `Dokumentarkivering misslyckades: ${message}`.slice(0, 2_000) })
.eq('id', submissionId)
return {
outcome: 'state_unknown',
submissionId,
idnummer: null,
url: null,
message:
'Bolagsverkets mottagande kunde inte bekräftas. Försök inte igen innan status har stämts av.',
}
}
// 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 uploadedAt = new Date().toISOString()
const externalReceipt = {
idnummer: svar.handlingsinfo.idnummer,
sha256_checksumma: svar.handlingsinfo.sha256checksumma,
url: svar.url,
received_at: uploadedAt,
}
const { error: uploadUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({
@@ -335,18 +591,60 @@ export async function submitArsredovisning(
sha256_checksumma: svar.handlingsinfo.sha256checksumma,
bolagsverket_url: svar.url,
dokument_id: dokumentId,
uploaded_at: new Date().toISOString(),
external_receipt: externalReceipt,
uploaded_at: uploadedAt,
})
.eq('id', submissionId)
if (uploadUpdateError) {
log.error('failed to persist uploaded state after successful inlämning', {
log.error('failed to persist uploaded state after successful inlämning: marking unknown', {
submissionId,
idnummer: svar.handlingsinfo.idnummer,
dbError: uploadUpdateError.message,
})
const serviceClient = createServiceClientNoCookies()
const { error: recoveryError } = await serviceClient
.from('arsredovisning_submissions')
.update({
status: 'unknown',
idnummer: svar.handlingsinfo.idnummer,
sha256_checksumma: svar.handlingsinfo.sha256checksumma,
bolagsverket_url: svar.url,
dokument_id: dokumentId,
external_receipt: externalReceipt,
uploaded_at: uploadedAt,
error_message: `Uppladdningen lyckades men lokal status kunde inte bekräftas: ${uploadUpdateError.message}`.slice(0, 2_000),
})
.eq('id', submissionId)
if (recoveryError) {
log.error('could not persist unknown state after successful remote upload', {
submissionId,
dbError: recoveryError.message,
})
}
return {
outcome: 'state_unknown',
submissionId,
idnummer: svar.handlingsinfo.idnummer,
url: svar.url,
message:
'Bolagsverket tog emot dokumentet men Accounted kunde inte bekräfta lokal status. Skicka inte igen.',
}
}
const { error: versionStatusError } = await supabase
.from('annual_report_versions')
.update({ status: 'filed' })
.eq('id', params.annualReportVersionId)
.eq('company_id', params.companyId)
.eq('status', 'signed')
if (versionStatusError) {
log.error('failed to mark annual report version as filed', {
submissionId,
annualReportVersionId: params.annualReportVersionId,
dbError: versionStatusError.message,
})
}
} catch (err) {
await markSubmissionError(supabase, log, submissionId, err)
await markSubmissionError(supabase, log, submissionId, err, remoteUploadStarted)
throw err // preserved for the route's error mapping (5xx / upstream status)
}
@@ -490,16 +788,21 @@ export async function applyHandelse(
// submission for the company.
const base = serviceClient
.from('arsredovisning_submissions')
.select('id, status, fiscal_period_id, user_id, company_id')
.select('id, status, fiscal_period_id, user_id, company_id, annual_report_version_id')
.eq('company_id', companyId)
const filtered =
idnummerList.length > 0
? base.in('idnummer', idnummerList)
: base.in('status', ['uploaded', 'inkommen', 'forelagd', 'komplettering'])
: base.in('status', ['unknown', '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'
| 'id'
| 'status'
| 'fiscal_period_id'
| 'user_id'
| 'company_id'
| 'annual_report_version_id'
>[] | null)?.[0]
if (!submission) continue
if (submission.status === mapped) continue
@@ -538,6 +841,21 @@ export async function applyHandelse(
},
})
if (mapped === 'registrerad') {
if (submission.annual_report_version_id) {
const { error: versionError } = await serviceClient
.from('annual_report_versions')
.update({ status: 'registered' })
.eq('id', submission.annual_report_version_id)
.eq('company_id', companyId)
.eq('status', 'filed')
if (versionError) {
log?.warn('registered submission did not update annual report version', {
submissionId: submission.id,
versionId: submission.annual_report_version_id,
dbError: versionError.message,
})
}
}
await eventBus.emit({
type: 'arsredovisning.registered',
payload: {
@@ -9,7 +9,10 @@
"BOLAGSVERKET_ENV",
"BOLAGSVERKET_CLIENT_CERT",
"BOLAGSVERKET_CLIENT_KEY",
"BOLAGSVERKET_CA"
"BOLAGSVERKET_CA",
"BOLAGSVERKET_FILING_ENABLED",
"BOLAGSVERKET_ARELLE_VALIDATOR_URL",
"BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN"
],
"npmDependencies": [],
"definition": {
+4 -1
View File
@@ -13,7 +13,7 @@ export type BolagsverketEnvironment = 'test' | 'accept' | 'prod'
export type HandlingTyp =
| 'arsredovisning_komplett'
| 'arsredovisning'
| 'arsredovisning_kompletteras'
| 'revisionsberattelse'
// ---- hamta-arsredovisningsinformation/v1.4 ---------------------------------
@@ -147,7 +147,9 @@ export interface HamtaHandelserSvar {
export type SubmissionStatus =
| 'draft'
| 'kontrollerad'
| 'sending'
| 'uploaded'
| 'unknown'
| 'inkommen'
| 'forelagd'
| 'komplettering'
@@ -160,6 +162,7 @@ export interface ArsredovisningSubmission {
company_id: string
user_id: string
fiscal_period_id: string
annual_report_version_id: string | null
handling_typ: HandlingTyp
taxonomy_version: string
entry_point: string
@@ -153,7 +153,7 @@ function makeSupabase(data: MockData) {
return resolve({ data: [], error: null })
},
}
const passthrough = ['select', 'eq', 'in', 'order', 'range', 'limit']
const passthrough = ['select', 'eq', 'neq', 'in', 'order', 'range', 'limit']
for (const method of passthrough) {
chain[method] = vi.fn().mockImplementation((col?: string, val?: string) => {
if (method === 'eq' && col === 'key') key = val ?? null
@@ -162,7 +162,7 @@ function makeSupabase(data: MockData) {
}
return chain
})
return { supabase: { from } as any, upsert }
return { supabase: { from } as any, from, upsert }
}
function syncParams(supabase: any, overrides: Record<string, unknown> = {}) {
@@ -310,6 +310,22 @@ describe('performSync needs_reauth handling', () => {
})
describe('performSync per-fiscal-year layout', () => {
it('ignores extension runtime state when fingerprinting Grunddata', async () => {
const { supabase, from } = makeSupabase({
connection: makeConnection(),
lastSync: upToDateLastSync(),
})
await performSync(syncParams(supabase))
const auditCallIndex = from.mock.calls.findIndex(([table]) => table === 'audit_log')
expect(auditCallIndex).toBeGreaterThanOrEqual(0)
const auditQuery = from.mock.results[auditCallIndex].value
expect(auditQuery.neq).toHaveBeenCalledWith('table_name', 'extension_data')
expect(auditQuery.order).toHaveBeenNthCalledWith(1, 'created_at', { ascending: false })
expect(auditQuery.order).toHaveBeenNthCalledWith(2, 'id', { ascending: false })
})
it('uploads one archive per period plus Grunddata and the folder README on first sync', async () => {
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
+7 -1
View File
@@ -494,12 +494,18 @@ async function fetchLatestAuditAt(
supabase: SupabaseClient,
companyId: string
): Promise<string> {
const { data } = await supabase
const { data, error } = await supabase
.from('audit_log')
.select('created_at')
.eq('company_id', companyId)
// extension_data is non-portable runtime state and includes this sync's
// own progress snapshots. Letting those rows advance the watermark makes
// every completed backup invalidate Grunddata for the next run.
.neq('table_name', 'extension_data')
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(1)
if (error) throw new Error(`Failed to fetch backup audit watermark: ${error.message}`)
const rows = (data as { created_at: string }[] | null) ?? []
return rows[0]?.created_at ?? ''
}
+1 -1
View File
@@ -21,7 +21,7 @@ For internal Anthropic API usage (today only `extensions/general/invoice-inbox/l
## Payload-size watchdog
`payload-size.bench.test.ts` enforces a `tools/list` JSON payload ceiling (currently 36,000 tokens, bumped from 32,000 when top-level `Tool.title` landed on all tools for Claude Connectors Directory readiness). If the test fires, the right answer is rarely "raise the ceiling". Instead, trim descriptions or leverage `gnubok_search_tools` (already deployed; tool definitions can defer to it for discovery rather than enumerating in `tools/list`).
`payload-size.bench.test.ts` enforces a `tools/list` JSON payload ceiling. If the test fires, the right answer is rarely "raise the ceiling". Instead, trim descriptions or set specialized wide tools to `catalogVisibility: 'search'`. Those tools remain discoverable with full schemas through `gnubok_search_tools` and callable through `tools/call` without bloating the default catalog.
## Where things live
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import { listAnnualReportVersions } from '@/lib/bokslut/arsredovisning/version-service'
import { tools } from '../server'
vi.mock('@/lib/bokslut/arsredovisning/model', () => ({
buildCanonicalAnnualReport: vi.fn(),
}))
vi.mock('@/lib/bokslut/arsredovisning/version-service', () => ({
listAnnualReportVersions: vi.fn(),
}))
const annualReportToolNames = [
'gnubok_preview_arsredovisning',
'gnubok_validate_arsredovisning',
'gnubok_list_arsredovisning_versions',
'gnubok_get_arsredovisning_filing_status',
] as const
function tool(name: (typeof annualReportToolNames)[number]) {
const found = tools.find((candidate) => candidate.name === name)
if (!found) throw new Error(`Missing MCP tool ${name}`)
return found
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('annual report MCP tools', () => {
it('registers every annual report tool as read-only and scope protected', () => {
for (const name of annualReportToolNames) {
const registered = tool(name)
expect(registered.annotations).toMatchObject({
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
})
expect(TOOL_SCOPE_MAP[name]).toBe('reports:read')
}
})
it('uses signing validation before a report version is locked', async () => {
const { supabase } = createQueuedMockSupabase()
vi.mocked(buildCanonicalAnnualReport).mockResolvedValue({
report: { accounting_framework: 'k2' },
profile: {},
eligibility: { k2_eligible: true },
validation: { ok: true, issues: [] },
} as never)
const result = await tool('gnubok_validate_arsredovisning').execute(
{ fiscal_period_id: 'period-1', stage: 'signing' },
'company-1',
'user-1',
supabase as never,
)
expect(buildCanonicalAnnualReport).toHaveBeenCalledWith(
supabase,
'company-1',
'period-1',
{ stage: 'signing', includeIxbrl: false },
)
expect(result).toMatchObject({ fiscal_period_id: 'period-1', framework: 'k2' })
})
it('lists immutable versions through the canonical version service', async () => {
const { supabase } = createQueuedMockSupabase()
vi.mocked(listAnnualReportVersions).mockResolvedValue([
{ id: 'version-1', version_number: 1, status: 'signed' },
] as never)
const result = await tool('gnubok_list_arsredovisning_versions').execute(
{ fiscal_period_id: 'period-1' },
'company-1',
'user-1',
supabase as never,
)
expect(listAnnualReportVersions).toHaveBeenCalledWith(
supabase,
'company-1',
'period-1',
)
expect(result).toMatchObject({
fiscal_period_id: 'period-1',
versions: [{ id: 'version-1', status: 'signed' }],
})
})
it('keeps Bolagsverket technical document ids out of filing-status output', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{
id: 'submission-1',
status: 'uploaded',
idnummer: 'technical-id',
bolagsverket_url: 'https://example.test/sign',
},
],
error: null,
})
const result = await tool('gnubok_get_arsredovisning_filing_status').execute(
{ fiscal_period_id: 'period-1' },
'company-1',
'user-1',
supabase as never,
) as { submissions: Array<Record<string, unknown>> }
expect(result.submissions).toEqual([
{
id: 'submission-1',
status: 'uploaded',
bolagsverket_url: 'https://example.test/sign',
},
])
})
})
@@ -0,0 +1,203 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
import { tools } from '../server'
const getTool = () => tools.find((tool) => tool.name === 'gnubok_get_company_settings')!
const updateTool = () => tools.find((tool) => tool.name === 'gnubok_update_company_settings')!
describe('company settings MCP tools: registration', () => {
it('registers the read and staged write tools with company scopes', () => {
expect(getTool()).toBeDefined()
expect(updateTool()).toBeDefined()
expect(getTool().annotations.readOnlyHint).toBe(true)
expect(getTool().catalogVisibility).toBe('search')
expect(updateTool().annotations.readOnlyHint).toBe(false)
expect(updateTool().annotations.idempotentHint).toBe(true)
expect(updateTool().catalogVisibility).toBe('search')
expect(TOOL_SCOPE_MAP.gnubok_get_company_settings).toBe('companies:read')
expect(TOOL_SCOPE_MAP.gnubok_update_company_settings).toBe('companies:write')
})
it('classifies payment-routing changes as medium risk', () => {
expect(OPERATION_RISK_TIERS.update_company_settings).toBe('medium')
})
it('uses strict top-level input schemas', () => {
expect(getTool().inputSchema.additionalProperties).toBe(false)
expect(updateTool().inputSchema.additionalProperties).toBe(false)
})
it('keeps both settings schemas discoverable through tool search', async () => {
const search = tools.find((tool) => tool.name === 'gnubok_search_tools')!
const readResult = (await search.execute(
{
query: 'get company settings',
detail: 'full',
__keyScopes: ['companies:read'],
},
'company-1',
'user-1',
{} as never,
)) as { tools: Array<{ name: string; inputSchema?: Record<string, unknown> }> }
const writeResult = (await search.execute(
{
query: 'update company settings',
detail: 'full',
__keyScopes: ['companies:write'],
},
'company-1',
'user-1',
{} as never,
)) as { tools: Array<{ name: string; inputSchema?: Record<string, unknown> }> }
expect(readResult.tools).toEqual([
expect.objectContaining({
name: 'gnubok_get_company_settings',
inputSchema: expect.any(Object),
}),
])
expect(writeResult.tools).toEqual([
expect.objectContaining({
name: 'gnubok_update_company_settings',
inputSchema: expect.any(Object),
}),
])
})
})
describe('gnubok_get_company_settings', () => {
it('returns payment details and maps the default reference to contact_person', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
bank_name: 'Testbanken',
clearing_number: '1234',
account_number: '1234567',
bankgiro: '5050-1055',
plusgiro: null,
swish: '1231231231',
iban: null,
bic: null,
default_our_reference: 'Test Contact',
},
})
const result = await getTool().execute({}, 'company-1', 'user-1', supabase as never)
expect(result).toMatchObject({
company_id: 'company-1',
bankgiro: '5050-1055',
contact_person: 'Test Contact',
})
expect(supabase.from).toHaveBeenCalledWith('company_settings')
})
it('fails when the company has no settings row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
await expect(
getTool().execute({}, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/not found/i)
})
})
describe('gnubok_update_company_settings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects an empty change set before querying the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updateTool().execute({ dry_run: true }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/at least one/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects a Bankgiro number with an invalid check digit', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updateTool().execute(
{ bankgiro: '1234567', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/bankgiro/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns a merged dry-run preview without staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
bank_name: 'Old Bank',
clearing_number: '1234',
account_number: '1234567',
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
bic: null,
default_our_reference: 'Old Contact',
},
})
const result = (await updateTool().execute(
{ bankgiro: '5050-1055', contact_person: 'New Contact', dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
dry_run?: boolean
preview: { proposed?: Record<string, unknown> }
}
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
expect(result.preview.proposed).toMatchObject({
bank_name: 'Old Bank',
bankgiro: '5050-1055',
contact_person: 'New Contact',
})
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('stages a validated update for approval', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
bank_name: null,
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
bic: null,
default_our_reference: null,
},
})
enqueue({ data: { id: 'op-settings-1' } })
const result = (await updateTool().execute(
{ contact_person: 'Test Contact' },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; operation_id?: string; risk_level: string }
expect(result).toMatchObject({
staged: true,
operation_id: 'op-settings-1',
risk_level: 'medium',
})
expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { tools, deriveToolMeta } from '../server'
import { tools, deriveToolMeta, isDefaultCatalogTool } from '../server'
import { projectToolInputSchema } from '../company-routing'
describe('tools/list payload size guard', () => {
@@ -7,7 +7,7 @@ describe('tools/list payload size guard', () => {
// Mirror the real tools/list serializer, including the derived staging
// _meta (requires_approval / approve_tool / preflight) merged over any
// literal _meta: otherwise the guard under-measures the wire payload.
const projection = tools.map((t) => {
const projection = tools.filter(isDefaultCatalogTool).map((t) => {
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
return {
name: t.name,
@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
import { tools } from '../server'
const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111'
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_update_customer')!
function currentCustomer(overrides: Record<string, unknown> = {}) {
return {
id: CUSTOMER_ID,
name: 'Test Customer AB',
customer_type: 'swedish_business',
customer_number: '1001',
email: 'billing@example.test',
phone: '',
address_line1: 'Testgatan 1',
address_line2: null,
postal_code: '12345',
city: 'Teststad',
country: 'Sweden',
org_number: '556000-0000',
vat_number: null,
vat_number_validated: false,
language: 'sv',
default_payment_terms: 30,
notes: null,
...overrides,
}
}
describe('gnubok_update_customer: registration', () => {
it('is a strict, staged customers:write tool at low risk', () => {
expect(tool()).toBeDefined()
expect(tool().inputSchema.additionalProperties).toBe(false)
expect(tool().annotations.readOnlyHint).toBe(false)
expect(tool().annotations.idempotentHint).toBe(true)
expect(tool().catalogVisibility).toBe('search')
expect(TOOL_SCOPE_MAP.gnubok_update_customer).toBe('customers:write')
expect(OPERATION_RISK_TIERS.update_customer).toBe('low')
})
it('does not expose personal_number as an input', () => {
const properties = tool().inputSchema.properties as Record<string, unknown>
expect(properties).not.toHaveProperty('personal_number')
})
it('keeps the wide write schema discoverable through tool search', async () => {
const search = tools.find((candidate) => candidate.name === 'gnubok_search_tools')!
const result = (await search.execute(
{
query: 'update customer',
detail: 'full',
__keyScopes: ['customers:write'],
},
'company-1',
'user-1',
{} as never,
)) as { tools: Array<{ name: string; inputSchema?: Record<string, unknown> }> }
expect(result.tools).toEqual([
expect.objectContaining({
name: 'gnubok_update_customer',
inputSchema: expect.any(Object),
}),
])
})
})
describe('gnubok_update_customer: validation and staging', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('requires at least one changed field', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{ customer_id: CUSTOMER_ID, dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/at least one/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects malformed email before querying the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{ customer_id: CUSTOMER_ID, email: 'not-an-email', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/email/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('fails when the customer is outside the selected company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
tool().execute(
{ customer_id: CUSTOMER_ID, city: 'New City', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/not found/i)
})
it('returns a merged dry-run preview without staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentCustomer() })
const result = (await tool().execute(
{
customer_id: CUSTOMER_ID,
city: 'New City',
default_payment_terms: 14,
dry_run: true,
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
dry_run?: boolean
preview: { proposed?: Record<string, unknown> }
}
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
expect(result.preview.proposed).toMatchObject({
customer_id: CUSTOMER_ID,
name: 'Test Customer AB',
city: 'New City',
default_payment_terms: 14,
})
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('stages the partial update for approval', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentCustomer() })
enqueue({ data: { id: 'op-customer-1' } })
const result = (await tool().execute(
{ customer_id: CUSTOMER_ID, phone: '0701234567' },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; operation_id?: string; risk_level: string }
expect(result).toMatchObject({
staged: true,
operation_id: 'op-customer-1',
risk_level: 'low',
})
expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
})
})
+448 -6
View File
@@ -43,6 +43,8 @@ import { accountClassTypeConflict } from '@/lib/pending-operations/schemas/accou
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions'
import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer'
import {
ensureCompanyDimensions,
fetchDimensionRegistry,
@@ -185,6 +187,8 @@ interface McpTool {
inputSchema: Record<string, unknown>
outputSchema?: Record<string, unknown>
annotations: McpToolAnnotations
/** Wide or specialized tools discoverable through gnubok_search_tools only. */
catalogVisibility?: 'default' | 'search'
_meta?: { ui: { resourceUri: string } }
// Result-level UI hint: when set, a call passing render_ui=true gets a
// _meta.ui.resourceUri on the RESULT, so the host renders the widget only when
@@ -897,6 +901,10 @@ export function deriveToolMeta(t: { name: string; outputSchema?: Record<string,
}
}
export function isDefaultCatalogTool(tool: { catalogVisibility?: 'default' | 'search' }): boolean {
return tool.catalogVisibility !== 'search'
}
function paginatedSchema(itemsKey: string, itemSchema: Record<string, unknown> = { type: 'object' }) {
return {
type: 'object',
@@ -1680,12 +1688,12 @@ export const tools: McpTool[] = [
{
name: 'gnubok_search_tools',
title: 'Search MCP Tools',
description: 'Search Accounted MCP tools by keyword and return their schemas at a chosen detail level. Call this first when looking for a capability: avoids loading every tool schema upfront.',
description: 'Search available tools by keyword and choose the returned schema detail level.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
query: { type: 'string', description: 'Keywords matched against tool name + description (e.g. "vat", "invoice", "categorize"). Empty string returns all tools.' },
query: { type: 'string', description: 'Keywords matched against tool names and descriptions. Empty returns all tools.' },
detail: { type: 'string', enum: ['name', 'summary', 'full'], description: 'Detail level. name: just names. summary: name + description + scope (default). full: complete schema including inputSchema and outputSchema.' },
scope: { type: 'string', description: 'Optional filter: only tools requiring this API key scope (e.g. "invoices:write").' },
limit: { type: 'number', description: 'Max results, 1-50 (default 20).' },
@@ -1901,6 +1909,179 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_get_company_settings',
title: 'Get Company Settings',
description: 'Get invoice payment details and the default company contact person. Use before creating invoices or staging a settings update.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
company_id: { type: 'string' },
bank_name: { type: ['string', 'null'] },
clearing_number: { type: ['string', 'null'] },
account_number: { type: ['string', 'null'] },
bankgiro: { type: ['string', 'null'] },
plusgiro: { type: ['string', 'null'] },
swish: { type: ['string', 'null'] },
iban: { type: ['string', 'null'] },
bic: { type: ['string', 'null'] },
contact_person: { type: ['string', 'null'], description: 'Default Our reference value on new invoices.' },
},
required: [
'company_id',
'bank_name',
'clearing_number',
'account_number',
'bankgiro',
'plusgiro',
'swish',
'iban',
'bic',
'contact_person',
],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
catalogVisibility: 'search',
async execute(_args, companyId, _userId, supabase) {
const { data, error } = await supabase
.from('company_settings')
.select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference')
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Database error: ${error.message}`)
if (!data) throw new Error('Company settings not found.')
return {
company_id: companyId,
bank_name: data.bank_name ?? null,
clearing_number: data.clearing_number ?? null,
account_number: data.account_number ?? null,
bankgiro: data.bankgiro ?? null,
plusgiro: data.plusgiro ?? null,
swish: data.swish ?? null,
iban: data.iban ?? null,
bic: data.bic ?? null,
contact_person: data.default_our_reference ?? null,
}
},
},
{
name: 'gnubok_update_company_settings',
title: 'Update Company Settings',
description: 'Stage changes to invoice payment details or the default company contact person. Requires approval before company settings are updated.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
bank_name: { type: 'string', maxLength: 100 },
clearing_number: { type: 'string', description: '4-5 digits. Empty string clears the value.' },
account_number: { type: 'string', description: '6-12 digits. Empty string clears the value.' },
bankgiro: { type: ['string', 'null'], description: 'Valid 7-8 digit Bankgiro with Luhn check digit. Null or empty string clears it.' },
plusgiro: { type: ['string', 'null'], description: 'Valid Plusgiro with hyphen and Luhn check digit. Null or empty string clears it.' },
swish: { type: ['string', 'null'], description: 'Swedish business or mobile Swish number. Null clears it.' },
iban: { type: ['string', 'null'], description: 'Swedish IBAN: SE followed by 22 digits. Null or empty string clears it.' },
bic: { type: ['string', 'null'], description: '8 or 11 character BIC/SWIFT. Null or empty string clears it.' },
contact_person: { type: ['string', 'null'], maxLength: 200, description: 'Default Our reference value on new invoices. Null clears it.' },
dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' },
idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' },
},
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const rawChanges: Record<string, unknown> = {}
for (const key of [
'bank_name',
'clearing_number',
'account_number',
'bankgiro',
'plusgiro',
'swish',
'iban',
'bic',
]) {
if (args[key] !== undefined) rawChanges[key] = args[key]
}
if (args.contact_person !== undefined) {
rawChanges.default_our_reference = args.contact_person
}
const parsed = UpdateCompanySettingsParamsSchema.safeParse({ changes: rawChanges })
if (!parsed.success) {
const issue = parsed.error.issues[0]
throw new Error(`Invalid company settings: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`)
}
const { data: current, error } = await supabase
.from('company_settings')
.select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference')
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Database error: ${error.message}`)
if (!current) throw new Error('Company settings not found.')
const currentPreview = {
company_id: companyId,
bank_name: current.bank_name ?? null,
clearing_number: current.clearing_number ?? null,
account_number: current.account_number ?? null,
bankgiro: current.bankgiro ?? null,
plusgiro: current.plusgiro ?? null,
swish: current.swish ?? null,
iban: current.iban ?? null,
bic: current.bic ?? null,
contact_person: current.default_our_reference ?? null,
}
const previewChanges = {
...parsed.data.changes,
...(parsed.data.changes.default_our_reference !== undefined
? { contact_person: parsed.data.changes.default_our_reference }
: {}),
}
delete (previewChanges as Record<string, unknown>).default_our_reference
return stagePendingOperation(
supabase,
companyId,
userId,
'update_company_settings',
'Uppdatera företagsinställningar',
parsed.data,
{
current: currentPreview,
changes: previewChanges,
proposed: { ...currentPreview, ...previewChanges },
},
actor,
undefined,
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
},
)
},
},
{
name: 'gnubok_list_skills',
title: 'List Domain Skills',
@@ -3471,6 +3652,130 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_update_customer',
title: 'Update Customer',
description: 'Stage a partial update to an existing customer. Find customer_id with gnubok_list_customers. Requires approval before customer data is changed.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
customer_id: { type: 'string', description: 'UUID from gnubok_list_customers.' },
name: { type: 'string', minLength: 1 },
customer_type: {
type: 'string',
enum: ['individual', 'swedish_business', 'eu_business', 'non_eu_business'],
description: 'Changing an individual customer to a business type clears its stored personal number.',
},
customer_number: { type: ['string', 'null'], maxLength: 32, description: 'Null or empty string clears the customer number.' },
email: { type: 'string', format: 'email' },
phone: { type: 'string' },
address_line1: { type: 'string' },
address_line2: { type: 'string' },
postal_code: { type: 'string' },
city: { type: 'string' },
country: { type: 'string' },
org_number: { type: 'string' },
vat_number: { type: 'string', description: 'EU VAT numbers are revalidated with VIES when the update is approved.' },
language: { type: 'string', enum: ['sv', 'en'] },
default_payment_terms: { type: 'integer', minimum: 1 },
notes: { type: 'string' },
dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' },
idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' },
},
required: ['customer_id'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const changes: Record<string, unknown> = {}
for (const key of [
'name',
'customer_type',
'customer_number',
'email',
'phone',
'address_line1',
'address_line2',
'postal_code',
'city',
'country',
'org_number',
'vat_number',
'language',
'default_payment_terms',
'notes',
]) {
if (args[key] !== undefined) changes[key] = args[key]
}
const parsed = UpdateCustomerParamsSchema.safeParse({
customer_id: args.customer_id,
changes,
})
if (!parsed.success) {
const issue = parsed.error.issues[0]
throw new Error(`Invalid customer update: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`)
}
const { data: current, error } = await supabase
.from('customers')
.select('id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, language, default_payment_terms, notes')
.eq('id', parsed.data.customer_id)
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Database error: ${error.message}`)
if (!current) throw new Error('Customer not found.')
const currentPreview = {
customer_id: current.id,
name: current.name,
customer_type: current.customer_type,
customer_number: current.customer_number ?? null,
email: current.email ?? null,
phone: current.phone ?? null,
address_line1: current.address_line1 ?? null,
address_line2: current.address_line2 ?? null,
postal_code: current.postal_code ?? null,
city: current.city ?? null,
country: current.country,
org_number: current.org_number ?? null,
vat_number: current.vat_number ?? null,
vat_number_validated: current.vat_number_validated ?? false,
language: current.language ?? 'sv',
default_payment_terms: current.default_payment_terms,
notes: current.notes ?? null,
}
return stagePendingOperation(
supabase,
companyId,
userId,
'update_customer',
`Uppdatera kund: ${current.name}`,
parsed.data,
{
current: currentPreview,
changes: parsed.data.changes,
proposed: { ...currentPreview, ...parsed.data.changes },
},
actor,
undefined,
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
},
)
},
},
// ── Article tools (artikelregister) ──────────────────────────
{
@@ -12621,7 +12926,7 @@ export const tools: McpTool[] = [
name: 'gnubok_preview_arsredovisning',
title: 'Preview Annual Report (Årsredovisning)',
description:
'Read-only K2 årsredovisning preview for a fiscal period. Returns flerårsöversikt, eget-kapital-förändring, RR, BR, K2 noter, signature slots. PDF download is via UI.',
'Read-only annual report preview from the canonical model. Returns report content, eligibility, compliance blockers, and capabilities. PDF and immutable versions are available in the UI.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -12635,8 +12940,144 @@ export const tools: McpTool[] = [
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { buildArsredovisningData } = await import('@/lib/bokslut/arsredovisning/build-data')
return buildArsredovisningData(supabase, companyId, fiscalPeriodId)
const { buildCanonicalAnnualReport } = await import('@/lib/bokslut/arsredovisning/model')
const { getAnnualReportCapabilities } = await import(
'@/lib/bokslut/arsredovisning/capabilities'
)
const model = await buildCanonicalAnnualReport(supabase, companyId, fiscalPeriodId, {
stage: 'draft',
includeIxbrl: false,
})
return {
schema_version: model.schema_version,
generated_at: model.generated_at,
report: model.report,
profile: model.profile,
disclosures: model.disclosures,
eligibility: model.eligibility,
validation: model.validation,
capabilities: getAnnualReportCapabilities(
model.report.accounting_framework,
model.eligibility,
),
}
},
},
{
name: 'gnubok_validate_arsredovisning',
title: 'Validate Annual Report (Årsredovisning)',
description:
'Read-only compliance validation for an annual report. Use draft while editing, signing before locking a version, and filing before a Bolagsverket submission.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
stage: {
type: 'string',
enum: ['draft', 'signing', 'filing'],
description: 'Validation strictness. Default: draft',
},
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const stage = (args.stage as 'draft' | 'signing' | 'filing' | undefined) ?? 'draft'
const { buildCanonicalAnnualReport } = await import('@/lib/bokslut/arsredovisning/model')
const model = await buildCanonicalAnnualReport(supabase, companyId, fiscalPeriodId, {
stage,
includeIxbrl: stage === 'filing',
})
return {
fiscal_period_id: fiscalPeriodId,
framework: model.report.accounting_framework,
profile: model.profile,
eligibility: model.eligibility,
validation: model.validation,
}
},
},
{
name: 'gnubok_list_arsredovisning_versions',
title: 'List Annual Report Versions',
description:
'Read-only list of immutable annual report versions with content hashes, taxonomy versions, and signing or filing status.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { listAnnualReportVersions } = await import(
'@/lib/bokslut/arsredovisning/version-service'
)
const versions = await listAnnualReportVersions(supabase, companyId, fiscalPeriodId)
return { fiscal_period_id: fiscalPeriodId, versions }
},
},
{
name: 'gnubok_get_arsredovisning_filing_status',
title: 'Get Annual Report Filing Status',
description:
'Read-only filing history for a fiscal period, including uncertain upload states that must be reconciled before retrying.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { data, error } = await supabase
.from('arsredovisning_submissions')
.select(
'id, annual_report_version_id, handling_typ, environment, status, archive_status, bolagsverket_url, error_message, uploaded_at, registered_at, created_at',
)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.order('created_at', { ascending: false })
.limit(50)
if (error) throw new Error(`Failed to list annual report filings: ${error.message}`)
const publicFields = [
'id',
'annual_report_version_id',
'handling_typ',
'environment',
'status',
'archive_status',
'bolagsverket_url',
'error_message',
'uploaded_at',
'registered_at',
'created_at',
] as const
const submissions = (data ?? []).map((submission) =>
Object.fromEntries(
publicFields.flatMap((field) =>
field in submission ? [[field, submission[field]]] : [],
),
),
)
return { fiscal_period_id: fiscalPeriodId, submissions }
},
},
@@ -13507,7 +13948,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'Accounted: Swedish double-entry bookkeeping via conversation.',
'',
'Discovery:',
'• tools/list returns the full schema for every tool. To narrow a large catalog, call gnubok_search_tools(query="…"): it ranks tools by relevance; pass detail="name"|"summary"|"full" to control payload size.',
'• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.',
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`,
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
@@ -13545,6 +13986,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
case 'tools/list': {
const listStartedAt = Date.now()
const allowedTools = tools.filter((t) => {
if (!isDefaultCatalogTool(t)) return false
const required = TOOL_SCOPE_MAP[t.name]
return !required || hasScope(keyScopes, required)
})
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const createServiceClientMock = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => createServiceClientMock(),
}))
import { writeSkatteverketAudit } from '../lib/audit'
describe('writeSkatteverketAudit', () => {
const insert = vi.fn()
const from = vi.fn(() => ({ insert }))
const userFrom = vi.fn()
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
insert.mockResolvedValue({ error: null })
createServiceClientMock.mockReturnValue({ from })
})
it('writes through the service-role client while preserving tenant metadata', async () => {
await writeSkatteverketAudit(
{
companyId: 'company-1',
userId: 'user-1',
supabase: { from: userFrom },
log,
} as never,
{
endpoint: '/momsdeklarationer',
outcome: 'ok',
responseStatus: 200,
correlationId: 'corr-1',
},
)
expect(createServiceClientMock).toHaveBeenCalledOnce()
expect(userFrom).not.toHaveBeenCalled()
expect(from).toHaveBeenCalledWith('skatteverket_api_audit_log')
expect(insert).toHaveBeenCalledWith(
expect.objectContaining({
company_id: 'company-1',
user_id: 'user-1',
endpoint: '/momsdeklarationer',
outcome: 'ok',
correlation_id: 'corr-1',
}),
)
expect(log.error).not.toHaveBeenCalled()
})
it('keeps the primary regulator flow alive and logs insert failures with correlation data', async () => {
insert.mockResolvedValue({ error: { message: 'database unavailable' } })
await writeSkatteverketAudit(
{
companyId: 'company-1',
userId: 'user-1',
supabase: { from: userFrom },
log,
} as never,
{
endpoint: '/momsdeklarationer',
outcome: 'internal_error',
correlationId: 'corr-2',
},
)
expect(log.error).toHaveBeenCalledWith(
'skatteverket_api_audit_log insert failed',
expect.objectContaining({
endpoint: '/momsdeklarationer',
outcome: 'internal_error',
correlationId: 'corr-2',
error: 'database unavailable',
}),
)
})
})
+8 -1
View File
@@ -1,4 +1,5 @@
import type { ExtensionContext } from '@/lib/extensions/types'
import { createServiceClient } from '@/lib/supabase/server'
/**
* Append an immutable row to skatteverket_api_audit_log. Errors are
@@ -28,7 +29,11 @@ export async function writeSkatteverketAudit(
},
): Promise<void> {
try {
const { error } = await ctx.supabase
// This table intentionally has no authenticated INSERT policy. Use the
// server-only service client so callers cannot fabricate regulator audit
// rows through their user session.
const auditClient = createServiceClient()
const { error } = await auditClient
.from('skatteverket_api_audit_log')
.insert({
company_id: ctx.companyId,
@@ -47,12 +52,14 @@ export async function writeSkatteverketAudit(
ctx.log.error('skatteverket_api_audit_log insert failed', {
endpoint: fields.endpoint,
outcome: fields.outcome,
correlationId: fields.correlationId ?? null,
error: error.message,
})
}
} catch (err) {
ctx.log.error('skatteverket_api_audit_log insert threw', {
endpoint: fields.endpoint,
correlationId: fields.correlationId ?? null,
error: err instanceof Error ? err.message : String(err),
})
}