Files
accounted/lib/company-lookup/fetch-company-lookup.ts
T
Jakob WennbergandClaude Fable 5 f9ef8913ae refactor(onboarding): extract first-year defaults + shared TIC lookup client (journey PR A) (#1141)
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>
2026-07-24 13:29:56 +02:00

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' }
}