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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
00e7ac92ae
commit
8ddc77fdfd
@@ -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)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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> = {
|
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',
|
||||||
|
|||||||
Reference in New Issue
Block a user