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:
Jakob Wennberg
2026-08-26 13:03:02 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 00e7ac92ae
commit 8ddc77fdfd
10 changed files with 541 additions and 137 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "accounted", "name": "accounted",
"displayName": "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.", "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": { "author": {
"name": "Accounted (erp-mafia)" "name": "Accounted (erp-mafia)"
}, },
+1 -1
View File
@@ -14,7 +14,7 @@ Call `accounted_get_agent_briefing`.
## Step 2: set up the company ## 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. 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; // tools/list, so catalogVisibility 'search' means discover-only there;
// the onboarding flow dead-ended on client-side tool-not-found when // the onboarding flow dead-ended on client-side tool-not-found when
// the skill pointed at them (SilverPark session, 2026-08-26). // 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 // 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 // fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further. // 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', 'gnubok_list_companies',
// Creates the company: by definition it runs before one exists. // Creates the company: by definition it runs before one exists.
'gnubok_create_company', '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',
]) ])
/** /**
+173 -1
View File
@@ -20,6 +20,11 @@ import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url' import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
import { createCompanyCore } from '@/lib/company/create-company' import { createCompanyCore } from '@/lib/company/create-company'
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input' 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 { import {
ANONYMOUS_METHODS, ANONYMOUS_METHODS,
ANONYMOUS_RATE_LIMIT, 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', name: 'gnubok_create_company',
title: 'Create Company', title: 'Create Company',
description: 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: { inputSchema: {
type: 'object', type: 'object',
additionalProperties: false, 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 call is retried automatically once connected. Do not send the user to the web
app to sign up first. 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 The result carries three parts; use them exactly as intended:
VAT-registered (the momsregistreringsnummer on every invoice derives from
it) and strongly recommended otherwise: it drives Skatteverket/SIE exports. An enskild firma's - \`company\`: the registry facts. Present them as a SHORT summary for the
org number is the owner's personnummer; that is fine to store here. user to confirm: "Jag hittade Example AB, Storgatan 1 i Stockholm,
2. **Company form**: \`aktiebolag\` or \`enskild_firma\`. Only these two are godkänd för F-skatt och momsregistrerad. Stämmer det?" Do NOT re-ask
supported today; HB/KB/förening are not. what the registry already answered.
3. **Company name** as registered. - \`suggested_create_company_input\`: prefilled arguments for
4. **F-skatt**: godkänd för F-skatt? Always ask; the tool refuses to assume it. \`gnubok_create_company\`. Merge the user's remaining answers into it.
A brand-new company may still be waiting for Skatteverket's approval (then - \`still_to_ask\`: the questions the registry could not answer. Ask exactly
false). these and nothing more.
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. Rules baked into that split (same as the web onboarding):
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 - **F-skatt** from the registry is a fact, both true and false.
exact first fiscal year start and end (BFL 3 kap.: it may be shorter than - **VAT** is a fact ONLY when positively registered. "No VAT registration
12 months or up to 18 months) and pass \`first_fiscal_year\`. found" is a question, never an assumption (ML 17 kap 24 §).
6. **VAT**: momsregistrerad? If yes, which period: \`monthly\`, \`quarterly\` or - **Moms period** (\`monthly\`/\`quarterly\`/\`yearly\`) and **accounting
\`yearly\`. This is required when VAT-registered: without it Accounted method** (\`accrual\`/\`cash\`) are ALWAYS the user's answer. Rules of
generates no VAT deadlines at all, silently. If the user does not know, thumb if they are unsure: turnover under 1 MSEK may report VAT yearly,
the rule of thumb: turnover under 1 MSEK may report yearly, under 40 MSEK under 40 MSEK quarterly, above that monthly (Skatteverket's registration
quarterly, above that monthly; Skatteverket's registration decision states decision states the actual period); cash method is only allowed under
the actual period. Never guess it into the tool; ask. 3 MSEK turnover and is common for small enskild firma, AB with invoices
7. **Accounting method**: \`accrual\` (faktureringsmetoden) or \`cash\` usually run accrual.
(kontantmetoden / bokslutsmetoden). Cash is only allowed under 3 MSEK - **Enskild firma name**: the verksamhetsnamn is freely choosable; suggest
turnover and is common for small enskild firma; AB with invoices usually the registered name but let the user pick. An AB's registered name is a
run accrual. 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 ## Step 2: preview, confirm, create
@@ -72,20 +92,21 @@ re-authentication.
## Step 3: connect the bank ## Step 3: connect the bank
Call \`gnubok_connect_bank\`. It reports existing connections and returns a 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 \`connect_url\`; on claude.ai/Claude Desktop a connect card with an
Accounted, picks the bank and approves with BankID (PSD2 consent, up to 180 open-in-browser button renders automatically. The user opens the link in a
days). Transactions start syncing within a minute. If they prefer not to browser where they are logged in to Accounted, picks the bank and approves
connect a bank, they can import bank statements as files in the web app with BankID (PSD2 consent, up to 180 days). Transactions start syncing
instead; do not block on this step. 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) ## Step 4: connect Skatteverket (optional but recommended)
Call \`gnubok_connect_skatteverket\`. Same pattern: the user opens the Call \`gnubok_connect_skatteverket\`. Same pattern: the user opens the
\`connect_url\`, identifies with BankID as firmatecknare at Skatteverket, and connect link (card button on claude.ai/Desktop), identifies with BankID as
lands back in Accounted. This enables skattekonto sync and filing of firmatecknare at Skatteverket, and lands back in Accounted. This enables
momsdeklaration and arbetsgivardeklaration from here. Filing is never skattekonto sync and filing of momsdeklaration and arbetsgivardeklaration
mandatory through Accounted: every declaration can be downloaded and filed from here. Filing is never mandatory through Accounted: every declaration
manually. can be downloaded and filed manually.
## Step 5: first bookkeeping ## Step 5: first bookkeeping
@@ -96,6 +117,7 @@ import (\`gnubok_import_sie\` in the search catalog) before categorizing.
## Tools ## Tools
- \`gnubok_lookup_company\`: registry facts + prefill from the orgnr; call first
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true) - \`gnubok_create_company\`: preview (no confirm) then create (confirm=true)
- \`gnubok_list_companies\`: see which companies this connection can reach - \`gnubok_list_companies\`: see which companies this connection can reach
- \`gnubok_connect_bank\`: status + connect link for PSD2 bank consent - \`gnubok_connect_bank\`: status + connect link for PSD2 bank consent
@@ -118,7 +140,7 @@ export const onboardingSkill: Skill = {
slug: 'onboarding', slug: 'onboarding',
name: 'Onboarding: New Company Setup', name: 'Onboarding: New Company Setup',
summary: 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'], tags: ['onboarding', 'setup', 'company', 'bank', 'skatteverket', 'agent-first'],
body, body,
tier: 'workflow', tier: 'workflow',
+3 -94
View File
@@ -34,7 +34,7 @@ import {
readBankIdFlow, readBankIdFlow,
setBankIdFlowCookies, setBankIdFlowCookies,
} from './lib/bankid-flow-cookie' } 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 { hashPersonalNumber, encryptPersonalNumberForStorage } from '@/lib/auth/bankid'
import { requireAuth } from '@/lib/auth/require-auth' import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server' 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 * Always logs the cleaned org number so we can correlate failures with input
* in Vercel logs. * 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( function handleTicError(
error: unknown, error: unknown,
log: { error: (msg: string, meta?: unknown) => void } | Console, log: { error: (msg: string, meta?: unknown) => void } | Console,
@@ -388,74 +356,15 @@ export const ticExtension: Extension = {
// newly-registered companies without a financial summary return // newly-registered companies without a financial summary return
// fiscalYear: null and the client-side first-year derivation // fiscalYear: null and the client-side first-year derivation
// takes over (see deriveFirstYearDefaults). // takes over (see deriveFirstYearDefaults).
const doc = await searchCompanyByOrgNumber(orgNumber) const result = await lookupCompanyByOrgNumber(orgNumber)
if (!doc) { if (!result) {
return NextResponse.json( return NextResponse.json(
{ error: 'Company not found' }, { error: 'Company not found' },
{ status: 404 } { 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 }) return NextResponse.json({ data: result })
} catch (error) { } catch (error) {
return handleTicError(error, log, 'lookup', cleanedOrgNumber, 'Failed to look up company') return handleTicError(error, log, 'lookup', cleanedOrgNumber, 'Failed to look up company')
+112
View File
@@ -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)
}
+1
View File
@@ -179,6 +179,7 @@ export const SCOPE_GROUPS = [
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = { export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
// Companies // Companies
gnubok_list_companies: 'companies:read', gnubok_list_companies: 'companies:read',
gnubok_lookup_company: 'companies:read',
gnubok_create_company: 'companies:write', gnubok_create_company: 'companies:write',
gnubok_connect_bank: 'companies:read', gnubok_connect_bank: 'companies:read',
gnubok_connect_skatteverket: 'companies:read', gnubok_connect_skatteverket: 'companies:read',