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
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user