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:
Mattsson
2026-09-08 14:23:05 +02:00
committed by GitHub
parent 5987523a25
commit 1157ff1b66
14 changed files with 984 additions and 50 deletions
+2
View File
@@ -1662,6 +1662,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] #2391 skeptic pass: orgNumberKey only strips hyphens and spaces and only unprefixes 12-digit values behind 16/18/19/20. Reason: 26 prod supplier rows hold a VAT number (orgnr + 01, prefixes 55/52/87) in org_number, and 'last 10 of any 12 digits' would have rewritten them to another company's identity; letters stay because BE0123456789 is not the Swedish 0123456789. The matcher scans live suppliers only (archived_at IS NULL), the list and v1 search compare without separators, the CSV import and the provider migration orchestrator key and write through the same rule.
[2026-09-08] correctEntry re-points the original entry's transaction_voucher_links rows to the corrected entry (lib/core/bookkeeping/storno-service.ts relinkTransactionsToEntry) instead of deleting them as issue #2364 proposed. Why: for a samlingsverifikat (bulk-book N>1) the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into Att bokföra; the pointer column already follows the correction and the junction now follows it the same way, so every reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC) sees one live anchor. Rejected: a relink_entry_anchors RPC moving pointer and junction atomically (a migration plus pg test for a path that is already best-effort across five other statements; revisit if a partial failure ever shows up in the surfaced transactionRelinkError). Prod repair (planned, runs after merge on the founder's go; completion gets its own dated entry): the 7 stale links (3 companies) all sit on rows whose pointer names a posted entry (4 on a correction chain, 3 from a June 2026 samlingsverifikat storno that predates the junction cleanup and were re-booked 1:1); they will be re-pointed to the pointer's entry, the same rule the fix applies, rather than deleted.
[2026-09-08] delete_last_voucher returns a correction's bank anchors (transactions.journal_entry_id and transaction_voucher_links rows) to correction_of_id before the row is deleted (migration 20260908095907). Why: the #2364 skeptic showed that once the junction follows the correction, the two-step undo (delete the correction, then the storno) cascaded the links away and restored an original that explains bank rows nobody points at, so the rows surfaced as bookable again; before, the links had stayed on the original by accident. Chosen over releasing the rows (the restored original would still explain them, same trap) and over a TS pre-step in the DELETE route (not atomic with the RPC's own guards: a refused delete would leave anchors on a reversed entry). A duplicate of a link the original already holds is dropped, not re-pointed (UNIQUE (transaction_id, journal_entry_id)).
[2026-09-08] Onboarding name search picks via chip row, never the top hit blind: Typesense name ranking is fuzzy and common names ("Bygg AB", sole-trader surnames) make a blind pick a wrong company; five hits, active first, fired on Enter only to protect the 3000/mo TIC budget (issue #2418).
[2026-09-08] #2418 skeptic pass: a name-search hit's org number is derived from the Lens registrationNumber via lensRegistrationToOrgNumber (16-prefixed 12 digits and the 16-digit enskild-firma form, century plus 4-digit serial, reduce to the 10-digit key; hits that do not normalize are dropped) instead of being stored as returned. Why: the typed-orgnr path never stores Lens's number, so this was the first place a 16-digit value reached settings.org_number and createCompany refused it at the last step. Sole-trader chips name the form ("Enskild firma") instead of the number, because that number is the owner's personnummer and the repo masks those everywhere else; the number still travels in the search payload since a pick has to store it.
[2026-09-08] Medelantal anställda (Not 2, ÅRL 5:20 §) gets a whole-number override on arsredovisning_narratives (migration 20260908130127) instead of the free-text note override the support request asked for. Why: the number keeps the statutory sentence and the iXBRL MedelantaletAnstallda fact correct; free text would let a non-compliant note through and could not be tagged. One resolver (lib/salary/medelantal.ts resolveMedelantalAnstallda: override, else FTE average over employees) feeds the K2 and K3 note builders and the iXBRL input, which also reads the previous period's override so the jämförelseår column shows the same figure the previous year's document did. Rejected: rounding 0.5 up globally (silently changes every company's note and does nothing for the 148 of 195 aktiebolag with salary but no employees rows); asking the user to backdate employment_start (fixes one company, misstates the hire date).
[2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched.
[2026-09-08] Migration files must carry their own BEGIN/COMMIT when they use transaction-only statements (LOCK TABLE, SET LOCAL, SET CONSTRAINTS): CI replays each file with psql -f in autocommit and the Supabase branch runner does the same on prod, so the bare LOCK TABLE in 20260908113353 (#2413, PR #2419) failed both and stalled prod's migration queue behind it. Prod never recorded the failed version, so the file was deleted and re-issued as 20260908120449 rather than edited in place.
@@ -8,8 +8,11 @@ import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { deriveFirstYearDefaults } from '@/lib/company/first-year-defaults'
import { parseStartMonthDay } from '@/lib/company/first-year-defaults'
import { fetchCompanyLookup } from '@/lib/company-lookup/fetch-company-lookup'
import { fetchCompanyLookup, fetchCompanySearch } from '@/lib/company-lookup/fetch-company-lookup'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { COMPANY_SEARCH_MIN_CHARS, type CompanySearchHit } from '@/lib/company-lookup/types'
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
import { formatOrgNumber } from '@/lib/utils'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useBranding } from '@/lib/branding/brand-context'
import posthog from 'posthog-js'
@@ -145,30 +148,71 @@ export default function OnboardingJourney({
// One lookup per confirmed orgnr: fired from the submit handler, never
// from typing. The dup check (internal endpoint) rides along, advisory.
const shakeOrg = useCallback(() => {
setOrgShake(true)
window.setTimeout(() => setOrgShake(false), 400)
}, [])
const checkDuplicate = useCallback((orgNumber: string) => {
setDupName(null)
setDupElsewhere(false)
fetch(`/api/company/check-org-number?org_number=${encodeURIComponent(orgNumber)}`)
.then(async (res) => {
if (!res.ok) return
const { data } = await res.json()
setDupName(data?.companies?.[0]?.name ?? null)
setDupElsewhere(Boolean(data?.exists_elsewhere))
})
.catch(() => {})
}, [])
// The one field takes either an orgnr or a company name. Digits (with
// dashes/spaces) are always the orgnr path, so a mistyped number shakes
// instead of turning into a name search; anything else is a name.
const submitOrg = useCallback(
(raw: string) => {
const normalized = normalizeOrgNumber(raw)
if (normalized === null) {
setOrgShake(true)
window.setTimeout(() => setOrgShake(false), 400)
const trimmed = raw.trim()
const looksNumeric = /^[\d\s-]+$/.test(trimmed)
if (looksNumeric) {
if (normalizeOrgNumber(trimmed) === null) {
shakeOrg()
return
}
dispatch({ type: 'ORG_SUBMITTED', orgNumber: trimmed })
fetchCompanyLookup(trimmed, { ticEnabled }).then((outcome) => {
dispatch({ type: 'LOOKUP_RESULT', outcome })
})
checkDuplicate(trimmed)
return
}
if (!ticEnabled || trimmed.length < COMPANY_SEARCH_MIN_CHARS) {
shakeOrg()
return
}
// A previous orgnr's "you already have X" note must not sit above the
// chip row; the pick re-checks for the number it resolves to.
setDupName(null)
setDupElsewhere(false)
dispatch({ type: 'ORG_SUBMITTED', orgNumber: raw })
fetchCompanyLookup(raw, { ticEnabled }).then((outcome) => {
dispatch({ type: 'LOOKUP_RESULT', outcome })
dispatch({ type: 'SEARCH_SUBMITTED', query: trimmed })
fetchCompanySearch(trimmed, { ticEnabled }).then((outcome) => {
dispatch({ type: 'SEARCH_RESULT', outcome })
if (outcome.status === 'found' && outcome.hits.length === 1) {
checkDuplicate(outcome.hits[0].orgNumber)
}
})
fetch(`/api/company/check-org-number?org_number=${encodeURIComponent(raw)}`)
.then(async (res) => {
if (!res.ok) return
const { data } = await res.json()
setDupName(data?.companies?.[0]?.name ?? null)
setDupElsewhere(Boolean(data?.exists_elsewhere))
})
.catch(() => {})
},
[ticEnabled],
[ticEnabled, shakeOrg, checkDuplicate],
)
// The field keeps the name the user typed: writing the picked number into
// it would print a sole trader's personnummer in plain text on Back, the
// one thing the chip row avoids. Back re-searches the name instead.
const pickSearchHit = useCallback(
(hit: CompanySearchHit) => {
dispatch({ type: 'SEARCH_HIT_PICKED', hit })
checkDuplicate(hit.orgNumber)
},
[checkDuplicate],
)
// BankID deep link: auto-submit the orgnr once on mount (the single
@@ -361,13 +405,15 @@ export default function OnboardingJourney({
? t('journey_err_org_invalid')
: state.lookupNote === 'error'
? t('journey_lookup_error')
: undefined
: state.lookupNote === 'nomatch'
? t('journey_search_nomatch')
: undefined
}
>
<div className={`jny-biginput${orgShake ? ' is-err' : ''}`} style={{ marginTop: 26 }}>
<input
value={orgInput}
inputMode="numeric"
inputMode="text"
placeholder="556677-8899"
aria-label={t('step2_org_number_label')}
autoComplete="off"
@@ -379,9 +425,34 @@ export default function OnboardingJourney({
}}
/>
</div>
<p className="jny-enterhint">
{t('journey_press')} <b>Enter</b>
</p>
{state.searchHits.length > 1 ? (
<>
<p className="jny-enterhint">{t('journey_search_pick')}</p>
<ChipRow
options={state.searchHits.map((h) => {
// A sole trader's org number is their personnummer: the
// chip names the form instead, never the number.
const isSoleTrader = mapEntityType(h.result.legalEntityType) === 'enskild_firma'
const ident = isSoleTrader ? t('journey_form_ef') : formatOrgNumber(h.orgNumber)
const city = h.result.address?.city
return {
key: h.orgNumber,
label: h.result.companyName || ident,
rec: h.result.companyName ? `${ident}${city ? ` · ${city}` : ''}` : undefined,
}
})}
onPick={(k) => {
const hit = state.searchHits.find((h) => h.orgNumber === k)
if (hit) pickSearchHit(hit)
}}
{...flyProps}
/>
</>
) : (
<p className="jny-enterhint">
{t('journey_press')} <b>Enter</b>
</p>
)}
</Question>
)
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../lib/tic-client', () => ({
searchCompanyByOrgNumber: vi.fn(),
searchCompaniesByName: vi.fn(),
getBankAccounts: vi.fn(),
getIndustryCodes: vi.fn(),
getEmails: vi.fn(),
getPhones: vi.fn(),
getFiscalYears: vi.fn(),
}))
import { ticExtension } from '../index'
import { searchCompaniesByName } from '../lib/tic-client'
import { lensRegistrationToOrgNumber, searchCompaniesForLookup } from '../lib/lookup'
import { TICAPIError } from '../lib/tic-types'
import type { TICCompanyDocument } from '../lib/tic-types'
const mockSearch = vi.mocked(searchCompaniesByName)
function makeRequest(q?: string): Request {
const url = q
? `http://localhost/api/extensions/ext/tic/search?q=${encodeURIComponent(q)}`
: 'http://localhost/api/extensions/ext/tic/search'
return new Request(url)
}
const route = ticExtension.apiRoutes!.find((r) => r.path === '/search')!
const searchHandler = route.handler
function doc(overrides: Partial<TICCompanyDocument>): TICCompanyDocument {
return {
companyId: 1,
registrationNumber: '5560360793',
names: [{ nameOrIdentifier: 'Testbrand AB', companyNamingType: 'name' }],
legalEntityType: 'AB',
registrationDate: Math.floor(Date.UTC(2020, 0, 1) / 1000),
mostRecentRegisteredAddress: { streetAddress: 'Storgatan 1', postalCode: '111 22', city: 'Stockholm' },
isRegisteredForFTax: true,
isRegisteredForVAT: true,
isCeased: false,
activityStatus: 'isActive',
...overrides,
}
}
describe('searchCompaniesForLookup', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('maps each document to a hit with a cleaned org number', async () => {
mockSearch.mockResolvedValue([doc({ registrationNumber: '556036-0793' })])
const hits = await searchCompaniesForLookup('Testbrand')
expect(mockSearch).toHaveBeenCalledWith('Testbrand', 5)
expect(hits).toHaveLength(1)
expect(hits[0].orgNumber).toBe('5560360793')
expect(hits[0].result.companyName).toBe('Testbrand AB')
expect(hits[0].result.legalEntityType).toBe('AB')
expect(hits[0].result.address?.city).toBe('Stockholm')
})
it('sinks ceased companies below active ones without reordering otherwise', async () => {
mockSearch.mockResolvedValue([
doc({ companyId: 1, registrationNumber: '5560000019', isCeased: true }),
doc({ companyId: 2, registrationNumber: '5560000027' }),
doc({ companyId: 3, registrationNumber: '5560000035', isCeased: true }),
doc({ companyId: 4, registrationNumber: '5560000043' }),
])
const hits = await searchCompaniesForLookup('Testbrand')
expect(hits.map((h) => h.orgNumber)).toEqual([
'5560000027',
'5560000043',
'5560000019',
'5560000035',
])
})
it("reduces a sole trader's 16-digit Lens number to the 10-digit personnummer form", async () => {
mockSearch.mockResolvedValue([
doc({
registrationNumber: '2002011732750001',
legalEntityType: 'EF',
names: [{ nameOrIdentifier: 'Alices Konsult', companyNamingType: 'name' }],
}),
])
const hits = await searchCompaniesForLookup('Alices Konsult')
expect(hits).toHaveLength(1)
expect(hits[0].orgNumber).toBe('0201173275')
expect(hits[0].result.legalEntityType).toBe('EF')
})
it('drops a hit whose registration number cannot become a valid org number', async () => {
mockSearch.mockResolvedValue([
doc({ companyId: 1, registrationNumber: '5560000000' }),
doc({ companyId: 2, registrationNumber: '5560000027' }),
])
const hits = await searchCompaniesForLookup('Testbrand')
expect(hits.map((h) => h.orgNumber)).toEqual(['5560000027'])
})
})
describe('lensRegistrationToOrgNumber', () => {
it('keeps a valid 10-digit organisationsnummer', () => {
expect(lensRegistrationToOrgNumber('5560360793')).toBe('5560360793')
expect(lensRegistrationToOrgNumber('556036-0793')).toBe('5560360793')
})
it('strips the 16 century prefix from a 12-digit organisationsnummer', () => {
expect(lensRegistrationToOrgNumber('165560360793')).toBe('5560360793')
})
it('strips century and serial from a 16-digit enskild firma number', () => {
expect(lensRegistrationToOrgNumber('2002011732750001')).toBe('0201173275')
expect(lensRegistrationToOrgNumber('1980010112310001')).toBe('8001011231')
})
it('returns null for a VAT-number shape, a bad check digit, or garbage', () => {
expect(lensRegistrationToOrgNumber('556036079301')).toBeNull()
expect(lensRegistrationToOrgNumber('5560360794')).toBeNull()
expect(lensRegistrationToOrgNumber('')).toBeNull()
expect(lensRegistrationToOrgNumber('abc')).toBeNull()
})
})
describe('TIC search route', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('is registered with skipCompanyContext (user has no company yet)', () => {
expect(route.method).toBe('GET')
expect(route.skipCompanyContext).toBe(true)
})
it('returns 400 when q is missing', async () => {
const res = await searchHandler(makeRequest())
expect(res.status).toBe(400)
expect(mockSearch).not.toHaveBeenCalled()
})
it('returns 400 when q is shorter than the minimum', async () => {
const res = await searchHandler(makeRequest('ab'))
expect(res.status).toBe(400)
expect(mockSearch).not.toHaveBeenCalled()
})
it('trims q before measuring it', async () => {
const res = await searchHandler(makeRequest(' ab '))
expect(res.status).toBe(400)
expect(mockSearch).not.toHaveBeenCalled()
})
it("returns the TIC handler's 404 body when nothing matched", async () => {
mockSearch.mockResolvedValue([])
const res = await searchHandler(makeRequest('Nothing Like This'))
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ error: 'Company not found' })
})
it('returns hits in /lookup shape on success', async () => {
mockSearch.mockResolvedValue([
doc({ registrationNumber: '5560360793' }),
doc({ companyId: 2, registrationNumber: '5566778899', names: [{ nameOrIdentifier: 'Testbrand Bygg AB', companyNamingType: 'name' }] }),
])
const res = await searchHandler(makeRequest('Testbrand'))
expect(res.status).toBe(200)
const { data } = await res.json()
expect(data).toHaveLength(2)
expect(data[0]).toMatchObject({
orgNumber: '5560360793',
result: { companyName: 'Testbrand AB', registration: { fTax: true, vat: true } },
})
expect(data[1].result.companyName).toBe('Testbrand Bygg AB')
})
it('maps a rate limit to 429', async () => {
mockSearch.mockRejectedValue(new TICAPIError('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED'))
const res = await searchHandler(makeRequest('Testbrand'))
expect(res.status).toBe(429)
})
it('maps NOT_CONFIGURED to 503', async () => {
mockSearch.mockRejectedValue(new TICAPIError('missing', undefined, 'NOT_CONFIGURED'))
const res = await searchHandler(makeRequest('Testbrand'))
expect(res.status).toBe(503)
})
it('maps an upstream 5xx to 502', async () => {
mockSearch.mockRejectedValue(new TICAPIError('boom', 500))
const res = await searchHandler(makeRequest('Testbrand'))
expect(res.status).toBe(502)
})
})
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { searchCompaniesByName, __resetTicCacheForTest } from '../lib/tic-client'
const PROXY_URL = 'https://proxy.example.com/api/tic/proxy'
function hit(registrationNumber: string) {
return {
document: {
companyId: 1,
registrationNumber,
names: [{ nameOrIdentifier: 'Testbrand AB', companyNamingType: 'name' }],
legalEntityType: 'AB',
registrationDate: 0,
},
}
}
describe('searchCompaniesByName', () => {
beforeEach(() => {
__resetTicCacheForTest()
vi.stubGlobal('fetch', vi.fn())
vi.stubEnv('TIC_API_PROXY_URL', PROXY_URL)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
it('queries the nested name field with an encoded, trimmed query and a page cap', async () => {
const mockFetch = vi.mocked(fetch)
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ found: 1, hits: [hit('5560360793')] }), { status: 200 }),
)
const docs = await searchCompaniesByName(' Testbrand Bygg & Co ', 5)
const endpoint = '/search-public/companies?q=Testbrand%20Bygg%20%26%20Co&query_by=names.nameOrIdentifier&per_page=5'
expect(mockFetch).toHaveBeenCalledWith(
`${PROXY_URL}?endpoint=${encodeURIComponent(endpoint)}`,
expect.anything(),
)
expect(docs).toHaveLength(1)
expect(docs[0].registrationNumber).toBe('5560360793')
})
it('returns an empty array without calling upstream for a blank query', async () => {
const docs = await searchCompaniesByName(' ')
expect(docs).toEqual([])
expect(fetch).not.toHaveBeenCalled()
})
it('returns an empty array when the index has no hits', async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ found: 0, hits: [] }), { status: 200 }),
)
expect(await searchCompaniesByName('Nothing')).toEqual([])
})
it('returns an empty array on a 404 from the proxy', async () => {
vi.mocked(fetch).mockResolvedValue(new Response('', { status: 404 }))
expect(await searchCompaniesByName('Nothing')).toEqual([])
})
it('drops documents without a registration number and caps at the limit', async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
found: 4,
hits: [
hit('1111111111'),
{ document: { ...hit('').document, registrationNumber: '' } },
hit('2222222222'),
hit('3333333333'),
],
}),
{ status: 200 },
),
)
const docs = await searchCompaniesByName('Testbrand', 2)
expect(docs.map((d) => d.registrationNumber)).toEqual(['1111111111', '2222222222'])
})
it('serves a repeated query from the process cache (one upstream call)', async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ found: 1, hits: [hit('5560360793')] }), { status: 200 }),
)
await searchCompaniesByName('Testbrand')
await searchCompaniesByName('Testbrand')
expect(fetch).toHaveBeenCalledTimes(1)
})
})
+37 -2
View File
@@ -34,7 +34,12 @@ import {
readBankIdFlow,
setBankIdFlowCookies,
} from './lib/bankid-flow-cookie'
import { lookupCompanyByOrgNumber, registrationDateToMs } from './lib/lookup'
import {
lookupCompanyByOrgNumber,
registrationDateToMs,
searchCompaniesForLookup,
} from './lib/lookup'
import { COMPANY_SEARCH_MIN_CHARS } from '@/lib/company-lookup/types'
import {
hasForeignCredential,
isUnadoptedPendingAccount,
@@ -359,7 +364,7 @@ function toFinancialReportSummary(
function handleTicError(
error: unknown,
log: { error: (msg: string, meta?: unknown) => void } | Console,
route: 'lookup' | 'profile',
route: 'lookup' | 'profile' | 'search',
orgNumber: string,
fallbackMessage: string
): Response {
@@ -1692,6 +1697,36 @@ export const ticExtension: Extension = {
}
},
},
{
method: 'GET',
path: '/search',
// Onboarding's orgnr field also accepts a company name: one Lens call
// returns up to five hits in /lookup's shape so a pick needs no second
// call. User is authenticated but may not yet have a company.
skipCompanyContext: true,
handler: async (request: Request, ctx?) => {
const log = ctx?.log ?? console
const url = new URL(request.url)
const query = (url.searchParams.get('q') ?? '').trim()
if (query.length < COMPANY_SEARCH_MIN_CHARS) {
return NextResponse.json(
{ error: `q must be at least ${COMPANY_SEARCH_MIN_CHARS} characters` },
{ status: 400 }
)
}
try {
const hits = await searchCompaniesForLookup(query)
if (hits.length === 0) {
return NextResponse.json({ error: 'Company not found' }, { status: 404 })
}
return NextResponse.json({ data: hits })
} catch (error) {
return handleTicError(error, log, 'search', query, 'Failed to search companies')
}
},
},
],
eventHandlers: [],
+44 -2
View File
@@ -1,6 +1,7 @@
import { searchCompanyByOrgNumber } from './tic-client'
import { searchCompaniesByName, searchCompanyByOrgNumber } from './tic-client'
import type { TICCompanyDocument } from './tic-types'
import type { CompanyLookupResult } from '@/lib/company-lookup/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
@@ -110,3 +111,44 @@ export async function lookupCompanyByOrgNumber(
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
}
+26
View File
@@ -197,6 +197,32 @@ export async function searchCompanyByOrgNumber(
return match?.document ?? null
}
/**
* Free-text search for companies by name. Returns the ranked documents
* (at most `limit`), or an empty array when nothing matched.
*
* Same Typesense index as `searchCompanyByOrgNumber`, queried on the nested
* `names.nameOrIdentifier` field (verified live 2026-09-08; the bare `name`
* field is not indexed). The response documents carry the same shape as an
* org-number hit, so the caller can map them with the /lookup mapper and a
* picked hit never costs a second Lens call.
*/
export async function searchCompaniesByName(
query: string,
limit = 5
): Promise<TICCompanyDocument[]> {
const trimmed = query.trim()
if (!trimmed) return []
const data = await ticApiFetch<TICCompanyResponse>(
`/search-public/companies?q=${encodeURIComponent(trimmed)}&query_by=names.nameOrIdentifier&per_page=${limit}`
)
if (!data || data.found === 0 || !data.hits?.length) return []
return data.hits
.map((hit) => hit.document)
.filter((doc): doc is TICCompanyDocument => Boolean(doc?.registrationNumber))
.slice(0, limit)
}
/**
* Get bank accounts for a company. v2 narrows this endpoint to Bankgirot
* numbers only (returns `Bankgironumber_Dto[]`); v1's IBAN / plusgiro
@@ -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' })
})
})
+58 -1
View File
@@ -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 {
+18
View File
@@ -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([])
})
})
+90 -23
View File
@@ -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')
+2
View File
@@ -1447,6 +1447,8 @@
"journey_notfound_sub": "Newly registered companies can take a few days to appear at Bolagsverket.",
"journey_notfound_continue": "Continue manually",
"journey_notfound_edit": "Change the number",
"journey_search_nomatch": "No company matched that name. Try another name or type the organisation number.",
"journey_search_pick": "Which company do you mean?",
"journey_ceased_title": "The company is deregistered.",
"journey_ceased_sub": "According to Bolagsverket the company is deregistered.",
"journey_ceased_continue": "Continue anyway",
+2
View File
@@ -1447,6 +1447,8 @@
"journey_notfound_sub": "Nystartade företag kan ta några dagar innan de syns hos Bolagsverket.",
"journey_notfound_continue": "Fortsätt manuellt",
"journey_notfound_edit": "Ändra numret",
"journey_search_nomatch": "Inget företag matchade namnet. Prova ett annat namn eller skriv organisationsnumret.",
"journey_search_pick": "Vilket företag menar du?",
"journey_ceased_title": "Företaget är avregistrerat.",
"journey_ceased_sub": "Enligt Bolagsverket är företaget avregistrerat.",
"journey_ceased_continue": "Fortsätt ändå",