diff --git a/components/dashboard/WelcomeOnboarding.tsx b/components/dashboard/WelcomeOnboarding.tsx index 7cc7d688..0264f6fa 100644 --- a/components/dashboard/WelcomeOnboarding.tsx +++ b/components/dashboard/WelcomeOnboarding.tsx @@ -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) { }).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 diff --git a/components/onboarding/Step2CompanyDetails.tsx b/components/onboarding/Step2CompanyDetails.tsx index 246441d4..77b75b0f 100644 --- a/components/onboarding/Step2CompanyDetails.tsx +++ b/components/onboarding/Step2CompanyDetails.tsx @@ -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) }) diff --git a/lib/company-lookup/__tests__/fetch-company-lookup.test.ts b/lib/company-lookup/__tests__/fetch-company-lookup.test.ts new file mode 100644 index 00000000..951c33ea --- /dev/null +++ b/lib/company-lookup/__tests__/fetch-company-lookup.test.ts @@ -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' }) + }) +}) diff --git a/lib/company-lookup/fetch-company-lookup.ts b/lib/company-lookup/fetch-company-lookup.ts new file mode 100644 index 00000000..5f869d28 --- /dev/null +++ b/lib/company-lookup/fetch-company-lookup.ts @@ -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 { + 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' } +} diff --git a/lib/company/__tests__/compute-fiscal-period.test.ts b/lib/company/__tests__/compute-fiscal-period.test.ts new file mode 100644 index 00000000..56a4c088 --- /dev/null +++ b/lib/company/__tests__/compute-fiscal-period.test.ts @@ -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') + }) +}) diff --git a/lib/company/__tests__/first-year-defaults.test.ts b/lib/company/__tests__/first-year-defaults.test.ts new file mode 100644 index 00000000..c5e6ad1a --- /dev/null +++ b/lib/company/__tests__/first-year-defaults.test.ts @@ -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) + }) +}) diff --git a/lib/company/first-year-defaults.ts b/lib/company/first-year-defaults.ts new file mode 100644 index 00000000..cace665f --- /dev/null +++ b/lib/company/first-year-defaults.ts @@ -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` } +}