diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 1929a82d..6b68a473 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -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)" }, diff --git a/claude-plugin/commands/setup.md b/claude-plugin/commands/setup.md index 9031ed53..01132b35 100644 --- a/claude-plugin/commands/setup.md +++ b/claude-plugin/commands/setup.md @@ -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. diff --git a/extensions/general/mcp-server/__tests__/lookup-company.test.ts b/extensions/general/mcp-server/__tests__/lookup-company.test.ts new file mode 100644 index 00000000..6a3586b3 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/lookup-company.test.ts @@ -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 { + 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 + 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).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 + 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 + 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') + }) +}) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 7c3f353a..bb7994c7 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -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) }) }) diff --git a/extensions/general/mcp-server/company-routing.ts b/extensions/general/mcp-server/company-routing.ts index a026af7d..cedb9ea4 100644 --- a/extensions/general/mcp-server/company-routing.ts +++ b/extensions/general/mcp-server/company-routing.ts @@ -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', ]) /** diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index a75255f6..53217588 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 = { + 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, diff --git a/extensions/general/mcp-server/skills/onboarding.ts b/extensions/general/mcp-server/skills/onboarding.ts index 670d4cde..cde6159e 100644 --- a/extensions/general/mcp-server/skills/onboarding.ts +++ b/extensions/general/mcp-server/skills/onboarding.ts @@ -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', diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index 558d23ec..55df1497 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -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') diff --git a/extensions/general/tic/lib/lookup.ts b/extensions/general/tic/lib/lookup.ts new file mode 100644 index 00000000..03b9e766 --- /dev/null +++ b/extensions/general/tic/lib/lookup.ts @@ -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 { + const doc = await searchCompanyByOrgNumber(orgNumber) + if (!doc) return null + return mapDocumentToLookupResult(doc) +} diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 46fb58ff..8c23748f 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -179,6 +179,7 @@ export const SCOPE_GROUPS = [ export const TOOL_SCOPE_MAP: Record = { // 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',