import type { CompanyLookupResult } from './types' import { normalizeOrgNumber } from './normalize-org-number' /** * Outcome of a client-side TIC company lookup. * * - `found`: TIC answered with company data. * - `not_found`: TIC looked and the company does not exist (the handler's * 404 with body `{ error: 'Company not found' }`). Show the "hittas inte" * path; the user continues manually. * - `disabled`: the lookup surface is not available at all: TIC extension * off client-side, malformed orgnr, legacy 403, dispatcher 404 * ("Extension not found" / "Route not found"), or a feature-flag 503 * (`code: 'EXTENSION_DISABLED'`). Degrade silently to the manual path; * there is nothing the user can do and nothing is wrong with their input. * - `error`: transient failure (429 rate limit, 502/504 upstream, 503 * NOT_CONFIGURED, 500, network). Show the advisory "kunde inte hämta" * note and continue manually. Never blocks. * - `aborted`: the caller's AbortSignal fired; ignore the result. */ export type CompanyLookupOutcome = | { status: 'found'; result: CompanyLookupResult } | { status: 'not_found' } | { status: 'disabled' } | { status: 'error' } | { status: 'aborted' } /** * Shared client-side TIC lookup for the onboarding surfaces (wizard Step 2 * and the journey flow). One GET to the extension dispatcher; never throws. * * Fixes the historical 403/404 conflation: the dispatcher returns 404 for a * missing extension and 503 (`EXTENSION_DISABLED`) for a feature-flagged one, * while the TIC handler's own 404 means "company not found". A dispatcher-level * miss must degrade silently instead of telling the user their company * doesn't exist. * * TIC budget note: this is the ONLY function that may call the Lens-backed * `/lookup` from the client. Callers fire it once per confirmed orgnr * (Enter / picker selection), not per keystroke; the server keeps a 5-min * process cache as a second guard. */ export async function fetchCompanyLookup( orgNumber: string, opts: { ticEnabled: boolean; signal?: AbortSignal }, ): Promise { if (!opts.ticEnabled) return { status: 'disabled' } if (normalizeOrgNumber(orgNumber) === null) return { status: 'disabled' } let res: Response try { res = await fetch( `/api/extensions/ext/tic/lookup?org_number=${encodeURIComponent(orgNumber)}`, { 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: CompanyLookupResult } if (!data || typeof data !== 'object') return { status: 'error' } return { status: 'found', result: data } } catch { return { status: 'error' } } } // Non-ok: read the body (best-effort) to disambiguate. let body: { error?: unknown; code?: unknown } = {} try { const parsed = (await res.json()) as unknown if (parsed && typeof parsed === 'object') { body = parsed as { error?: unknown; code?: unknown } } } catch { // Non-JSON error body: fall through to status-only mapping. } if (res.status === 403) return { status: 'disabled' } if (res.status === 404) { // TIC handler: { error: 'Company not found' }. Dispatcher: 'Extension // not found' / 'Route not found'. Only the former is a user-facing miss. return body.error === 'Company not found' ? { status: 'not_found' } : { status: 'disabled' } } if (res.status === 503 && body.code === 'EXTENSION_DISABLED') { return { status: 'disabled' } } return { status: 'error' } }