feat(mcp): org-number-first onboarding: gnubok_lookup_company prefills the company from the registry (#1940)
The onboarding flow now mirrors the web wizard: ask for the organisationsnummer first, look the company up in the public registry (one TIC Lens call through the extracted extensions/general/tic/lib/lookup.ts, shared with the /lookup HTTP route), and present the facts for confirmation instead of interrogating the user. The new gnubok_lookup_company tool (companies:read, company-independent, default catalog) returns the registry facts, a prefilled suggested_create_company_input, and a still_to_ask list that encodes the same fact-vs-question rules as lib/onboarding-journey/reducer.ts: F-skatt is a fact both ways, VAT is a fact only when positively registered (ML 17 kap 24 paragraf), moms period and accounting method are always asked, an enskild firma's verksamhetsnamn is the user's choice, and a known fiscal year becomes a confirm question. Registry outages degrade to the full question list instead of failing onboarding. The onboarding skill and the plugin's /accounted:setup command are updated to the orgnr-first flow (plugin 1.2.0). tools/list ceiling bumped 61.2K to 61.5K with the reason documented in the bench. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"name": "accounted",
|
||||
"displayName": "Accounted",
|
||||
"description": "Official Accounted plugin: Swedish double-entry bookkeeping flows for Claude. Connects your ledger over MCP and ships short workflow skills (daily bookkeeping, health check, month close, VAT, payroll, year-end) that work from the company's live data and load Swedish accounting knowledge from the product when needed. Every write is staged for your approval; nothing is booked on its own.",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": {
|
||||
"name": "Accounted (erp-mafia)"
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ Call `accounted_get_agent_briefing`.
|
||||
|
||||
## Step 2: set up the company
|
||||
|
||||
Call `accounted_load_skill("onboarding")` and follow it. In short: ask for the facts (company form, organisationsnummer, F-skatt, fiscal year, VAT registration and moms period, accounting method), then call `accounted_create_company` **without** `confirm` to get a preview, read the preview back in plain Swedish, and only after an explicit "ja" call it again with `confirm: true`.
|
||||
Call `accounted_load_skill("onboarding")` and follow it. In short: ask for the **organisationsnummer** first and call `accounted_lookup_company`; the public registry answers most of the form (name, address, F-skatt, VAT status, legal form, fiscal year), so present those as facts to confirm and ask only what `still_to_ask` lists (typically the moms period and the accounting method). Then call `accounted_create_company` **without** `confirm` to get a preview, read the preview back in plain Swedish, and only after an explicit "ja" call it again with `confirm: true`.
|
||||
|
||||
Rules the tool enforces, so do not argue with them: a VAT-registered company needs both an organisationsnummer and a moms period; F-skatt must be stated, never assumed; an enskild firma always runs on the calendar year.
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* gnubok_lookup_company: the org-number-first onboarding entry point. Tests
|
||||
* the fact-vs-question split mirrored from lib/onboarding-journey/reducer.ts:
|
||||
* registry facts are presented for confirmation, VAT is a fact only when
|
||||
* positively registered, moms period and accounting method are always asked.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { TICAPIError } from '@/extensions/general/tic/lib/tic-types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
lookupCompanyByOrgNumber: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/tic/lib/lookup', () => ({
|
||||
lookupCompanyByOrgNumber: (...args: unknown[]) => mocks.lookupCompanyByOrgNumber(...args),
|
||||
}))
|
||||
|
||||
import { tools } from '../server'
|
||||
import { isCompanyDependentTool } from '../company-routing'
|
||||
|
||||
const tool = tools.find((t) => t.name === 'gnubok_lookup_company')!
|
||||
|
||||
function found(overrides: Partial<CompanyLookupResult> = {}): CompanyLookupResult {
|
||||
return {
|
||||
companyName: 'Testbolaget AB',
|
||||
isCeased: false,
|
||||
address: { street: 'Storgatan 1', postalCode: '111 22', city: 'Stockholm' },
|
||||
registration: { fTax: true, vat: true },
|
||||
bankAccounts: [],
|
||||
email: null,
|
||||
phone: null,
|
||||
sniCodes: [{ code: '62010', name: 'Dataprogrammering' }],
|
||||
fiscalYear: { startMonthDay: '01-01', endMonthDay: '12-31' },
|
||||
legalEntityType: 'AB',
|
||||
registrationDate: Date.UTC(2018, 2, 1),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function run(orgNumber: string) {
|
||||
return (await tool.execute({ org_number: orgNumber }, '', 'user-1', {} as never)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
}
|
||||
|
||||
describe('gnubok_lookup_company', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('is a companies:read, company-independent read tool', () => {
|
||||
expect(tool).toBeDefined()
|
||||
expect(TOOL_SCOPE_MAP.gnubok_lookup_company).toBe('companies:read')
|
||||
expect(isCompanyDependentTool('gnubok_lookup_company')).toBe(false)
|
||||
expect(tool.annotations.readOnlyHint).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a malformed organisationsnummer without spending a registry call', async () => {
|
||||
await expect(run('12345')).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
||||
expect(mocks.lookupCompanyByOrgNumber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a VAT-registered AB as facts: prefill everything, ask only period and method', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(found())
|
||||
const result = await run('556000-0001')
|
||||
|
||||
expect(result.status).toBe('found')
|
||||
expect(result.warnings).toEqual([])
|
||||
expect(result.suggested_create_company_input).toMatchObject({
|
||||
name: 'Testbolaget AB',
|
||||
entity_type: 'aktiebolag',
|
||||
org_number: '5560000001',
|
||||
f_skatt: true,
|
||||
vat_registered: true,
|
||||
address_line1: 'Storgatan 1',
|
||||
postal_code: '111 22',
|
||||
city: 'Stockholm',
|
||||
fiscal_year_start_month: 1,
|
||||
})
|
||||
|
||||
const ask = result.still_to_ask as string[]
|
||||
expect(ask.some((q) => q.startsWith('moms_period ('))).toBe(true)
|
||||
expect(ask.some((q) => q.startsWith('accounting_method'))).toBe(true)
|
||||
// Registry facts are confirmed, never re-asked.
|
||||
expect(ask.some((q) => q.startsWith('entity_type'))).toBe(false)
|
||||
expect(ask.some((q) => q.startsWith('vat_registered'))).toBe(false)
|
||||
expect(ask.some((q) => q.startsWith('name'))).toBe(false)
|
||||
// The known fiscal year becomes a confirmation, not an open question.
|
||||
expect(ask.some((q) => q.includes('stämmer detta?'))).toBe(true)
|
||||
})
|
||||
|
||||
it('never silently defaults VAT: absence of registration is a question, not a fact', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
||||
found({ registration: { fTax: false, vat: false } })
|
||||
)
|
||||
const result = await run('5560000001')
|
||||
|
||||
const suggested = result.suggested_create_company_input as Record<string, unknown>
|
||||
expect('vat_registered' in suggested).toBe(false)
|
||||
// f_skatt=false IS a fact (the registry answered), unlike vat=false.
|
||||
expect(suggested.f_skatt).toBe(false)
|
||||
|
||||
const ask = result.still_to_ask as string[]
|
||||
expect(ask.some((q) => q.startsWith('vat_registered'))).toBe(true)
|
||||
})
|
||||
|
||||
it('lets the user pick the enskild firma verksamhetsnamn instead of assuming the registered name', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
||||
found({ legalEntityType: 'EF', companyName: 'Anna Andersson' })
|
||||
)
|
||||
const result = await run('5560000001')
|
||||
|
||||
const ask = result.still_to_ask as string[]
|
||||
expect(ask.some((q) => q.startsWith('name'))).toBe(true)
|
||||
// The registered name still arrives as the suggestion.
|
||||
expect((result.suggested_create_company_input as Record<string, unknown>).name).toBe(
|
||||
'Anna Andersson'
|
||||
)
|
||||
})
|
||||
|
||||
it('flags an unsupported legal form and asks for the entity type', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(found({ legalEntityType: 'HB' }))
|
||||
const result = await run('5560000001')
|
||||
|
||||
expect((result.warnings as string[]).some((w) => w.includes('not supported'))).toBe(true)
|
||||
const suggested = result.suggested_create_company_input as Record<string, unknown>
|
||||
expect('entity_type' in suggested && suggested.entity_type !== undefined).toBe(false)
|
||||
expect((result.still_to_ask as string[]).some((q) => q.startsWith('entity_type'))).toBe(true)
|
||||
})
|
||||
|
||||
it('warns about a ceased company but lets the flow continue', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(found({ isCeased: true }))
|
||||
const result = await run('5560000001')
|
||||
|
||||
expect(result.status).toBe('found')
|
||||
expect((result.warnings as string[]).some((w) => w.includes('CEASED'))).toBe(true)
|
||||
})
|
||||
|
||||
it('suggests a first fiscal year for a recently registered company with no closed period', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
||||
found({ fiscalYear: null, registrationDate: Date.now() - 60 * 24 * 60 * 60 * 1000 })
|
||||
)
|
||||
const result = await run('5560000001')
|
||||
|
||||
const ask = result.still_to_ask as string[]
|
||||
expect(ask.some((q) => q.includes('first fiscal year') || q.includes('first_fiscal_year'))).toBe(
|
||||
true
|
||||
)
|
||||
const suggested = result.suggested_create_company_input as Record<string, unknown>
|
||||
expect('fiscal_year_start_month' in suggested).toBe(false)
|
||||
})
|
||||
|
||||
it('returns not_found with the full question list when no company matches', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(null)
|
||||
const result = await run('5560000001')
|
||||
|
||||
expect(result.status).toBe('not_found')
|
||||
expect(result.suggested_create_company_input).toBeNull()
|
||||
expect((result.still_to_ask as string[]).length).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
|
||||
it('degrades to unavailable on a TIC error instead of failing the onboarding', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockRejectedValue(
|
||||
new TICAPIError('not configured', undefined, 'NOT_CONFIGURED')
|
||||
)
|
||||
const result = await run('5560000001')
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect((result.warnings as string[])[0]).toContain('NOT_CONFIGURED')
|
||||
expect((result.still_to_ask as string[]).length).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
|
||||
it('rethrows non-TIC errors', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockRejectedValue(new Error('boom'))
|
||||
await expect(run('5560000001')).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
@@ -221,9 +221,14 @@ describe('tools/list payload size guard', () => {
|
||||
// tools/list, so catalogVisibility 'search' means discover-only there;
|
||||
// the onboarding flow dead-ended on client-side tool-not-found when
|
||||
// the skill pointed at them (SilverPark session, 2026-08-26).
|
||||
// * 61.2K to 61.5K with gnubok_lookup_company (org-number-first
|
||||
// onboarding): default-catalog for the same reason as the connect
|
||||
// tools; the onboarding skill's first instruction is to call it, and
|
||||
// a search-only tool is uncallable on Claude.ai. Descriptions were
|
||||
// trimmed first; the tool costs ~265 tokens against ~0 headroom.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(61_200)
|
||||
expect(approxTokens).toBeLessThan(61_500)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,9 @@ const COMPANY_INDEPENDENT_TOOLS = new Set([
|
||||
'gnubok_list_companies',
|
||||
// Creates the company: by definition it runs before one exists.
|
||||
'gnubok_create_company',
|
||||
// Public-registry lookup that feeds gnubok_create_company: same pre-company
|
||||
// stage of onboarding, no company data touched at all.
|
||||
'gnubok_lookup_company',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,11 @@ import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
|
||||
import { createCompanyCore } from '@/lib/company/create-company'
|
||||
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input'
|
||||
import { lookupCompanyByOrgNumber } from '@/extensions/general/tic/lib/lookup'
|
||||
import { TICAPIError } from '@/extensions/general/tic/lib/tic-types'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
|
||||
import { deriveFirstYearDefaults, parseStartMonthDay } from '@/lib/company/first-year-defaults'
|
||||
import {
|
||||
ANONYMOUS_METHODS,
|
||||
ANONYMOUS_RATE_LIMIT,
|
||||
@@ -2985,11 +2990,178 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_lookup_company',
|
||||
title: 'Look Up Company',
|
||||
description:
|
||||
'Look up a Swedish company by organisationsnummer in the public registry (name, address, F-skatt, VAT, legal form, fiscal year). Call FIRST in onboarding: the user confirms facts instead of answering questions. Feeds gnubok_create_company; works before any company exists.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
org_number: {
|
||||
type: 'string',
|
||||
description: '10 digits (personnummer for enskild firma); hyphens/spaces OK',
|
||||
},
|
||||
},
|
||||
required: ['org_number'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', enum: ['found', 'not_found', 'unavailable'] },
|
||||
company: { type: ['object', 'null'] },
|
||||
suggested_create_company_input: { type: ['object', 'null'] },
|
||||
still_to_ask: { type: 'array', items: { type: 'string' } },
|
||||
warnings: { type: 'array', items: { type: 'string' } },
|
||||
instructions: { type: 'string' },
|
||||
},
|
||||
required: ['status', 'company', 'suggested_create_company_input', 'still_to_ask', 'warnings', 'instructions'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async execute(args) {
|
||||
const raw = String((args as { org_number: string }).org_number ?? '')
|
||||
const normalized = normalizeOrgNumber(raw)
|
||||
if (!normalized) {
|
||||
throw Object.assign(
|
||||
new Error('Invalid organisationsnummer: expected 10 digits (hyphens and spaces are OK).'),
|
||||
{ code: 'VALIDATION_ERROR' }
|
||||
)
|
||||
}
|
||||
|
||||
const askEverything = [
|
||||
'name',
|
||||
'entity_type (enskild firma or aktiebolag)',
|
||||
'f_skatt',
|
||||
'vat_registered (and moms_period if yes)',
|
||||
'accounting_method (accrual or cash)',
|
||||
'fiscal year',
|
||||
'address (optional)',
|
||||
]
|
||||
|
||||
let lookup
|
||||
try {
|
||||
lookup = await lookupCompanyByOrgNumber(normalized)
|
||||
} catch (error) {
|
||||
if (error instanceof TICAPIError) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
company: null,
|
||||
suggested_create_company_input: null,
|
||||
still_to_ask: askEverything,
|
||||
warnings: [`Registry lookup unavailable (${error.code}).`],
|
||||
instructions:
|
||||
'The registry lookup is unavailable right now. Fall back to asking the user each question in still_to_ask, then call gnubok_create_company (preview first, then confirm=true).',
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!lookup) {
|
||||
return {
|
||||
status: 'not_found',
|
||||
company: null,
|
||||
suggested_create_company_input: null,
|
||||
still_to_ask: askEverything,
|
||||
warnings: [],
|
||||
instructions:
|
||||
'No company matched this organisationsnummer. Double-check the number with the user; a brand-new registration can take days to appear. If the number is right, ask each question in still_to_ask and call gnubok_create_company manually.',
|
||||
}
|
||||
}
|
||||
|
||||
const entityType = mapEntityType(lookup.legalEntityType)
|
||||
const warnings: string[] = []
|
||||
if (lookup.isCeased) {
|
||||
warnings.push(
|
||||
'The registry marks this company as CEASED (avregistrerat). Surface this to the user before continuing; they may still proceed.'
|
||||
)
|
||||
}
|
||||
if (!entityType) {
|
||||
warnings.push(
|
||||
`Legal form "${lookup.legalEntityType ?? 'unknown'}" is not supported for automatic setup: only enskild firma and aktiebolag can be created here.`
|
||||
)
|
||||
}
|
||||
|
||||
// Mirror the web onboarding journey's fact-vs-question rules
|
||||
// (lib/onboarding-journey/reducer.ts): facts from a successful lookup
|
||||
// are presented for confirmation, not asked. F-skatt is a fact both
|
||||
// ways; VAT is a fact ONLY when positively registered (ML 17 kap 24
|
||||
// paragraf: never silently default vat_registered); moms period and
|
||||
// accounting method are ALWAYS the user's answer.
|
||||
const vatIsFact = lookup.registration.vat === true
|
||||
const stillToAsk: string[] = []
|
||||
if (!entityType) stillToAsk.push('entity_type (enskild firma or aktiebolag)')
|
||||
if (entityType === 'enskild_firma') {
|
||||
stillToAsk.push(
|
||||
'name: for enskild firma the verksamhetsnamn is freely choosable; suggest the registered name but let the user pick'
|
||||
)
|
||||
}
|
||||
if (!vatIsFact) stillToAsk.push('vat_registered (the registry shows no VAT registration; confirm with the user)')
|
||||
if (vatIsFact) stillToAsk.push('moms_period (monthly, quarterly or yearly; never guess)')
|
||||
else stillToAsk.push('moms_period IF vat_registered turns out true')
|
||||
stillToAsk.push('accounting_method (accrual = faktureringsmetoden, cash = kontantmetoden; never guess)')
|
||||
|
||||
const startMonth = parseStartMonthDay(lookup.fiscalYear?.startMonthDay)
|
||||
const firstYear = deriveFirstYearDefaults(lookup.registrationDate)
|
||||
if (startMonth !== null) {
|
||||
stillToAsk.push(
|
||||
`fiscal year: registry shows ${lookup.fiscalYear?.startMonthDay} to ${lookup.fiscalYear?.endMonthDay}; ask "stämmer detta?" instead of an open question`
|
||||
)
|
||||
} else if (firstYear.isFirstFiscalYear) {
|
||||
stillToAsk.push(
|
||||
`fiscal year: company registered recently; suggest a first fiscal year starting ${firstYear.firstYearStart} (first_fiscal_year start/end; an enskild firma's first year must end 31 December)`
|
||||
)
|
||||
} else {
|
||||
stillToAsk.push('fiscal year: calendar year or broken year (no registry data)')
|
||||
}
|
||||
|
||||
const suggested: Record<string, unknown> = {
|
||||
name: lookup.companyName || undefined,
|
||||
entity_type: entityType ?? undefined,
|
||||
org_number: normalized,
|
||||
f_skatt: lookup.registration.fTax,
|
||||
...(vatIsFact ? { vat_registered: true } : {}),
|
||||
...(lookup.address?.street ? { address_line1: lookup.address.street } : {}),
|
||||
...(lookup.address?.postalCode ? { postal_code: lookup.address.postalCode } : {}),
|
||||
...(lookup.address?.city ? { city: lookup.address.city } : {}),
|
||||
...(startMonth !== null ? { fiscal_year_start_month: startMonth } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'found',
|
||||
company: {
|
||||
name: lookup.companyName,
|
||||
org_number: normalized,
|
||||
legal_entity_type: lookup.legalEntityType,
|
||||
is_ceased: lookup.isCeased,
|
||||
address: lookup.address,
|
||||
f_skatt: lookup.registration.fTax,
|
||||
vat_registered: lookup.registration.vat,
|
||||
fiscal_year: lookup.fiscalYear ?? null,
|
||||
registration_date: lookup.registrationDate
|
||||
? new Date(lookup.registrationDate).toISOString().slice(0, 10)
|
||||
: null,
|
||||
sni_codes: lookup.sniCodes,
|
||||
},
|
||||
suggested_create_company_input: suggested,
|
||||
still_to_ask: stillToAsk,
|
||||
warnings,
|
||||
instructions:
|
||||
'Present the company facts as a short summary for the user to CONFIRM (name, address, F-skatt, VAT status; do not re-ask them). Then ask ONLY the still_to_ask questions, merge the answers into suggested_create_company_input, and call gnubok_create_company (preview first, read it back, then confirm=true).',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_create_company',
|
||||
title: 'Create Company',
|
||||
description:
|
||||
'Create a NEW company for the connected user, set up for bookkeeping (chart, settings, first fiscal period, tax deadlines; 30-day trial). Preview first (no confirm), read it back, then confirm=true. Ask, never assume: form, orgnr, VAT + moms period, method. Skill: onboarding.',
|
||||
'Create a NEW company for the connected user, set up for bookkeeping (chart, settings, first fiscal period, tax deadlines; 30-day trial). Call gnubok_lookup_company FIRST to prefill facts from the orgnr. Preview (no confirm), read it back, then confirm=true. Skill: onboarding.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -24,36 +24,56 @@ inget konto skapar du det där (BankID eller e-post), det tar en minut." The
|
||||
call is retried automatically once connected. Do not send the user to the web
|
||||
app to sign up first.
|
||||
|
||||
## Step 1: gather the facts (ask, never assume)
|
||||
## Step 1: ask for the organisationsnummer, then look it up
|
||||
|
||||
Collect these before creating anything. The order mirrors the in-app wizard.
|
||||
Ask for ONE thing first: the **organisationsnummer** (10 digits; an enskild
|
||||
firma's org number is the owner's personnummer, fine to use here). Then call
|
||||
\`gnubok_lookup_company\` with it. This mirrors the in-app wizard: the public
|
||||
registry answers most of the questions, so the user confirms facts instead of
|
||||
filling in a form.
|
||||
|
||||
1. **Organisationsnummer** (10 digits). Required when the company is
|
||||
VAT-registered (the momsregistreringsnummer on every invoice derives from
|
||||
it) and strongly recommended otherwise: it drives Skatteverket/SIE exports. An enskild firma's
|
||||
org number is the owner's personnummer; that is fine to store here.
|
||||
2. **Company form**: \`aktiebolag\` or \`enskild_firma\`. Only these two are
|
||||
supported today; HB/KB/förening are not.
|
||||
3. **Company name** as registered.
|
||||
4. **F-skatt**: godkänd för F-skatt? Always ask; the tool refuses to assume it.
|
||||
A brand-new company may still be waiting for Skatteverket's approval (then
|
||||
false).
|
||||
5. **Fiscal year**: for enskild firma always the calendar year (do not ask);
|
||||
its first year may be shorter or up to 18 months but always ends 31 December.
|
||||
For an AB ask whether it is the calendar year or another 12-month period
|
||||
(\`fiscal_year_start_month\`). For a company in its FIRST year ask for the
|
||||
exact first fiscal year start and end (BFL 3 kap.: it may be shorter than
|
||||
12 months or up to 18 months) and pass \`first_fiscal_year\`.
|
||||
6. **VAT**: momsregistrerad? If yes, which period: \`monthly\`, \`quarterly\` or
|
||||
\`yearly\`. This is required when VAT-registered: without it Accounted
|
||||
generates no VAT deadlines at all, silently. If the user does not know,
|
||||
the rule of thumb: turnover under 1 MSEK may report yearly, under 40 MSEK
|
||||
quarterly, above that monthly; Skatteverket's registration decision states
|
||||
the actual period. Never guess it into the tool; ask.
|
||||
7. **Accounting method**: \`accrual\` (faktureringsmetoden) or \`cash\`
|
||||
(kontantmetoden / bokslutsmetoden). Cash is only allowed under 3 MSEK
|
||||
turnover and is common for small enskild firma; AB with invoices usually
|
||||
run accrual.
|
||||
The result carries three parts; use them exactly as intended:
|
||||
|
||||
- \`company\`: the registry facts. Present them as a SHORT summary for the
|
||||
user to confirm: "Jag hittade Example AB, Storgatan 1 i Stockholm,
|
||||
godkänd för F-skatt och momsregistrerad. Stämmer det?" Do NOT re-ask
|
||||
what the registry already answered.
|
||||
- \`suggested_create_company_input\`: prefilled arguments for
|
||||
\`gnubok_create_company\`. Merge the user's remaining answers into it.
|
||||
- \`still_to_ask\`: the questions the registry could not answer. Ask exactly
|
||||
these and nothing more.
|
||||
|
||||
Rules baked into that split (same as the web onboarding):
|
||||
|
||||
- **F-skatt** from the registry is a fact, both true and false.
|
||||
- **VAT** is a fact ONLY when positively registered. "No VAT registration
|
||||
found" is a question, never an assumption (ML 17 kap 24 §).
|
||||
- **Moms period** (\`monthly\`/\`quarterly\`/\`yearly\`) and **accounting
|
||||
method** (\`accrual\`/\`cash\`) are ALWAYS the user's answer. Rules of
|
||||
thumb if they are unsure: turnover under 1 MSEK may report VAT yearly,
|
||||
under 40 MSEK quarterly, above that monthly (Skatteverket's registration
|
||||
decision states the actual period); cash method is only allowed under
|
||||
3 MSEK turnover and is common for small enskild firma, AB with invoices
|
||||
usually run accrual.
|
||||
- **Enskild firma name**: the verksamhetsnamn is freely choosable; suggest
|
||||
the registered name but let the user pick. An AB's registered name is a
|
||||
fact.
|
||||
- **Fiscal year**: when the registry shows one, confirm it ("Ert
|
||||
räkenskapsår är 1 januari till 31 december, stämmer det?") instead of
|
||||
asking openly. For a company registered within the last 12 months,
|
||||
suggest a first fiscal year from the registration date and pass
|
||||
\`first_fiscal_year\` (BFL 3 kap.: it may be shorter than 12 months or up
|
||||
to 18 months). Enskild firma is always calendar-year and its first year
|
||||
always ends 31 December.
|
||||
|
||||
If the lookup returns \`not_found\` or \`unavailable\`, fall back to asking
|
||||
each question in \`still_to_ask\` (the full list) and continue; the flow is
|
||||
the same, just without prefill. A brand-new registration can take days to
|
||||
appear in the registry.
|
||||
|
||||
Only \`aktiebolag\` and \`enskild_firma\` are supported today; HB/KB/förening
|
||||
are not. A company marked CEASED in the registry: surface the warning, but
|
||||
the user may continue (they know their company best).
|
||||
|
||||
## Step 2: preview, confirm, create
|
||||
|
||||
@@ -72,20 +92,21 @@ re-authentication.
|
||||
## Step 3: connect the bank
|
||||
|
||||
Call \`gnubok_connect_bank\`. It reports existing connections and returns a
|
||||
\`connect_url\`. The user opens it in a browser where they are logged in to
|
||||
Accounted, picks the bank and approves with BankID (PSD2 consent, up to 180
|
||||
days). Transactions start syncing within a minute. If they prefer not to
|
||||
connect a bank, they can import bank statements as files in the web app
|
||||
instead; do not block on this step.
|
||||
\`connect_url\`; on claude.ai/Claude Desktop a connect card with an
|
||||
open-in-browser button renders automatically. The user opens the link in a
|
||||
browser where they are logged in to Accounted, picks the bank and approves
|
||||
with BankID (PSD2 consent, up to 180 days). Transactions start syncing
|
||||
within a minute. If they prefer not to connect a bank, they can import bank
|
||||
statements as files in the web app instead; do not block on this step.
|
||||
|
||||
## Step 4: connect Skatteverket (optional but recommended)
|
||||
|
||||
Call \`gnubok_connect_skatteverket\`. Same pattern: the user opens the
|
||||
\`connect_url\`, identifies with BankID as firmatecknare at Skatteverket, and
|
||||
lands back in Accounted. This enables skattekonto sync and filing of
|
||||
momsdeklaration and arbetsgivardeklaration from here. Filing is never
|
||||
mandatory through Accounted: every declaration can be downloaded and filed
|
||||
manually.
|
||||
connect link (card button on claude.ai/Desktop), identifies with BankID as
|
||||
firmatecknare at Skatteverket, and lands back in Accounted. This enables
|
||||
skattekonto sync and filing of momsdeklaration and arbetsgivardeklaration
|
||||
from here. Filing is never mandatory through Accounted: every declaration
|
||||
can be downloaded and filed manually.
|
||||
|
||||
## Step 5: first bookkeeping
|
||||
|
||||
@@ -96,6 +117,7 @@ import (\`gnubok_import_sie\` in the search catalog) before categorizing.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_lookup_company\`: registry facts + prefill from the orgnr; call first
|
||||
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true)
|
||||
- \`gnubok_list_companies\`: see which companies this connection can reach
|
||||
- \`gnubok_connect_bank\`: status + connect link for PSD2 bank consent
|
||||
@@ -118,7 +140,7 @@ export const onboardingSkill: Skill = {
|
||||
slug: 'onboarding',
|
||||
name: 'Onboarding: New Company Setup',
|
||||
summary:
|
||||
'Set up a company from the conversation: gather facts, preview and create with gnubok_create_company, then hand out the bank and Skatteverket connect links.',
|
||||
'Set up a company from the conversation: ask for the orgnr, prefill facts with gnubok_lookup_company, preview and create with gnubok_create_company, then the bank and Skatteverket connect links.',
|
||||
tags: ['onboarding', 'setup', 'company', 'bank', 'skatteverket', 'agent-first'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
readBankIdFlow,
|
||||
setBankIdFlowCookies,
|
||||
} from './lib/bankid-flow-cookie'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import { lookupCompanyByOrgNumber, registrationDateToMs } from './lib/lookup'
|
||||
import { hashPersonalNumber, encryptPersonalNumberForStorage } from '@/lib/auth/bankid'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
@@ -249,38 +249,6 @@ function toFinancialReportSummary(
|
||||
* Always logs the cleaned org number so we can correlate failures with input
|
||||
* in Vercel logs.
|
||||
*/
|
||||
// Derive `{ startMonthDay, endMonthDay }` (e.g. "01-01" / "12-31") from the
|
||||
// search doc's mostRecentFinancialSummary. periodStart/periodEnd are Unix
|
||||
// timestamps in seconds. Returns null when the company has no closed period
|
||||
// yet: the client's deriveFirstYearDefaults handles newly-registered
|
||||
// companies from registrationDate instead.
|
||||
function deriveFiscalYearMonthDay(
|
||||
fin: { periodStart?: number; periodEnd?: number } | undefined,
|
||||
): { startMonthDay: string | null; endMonthDay: string | null } | null {
|
||||
if (!fin?.periodStart || !fin?.periodEnd) return null
|
||||
const toMonthDay = (unixSeconds: number): string | null => {
|
||||
const d = new Date(unixSeconds * 1000)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getUTCDate()).padStart(2, '0')
|
||||
return `${mm}-${dd}`
|
||||
}
|
||||
const startMonthDay = toMonthDay(fin.periodStart)
|
||||
const endMonthDay = toMonthDay(fin.periodEnd)
|
||||
if (!startMonthDay && !endMonthDay) return null
|
||||
return { startMonthDay, endMonthDay }
|
||||
}
|
||||
|
||||
// The search doc's registrationDate is a Unix timestamp in seconds (same
|
||||
// unit as periodStart/periodEnd above), but the app-facing contract
|
||||
// (CompanyLookupResult / TICCompanyProfile) is a millisecond epoch:
|
||||
// consumers feed it straight into `new Date()`. Skipping this conversion
|
||||
// is how 2026 registrations rendered as "21 jan 1970" in onboarding.
|
||||
function registrationDateToMs(unixSeconds: number | null | undefined): number | null {
|
||||
if (unixSeconds == null || !Number.isFinite(unixSeconds)) return null
|
||||
return unixSeconds * 1000
|
||||
}
|
||||
|
||||
function handleTicError(
|
||||
error: unknown,
|
||||
log: { error: (msg: string, meta?: unknown) => void } | Console,
|
||||
@@ -388,74 +356,15 @@ export const ticExtension: Extension = {
|
||||
// newly-registered companies without a financial summary return
|
||||
// fiscalYear: null and the client-side first-year derivation
|
||||
// takes over (see deriveFirstYearDefaults).
|
||||
const doc = await searchCompanyByOrgNumber(orgNumber)
|
||||
const result = await lookupCompanyByOrgNumber(orgNumber)
|
||||
|
||||
if (!doc) {
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Company not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const nameEntry =
|
||||
doc.names.find((n) => n.companyNamingType === 'name') ?? doc.names[0]
|
||||
const companyName = nameEntry?.nameOrIdentifier ?? ''
|
||||
|
||||
const isCeased = doc.isCeased ?? doc.activityStatus === 'isNoLongerActive'
|
||||
|
||||
const address = doc.mostRecentRegisteredAddress
|
||||
? {
|
||||
street: doc.mostRecentRegisteredAddress.streetAddress ?? null,
|
||||
postalCode: doc.mostRecentRegisteredAddress.postalCode ?? null,
|
||||
city: doc.mostRecentRegisteredAddress.city ?? null,
|
||||
}
|
||||
: null
|
||||
|
||||
const registration = {
|
||||
fTax: doc.isRegisteredForFTax ?? false,
|
||||
vat: doc.isRegisteredForVAT ?? false,
|
||||
}
|
||||
|
||||
const bankAccounts = (doc.bankAccounts ?? [])
|
||||
.filter((ba) => ba.accountNumber != null && ba.bankAccountType === 'bankgiro')
|
||||
.map((ba) => ({
|
||||
type: 'bankgiro',
|
||||
accountNumber: String(ba.accountNumber),
|
||||
bic: null,
|
||||
}))
|
||||
|
||||
// Search-doc shape is `{ rank, sni_2007Code, sni_2007Name, ... }`;
|
||||
// map to the canonical { code, name } the rest of the app expects.
|
||||
const sniCodes = (doc.sniCodes ?? [])
|
||||
.filter((s) => s.sni_2007Code)
|
||||
.map((s) => ({
|
||||
code: s.sni_2007Code ?? '',
|
||||
name: s.sni_2007Name ?? '',
|
||||
}))
|
||||
|
||||
const email = doc.emailAddresses?.[0]?.emailAddress ?? null
|
||||
|
||||
const phone =
|
||||
doc.phoneNumbers?.[0]?.phoneNumberFormatted
|
||||
?? doc.phoneNumbers?.[0]?.e164PhoneNumber
|
||||
?? null
|
||||
|
||||
const fiscalYear = deriveFiscalYearMonthDay(doc.mostRecentFinancialSummary)
|
||||
|
||||
const result: CompanyLookupResult = {
|
||||
companyName,
|
||||
isCeased,
|
||||
address,
|
||||
registration,
|
||||
bankAccounts,
|
||||
email,
|
||||
phone,
|
||||
sniCodes,
|
||||
fiscalYear,
|
||||
legalEntityType: doc.legalEntityType ?? null,
|
||||
registrationDate: registrationDateToMs(doc.registrationDate),
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
return handleTicError(error, log, 'lookup', cleanedOrgNumber, 'Failed to look up company')
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { searchCompanyByOrgNumber } from './tic-client'
|
||||
import type { TICCompanyDocument } from './tic-types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
|
||||
/**
|
||||
* Shared org-number → CompanyLookupResult lookup, used by both the /lookup
|
||||
* HTTP route (web onboarding) and the mcp-server extension's
|
||||
* gnubok_lookup_company tool (agent onboarding). One Lens call per lookup;
|
||||
* the 5-minute process cache in tic-client absorbs retries, and 404s are
|
||||
* cached too so a typo does not re-spend budget.
|
||||
*/
|
||||
|
||||
// TIC financial summaries are Unix seconds. A missing summary means the
|
||||
// company has never closed a fiscal period: the consumer's
|
||||
// deriveFirstYearDefaults handles newly-registered companies from
|
||||
// registrationDate instead.
|
||||
export function deriveFiscalYearMonthDay(
|
||||
fin: { periodStart?: number; periodEnd?: number } | undefined,
|
||||
): { startMonthDay: string | null; endMonthDay: string | null } | null {
|
||||
if (!fin?.periodStart || !fin?.periodEnd) return null
|
||||
const toMonthDay = (unixSeconds: number): string | null => {
|
||||
const d = new Date(unixSeconds * 1000)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getUTCDate()).padStart(2, '0')
|
||||
return `${mm}-${dd}`
|
||||
}
|
||||
const startMonthDay = toMonthDay(fin.periodStart)
|
||||
const endMonthDay = toMonthDay(fin.periodEnd)
|
||||
if (!startMonthDay && !endMonthDay) return null
|
||||
return { startMonthDay, endMonthDay }
|
||||
}
|
||||
|
||||
// The search doc's registrationDate is a Unix timestamp in seconds (same
|
||||
// unit as periodStart/periodEnd above), but the app-facing contract
|
||||
// (CompanyLookupResult / TICCompanyProfile) is a millisecond epoch:
|
||||
// consumers feed it straight into `new Date()`. Skipping this conversion
|
||||
// is how 2026 registrations rendered as "21 jan 1970" in onboarding.
|
||||
export function registrationDateToMs(unixSeconds: number | null | undefined): number | null {
|
||||
if (unixSeconds == null || !Number.isFinite(unixSeconds)) return null
|
||||
return unixSeconds * 1000
|
||||
}
|
||||
|
||||
export function mapDocumentToLookupResult(doc: TICCompanyDocument): CompanyLookupResult {
|
||||
const nameEntry =
|
||||
doc.names.find((n) => n.companyNamingType === 'name') ?? doc.names[0]
|
||||
const companyName = nameEntry?.nameOrIdentifier ?? ''
|
||||
|
||||
const isCeased = doc.isCeased ?? doc.activityStatus === 'isNoLongerActive'
|
||||
|
||||
const address = doc.mostRecentRegisteredAddress
|
||||
? {
|
||||
street: doc.mostRecentRegisteredAddress.streetAddress ?? null,
|
||||
postalCode: doc.mostRecentRegisteredAddress.postalCode ?? null,
|
||||
city: doc.mostRecentRegisteredAddress.city ?? null,
|
||||
}
|
||||
: null
|
||||
|
||||
const registration = {
|
||||
fTax: doc.isRegisteredForFTax ?? false,
|
||||
vat: doc.isRegisteredForVAT ?? false,
|
||||
}
|
||||
|
||||
const bankAccounts = (doc.bankAccounts ?? [])
|
||||
.filter((ba) => ba.accountNumber != null && ba.bankAccountType === 'bankgiro')
|
||||
.map((ba) => ({
|
||||
type: 'bankgiro',
|
||||
accountNumber: String(ba.accountNumber),
|
||||
bic: null,
|
||||
}))
|
||||
|
||||
// Search-doc shape is `{ rank, sni_2007Code, sni_2007Name, ... }`;
|
||||
// map to the canonical { code, name } the rest of the app expects.
|
||||
const sniCodes = (doc.sniCodes ?? [])
|
||||
.filter((s) => s.sni_2007Code)
|
||||
.map((s) => ({
|
||||
code: s.sni_2007Code ?? '',
|
||||
name: s.sni_2007Name ?? '',
|
||||
}))
|
||||
|
||||
const email = doc.emailAddresses?.[0]?.emailAddress ?? null
|
||||
|
||||
const phone =
|
||||
doc.phoneNumbers?.[0]?.phoneNumberFormatted
|
||||
?? doc.phoneNumbers?.[0]?.e164PhoneNumber
|
||||
?? null
|
||||
|
||||
const fiscalYear = deriveFiscalYearMonthDay(doc.mostRecentFinancialSummary)
|
||||
|
||||
return {
|
||||
companyName,
|
||||
isCeased,
|
||||
address,
|
||||
registration,
|
||||
bankAccounts,
|
||||
email,
|
||||
phone,
|
||||
sniCodes,
|
||||
fiscalYear,
|
||||
legalEntityType: doc.legalEntityType ?? null,
|
||||
registrationDate: registrationDateToMs(doc.registrationDate),
|
||||
}
|
||||
}
|
||||
|
||||
/** Null means no company matched the org number (a clean "not found"). */
|
||||
export async function lookupCompanyByOrgNumber(
|
||||
orgNumber: string
|
||||
): Promise<CompanyLookupResult | null> {
|
||||
const doc = await searchCompanyByOrgNumber(orgNumber)
|
||||
if (!doc) return null
|
||||
return mapDocumentToLookupResult(doc)
|
||||
}
|
||||
@@ -179,6 +179,7 @@ export const SCOPE_GROUPS = [
|
||||
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Companies
|
||||
gnubok_list_companies: 'companies:read',
|
||||
gnubok_lookup_company: 'companies:read',
|
||||
gnubok_create_company: 'companies:write',
|
||||
gnubok_connect_bank: 'companies:read',
|
||||
gnubok_connect_skatteverket: 'companies:read',
|
||||
|
||||
Reference in New Issue
Block a user