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>
This commit is contained in:
Jakob Wennberg
2026-07-24 13:29:56 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2bec5acedb
commit f9ef8913ae
7 changed files with 483 additions and 55 deletions
+10 -19
View File
@@ -13,6 +13,7 @@ import { Loader2, ArrowRight, ArrowLeft, CheckCircle2, AlertTriangle } from 'luc
import type { EntityType } from '@/types'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { fetchCompanyLookup } from '@/lib/company-lookup/fetch-company-lookup'
const schema = z.object({
company_name: z.string().min(1, 'Företagsnamn krävs'),
@@ -152,32 +153,27 @@ export default function Step2CompanyDetails({
setIsLooking(true)
fetch(`/api/extensions/ext/tic/lookup?org_number=${encodeURIComponent(orgNumber)}`, {
signal: controller.signal,
})
.then(async (res) => {
if (controller.signal.aborted) return
fetchCompanyLookup(orgNumber, { ticEnabled: true, signal: controller.signal })
.then((outcome) => {
if (controller.signal.aborted || outcome.status === 'aborted') return
if (res.status === 403) {
// Extension disabled: silently ignore
if (outcome.status === 'disabled') {
// Lookup surface unavailable (extension off / dispatcher miss):
// degrade silently to manual entry, the input is not at fault.
return
}
if (res.status === 404) {
if (outcome.status === 'not_found') {
setLookupError(t('step2_lookup_not_found'))
onTicLookup?.(null)
return
}
if (!res.ok) {
if (outcome.status === 'error') {
setLookupError(t('step2_lookup_failed'))
onTicLookup?.(null)
return
}
const { data } = (await res.json()) as { data: CompanyLookupResult }
// Guard: only apply if org_number still matches (user may have changed it)
if (controller.signal.aborted) return
const data = outcome.result
setLookupDone(data)
onTicLookup?.(data)
@@ -187,11 +183,6 @@ export default function Step2CompanyDetails({
if (data.address?.postalCode) setValue('postal_code', data.address.postalCode)
if (data.address?.city) setValue('city', data.address.city)
})
.catch((err) => {
if ((err as Error).name === 'AbortError') return
setLookupError(t('step2_lookup_failed'))
onTicLookup?.(null)
})
.finally(() => {
if (!controller.signal.aborted) setIsLooking(false)
})