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:
@@ -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', () => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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() })
|
||||
|
||||
|
||||
@@ -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 ?? ''
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user