From 61267cc2cf31296a1272cd38cae81cac762bfbdd Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Thu, 5 Nov 2026 17:39:02 +0100 Subject: [PATCH 1/4] refactor: move the invoice flow query and mutation options to core --- packages/core/src/api/contractorCurrencies.ts | 17 +++ packages/core/src/api/employment.ts | 21 +++ packages/core/src/api/invoiceSchedules.ts | 114 +++++++++++++++++ packages/core/src/index.ts | 9 ++ src/common/api/contractor-contract-details.ts | 17 +-- src/common/api/employment.ts | 15 +-- src/common/invoice-schedules/api.ts | 121 ++---------------- .../useOnboardingJsonSchemaVersion.test.tsx | 2 + 8 files changed, 181 insertions(+), 135 deletions(-) create mode 100644 packages/core/src/api/contractorCurrencies.ts create mode 100644 packages/core/src/api/employment.ts create mode 100644 packages/core/src/api/invoiceSchedules.ts diff --git a/packages/core/src/api/contractorCurrencies.ts b/packages/core/src/api/contractorCurrencies.ts new file mode 100644 index 000000000..4cd9748a0 --- /dev/null +++ b/packages/core/src/api/contractorCurrencies.ts @@ -0,0 +1,17 @@ +import { getV1ContractorsEmploymentsEmploymentIdContractorCurrencies } from '../client/sdk.gen'; +import type { Client } from '../client/client'; + +export const contractorCurrenciesOptions = ( + client: Client, + employmentId: string, +) => ({ + queryKey: ['contractor-currencies', employmentId] as const, + queryFn: () => + getV1ContractorsEmploymentsEmploymentIdContractorCurrencies({ + client, + path: { employment_id: employmentId }, + query: { + restrict_to_guaranteed_pay_out_currencies: false, + }, + }), +}); diff --git a/packages/core/src/api/employment.ts b/packages/core/src/api/employment.ts new file mode 100644 index 000000000..d87409687 --- /dev/null +++ b/packages/core/src/api/employment.ts @@ -0,0 +1,21 @@ +import { getV1EmploymentsEmploymentId } from '../client/sdk.gen'; +import type { Client } from '../client/client'; +import type { $TSFixMe } from '../types'; + +export const employmentOptions = ( + client: Client, + employmentId: string, + queryParams?: $TSFixMe, +) => ({ + queryKey: ['employment', employmentId] as const, + retry: false as const, + queryFn: () => + getV1EmploymentsEmploymentId({ + client, + headers: { + Authorization: ``, + }, + path: { employment_id: employmentId }, + query: queryParams, + }), +}); diff --git a/packages/core/src/api/invoiceSchedules.ts b/packages/core/src/api/invoiceSchedules.ts new file mode 100644 index 000000000..a0347993f --- /dev/null +++ b/packages/core/src/api/invoiceSchedules.ts @@ -0,0 +1,114 @@ +import { + getV1ContractorInvoiceSchedules, + patchV1ContractorInvoiceSchedulesId2, + postV1ContractorInvoiceSchedules, + postV1EmploymentsEmploymentIdContractorInvoicesPreview, +} from '../client/sdk.gen'; +import type { + BulkContractorInvoiceScheduleCreateParams, + ContractorInvoiceScheduleCreateParams, + PreviewContractorInvoiceParams, + UpdateScheduleContractorInvoiceParams, +} from '../client/types.gen'; +import type { Client } from '../client/client'; +import { + buildInvoicePreviewPayload, + buildInvoiceSchedulePayload, +} from '../invoiceSchedules/utils'; +import type { FieldValues } from '../types'; + +export const invoiceSchedulesOptions = ( + client: Client, + employmentId: string, +) => ({ + queryKey: ['contractor-invoice-schedules', employmentId] as const, + queryFn: async () => { + const response = await getV1ContractorInvoiceSchedules({ + client, + query: { employment_id: employmentId }, + }); + + if (response.error || !response.data) { + throw new Error('Failed to fetch invoice schedules'); + } + + return response; + }, +}); + +export const createInvoiceScheduleMutation = (client: Client) => ({ + mutationFn: async ({ + employmentId, + values, + }: { + employmentId: string; + values: FieldValues; + }) => { + const payload: BulkContractorInvoiceScheduleCreateParams = { + contractor_invoice_schedules: [ + { + employment_id: employmentId, + ...buildInvoiceSchedulePayload(values), + } as ContractorInvoiceScheduleCreateParams, + ], + }; + + return postV1ContractorInvoiceSchedules({ + client, + body: payload, + }); + }, +}); + +export const updateInvoiceScheduleMutation = (client: Client) => ({ + mutationFn: async ({ + scheduleId, + values, + }: { + scheduleId: string; + values: FieldValues; + }) => { + const payload: UpdateScheduleContractorInvoiceParams = + buildInvoiceSchedulePayload(values); + + return patchV1ContractorInvoiceSchedulesId2({ + client, + path: { id: scheduleId }, + body: payload, + }); + }, +}); + +export const skipInvoiceScheduleMutation = (client: Client) => ({ + mutationFn: async ({ scheduleId }: { scheduleId: string }) => { + const payload: UpdateScheduleContractorInvoiceParams = { + status: 'deleted', + }; + + return patchV1ContractorInvoiceSchedulesId2({ + client, + path: { id: scheduleId }, + body: payload, + }); + }, +}); + +export const previewContractorInvoiceMutation = (client: Client) => ({ + mutationFn: async ({ + employmentId, + values, + }: { + employmentId: string; + values: FieldValues; + }) => { + const payload = buildInvoicePreviewPayload( + values, + ) as PreviewContractorInvoiceParams; + + return postV1EmploymentsEmploymentIdContractorInvoicesPreview({ + client, + path: { employment_id: employmentId }, + body: payload, + }); + }, +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 106a5599b..e5da767bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,3 +75,12 @@ export type { InvoiceScheduleFormSource } from './invoiceSchedules/utils'; export { clearBase64Data, formatCurrency } from './format'; export { createClient } from './createClient'; export type { AuthResponse, CreateClientOptions } from './createClient'; +export { contractorCurrenciesOptions } from './api/contractorCurrencies'; +export { employmentOptions } from './api/employment'; +export { + createInvoiceScheduleMutation, + invoiceSchedulesOptions, + previewContractorInvoiceMutation, + skipInvoiceScheduleMutation, + updateInvoiceScheduleMutation, +} from './api/invoiceSchedules'; diff --git a/src/common/api/contractor-contract-details.ts b/src/common/api/contractor-contract-details.ts index 82d046da5..8a00da0ad 100644 --- a/src/common/api/contractor-contract-details.ts +++ b/src/common/api/contractor-contract-details.ts @@ -1,10 +1,8 @@ import { useMemo } from 'react'; import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { FieldValues } from 'react-hook-form'; -import { - getV1CountriesCountryCodeContractorContractDetails, - getV1ContractorsEmploymentsEmploymentIdContractorCurrencies, -} from '@/src/client'; +import { getV1CountriesCountryCodeContractorContractDetails } from '@/src/client'; +import { contractorCurrenciesOptions } from '@remoteoss/remote-flows-core'; import { useClient } from '@/src/context'; import { Client } from '@/src/client/client'; // oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md @@ -24,16 +22,7 @@ export const useContractorCurrencies = ({ }) => { const { client } = useClient(); return useQuery({ - queryKey: ['contractor-currencies', employmentId], - queryFn: async () => { - return getV1ContractorsEmploymentsEmploymentIdContractorCurrencies({ - client: client as Client, - path: { employment_id: employmentId }, - query: { - restrict_to_guaranteed_pay_out_currencies: false, - }, - }); - }, + ...contractorCurrenciesOptions(client as Client, employmentId), enabled: options?.queryOptions?.enabled, select: ({ data }) => { return data?.data; diff --git a/src/common/api/employment.ts b/src/common/api/employment.ts index 05aadc10f..5b4f74a20 100644 --- a/src/common/api/employment.ts +++ b/src/common/api/employment.ts @@ -1,5 +1,4 @@ import { - getV1EmploymentsEmploymentId, GetV1EmploymentsEmploymentIdResponse, getV2EmploymentsEmploymentIdBasicInformation, GetV2EmploymentsEmploymentIdBasicInformationResponse, @@ -9,6 +8,7 @@ import { useClient } from '@/src/context'; import { $TSFixMe } from '@/src/types/remoteFlows'; import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; import { Client } from '@/src/client/client'; +import { employmentOptions } from '@remoteoss/remote-flows-core'; /** * Hook to retrieve employment details for a specific employment ID. @@ -32,18 +32,7 @@ export const useEmploymentQuery = ({ > => { const { client } = useClient(); return useQuery({ - queryKey: ['employment', employmentId], - retry: false, - queryFn: () => { - return getV1EmploymentsEmploymentId({ - client: client as Client, - headers: { - Authorization: ``, - }, - path: { employment_id: employmentId }, - query: queryParams, - }); - }, + ...employmentOptions(client as Client, employmentId, queryParams), enabled: !!employmentId && enabled, select: ({ data }) => data?.data?.employment, }); diff --git a/src/common/invoice-schedules/api.ts b/src/common/invoice-schedules/api.ts index 152c123bc..032d25cb2 100644 --- a/src/common/invoice-schedules/api.ts +++ b/src/common/invoice-schedules/api.ts @@ -1,27 +1,19 @@ import { useMemo } from 'react'; import { useMutation, useQuery, queryOptions } from '@tanstack/react-query'; -import { FieldValues } from 'react-hook-form'; -import { - BulkContractorInvoiceScheduleCreateParams, - ContractorInvoiceScheduleCreateParams, - getV1ContractorInvoiceSchedules, - patchV1ContractorInvoiceSchedulesId2, - postV1ContractorInvoiceSchedules, - postV1EmploymentsEmploymentIdContractorInvoicesPreview, - PreviewContractorInvoiceParams, - UpdateScheduleContractorInvoiceParams, -} from '@/src/client'; import { Client } from '@/src/client/client'; import { useClient } from '@/src/context'; import { useHeadlessForm } from '@/src/common/useHeadlessForm'; import { useContractorCurrencies } from '@/src/common/api/contractor-contract-details'; import { INVOICE_SCHEDULE_STATUS } from '@/src/common/invoice-schedules/constants'; import { buildCreateInvoiceScheduleSchema } from '@/src/common/invoice-schedules/json-schema'; -import { - buildInvoicePreviewPayload, - buildInvoiceSchedulePayload, -} from '@/src/common/invoice-schedules/utils'; import { JSFModify } from '@/src/flows/types'; +import { + createInvoiceScheduleMutation, + invoiceSchedulesOptions as invoiceSchedulesOptionsCore, + previewContractorInvoiceMutation, + skipInvoiceScheduleMutation, + updateInvoiceScheduleMutation, +} from '@remoteoss/remote-flows-core'; type UseGetCreateInvoiceScheduleSchemaParams = { enabled?: boolean; @@ -101,26 +93,8 @@ export const useGetCreateInvoiceScheduleSchema = ({ * @param employmentId - The employment ID * @returns Query options for contractor invoice schedules */ -export const invoiceSchedulesOptions = ( - client: Client, - employmentId: string, -) => { - return queryOptions({ - queryKey: ['contractor-invoice-schedules', employmentId] as const, - queryFn: async () => { - const response = await getV1ContractorInvoiceSchedules({ - client, - query: { employment_id: employmentId }, - }); - - if (response.error || !response.data) { - throw new Error('Failed to fetch invoice schedules'); - } - - return response; - }, - }); -}; +export const invoiceSchedulesOptions = (client: Client, employmentId: string) => + queryOptions(invoiceSchedulesOptionsCore(client, employmentId)); /** * Get the last invoice schedule for an employment that matches specific statuses. @@ -162,29 +136,7 @@ export const useGetExistingInvoiceSchedule = ({ */ export const useCreateInvoiceSchedule = () => { const { client } = useClient(); - return useMutation({ - mutationFn: async ({ - employmentId, - values, - }: { - employmentId: string; - values: FieldValues; - }) => { - const payload: BulkContractorInvoiceScheduleCreateParams = { - contractor_invoice_schedules: [ - { - employment_id: employmentId, - ...buildInvoiceSchedulePayload(values), - } as ContractorInvoiceScheduleCreateParams, - ], - }; - - return postV1ContractorInvoiceSchedules({ - client: client as Client, - body: payload, - }); - }, - }); + return useMutation(createInvoiceScheduleMutation(client as Client)); }; /** @@ -195,24 +147,7 @@ export const useCreateInvoiceSchedule = () => { */ export const useUpdateInvoiceSchedule = () => { const { client } = useClient(); - return useMutation({ - mutationFn: async ({ - scheduleId, - values, - }: { - scheduleId: string; - values: FieldValues; - }) => { - const payload: UpdateScheduleContractorInvoiceParams = - buildInvoiceSchedulePayload(values); - - return patchV1ContractorInvoiceSchedulesId2({ - client: client as Client, - path: { id: scheduleId }, - body: payload, - }); - }, - }); + return useMutation(updateInvoiceScheduleMutation(client as Client)); }; /** @@ -222,19 +157,7 @@ export const useUpdateInvoiceSchedule = () => { */ export const useSkipInvoiceSchedule = () => { const { client } = useClient(); - return useMutation({ - mutationFn: async ({ scheduleId }: { scheduleId: string }) => { - const payload: UpdateScheduleContractorInvoiceParams = { - status: 'deleted', - }; - - return patchV1ContractorInvoiceSchedulesId2({ - client: client as Client, - path: { id: scheduleId }, - body: payload, - }); - }, - }); + return useMutation(skipInvoiceScheduleMutation(client as Client)); }; /** @@ -245,23 +168,5 @@ export const useSkipInvoiceSchedule = () => { */ export const usePreviewContractorInvoice = () => { const { client } = useClient(); - return useMutation({ - mutationFn: async ({ - employmentId, - values, - }: { - employmentId: string; - values: FieldValues; - }) => { - const payload = buildInvoicePreviewPayload( - values, - ) as PreviewContractorInvoiceParams; - - return postV1EmploymentsEmploymentIdContractorInvoicesPreview({ - client: client as Client, - path: { employment_id: employmentId }, - body: payload, - }); - }, - }); + return useMutation(previewContractorInvoiceMutation(client as Client)); }; diff --git a/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx b/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx index d61244f5f..71002855e 100644 --- a/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx +++ b/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx @@ -13,6 +13,8 @@ const mockPutV1EmploymentsEmploymentIdBenefitOffers = vi.fn(); const mockPostV1EmploymentsEmploymentIdContractEligibility = vi.fn(); // Mock the client functions +vi.mock('@remoteoss/remote-flows-core/client/sdk.gen', () => ({})); + vi.mock('@/src/client', () => ({ getV1CountriesCountryCodeForm: (...args: $TSFixMe[]) => mockGetV1CountriesCountryCodeForm(...args), From d227f6fe259419d6b5d790f1238aa679fe335e54 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 6 Nov 2026 12:15:55 +0100 Subject: [PATCH 2/4] fix: import the invoice schedule utils from their new folder in core The invoice schedule helpers moved to domains/invoice-schedule, so the query options import them from there. The preview payload builder is only used inside core, so it is no longer exported from the package entry. --- packages/core/src/api/invoiceSchedules.ts | 2 +- packages/core/src/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/api/invoiceSchedules.ts b/packages/core/src/api/invoiceSchedules.ts index a0347993f..ffbcea921 100644 --- a/packages/core/src/api/invoiceSchedules.ts +++ b/packages/core/src/api/invoiceSchedules.ts @@ -14,7 +14,7 @@ import type { Client } from '../client/client'; import { buildInvoicePreviewPayload, buildInvoiceSchedulePayload, -} from '../invoiceSchedules/utils'; +} from '../domains/invoice-schedule/utils'; import type { FieldValues } from '../types'; export const invoiceSchedulesOptions = ( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fa203ba1f..39f8c47ce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,7 +40,6 @@ export type { export { INVOICE_SCHEDULE_STATUS } from './domains/invoice-schedule/constants'; export { buildCreateInvoiceScheduleSchema } from './domains/invoice-schedule/jsonSchema'; export { - buildInvoicePreviewPayload, buildInvoiceSchedulePayload, buildInvoiceScheduleFormValues, } from './domains/invoice-schedule/utils'; From 423180c9639ca0525c8d03f225e2e8a565fdeace Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 6 Nov 2026 13:11:58 +0100 Subject: [PATCH 3/4] refactor: bind core query and mutation factories to the client once Add `withClient` and `createClientMutation` so a core options factory becomes a hook in one line. The mutation hook also returns `mutateAsyncOrThrow`, memoized on the stable `mutate`, so flow hooks no longer wrap each mutation themselves. Move the response unwrapping and the "editable schedule" status rule into core selectors next to the queries they read, and use them from the invoice schedule and contractor currencies hooks. --- .../core/src/api/contractorCurrencies.test.ts | 16 ++++ packages/core/src/api/contractorCurrencies.ts | 12 +++ .../core/src/api/invoiceSchedules.test.ts | 22 ++++++ packages/core/src/api/invoiceSchedules.ts | 18 +++++ packages/core/src/index.ts | 7 +- src/common/api/contractor-contract-details.ts | 22 +++--- src/common/invoice-schedules/api.ts | 76 +++++++------------ src/flows/ContractorOnboarding/hooks.tsx | 8 +- src/flows/InvoiceSchedule/hooks.tsx | 7 +- src/lib/__tests__/withClient.test.tsx | 71 +++++++++++++++++ src/lib/withClient.ts | 39 ++++++++++ 11 files changed, 230 insertions(+), 68 deletions(-) create mode 100644 packages/core/src/api/contractorCurrencies.test.ts create mode 100644 packages/core/src/api/invoiceSchedules.test.ts create mode 100644 src/lib/__tests__/withClient.test.tsx create mode 100644 src/lib/withClient.ts diff --git a/packages/core/src/api/contractorCurrencies.test.ts b/packages/core/src/api/contractorCurrencies.test.ts new file mode 100644 index 000000000..0fdbc85c1 --- /dev/null +++ b/packages/core/src/api/contractorCurrencies.test.ts @@ -0,0 +1,16 @@ +import { selectContractorCurrencyCodes } from './contractorCurrencies'; + +describe('selectContractorCurrencyCodes', () => { + it('returns only the currency codes', () => { + const response = { + data: { + data: [ + { code: 'EUR', source: 'guaranteed' }, + { code: 'USD', source: 'guaranteed' }, + ], + }, + } as Parameters[0]; + + expect(selectContractorCurrencyCodes(response)).toEqual(['EUR', 'USD']); + }); +}); diff --git a/packages/core/src/api/contractorCurrencies.ts b/packages/core/src/api/contractorCurrencies.ts index 4cd9748a0..fc0f00f13 100644 --- a/packages/core/src/api/contractorCurrencies.ts +++ b/packages/core/src/api/contractorCurrencies.ts @@ -15,3 +15,15 @@ export const contractorCurrenciesOptions = ( }, }), }); + +type ContractorCurrenciesResponse = Awaited< + ReturnType['queryFn']> +>; + +export const selectContractorCurrencies = ({ + data, +}: ContractorCurrenciesResponse) => data?.data; + +export const selectContractorCurrencyCodes = ( + response: ContractorCurrenciesResponse, +) => selectContractorCurrencies(response)?.map((currency) => currency.code); diff --git a/packages/core/src/api/invoiceSchedules.test.ts b/packages/core/src/api/invoiceSchedules.test.ts new file mode 100644 index 000000000..118121d41 --- /dev/null +++ b/packages/core/src/api/invoiceSchedules.test.ts @@ -0,0 +1,22 @@ +import { selectExistingInvoiceSchedule } from './invoiceSchedules'; + +describe('selectExistingInvoiceSchedule', () => { + it('returns the first schedule that is still editable', () => { + const response = { + data: { + data: { + contractor_invoice_schedules: [ + { id: 'done', status: 'completed' }, + { id: 'open', status: 'processing' }, + { id: 'later', status: 'pending_company_action' }, + ], + }, + }, + } as Parameters[0]; + + expect(selectExistingInvoiceSchedule(response)).toEqual({ + id: 'open', + status: 'processing', + }); + }); +}); diff --git a/packages/core/src/api/invoiceSchedules.ts b/packages/core/src/api/invoiceSchedules.ts index ffbcea921..b70609141 100644 --- a/packages/core/src/api/invoiceSchedules.ts +++ b/packages/core/src/api/invoiceSchedules.ts @@ -11,6 +11,7 @@ import type { UpdateScheduleContractorInvoiceParams, } from '../client/types.gen'; import type { Client } from '../client/client'; +import { INVOICE_SCHEDULE_STATUS } from '../domains/invoice-schedule/constants'; import { buildInvoicePreviewPayload, buildInvoiceSchedulePayload, @@ -36,6 +37,23 @@ export const invoiceSchedulesOptions = ( }, }); +type InvoiceSchedulesResponse = Awaited< + ReturnType['queryFn']> +>; + +const EDITABLE_STATUSES: string[] = [ + INVOICE_SCHEDULE_STATUS.PENDING_CONTRACTOR_ACTION, + INVOICE_SCHEDULE_STATUS.PROCESSING, + INVOICE_SCHEDULE_STATUS.PENDING_COMPANY_ACTION, +]; + +export const selectExistingInvoiceSchedule = ( + response: InvoiceSchedulesResponse, +) => + response.data?.data?.contractor_invoice_schedules?.find((schedule) => + EDITABLE_STATUSES.includes(schedule.status), + ); + export const createInvoiceScheduleMutation = (client: Client) => ({ mutationFn: async ({ employmentId, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 39f8c47ce..ea77f86f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -47,12 +47,17 @@ export type { ContractorInvoicePreview } from './domains/invoice-schedule/types' export { clearBase64Data, formatCurrency } from './format'; export { createClient } from './createClient'; export type { AuthResponse, CreateClientOptions } from './createClient'; -export { contractorCurrenciesOptions } from './api/contractorCurrencies'; +export { + contractorCurrenciesOptions, + selectContractorCurrencies, + selectContractorCurrencyCodes, +} from './api/contractorCurrencies'; export { employmentOptions } from './api/employment'; export { createInvoiceScheduleMutation, invoiceSchedulesOptions, previewContractorInvoiceMutation, + selectExistingInvoiceSchedule, skipInvoiceScheduleMutation, updateInvoiceScheduleMutation, } from './api/invoiceSchedules'; diff --git a/src/common/api/contractor-contract-details.ts b/src/common/api/contractor-contract-details.ts index 8a00da0ad..2239a73f0 100644 --- a/src/common/api/contractor-contract-details.ts +++ b/src/common/api/contractor-contract-details.ts @@ -2,8 +2,12 @@ import { useMemo } from 'react'; import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { FieldValues } from 'react-hook-form'; import { getV1CountriesCountryCodeContractorContractDetails } from '@/src/client'; -import { contractorCurrenciesOptions } from '@remoteoss/remote-flows-core'; +import { + contractorCurrenciesOptions, + selectContractorCurrencies, +} from '@remoteoss/remote-flows-core'; import { useClient } from '@/src/context'; +import { withClient } from '@/src/lib/withClient'; import { Client } from '@/src/client/client'; // oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; @@ -13,22 +17,22 @@ import { } from '@/src/flows/types'; import { $TSFixMe } from '@/src/types/remoteFlows'; +export const useContractorCurrenciesOptions = withClient( + contractorCurrenciesOptions, +); + export const useContractorCurrencies = ({ employmentId, options, }: { employmentId: string; options?: { queryOptions?: { enabled?: boolean } }; -}) => { - const { client } = useClient(); - return useQuery({ - ...contractorCurrenciesOptions(client as Client, employmentId), +}) => + useQuery({ + ...useContractorCurrenciesOptions(employmentId), enabled: options?.queryOptions?.enabled, - select: ({ data }) => { - return data?.data; - }, + select: selectContractorCurrencies, }); -}; /** * Get the contractor onboarding details schema for a given country diff --git a/src/common/invoice-schedules/api.ts b/src/common/invoice-schedules/api.ts index 49df01a37..f78c07038 100644 --- a/src/common/invoice-schedules/api.ts +++ b/src/common/invoice-schedules/api.ts @@ -1,16 +1,16 @@ import { useMemo } from 'react'; -import { useMutation, useQuery, queryOptions } from '@tanstack/react-query'; -import { Client } from '@/src/client/client'; -import { useClient } from '@/src/context'; +import { useQuery } from '@tanstack/react-query'; import { useHeadlessForm } from '@/src/common/useHeadlessForm'; -import { useContractorCurrencies } from '@/src/common/api/contractor-contract-details'; +import { useContractorCurrenciesOptions } from '@/src/common/api/contractor-contract-details'; import { JSFModify } from '@/src/flows/types'; +import { createClientMutation, withClient } from '@/src/lib/withClient'; import { buildCreateInvoiceScheduleSchema, createInvoiceScheduleMutation, - INVOICE_SCHEDULE_STATUS, invoiceSchedulesOptions as invoiceSchedulesOptionsCore, previewContractorInvoiceMutation, + selectContractorCurrencyCodes, + selectExistingInvoiceSchedule, skipInvoiceScheduleMutation, updateInvoiceScheduleMutation, } from '@remoteoss/remote-flows-core'; @@ -48,11 +48,10 @@ export const useGetCreateInvoiceScheduleSchema = ({ includeFieldsets, initialValues, }: UseGetCreateInvoiceScheduleSchemaParams) => { - const { data: currencies, isLoading } = useContractorCurrencies({ - employmentId: employmentId as string, - options: { - queryOptions: { enabled: enabled && Boolean(employmentId) }, - }, + const { data: currencies, isLoading } = useQuery({ + ...useContractorCurrenciesOptions(employmentId as string), + enabled: enabled && Boolean(employmentId), + select: selectContractorCurrencyCodes, }); const schema = useMemo(() => { @@ -63,9 +62,7 @@ export const useGetCreateInvoiceScheduleSchema = ({ } return buildCreateInvoiceScheduleSchema({ - currencies: currencies.map( - (currency: { code: string; source: string }) => currency.code, - ), + currencies, isContractorOfRecord, includeFieldsets, }); @@ -87,14 +84,9 @@ export const useGetCreateInvoiceScheduleSchema = ({ }; }; -/** - * Query options factory for fetching invoice schedules by employment id - * @param client - The API client - * @param employmentId - The employment ID - * @returns Query options for contractor invoice schedules - */ -export const invoiceSchedulesOptions = (client: Client, employmentId: string) => - queryOptions(invoiceSchedulesOptionsCore(client, employmentId)); +export const useInvoiceSchedulesOptions = withClient( + invoiceSchedulesOptionsCore, +); /** * Get the last invoice schedule for an employment that matches specific statuses. @@ -111,20 +103,10 @@ export const useGetExistingInvoiceSchedule = ({ employmentId: string; options?: { queryOptions?: { enabled?: boolean } }; }) => { - const { client } = useClient(); return useQuery({ - ...invoiceSchedulesOptions(client as Client, employmentId), + ...useInvoiceSchedulesOptions(employmentId), enabled: options?.queryOptions?.enabled, - select: (response) => { - // Find the first invoice schedule that matches the editing criteria - return response.data?.data?.contractor_invoice_schedules?.find( - (invoice) => - invoice.status === - INVOICE_SCHEDULE_STATUS.PENDING_CONTRACTOR_ACTION || - invoice.status === INVOICE_SCHEDULE_STATUS.PROCESSING || - invoice.status === INVOICE_SCHEDULE_STATUS.PENDING_COMPANY_ACTION, - ); - }, + select: selectExistingInvoiceSchedule, }); }; @@ -134,10 +116,9 @@ export const useGetExistingInvoiceSchedule = ({ * @param values - The form values containing invoice schedule data * @returns The created invoice schedules */ -export const useCreateInvoiceSchedule = () => { - const { client } = useClient(); - return useMutation(createInvoiceScheduleMutation(client as Client)); -}; +export const useCreateInvoiceSchedule = createClientMutation( + createInvoiceScheduleMutation, +); /** * Updates an existing contractor invoice schedule @@ -145,20 +126,18 @@ export const useCreateInvoiceSchedule = () => { * @param values - The form values containing invoice schedule data * @returns The updated invoice schedule */ -export const useUpdateInvoiceSchedule = () => { - const { client } = useClient(); - return useMutation(updateInvoiceScheduleMutation(client as Client)); -}; +export const useUpdateInvoiceSchedule = createClientMutation( + updateInvoiceScheduleMutation, +); /** * Skips (cancels) an existing contractor invoice schedule by marking it as deleted. * @param scheduleId - The invoice schedule ID * @returns The updated invoice schedule */ -export const useSkipInvoiceSchedule = () => { - const { client } = useClient(); - return useMutation(skipInvoiceScheduleMutation(client as Client)); -}; +export const useSkipInvoiceSchedule = createClientMutation( + skipInvoiceScheduleMutation, +); /** * Previews a contractor invoice as a draft PDF, without persisting it. @@ -166,7 +145,6 @@ export const useSkipInvoiceSchedule = () => { * @param values - The form values containing invoice details * @returns The base64-encoded PDF preview */ -export const usePreviewContractorInvoice = () => { - const { client } = useClient(); - return useMutation(previewContractorInvoiceMutation(client as Client)); -}; +export const usePreviewContractorInvoice = createClientMutation( + previewContractorInvoiceMutation, +); diff --git a/src/flows/ContractorOnboarding/hooks.tsx b/src/flows/ContractorOnboarding/hooks.tsx index e8f93b0e2..72a7ae8cd 100644 --- a/src/flows/ContractorOnboarding/hooks.tsx +++ b/src/flows/ContractorOnboarding/hooks.tsx @@ -346,16 +346,16 @@ export const useContractorOnboarding = ({ mutationToPromise(setContractOriginMutation); const { mutateAsyncOrThrow: createInvoiceScheduleMutationAsync } = - mutationToPromise(createInvoiceScheduleMutation); + createInvoiceScheduleMutation; const { mutateAsyncOrThrow: updateInvoiceScheduleMutationAsync } = - mutationToPromise(updateInvoiceScheduleMutation); + updateInvoiceScheduleMutation; const { mutateAsyncOrThrow: skipInvoiceScheduleMutationAsync } = - mutationToPromise(skipInvoiceScheduleMutation); + skipInvoiceScheduleMutation; const { mutateAsyncOrThrow: previewContractorInvoiceMutationAsync } = - mutationToPromise(previewContractorInvoiceMutation); + previewContractorInvoiceMutation; // if the employment is loaded, country code has not been set yet // we set the internal country code with the employment country code diff --git a/src/flows/InvoiceSchedule/hooks.tsx b/src/flows/InvoiceSchedule/hooks.tsx index 5c9d4badf..9f5a254c6 100644 --- a/src/flows/InvoiceSchedule/hooks.tsx +++ b/src/flows/InvoiceSchedule/hooks.tsx @@ -18,7 +18,6 @@ import { UseInvoiceScheduleOptions, } from '@/src/flows/InvoiceSchedule/types'; import { clearBase64Data, createStructuredError } from '@/src/lib/utils'; -import { mutationToPromise } from '@/src/lib/mutations'; import { $TSFixMe } from '@/src/types/remoteFlows'; import { Fields } from '@remoteoss/remote-json-schema-form-kit'; @@ -89,13 +88,11 @@ export const useInvoiceSchedule = ({ ); const createInvoiceScheduleMutation = useCreateInvoiceSchedule(); - const { mutateAsyncOrThrow } = mutationToPromise( - createInvoiceScheduleMutation, - ); + const { mutateAsyncOrThrow } = createInvoiceScheduleMutation; const previewContractorInvoiceMutation = usePreviewContractorInvoice(); const { mutateAsyncOrThrow: previewContractorInvoiceAsync } = - mutationToPromise(previewContractorInvoiceMutation); + previewContractorInvoiceMutation; const parseFormValues = useCallback( async (values: InvoiceScheduleFormValues) => { diff --git a/src/lib/__tests__/withClient.test.tsx b/src/lib/__tests__/withClient.test.tsx new file mode 100644 index 000000000..6add03d76 --- /dev/null +++ b/src/lib/__tests__/withClient.test.tsx @@ -0,0 +1,71 @@ +import { PropsWithChildren } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { RemoteFlowContext } from '@/src/context'; +import { client, queryClient, TestProviders } from '@/src/tests/testHelpers'; +import { createClientMutation } from '@/src/lib/withClient'; + +const Wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + +); + +describe('createClientMutation', () => { + afterEach(() => { + queryClient.clear(); + }); + + it('passes the context client and the extra arguments to the factory', () => { + const factory = vi.fn( + (_client: unknown, _countryCode: string, _version: number) => ({ + mutationFn: async (values: { name: string }) => ({ + data: values, + error: null, + }), + }), + ); + const useTestMutation = createClientMutation(factory); + + renderHook(() => useTestMutation('PRT', 2), { wrapper: Wrapper }); + + expect(factory).toHaveBeenCalledWith(client, 'PRT', 2); + }); + + it('resolves mutateAsyncOrThrow with the unwrapped data', async () => { + const useTestMutation = createClientMutation(() => ({ + mutationFn: async (values: { name: string }) => ({ + data: values, + error: null, + }), + })); + + const { result } = renderHook(() => useTestMutation(), { + wrapper: Wrapper, + }); + + await expect( + result.current.mutateAsyncOrThrow({ name: 'Ana' }), + ).resolves.toEqual({ name: 'Ana' }); + await waitFor(() => expect(result.current.isPending).toBe(false)); + }); + + it('keeps mutateAsyncOrThrow stable across re-renders', () => { + const useTestMutation = createClientMutation(() => ({ + mutationFn: async (values: { name: string }) => ({ + data: values, + error: null, + }), + })); + + const { result, rerender } = renderHook(() => useTestMutation(), { + wrapper: Wrapper, + }); + const first = result.current.mutateAsyncOrThrow; + + rerender(); + + expect(result.current.mutateAsyncOrThrow).toBe(first); + }); +}); diff --git a/src/lib/withClient.ts b/src/lib/withClient.ts new file mode 100644 index 000000000..0fd5afbb9 --- /dev/null +++ b/src/lib/withClient.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { Client } from '@/src/client/client'; +import { useClient } from '@/src/context'; +import { mutationToPromise } from '@/src/lib/mutations'; + +/** + * Binds a core options factory to the client from context, keeping its remaining arguments. + * Name the result `useX`: it reads context, so it must be called as a hook. + */ +export const withClient = + ( + factory: (client: Client, ...args: TArgs) => TOptions, + ) => + (...args: TArgs): TOptions => { + const { client } = useClient(); + return factory(client as Client, ...args); + }; + +/** + * Turns a core mutation factory into a hook that returns the mutation plus `mutateAsyncOrThrow`. + */ +export const createClientMutation = + ( + factory: ( + client: Client, + ...args: TArgs + ) => { mutationFn: (vars: TVars) => Promise }, + ) => + (...args: TArgs) => { + const { client } = useClient(); + const mutation = useMutation(factory(client as Client, ...args)); + const { mutate } = mutation; + const { mutateAsyncOrThrow } = useMemo( + () => mutationToPromise({ mutate } as typeof mutation), + [mutate], + ); + return { ...mutation, mutateAsyncOrThrow }; + }; From 0a423f6085649a5b6d830af13be7ef929204f61f Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 6 Nov 2026 13:14:15 +0100 Subject: [PATCH 4/4] fix: stop exporting the invoice schedule status constant from core --- packages/core/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ea77f86f9..c30040354 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,7 +37,6 @@ export type { JSONSchemaFormResultWithFieldsets, UploadedFileReference, } from './types'; -export { INVOICE_SCHEDULE_STATUS } from './domains/invoice-schedule/constants'; export { buildCreateInvoiceScheduleSchema } from './domains/invoice-schedule/jsonSchema'; export { buildInvoiceSchedulePayload,