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:
co-authored by
Claude Fable 5
parent
2bec5acedb
commit
f9ef8913ae
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createCompanyFromOnboarding } from '@/lib/company/actions'
|
||||
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
|
||||
import { deriveFirstYearDefaults, parseStartMonthDay } from '@/lib/company/first-year-defaults'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -66,42 +67,6 @@ function logError(message: string, extra?: Record<string, unknown>) {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// Parse TIC v2's `startMonthDay` ("MM-DD": e.g. "07-01") into a month
|
||||
// number 1-12. Returns null on missing / malformed input so the caller
|
||||
// can fall through to the manual picker default.
|
||||
function parseStartMonthDay(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const match = /^(\d{1,2})-\d{1,2}$/.exec(value)
|
||||
if (!match) return null
|
||||
const month = Number(match[1])
|
||||
if (!Number.isInteger(month) || month < 1 || month > 12) return null
|
||||
return month
|
||||
}
|
||||
|
||||
// Derive the Step-3 first-year defaults from TIC's `registrationDate`.
|
||||
// A company is treated as "first year" when registered less than 12 months
|
||||
// ago: fits BFL's 6-18 month opening-period window comfortably. Returns
|
||||
// both the toggle state and a seeded `first_year_start` (always the 1st of
|
||||
// the registration month, the format Step 3's date inputs expect).
|
||||
function deriveFirstYearDefaults(registrationDate: number | null | undefined): {
|
||||
isFirstFiscalYear: boolean
|
||||
firstYearStart: string | undefined
|
||||
} {
|
||||
if (!registrationDate || !Number.isFinite(registrationDate)) {
|
||||
return { isFirstFiscalYear: false, firstYearStart: undefined }
|
||||
}
|
||||
const regDate = new Date(registrationDate)
|
||||
if (Number.isNaN(regDate.getTime())) {
|
||||
return { isFirstFiscalYear: false, firstYearStart: undefined }
|
||||
}
|
||||
const monthsAgo =
|
||||
(Date.now() - regDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)
|
||||
if (monthsAgo >= 12) return { isFirstFiscalYear: false, firstYearStart: undefined }
|
||||
const year = regDate.getUTCFullYear()
|
||||
const month = String(regDate.getUTCMonth() + 1).padStart(2, '0')
|
||||
return { isFirstFiscalYear: true, firstYearStart: `${year}-${month}-01` }
|
||||
}
|
||||
|
||||
interface WelcomeOnboardingProps {
|
||||
firstName?: string | null
|
||||
teamId: string
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { fetchCompanyLookup } from '../fetch-company-lookup'
|
||||
import type { CompanyLookupResult } from '../types'
|
||||
|
||||
const LOOKUP: CompanyLookupResult = {
|
||||
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: 1710000000000,
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchCompanyLookup', () => {
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
it('returns disabled without fetching when tic is not enabled', async () => {
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: false })
|
||||
expect(outcome).toEqual({ status: 'disabled' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns disabled without fetching for a malformed orgnr', async () => {
|
||||
const outcome = await fetchCompanyLookup('12', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'disabled' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the lookup result on 200', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(200, { data: LOOKUP }))
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'found', result: LOOKUP })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain(
|
||||
'/api/extensions/ext/tic/lookup?org_number=',
|
||||
)
|
||||
})
|
||||
|
||||
it("maps the TIC handler's 404 (Company not found) to not_found", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(404, { error: 'Company not found' }))
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'not_found' })
|
||||
})
|
||||
|
||||
it("maps the dispatcher's 404 (Extension not found) to disabled, not not_found", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(404, { error: 'Extension not found' }))
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'disabled' })
|
||||
})
|
||||
|
||||
it('maps a legacy 403 to disabled', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(403, { error: 'Extension disabled' }))
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'disabled' })
|
||||
})
|
||||
|
||||
it('maps a feature-flag 503 (EXTENSION_DISABLED) to disabled', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(503, { error: 'Not in this environment', code: 'EXTENSION_DISABLED' }),
|
||||
)
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'disabled' })
|
||||
})
|
||||
|
||||
it('maps NOT_CONFIGURED 503 to error (advisory note, manual path)', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(503, { error: 'TIC is not configured' }))
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'error' })
|
||||
})
|
||||
|
||||
it('maps 429 rate limit and 504 timeout to error', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(429, { error: 'Rate limit exceeded' }))
|
||||
expect(await fetchCompanyLookup('556677-8899', { ticEnabled: true })).toEqual({
|
||||
status: 'error',
|
||||
})
|
||||
fetchMock.mockResolvedValue(jsonResponse(504, { error: 'Timeout' }))
|
||||
expect(await fetchCompanyLookup('556677-8899', { ticEnabled: true })).toEqual({
|
||||
status: 'error',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a network failure to error without throwing', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'error' })
|
||||
})
|
||||
|
||||
it('maps an abort to aborted', async () => {
|
||||
const abortError = new DOMException('Aborted', 'AbortError')
|
||||
fetchMock.mockRejectedValue(abortError)
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'aborted' })
|
||||
})
|
||||
|
||||
it('returns aborted when the signal fired during the response', async () => {
|
||||
const controller = new AbortController()
|
||||
fetchMock.mockImplementation(async () => {
|
||||
controller.abort()
|
||||
return jsonResponse(200, { data: LOOKUP })
|
||||
})
|
||||
const outcome = await fetchCompanyLookup('556677-8899', {
|
||||
ticEnabled: true,
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(outcome).toEqual({ status: 'aborted' })
|
||||
})
|
||||
|
||||
it('maps a malformed success body to error', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response('not json', { status: 200, headers: { 'Content-Type': 'text/html' } }),
|
||||
)
|
||||
const outcome = await fetchCompanyLookup('556677-8899', { ticEnabled: true })
|
||||
expect(outcome).toEqual({ status: 'error' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
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' }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { computeFiscalPeriod } from '../compute-fiscal-period'
|
||||
|
||||
describe('computeFiscalPeriod', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-24T12:00:00'))
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('derives a calendar year from start month 1', () => {
|
||||
const result = computeFiscalPeriod({ fiscal_year_start_month: 1, entity_type: 'aktiebolag' })
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.startStr).toBe('2026-01-01')
|
||||
expect(result.endStr).toBe('2026-12-31')
|
||||
expect(result.periodName).toBe('Räkenskapsår 2026')
|
||||
})
|
||||
|
||||
it('defaults to a calendar year when start month is missing', () => {
|
||||
const result = computeFiscalPeriod({ entity_type: 'aktiebolag' })
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.startStr).toBe('2026-01-01')
|
||||
expect(result.endStr).toBe('2026-12-31')
|
||||
})
|
||||
|
||||
it('derives a broken fiscal year (brutet räkenskapsår) crossing the year end', () => {
|
||||
const result = computeFiscalPeriod({ fiscal_year_start_month: 7, entity_type: 'aktiebolag' })
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.startStr).toBe('2026-07-01')
|
||||
expect(result.endStr).toBe('2027-06-30')
|
||||
expect(result.periodName).toBe('Räkenskapsår 2026/2027')
|
||||
})
|
||||
|
||||
it('forces enskild firma onto the calendar year regardless of start month', () => {
|
||||
const result = computeFiscalPeriod({ fiscal_year_start_month: 7, entity_type: 'enskild_firma' })
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.startStr).toBe('2026-01-01')
|
||||
expect(result.endStr).toBe('2026-12-31')
|
||||
expect(result.periodName).toBe('Räkenskapsår 2026')
|
||||
})
|
||||
|
||||
it('uses first-year dates verbatim, same-year name', () => {
|
||||
const result = computeFiscalPeriod({
|
||||
entity_type: 'aktiebolag',
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: '2026-03-14',
|
||||
first_year_end: '2026-12-31',
|
||||
})
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.startStr).toBe('2026-03-14')
|
||||
expect(result.endStr).toBe('2026-12-31')
|
||||
expect(result.periodName).toBe('Första räkenskapsåret 2026')
|
||||
})
|
||||
|
||||
it('allows a mid-month start date only for the first year (BFL 3 kap.)', () => {
|
||||
const result = computeFiscalPeriod({
|
||||
entity_type: 'aktiebolag',
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: '2026-03-14',
|
||||
first_year_end: '2027-06-30',
|
||||
})
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.periodName).toBe('Första räkenskapsåret 2026/2027')
|
||||
})
|
||||
|
||||
it('accepts an extended first year up to 18 months', () => {
|
||||
const result = computeFiscalPeriod({
|
||||
entity_type: 'aktiebolag',
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: '2026-07-01',
|
||||
first_year_end: '2027-12-31',
|
||||
})
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.periodName).toBe('Första räkenskapsåret 2026/2027')
|
||||
})
|
||||
|
||||
it('rejects a first year longer than 18 months', () => {
|
||||
const result = computeFiscalPeriod({
|
||||
entity_type: 'aktiebolag',
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: '2026-01-01',
|
||||
first_year_end: '2027-12-31',
|
||||
})
|
||||
expect(result.error).toContain('exceeds maximum 18 months')
|
||||
expect(result.startStr).toBe('')
|
||||
expect(result.endStr).toBe('')
|
||||
expect(result.periodName).toBe('')
|
||||
})
|
||||
|
||||
it('rejects a first year shorter than 6 months', () => {
|
||||
const result = computeFiscalPeriod({
|
||||
entity_type: 'aktiebolag',
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: '2026-10-01',
|
||||
first_year_end: '2026-12-31',
|
||||
})
|
||||
expect(result.error).toContain('at least 6 months')
|
||||
})
|
||||
|
||||
it('rejects an end date that is not the last day of a month', () => {
|
||||
const result = computeFiscalPeriod({
|
||||
entity_type: 'aktiebolag',
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: '2026-03-01',
|
||||
first_year_end: '2026-12-30',
|
||||
})
|
||||
expect(result.error).toContain('last day of a month')
|
||||
})
|
||||
|
||||
it('rejects an end before the start', () => {
|
||||
const result = computeFiscalPeriod({
|
||||
entity_type: 'aktiebolag',
|
||||
is_first_fiscal_year: true,
|
||||
first_year_start: '2026-06-01',
|
||||
first_year_end: '2026-05-31',
|
||||
})
|
||||
expect(result.error).toContain('must be after')
|
||||
})
|
||||
|
||||
it('falls back to the standard branch when first-year dates are incomplete', () => {
|
||||
const result = computeFiscalPeriod({
|
||||
entity_type: 'aktiebolag',
|
||||
is_first_fiscal_year: true,
|
||||
fiscal_year_start_month: 1,
|
||||
})
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.startStr).toBe('2026-01-01')
|
||||
expect(result.endStr).toBe('2026-12-31')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { deriveFirstYearDefaults, parseStartMonthDay } from '../first-year-defaults'
|
||||
|
||||
const NOW = new Date('2026-07-24T12:00:00Z').getTime()
|
||||
const MONTH_MS = 1000 * 60 * 60 * 24 * 30.44
|
||||
|
||||
describe('parseStartMonthDay', () => {
|
||||
it('parses a TIC MM-DD into the start month', () => {
|
||||
expect(parseStartMonthDay('07-01')).toBe(7)
|
||||
expect(parseStartMonthDay('1-15')).toBe(1)
|
||||
expect(parseStartMonthDay('12-31')).toBe(12)
|
||||
})
|
||||
|
||||
it('returns null for missing input', () => {
|
||||
expect(parseStartMonthDay(null)).toBeNull()
|
||||
expect(parseStartMonthDay(undefined)).toBeNull()
|
||||
expect(parseStartMonthDay('')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for malformed or out-of-range input', () => {
|
||||
expect(parseStartMonthDay('July 1')).toBeNull()
|
||||
expect(parseStartMonthDay('2026-07-01')).toBeNull()
|
||||
expect(parseStartMonthDay('13-01')).toBeNull()
|
||||
expect(parseStartMonthDay('0-15')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveFirstYearDefaults', () => {
|
||||
it('pre-checks first year for a company registered 11 months ago', () => {
|
||||
const registered = NOW - 11 * MONTH_MS
|
||||
const result = deriveFirstYearDefaults(registered, NOW)
|
||||
expect(result.isFirstFiscalYear).toBe(true)
|
||||
expect(result.firstYearStart).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not pre-check for a company registered 13 months ago', () => {
|
||||
const registered = NOW - 13 * MONTH_MS
|
||||
expect(deriveFirstYearDefaults(registered, NOW)).toEqual({
|
||||
isFirstFiscalYear: false,
|
||||
firstYearStart: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not pre-check at exactly 12 months', () => {
|
||||
const registered = NOW - 12 * MONTH_MS
|
||||
expect(deriveFirstYearDefaults(registered, NOW).isFirstFiscalYear).toBe(false)
|
||||
})
|
||||
|
||||
it('seeds first_year_start as the 1st of the UTC registration month', () => {
|
||||
const registered = new Date('2026-03-14T10:00:00Z').getTime()
|
||||
const result = deriveFirstYearDefaults(registered, NOW)
|
||||
expect(result.isFirstFiscalYear).toBe(true)
|
||||
expect(result.firstYearStart).toBe('2026-03-01')
|
||||
})
|
||||
|
||||
it('returns falsy defaults for missing or invalid registration dates', () => {
|
||||
const empty = { isFirstFiscalYear: false, firstYearStart: undefined }
|
||||
expect(deriveFirstYearDefaults(null, NOW)).toEqual(empty)
|
||||
expect(deriveFirstYearDefaults(undefined, NOW)).toEqual(empty)
|
||||
expect(deriveFirstYearDefaults(0, NOW)).toEqual(empty)
|
||||
expect(deriveFirstYearDefaults(Number.NaN, NOW)).toEqual(empty)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* First-fiscal-year defaults derived from TIC lookup data.
|
||||
*
|
||||
* Extracted from WelcomeOnboarding so both the wizard and the journey
|
||||
* onboarding can share them (dev_docs/onboarding_migration_plan.md, PR A).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse TIC v2's `startMonthDay` ("MM-DD": e.g. "07-01") into a month
|
||||
* number 1-12. Returns null on missing / malformed input so the caller
|
||||
* can fall through to the manual picker default.
|
||||
*/
|
||||
export function parseStartMonthDay(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const match = /^(\d{1,2})-\d{1,2}$/.exec(value)
|
||||
if (!match) return null
|
||||
const month = Number(match[1])
|
||||
if (!Number.isInteger(month) || month < 1 || month > 12) return null
|
||||
return month
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the first-year defaults from TIC's `registrationDate`.
|
||||
* A company is treated as "first year" when registered less than 12 months
|
||||
* ago: fits BFL's 6-18 month opening-period window comfortably. Returns
|
||||
* both the toggle state and a seeded `first_year_start` (always the 1st of
|
||||
* the registration month, the format the date inputs expect).
|
||||
*
|
||||
* `now` exists for tests; production callers omit it.
|
||||
*/
|
||||
export function deriveFirstYearDefaults(
|
||||
registrationDate: number | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): {
|
||||
isFirstFiscalYear: boolean
|
||||
firstYearStart: string | undefined
|
||||
} {
|
||||
if (!registrationDate || !Number.isFinite(registrationDate)) {
|
||||
return { isFirstFiscalYear: false, firstYearStart: undefined }
|
||||
}
|
||||
const regDate = new Date(registrationDate)
|
||||
if (Number.isNaN(regDate.getTime())) {
|
||||
return { isFirstFiscalYear: false, firstYearStart: undefined }
|
||||
}
|
||||
const monthsAgo = (now - regDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)
|
||||
if (monthsAgo >= 12) return { isFirstFiscalYear: false, firstYearStart: undefined }
|
||||
const year = regDate.getUTCFullYear()
|
||||
const month = String(regDate.getUTCMonth() + 1).padStart(2, '0')
|
||||
return { isFirstFiscalYear: true, firstYearStart: `${year}-${month}-01` }
|
||||
}
|
||||
Reference in New Issue
Block a user