From 93284ba96de8c96a4d2b262534c6f53051a72a45 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 12:47:31 +0200 Subject: [PATCH 1/5] refactor(scripts): extract seed-onboarding's schema filler and pick holiday-free start dates Moves fillSchema/fakeValueFor/preferNoOption out of seed-onboarding.ts into scripts/fill-schema.ts so the upcoming schema canary (#1382) can reuse them. Along the way, seeded employments get: - a provisional_start_date at least 35 days out that skips the country's public holidays (fetched from /v1/countries/{country}/holidays/{year}), since 21 days wasn't enough lead time for some countries (e.g. Iceland) and fixed offsets regularly landed on local holidays - job_title "Software Engineer" instead of lorem words, which some countries' job title eligibility checks reject Co-Authored-By: Claude Opus 5.5 --- scripts/fill-schema.ts | 161 +++++++++++++++++++++++++++++++++++++ scripts/seed-onboarding.ts | 131 ++++++------------------------ 2 files changed, 184 insertions(+), 108 deletions(-) create mode 100644 scripts/fill-schema.ts diff --git a/scripts/fill-schema.ts b/scripts/fill-schema.ts new file mode 100644 index 000000000..b8d8a8ec2 --- /dev/null +++ b/scripts/fill-schema.ts @@ -0,0 +1,161 @@ +import { createHeadlessForm } from '@remoteoss/remote-json-schema-form-kit'; +import { faker } from '@faker-js/faker'; +import { $TSFixMe } from '@/src/types/remoteFlows'; + +/** First option whose value/label reads as "no" - collapses conditional sub-fields (file + * uploads, free-text detail boxes) that a generic filler can't produce plausible data for. */ +export function preferNoOption(options: $TSFixMe[]) { + return ( + options.find((o) => String(o.value).toLowerCase() === 'no') || options[0] + ); +} + +export function fakeValueFor(field: $TSFixMe) { + const inputType = field.inputType; + const options: $TSFixMe[] | undefined = field.options; + const multiple = field.multiple; + const name = field.name; + const constValue = field.const; + + if (options?.length) { + if (inputType === 'radio' || inputType === 'select') { + return preferNoOption(options).value; + } + if (inputType === 'countries' || multiple) { + return [faker.helpers.arrayElement(options).value]; + } + if (inputType === 'tel') { + // options here are per-country dialing patterns (e.g. pattern: + // '^(+49)[0-9]{6,}$', meta.countryCode: '49'), not user-facing choices - + // any one produces a validly-formatted number, regardless of the + // employment's own country. + const option = options.find((o) => o.meta?.countryCode) || options[0]; + return `+${option.meta.countryCode}${faker.string.numeric(9)}`; + } + return preferNoOption(options).value; + } + + switch (inputType) { + case 'email': + return name === 'work_email' + ? faker.internet.email({ provider: 'remote-e2e-test.com' }) + : faker.internet.email(); + case 'tel': + return `+1${faker.string.numeric(9)}`; + case 'date': { + // Some countries require more lead time than a fixed short offset + // covers (e.g. Iceland: 20 working days, ~28 calendar days) - 35 + // calendar days clears that with margin. Doesn't dodge country-specific + // holidays (e.g. Georgia) on its own; seed-employment.ts's + // findSafeStartDate seeds provisional_start_date directly for that. + const d = new Date(); + d.setDate(d.getDate() + 35); + return d.toISOString().slice(0, 10); + } + case 'number': + return faker.number.int({ min: 5, max: 30 }); + case 'money': + return faker.number.int({ min: 3_000_000, max: 8_000_000 }); + case 'textarea': + return faker.lorem.sentence(); + case 'checkbox': + // No enumerated options: this is a single acknowledgement toggle. RHF + // holds a plain `true` here, which parseFormValuesToAPI (src/components/ + // form/utils.ts) swaps for `field.const` (e.g. "acknowledged") at submit + // time when the schema is const-based - mirror that instead of sending + // the raw RHF value, or the API rejects it as a type mismatch. + return constValue ?? true; + case 'file': + return null; + default: + // job_title is screened by some countries against an eligibility list + // that appears to gate on regulated/restricted categories rather than + // "does this look like a real title" - lorem-ipsum gibberish and even + // faker.person.jobTitle() (e.g. "Business Systems Officer") both got + // rejected. "Software Engineer" is a common, unregulated role that + // clears eligibility checks broadly. + return name === 'job_title' + ? 'Software Engineer' + : faker.lorem.words({ min: 2, max: 4 }); + } +} + +export const SAFE_START_DATE_MIN_LEAD_DAYS = 35; +export const SAFE_START_DATE_SEARCH_WINDOW_DAYS = 60; + +function toIsoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** Years to fetch public holidays for, covering the whole + * [minLeadDays, minLeadDays + windowDays] candidate range. */ +export function safeStartDateYears( + minLeadDays = SAFE_START_DATE_MIN_LEAD_DAYS, + windowDays = SAFE_START_DATE_SEARCH_WINDOW_DAYS, +): string[] { + const earliest = new Date(); + earliest.setDate(earliest.getDate() + minLeadDays); + const latest = new Date(); + latest.setDate(latest.getDate() + minLeadDays + windowDays); + return [ + String(earliest.getFullYear()), + ...(latest.getFullYear() !== earliest.getFullYear() + ? [String(latest.getFullYear())] + : []), + ]; +} + +/** + * Picks the earliest date at least `minLeadDays` out that isn't in + * `holidayDates` - a fixed offset alone isn't enough: with ~90 countries + * checked nightly, some will always land on one of their own holidays by + * chance (real data: CYP/CZE/GRC/GEO all hit "cannot be in a holiday" in a + * single schema-canary run). Falls back to the unchecked minimum-lead date + * if nothing in the search window is holiday-free. + */ +export function pickSafeDate( + holidayDates: ReadonlySet, + minLeadDays = SAFE_START_DATE_MIN_LEAD_DAYS, + windowDays = SAFE_START_DATE_SEARCH_WINDOW_DAYS, +): string { + const earliest = new Date(); + earliest.setDate(earliest.getDate() + minLeadDays); + const latest = new Date(); + latest.setDate(latest.getDate() + minLeadDays + windowDays); + + const candidate = new Date(earliest); + while (candidate <= latest) { + const iso = toIsoDate(candidate); + if (!holidayDates.has(iso)) return iso; + candidate.setDate(candidate.getDate() + 1); + } + return toIsoDate(earliest); +} + +/** Progressively fills a JSF schema: fill whatever's required+visible, recompute (new + * conditionally-required fields may appear), repeat until stable. Mirrors what the real + * multi-step form does field-by-field, but against the schema directly instead of the DOM. */ +export function fillSchema( + schema: Record, + seedValues: Record = {}, +): { values: Record; skipped: string[] } { + const values: Record = { ...seedValues }; + const skipped: string[] = []; + for (let round = 0; round < 8; round++) { + const { fields } = createHeadlessForm(schema, { + initialValues: values as $TSFixMe, + }); + const missing = (fields as $TSFixMe[]).filter( + (f) => f.required && f.isVisible && values[f.name] === undefined, + ); + if (missing.length === 0) break; + for (const field of missing) { + if (field.inputType === 'file') { + skipped.push(field.name); + continue; + } + values[field.name] = fakeValueFor(field); + } + } + return { values, skipped }; +} diff --git a/scripts/seed-onboarding.ts b/scripts/seed-onboarding.ts index 2bad25eab..962388797 100644 --- a/scripts/seed-onboarding.ts +++ b/scripts/seed-onboarding.ts @@ -46,39 +46,19 @@ * * npm run seed:onboarding -- --country=DEU --env=sandbox */ -import { createHeadlessForm } from '@remoteoss/remote-json-schema-form-kit'; -import { faker } from '@faker-js/faker'; import dotenv from 'dotenv'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { fillSchema, pickSafeDate, safeStartDateYears } from './fill-schema'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); -type FormSchema = Parameters[0]; -type HeadlessFormOptions = NonNullable< - Parameters[1] ->; -type FormValues = Record; +type FormSchema = Record; type HttpMethod = 'GET' | 'POST'; type AuthHeaders = Record; -interface FieldOption { - value: unknown; - meta?: { countryCode?: string }; -} - -interface SeedField { - name: string; - inputType?: string; - required?: boolean; - isVisible?: boolean; - multiple?: boolean; - options?: FieldOption[]; - const?: unknown; -} - interface ApiOptions { query?: Record; body?: unknown; @@ -205,93 +185,25 @@ async function fetchSchema( }).then((res) => res.data); } -/** First option whose value/label reads as "no" - collapses conditional sub-fields (file - * uploads, free-text detail boxes) that a generic filler can't produce plausible data for. */ -function preferNoOption(options: FieldOption[]): FieldOption { - return ( - options.find((o) => String(o.value).toLowerCase() === 'no') || options[0] - ); -} - -function fakeValueFor(field: SeedField): unknown { - const { inputType, options, multiple, name, const: constValue } = field; - - if (options?.length) { - if (inputType === 'radio' || inputType === 'select') { - return preferNoOption(options).value; - } - if (inputType === 'countries' || multiple) { - return [faker.helpers.arrayElement(options).value]; - } - if (inputType === 'tel') { - // options here are per-country dialing patterns (e.g. pattern: - // '^(+49)[0-9]{6,}$', meta.countryCode: '49'), not user-facing choices - - // any one produces a validly-formatted number, regardless of the - // employment's own country. - const option = options.find((o) => o.meta?.countryCode) || options[0]; - return `+${option.meta?.countryCode}${faker.string.numeric(9)}`; - } - return preferNoOption(options).value; - } - - switch (inputType) { - case 'email': - return name === 'work_email' - ? faker.internet.email({ provider: 'remote-e2e-test.com' }) - : faker.internet.email(); - case 'tel': - return `+1${faker.string.numeric(9)}`; - case 'date': { - const d = new Date(); - d.setDate(d.getDate() + 21); - return d.toISOString().slice(0, 10); - } - case 'number': - return faker.number.int({ min: 5, max: 30 }); - case 'money': - return faker.number.int({ min: 3_000_000, max: 8_000_000 }); - case 'textarea': - return faker.lorem.sentence(); - case 'checkbox': - // No enumerated options: this is a single acknowledgement toggle. RHF - // holds a plain `true` here, which parseFormValuesToAPI (src/components/ - // form/utils.ts) swaps for `field.const` (e.g. "acknowledged") at submit - // time when the schema is const-based - mirror that instead of sending - // the raw RHF value, or the API rejects it as a type mismatch. - return constValue ?? true; - case 'file': - return null; - default: - return faker.lorem.words({ min: 2, max: 4 }); - } -} - -/** Progressively fills a JSF schema: fill whatever's required+visible, recompute (new - * conditionally-required fields may appear), repeat until stable. Mirrors what the real - * multi-step form does field-by-field, but against the schema directly instead of the DOM. */ -function fillSchema( - schema: FormSchema, - seedValues: FormValues = {}, -): { values: FormValues; skipped: string[] } { - const values: FormValues = { ...seedValues }; - const skipped: string[] = []; - for (let round = 0; round < 8; round++) { - const { fields } = createHeadlessForm(schema, { - initialValues: values as HeadlessFormOptions['initialValues'], - }); - const missing = (fields as unknown as SeedField[]).filter( - (f) => f.required && f.isVisible && values[f.name] === undefined, - ); - if (missing.length === 0) break; - for (const field of missing) { - if (field.inputType === 'file') { - skipped.push(field.name); - continue; +/** See pickSafeDate in fill-schema.ts for why a fixed offset alone isn't + * enough - falls back to the unchecked minimum-lead date if the holidays + * lookup itself fails. */ +async function findSafeStartDate(): Promise { + const holidayDates = new Set(); + try { + for (const year of safeStartDateYears()) { + const holidays = await api<{ + data?: { day: string; observed_day?: string }[]; + }>('GET', `/v1/countries/${COUNTRY}/holidays/${year}`); + for (const holiday of holidays.data ?? []) { + holidayDates.add(holiday.day); + if (holiday.observed_day) holidayDates.add(holiday.observed_day); } - values[field.name] = fakeValueFor(field); } + } catch { + return pickSafeDate(new Set()); } - return { values, skipped }; + return pickSafeDate(holidayDates); } async function main() { @@ -300,8 +212,11 @@ async function main() { 'employment_basic_information', BASIC_INFO_VERSION, ); - const { values: basicInformation, skipped: basicSkipped } = - fillSchema(basicInfoSchema); + const startDate = await findSafeStartDate(); + const { values: basicInformation, skipped: basicSkipped } = fillSchema( + basicInfoSchema, + { provisional_start_date: startDate }, + ); console.log( 'basic_information payload:', JSON.stringify(basicInformation, null, 2), From c24baad3455c053b2e951ab776e564cc9b77cf35 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 12:59:19 +0200 Subject: [PATCH 2/5] fix(scripts): keep seed start dates off blocked weekdays and partial holiday lookups Tiger blocks some start weekdays per country (ISL: Friday/Saturday, much of LATAM: Saturday/Sunday) and the public API doesn't expose that table, so pickSafeDate now only returns a Monday-Thursday date. A failed holidays lookup for one year no longer throws away the years that did load; it logs a warning and skips just that year. Co-Authored-By: Claude Opus 5.5 --- scripts/fill-schema.ts | 12 +++++++++--- scripts/seed-onboarding.ts | 12 ++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/scripts/fill-schema.ts b/scripts/fill-schema.ts index b8d8a8ec2..e21ae3f7f 100644 --- a/scripts/fill-schema.ts +++ b/scripts/fill-schema.ts @@ -82,6 +82,7 @@ export function fakeValueFor(field: $TSFixMe) { export const SAFE_START_DATE_MIN_LEAD_DAYS = 35; export const SAFE_START_DATE_SEARCH_WINDOW_DAYS = 60; +const START_WEEKDAYS = new Set([1, 2, 3, 4]); function toIsoDate(date: Date): string { return date.toISOString().slice(0, 10); @@ -110,8 +111,11 @@ export function safeStartDateYears( * `holidayDates` - a fixed offset alone isn't enough: with ~90 countries * checked nightly, some will always land on one of their own holidays by * chance (real data: CYP/CZE/GRC/GEO all hit "cannot be in a holiday" in a - * single schema-canary run). Falls back to the unchecked minimum-lead date - * if nothing in the search window is holiday-free. + * single schema-canary run). Only Monday-Thursday is considered because + * Tiger blocks some weekdays per country (ISL: Friday/Saturday, much of + * LATAM: Saturday/Sunday) and the public API doesn't expose that table. + * Falls back to the unchecked minimum-lead date if nothing in the search + * window qualifies. */ export function pickSafeDate( holidayDates: ReadonlySet, @@ -126,7 +130,9 @@ export function pickSafeDate( const candidate = new Date(earliest); while (candidate <= latest) { const iso = toIsoDate(candidate); - if (!holidayDates.has(iso)) return iso; + if (START_WEEKDAYS.has(candidate.getUTCDay()) && !holidayDates.has(iso)) { + return iso; + } candidate.setDate(candidate.getDate() + 1); } return toIsoDate(earliest); diff --git a/scripts/seed-onboarding.ts b/scripts/seed-onboarding.ts index 962388797..ac7aed7b1 100644 --- a/scripts/seed-onboarding.ts +++ b/scripts/seed-onboarding.ts @@ -186,12 +186,12 @@ async function fetchSchema( } /** See pickSafeDate in fill-schema.ts for why a fixed offset alone isn't - * enough - falls back to the unchecked minimum-lead date if the holidays - * lookup itself fails. */ + * enough - a year whose holidays lookup fails is skipped rather than + * discarding the years that did load. */ async function findSafeStartDate(): Promise { const holidayDates = new Set(); - try { - for (const year of safeStartDateYears()) { + for (const year of safeStartDateYears()) { + try { const holidays = await api<{ data?: { day: string; observed_day?: string }[]; }>('GET', `/v1/countries/${COUNTRY}/holidays/${year}`); @@ -199,9 +199,9 @@ async function findSafeStartDate(): Promise { holidayDates.add(holiday.day); if (holiday.observed_day) holidayDates.add(holiday.observed_day); } + } catch (error) { + console.warn(`Could not load ${year} holidays for ${COUNTRY}:`, error); } - } catch { - return pickSafeDate(new Set()); } return pickSafeDate(holidayDates); } From ef0e15893018f13fe56f04e78073429e28bd9136 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 13:00:47 +0200 Subject: [PATCH 3/5] fix(scripts): restore fill-schema's field types and use UTC for holiday years The extraction swapped SeedField/FieldOption for $TSFixMe and dropped an optional chain on tel option meta. safeStartDateYears now reads the UTC year so it matches the UTC ISO dates pickSafeDate compares against. Co-Authored-By: Claude Opus 5.5 --- scripts/fill-schema.ts | 53 +++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/scripts/fill-schema.ts b/scripts/fill-schema.ts index e21ae3f7f..164583fee 100644 --- a/scripts/fill-schema.ts +++ b/scripts/fill-schema.ts @@ -1,21 +1,36 @@ import { createHeadlessForm } from '@remoteoss/remote-json-schema-form-kit'; import { faker } from '@faker-js/faker'; -import { $TSFixMe } from '@/src/types/remoteFlows'; + +type FormValues = Record; +type HeadlessFormOptions = NonNullable< + Parameters[1] +>; + +export interface FieldOption { + value: unknown; + meta?: { countryCode?: string }; +} + +export interface SeedField { + name: string; + inputType?: string; + required?: boolean; + isVisible?: boolean; + multiple?: boolean; + options?: FieldOption[]; + const?: unknown; +} /** First option whose value/label reads as "no" - collapses conditional sub-fields (file * uploads, free-text detail boxes) that a generic filler can't produce plausible data for. */ -export function preferNoOption(options: $TSFixMe[]) { +export function preferNoOption(options: FieldOption[]): FieldOption { return ( options.find((o) => String(o.value).toLowerCase() === 'no') || options[0] ); } -export function fakeValueFor(field: $TSFixMe) { - const inputType = field.inputType; - const options: $TSFixMe[] | undefined = field.options; - const multiple = field.multiple; - const name = field.name; - const constValue = field.const; +export function fakeValueFor(field: SeedField): unknown { + const { inputType, options, multiple, name, const: constValue } = field; if (options?.length) { if (inputType === 'radio' || inputType === 'select') { @@ -30,7 +45,7 @@ export function fakeValueFor(field: $TSFixMe) { // any one produces a validly-formatted number, regardless of the // employment's own country. const option = options.find((o) => o.meta?.countryCode) || options[0]; - return `+${option.meta.countryCode}${faker.string.numeric(9)}`; + return `+${option.meta?.countryCode}${faker.string.numeric(9)}`; } return preferNoOption(options).value; } @@ -46,7 +61,7 @@ export function fakeValueFor(field: $TSFixMe) { // Some countries require more lead time than a fixed short offset // covers (e.g. Iceland: 20 working days, ~28 calendar days) - 35 // calendar days clears that with margin. Doesn't dodge country-specific - // holidays (e.g. Georgia) on its own; seed-employment.ts's + // holidays (e.g. Georgia) on its own; seed-onboarding.ts's // findSafeStartDate seeds provisional_start_date directly for that. const d = new Date(); d.setDate(d.getDate() + 35); @@ -99,9 +114,9 @@ export function safeStartDateYears( const latest = new Date(); latest.setDate(latest.getDate() + minLeadDays + windowDays); return [ - String(earliest.getFullYear()), - ...(latest.getFullYear() !== earliest.getFullYear() - ? [String(latest.getFullYear())] + String(earliest.getUTCFullYear()), + ...(latest.getUTCFullYear() !== earliest.getUTCFullYear() + ? [String(latest.getUTCFullYear())] : []), ]; } @@ -142,16 +157,16 @@ export function pickSafeDate( * conditionally-required fields may appear), repeat until stable. Mirrors what the real * multi-step form does field-by-field, but against the schema directly instead of the DOM. */ export function fillSchema( - schema: Record, - seedValues: Record = {}, -): { values: Record; skipped: string[] } { - const values: Record = { ...seedValues }; + schema: Parameters[0], + seedValues: FormValues = {}, +): { values: FormValues; skipped: string[] } { + const values: FormValues = { ...seedValues }; const skipped: string[] = []; for (let round = 0; round < 8; round++) { const { fields } = createHeadlessForm(schema, { - initialValues: values as $TSFixMe, + initialValues: values as HeadlessFormOptions['initialValues'], }); - const missing = (fields as $TSFixMe[]).filter( + const missing = (fields as unknown as SeedField[]).filter( (f) => f.required && f.isVisible && values[f.name] === undefined, ); if (missing.length === 0) break; From 12dcd3c520dfccae10837684d5cc58776c451495 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 13:06:32 +0200 Subject: [PATCH 4/5] fix(scripts): go back to lorem words for the seeded job_title "Software Engineer" was added on the assumption that job title eligibility checks reject lorem titles, but the failures that prompted it had a different cause. Seeding PRT, ISL, DEU, BRA, GBR, USA, FRA, ESP, NLD, IND, MEX and JPN in sandbox with lorem job titles all succeed. Co-Authored-By: Claude Opus 5.5 --- scripts/fill-schema.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/scripts/fill-schema.ts b/scripts/fill-schema.ts index 164583fee..b80879bf7 100644 --- a/scripts/fill-schema.ts +++ b/scripts/fill-schema.ts @@ -83,15 +83,7 @@ export function fakeValueFor(field: SeedField): unknown { case 'file': return null; default: - // job_title is screened by some countries against an eligibility list - // that appears to gate on regulated/restricted categories rather than - // "does this look like a real title" - lorem-ipsum gibberish and even - // faker.person.jobTitle() (e.g. "Business Systems Officer") both got - // rejected. "Software Engineer" is a common, unregulated role that - // clears eligibility checks broadly. - return name === 'job_title' - ? 'Software Engineer' - : faker.lorem.words({ min: 2, max: 4 }); + return faker.lorem.words({ min: 2, max: 4 }); } } From 3a624d8aeb2fa0be48a7b37b91d28cee14d56657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Garc=C3=ADa=20Seco?= Date: Wed, 7 Oct 2026 07:52:56 +0200 Subject: [PATCH 5/5] feat(scripts): seed contractor employments with seed:onboarding --type=contractor (#1455) * feat(scripts): seed contractor employments with seed:onboarding --type=contractor Fills contractor_basic_information and creates a contractor employment, the same as ContractorOnboarding's basic_information step. GBR gets ir_35 and the Gulf countries get nationality on the contractor contract document, because those fields are added client-side and saved there, not in basic_information. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(example): prefill contractor onboarding employment ID from ?employmentId= Mirrors the employee onboarding demo so the link printed by seed:onboarding --type=contractor loads the seeded employment. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- example/src/ContractorOnboarding.tsx | 5 +- scripts/seed-onboarding.ts | 104 ++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/example/src/ContractorOnboarding.tsx b/example/src/ContractorOnboarding.tsx index e340fc1c8..8eecbaf3d 100644 --- a/example/src/ContractorOnboarding.tsx +++ b/example/src/ContractorOnboarding.tsx @@ -31,6 +31,7 @@ import { import Flag from 'react-flagpack'; import React, { useState } from 'react'; import { RemoteFlows } from './RemoteFlows'; +import { getUrlParam } from './utils/urlState'; import { AlertError } from './AlertError'; import { ReviewContractorOnboardingStep } from './ReviewContractorOnboardingStep'; import { PricingPlanCard } from './components/PricingPlanCard'; @@ -821,7 +822,9 @@ export const ContractorOnboardingWithProps = ({ export const ContractorOnboardingForm = () => { const [formData, setFormData] = useState({ employmentId: - import.meta.env.VITE_CONTRACTOR_MANAGEMENT_EMPLOYMENT_ID || '', // use your own employment ID + getUrlParam('employmentId') || + import.meta.env.VITE_CONTRACTOR_MANAGEMENT_EMPLOYMENT_ID || + '', // use your own employment ID externalId: '', partnerExternalId: '', }); diff --git a/scripts/seed-onboarding.ts b/scripts/seed-onboarding.ts index ac7aed7b1..ab59549a0 100644 --- a/scripts/seed-onboarding.ts +++ b/scripts/seed-onboarding.ts @@ -31,6 +31,17 @@ * npm run seed:onboarding -- --country=DEU * npm run seed:onboarding -- --country=ESP --basic-info-version=4 * + * Pass --type=contractor to instead create a contractor employment from the + * contractor_basic_information schema and stop there, mirroring the + * basic_information step of src/flows/ContractorOnboarding/hooks.tsx. + * ir35 (GBR) and nationality_status (SAU/KWT/OMN/QAT/BHR) aren't in that + * schema - ContractorOnboarding's jsfModify adds them client-side and the + * hook writes them to the contractor contract document after creating the + * employment - so this does the same with fixed answers that avoid the IR35 + * SDS upload and the non-national warning. + * + * npm run seed:onboarding -- --country=GBR --type=contractor --env=sandbox + * * By default this proxies through a locally running `example` dev server * (BASE_URL, `example/.env`'s VITE_REMOTE_GATEWAY decides which gateway that * is - easy to lose track of). @@ -89,7 +100,13 @@ const stringArg = (key: string): string | undefined => { return typeof value === 'string' ? value : undefined; }; const COUNTRY = (stringArg('country') || 'DEU').toUpperCase(); -const BASIC_INFO_VERSION = Number(stringArg('basic-info-version') || 4); +const TYPE = stringArg('type') || 'employee'; +if (TYPE !== 'employee' && TYPE !== 'contractor') { + throw new Error(`--type must be employee or contractor (got ${TYPE}).`); +} +const BASIC_INFO_VERSION = Number( + stringArg('basic-info-version') || (TYPE === 'contractor' ? 1 : 4), +); if ('env' in args && stringArg('env') === undefined) { throw new Error( '--env requires a value, e.g. --env=sandbox (got a bare --env flag).', @@ -206,7 +223,86 @@ async function findSafeStartDate(): Promise { return pickSafeDate(holidayDates); } -async function main() { +const CONTRACTOR_NATIONALITY_COUNTRIES = ['SAU', 'KWT', 'OMN', 'QAT', 'BHR']; + +function contractorContractDocumentSeed(): Record | undefined { + if (COUNTRY === 'GBR') return { ir_35: 'exempt' }; + if (CONTRACTOR_NATIONALITY_COUNTRIES.includes(COUNTRY)) { + return { nationality: 'national' }; + } + return undefined; +} + +async function seedContractor() { + console.log(`Fetching contractor_basic_information schema for ${COUNTRY}...`); + const basicInfoSchema = await fetchSchema( + 'contractor_basic_information', + BASIC_INFO_VERSION, + ); + const startDate = await findSafeStartDate(); + const { values: basicInformation, skipped } = fillSchema(basicInfoSchema, { + provisional_start_date: startDate, + }); + console.log( + 'basic_information payload:', + JSON.stringify(basicInformation, null, 2), + ); + if (skipped.length) { + console.log('Skipped (unfillable) fields:', skipped.join(', ')); + } + + console.log('\nCreating contractor employment...'); + const created = await api<{ data?: { employment?: { id?: string } } }>( + 'POST', + '/v1/employments', + { + query: { json_schema_version: BASIC_INFO_VERSION }, + body: { + basic_information: basicInformation, + type: 'contractor', + country_code: COUNTRY, + }, + }, + ); + const employmentId = created?.data?.employment?.id; + if (!employmentId) { + throw new Error( + `Could not find employment id in response: ${JSON.stringify(created)}`, + ); + } + console.log(`Employment created: ${employmentId}`); + + const contractDocument = contractorContractDocumentSeed(); + if (contractDocument) { + console.log( + `\nSetting ${Object.keys(contractDocument).join(', ')} on the contract document...`, + ); + await api( + 'POST', + `/v1/contractors/employments/${employmentId}/contract-documents`, + { body: { contract_document: contractDocument } }, + ); + } + + console.log( + `\nDone. Contractor employment ${employmentId} for ${COUNTRY} has basic_information filled in.`, + ); + const appUrl = process.env.VITE_APP_URL || (ENV ? undefined : BASE_URL); + if (appUrl) { + console.log( + `\nOpen this link (Employment ID is prefilled via ?employmentId=):\n\n` + + ` ${appUrl}/?demo=contract-onboarding&employmentId=${employmentId}\n`, + ); + } else { + console.log( + `\nOpen your app's contractor onboarding demo and enter this Employment ID ` + + `(?employmentId=${employmentId} also works as a query param).\n` + + `Tip: set VITE_APP_URL= in .env.${ENV} to get a ready-to-click link next time.`, + ); + } +} + +async function seedEmployee() { console.log(`Fetching employment_basic_information schema for ${COUNTRY}...`); const basicInfoSchema = await fetchSchema( 'employment_basic_information', @@ -311,6 +407,10 @@ async function main() { } } +function main() { + return TYPE === 'contractor' ? seedContractor() : seedEmployee(); +} + main().catch((err: unknown) => { console.error('\nFailed:', err instanceof Error ? err.message : err); if (err instanceof ApiError && err.body) {