feat(mcp): org-number-first onboarding: gnubok_lookup_company prefills the company from the registry (#1940)

The onboarding flow now mirrors the web wizard: ask for the
organisationsnummer first, look the company up in the public registry (one
TIC Lens call through the extracted extensions/general/tic/lib/lookup.ts,
shared with the /lookup HTTP route), and present the facts for confirmation
instead of interrogating the user.

The new gnubok_lookup_company tool (companies:read, company-independent,
default catalog) returns the registry facts, a prefilled
suggested_create_company_input, and a still_to_ask list that encodes the
same fact-vs-question rules as lib/onboarding-journey/reducer.ts: F-skatt
is a fact both ways, VAT is a fact only when positively registered (ML 17
kap 24 paragraf), moms period and accounting method are always asked, an
enskild firma's verksamhetsnamn is the user's choice, and a known fiscal
year becomes a confirm question. Registry outages degrade to the full
question list instead of failing onboarding.

The onboarding skill and the plugin's /accounted:setup command are updated
to the orgnr-first flow (plugin 1.2.0). tools/list ceiling bumped 61.2K to
61.5K with the reason documented in the bench.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 13:03:02 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 00e7ac92ae
commit 8ddc77fdfd
10 changed files with 541 additions and 137 deletions
+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)
}