Files
accounted/lib/onboarding-journey/reducer.ts
T
MattssonandClaude Fable 5.1 1157ff1b66 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>
2026-09-08 14:23:05 +02:00

630 lines
20 KiB
TypeScript

import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
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'
/**
* Pure state machine for the journey onboarding
* (dev_docs/onboarding_migration_plan.md). The component renders `step`,
* dispatches actions, and performs the side effects (the single TIC lookup
* via fetchCompanyLookup, the createCompanyFromOnboarding call); the reducer
* owns every transition and every settings write.
*
* Invariants encoded here:
* - `settings` accumulates the exact CompanySettings partial today's wizard
* sends to createCompanyFromOnboarding: nothing less.
* - `lookupRan` is true only when TIC answered with data for the CURRENT
* orgnr. Facts (name/address/F-skatt/moms/räkenskapsår) may be presented
* as facts only then; otherwise they are asked as questions. BankID
* prefill without a successful lookup is the degraded ask-questions path.
* - `vat_registered` is never silently defaulted: it is either lookup data
* (registration.vat === true) or an explicit answer (ML 17 kap 24 §).
* - History stores each step's ENTRY snapshot, so Back rolls both answers
* and stations to how they were when the step began.
*/
export type JourneyStep =
| 'orgnr'
| 'notfound'
| 'ceased'
| 'form'
| 'name'
| 'address'
| 'fskatt'
| 'fy'
| 'fymonth'
| 'fystart'
| 'fyend'
| 'momsyn'
| 'moms'
| 'method'
| 'done'
| 'source'
export type JourneyStation = 0 | 1 | 2 | 3 | 4
const STATION_OF: Record<JourneyStep, JourneyStation> = {
orgnr: 0,
notfound: 0,
ceased: 0,
form: 0,
name: 0,
address: 0,
fskatt: 0,
fy: 1,
fymonth: 1,
fystart: 1,
fyend: 1,
momsyn: 2,
moms: 2,
method: 3,
done: 4,
source: 4,
}
export function stationOfStep(step: JourneyStep): JourneyStation {
return STATION_OF[step]
}
export type JourneyServerError =
| 'org_number_invalid'
| 'period_invalid'
| 'generic'
| null
/** The slice of state a step's entry snapshot preserves for Back. */
interface JourneySnapshot {
step: JourneyStep
settings: Partial<CompanySettings>
ticLookup: CompanyLookupResult | null
lookupRan: boolean
/** `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
}
export interface JourneyState extends JourneySnapshot {
/** Entry snapshot of the CURRENT step (what Back from a later step restores). */
entry: 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'
submitting: boolean
serverError: JourneyServerError
}
export interface JourneyInit {
mode?: 'first' | 'add'
/** ?org_number= deep link. The component auto-submits it on mount, which
* triggers the same single lookup as manual entry (2026-07-24 addendum:
* no preverified suppression in the journey). */
initialOrgNumber?: string
initialEntityType?: EntityType
initialLegalName?: string
}
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' }
| { type: 'CEASED_EDIT' }
| { type: 'ENTITY_PICKED'; entityType: EntityType }
| { type: 'NAME_SUBMITTED'; name: string }
| { type: 'ADDRESS_SUBMITTED'; addressLine1?: string; postalCode?: string; city?: string }
| { type: 'FSKATT_ANSWERED'; fskatt: boolean }
| { type: 'FY_CALENDAR_CONFIRMED' }
| { type: 'FY_OTHER_SELECTED' }
| { type: 'FY_FIRST_SELECTED' }
| { type: 'FY_END_MONTH_PICKED'; endMonth: number }
| { type: 'FY_START_PICKED'; date: string }
| { type: 'FY_END_PICKED'; date: string }
| { type: 'VAT_ANSWERED'; registered: boolean }
| { type: 'MOMS_PERIOD_PICKED'; period: MomsPeriod }
| { type: 'METHOD_PICKED'; method: 'accrual' | 'cash' }
| { type: 'SUBMIT_SUCCEEDED' }
| { type: 'SUBMIT_FAILED'; code: 'org_number_invalid' | 'period_invalid' | 'generic' }
| { type: 'DONE_CONTINUE' }
| { type: 'BACK' }
| { type: 'STATION_JUMP'; station: 0 | 1 | 2 | 3 }
function snapshotOf(s: JourneySnapshot): JourneySnapshot {
return {
step: s.step,
settings: s.settings,
ticLookup: s.ticLookup,
lookupRan: s.lookupRan,
lookupNote: s.lookupNote,
addressAsked: s.addressAsked,
nameConfirmedForEf: s.nameConfirmedForEf,
}
}
export function initJourney(init: JourneyInit = {}): JourneyState {
const settings: Partial<CompanySettings> = {}
if (init.initialOrgNumber) settings.org_number = init.initialOrgNumber
if (init.initialEntityType) settings.entity_type = init.initialEntityType
if (init.initialLegalName) settings.company_name = init.initialLegalName
const base: JourneySnapshot = {
step: 'orgnr',
settings,
ticLookup: null,
lookupRan: false,
lookupNote: 'none',
addressAsked: false,
nameConfirmedForEf: false,
}
return {
...base,
entry: snapshotOf(base),
history: [],
lookupPending: false,
searchHits: [],
viaPrefill: Boolean(init.initialOrgNumber && (init.initialEntityType || init.initialLegalName)),
mode: init.mode ?? 'first',
submitting: false,
serverError: null,
}
}
/** Transition to `next`, pushing the current step's entry snapshot.
* `patch` wins over the defaults (a transition may carry a serverError). */
function go(state: JourneyState, next: JourneyStep, patch?: Partial<JourneyState>): JourneyState {
const moved: JourneyState = {
...state,
lookupPending: false,
searchHits: [],
serverError: null,
...patch,
step: next,
history: [...state.history, state.entry],
}
return { ...moved, entry: snapshotOf(moved) }
}
/** Update within the current step (no history push, entry unchanged). */
function stay(state: JourneyState, patch: Partial<JourneyState>): JourneyState {
return { ...state, ...patch }
}
/**
* The Företaget station asks only what is still unknown, then hands over to
* the fiscal-year station. Order: name → address → F-skatt.
* - AB with a known company_name (lookup or BankID roles) skips the name
* question; EF always confirms the verksamhetsnamn (it defaults to the
* person's name but is freely choosable, same as the wizard).
* - Address is asked only when the lookup did not provide one.
* - F-skatt is asked whenever it is not lookup data.
*/
function nextCompanyStep(state: JourneyState): JourneyStep {
const s = state.settings
const nameKnown =
s.entity_type === 'aktiebolag'
? Boolean(s.company_name)
: Boolean(s.company_name) && state.nameConfirmedForEf === true
if (!nameKnown) return 'name'
if (!state.lookupRan && !state.addressAsked) return 'address'
if (s.f_skatt === undefined) return 'fskatt'
return 'fy'
}
/** After the fiscal-year station: skip the moms question only when the
* lookup POSITIVELY says the company is VAT registered. A negative or
* missing registration is always asked (never defaulted). */
function afterFiscalYear(state: JourneyState): JourneyState {
if (state.lookupRan && state.ticLookup?.registration.vat === true) {
const settings = {
...state.settings,
vat_registered: true,
vat_number: deriveSwedishVatNumber(state.settings.org_number),
}
return go(stay(state, { settings }), 'moms')
}
return go(state, 'momsyn')
}
/** Downstream answers invalidated by an entity-type change. */
function wipeDownstream(settings: Partial<CompanySettings>): Partial<CompanySettings> {
const next = { ...settings }
delete next.fiscal_year_start_month
delete next.is_first_fiscal_year
delete next.first_year_start
delete next.first_year_end
delete next.vat_registered
delete next.vat_number
delete next.moms_period
delete next.accounting_method
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': {
if (state.submitting) return state
// Fresh orgnr invalidates any previous lookup facts.
return stay(state, {
settings: { ...state.settings, org_number: action.orgNumber },
ticLookup: null,
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,
})
}
case 'LOOKUP_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') {
return applyLookupFound(cleared, outcome.result)
}
if (outcome.status === 'not_found') {
return go(cleared, 'notfound')
}
// disabled: silent manual path. error: manual path + advisory note.
const noted = stay(cleared, {
lookupNote: outcome.status === 'error' ? ('error' as const) : ('none' as const),
})
if (noted.settings.entity_type) {
// BankID prefill (or re-run after entity known): degraded ask-
// questions path; entity/name from CompanyRoles survive as prefill.
return go(noted, nextCompanyStep(noted))
}
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')
}
case 'NOTFOUND_EDIT':
case 'CEASED_EDIT': {
// Back to the orgnr question; the fresh submit re-runs the single lookup.
return go(state, 'orgnr', {
settings: { ...state.settings, org_number: undefined },
ticLookup: null,
lookupRan: false,
lookupNote: 'none',
})
}
case 'CEASED_CONTINUE': {
// Proceed with the (ceased) lookup facts: same as wizard, which lets
// the user continue after the inline warning.
if (!state.settings.entity_type) return go(state, 'form')
return go(state, nextCompanyStep(state))
}
case 'ENTITY_PICKED': {
const prev = state.settings.entity_type
let settings: Partial<CompanySettings> = { ...state.settings, entity_type: action.entityType }
let next = state
if (prev && prev !== action.entityType) {
// Same guard as the wizard's step-1 wipe, but broader per the plan:
// a changed entity invalidates org/name and every downstream answer.
settings = wipeDownstream({
...settings,
org_number: undefined,
company_name: undefined,
})
next = stay(state, { ticLookup: null, lookupRan: false, nameConfirmedForEf: false })
return go(stay(next, { settings }), 'orgnr')
}
return go(stay(next, { settings }), nextCompanyStep(stay(next, { settings })))
}
case 'NAME_SUBMITTED': {
const trimmed = action.name.trim()
if (!trimmed) return state
const patched = stay(state, {
settings: { ...state.settings, company_name: trimmed },
nameConfirmedForEf: true,
})
return go(patched, nextCompanyStep(patched))
}
case 'ADDRESS_SUBMITTED': {
const patched = stay(state, {
settings: {
...state.settings,
address_line1: action.addressLine1 || state.settings.address_line1,
postal_code: action.postalCode || state.settings.postal_code,
city: action.city || state.settings.city,
},
addressAsked: true,
})
return go(patched, nextCompanyStep(patched))
}
case 'FSKATT_ANSWERED': {
const patched = stay(state, {
settings: { ...state.settings, f_skatt: action.fskatt },
})
return go(patched, nextCompanyStep(patched))
}
case 'FY_CALENDAR_CONFIRMED': {
const patched = stay(state, {
settings: {
...state.settings,
fiscal_year_start_month: 1,
is_first_fiscal_year: false,
first_year_start: undefined,
first_year_end: undefined,
},
})
return afterFiscalYear(patched)
}
case 'FY_OTHER_SELECTED':
return go(state, 'fymonth')
case 'FY_FIRST_SELECTED':
return go(state, 'fystart')
case 'FY_END_MONTH_PICKED': {
const m = action.endMonth
if (!Number.isInteger(m) || m < 1 || m > 12) return state
const patched = stay(state, {
settings: {
...state.settings,
fiscal_year_start_month: m === 12 ? 1 : m + 1,
is_first_fiscal_year: false,
first_year_start: undefined,
first_year_end: undefined,
},
})
return afterFiscalYear(patched)
}
case 'FY_START_PICKED': {
const patched = stay(state, {
settings: {
...state.settings,
is_first_fiscal_year: true,
first_year_start: action.date,
},
})
return go(patched, 'fyend')
}
case 'FY_END_PICKED': {
const endMonth = Number(action.date.split('-')[1])
const patched = stay(state, {
settings: {
...state.settings,
is_first_fiscal_year: true,
first_year_end: action.date,
// The ongoing fiscal year starts the month after the first year
// ends: same derivation as the wizard's Step 3.
fiscal_year_start_month:
Number.isInteger(endMonth) && endMonth >= 1 && endMonth <= 12
? endMonth === 12
? 1
: endMonth + 1
: 1,
},
})
return afterFiscalYear(patched)
}
case 'VAT_ANSWERED': {
if (action.registered) {
const patched = stay(state, {
settings: {
...state.settings,
vat_registered: true,
vat_number: deriveSwedishVatNumber(state.settings.org_number),
},
})
return go(patched, 'moms')
}
const patched = stay(state, {
settings: {
...state.settings,
vat_registered: false,
vat_number: null,
moms_period: null,
},
})
return go(patched, 'method')
}
case 'MOMS_PERIOD_PICKED': {
const patched = stay(state, {
settings: { ...state.settings, moms_period: action.period },
})
return go(patched, 'method')
}
case 'METHOD_PICKED': {
return stay(state, {
settings: { ...state.settings, accounting_method: action.method },
submitting: true,
serverError: null,
})
}
case 'SUBMIT_SUCCEEDED':
return go(stay(state, { submitting: false }), 'done')
case 'DONE_CONTINUE': {
// Welcome screen → the branch question ("Var fanns bokföringen
// innan?") as its own step. First-company flow only: mode='add'
// ends on the done screen with "Öppna appen".
if (state.step !== 'done' || state.mode !== 'first') return state
return go(state, 'source')
}
case 'SUBMIT_FAILED': {
const cleared = stay(state, { submitting: false })
if (action.code === 'org_number_invalid') {
// The server rejected the orgnr: travel back to the Företaget
// station. Answers are kept; the fresh orgnr re-runs the lookup and
// the flow walks forward again.
return go(cleared, 'orgnr', {
ticLookup: null,
lookupRan: false,
serverError: 'org_number_invalid',
})
}
if (action.code === 'period_invalid') {
return go(cleared, 'fy', { serverError: 'period_invalid' })
}
return stay(cleared, { serverError: 'generic' })
}
case 'BACK': {
if (state.submitting || state.history.length === 0) return state
const history = [...state.history]
const snap = history.pop() as JourneySnapshot
return {
...state,
...snap,
entry: snap,
history,
lookupPending: false,
submitting: false,
serverError: null,
}
}
case 'STATION_JUMP': {
if (state.submitting) return state
const target = action.station
if (stationOfStep(state.step) <= target) return state
const history = [...state.history]
let snap: JourneySnapshot | null = null
while (history.length > 0) {
const top = history[history.length - 1]
const st = stationOfStep(top.step)
if (st > target) {
history.pop()
continue
}
if (st === target) {
snap = history.pop() as JourneySnapshot
// Rewind to the station's FIRST step, not its last.
while (
history.length > 0 &&
stationOfStep(history[history.length - 1].step) === target
) {
snap = history.pop() as JourneySnapshot
}
}
break
}
if (!snap) return state
return {
...state,
...snap,
entry: snap,
history,
lookupPending: false,
submitting: false,
serverError: null,
}
}
default:
return state
}
}