From 8e4776f9b7e0fc905549353442c38de3d466ba96 Mon Sep 17 00:00:00 2001 From: Andrea Maria Piana Date: Tue, 29 Sep 2026 18:32:29 +0100 Subject: [PATCH 01/10] fix(onboarding): submit computed money forced values in the right unit Portugal's extended work hours allowance is a forced money value computed from the salary. Onboarding built its render-time form without converting money fields to cents, so the allowance was computed from the salary in major units (100x too small), and the forced value was then written into the form state in cents while the form keeps money in major units. Validation recomputed the correct value and rejected the submission on a field with no input to show the error, blocking the step. - Onboarding's useJSONSchemaForm passes transformMoneyFields: true, since createHeadlessForm only defaults it on when no options are passed. - Forced money values are converted from cents before being set in the form. Co-Authored-By: Claude Opus 5.5 --- src/components/form/JSONSchemaForm.tsx | 8 +- src/components/form/fields/FieldSetField.tsx | 7 +- src/components/form/tests/utils.test.ts | 14 +++ src/components/form/utils.ts | 13 +++ src/flows/Onboarding/api.ts | 9 +- .../Onboarding/tests/OnboardingFlow.test.tsx | 88 +++++++++++++++++++ 6 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 src/components/form/tests/utils.test.ts diff --git a/src/components/form/JSONSchemaForm.tsx b/src/components/form/JSONSchemaForm.tsx index 515a73752..f2e8f2dc1 100644 --- a/src/components/form/JSONSchemaForm.tsx +++ b/src/components/form/JSONSchemaForm.tsx @@ -12,7 +12,11 @@ import { $TSFixMe, } from '@/src/types/remoteFlows'; import { StatementComponentProps } from '@/src/types/fields'; -import { checkFieldHasForcedValue, getFieldsWithFlatFieldsets } from './utils'; +import { + checkFieldHasForcedValue, + getFieldsWithFlatFieldsets, + getForcedValue, +} from './utils'; type JSONSchemaFormFieldsProps = { fields: JSFFields; @@ -75,7 +79,7 @@ export const JSONSchemaFormFields = ({ { + it('converts a money const from cents to the major units kept in the form', () => { + expect(getForcedValue({ type: 'money', const: 81257 })).toBe(812.57); + }); + + it('returns the const unchanged for other field types', () => { + expect(getForcedValue({ type: 'number', const: 40 })).toBe(40); + expect(getForcedValue({ type: 'text', const: 'acknowledged' })).toBe( + 'acknowledged', + ); + }); +}); diff --git a/src/components/form/utils.ts b/src/components/form/utils.ts index e4fca9ffc..78ba42ad4 100644 --- a/src/components/form/utils.ts +++ b/src/components/form/utils.ts @@ -871,3 +871,16 @@ export function checkFieldHasForcedValue(field: $TSFixMe) { field.type !== 'hidden' // Because hidden inputs shouldn't be visible ); } + +/** + * The value a forced value field should hold in the form state. + * The schema's `const` is in API units, but money fields are kept in major units in the + * form and converted to cents on submit, so a money `const` has to be converted back. + */ +export function getForcedValue(field: $TSFixMe) { + if (field.type === supportedTypes.MONEY) { + return convertFromCents(field.const); + } + + return field.const; +} diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index 99ea7fc77..823283564 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -247,7 +247,14 @@ export const useJSONSchemaForm = ({ enabled: options?.queryOptions?.enabled, select: ({ data }) => { const jsfSchema = data?.data || {}; - return createHeadlessForm(jsfSchema, fieldValues, options); + // `transformMoneyFields` has to be explicit: `createHeadlessForm` only defaults it on + // when no options object is passed at all. Without it the schema's conditionals and + // computed values see money fields in major units instead of the cents they are written + // for, e.g. PRT's extended work hours allowance came out 100x too small. + return createHeadlessForm(jsfSchema, fieldValues, { + ...options, + transformMoneyFields: true, + }); }, }); }; 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', () => { From ccf0275c44669d2898f29f3eef1bc45275e37140 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 20:20:39 +0200 Subject: [PATCH 02/10] refactor(forms): default transformMoneyFields per key Default transformMoneyFields to true whenever it's not explicitly set, instead of only when the whole options object is missing. Callers that pass options without transformMoneyFields (e.g. only jsfModify) now get money fields converted to cents too. Co-Authored-By: Claude Opus 5.5 --- src/common/createHeadlessForm.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 From 77136fcb56305df1f645993a0d3b05bec88e2d3b Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 20:21:15 +0200 Subject: [PATCH 03/10] refactor(invoice-schedules): drop redundant money flag Co-Authored-By: Claude Opus 5.5 --- src/common/invoice-schedules/api.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) 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, From c28068dfae7020358aebe96f0a7230fa71782ebf Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 21:59:28 +0200 Subject: [PATCH 04/10] test(onboarding): cover money forced values on the legacy contract details path The flow regression test only covers the legacy path while PRT is outside JSF_V1_CONTRACT_DETAILS_COUNTRIES. These test the Onboarding useJSONSchemaForm hook and JSONSchemaFormFields directly, so they keep covering the fix whichever countries are on v1. Co-Authored-By: Claude Opus 5.5 --- .../tests/JSONSchemaFormForcedValue.test.tsx | 66 +++++++++++++++++++ ...useJSONSchemaFormMoneyForcedValue.test.tsx | 64 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/components/form/tests/JSONSchemaFormForcedValue.test.tsx create mode 100644 src/flows/Onboarding/tests/useJSONSchemaFormMoneyForcedValue.test.tsx diff --git a/src/components/form/tests/JSONSchemaFormForcedValue.test.tsx b/src/components/form/tests/JSONSchemaFormForcedValue.test.tsx new file mode 100644 index 000000000..a870812b3 --- /dev/null +++ b/src/components/form/tests/JSONSchemaFormForcedValue.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; +import { ForcedValueComponentProps } from '@/src/types/fields'; + +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(() => ({ + components: { + forcedValue: ({ fieldData }: ForcedValueComponentProps) => ( +

{`Forced value: ${fieldData.value}`}

+ ), + }, + })), + }; +}); + +const schema = { + type: 'object', + properties: { + allowance: { + title: 'Allowance', + type: 'integer', + const: 81257, + default: 81257, + 'x-jsf-presentation': { + inputType: 'money', + currency: 'EUR', + statement: { + title: 'Allowance', + description: 'You need to pay an allowance', + }, + }, + }, + }, + 'x-jsf-order': ['allowance'], +}; + +describe('JSONSchemaForm - money forced value', () => { + it('holds a money forced value in major units, like any other money field', async () => { + const form = createHeadlessForm(schema, {}); + let latestValues: Record = {}; + + const TestComponent = () => { + const methods = useForm(); + latestValues = methods.watch(); + + return ( + + + + ); + }; + + render(); + + await screen.findByText('Forced value: 812.57'); + + await waitFor(() => { + expect(latestValues).toEqual({ allowance: 812.57 }); + }); + }); +}); 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); + }, + ); +}); From 6896273a5b95ac9db85183904450e1c1d537f77a Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 22:07:00 +0200 Subject: [PATCH 05/10] refactor(form): drop getForcedValue Submitting a forced value already uses the field's `const` in cents (parseFormValuesToAPI overwrites the form value with it), and the default forced value component doesn't render `value`. The `transformMoneyFields` change alone fixes the PRT allowance; getForcedValue would only change what custom forcedValue components receive in `fieldData.value`. Co-Authored-By: Claude Opus 5.5 --- src/components/form/JSONSchemaForm.tsx | 8 +-- src/components/form/fields/FieldSetField.tsx | 7 +- .../tests/JSONSchemaFormForcedValue.test.tsx | 66 ------------------- src/components/form/tests/utils.test.ts | 14 ---- src/components/form/utils.ts | 13 ---- 5 files changed, 4 insertions(+), 104 deletions(-) delete mode 100644 src/components/form/tests/JSONSchemaFormForcedValue.test.tsx delete mode 100644 src/components/form/tests/utils.test.ts diff --git a/src/components/form/JSONSchemaForm.tsx b/src/components/form/JSONSchemaForm.tsx index f2e8f2dc1..515a73752 100644 --- a/src/components/form/JSONSchemaForm.tsx +++ b/src/components/form/JSONSchemaForm.tsx @@ -12,11 +12,7 @@ import { $TSFixMe, } from '@/src/types/remoteFlows'; import { StatementComponentProps } from '@/src/types/fields'; -import { - checkFieldHasForcedValue, - getFieldsWithFlatFieldsets, - getForcedValue, -} from './utils'; +import { checkFieldHasForcedValue, getFieldsWithFlatFieldsets } from './utils'; type JSONSchemaFormFieldsProps = { fields: JSFFields; @@ -79,7 +75,7 @@ export const JSONSchemaFormFields = ({ { - const actual = await importOriginal(); - return { - ...actual, - useFormFields: vi.fn(() => ({ - components: { - forcedValue: ({ fieldData }: ForcedValueComponentProps) => ( -

{`Forced value: ${fieldData.value}`}

- ), - }, - })), - }; -}); - -const schema = { - type: 'object', - properties: { - allowance: { - title: 'Allowance', - type: 'integer', - const: 81257, - default: 81257, - 'x-jsf-presentation': { - inputType: 'money', - currency: 'EUR', - statement: { - title: 'Allowance', - description: 'You need to pay an allowance', - }, - }, - }, - }, - 'x-jsf-order': ['allowance'], -}; - -describe('JSONSchemaForm - money forced value', () => { - it('holds a money forced value in major units, like any other money field', async () => { - const form = createHeadlessForm(schema, {}); - let latestValues: Record = {}; - - const TestComponent = () => { - const methods = useForm(); - latestValues = methods.watch(); - - return ( - - - - ); - }; - - render(); - - await screen.findByText('Forced value: 812.57'); - - await waitFor(() => { - expect(latestValues).toEqual({ allowance: 812.57 }); - }); - }); -}); diff --git a/src/components/form/tests/utils.test.ts b/src/components/form/tests/utils.test.ts deleted file mode 100644 index 5ffdd1ef4..000000000 --- a/src/components/form/tests/utils.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getForcedValue } from '@/src/components/form/utils'; - -describe('getForcedValue', () => { - it('converts a money const from cents to the major units kept in the form', () => { - expect(getForcedValue({ type: 'money', const: 81257 })).toBe(812.57); - }); - - it('returns the const unchanged for other field types', () => { - expect(getForcedValue({ type: 'number', const: 40 })).toBe(40); - expect(getForcedValue({ type: 'text', const: 'acknowledged' })).toBe( - 'acknowledged', - ); - }); -}); diff --git a/src/components/form/utils.ts b/src/components/form/utils.ts index 78ba42ad4..e4fca9ffc 100644 --- a/src/components/form/utils.ts +++ b/src/components/form/utils.ts @@ -871,16 +871,3 @@ export function checkFieldHasForcedValue(field: $TSFixMe) { field.type !== 'hidden' // Because hidden inputs shouldn't be visible ); } - -/** - * The value a forced value field should hold in the form state. - * The schema's `const` is in API units, but money fields are kept in major units in the - * form and converted to cents on submit, so a money `const` has to be converted back. - */ -export function getForcedValue(field: $TSFixMe) { - if (field.type === supportedTypes.MONEY) { - return convertFromCents(field.const); - } - - return field.const; -} From 2d2809d17acb042ea8fc68917d691a8311da8f8d Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 22:11:23 +0200 Subject: [PATCH 06/10] test(form): pin that forced values are submitted as their const Correct submission of forced money values relies on parseFormValuesToAPI overwriting the form value with the field's const. Covers a top-level field and one nested in a fieldset; both fail with 100x the amount without it. Co-Authored-By: Claude Opus 5.5 --- .../form/tests/parseSubmitValues.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/components/form/tests/parseSubmitValues.test.ts diff --git a/src/components/form/tests/parseSubmitValues.test.ts b/src/components/form/tests/parseSubmitValues.test.ts new file mode 100644 index 000000000..2c287dde9 --- /dev/null +++ b/src/components/form/tests/parseSubmitValues.test.ts @@ -0,0 +1,50 @@ +import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import { parseSubmitValues } from '@/src/components/form/utils'; + +const forcedAllowance = { + title: 'Allowance', + type: 'integer', + const: 81257, + default: 81257, + 'x-jsf-presentation': { inputType: 'money', currency: 'EUR' }, +}; + +describe('parseSubmitValues', () => { + it('submits a forced money value as its const, even though the form holds it unconverted', async () => { + const { fields } = createHeadlessForm( + { + type: 'object', + properties: { allowance: forcedAllowance }, + 'x-jsf-order': ['allowance'], + }, + {}, + ); + + expect(await parseSubmitValues({ allowance: 81257 }, fields)).toEqual({ + allowance: 81257, + }); + }); + + it('submits a forced money value nested in a fieldset as its const', async () => { + const { fields } = createHeadlessForm( + { + type: 'object', + properties: { + compensation: { + title: 'Compensation', + type: 'object', + properties: { allowance: forcedAllowance }, + 'x-jsf-order': ['allowance'], + 'x-jsf-presentation': { inputType: 'fieldset' }, + }, + }, + 'x-jsf-order': ['compensation'], + }, + {}, + ); + + expect( + await parseSubmitValues({ compensation: { allowance: 81257 } }, fields), + ).toEqual({ compensation: { allowance: 81257 } }); + }); +}); From 8ac5bcbdba427522942d09b9bb345e5f4749f863 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 22:15:47 +0200 Subject: [PATCH 07/10] refactor(onboarding): drop explicit transformMoneyFields createHeadlessForm now defaults transformMoneyFields per key (#1426), so the explicit override in useJSONSchemaForm is redundant. Co-Authored-By: Claude Opus 5.5 --- src/flows/Onboarding/api.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/flows/Onboarding/api.ts b/src/flows/Onboarding/api.ts index 823283564..99ea7fc77 100644 --- a/src/flows/Onboarding/api.ts +++ b/src/flows/Onboarding/api.ts @@ -247,14 +247,7 @@ export const useJSONSchemaForm = ({ enabled: options?.queryOptions?.enabled, select: ({ data }) => { const jsfSchema = data?.data || {}; - // `transformMoneyFields` has to be explicit: `createHeadlessForm` only defaults it on - // when no options object is passed at all. Without it the schema's conditionals and - // computed values see money fields in major units instead of the cents they are written - // for, e.g. PRT's extended work hours allowance came out 100x too small. - return createHeadlessForm(jsfSchema, fieldValues, { - ...options, - transformMoneyFields: true, - }); + return createHeadlessForm(jsfSchema, fieldValues, options); }, }); }; From 39b2adcb49f6d89c3c10d2b220e20fd7b6e491c1 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Tue, 29 Sep 2026 22:21:48 +0200 Subject: [PATCH 08/10] test(form): drop parseSubmitValues forced value test Co-Authored-By: Claude Opus 5.5 --- .../form/tests/parseSubmitValues.test.ts | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 src/components/form/tests/parseSubmitValues.test.ts diff --git a/src/components/form/tests/parseSubmitValues.test.ts b/src/components/form/tests/parseSubmitValues.test.ts deleted file mode 100644 index 2c287dde9..000000000 --- a/src/components/form/tests/parseSubmitValues.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createHeadlessForm } from '@/src/common/createHeadlessForm'; -import { parseSubmitValues } from '@/src/components/form/utils'; - -const forcedAllowance = { - title: 'Allowance', - type: 'integer', - const: 81257, - default: 81257, - 'x-jsf-presentation': { inputType: 'money', currency: 'EUR' }, -}; - -describe('parseSubmitValues', () => { - it('submits a forced money value as its const, even though the form holds it unconverted', async () => { - const { fields } = createHeadlessForm( - { - type: 'object', - properties: { allowance: forcedAllowance }, - 'x-jsf-order': ['allowance'], - }, - {}, - ); - - expect(await parseSubmitValues({ allowance: 81257 }, fields)).toEqual({ - allowance: 81257, - }); - }); - - it('submits a forced money value nested in a fieldset as its const', async () => { - const { fields } = createHeadlessForm( - { - type: 'object', - properties: { - compensation: { - title: 'Compensation', - type: 'object', - properties: { allowance: forcedAllowance }, - 'x-jsf-order': ['allowance'], - 'x-jsf-presentation': { inputType: 'fieldset' }, - }, - }, - 'x-jsf-order': ['compensation'], - }, - {}, - ); - - expect( - await parseSubmitValues({ compensation: { allowance: 81257 } }, fields), - ).toEqual({ compensation: { allowance: 81257 } }); - }); -}); From 421d7841e6bfcc376bf44990b82930ffb137a075 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Wed, 30 Sep 2026 09:10:28 +0200 Subject: [PATCH 09/10] test(form): run money forced values through every engine and strategy Runs the Portugal contract details schema through each combination of kit engine (jsf v0, jsf v1, no meta) and SDK form strategy (rebuild without options, rebuild with jsfModify, build once + handleValidation), and asserts the same money rules in every cell: computed forced values and their text in cents, forced non-money values untouched, dependent recomputation, visibility on submit, money minimum validation. With the #1195 default restored, the 11 rebuild-with-jsfModify cases fail on all three engines; every other cell passes either way. Co-Authored-By: Claude Opus 5.5 --- src/common/tests/moneyEngineContract.test.ts | 230 +++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/common/tests/moneyEngineContract.test.ts diff --git a/src/common/tests/moneyEngineContract.test.ts b/src/common/tests/moneyEngineContract.test.ts new file mode 100644 index 000000000..b4c5c2b34 --- /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 forced acknowledgement strings as their const', async () => { + const { submitted } = await fillForm(start(schema()), { + ...extendedHours, + salary_installments_confirmation: 'acknowledged', + contract_duration_type: 'indefinite', + }); + + expect(submitted.salary_installments_confirmation).toBe('acknowledged'); + expect(submitted.contract_duration_type).toBe('indefinite'); + }); + }); +}); From a36e34405087969ea1f79fa53103d5c10e1b079b Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Wed, 30 Sep 2026 09:11:35 +0200 Subject: [PATCH 10/10] test(form): submit acknowledgement checkboxes as checked, not pre-converted Co-Authored-By: Claude Opus 5.5 --- src/common/tests/moneyEngineContract.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/common/tests/moneyEngineContract.test.ts b/src/common/tests/moneyEngineContract.test.ts index b4c5c2b34..539035482 100644 --- a/src/common/tests/moneyEngineContract.test.ts +++ b/src/common/tests/moneyEngineContract.test.ts @@ -216,11 +216,11 @@ describe.each(ENGINES)('money contract on $engine', ({ meta }) => { expect(empty.submitted).not.toHaveProperty('signing_bonus_amount'); }); - it('submits forced acknowledgement strings as their const', async () => { + it('submits checked acknowledgement checkboxes as their const', async () => { const { submitted } = await fillForm(start(schema()), { ...extendedHours, - salary_installments_confirmation: 'acknowledged', - contract_duration_type: 'indefinite', + salary_installments_confirmation: true, + contract_duration_type: true, }); expect(submitted.salary_installments_confirmation).toBe('acknowledged');