First of four PRs replacing the onboarding wizard with the journey flow
(dev_docs/onboarding_migration_plan.md, local). No UI change.
- Move deriveFirstYearDefaults + parseStartMonthDay out of
WelcomeOnboarding into lib/company/first-year-defaults.ts and unit-test
them (11-vs-13-months boundary, UTC month seeding, malformed input).
- Add the missing computeFiscalPeriod unit tests (calendar year, brutet
ar, first year short/extended, EF calendar-year rule, period names,
BFL 3 kap. 6-18 month window errors).
- New shared fetchCompanyLookup() client: the single client path to the
Lens-backed /lookup, typed outcomes (found / not_found / disabled /
error / aborted), never throws. Fixes the 403/404 conflation: the
dispatcher's 404 ("Extension not found") and feature-flag 503
(EXTENSION_DISABLED) now degrade silently instead of rendering as
"company not found"; only the TIC handler's own 404 does.
- Step2CompanyDetails consumes the helper; identical UX otherwise.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
96 lines
3.7 KiB
TypeScript
96 lines
3.7 KiB
TypeScript
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<CompanyLookupOutcome> {
|
|
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' }
|
|
}
|