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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
5987523a25
commit
1157ff1b66
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { fetchCompanySearch } from '../fetch-company-lookup'
|
||||
import type { CompanyLookupResult, CompanySearchHit } from '../types'
|
||||
|
||||
const LOOKUP: CompanyLookupResult = {
|
||||
companyName: 'Testbrand AB',
|
||||
isCeased: false,
|
||||
address: { street: 'Storgatan 1', postalCode: '211 34', city: 'Malmö' },
|
||||
registration: { fTax: true, vat: true },
|
||||
bankAccounts: [],
|
||||
email: null,
|
||||
phone: null,
|
||||
sniCodes: [],
|
||||
fiscalYear: { startMonthDay: '01-01', endMonthDay: '12-31' },
|
||||
legalEntityType: 'AB',
|
||||
registrationDate: 1710000000000,
|
||||
}
|
||||
|
||||
const HIT: CompanySearchHit = { orgNumber: '5560360793', result: LOOKUP }
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchCompanySearch', () => {
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
it('returns disabled without fetching when tic is not enabled', async () => {
|
||||
const outcome = await fetchCompanySearch('Testbrand', { ticEnabled: false })
|
||||
expect(outcome).toEqual({ status: 'disabled' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns disabled without fetching for a query under the minimum length', async () => {
|
||||
const outcome = await fetchCompanySearch(' ab ', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'disabled' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls the search route with the trimmed, encoded query', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(200, { data: [HIT] }))
|
||||
await fetchCompanySearch(' Testbrand & Co ', { ticEnabled: true })
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
'/api/extensions/ext/tic/search?q=Testbrand%20%26%20Co',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the hits on 200', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(200, { data: [HIT, { ...HIT, orgNumber: '5591234567' }] }))
|
||||
const outcome = await fetchCompanySearch('Testbrand', { ticEnabled: true })
|
||||
expect(outcome.status).toBe('found')
|
||||
if (outcome.status !== 'found') throw new Error('unreachable')
|
||||
expect(outcome.hits.map((h) => h.orgNumber)).toEqual(['5560360793', '5591234567'])
|
||||
})
|
||||
|
||||
it('drops malformed hits and maps an all-malformed payload to not_found', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(200, { data: [{ orgNumber: 1 }, { result: LOOKUP }] }))
|
||||
const outcome = await fetchCompanySearch('Testbrand', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'not_found' })
|
||||
})
|
||||
|
||||
it('maps a non-array data payload to error', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(200, { data: { orgNumber: '5560360793' } }))
|
||||
const outcome = await fetchCompanySearch('Testbrand', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'error' })
|
||||
})
|
||||
|
||||
it("maps the TIC handler's 404 (Company not found) to not_found", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(404, { error: 'Company not found' }))
|
||||
const outcome = await fetchCompanySearch('Testbrand', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'not_found' })
|
||||
})
|
||||
|
||||
it("maps the dispatcher's 404 (Route not found) to disabled", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(404, { error: 'Route not found' }))
|
||||
const outcome = await fetchCompanySearch('Testbrand', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'disabled' })
|
||||
})
|
||||
|
||||
it('maps a feature-flag 503 to disabled and any other 5xx to error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(503, { code: 'EXTENSION_DISABLED' }))
|
||||
expect(await fetchCompanySearch('Testbrand', { ticEnabled: true })).toEqual({ status: 'disabled' })
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(502, { error: 'upstream' }))
|
||||
expect(await fetchCompanySearch('Testbrand', { ticEnabled: true })).toEqual({ status: 'error' })
|
||||
})
|
||||
|
||||
it('maps 429 to error (advisory note, manual path)', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(429, { error: 'Rate limit exceeded' }))
|
||||
expect(await fetchCompanySearch('Testbrand', { ticEnabled: true })).toEqual({ status: 'error' })
|
||||
})
|
||||
|
||||
it('maps a network failure to error and an abort to aborted', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
expect(await fetchCompanySearch('Testbrand', { ticEnabled: true })).toEqual({ status: 'error' })
|
||||
const abortErr = new Error('aborted')
|
||||
abortErr.name = 'AbortError'
|
||||
fetchMock.mockRejectedValueOnce(abortErr)
|
||||
expect(await fetchCompanySearch('Testbrand', { ticEnabled: true })).toEqual({ status: 'aborted' })
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CompanyLookupResult } from './types'
|
||||
import { COMPANY_SEARCH_MIN_CHARS } from './types'
|
||||
import type { CompanyLookupResult, CompanySearchHit } from './types'
|
||||
import { normalizeOrgNumber } from './normalize-org-number'
|
||||
|
||||
/**
|
||||
@@ -69,6 +70,62 @@ export async function fetchCompanyLookup(
|
||||
}
|
||||
}
|
||||
|
||||
return mapFailure(res)
|
||||
}
|
||||
|
||||
export type CompanySearchOutcome =
|
||||
| { status: 'found'; hits: CompanySearchHit[] }
|
||||
| { status: 'not_found' }
|
||||
| { status: 'disabled' }
|
||||
| { status: 'error' }
|
||||
| { status: 'aborted' }
|
||||
|
||||
/**
|
||||
* Free-text counterpart of fetchCompanyLookup for the journey's orgnr field,
|
||||
* which also accepts a company name. Same dispatcher, same failure mapping,
|
||||
* same budget rule: fire once per Enter, never per keystroke. Each hit already
|
||||
* carries the full lookup result, so picking one needs no further call.
|
||||
*/
|
||||
export async function fetchCompanySearch(
|
||||
query: string,
|
||||
opts: { ticEnabled: boolean; signal?: AbortSignal },
|
||||
): Promise<CompanySearchOutcome> {
|
||||
if (!opts.ticEnabled) return { status: 'disabled' }
|
||||
const trimmed = query.trim()
|
||||
if (trimmed.length < COMPANY_SEARCH_MIN_CHARS) return { status: 'disabled' }
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`/api/extensions/ext/tic/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: CompanySearchHit[] }
|
||||
if (!Array.isArray(data)) return { status: 'error' }
|
||||
const hits = data.filter(
|
||||
(h) => h && typeof h.orgNumber === 'string' && h.result && typeof h.result === 'object',
|
||||
)
|
||||
return hits.length > 0 ? { status: 'found', hits } : { status: 'not_found' }
|
||||
} catch {
|
||||
return { status: 'error' }
|
||||
}
|
||||
}
|
||||
|
||||
return mapFailure(res)
|
||||
}
|
||||
|
||||
/** Shared non-ok mapping: dispatcher misses degrade silently, only the TIC
|
||||
* handler's own 404 is a user-facing "not found". */
|
||||
async function mapFailure(
|
||||
res: Response,
|
||||
): Promise<{ status: 'not_found' } | { status: 'disabled' } | { status: 'error' }> {
|
||||
// Non-ok: read the body (best-effort) to disambiguate.
|
||||
let body: { error?: unknown; code?: unknown } = {}
|
||||
try {
|
||||
|
||||
@@ -55,3 +55,21 @@ export interface CompanyLookupResult {
|
||||
*/
|
||||
registrationDate?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One hit from a free-text company search (onboarding's orgnr field also
|
||||
* accepts a name). Carries the org number the hit resolves to alongside the
|
||||
* same lookup result `/lookup` would return for it, so picking a hit costs
|
||||
* no second provider call.
|
||||
*/
|
||||
export interface CompanySearchHit {
|
||||
orgNumber: string
|
||||
result: CompanyLookupResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest free-text query the search accepts. Shared by the client (which
|
||||
* shakes the field instead of calling) and the TIC route (which answers 400)
|
||||
* so the two never disagree on what is worth a provider call.
|
||||
*/
|
||||
export const COMPANY_SEARCH_MIN_CHARS = 3
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { initJourney, journeyReducer, type JourneyAction, type JourneyState } from '../reducer'
|
||||
import type { CompanyLookupResult, CompanySearchHit } from '@/lib/company-lookup/types'
|
||||
|
||||
function lookup(overrides: Partial<CompanyLookupResult> = {}): CompanyLookupResult {
|
||||
return {
|
||||
companyName: 'Testbrand AB',
|
||||
isCeased: false,
|
||||
address: { street: 'Storgatan 1', postalCode: '211 34', city: 'Malmö' },
|
||||
registration: { fTax: true, vat: true },
|
||||
bankAccounts: [],
|
||||
email: null,
|
||||
phone: null,
|
||||
sniCodes: [],
|
||||
fiscalYear: { startMonthDay: '01-01', endMonthDay: '12-31' },
|
||||
legalEntityType: 'AB',
|
||||
registrationDate: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function hit(orgNumber: string, overrides: Partial<CompanyLookupResult> = {}): CompanySearchHit {
|
||||
return { orgNumber, result: lookup(overrides) }
|
||||
}
|
||||
|
||||
function run(state: JourneyState, ...actions: JourneyAction[]): JourneyState {
|
||||
return actions.reduce(journeyReducer, state)
|
||||
}
|
||||
|
||||
describe('journeyReducer: name search', () => {
|
||||
it('SEARCH_SUBMITTED clears the previous orgnr and facts and marks the lookup pending', () => {
|
||||
const prior = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
{ type: 'NOTFOUND_EDIT' },
|
||||
)
|
||||
const s = journeyReducer(prior, { type: 'SEARCH_SUBMITTED', query: 'Testbrand' })
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.lookupPending).toBe(true)
|
||||
expect(s.settings.org_number).toBeUndefined()
|
||||
expect(s.ticLookup).toBeNull()
|
||||
expect(s.searchHits).toEqual([])
|
||||
})
|
||||
|
||||
it('a single hit resolves exactly like a typed orgnr', () => {
|
||||
const viaSearch = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{ type: 'SEARCH_RESULT', outcome: { status: 'found', hits: [hit('5566778899')] } },
|
||||
)
|
||||
const viaOrg = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '5566778899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
)
|
||||
expect(viaSearch.step).toBe('fy')
|
||||
expect(viaSearch.settings).toEqual(viaOrg.settings)
|
||||
expect(viaSearch.lookupRan).toBe(true)
|
||||
expect(viaSearch.searchHits).toEqual([])
|
||||
expect(viaSearch.lookupPending).toBe(false)
|
||||
})
|
||||
|
||||
it('several hits stay on the orgnr step and wait for a pick', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{
|
||||
type: 'SEARCH_RESULT',
|
||||
outcome: { status: 'found', hits: [hit('1111111111'), hit('2222222222')] },
|
||||
},
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.lookupPending).toBe(false)
|
||||
expect(s.searchHits.map((h) => h.orgNumber)).toEqual(['1111111111', '2222222222'])
|
||||
expect(s.settings.org_number).toBeUndefined()
|
||||
expect(s.lookupRan).toBe(false)
|
||||
})
|
||||
|
||||
it('SEARCH_HIT_PICKED applies the picked hit as lookup facts and advances', () => {
|
||||
const waiting = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{
|
||||
type: 'SEARCH_RESULT',
|
||||
outcome: {
|
||||
status: 'found',
|
||||
hits: [hit('1111111111'), hit('2222222222', { companyName: 'Testbrand Bygg AB' })],
|
||||
},
|
||||
},
|
||||
)
|
||||
const s = journeyReducer(waiting, {
|
||||
type: 'SEARCH_HIT_PICKED',
|
||||
hit: hit('2222222222', { companyName: 'Testbrand Bygg AB' }),
|
||||
})
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.searchHits).toEqual([])
|
||||
expect(s.lookupRan).toBe(true)
|
||||
expect(s.settings).toMatchObject({
|
||||
org_number: '2222222222',
|
||||
entity_type: 'aktiebolag',
|
||||
company_name: 'Testbrand Bygg AB',
|
||||
f_skatt: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('a picked ceased hit routes to the ceased step like a typed orgnr would', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{
|
||||
type: 'SEARCH_RESULT',
|
||||
outcome: { status: 'found', hits: [hit('1111111111'), hit('2222222222', { isCeased: true })] },
|
||||
},
|
||||
{ type: 'SEARCH_HIT_PICKED', hit: hit('2222222222', { isCeased: true }) },
|
||||
)
|
||||
expect(s.step).toBe('ceased')
|
||||
expect(s.settings.org_number).toBe('2222222222')
|
||||
})
|
||||
|
||||
it('a hit without a mappable entity type goes to the form step', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{
|
||||
type: 'SEARCH_RESULT',
|
||||
outcome: { status: 'found', hits: [hit('1111111111', { legalEntityType: 'HB' })] },
|
||||
},
|
||||
)
|
||||
expect(s.step).toBe('form')
|
||||
})
|
||||
|
||||
it('SEARCH_HIT_PICKED is ignored off the orgnr step', () => {
|
||||
const atFy = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{ type: 'SEARCH_RESULT', outcome: { status: 'found', hits: [hit('1111111111')] } },
|
||||
)
|
||||
expect(atFy.step).toBe('fy')
|
||||
const s = journeyReducer(atFy, { type: 'SEARCH_HIT_PICKED', hit: hit('2222222222') })
|
||||
expect(s).toBe(atFy)
|
||||
})
|
||||
|
||||
it('no match stays on the step with the nomatch note (no orgnr to continue with)', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Nothing Like This' },
|
||||
{ type: 'SEARCH_RESULT', outcome: { status: 'not_found' } },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.lookupPending).toBe(false)
|
||||
expect(s.lookupNote).toBe('nomatch')
|
||||
expect(s.searchHits).toEqual([])
|
||||
})
|
||||
|
||||
it('error and disabled stay on the step with the error note', () => {
|
||||
for (const status of ['error', 'disabled'] as const) {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{ type: 'SEARCH_RESULT', outcome: { status } },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.lookupNote).toBe('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('aborted only clears the pending flag', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{ type: 'SEARCH_RESULT', outcome: { status: 'aborted' } },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.lookupPending).toBe(false)
|
||||
expect(s.lookupNote).toBe('none')
|
||||
})
|
||||
|
||||
it('a stale SEARCH_RESULT without a pending search is ignored', () => {
|
||||
const idle = initJourney()
|
||||
const s = journeyReducer(idle, {
|
||||
type: 'SEARCH_RESULT',
|
||||
outcome: { status: 'found', hits: [hit('1111111111')] },
|
||||
})
|
||||
expect(s).toBe(idle)
|
||||
})
|
||||
|
||||
it('a fresh ORG_SUBMITTED drops waiting hits and the nomatch note', () => {
|
||||
const waiting = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{
|
||||
type: 'SEARCH_RESULT',
|
||||
outcome: { status: 'found', hits: [hit('1111111111'), hit('2222222222')] },
|
||||
},
|
||||
)
|
||||
const s = journeyReducer(waiting, { type: 'ORG_SUBMITTED', orgNumber: '556677-8899' })
|
||||
expect(s.searchHits).toEqual([])
|
||||
expect(s.lookupNote).toBe('none')
|
||||
expect(s.lookupPending).toBe(true)
|
||||
})
|
||||
|
||||
it('Back from a step reached via a pick returns to an orgnr step with no hits', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'SEARCH_SUBMITTED', query: 'Testbrand' },
|
||||
{
|
||||
type: 'SEARCH_RESULT',
|
||||
outcome: { status: 'found', hits: [hit('1111111111'), hit('2222222222')] },
|
||||
},
|
||||
{ type: 'SEARCH_HIT_PICKED', hit: hit('2222222222') },
|
||||
{ type: 'BACK' },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.searchHits).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import type { CompanyLookupOutcome } from '@/lib/company-lookup/fetch-company-lookup'
|
||||
import type { CompanyLookupResult, CompanySearchHit } from '@/lib/company-lookup/types'
|
||||
import type {
|
||||
CompanyLookupOutcome,
|
||||
CompanySearchOutcome,
|
||||
} from '@/lib/company-lookup/fetch-company-lookup'
|
||||
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
|
||||
import { deriveSwedishVatNumber } from '@/lib/vat/vat-number'
|
||||
|
||||
@@ -79,7 +82,8 @@ interface JourneySnapshot {
|
||||
settings: Partial<CompanySettings>
|
||||
ticLookup: CompanyLookupResult | null
|
||||
lookupRan: boolean
|
||||
lookupNote: 'none' | 'error'
|
||||
/** `nomatch`: a name search returned nothing; the user refines in place. */
|
||||
lookupNote: 'none' | 'error' | 'nomatch'
|
||||
addressAsked: boolean
|
||||
/** EF only: the verksamhetsnamn question was explicitly answered. */
|
||||
nameConfirmedForEf: boolean
|
||||
@@ -91,6 +95,9 @@ export interface JourneyState extends JourneySnapshot {
|
||||
history: JourneySnapshot[]
|
||||
/** Component fires the lookup while this is true; reducer set on ORG_SUBMITTED. */
|
||||
lookupPending: boolean
|
||||
/** Name-search hits awaiting a pick on the orgnr step; empty otherwise.
|
||||
* Not snapshotted: leaving the step drops them, Back re-asks. */
|
||||
searchHits: CompanySearchHit[]
|
||||
/** BankID CompanyRoles prefill present (name/entity trusted without lookup). */
|
||||
viaPrefill: boolean
|
||||
mode: 'first' | 'add'
|
||||
@@ -111,6 +118,9 @@ export interface JourneyInit {
|
||||
export type JourneyAction =
|
||||
| { type: 'ORG_SUBMITTED'; orgNumber: string }
|
||||
| { type: 'LOOKUP_RESULT'; outcome: CompanyLookupOutcome }
|
||||
| { type: 'SEARCH_SUBMITTED'; query: string }
|
||||
| { type: 'SEARCH_RESULT'; outcome: CompanySearchOutcome }
|
||||
| { type: 'SEARCH_HIT_PICKED'; hit: CompanySearchHit }
|
||||
| { type: 'NOTFOUND_CONTINUE' }
|
||||
| { type: 'NOTFOUND_EDIT' }
|
||||
| { type: 'CEASED_CONTINUE' }
|
||||
@@ -165,6 +175,7 @@ export function initJourney(init: JourneyInit = {}): JourneyState {
|
||||
entry: snapshotOf(base),
|
||||
history: [],
|
||||
lookupPending: false,
|
||||
searchHits: [],
|
||||
viaPrefill: Boolean(init.initialOrgNumber && (init.initialEntityType || init.initialLegalName)),
|
||||
mode: init.mode ?? 'first',
|
||||
submitting: false,
|
||||
@@ -178,6 +189,7 @@ function go(state: JourneyState, next: JourneyStep, patch?: Partial<JourneyState
|
||||
const moved: JourneyState = {
|
||||
...state,
|
||||
lookupPending: false,
|
||||
searchHits: [],
|
||||
serverError: null,
|
||||
...patch,
|
||||
step: next,
|
||||
@@ -241,6 +253,37 @@ function wipeDownstream(settings: Partial<CompanySettings>): Partial<CompanySett
|
||||
return next
|
||||
}
|
||||
|
||||
function withOrgNumber(state: JourneyState, orgNumber: string): JourneyState {
|
||||
return stay(state, { settings: { ...state.settings, org_number: orgNumber } })
|
||||
}
|
||||
|
||||
/**
|
||||
* The single "TIC answered with data" transition, shared by a typed orgnr,
|
||||
* a one-hit name search and a picked hit: facts become settings, the step
|
||||
* advances past whatever the lookup already answered.
|
||||
*/
|
||||
function applyLookupFound(state: JourneyState, lookup: CompanyLookupResult): JourneyState {
|
||||
const mapped = mapEntityType(lookup.legalEntityType)
|
||||
const settings: Partial<CompanySettings> = {
|
||||
...state.settings,
|
||||
entity_type: mapped ?? state.settings.entity_type,
|
||||
company_name: lookup.companyName || state.settings.company_name,
|
||||
address_line1: lookup.address?.street ?? state.settings.address_line1,
|
||||
postal_code: lookup.address?.postalCode ?? state.settings.postal_code,
|
||||
city: lookup.address?.city ?? state.settings.city,
|
||||
f_skatt: lookup.registration.fTax,
|
||||
}
|
||||
const enriched = stay(state, {
|
||||
settings,
|
||||
ticLookup: lookup,
|
||||
lookupRan: true,
|
||||
lookupNote: 'none' as const,
|
||||
})
|
||||
if (lookup.isCeased) return go(enriched, 'ceased')
|
||||
if (!settings.entity_type) return go(enriched, 'form')
|
||||
return go(enriched, nextCompanyStep(enriched))
|
||||
}
|
||||
|
||||
export function journeyReducer(state: JourneyState, action: JourneyAction): JourneyState {
|
||||
switch (action.type) {
|
||||
case 'ORG_SUBMITTED': {
|
||||
@@ -252,6 +295,21 @@ export function journeyReducer(state: JourneyState, action: JourneyAction): Jour
|
||||
lookupRan: false,
|
||||
lookupNote: 'none',
|
||||
lookupPending: true,
|
||||
searchHits: [],
|
||||
serverError: null,
|
||||
})
|
||||
}
|
||||
|
||||
case 'SEARCH_SUBMITTED': {
|
||||
if (state.submitting) return state
|
||||
// A name search has no orgnr yet: it arrives with the picked hit.
|
||||
return stay(state, {
|
||||
settings: { ...state.settings, org_number: undefined },
|
||||
ticLookup: null,
|
||||
lookupRan: false,
|
||||
lookupNote: 'none',
|
||||
lookupPending: true,
|
||||
searchHits: [],
|
||||
serverError: null,
|
||||
})
|
||||
}
|
||||
@@ -264,26 +322,7 @@ export function journeyReducer(state: JourneyState, action: JourneyAction): Jour
|
||||
if (outcome.status === 'aborted') return cleared
|
||||
|
||||
if (outcome.status === 'found') {
|
||||
const lookup = outcome.result
|
||||
const mapped = mapEntityType(lookup.legalEntityType)
|
||||
const settings: Partial<CompanySettings> = {
|
||||
...state.settings,
|
||||
entity_type: mapped ?? state.settings.entity_type,
|
||||
company_name: lookup.companyName || state.settings.company_name,
|
||||
address_line1: lookup.address?.street ?? state.settings.address_line1,
|
||||
postal_code: lookup.address?.postalCode ?? state.settings.postal_code,
|
||||
city: lookup.address?.city ?? state.settings.city,
|
||||
f_skatt: lookup.registration.fTax,
|
||||
}
|
||||
const enriched = stay(cleared, {
|
||||
settings,
|
||||
ticLookup: lookup,
|
||||
lookupRan: true,
|
||||
lookupNote: 'none' as const,
|
||||
})
|
||||
if (lookup.isCeased) return go(enriched, 'ceased')
|
||||
if (!settings.entity_type) return go(enriched, 'form')
|
||||
return go(enriched, nextCompanyStep(enriched))
|
||||
return applyLookupFound(cleared, outcome.result)
|
||||
}
|
||||
|
||||
if (outcome.status === 'not_found') {
|
||||
@@ -302,6 +341,34 @@ export function journeyReducer(state: JourneyState, action: JourneyAction): Jour
|
||||
return go(noted, 'form')
|
||||
}
|
||||
|
||||
case 'SEARCH_RESULT': {
|
||||
if (!state.lookupPending) return state
|
||||
const cleared = stay(state, { lookupPending: false })
|
||||
const outcome = action.outcome
|
||||
|
||||
if (outcome.status === 'aborted') return cleared
|
||||
|
||||
if (outcome.status === 'found') {
|
||||
// One hit resolves exactly like a typed orgnr; several wait for a pick.
|
||||
if (outcome.hits.length === 1) {
|
||||
return applyLookupFound(withOrgNumber(cleared, outcome.hits[0].orgNumber), outcome.hits[0].result)
|
||||
}
|
||||
return stay(cleared, { searchHits: outcome.hits })
|
||||
}
|
||||
|
||||
// Without an orgnr there is no "continue manually" path from here:
|
||||
// the user refines the query or types the number. Both misses and
|
||||
// failures stay on the step with an advisory note.
|
||||
return stay(cleared, {
|
||||
lookupNote: outcome.status === 'not_found' ? ('nomatch' as const) : ('error' as const),
|
||||
})
|
||||
}
|
||||
|
||||
case 'SEARCH_HIT_PICKED': {
|
||||
if (state.submitting || state.step !== 'orgnr') return state
|
||||
return applyLookupFound(withOrgNumber(state, action.hit.orgNumber), action.hit.result)
|
||||
}
|
||||
|
||||
case 'NOTFOUND_CONTINUE': {
|
||||
if (state.settings.entity_type) return go(state, nextCompanyStep(state))
|
||||
return go(state, 'form')
|
||||
|
||||
Reference in New Issue
Block a user