* feat(onboarding): the orgnr step suggests companies as you type, SCB search, TIC on the pick Most people do not know their organisationsnummer. They left the onboarding for allabolag, searched their company name there, copied the number and pasted it back. #2421 let the field take a name, but only on Enter and behind a screen that still said "organisationsnummer", so the detour stayed. Now the field suggests companies while a name is typed (name, orgnr or "Enskild firma", city; arrow keys or click to pick), the pick fills the company like a typed orgnr, and the screen says "Vilket företag är det?" with "Företagsnamn eller organisationsnummer" as the placeholder. Why the problem occurred: the one identifier the step asked for is the one the user is least likely to remember, and the free-text path added in #2421 was invisible (copy unchanged) and had to be guessed (Enter only), because the only search index behind it was TIC, whose Lens budget cannot take a call per keystroke. What was removed or simplified: nothing is stored and no new state model: a picked suggestion is an ORG_SUBMITTED with prefill, so the existing LOOKUP_RESULT transitions (found, not found, disabled, error) decide the step exactly as for a typed number. SCB's name search already existed for the parties picker; it gained one option (sole traders) instead of a second client. No rate limiting anywhere, per the founder. Why this shape: SCB's företagsregister is free and already configured for the parties picker, so search-as-you-type costs nothing while typing; TIC runs once, on the pick, as it always did on Enter. TIC per keystroke was rejected (3000/month). SCB alone was rejected for the pick because it knows no F-skatt, VAT registration or fiscal year. The Enter path and the chip row from #2421 stay as the fallback when no row is picked. Sole traders are offered (they are half the users) but their row names the form and never prints the personnummer, and the field shows the company name after a pick for the same reason. Changes: - app/api/company/search: GET ?q= over the SCB client with sole traders included, top 6 rows plus a truncated flag; requireAuth() (no company yet), 400 for short or numeric q, 503 without SCB credentials, 502 when SCB does not answer. - lib/parties/scb/client.ts: searchByName(query, { includeSoleTraders }), legalFormCode on every candidate; the parties picker is unchanged. - lib/company-lookup: CompanySuggestion, COMPANY_SUGGEST_MAX, fetchCompanySuggestions (503 is disabled, everything else error, never throws), toCompanySuggestion (SCB legal form 49/10/61 into the TIC vocabulary mapSetupEntityType reads). - lib/onboarding-journey/reducer.ts: SUGGESTION_PICKED (orgnr, name and form as prefill, lookupPending; lookupRan stays false until TIC answers). - components/onboarding/journey: 300 ms debounced SCB search with abort of the superseded request, listbox under the field (combobox ARIA, arrow keys, Escape, Enter picks the highlighted row, otherwise the Enter path), copy switches with companySearchEnabled or ticEnabled; both journey pages pass isScbConfigured(). - messages sv+en: five strings. Tests: route (401, 400 short, 400 missing, 400 numeric, 503, happy with a sole trader, cap at 6, flood, 502); fetchCompanySuggestions (every outcome); toCompanySuggestion; reducer (pick equals typed orgnr after TIC, TIC overrides prefill, TIC off keeps the AB past form and name, unmapped form falls to the picker, sole trader confirms the name, replaces a previous orgnr, ignored off-step); SCB client sole-trader option. Fixes #2448 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi * fix(onboarding): the suggestion list stays visible and stands alone (skeptic on e56eb242c) Three independent refuters on the frozen commit; every refutation that stood is fixed here. - The listbox was position: absolute inside the field, but the step scrolls (.jny-qstep is overflow-y: auto), so the list was clipped to the first row and mouse picks were unreachable (measured in headless Chrome). It now renders in flow under the field, where the chip row from #2421 already lives. - After Enter on a name (the #2421 path), SEARCH_RESULT flipped lookupPending back and the debounced effect refetched SCB, laying the listbox over the chip row or next to the nomatch note. The effect is now quiet while searchHits is non-empty and for text the user already confirmed (Enter or a pick), until the text changes. - The "many matches, type more" hint only rendered inside the list, so the flood case (SCB counts over 100 rows and sends none) showed nothing. The hint now renders on its own for that case. - app/companies/new-client (byrå adds a client) renders the same journey and now passes companySearchEnabled like the other two pages. - A stale mouse highlight could commit a row from the previous text on Enter: typing resets the highlight. - Any 503 switched the picker off for the session; only the route's own SCB_NOT_CONFIGURED does now. - NOTFOUND_EDIT / CEASED_EDIT dropped only the number and kept the abandoned pick's name and form, which a later TIC error path would have written into the company. Both now drop name and form too, unless they came from BankID's CompanyRoles prefill, which is not about the number. Not changed, recorded: a sole trader picked from SCB whom TIC does not know lands on the "no company on that number" step with the name in the field; the flow continues with the SCB name prefilled. The search JSON carries the personnummer of sole-trader rows to the authenticated browser (the row prints "Enskild firma"), same class as #2421's Enter search; flagged to the founder. Refs #2448 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
169 lines
7.3 KiB
TypeScript
169 lines
7.3 KiB
TypeScript
import type { ScbConfig } from './config'
|
|
import { factsFromScbCompany, type ScbCompanyRow, type ScbFact } from './map'
|
|
import { isLegalPersonOrgNumber, toPeOrgNr } from './org-number'
|
|
import { scbJson } from './transport'
|
|
|
|
/**
|
|
* The wire format of the current SokPaVar API, checked against the live
|
|
* service on 2026-09-03 (scripts/scb/discover.ts, help page at
|
|
* <base>/help). A search is a list of variable filters; an identity lookup
|
|
* is one filter on "OrgNr (10 siffror)" with operator ArLikaMed, and
|
|
* without Företagsstatus/Registreringsstatus so a deregistered company is
|
|
* still returned (an empty string there is rejected with 400). The row
|
|
* comes back with every purchased column, codes and texts side by side.
|
|
*/
|
|
export const SCB_ORG_VARIABLE = 'OrgNr (10 siffror)'
|
|
|
|
export interface ScbLookupResult {
|
|
found: boolean
|
|
peOrgNr: string
|
|
row: ScbCompanyRow | null
|
|
facts: ScbFact[]
|
|
fetchedAt: string
|
|
}
|
|
|
|
export interface ScbCandidate {
|
|
orgNumber: string
|
|
name: string
|
|
city: string | null
|
|
industry: string | null
|
|
legalForm: string | null
|
|
/** SCB's legal form code ("49" övriga aktiebolag, "10" enskild näringsidkare). */
|
|
legalFormCode: string | null
|
|
/** SCB's own status text; active is Företagsstatus code 1. */
|
|
status: string | null
|
|
active: boolean
|
|
}
|
|
|
|
export interface ScbSearchResult {
|
|
query: string
|
|
/** How SCB was asked: a prefix match first, a contains match as fallback. */
|
|
mode: 'starts_with' | 'contains'
|
|
/** Rows SCB counted before the cap; above the cap the list is cut and the user should refine. */
|
|
total: number
|
|
truncated: boolean
|
|
candidates: ScbCandidate[]
|
|
}
|
|
|
|
export interface ScbClient {
|
|
variables(): Promise<unknown>
|
|
categories(): Promise<unknown>
|
|
lookupByOrgNumber(orgNumber: string): Promise<ScbLookupResult>
|
|
searchByName(query: string, opts?: ScbSearchOptions): Promise<ScbSearchResult>
|
|
}
|
|
|
|
export interface ScbSearchOptions {
|
|
/**
|
|
* Offer enskilda näringsidkare (legal form 10) alongside legal persons.
|
|
* Off by default: the parties picker matches counterparts on supplier
|
|
* invoices, where a natural person is noise. Onboarding turns it on
|
|
* because a sole trader searching for their own firm is the point; the
|
|
* org number SCB returns there is the owner's personnummer, so the caller
|
|
* decides what to print. Estates (91) are never offered.
|
|
*/
|
|
includeSoleTraders?: boolean
|
|
}
|
|
|
|
/** Candidates shown per search; SCB can return thousands for a short word. */
|
|
export const SCB_SEARCH_CAP = 25
|
|
/** Legal forms never offered in the picker: natural persons and estates. */
|
|
const NON_COMPANY_LEGAL_FORMS = new Set(['10', '91'])
|
|
/** SCB legal form code for a natural person running a business (enskild näringsidkare). */
|
|
export const SCB_LEGAL_FORM_SOLE_TRADER = '10'
|
|
|
|
/**
|
|
* What we send SCB for a name: the AP prefix, supplier numbers and a
|
|
* trailing legal form are noise ("Levfakt Telia Sverige AB (17)" becomes
|
|
* "Telia Sverige"). SCB's name filter refuses an apostrophe.
|
|
*/
|
|
export function nameQuery(raw: string): string {
|
|
return raw
|
|
.replace(/^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura från\s*\d*|leverantörsfaktura|levbet\.?|kundbet\.?|kundfaktura|faktura från|faktura|kvitto|utgift|inköp)\s+/i, '')
|
|
.replace(/[(),]/g, ' ')
|
|
.replace(/\b\d{1,6}\b/g, ' ')
|
|
.replace(/(\s+(?:ab|aktiebolag|hb|kb|publ|\(publ\)))+\.?\s*$/i, '')
|
|
.replace(/'/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
export function nameSearchBody(query: string, mode: 'starts_with' | 'contains') {
|
|
return {
|
|
Variabler: [{ Variabel: 'Namn', Operator: mode === 'starts_with' ? 'BorjarPa' : 'Innehaller', Varde1: query, Varde2: '' }],
|
|
Kategorier: [],
|
|
}
|
|
}
|
|
|
|
function candidateFrom(row: ScbCompanyRow, includeSoleTraders: boolean): ScbCandidate | null {
|
|
const org = String(row.OrgNr ?? '').replace(/[^0-9]/g, '')
|
|
const legalFormCode = String(row['Juridisk form, kod'] ?? '').trim()
|
|
if (org.length !== 10) return null
|
|
if (NON_COMPANY_LEGAL_FORMS.has(legalFormCode) && !(includeSoleTraders && legalFormCode === SCB_LEGAL_FORM_SOLE_TRADER)) return null
|
|
const str = (k: string) => {
|
|
const v = row[k]
|
|
const t = v === null || v === undefined ? '' : String(v).trim()
|
|
return t === '' ? null : t
|
|
}
|
|
return {
|
|
orgNumber: org,
|
|
name: str('Företagsnamn') ?? org,
|
|
city: str('PostOrt'),
|
|
industry: str('Bransch_1'),
|
|
legalForm: str('Juridisk form'),
|
|
legalFormCode: legalFormCode || null,
|
|
status: str('Företagsstatus'),
|
|
active: String(row['Företagsstatus, kod'] ?? '').trim() === '1',
|
|
}
|
|
}
|
|
|
|
export function identityLookupBody(orgNumber10: string) {
|
|
return {
|
|
Variabler: [{ Variabel: SCB_ORG_VARIABLE, Operator: 'ArLikaMed', Varde1: orgNumber10, Varde2: '' }],
|
|
Kategorier: [],
|
|
}
|
|
}
|
|
|
|
export function createScbClient(config: ScbConfig, deps: { json?: typeof scbJson } = {}): ScbClient {
|
|
const json = deps.json ?? scbJson
|
|
return {
|
|
variables: () => json(config, 'GET', '/api/Je/Variabler'),
|
|
categories: () => json(config, 'GET', '/api/Je/KategorierMedKodtabeller'),
|
|
async lookupByOrgNumber(orgNumber) {
|
|
if (!isLegalPersonOrgNumber(orgNumber)) {
|
|
throw new Error('SCB-uppslag görs bara på organisationsnummer för juridiska personer.')
|
|
}
|
|
const org10 = orgNumber.replace(/[^0-9]/g, '')
|
|
const peOrgNr = toPeOrgNr(org10)
|
|
const fetchedAt = new Date().toISOString()
|
|
const rows = await json<ScbCompanyRow[]>(config, 'POST', '/api/Je/HamtaForetag', identityLookupBody(org10))
|
|
const list = Array.isArray(rows) ? rows : []
|
|
const row = list.find((r) => String(r.OrgNr ?? r.PeOrgNr ?? '').replace(/[^0-9]/g, '').endsWith(org10)) ?? null
|
|
return { found: Boolean(row), peOrgNr, row, facts: row ? factsFromScbCompany(row) : [], fetchedAt }
|
|
},
|
|
async searchByName(raw, opts = {}) {
|
|
const includeSoleTraders = opts.includeSoleTraders === true
|
|
const query = nameQuery(raw)
|
|
if (query.length < 2) return { query, mode: 'starts_with', total: 0, truncated: false, candidates: [] }
|
|
// Count first: a short word can match thousands and we never pull those.
|
|
const run = async (mode: 'starts_with' | 'contains'): Promise<ScbSearchResult> => {
|
|
const body = nameSearchBody(query, mode)
|
|
const total = Number(await json<number | string>(config, 'POST', '/api/Je/RaknaForetag', body)) || 0
|
|
if (total === 0) return { query, mode, total, truncated: false, candidates: [] }
|
|
if (total > SCB_SEARCH_CAP * 4) return { query, mode, total, truncated: true, candidates: [] }
|
|
const rows = await json<ScbCompanyRow[]>(config, 'POST', '/api/Je/HamtaForetag', body)
|
|
const all = (Array.isArray(rows) ? rows : [])
|
|
.map((r) => candidateFrom(r, includeSoleTraders))
|
|
.filter((c): c is ScbCandidate => c !== null)
|
|
// Active companies first, then by name; the cap keeps the picker a picker.
|
|
all.sort((a, b) => Number(b.active) - Number(a.active) || a.name.localeCompare(b.name, 'sv'))
|
|
// total is what the picker can offer: SCB's count minus the natural
|
|
// persons and estates we never show ("Eismann" counted 1, offered 0).
|
|
return { query, mode, total: all.length, truncated: all.length > SCB_SEARCH_CAP, candidates: all.slice(0, SCB_SEARCH_CAP) }
|
|
}
|
|
const first = await run('starts_with')
|
|
if (first.total > 0 || first.truncated || query.length < 4) return first
|
|
return run('contains')
|
|
},
|
|
}
|
|
}
|