feat(onboarding): journey PR B — reducer, orb, track, question primitives (behind /sandbox demo) (#1145)
* refactor(onboarding): extract first-year defaults + shared TIC lookup client (journey PR A)
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>
* feat(onboarding): journey state machine reducer with full branch coverage (journey PR B, 1/3)
Pure reducer for the journey onboarding: owns every transition and every
CompanySettings write; the component layer only renders steps, runs the
single TIC lookup, and calls the server action.
Encodes the plan's invariants: entry-snapshot history (Back rolls answers
AND stations), lookupRan gates fact-vs-question per field (BankID prefill
without lookup degrades to questions), vat_registered is never defaulted
without lookup data or an explicit answer, entity change wipes downstream,
org_number_invalid bounces to the Företaget station, station jumps rewind
to a station's first step.
34 unit tests: AB/EF found, not-found manual, ceased, BankID prefill
(found + degraded + disabled), first year, brutet år, moms nej, Back from
every step, station jumps, server-error bounces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(onboarding): journey visual primitives + sandbox gallery (journey PR B, 2/3)
Ports the founder-approved concept (artifact c82c9358) to React:
- JourneyOrb: 320-particle canvas sphere with comet travel, check morph
and the monogram finale (glyph sampled live from --font-display). Own
component per plan, NOT thinking-orbs. rAF pauses on document.hidden;
reduced motion renders static frames.
- JourneyTrack: five stations with inked answers; completed stations are
keyboard-accessible jump-back buttons; answers mirrored to an aria-live
region.
- Question primitives: Question (ink title + "?" popover, Esc closes),
ChipRow (fly-to-orb ghost), YearBand (springy fiscal-year preview),
JourneyDatePicker (year -> month by name -> day), AddressFields
(Enter-chained, skippable).
- journey.css: concept stylesheet namespaced under .jny on app tokens,
incl. the no-scroll composition (100dvh + optical-centering balance
spacer) and the dawn layer.
- /sandbox/journey: internal primitive gallery (auth-free sandbox path),
demo data only: this page makes ZERO TIC calls.
i18n note: primitives are copy-agnostic (strings via props); the real
flow's sv/en keys land with their consumer in PR C.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: log journey reducer location decision
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(onboarding): annotate ENTITY_PICKED settings as Partial<CompanySettings>
The wipeDownstream return narrows against the inferred initializer type;
tsc strict rejects the reassignment without the explicit annotation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f9ef8913ae
commit
b771c1f923
@@ -0,0 +1,501 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
initJourney,
|
||||
journeyReducer,
|
||||
stationOfStep,
|
||||
type JourneyAction,
|
||||
type JourneyState,
|
||||
} from '../reducer'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
|
||||
function lookup(overrides: Partial<CompanyLookupResult> = {}): CompanyLookupResult {
|
||||
return {
|
||||
companyName: 'Nordvik Bygg & Konsult AB',
|
||||
isCeased: false,
|
||||
address: { street: 'Storgatan 1', postalCode: '211 34', city: 'Malmö' },
|
||||
registration: { fTax: true, vat: true },
|
||||
bankAccounts: [],
|
||||
email: null,
|
||||
phone: null,
|
||||
sniCodes: [],
|
||||
fiscalYear: { startMonthDay: '01-01', endMonthDay: '12-31' },
|
||||
legalEntityType: 'AB',
|
||||
registrationDate: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function run(state: JourneyState, ...actions: JourneyAction[]): JourneyState {
|
||||
return actions.reduce(journeyReducer, state)
|
||||
}
|
||||
|
||||
/** Manual AB path up to the fiscal-year station. */
|
||||
function manualAbAtFy(): JourneyState {
|
||||
return run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
{ type: 'NOTFOUND_CONTINUE' },
|
||||
{ type: 'ENTITY_PICKED', entityType: 'aktiebolag' },
|
||||
{ type: 'NAME_SUBMITTED', name: 'Testbolaget AB' },
|
||||
{ type: 'ADDRESS_SUBMITTED', addressLine1: 'Gatan 1', postalCode: '123 45', city: 'Lund' },
|
||||
{ type: 'FSKATT_ANSWERED', fskatt: true },
|
||||
)
|
||||
}
|
||||
|
||||
describe('journeyReducer: AB found via lookup', () => {
|
||||
it('lands on the fiscal-year station with name/address/F-skatt as facts', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
)
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.lookupRan).toBe(true)
|
||||
expect(s.settings).toMatchObject({
|
||||
org_number: '556677-8899',
|
||||
entity_type: 'aktiebolag',
|
||||
company_name: 'Nordvik Bygg & Konsult AB',
|
||||
address_line1: 'Storgatan 1',
|
||||
postal_code: '211 34',
|
||||
city: 'Malmö',
|
||||
f_skatt: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('skips the moms question only for a POSITIVE VAT registration', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
)
|
||||
expect(s.step).toBe('moms')
|
||||
expect(s.settings.vat_registered).toBe(true)
|
||||
expect(s.settings.vat_number).toBe('SE556677889901')
|
||||
})
|
||||
|
||||
it('collects the full wizard payload through to submit', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
{ type: 'MOMS_PERIOD_PICKED', period: 'quarterly' },
|
||||
{ type: 'METHOD_PICKED', method: 'accrual' },
|
||||
)
|
||||
expect(s.submitting).toBe(true)
|
||||
expect(s.settings).toMatchObject({
|
||||
entity_type: 'aktiebolag',
|
||||
company_name: 'Nordvik Bygg & Konsult AB',
|
||||
org_number: '556677-8899',
|
||||
address_line1: 'Storgatan 1',
|
||||
postal_code: '211 34',
|
||||
city: 'Malmö',
|
||||
f_skatt: true,
|
||||
fiscal_year_start_month: 1,
|
||||
vat_registered: true,
|
||||
vat_number: 'SE556677889901',
|
||||
moms_period: 'quarterly',
|
||||
accounting_method: 'accrual',
|
||||
})
|
||||
const done = journeyReducer(s, { type: 'SUBMIT_SUCCEEDED' })
|
||||
expect(done.step).toBe('done')
|
||||
expect(stationOfStep(done.step)).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: EF found via lookup', () => {
|
||||
const efLookup = lookup({
|
||||
companyName: 'Alice Nordin',
|
||||
legalEntityType: 'Enskild näringsidkare',
|
||||
registration: { fTax: true, vat: false },
|
||||
})
|
||||
|
||||
it('still asks the verksamhetsnamn question (name is a choice for EF)', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '19850420-1234' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: efLookup } },
|
||||
)
|
||||
expect(s.step).toBe('name')
|
||||
expect(s.settings.entity_type).toBe('enskild_firma')
|
||||
expect(s.settings.company_name).toBe('Alice Nordin')
|
||||
})
|
||||
|
||||
it('skips address and F-skatt after the name (lookup facts), then asks moms: never defaults a negative VAT', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '19850420-1234' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: efLookup } },
|
||||
{ type: 'NAME_SUBMITTED', name: 'Alice Nordin Design' },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
)
|
||||
expect(s.settings.company_name).toBe('Alice Nordin Design')
|
||||
expect(s.settings.f_skatt).toBe(true)
|
||||
expect(s.step).toBe('momsyn')
|
||||
expect(s.settings.vat_registered).toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers moms nej: no VAT number, no period, straight to method', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '19850420-1234' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: efLookup } },
|
||||
{ type: 'NAME_SUBMITTED', name: 'Alice Nordin' },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
{ type: 'VAT_ANSWERED', registered: false },
|
||||
)
|
||||
expect(s.step).toBe('method')
|
||||
expect(s.settings.vat_registered).toBe(false)
|
||||
expect(s.settings.vat_number).toBeNull()
|
||||
expect(s.settings.moms_period).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: manual path (not found)', () => {
|
||||
it('walks form → name → address → F-skatt → fy', () => {
|
||||
let s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
)
|
||||
expect(s.step).toBe('notfound')
|
||||
s = journeyReducer(s, { type: 'NOTFOUND_CONTINUE' })
|
||||
expect(s.step).toBe('form')
|
||||
s = journeyReducer(s, { type: 'ENTITY_PICKED', entityType: 'aktiebolag' })
|
||||
expect(s.step).toBe('name')
|
||||
s = journeyReducer(s, { type: 'NAME_SUBMITTED', name: 'Testbolaget AB' })
|
||||
expect(s.step).toBe('address')
|
||||
s = journeyReducer(s, { type: 'ADDRESS_SUBMITTED', city: 'Lund' })
|
||||
expect(s.step).toBe('fskatt')
|
||||
s = journeyReducer(s, { type: 'FSKATT_ANSWERED', fskatt: false })
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.settings.f_skatt).toBe(false)
|
||||
expect(s.lookupRan).toBe(false)
|
||||
})
|
||||
|
||||
it('address can be skipped (empty submit) and is only asked once', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
{ type: 'NOTFOUND_CONTINUE' },
|
||||
{ type: 'ENTITY_PICKED', entityType: 'aktiebolag' },
|
||||
{ type: 'NAME_SUBMITTED', name: 'Testbolaget AB' },
|
||||
{ type: 'ADDRESS_SUBMITTED' },
|
||||
)
|
||||
expect(s.step).toBe('fskatt')
|
||||
expect(s.settings.address_line1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notfound → edit clears the orgnr and returns to the question', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
{ type: 'NOTFOUND_EDIT' },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.settings.org_number).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: ceased company', () => {
|
||||
it('interjects the ceased step and can continue with facts', () => {
|
||||
let s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556012-3456' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup({ isCeased: true }) } },
|
||||
)
|
||||
expect(s.step).toBe('ceased')
|
||||
s = journeyReducer(s, { type: 'CEASED_CONTINUE' })
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.settings.company_name).toBe('Nordvik Bygg & Konsult AB')
|
||||
})
|
||||
|
||||
it('ceased → edit returns to the orgnr question', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556012-3456' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup({ isCeased: true }) } },
|
||||
{ type: 'CEASED_EDIT' },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.settings.org_number).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: BankID prefill', () => {
|
||||
const init = () =>
|
||||
initJourney({
|
||||
initialOrgNumber: '556677-8899',
|
||||
initialEntityType: 'aktiebolag',
|
||||
initialLegalName: 'Nordvik Bygg & Konsult AB',
|
||||
})
|
||||
|
||||
it('a successful lookup on the deep-linked orgnr yields the facts path', () => {
|
||||
const s = run(
|
||||
init(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
)
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.lookupRan).toBe(true)
|
||||
})
|
||||
|
||||
it('lookup failure degrades to questions: address and F-skatt are ASKED, VAT never defaulted', () => {
|
||||
let s = run(
|
||||
init(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'error' } },
|
||||
)
|
||||
// Name/entity from CompanyRoles survive as prefill; the rest is asked.
|
||||
expect(s.step).toBe('address')
|
||||
expect(s.lookupNote).toBe('error')
|
||||
expect(s.lookupRan).toBe(false)
|
||||
s = run(
|
||||
s,
|
||||
{ type: 'ADDRESS_SUBMITTED', city: 'Malmö' },
|
||||
{ type: 'FSKATT_ANSWERED', fskatt: true },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
)
|
||||
expect(s.step).toBe('momsyn')
|
||||
expect(s.settings.vat_registered).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a disabled lookup surface degrades silently (no advisory note)', () => {
|
||||
const s = run(
|
||||
init(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'disabled' } },
|
||||
)
|
||||
expect(s.step).toBe('address')
|
||||
expect(s.lookupNote).toBe('none')
|
||||
})
|
||||
|
||||
it('without any prefill a disabled lookup goes to the form question', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'disabled' } },
|
||||
)
|
||||
expect(s.step).toBe('form')
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: fiscal-year branches', () => {
|
||||
it('first year: start + end dates, ongoing start month derived from the end', () => {
|
||||
const s = run(
|
||||
manualAbAtFy(),
|
||||
{ type: 'FY_FIRST_SELECTED' },
|
||||
{ type: 'FY_START_PICKED', date: '2026-03-14' },
|
||||
{ type: 'FY_END_PICKED', date: '2026-12-31' },
|
||||
)
|
||||
expect(s.settings).toMatchObject({
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: '2026-03-14',
|
||||
first_year_end: '2026-12-31',
|
||||
fiscal_year_start_month: 1,
|
||||
})
|
||||
expect(s.step).toBe('momsyn')
|
||||
})
|
||||
|
||||
it('first year ending mid-year points the ongoing year at the next month', () => {
|
||||
const s = run(
|
||||
manualAbAtFy(),
|
||||
{ type: 'FY_FIRST_SELECTED' },
|
||||
{ type: 'FY_START_PICKED', date: '2026-03-14' },
|
||||
{ type: 'FY_END_PICKED', date: '2027-06-30' },
|
||||
)
|
||||
expect(s.settings.fiscal_year_start_month).toBe(7)
|
||||
})
|
||||
|
||||
it('brutet år: end month picks the following start month', () => {
|
||||
const s = run(manualAbAtFy(), { type: 'FY_OTHER_SELECTED' }, { type: 'FY_END_MONTH_PICKED', endMonth: 6 })
|
||||
expect(s.settings.fiscal_year_start_month).toBe(7)
|
||||
expect(s.settings.is_first_fiscal_year).toBe(false)
|
||||
})
|
||||
|
||||
it('brutet år resolving to December is a plain calendar year', () => {
|
||||
const s = run(manualAbAtFy(), { type: 'FY_OTHER_SELECTED' }, { type: 'FY_END_MONTH_PICKED', endMonth: 12 })
|
||||
expect(s.settings.fiscal_year_start_month).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: Back and station jumps', () => {
|
||||
it('Back restores each step to its ENTRY state (answers roll back)', () => {
|
||||
const atFy = manualAbAtFy()
|
||||
let s = journeyReducer(atFy, { type: 'BACK' })
|
||||
expect(s.step).toBe('fskatt')
|
||||
expect(s.settings.f_skatt).toBeUndefined()
|
||||
s = journeyReducer(s, { type: 'BACK' })
|
||||
expect(s.step).toBe('address')
|
||||
expect(s.settings.address_line1).toBeUndefined()
|
||||
expect(s.settings.city).toBeUndefined()
|
||||
s = journeyReducer(s, { type: 'BACK' })
|
||||
expect(s.step).toBe('name')
|
||||
expect(s.settings.company_name).toBeUndefined()
|
||||
s = journeyReducer(s, { type: 'BACK' })
|
||||
expect(s.step).toBe('form')
|
||||
expect(s.settings.entity_type).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a station jump rewinds to the first step of that station', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
{ type: 'MOMS_PERIOD_PICKED', period: 'quarterly' },
|
||||
{ type: 'STATION_JUMP', station: 1 },
|
||||
)
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.settings.moms_period).toBeUndefined()
|
||||
expect(s.settings.fiscal_year_start_month).toBeUndefined()
|
||||
// The lookup facts from station 0 survive.
|
||||
expect(s.settings.company_name).toBe('Nordvik Bygg & Konsult AB')
|
||||
})
|
||||
|
||||
it('jumping to the Företaget station lands on the orgnr question', () => {
|
||||
const s = run(
|
||||
manualAbAtFy(),
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
{ type: 'STATION_JUMP', station: 0 },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.settings.company_name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores jumps to the current or a later station', () => {
|
||||
const s = manualAbAtFy()
|
||||
expect(journeyReducer(s, { type: 'STATION_JUMP', station: 1 })).toBe(s)
|
||||
expect(journeyReducer(s, { type: 'STATION_JUMP', station: 3 })).toBe(s)
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: entity change wipe', () => {
|
||||
it('a genuine entity change wipes org/name and all downstream answers', () => {
|
||||
let s = run(manualAbAtFy(), { type: 'FY_CALENDAR_CONFIRMED' }, { type: 'VAT_ANSWERED', registered: false })
|
||||
expect(s.step).toBe('method')
|
||||
// Defensive guard: an entity change with answers on file (however the UI
|
||||
// reaches it) resets the flow to the orgnr question and wipes downstream.
|
||||
s = journeyReducer(s, { type: 'ENTITY_PICKED', entityType: 'enskild_firma' })
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.settings.entity_type).toBe('enskild_firma')
|
||||
expect(s.settings.org_number).toBeUndefined()
|
||||
expect(s.settings.company_name).toBeUndefined()
|
||||
expect(s.settings.vat_registered).toBeUndefined()
|
||||
expect(s.settings.moms_period).toBeUndefined()
|
||||
expect(s.settings.accounting_method).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a station jump to Företaget already rolls entity back, so re-picking is first-time', () => {
|
||||
let s = run(manualAbAtFy(), { type: 'FY_CALENDAR_CONFIRMED' }, { type: 'VAT_ANSWERED', registered: false })
|
||||
s = run(
|
||||
s,
|
||||
{ type: 'STATION_JUMP', station: 0 },
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '559999-9999' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } },
|
||||
{ type: 'NOTFOUND_CONTINUE' },
|
||||
{ type: 'ENTITY_PICKED', entityType: 'enskild_firma' },
|
||||
)
|
||||
// Entry snapshots already wiped the old answers at the jump; the fresh
|
||||
// pick proceeds forward (no second wipe needed).
|
||||
expect(s.step).toBe('name')
|
||||
expect(s.settings.entity_type).toBe('enskild_firma')
|
||||
expect(s.settings.vat_registered).toBeUndefined()
|
||||
expect(s.settings.accounting_method).toBeUndefined()
|
||||
})
|
||||
|
||||
it('first-time entity selection keeps a deep-link prefill', () => {
|
||||
const s = run(
|
||||
initJourney({ initialOrgNumber: '556677-8899', initialLegalName: 'Nordvik AB' }),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'error' } },
|
||||
{ type: 'ENTITY_PICKED', entityType: 'aktiebolag' },
|
||||
)
|
||||
expect(s.settings.org_number).toBe('556677-8899')
|
||||
expect(s.settings.company_name).toBe('Nordvik AB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: server errors', () => {
|
||||
function submitted(): JourneyState {
|
||||
return run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } },
|
||||
{ type: 'FY_CALENDAR_CONFIRMED' },
|
||||
{ type: 'MOMS_PERIOD_PICKED', period: 'monthly' },
|
||||
{ type: 'METHOD_PICKED', method: 'cash' },
|
||||
)
|
||||
}
|
||||
|
||||
it('org_number_invalid travels back to the orgnr question, keeping answers', () => {
|
||||
const s = journeyReducer(submitted(), { type: 'SUBMIT_FAILED', code: 'org_number_invalid' })
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.serverError).toBe('org_number_invalid')
|
||||
expect(s.submitting).toBe(false)
|
||||
expect(s.lookupRan).toBe(false)
|
||||
expect(s.settings.accounting_method).toBe('cash')
|
||||
})
|
||||
|
||||
it('period_invalid returns to the fiscal-year station', () => {
|
||||
const s = journeyReducer(submitted(), { type: 'SUBMIT_FAILED', code: 'period_invalid' })
|
||||
expect(s.step).toBe('fy')
|
||||
expect(s.serverError).toBe('period_invalid')
|
||||
})
|
||||
|
||||
it('generic failure stays on method with a retry state', () => {
|
||||
const s = journeyReducer(submitted(), { type: 'SUBMIT_FAILED', code: 'generic' })
|
||||
expect(s.step).toBe('method')
|
||||
expect(s.serverError).toBe('generic')
|
||||
expect(s.submitting).toBe(false)
|
||||
})
|
||||
|
||||
it('any new answer clears the server error', () => {
|
||||
const failed = journeyReducer(submitted(), { type: 'SUBMIT_FAILED', code: 'org_number_invalid' })
|
||||
const s = run(failed, { type: 'ORG_SUBMITTED', orgNumber: '556677-8899' })
|
||||
expect(s.serverError).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journeyReducer: robustness', () => {
|
||||
it('ignores a LOOKUP_RESULT when no lookup is pending', () => {
|
||||
const s = initJourney()
|
||||
expect(journeyReducer(s, { type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } })).toBe(s)
|
||||
})
|
||||
|
||||
it('ignores an aborted lookup (stays on orgnr, pending cleared)', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '556677-8899' },
|
||||
{ type: 'LOOKUP_RESULT', outcome: { status: 'aborted' } },
|
||||
)
|
||||
expect(s.step).toBe('orgnr')
|
||||
expect(s.lookupPending).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores Back while submitting and with empty history', () => {
|
||||
const fresh = initJourney()
|
||||
expect(journeyReducer(fresh, { type: 'BACK' })).toBe(fresh)
|
||||
})
|
||||
|
||||
it('an unmappable entity type from the lookup falls through to the form question', () => {
|
||||
const s = run(
|
||||
initJourney(),
|
||||
{ type: 'ORG_SUBMITTED', orgNumber: '769612-3456' },
|
||||
{
|
||||
type: 'LOOKUP_RESULT',
|
||||
outcome: {
|
||||
status: 'found',
|
||||
result: lookup({ companyName: 'Brf Sjöutsikten', legalEntityType: 'BRF' }),
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(s.step).toBe('form')
|
||||
expect(s.settings.company_name).toBe('Brf Sjöutsikten')
|
||||
expect(s.lookupRan).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,551 @@
|
||||
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import type { CompanyLookupOutcome } 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'
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
lookupNote: 'none' | 'error'
|
||||
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
|
||||
/** 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: '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: '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,
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
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') {
|
||||
const lookup = outcome.result
|
||||
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(cleared, {
|
||||
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))
|
||||
}
|
||||
|
||||
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 '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 '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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user