Files
MattssonandClaude Fable 5.1 1157ff1b66 feat(onboarding): the orgnr field also accepts a company name (#2421)
* feat(onboarding): the orgnr field also accepts a company name

The journey's first question kept asking for an organisationsnummer, and
people who do not know theirs by heart left to look it up. The same field
now takes either: digits (with dashes or spaces) run the existing orgnr
lookup unchanged; anything else with three or more characters runs a
free-text name search against the same TIC index. One hit continues
exactly as a typed orgnr would; several hits render as a chip row
"Name / orgnr / city" inside the same question, and the pick applies the
hit's already-fetched lookup result. No hits stays on the step with a note
to refine or type the number. The screen, placeholder and hint are
otherwise untouched; only the mobile keyboard changes from numeric to text.

Why the problem occurred: the lookup was keyed on the one identifier the
user is least likely to remember, while the provider index behind it is a
full-text index that already answers names.

What was removed or simplified: nothing new is stored. The TIC search
document carries every field /lookup returns, so a name hit is mapped by
the same mapper and a picked hit costs no second provider call. The reducer
gained one shared "TIC answered" transition (applyLookupFound) that the
typed-orgnr path, the single-hit path and the pick path all use, instead of
three copies of the fact-to-settings mapping.

Why this shape and not the proposed one: search-as-you-type autocomplete
would burn the 3000/mo TIC budget in days, so the search fires on Enter
only, like the orgnr lookup. Taking the top hit blind on several matches
was rejected: name ranking is fuzzy and common names or sole-trader
surnames would land on a stranger's company; a five-chip pick row is the
smallest thing that keeps the user in control. The route answers 400 under
three characters, 404 in the handler's own "Company not found" shape so the
client's existing dispatcher-vs-handler mapping applies, and every TIC
failure code maps through the same handler as /lookup.

Fixes #2418

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKHTqmnvBJAgdW4V7wZsAW

* fix(onboarding): reduce Lens registration numbers to the 10-digit form for name-search hits

Skeptic pass on 1d70716a8 (issue #2418). A sole trader found by name got
Lens's 16-digit registration number (century-prefixed personnummer plus a
4-digit serial) stored as org_number; createCompany refuses anything
normalizeOrgNumber rejects, so the journey dead-ended at the last step and
the returned orgnr step could only shake. The typed-orgnr path never stored
Lens's number, so this was the first place it reached settings.

- searchCompaniesForLookup derives orgNumber through the new
  lensRegistrationToOrgNumber (16-prefixed 12 digits and the 16-digit
  enskild-firma form reduce to the 10-digit key; hits that do not
  normalize are dropped, never dead-ended).
- Sole-trader chips show "Enskild firma" and city instead of the number,
  which is the owner's personnummer.
- The name path resets the duplicate note on submit, so an earlier orgnr's
  "you already have X" no longer sits above the chip row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKHTqmnvBJAgdW4V7wZsAW

* fix(onboarding): keep the typed name in the field after a search pick

Compliance swarm on PR #2421: writing the picked hit's org number into the
visible input printed a sole trader's personnummer in plain text on Back,
the one thing the chip row masks. The field now keeps the name the user
typed; Back re-searches it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKHTqmnvBJAgdW4V7wZsAW

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 14:23:05 +02:00

155 lines
6.1 KiB
TypeScript

import { searchCompaniesByName, searchCompanyByOrgNumber } from './tic-client'
import type { TICCompanyDocument } from './tic-types'
import type { CompanyLookupResult, CompanySearchHit } from '@/lib/company-lookup/types'
import { normalizeOrgNumber, orgNumberKey } from '@/lib/invariants/org-number'
/**
* 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)
}
/** Maximum hits the onboarding chip row shows for a name search. */
export const COMPANY_SEARCH_LIMIT = 5
/**
* Free-text name search mapped to the same shape /lookup returns, one per
* hit, active companies first. Empty array means nothing matched. One Lens
* call per distinct query; a picked hit reuses its result, so the whole
* search-and-pick flow costs the same as an org-number lookup.
*/
export async function searchCompaniesForLookup(query: string): Promise<CompanySearchHit[]> {
const docs = await searchCompaniesByName(query, COMPANY_SEARCH_LIMIT)
const hits: CompanySearchHit[] = []
for (const doc of docs) {
const orgNumber = lensRegistrationToOrgNumber(doc.registrationNumber)
if (!orgNumber) continue
hits.push({ orgNumber, result: mapDocumentToLookupResult(doc) })
}
// Stable: ceased companies sink below active ones but keep their rank.
return [...hits.filter((h) => !h.result.isCeased), ...hits.filter((h) => h.result.isCeased)]
}
/**
* Lens `registrationNumber` → Accounted's 10-digit org number, or null when
* the document cannot become a valid one.
*
* The typed-orgnr path never stores Lens's number (it keeps what the user
* typed), so this is the first place a Lens identifier enters settings. Lens
* shapes: an AB is 10 digits or 16-prefixed 12; an enskild firma is 16
* digits, the century-prefixed personnummer plus a 4-digit serial
* (`2002011732750001` for personnummer `0201173275`, see
* searchCompanyByOrgNumber). createCompany refuses anything normalizeOrgNumber
* rejects, so a hit that does not reduce to a valid number is dropped here
* rather than dead-ending the journey at submit.
*/
export function lensRegistrationToOrgNumber(registrationNumber: string): string | null {
const digits = registrationNumber.replace(/\D/g, '')
const candidate = /^(18|19|20)\d{14}$/.test(digits) ? digits.slice(0, 12) : digits
const key = orgNumberKey(candidate)
return key && normalizeOrgNumber(key) ? key : null
}