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
parent 00e7ac92ae
commit 8ddc77fdfd
10 changed files with 541 additions and 137 deletions
+1 -1
View File
@@ -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)"
},
+1 -1
View File
@@ -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',
])
/**
+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 { 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',
+3 -94
View File
@@ -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')
+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> = {
// 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',