diff --git a/src/common/createHeadlessForm.tsx b/src/common/createHeadlessForm.tsx index 5ade854ff..f0eef45da 100644 --- a/src/common/createHeadlessForm.tsx +++ b/src/common/createHeadlessForm.tsx @@ -23,9 +23,7 @@ export const createHeadlessForm = ( fieldValues?: FieldValues, options?: { jsfModify?: JSFModify; transformMoneyFields?: boolean }, ): JSONSchemaFormResultWithFieldsets => { - const { transformMoneyFields } = options || { - transformMoneyFields: true, - }; + const transformMoneyFields = options?.transformMoneyFields ?? true; if (options && options.jsfModify) { const { required, allOf, ...modifyConfig } = options.jsfModify; // muteLogging: true suppresses the generic library log; we surface the diff --git a/src/common/invoice-schedules/api.ts b/src/common/invoice-schedules/api.ts index 1732f461c..cf8665b6c 100644 --- a/src/common/invoice-schedules/api.ts +++ b/src/common/invoice-schedules/api.ts @@ -86,15 +86,7 @@ export const useGetCreateInvoiceScheduleSchema = ({ includeCustomDays, }); - // `transformMoneyFields` has to be explicit: `createHeadlessForm` only defaults it on - // when no options object is passed at all, so passing `jsfModify` alone silently opted - // out. Without it the item amounts stay in the money input's major units — a decimal as - // soon as an amount has cents — and never satisfy the `integer` the row-reveal - // conditional matches on, which froze the form at a single item for any such amount. - return createHeadlessForm(schema, fieldValues, { - jsfModify, - transformMoneyFields: true, - }); + return createHeadlessForm(schema, fieldValues, { jsfModify }); }, [ enabled, currencies, diff --git a/src/common/tests/moneyEngineContract.test.ts b/src/common/tests/moneyEngineContract.test.ts new file mode 100644 index 000000000..539035482 --- /dev/null +++ b/src/common/tests/moneyEngineContract.test.ts @@ -0,0 +1,230 @@ +import { FieldValues } from 'react-hook-form'; +import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import { + checkFieldHasForcedValue, + parseJSFToValidate, + parseSubmitValues, +} from '@/src/components/form/utils'; +import { JSONSchemaFormResultWithFieldsets } from '@/src/flows/types'; +import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; +import { $TSFixMe } from '@/src/types/remoteFlows'; + +type Schema = Record; + +type FormSession = { + render: (values: FieldValues) => Promise<$TSFixMe[]>; + validate: (values: FieldValues) => Promise<{ + fields: $TSFixMe[]; + errors: Record | undefined; + }>; +}; + +const rebuildSession = + (options?: Parameters[2]) => + (schema: Schema): FormSession => { + let form: JSONSchemaFormResultWithFieldsets; + return { + render: async (values) => { + form = createHeadlessForm(schema, values, options); + return form.fields; + }, + validate: async (values) => { + const { formErrors } = form.handleValidation( + await parseJSFToValidate(values, form.fields), + ); + return { fields: form.fields, errors: formErrors }; + }, + }; + }; + +const buildOnceSession = (schema: Schema): FormSession => { + const form = createHeadlessForm(schema, {}, { transformMoneyFields: false }); + const validate = async (values: FieldValues) => { + const { formErrors } = form.handleValidation( + await parseJSFToValidate(values, form.fields, { + isPartialValidation: true, + }), + ); + return { fields: form.fields, errors: formErrors }; + }; + return { + render: async (values) => (await validate(values)).fields, + validate, + }; +}; + +const ENGINES = [ + { engine: 'jsf v0', meta: { jsfOldVersion: true } }, + { engine: 'jsf v1', meta: { jsfVersion: '1' } }, + { engine: 'no meta', meta: undefined }, +]; + +const STRATEGIES = [ + { strategy: 'rebuild without options', start: rebuildSession() }, + { + strategy: 'rebuild with jsfModify', + start: rebuildSession({ jsfModify: {} }), + }, + { strategy: 'build once + handleValidation', start: buildOnceSession }, +]; + +const forcedValues = (fields: $TSFixMe[]) => + Object.fromEntries( + fields + .filter((field) => field.isVisible && checkFieldHasForcedValue(field)) + .map((field) => [field.name, field.const]), + ); + +const fillForm = async (session: FormSession, typed: FieldValues) => { + let values = typed; + let rendered = await session.render(values); + for (let pass = 0; pass < 2; pass++) { + values = { ...typed, ...forcedValues(rendered) }; + rendered = await session.render(values); + } + const renderedAllowance = rendered.find( + (field) => field.name === 'working_hours_exemption_allowance', + ); + const shown = { + allowance: renderedAllowance?.const, + description: renderedAllowance?.statement?.description, + }; + const { fields, errors } = await session.validate(values); + return { + shown, + errors, + submitted: await parseSubmitValues(values, fields), + }; +}; + +const { 'x-rmt-meta': _meta, ...portugalSchema } = + contractDetailsSchemaV1Portugal.data; + +const extendedHours = { + annual_gross_salary: 71703.77, + work_schedule: 'full_time', + working_hours_exemption: 'yes', + maximum_working_hours_regime: 'yes', +}; + +describe.each(ENGINES)('money contract on $engine', ({ meta }) => { + const schema = (): Schema => + structuredClone( + meta ? { ...portugalSchema, 'x-rmt-meta': meta } : portugalSchema, + ); + + describe.each(STRATEGIES)('$strategy', ({ start }) => { + it.each([ + { regime: 'yes', cents: 81257, label: '812.57' }, + { regime: 'no', cents: 29548, label: '295.48' }, + ])( + 'computes and submits the forced money allowance in cents (regime $regime)', + async ({ regime, cents, label }) => { + const { shown, submitted } = await fillForm(start(schema()), { + ...extendedHours, + maximum_working_hours_regime: regime, + }); + + expect(shown.allowance).toBe(cents); + expect(shown.description).toContain(`additional ${label} EUR monthly`); + expect(submitted.working_hours_exemption_allowance).toBe(cents); + expect(submitted.annual_gross_salary).toBe(7170377); + }, + ); + + it('computes the allowance from the forced 40 hours when none were typed', async () => { + const { shown, submitted } = await fillForm( + start(schema()), + extendedHours, + ); + + expect(submitted.work_hours_per_week).toBe(40); + expect(shown.allowance).toBe(81257); + expect(submitted.working_hours_exemption_allowance).toBe(81257); + }); + + it('computes the allowance from typed part-time hours', async () => { + const { submitted } = await fillForm(start(schema()), { + ...extendedHours, + work_schedule: 'part_time', + work_hours_per_week: 20, + part_time_salary_confirmation: 'acknowledged', + }); + + expect(submitted.work_hours_per_week).toBe(20); + expect(submitted.working_hours_exemption_allowance).toBe(162515); + }); + + it('recomputes the allowance on the same form when the regime changes', async () => { + const session = start(schema()); + + const first = await fillForm(session, extendedHours); + const second = await fillForm(session, { + ...extendedHours, + maximum_working_hours_regime: 'no', + }); + const third = await fillForm(session, extendedHours); + + expect([ + first.submitted.working_hours_exemption_allowance, + second.submitted.working_hours_exemption_allowance, + third.submitted.working_hours_exemption_allowance, + ]).toEqual([81257, 29548, 81257]); + }); + + it('drops the allowance once extended hours are turned off', async () => { + const session = start(schema()); + await fillForm(session, extendedHours); + + const { submitted } = await fillForm(session, { + ...extendedHours, + working_hours_exemption: 'no', + }); + + expect(submitted).not.toHaveProperty('working_hours_exemption_allowance'); + expect(submitted).not.toHaveProperty('maximum_working_hours_regime'); + }); + + it.each([ + { salary: 12179.99, hasError: true }, + { salary: 12180, hasError: false }, + ])( + 'validates the full-time minimum salary in cents ($salary)', + async ({ salary, hasError }) => { + const { errors } = await fillForm(start(schema()), { + ...extendedHours, + annual_gross_salary: salary, + }); + + expect(Boolean(errors?.annual_gross_salary)).toBe(hasError); + }, + ); + + it('submits a conditional nullable money field in cents, and leaves it out when empty', async () => { + const withBonus = { ...extendedHours, has_signing_bonus: 'yes' }; + + const filled = await fillForm(start(schema()), { + ...withBonus, + signing_bonus_amount: 1000.5, + }); + const empty = await fillForm(start(schema()), { + ...withBonus, + signing_bonus_amount: '', + }); + + expect(filled.submitted.signing_bonus_amount).toBe(100050); + expect(empty.submitted).not.toHaveProperty('signing_bonus_amount'); + }); + + it('submits checked acknowledgement checkboxes as their const', async () => { + const { submitted } = await fillForm(start(schema()), { + ...extendedHours, + salary_installments_confirmation: true, + contract_duration_type: true, + }); + + expect(submitted.salary_installments_confirmation).toBe('acknowledged'); + expect(submitted.contract_duration_type).toBe('indefinite'); + }); + }); +}); diff --git a/src/flows/Onboarding/tests/OnboardingFlow.test.tsx b/src/flows/Onboarding/tests/OnboardingFlow.test.tsx index 0442d12b8..3328b96cb 100644 --- a/src/flows/Onboarding/tests/OnboardingFlow.test.tsx +++ b/src/flows/Onboarding/tests/OnboardingFlow.test.tsx @@ -761,6 +761,94 @@ describe('OnboardingFlow', () => { await screen.findByText(/Step: Benefits/i); }); + it.each([ + { + maximumWorkingHoursRegime: 'yes', + allowanceInCents: 81257, + allowanceLabel: '812.57', + }, + { + maximumWorkingHoursRegime: 'no', + allowanceInCents: 29548, + allowanceLabel: '295.48', + }, + ])( + 'should submit the computed extended work hours allowance in cents (maximum_working_hours_regime: $maximumWorkingHoursRegime)', + async ({ maximumWorkingHoursRegime, allowanceInCents, allowanceLabel }) => { + // Served without x-rmt-meta, like PRT contract details v3, so the kit evaluates it with + // the jsf v1 engine. The allowance is a forced money value computed from the salary. + const { 'x-rmt-meta': _meta, ...schemaWithoutMeta } = + contractDetailsSchemaV1Portugal.data; + + server.use( + http.get('*/v1/countries/PRT/contract_details*', () => { + return HttpResponse.json({ data: schemaWithoutMeta }); + }), + http.get('*/v1/employments/:id', ({ params }) => { + return HttpResponse.json({ + ...employmentDefaultResponse, + data: { + ...employmentDefaultResponse.data, + employment: { + ...employmentDefaultResponse.data.employment, + id: params.id, + contract_details: { + ...employmentDefaultResponse.data.employment.contract_details, + annual_gross_salary: 7170377, + working_hours_exemption: 'yes', + maximum_working_hours_regime: maximumWorkingHoursRegime, + }, + }, + }, + }); + }), + ); + + render( + , + { + wrapper: TestProviders, + }, + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('spinner')); + + await fillCountry('PRT'); + + await waitFor(() => { + expect(screen.getByLabelText(/Personal email/i)).toBeInTheDocument(); + }); + + screen.getByText(/Next Step/i).click(); + + await screen.findByText(/Step: Contract Details/i); + + await screen.findByText( + new RegExp(`additional ${allowanceLabel} EUR monthly`, 'i'), + ); + + screen.getByText(/Next Step/i).click(); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledTimes(3); + }); + + expect(mockOnSubmit.mock.calls[2][0]).toEqual( + expect.objectContaining({ + annual_gross_salary: 7170377, + working_hours_exemption: 'yes', + maximum_working_hours_regime: maximumWorkingHoursRegime, + working_hours_exemption_allowance: allowanceInCents, + }), + ); + + await screen.findByText(/Step: Benefits/i); + }, + ); + it('should show validation errors when submitting benefits step without selecting any benefits', async () => { server.use( http.get('*/v1/employments/*/benefit-offers', () => { diff --git a/src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx b/src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx new file mode 100644 index 000000000..408c96eb7 --- /dev/null +++ b/src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx @@ -0,0 +1,64 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { useJSONSchemaForm } from '@/src/flows/Onboarding/api'; +import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures'; +import { server } from '@/src/tests/server'; +import { queryClient, TestProviders } from '@/src/tests/testHelpers'; +import { $TSFixMe } from '@/src/types/remoteFlows'; + +const findField = (fields: $TSFixMe[] = [], name: string): $TSFixMe => + fields.reduce<$TSFixMe>( + (found, field) => + found ?? (field.name === name ? field : findField(field.fields, name)), + undefined, + ); + +describe('Onboarding useJSONSchemaForm money forced values', () => { + beforeEach(() => { + queryClient.clear(); + + const { 'x-rmt-meta': _meta, ...schemaWithoutMeta } = + contractDetailsSchemaV1Portugal.data; + + server.use( + http.get('*/v1/countries/PRT/contract_details*', () => { + return HttpResponse.json({ data: schemaWithoutMeta }); + }), + ); + }); + + it.each([ + { maximumWorkingHoursRegime: 'yes', allowanceInCents: 81257 }, + { maximumWorkingHoursRegime: 'no', allowanceInCents: 29548 }, + ])( + 'computes the allowance from the salary in cents (maximum_working_hours_regime: $maximumWorkingHoursRegime)', + async ({ maximumWorkingHoursRegime, allowanceInCents }) => { + const { result } = renderHook( + () => + useJSONSchemaForm({ + countryCode: 'PRT', + form: 'contract_details', + fieldValues: { + annual_gross_salary: 71703.77, + work_hours_per_week: 40, + working_hours_exemption: 'yes', + maximum_working_hours_regime: maximumWorkingHoursRegime, + }, + options: { queryOptions: { enabled: true } }, + }), + { wrapper: TestProviders }, + ); + + await waitFor(() => { + expect(result.current.data).toBeDefined(); + }); + + const allowance = findField( + result.current.data?.fields, + 'working_hours_exemption_allowance', + ); + + expect(allowance?.const).toBe(allowanceInCents); + }, + ); +});