feat(onboarding): the orgnr step suggests companies as you type (SCB search, TIC on the pick) (#2452)

* 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>
This commit is contained in:
Mattsson
2026-09-09 11:43:14 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent cb9eedd7f2
commit fc2d78a7c4
20 changed files with 848 additions and 22 deletions
+5
View File
@@ -4301,6 +4301,11 @@ export const PartySearchRegistryQuerySchema = z.object({
q: z.string().max(120).optional(),
})
/** GET /api/company/search: the onboarding picker's free-text query. */
export const CompanySearchQuerySchema = z.object({
q: z.string().trim().min(3).max(120),
})
/**
* GET /api/parties/registry: the org number a customer or supplier form is
* being filled for. Shape, check digit and the legal-person rule are one
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { fetchCompanySuggestions } from '../fetch-company-lookup'
import type { CompanySuggestion } from '../types'
const ROW: CompanySuggestion = {
orgNumber: '5566778899',
name: 'Testbrand AB',
city: 'Malmö',
legalEntityType: 'AB',
active: true,
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
describe('fetchCompanySuggestions', () => {
const fetchMock = vi.fn()
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
it('returns disabled without fetching below the minimum length', async () => {
expect(await fetchCompanySuggestions('Te')).toEqual({ status: 'disabled' })
expect(fetchMock).not.toHaveBeenCalled()
})
it('calls the core search route, never TIC, and returns the rows', async () => {
fetchMock.mockResolvedValue(jsonResponse(200, { data: { suggestions: [ROW], truncated: false } }))
const outcome = await fetchCompanySuggestions(' Testbrand ')
expect(fetchMock.mock.calls[0]![0]).toBe('/api/company/search?q=Testbrand')
expect(outcome).toEqual({ status: 'found', suggestions: [ROW], truncated: false })
})
it('reports an empty list with the truncation flag so the field can ask for more', async () => {
fetchMock.mockResolvedValue(jsonResponse(200, { data: { suggestions: [], truncated: true } }))
expect(await fetchCompanySuggestions('Sve')).toEqual({ status: 'empty', truncated: true })
})
it('drops malformed rows and treats a malformed body as an error', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { suggestions: [ROW, { name: 1 }, null], truncated: false } }))
expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'found', suggestions: [ROW], truncated: false })
fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { nope: true } }))
expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' })
})
it('maps the not-configured 503 to disabled and every other failure to error', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(503, { error: { code: 'SCB_NOT_CONFIGURED' } }))
expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'disabled' })
fetchMock.mockResolvedValueOnce(new Response('Service Unavailable', { status: 503 }))
expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' })
fetchMock.mockResolvedValueOnce(jsonResponse(502, { error: { code: 'SCB_LOOKUP_FAILED' } }))
expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' })
fetchMock.mockResolvedValueOnce(jsonResponse(401, { error: { code: 'UNAUTHORIZED' } }))
expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' })
fetchMock.mockRejectedValueOnce(new TypeError('network'))
expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' })
})
it('returns aborted when the caller cancels', async () => {
const abortErr = Object.assign(new Error('aborted'), { name: 'AbortError' })
fetchMock.mockRejectedValueOnce(abortErr)
expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'aborted' })
const controller = new AbortController()
fetchMock.mockImplementationOnce(async () => {
controller.abort()
return jsonResponse(200, { data: { suggestions: [ROW], truncated: false } })
})
expect(await fetchCompanySuggestions('Testbrand', { signal: controller.signal })).toEqual({ status: 'aborted' })
})
})
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { toCompanySuggestion } from '../scb-suggestion'
import type { ScbCandidate } from '@/lib/parties/scb/client'
const candidate = (over: Partial<ScbCandidate> = {}): ScbCandidate => ({
orgNumber: '5566778899',
name: 'Testbrand AB',
city: 'Malmö',
industry: null,
legalForm: 'Aktiebolag',
legalFormCode: '49',
status: 'Är verksam',
active: true,
...over,
})
describe('toCompanySuggestion', () => {
it('maps the forms the journey sets up and leaves the rest to the user', () => {
expect(toCompanySuggestion(candidate()).legalEntityType).toBe('AB')
expect(toCompanySuggestion(candidate({ legalFormCode: '10' })).legalEntityType).toBe('EF')
expect(toCompanySuggestion(candidate({ legalFormCode: '61' })).legalEntityType).toBe('Ideell förening')
// Insurance AB, bank AB, ekonomisk förening, stiftelse: not a plain AB.
for (const code of ['42', '41', '51', '72', null]) {
expect(toCompanySuggestion(candidate({ legalFormCode: code })).legalEntityType).toBeNull()
}
})
it('carries only what the picker shows plus the number it resolves to', () => {
expect(toCompanySuggestion(candidate({ active: false }))).toEqual({
orgNumber: '5566778899',
name: 'Testbrand AB',
city: 'Malmö',
legalEntityType: 'AB',
active: false,
})
})
})
+60 -1
View File
@@ -1,5 +1,5 @@
import { COMPANY_SEARCH_MIN_CHARS } from './types'
import type { CompanyLookupResult, CompanySearchHit } from './types'
import type { CompanyLookupResult, CompanySearchHit, CompanySuggestion } from './types'
import { normalizeOrgNumber } from './normalize-org-number'
/**
@@ -121,6 +121,65 @@ export async function fetchCompanySearch(
return mapFailure(res)
}
export type CompanySuggestOutcome =
| { status: 'found'; suggestions: CompanySuggestion[]; truncated: boolean }
| { status: 'empty'; truncated: boolean }
| { status: 'disabled' }
| { status: 'error' }
| { status: 'aborted' }
/**
* Search-as-you-type for the journey's orgnr field: SCB's företagsregister
* via the core route, never TIC. Free, so the caller may fire it per
* debounced keystroke; the AbortSignal drops the superseded request. A
* 503 (SCB not configured in this environment) is `disabled` so the field
* quietly stays an orgnr-or-Enter field; every other failure is `error`
* and the picker just does not appear. Never throws.
*/
export async function fetchCompanySuggestions(
query: string,
opts: { signal?: AbortSignal } = {},
): Promise<CompanySuggestOutcome> {
const trimmed = query.trim()
if (trimmed.length < COMPANY_SEARCH_MIN_CHARS) return { status: 'disabled' }
let res: Response
try {
res = await fetch(`/api/company/search?q=${encodeURIComponent(trimmed)}`, { signal: opts.signal })
} catch (err) {
if ((err as Error).name === 'AbortError') return { status: 'aborted' }
return { status: 'error' }
}
if (opts.signal?.aborted) return { status: 'aborted' }
if (res.ok) {
try {
const { data } = (await res.json()) as {
data: { suggestions: CompanySuggestion[]; truncated: boolean }
}
if (!data || !Array.isArray(data.suggestions)) return { status: 'error' }
const suggestions = data.suggestions.filter(
(h) => h && typeof h.orgNumber === 'string' && typeof h.name === 'string',
)
const truncated = data.truncated === true
return suggestions.length > 0 ? { status: 'found', suggestions, truncated } : { status: 'empty', truncated }
} catch {
return { status: 'error' }
}
}
// Only the route's own "no SCB credentials here" switches the picker off
// for the session; an infrastructure 503 is transient like any other.
if (res.status === 503) {
try {
const body = (await res.json()) as { error?: { code?: unknown } }
if (body?.error?.code === 'SCB_NOT_CONFIGURED') return { status: 'disabled' }
} catch {
// Non-JSON 503: transient.
}
}
return { status: 'error' }
}
/** Shared non-ok mapping: dispatcher misses degrade silently, only the TIC
* handler's own 404 is a user-facing "not found". */
async function mapFailure(
+26
View File
@@ -0,0 +1,26 @@
import { SCB_LEGAL_FORM_SOLE_TRADER, type ScbCandidate } from '@/lib/parties/scb/client'
import type { CompanySuggestion } from './types'
/**
* SCB legal form codes the journey may prefill, expressed in the TIC
* vocabulary `mapSetupEntityType` already understands: 49 (aktiebolag),
* 10 (enskild näringsidkare) and 61 (ideell förening, which the flag may
* still refuse at setup). Bank and insurance AB (41, 42), ekonomisk
* förening (51), stiftelser and the rest stay null: the user picks the
* form, as after a TIC lookup with an unmapped type.
*/
const LEGAL_ENTITY_TYPE_BY_SCB_CODE: Record<string, string> = {
'49': 'AB',
[SCB_LEGAL_FORM_SOLE_TRADER]: 'EF',
'61': 'Ideell förening',
}
export function toCompanySuggestion(c: ScbCandidate): CompanySuggestion {
return {
orgNumber: c.orgNumber,
name: c.name,
city: c.city,
legalEntityType: (c.legalFormCode && LEGAL_ENTITY_TYPE_BY_SCB_CODE[c.legalFormCode]) || null,
active: c.active,
}
}
+20
View File
@@ -73,3 +73,23 @@ export interface CompanySearchHit {
* so the two never disagree on what is worth a provider call.
*/
export const COMPANY_SEARCH_MIN_CHARS = 3
/**
* One row of the search-as-you-type picker on the onboarding orgnr step,
* from SCB's företagsregister (free): enough to recognise the company and
* to run the single TIC lookup once it is picked. `legalEntityType` uses
* the same vocabulary as CompanyLookupResult so the reducer maps it with
* mapSetupEntityType; null when SCB's legal form is not one we set up.
* A sole trader's `orgNumber` is the owner's personnummer: the picker
* names the form instead of printing it.
*/
export interface CompanySuggestion {
orgNumber: string
name: string
city: string | null
legalEntityType: string | null
active: boolean
}
/** Rows the picker shows; SCB may return more, the client keeps a picker a picker. */
export const COMPANY_SUGGEST_MAX = 6
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { initJourney, journeyReducer, type JourneyAction, type JourneyState } from '../reducer'
import type { CompanyLookupResult, CompanySearchHit } from '@/lib/company-lookup/types'
import type { CompanyLookupResult, CompanySearchHit, CompanySuggestion } from '@/lib/company-lookup/types'
function lookup(overrides: Partial<CompanyLookupResult> = {}): CompanyLookupResult {
return {
@@ -215,3 +215,133 @@ describe('journeyReducer: name search', () => {
expect(s.searchHits).toEqual([])
})
})
describe('journeyReducer: search-as-you-type pick (SCB row, TIC on pick)', () => {
const suggestion = (overrides: Partial<CompanySuggestion> = {}): CompanySuggestion => ({
orgNumber: '5566778899',
name: 'Testbrand AB',
city: 'Malmö',
legalEntityType: 'AB',
active: true,
...overrides,
})
it('SUGGESTION_PICKED stores the orgnr and prefill, then waits for the single TIC lookup', () => {
const s = journeyReducer(initJourney(), { type: 'SUGGESTION_PICKED', suggestion: suggestion() })
expect(s.step).toBe('orgnr')
expect(s.lookupPending).toBe(true)
expect(s.lookupRan).toBe(false)
expect(s.ticLookup).toBeNull()
expect(s.settings.org_number).toBe('5566778899')
expect(s.settings.company_name).toBe('Testbrand AB')
expect(s.settings.entity_type).toBe('aktiebolag')
expect(s.searchHits).toEqual([])
})
it('a picked row followed by a TIC answer lands exactly where a typed orgnr does', () => {
const viaPick = run(
initJourney(),
{ type: 'SUGGESTION_PICKED', suggestion: suggestion() },
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
)
const viaOrg = run(
initJourney(),
{ type: 'ORG_SUBMITTED', orgNumber: '5566778899' },
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
)
expect(viaPick.step).toBe('fy')
expect(viaPick.settings).toEqual(viaOrg.settings)
expect(viaPick.lookupRan).toBe(true)
})
it('TIC facts override the SCB prefill', () => {
const s = run(
initJourney(),
{ type: 'SUGGESTION_PICKED', suggestion: suggestion({ name: 'TESTBRAND AKTIEBOLAG' }) },
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup({ companyName: 'Testbrand AB' }) } },
)
expect(s.settings.company_name).toBe('Testbrand AB')
})
it('with TIC off, the SCB prefill carries the AB past the form and name questions', () => {
const s = run(
initJourney(),
{ type: 'SUGGESTION_PICKED', suggestion: suggestion() },
{ type: 'LOOKUP_RESULT', outcome: { status: 'disabled' } },
)
// Name and form known, address not: the degraded path asks for it.
expect(s.step).toBe('address')
expect(s.lookupRan).toBe(false)
expect(s.settings.company_name).toBe('Testbrand AB')
expect(s.settings.entity_type).toBe('aktiebolag')
})
it('an unmapped legal form falls through to the form picker on the degraded path', () => {
const s = run(
initJourney(),
{ type: 'SUGGESTION_PICKED', suggestion: suggestion({ legalEntityType: null }) },
{ type: 'LOOKUP_RESULT', outcome: { status: 'error' } },
)
expect(s.step).toBe('form')
expect(s.lookupNote).toBe('error')
expect(s.settings.org_number).toBe('5566778899')
})
it('a sole trader row still confirms the verksamhetsnamn', () => {
const s = run(
initJourney(),
{ type: 'SUGGESTION_PICKED', suggestion: suggestion({ orgNumber: '8001011234', name: 'ANDERSSON, ANNA', legalEntityType: 'EF' }) },
{ type: 'LOOKUP_RESULT', outcome: { status: 'disabled' } },
)
expect(s.settings.entity_type).toBe('enskild_firma')
expect(s.step).toBe('name')
})
it('a pick replaces a previous orgnr and its facts', () => {
const s = run(
initJourney(),
{ type: 'ORG_SUBMITTED', orgNumber: '1111111111' },
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
{ type: 'NOTFOUND_EDIT' },
{ type: 'SUGGESTION_PICKED', suggestion: suggestion() },
)
expect(s.settings.org_number).toBe('5566778899')
expect(s.ticLookup).toBeNull()
expect(s.lookupNote).toBe('none')
})
it('editing the number after a missed pick drops the pick\'s name and form', () => {
const s = run(
initJourney(),
{ type: 'SUGGESTION_PICKED', suggestion: suggestion({ name: 'Alpha AB' }) },
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
{ type: 'NOTFOUND_EDIT' },
{ type: 'ORG_SUBMITTED', orgNumber: '2222222222' },
{ type: 'LOOKUP_RESULT', outcome: { status: 'error' } },
)
expect(s.settings.company_name).toBeUndefined()
expect(s.settings.entity_type).toBeUndefined()
expect(s.step).toBe('form')
})
it('editing the number keeps a BankID prefill, which was never about the number', () => {
const s = run(
initJourney({ initialOrgNumber: '1111111111', initialEntityType: 'aktiebolag', initialLegalName: 'Roles AB' }),
{ type: 'ORG_SUBMITTED', orgNumber: '1111111111' },
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
{ type: 'NOTFOUND_EDIT' },
)
expect(s.settings.company_name).toBe('Roles AB')
expect(s.settings.entity_type).toBe('aktiebolag')
})
it('is ignored off the orgnr step and while submitting', () => {
const later = run(
initJourney(),
{ type: 'ORG_SUBMITTED', orgNumber: '5566778899' },
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
)
expect(later.step).toBe('fy')
expect(journeyReducer(later, { type: 'SUGGESTION_PICKED', suggestion: suggestion({ orgNumber: '2222222222' }) })).toBe(later)
})
})
+36 -2
View File
@@ -1,5 +1,5 @@
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import type { CompanyLookupResult, CompanySearchHit } from '@/lib/company-lookup/types'
import type { CompanyLookupResult, CompanySearchHit, CompanySuggestion } from '@/lib/company-lookup/types'
import type {
CompanyLookupOutcome,
CompanySearchOutcome,
@@ -121,6 +121,7 @@ export type JourneyAction =
| { type: 'SEARCH_SUBMITTED'; query: string }
| { type: 'SEARCH_RESULT'; outcome: CompanySearchOutcome }
| { type: 'SEARCH_HIT_PICKED'; hit: CompanySearchHit }
| { type: 'SUGGESTION_PICKED'; suggestion: CompanySuggestion }
| { type: 'NOTFOUND_CONTINUE' }
| { type: 'NOTFOUND_EDIT' }
| { type: 'CEASED_CONTINUE' }
@@ -371,6 +372,32 @@ export function journeyReducer(state: JourneyState, action: JourneyAction): Jour
return applyLookupFound(withOrgNumber(state, action.hit.orgNumber), action.hit.result)
}
case 'SUGGESTION_PICKED': {
// A search-as-you-type row (SCB) resolves to an orgnr the same way a
// typed one does: the component fires the single TIC lookup next and
// LOOKUP_RESULT decides the step. What SCB already knows (name, form)
// is prefill for the degraded paths (TIC off, error, not found), and
// TIC's answer overrides it when it comes. lookupRan stays false: SCB
// says nothing about F-skatt, VAT or the fiscal year.
if (state.submitting || state.step !== 'orgnr') return state
const { suggestion } = action
const mapped = mapSetupEntityType(suggestion.legalEntityType)
return stay(state, {
settings: {
...state.settings,
org_number: suggestion.orgNumber,
company_name: suggestion.name,
entity_type: mapped ?? state.settings.entity_type,
},
ticLookup: null,
lookupRan: false,
lookupNote: 'none',
lookupPending: true,
searchHits: [],
serverError: null,
})
}
case 'NOTFOUND_CONTINUE': {
if (state.settings.entity_type) return go(state, nextCompanyStep(state))
return go(state, 'form')
@@ -379,8 +406,15 @@ export function journeyReducer(state: JourneyState, action: JourneyAction): Jour
case 'NOTFOUND_EDIT':
case 'CEASED_EDIT': {
// Back to the orgnr question; the fresh submit re-runs the single lookup.
// The abandoned number's name and form go with it (a picked SCB row or
// a ceased lookup put them there); BankID's CompanyRoles prefill stays,
// it was never about this number.
return go(state, 'orgnr', {
settings: { ...state.settings, org_number: undefined },
settings: {
...state.settings,
org_number: undefined,
...(state.viaPrefill ? {} : { company_name: undefined, entity_type: undefined }),
},
ticLookup: null,
lookupRan: false,
lookupNote: 'none',
+16
View File
@@ -181,6 +181,22 @@ describe('name search', () => {
expect(SCB_SEARCH_CAP).toBe(25)
})
it('offers sole traders only when asked, and never estates', async () => {
const json = async (_c: unknown, _m: string, path: string) => {
if (path.endsWith('RaknaForetag')) return 3
return [row('5564082161', 'Adobe Systems Nordic Aktiebolag'), row('8001011234', 'ADOBE, ANNA', '1', '10'), row('8001011235', 'ADOBE, ANNA DÖDSBO', '1', '91')]
}
const client = createScbClient(cfg, { json: json as never })
const parties = await client.searchByName('Adobe')
expect(parties.candidates.map((c) => c.orgNumber)).toEqual(['5564082161'])
const onboarding = await client.searchByName('Adobe', { includeSoleTraders: true })
expect(onboarding.candidates.map((c) => [c.orgNumber, c.legalFormCode])).toEqual([
['5564082161', '49'],
['8001011234', '10'],
])
expect(onboarding.total).toBe(2)
})
it('does not call SCB for a query shorter than two characters', async () => {
const json = async () => {
throw new Error('should not be called')
+26 -5
View File
@@ -28,6 +28,8 @@ export interface ScbCandidate {
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
@@ -47,13 +49,27 @@ export interface ScbClient {
variables(): Promise<unknown>
categories(): Promise<unknown>
lookupByOrgNumber(orgNumber: string): Promise<ScbLookupResult>
searchByName(query: string): Promise<ScbSearchResult>
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
@@ -78,10 +94,11 @@ export function nameSearchBody(query: string, mode: 'starts_with' | 'contains')
}
}
function candidateFrom(row: ScbCompanyRow): ScbCandidate | null {
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 || NON_COMPANY_LEGAL_FORMS.has(legalFormCode)) return null
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()
@@ -93,6 +110,7 @@ function candidateFrom(row: ScbCompanyRow): ScbCandidate | null {
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',
}
@@ -122,7 +140,8 @@ export function createScbClient(config: ScbConfig, deps: { json?: typeof scbJson
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) {
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.
@@ -132,7 +151,9 @@ export function createScbClient(config: ScbConfig, deps: { json?: typeof scbJson
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(candidateFrom).filter((c): c is ScbCandidate => c !== null)
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