diff --git a/example/api/get_token.js b/example/api/get_token.js index 1908ef4bf..a5885881e 100644 --- a/example/api/get_token.js +++ b/example/api/get_token.js @@ -1,4 +1,5 @@ const { buildGatewayURL } = require('./utils.js'); +const { fetchCompanyManagerToken } = require('./jwt_auth.js'); async function fetchAccessToken() { const { @@ -6,8 +7,16 @@ async function fetchAccessToken() { VITE_CLIENT_SECRET, VITE_REMOTE_GATEWAY, VITE_REFRESH_TOKEN, + VITE_USER_ID, } = process.env; + // Local dev has no interactively-obtained refresh token; fall back to the + // JWT-bearer assertion flow (same as fetchCompanyManagerToken) using + // VITE_USER_ID instead. + if (VITE_REMOTE_GATEWAY === 'local' && !VITE_REFRESH_TOKEN && VITE_USER_ID) { + return fetchCompanyManagerToken(); + } + // for local development, we don't need a client secret if ( !VITE_CLIENT_ID || diff --git a/example/src/ContractorOnboarding.tsx b/example/src/ContractorOnboarding.tsx index 47b0fc650..7a3c7b8bb 100644 --- a/example/src/ContractorOnboarding.tsx +++ b/example/src/ContractorOnboarding.tsx @@ -229,6 +229,8 @@ const MultiStepForm = ({ ContractOriginStep, InvoiceScheduleStep, CreateInvoiceScheduleStep, + SkipInvoiceScheduleButton, + PreviewInvoiceButton, } = components; const [errors, setErrors] = useState<{ apiError: string; @@ -591,6 +593,29 @@ const MultiStepForm = ({ > Back + {contractorOnboardingBag.existingInvoiceSchedule?.id && ( + console.log('invoice schedule skipped')} + onError={({ error, fieldErrors }) => + setErrors({ apiError: error.message, fieldErrors }) + } + > + Skip this invoice schedule + + )} + { + console.log('invoice preview', preview); + window.open(preview.content, '_blank'); + }} + onError={({ error, fieldErrors }) => + setErrors({ apiError: error.message, fieldErrors }) + } + > + Preview invoice + setErrors({ apiError: '', fieldErrors: [] })} diff --git a/src/client/index.ts b/src/client/index.ts index 74cb183b8..067ed1cc2 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -212,6 +212,7 @@ export { postV1Employments, postV1EmploymentsEmploymentIdContractEligibility, postV1EmploymentsEmploymentIdContractOrigin, + postV1EmploymentsEmploymentIdContractorInvoicesPreview, postV1EmploymentsEmploymentIdEngagementAgreementDetails, postV1EmploymentsEmploymentIdInvite, postV1EmploymentsEmploymentIdRiskReserveProofOfPayments, @@ -394,6 +395,7 @@ export type { ContractorInvoiceId, ContractorInvoiceItem, ContractorInvoiceItemType, + ContractorInvoicePreviewResponse, ContractorInvoiceResponse, ContractorInvoiceSchedule, ContractorInvoiceScheduleCreateParams, @@ -1767,6 +1769,11 @@ export type { PostV1EmploymentsEmploymentIdContractOriginErrors, PostV1EmploymentsEmploymentIdContractOriginResponse, PostV1EmploymentsEmploymentIdContractOriginResponses, + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewData, + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewError, + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewErrors, + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewResponse, + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewResponses, PostV1EmploymentsEmploymentIdEngagementAgreementDetailsData, PostV1EmploymentsEmploymentIdEngagementAgreementDetailsError, PostV1EmploymentsEmploymentIdEngagementAgreementDetailsErrors, @@ -1977,6 +1984,7 @@ export type { PostV2EmploymentsEmploymentIdEngagementAgreementDetailsResponses, PreOnboardingDocumentRequirement, PreOnboardingRequirement, + PreviewContractorInvoiceParams, Price, PricingPlan, PricingPlanDetails, diff --git a/src/client/sdk.gen.ts b/src/client/sdk.gen.ts index f14182611..ebd8fd6b9 100644 --- a/src/client/sdk.gen.ts +++ b/src/client/sdk.gen.ts @@ -634,6 +634,9 @@ import type { PostV1EmploymentsEmploymentIdContractOriginData, PostV1EmploymentsEmploymentIdContractOriginErrors, PostV1EmploymentsEmploymentIdContractOriginResponses, + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewData, + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewErrors, + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewResponses, PostV1EmploymentsEmploymentIdEngagementAgreementDetailsData, PostV1EmploymentsEmploymentIdEngagementAgreementDetailsErrors, PostV1EmploymentsEmploymentIdEngagementAgreementDetailsResponses, @@ -6595,6 +6598,39 @@ export const postV1EmploymentsEmploymentIdContractOrigin = < }, }); +/** + * Preview a Contractor Invoice + * + * Returns a base64-encoded PDF preview of a contractor invoice built from the given parameters. + * + * The document is a draft and is not persisted. + * + */ +export const postV1EmploymentsEmploymentIdContractorInvoicesPreview = < + ThrowOnError extends boolean = false, +>( + options: Options< + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewData, + ThrowOnError + >, +) => + (options.client ?? client).post< + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewResponses, + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewErrors, + ThrowOnError + >({ + security: [ + { scheme: 'bearer', type: 'http' }, + { scheme: 'bearer', type: 'http' }, + ], + url: '/v1/employments/{employment_id}/contractor-invoices/preview', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + /** * List Time Off Types * diff --git a/src/client/types.gen.ts b/src/client/types.gen.ts index 5156c1c63..9d4a6e484 100644 --- a/src/client/types.gen.ts +++ b/src/client/types.gen.ts @@ -5583,6 +5583,29 @@ export type UpdateScheduleContractorInvoiceParams = { number?: string | null; periodicity?: ContractorInvoiceSchedulePeriodicity; start_date?: Date; + status?: 'inactive' | 'deleted' | 'active' | 'processing'; +}; + +/** + * PreviewContractorInvoiceParams + * + * Payload shape used to preview a contractor invoice before it's created. + */ +export type PreviewContractorInvoiceParams = { + currency: CurrencyCode; + /** + * List of invoice items that composes the overall invoice amount. + */ + items: Array; + /** + * Custom defined note. + */ + note?: string | null; + /** + * Invoice identifier. + */ + number?: string | null; + start_date: Date; }; /** @@ -5634,10 +5657,12 @@ export type CommonIncentiveParams = { * - `generation_failed_unrelated_to_withdrawal_method`: Generation failed for any other reason. * - `completed`: Number of generated contractor invoices has been reached. * - `inactive`: Does not create any further contractor invoices but it's still possible for the employer to activate it again. + * - `deleted`: The schedule was cancelled and will not generate any further contractor invoices. * */ export type ContractorInvoiceScheduleStatus = | 'inactive' + | 'deleted' | 'completed' | 'active' | 'processing' @@ -12309,6 +12334,20 @@ export type ContractorInvoiceScheduleItem = { description: string; }; +/** + * ContractorInvoicePreviewResponse + * + * Returns a base64 encoded Contractor Invoice preview document. + */ +export type ContractorInvoicePreviewResponse = { + data: { + contractor_invoice_preview: { + content: Blob | File; + name: string; + }; + }; +}; + /** * ForbiddenResponse * @@ -21487,6 +21526,53 @@ export type PostV1EmploymentsEmploymentIdContractOriginResponses = { export type PostV1EmploymentsEmploymentIdContractOriginResponse = PostV1EmploymentsEmploymentIdContractOriginResponses[keyof PostV1EmploymentsEmploymentIdContractOriginResponses]; +export type PostV1EmploymentsEmploymentIdContractorInvoicesPreviewData = { + /** + * Preview parameters + */ + body: PreviewContractorInvoiceParams; + path: { + /** + * Employment identifier + */ + employment_id: UuidSlug; + }; + query?: never; + url: '/v1/employments/{employment_id}/contractor-invoices/preview'; +}; + +export type PostV1EmploymentsEmploymentIdContractorInvoicesPreviewErrors = { + /** + * Unauthorized + */ + 401: UnauthorizedResponse; + /** + * Forbidden + */ + 403: ForbiddenResponse; + /** + * Not Found + */ + 404: NotFoundResponse; + /** + * Unprocessable Entity + */ + 422: UnprocessableEntityResponse; +}; + +export type PostV1EmploymentsEmploymentIdContractorInvoicesPreviewError = + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewErrors[keyof PostV1EmploymentsEmploymentIdContractorInvoicesPreviewErrors]; + +export type PostV1EmploymentsEmploymentIdContractorInvoicesPreviewResponses = { + /** + * Success + */ + 200: ContractorInvoicePreviewResponse; +}; + +export type PostV1EmploymentsEmploymentIdContractorInvoicesPreviewResponse = + PostV1EmploymentsEmploymentIdContractorInvoicesPreviewResponses[keyof PostV1EmploymentsEmploymentIdContractorInvoicesPreviewResponses]; + export type GetV1TimeoffTypesData = { body?: never; headers: { diff --git a/src/flows/ContractorOnboarding/ContractorOnboarding.tsx b/src/flows/ContractorOnboarding/ContractorOnboarding.tsx index 9a4d055d6..35661f81e 100644 --- a/src/flows/ContractorOnboarding/ContractorOnboarding.tsx +++ b/src/flows/ContractorOnboarding/ContractorOnboarding.tsx @@ -14,6 +14,8 @@ import { OnboardingInvite } from '@/src/flows/ContractorOnboarding/components/On import { ContractReviewButton } from '@/src/flows/ContractorOnboarding/components/ContractReviewButton'; import { EligibilityQuestionnaireStep } from '@/src/flows/ContractorOnboarding/components/EligibilityQuestionnaireStep'; import { SaveDraftButton } from '@/src/flows/ContractorOnboarding/components/SaveDraftButton'; +import { SkipInvoiceScheduleButton } from '@/src/flows/ContractorOnboarding/components/SkipInvoiceScheduleButton'; +import { PreviewInvoiceButton } from '@/src/flows/ContractorOnboarding/components/PreviewInvoiceButton'; import { ContractOriginStep } from '@/src/flows/ContractorOnboarding/components/ContractOriginStep'; import { InvoiceScheduleStep } from '@/src/flows/ContractorOnboarding/components/InvoiceScheduleStep'; import { CreateInvoiceScheduleStep } from '@/src/flows/ContractorOnboarding/components/CreateInvoiceScheduleStep'; @@ -73,6 +75,8 @@ export const ContractorOnboardingFlow = ({ OnboardingInvite: OnboardingInvite, ContractReviewButton: ContractReviewButton, SaveDraftButton: SaveDraftButton, + SkipInvoiceScheduleButton: SkipInvoiceScheduleButton, + PreviewInvoiceButton: PreviewInvoiceButton, }, })} diff --git a/src/flows/ContractorOnboarding/README.md b/src/flows/ContractorOnboarding/README.md index f74584dba..dc0005df8 100644 --- a/src/flows/ContractorOnboarding/README.md +++ b/src/flows/ContractorOnboarding/README.md @@ -15,8 +15,10 @@ The contractor onboarding process consists of the following steps in order: 3. **Contract Details** - Define contract terms including services, duration, compensation, and notice periods 4. **Pricing Plan** - Select and configure subscription/pricing tier 5. **Contract Origin** - Choose where the contract comes from -6. **Contract Preview** - Review and electronically sign the generated contract -7. **Review** - Final review step (internal) +6. **Invoice Schedule** - Choose whether to set up a recurring invoice schedule now or skip it for later (shown when `options.features` includes `'create_invoice_schedule'`) +7. **Create Invoice Schedule** - Fill in the invoice schedule details (currency, periodicity, items, etc.); also used to edit an already-created schedule +8. **Contract Preview** - Review and electronically sign the generated contract +9. **Review** - Final review step (internal) ### Skipping Steps @@ -72,6 +74,10 @@ The render function receives an object with: - `ContractDetailsStep` - Contract terms form - `PricingPlanStep` - Pricing tier selection - `ContractOriginStep` - Contract origin selection form + - `InvoiceScheduleStep` - Invoice schedule preference selection form + - `CreateInvoiceScheduleStep` - Invoice schedule detail form (create or edit) + - `SkipInvoiceScheduleButton` - Cancels an existing invoice schedule + - `PreviewInvoiceButton` - Previews a draft invoice PDF from unsaved form values - `ContractPreviewStep` - Contract review and signature - `OnboardingInvite` - Invitation/status component - `SubmitButton` - Multi-purpose submit button @@ -139,6 +145,59 @@ Rendered as a radio group. - `onSuccess?: (data: { contractOrigin: string }) => void` - Called on successful submission - `onError?: (error: { error: Error; rawError: Record; fieldErrors: NormalizedFieldError[] }) => void` - Called on error +### InvoiceScheduleStep + +Lets the employer choose whether to set up a recurring invoice schedule now (`schedule`) or skip it for now (`manual`). Only rendered when `options.features` includes `'create_invoice_schedule'`. + +**Props:** + +- `onSubmit?: (payload: InvoiceScheduleFormPayload) => void` - Called before submission +- `onSuccess?: (data: InvoiceScheduleResponse) => void` - Called on successful submission +- `onError?: (error: { error: Error; fieldErrors: NormalizedFieldError[] }) => void` - Called on error + +### CreateInvoiceScheduleStep + +Collects the invoice schedule details: currency, periodicity, start date, up to 10 invoice items, invoice number, note, and number of occurrences. When an invoice schedule already exists for the employment, this same step is prefilled and submitting it updates the existing schedule instead of creating a new one. + +**Props:** + +- `onSubmit?: (payload: $TSFixMe) => void` - Called before submission +- `onSuccess?: (data: $TSFixMe) => void` - Called on successful submission +- `onError?: (error: { error: Error; fieldErrors: NormalizedFieldError[] }) => void` - Called on error + +#### SkipInvoiceScheduleButton + +Cancels the existing invoice schedule (marks it as `deleted`) and returns the wizard to the `InvoiceScheduleStep`. Disabled unless `contractorOnboardingBag.existingInvoiceSchedule` is present, so it's meant to be rendered alongside `CreateInvoiceScheduleStep` only when editing an existing schedule. + +```tsx + {}} onError={(error) => {}}> + Skip this invoice schedule + +``` + +**Props:** + +- `onSuccess?: () => void` - Called after the schedule is successfully skipped +- `onError?: (error: { error: Error; rawError: Record; fieldErrors: NormalizedFieldError[] }) => void` - Called on error + +#### PreviewInvoiceButton + +Generates a draft (non-persisted) PDF preview of a contractor invoice from the current, unsaved `CreateInvoiceScheduleStep` form values (`contractorOnboardingBag.fieldValues`). Meant to be rendered alongside `CreateInvoiceScheduleStep`. + +```tsx + window.open(preview.content, '_blank')} + onError={(error) => {}} +> + Preview invoice + +``` + +**Props:** + +- `onSuccess?: (data: ContractorInvoicePreview) => void` - Called with `{ name, content }` (`content` is a `data:application/pdf;base64,...` URI) on success +- `onError?: (error: { error: Error; rawError: Record; fieldErrors: NormalizedFieldError[] }) => void` - Called on error + ### ContractPreviewStep Displays the generated contract document for review and electronic signature. diff --git a/src/flows/ContractorOnboarding/api.ts b/src/flows/ContractorOnboarding/api.ts index ced907ca9..48ec16fb2 100644 --- a/src/flows/ContractorOnboarding/api.ts +++ b/src/flows/ContractorOnboarding/api.ts @@ -26,6 +26,8 @@ import { Country, ContractorInvoiceScheduleCreateParams, PostV1EmploymentsEmploymentIdContractOriginData, + postV1EmploymentsEmploymentIdContractorInvoicesPreview, + PreviewContractorInvoiceParams, } from '@/src/client'; import { useClient } from '@/src/context'; import { signatureSchema } from '@/src/flows/ContractorOnboarding/json-schemas/signature'; @@ -67,6 +69,7 @@ import { selectCountryStepSchema } from '@/src/flows/Onboarding/json-schemas/sel import { shouldIncludeProduct, buildInvoiceSchedulePayload, + buildInvoicePreviewPayload, } from '@/src/flows/ContractorOnboarding/utils'; import { useCompanyPricingPlans, hasCompany } from '@/src/common/api/companies'; import { useIdentity } from '@/src/common/api/identity'; @@ -1020,3 +1023,54 @@ export const useUpdateInvoiceSchedule = () => { }, }); }; + +/** + * 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({ + mutationFn: async ({ scheduleId }: { scheduleId: string }) => { + const payload: UpdateScheduleContractorInvoiceParams = { + status: 'deleted', + }; + + return patchV1ContractorInvoiceSchedulesId2({ + client: client as Client, + path: { id: scheduleId }, + body: payload, + }); + }, + }); +}; + +/** + * Previews a contractor invoice as a draft PDF, without persisting it. + * @param employmentId - The employment ID + * @param values - The form values containing invoice details + * @returns The base64-encoded PDF preview + */ +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, + }); + }, + }); +}; diff --git a/src/flows/ContractorOnboarding/components/PreviewInvoiceButton.tsx b/src/flows/ContractorOnboarding/components/PreviewInvoiceButton.tsx new file mode 100644 index 000000000..64817e67e --- /dev/null +++ b/src/flows/ContractorOnboarding/components/PreviewInvoiceButton.tsx @@ -0,0 +1,72 @@ +import { useFormFields } from '@/src/context'; +import { useContractorOnboardingContext } from '@/src/flows/ContractorOnboarding/context'; +import { ButtonHTMLAttributes } from 'react'; +import { NormalizedFieldError } from '@/src/lib/mutations'; +import { handleStepError } from '@/src/lib/utils'; +import { ContractorInvoicePreview } from '@/src/flows/ContractorOnboarding/types'; + +type PreviewInvoiceButtonProps = Omit< + ButtonHTMLAttributes, + 'onError' +> & { + onSuccess?: (data: ContractorInvoicePreview) => void | Promise; + onError?: ({ + error, + rawError, + fieldErrors, + }: { + error: Error; + rawError: Record; + fieldErrors: NormalizedFieldError[]; + }) => void; +}; + +/** + * Generates a draft (non-persisted) PDF preview of a contractor invoice from + * the current, unsaved create_invoice_schedule form values. + */ +export const PreviewInvoiceButton = ({ + onSuccess, + onError, + className, + children, + disabled = false, + ...props +}: PreviewInvoiceButtonProps) => { + const { contractorOnboardingBag } = useContractorOnboardingContext(); + + const { components } = useFormFields(); + + const handlePreview = async () => { + try { + const preview = await contractorOnboardingBag.previewContractorInvoice( + contractorOnboardingBag.fieldValues, + ); + if (preview) { + await onSuccess?.(preview); + } + } catch (error: unknown) { + const structuredError = handleStepError( + error, + contractorOnboardingBag.meta?.fields?.create_invoice_schedule, + ); + onError?.(structuredError); + } + }; + + const CustomButton = components?.button; + if (!CustomButton) { + throw new Error(`Button component not found`); + } + + return ( + + {children} + + ); +}; diff --git a/src/flows/ContractorOnboarding/components/SkipInvoiceScheduleButton.tsx b/src/flows/ContractorOnboarding/components/SkipInvoiceScheduleButton.tsx new file mode 100644 index 000000000..b3142a13a --- /dev/null +++ b/src/flows/ContractorOnboarding/components/SkipInvoiceScheduleButton.tsx @@ -0,0 +1,72 @@ +import { useFormFields } from '@/src/context'; +import { useContractorOnboardingContext } from '@/src/flows/ContractorOnboarding/context'; +import { ButtonHTMLAttributes } from 'react'; +import { NormalizedFieldError } from '@/src/lib/mutations'; +import { handleStepError } from '@/src/lib/utils'; + +type SkipInvoiceScheduleButtonProps = Omit< + ButtonHTMLAttributes, + 'onError' +> & { + onSuccess?: () => void | Promise; + onError?: ({ + error, + rawError, + fieldErrors, + }: { + error: Error; + rawError: Record; + fieldErrors: NormalizedFieldError[]; + }) => void; +}; + +/** + * Cancels the existing scheduled invoice (marks it as `deleted`) and returns + * the wizard to the invoice_schedule step. Only meaningful once an existing + * schedule has been loaded on the create_invoice_schedule step. + */ +export const SkipInvoiceScheduleButton = ({ + onSuccess, + onError, + className, + children, + disabled = false, + ...props +}: SkipInvoiceScheduleButtonProps) => { + const { contractorOnboardingBag } = useContractorOnboardingContext(); + + const { components } = useFormFields(); + + const handleSkip = async () => { + try { + await contractorOnboardingBag.skipInvoiceSchedule(); + await onSuccess?.(); + } catch (error: unknown) { + const structuredError = handleStepError( + error, + contractorOnboardingBag.meta?.fields?.create_invoice_schedule, + ); + onError?.(structuredError); + } + }; + + const CustomButton = components?.button; + if (!CustomButton) { + throw new Error(`Button component not found`); + } + + return ( + + {children} + + ); +}; diff --git a/src/flows/ContractorOnboarding/hooks.tsx b/src/flows/ContractorOnboarding/hooks.tsx index 6e5008fc1..b0a9cb9ee 100644 --- a/src/flows/ContractorOnboarding/hooks.tsx +++ b/src/flows/ContractorOnboarding/hooks.tsx @@ -36,6 +36,8 @@ import { useGetCreateInvoiceScheduleSchema, useCreateInvoiceSchedule, useUpdateInvoiceSchedule, + useSkipInvoiceSchedule, + usePreviewContractorInvoice, useGetExistingInvoiceSchedule, } from '@/src/flows/ContractorOnboarding/api'; import { useContractorContractDetailsSchema } from '@/src/common/api/contractor-contract-details'; @@ -74,7 +76,10 @@ import { buildContractPreviewJsfModify, } from '@/src/flows/ContractorOnboarding/jsfModify'; import { transformAiErrorResponse } from '@/src/flows/ContractorOnboarding/utils'; -import { AiValidationError } from '@/src/flows/ContractorOnboarding/types'; +import { + AiValidationError, + ContractorInvoicePreview, +} from '@/src/flows/ContractorOnboarding/types'; import { useUploadFile } from '@/src/common/api/files'; import { dataURLtoFile } from '@/src/lib/files'; import { @@ -296,6 +301,8 @@ export const useContractorOnboarding = ({ const setContractOriginMutation = useSetContractOrigin(); const createInvoiceScheduleMutation = useCreateInvoiceSchedule(); const updateInvoiceScheduleMutation = useUpdateInvoiceSchedule(); + const skipInvoiceScheduleMutation = useSkipInvoiceSchedule(); + const previewContractorInvoiceMutation = usePreviewContractorInvoice(); const { mutateAsyncOrThrow: updateEmploymentMutationAsync } = mutationToPromise(updateEmploymentMutation); @@ -335,6 +342,12 @@ export const useContractorOnboarding = ({ const { mutateAsyncOrThrow: updateInvoiceScheduleMutationAsync } = mutationToPromise(updateInvoiceScheduleMutation); + const { mutateAsyncOrThrow: skipInvoiceScheduleMutationAsync } = + mutationToPromise(skipInvoiceScheduleMutation); + + const { mutateAsyncOrThrow: previewContractorInvoiceMutationAsync } = + mutationToPromise(previewContractorInvoiceMutation); + // if the employment is loaded, country code has not been set yet // we set the internal country code with the employment country code if (employmentId && employment?.country?.code && !internalCountryCode) { @@ -1612,6 +1625,46 @@ export const useContractorOnboarding = ({ onContractReviewedRef.current?.(); }; + const skipInvoiceSchedule = async () => { + if (!existingInvoiceSchedule?.id) { + throw createStructuredError('No invoice schedule to skip'); + } + + await skipInvoiceScheduleMutationAsync({ + scheduleId: existingInvoiceSchedule.id, + }); + await refetchInvoiceSchedule(); + setIncludeCreateInvoiceSchedule(false); + setStepValues({ + ...stepState.values, + invoice_schedule: {}, + create_invoice_schedule: {}, + } as Record); + goToStep('invoice_schedule'); + }; + + const previewContractorInvoice = async (values: FieldValues) => { + if (!internalEmploymentId) { + throw createStructuredError('Employment ID is required'); + } + + // fieldValues are the raw, unparsed form values (e.g. money fields still + // in display units) — run them through the same parsing/type-coercion + // pipeline a real submit would use before building the preview payload. + const parsedValues = await parseFormValues(values); + + const response = await previewContractorInvoiceMutationAsync({ + employmentId: internalEmploymentId, + values: parsedValues, + }); + + // The API returns `content` as a `data:application/pdf;base64,...` string, + // not the `Blob | File` the OpenAPI spec's `format: binary` implies. + return response?.data?.contractor_invoice_preview as + | ContractorInvoicePreview + | undefined; + }; + const handleNextStep = () => { if (internalEmploymentId) { refetchEmployment(); @@ -1788,6 +1841,36 @@ export const useContractorOnboarding = ({ */ markContractAsReviewed, + /** + * The existing invoice schedule for the current employment, if any. + */ + existingInvoiceSchedule, + + /** + * Skips (cancels) the existing invoice schedule and returns to the + * invoice_schedule step. + * @returns {Promise} + */ + skipInvoiceSchedule, + + /** + * Whether a skip-invoice-schedule request is in flight. + */ + isSkippingInvoiceSchedule: skipInvoiceScheduleMutation.isPending, + + /** + * Previews a contractor invoice as a draft PDF from the given (typically + * unsaved) form values, without persisting anything. + * @param values - Form values to build the preview from + * @returns {Promise<{name: string; content: string} | undefined>} + */ + previewContractorInvoice, + + /** + * Whether a preview-contractor-invoice request is in flight. + */ + isPreviewingInvoiceSchedule: previewContractorInvoiceMutation.isPending, + /** * Function to handle going back to the previous step * @returns {void} diff --git a/src/flows/ContractorOnboarding/index.ts b/src/flows/ContractorOnboarding/index.ts index 5fb86743e..72130a27b 100644 --- a/src/flows/ContractorOnboarding/index.ts +++ b/src/flows/ContractorOnboarding/index.ts @@ -12,6 +12,7 @@ export type { EligibilityQuestionnaireResponse, InvoiceScheduleFormPayload, InvoiceScheduleResponse, + ContractorInvoicePreview, } from './types'; export type { ProductType } from './constants'; export { diff --git a/src/flows/ContractorOnboarding/invoiceScheduleConstants.ts b/src/flows/ContractorOnboarding/invoiceScheduleConstants.ts index c03f73abf..72dfe17c6 100644 --- a/src/flows/ContractorOnboarding/invoiceScheduleConstants.ts +++ b/src/flows/ContractorOnboarding/invoiceScheduleConstants.ts @@ -11,4 +11,5 @@ export const INVOICE_SCHEDULE_STATUS = { COMPLETED: 'completed', GENERATION_FAILED_UNRELATED_TO_WITHDRAWAL_METHOD: 'generation_failed_unrelated_to_withdrawal_method', + DELETED: 'deleted', } as const; diff --git a/src/flows/ContractorOnboarding/tests/ContractorOnboarding.test.tsx b/src/flows/ContractorOnboarding/tests/ContractorOnboarding.test.tsx index 637c0bcc4..6a51d99d2 100644 --- a/src/flows/ContractorOnboarding/tests/ContractorOnboarding.test.tsx +++ b/src/flows/ContractorOnboarding/tests/ContractorOnboarding.test.tsx @@ -252,6 +252,8 @@ describe('ContractorOnboardingFlow', () => { BackButton, OnboardingInvite, ContractReviewButton, + SkipInvoiceScheduleButton, + PreviewInvoiceButton, } = components; if (contractorOnboardingBag.isLoading) { @@ -370,6 +372,18 @@ describe('ContractorOnboardingFlow', () => { /> Back Continue + + Skip + + + Preview + ); @@ -4459,5 +4473,294 @@ describe('ContractorOnboardingFlow', () => { expect(createScheduleSection).toHaveTextContent('item_2_amount: 7500'); }); }); + + it('should skip an existing invoice schedule and return to the invoice_schedule step', async () => { + const employmentId = generateUniqueEmploymentId(); + const scheduleId = '345b0f1b-5254-4f17-b235-528bc9529055'; + let getInvoiceSchedulesCallCount = 0; + let patchRequestBody: Record | undefined; + + server.use( + http.get(`*/v1/employments/${employmentId}`, () => { + return HttpResponse.json({ + ...mockContractorEmploymentResponse, + data: { + ...mockContractorEmploymentResponse.data, + employment: { + ...mockContractorEmploymentResponse.data.employment, + id: employmentId, + status: 'created', + }, + }, + }); + }), + http.get('*/v1/contractor-invoice-schedules', () => { + getInvoiceSchedulesCallCount++; + const hasSchedule = getInvoiceSchedulesCallCount === 1; + return HttpResponse.json({ + data: { + total_count: hasSchedule ? 1 : 0, + current_page: 1, + total_pages: hasSchedule ? 1 : 0, + contractor_invoice_schedules: hasSchedule + ? [ + { + id: scheduleId, + status: 'pending_contractor_action', + number: '1234', + items: [{ description: 'salary', amount: 250000 }], + currency: 'EUR', + start_date: '2026-09-17', + employment_id: employmentId, + note: 'note', + total_amount: 250000, + periodicity: 'weekly', + nr_occurrences: null, + next_invoice_at: null, + }, + ] + : [], + }, + }); + }), + http.patch( + '*/v1/contractor-invoice-schedules/*', + async ({ request }) => { + patchRequestBody = (await request.json()) as Record< + string, + unknown + >; + return HttpResponse.json({ + data: { + total_count: 1, + current_page: 1, + total_pages: 1, + contractor_invoice_schedules: [ + { id: scheduleId, ...patchRequestBody }, + ], + }, + }); + }, + ), + ); + + mockRender.mockImplementation( + createMockRenderImplementation(MultiStepFormWithoutCountry), + ); + + render( + + + , + { wrapper: TestProviders }, + ); + + await screen.findByText('Step: Basic Information'); + + await fillBasicInformation(); + let nextButton = screen.getByText(/Next Step/i); + nextButton.click(); + + await screen.findByText('Step: Pricing Plan'); + await fillContractorSubscription('Contractor Management'); + nextButton = screen.getByText(/Next Step/i); + nextButton.click(); + + await screen.findByText('Step: Contract Origin'); + await fillContractOrigin('Without an agreement'); + nextButton = screen.getByText(/Next Step/i); + nextButton.click(); + + await screen.findByText('Step: Invoice schedule'); + const createScheduleRadio = await screen.findByRole('radio', { + name: /create invoice schedule now/i, + }); + fireEvent.click(createScheduleRadio); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await screen.findByText('Step: Create Invoice Schedule'); + await waitFor(() => { + expect(screen.getByLabelText(/Invoice currency/i)).toHaveValue('EUR'); + }); + + const skipButton = screen.getByRole('button', { name: /^skip$/i }); + fireEvent.click(skipButton); + + await waitFor(() => { + expect(patchRequestBody).toEqual({ status: 'deleted' }); + }); + + await screen.findByText('Step: Invoice schedule'); + await waitFor(() => { + expect(getInvoiceSchedulesCallCount).toBeGreaterThanOrEqual(2); + }); + }); + + it('should preview a contractor invoice using unsaved form values', async () => { + const employmentId = generateUniqueEmploymentId(); + let previewRequestBody: Record | undefined; + + server.use( + http.post( + `*/v1/employments/*/contractor-invoices/preview`, + async ({ request }) => { + previewRequestBody = (await request.json()) as Record< + string, + unknown + >; + return HttpResponse.json({ + data: { + contractor_invoice_preview: { + name: 'invoice-preview.pdf', + content: 'data:application/pdf;base64,JVBERi0xLjQK', + }, + }, + }); + }, + ), + ); + + mockRender.mockImplementation( + createMockRenderImplementation(MultiStepFormWithoutCountry), + ); + + render( + + + , + { wrapper: TestProviders }, + ); + + await screen.findByText('Step: Basic Information'); + + await fillBasicInformation(); + let nextButton = screen.getByText(/Next Step/i); + nextButton.click(); + + await screen.findByText('Step: Pricing Plan'); + await fillContractorSubscription('Contractor Management'); + nextButton = screen.getByText(/Next Step/i); + nextButton.click(); + + await screen.findByText('Step: Contract Origin'); + await fillContractOrigin('Without an agreement'); + nextButton = screen.getByText(/Next Step/i); + nextButton.click(); + + await screen.findByText('Step: Invoice schedule'); + const createScheduleRadio = await screen.findByRole('radio', { + name: /create invoice schedule now/i, + }); + fireEvent.click(createScheduleRadio); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await screen.findByText('Step: Create Invoice Schedule'); + await fillCreateInvoiceSchedule(); + + const previewButton = screen.getByRole('button', { name: /^preview$/i }); + fireEvent.click(previewButton); + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalledWith({ + name: 'invoice-preview.pdf', + content: 'data:application/pdf;base64,JVBERi0xLjQK', + }); + }); + + expect(previewRequestBody).toMatchObject({ + currency: 'EUR', + start_date: expect.any(String), + items: [{ description: 'Salary', amount: 250000 }], + }); + expect(previewRequestBody).not.toHaveProperty('periodicity'); + expect(previewRequestBody).not.toHaveProperty('nr_occurrences'); + }); + + it('should surface normalized field errors when previewing fails', async () => { + const employmentId = generateUniqueEmploymentId(); + + server.use( + http.post( + `*/v1/employments/*/contractor-invoices/preview`, + async () => { + return HttpResponse.json( + { errors: { currency: ["can't be blank"] } }, + { status: 422 }, + ); + }, + ), + ); + + mockRender.mockImplementation( + createMockRenderImplementation(MultiStepFormWithoutCountry), + ); + + render( + + + , + { wrapper: TestProviders }, + ); + + await screen.findByText('Step: Basic Information'); + + await fillBasicInformation(); + let nextButton = screen.getByText(/Next Step/i); + nextButton.click(); + + await screen.findByText('Step: Pricing Plan'); + await fillContractorSubscription('Contractor Management'); + nextButton = screen.getByText(/Next Step/i); + nextButton.click(); + + await screen.findByText('Step: Contract Origin'); + await fillContractOrigin('Without an agreement'); + nextButton = screen.getByText(/Next Step/i); + nextButton.click(); + + await screen.findByText('Step: Invoice schedule'); + const createScheduleRadio = await screen.findByRole('radio', { + name: /create invoice schedule now/i, + }); + fireEvent.click(createScheduleRadio); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await screen.findByText('Step: Create Invoice Schedule'); + await fillCreateInvoiceSchedule(); + + const previewButton = screen.getByRole('button', { name: /^preview$/i }); + fireEvent.click(previewButton); + + await waitFor(() => { + expect(mockOnError).toHaveBeenCalled(); + }); + const [{ fieldErrors }] = mockOnError.mock.calls[ + mockOnError.mock.calls.length - 1 + ] as [{ fieldErrors: { field: string }[] }]; + expect( + fieldErrors.some((fieldError) => fieldError.field === 'currency'), + ).toBe(true); + }); }); }); diff --git a/src/flows/ContractorOnboarding/tests/utils.test.ts b/src/flows/ContractorOnboarding/tests/utils.test.ts index f226be238..18f67176f 100644 --- a/src/flows/ContractorOnboarding/tests/utils.test.ts +++ b/src/flows/ContractorOnboarding/tests/utils.test.ts @@ -1,6 +1,7 @@ import { shouldIncludeProduct, getBasicInformationSchemaVersion, + buildInvoicePreviewPayload, } from '../utils'; import { corProductIdentifier, eorProductIdentifier } from '../constants'; @@ -18,6 +19,46 @@ describe('shouldIncludeProduct', () => { }); }); +describe('buildInvoicePreviewPayload', () => { + it('builds the base payload from currency, start_date and items', () => { + const payload = buildInvoicePreviewPayload({ + currency: 'EUR', + start_date: '2026-06-01', + item_1_description: 'Consulting work', + item_1_amount: 250000, + }); + + expect(payload).toEqual({ + currency: 'EUR', + start_date: '2026-06-01', + items: [{ description: 'Consulting work', amount: 250000 }], + }); + }); + + it('includes number and note only when present', () => { + const payload = buildInvoicePreviewPayload({ + currency: 'EUR', + start_date: '2026-06-01', + number: '1234', + note: 'A note', + }); + + expect(payload).toMatchObject({ number: '1234', note: 'A note' }); + }); + + it('omits periodicity and nr_occurrences even when present in values', () => { + const payload = buildInvoicePreviewPayload({ + currency: 'EUR', + start_date: '2026-06-01', + periodicity: 'monthly', + nr_occurrences: 5, + }); + + expect(payload).not.toHaveProperty('periodicity'); + expect(payload).not.toHaveProperty('nr_occurrences'); + }); +}); + describe('getBasicInformationSchemaVersion', () => { it('should return version 1 by default', () => { expect(getBasicInformationSchemaVersion(undefined)).toEqual(1); diff --git a/src/flows/ContractorOnboarding/types.ts b/src/flows/ContractorOnboarding/types.ts index b792ac441..63c419081 100644 --- a/src/flows/ContractorOnboarding/types.ts +++ b/src/flows/ContractorOnboarding/types.ts @@ -20,6 +20,8 @@ import { InvoiceScheduleStep } from '@/src/flows/ContractorOnboarding/components import { CreateInvoiceScheduleStep } from '@/src/flows/ContractorOnboarding/components/CreateInvoiceScheduleStep'; import { ProductType } from '@/src/flows/ContractorOnboarding/constants'; import { SaveDraftButton } from '@/src/flows/ContractorOnboarding/components/SaveDraftButton'; +import { SkipInvoiceScheduleButton } from '@/src/flows/ContractorOnboarding/components/SkipInvoiceScheduleButton'; +import { PreviewInvoiceButton } from '@/src/flows/ContractorOnboarding/components/PreviewInvoiceButton'; export type ContractorOnboardingRenderProps = { /** @@ -56,6 +58,8 @@ export type ContractorOnboardingRenderProps = { ContractReviewButton: typeof ContractReviewButton; EligibilityQuestionnaireStep: typeof EligibilityQuestionnaireStep; SaveDraftButton: typeof SaveDraftButton; + SkipInvoiceScheduleButton: typeof SkipInvoiceScheduleButton; + PreviewInvoiceButton: typeof PreviewInvoiceButton; }; }; @@ -198,3 +202,14 @@ export type InvoiceScheduleFormPayload = { export type InvoiceScheduleResponse = { invoiceSchedulePreference: string; }; + +/** + * A draft (non-persisted) contractor invoice preview document. + */ +export type ContractorInvoicePreview = { + name: string; + /** + * A `data:application/pdf;base64,...` data URI. + */ + content: string; +}; diff --git a/src/flows/ContractorOnboarding/utils.ts b/src/flows/ContractorOnboarding/utils.ts index 7acba313b..5c865cc8d 100644 --- a/src/flows/ContractorOnboarding/utils.ts +++ b/src/flows/ContractorOnboarding/utils.ts @@ -272,3 +272,31 @@ export function buildInvoiceSchedulePayload( return payload; } + +/** + * Builds the payload for previewing a contractor invoice from form values. + * Unlike buildInvoiceSchedulePayload, the preview endpoint previews a single + * invoice, not a recurring schedule, so it doesn't accept periodicity or + * nr_occurrences. + * @param values - Form values containing invoice schedule data + * @returns Invoice preview payload object + */ +export function buildInvoicePreviewPayload( + values: Record, +): Record { + const payload: Record = { + currency: values.currency, + start_date: values.start_date, + items: buildInvoiceItems(values), + }; + + if (values.number) { + payload.number = String(values.number); + } + + if (values.note) { + payload.note = values.note; + } + + return payload; +} diff --git a/src/index.tsx b/src/index.tsx index 95163a77d..ec2021a2b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -89,6 +89,7 @@ export type { EligibilityQuestionnaireResponse, InvoiceScheduleFormPayload, InvoiceScheduleResponse, + ContractorInvoicePreview, } from '@/src/flows/ContractorOnboarding'; export type { ContractPreviewStatementProps } from '@/src/flows/ContractorOnboarding/components/ContractPreviewStatement'; diff --git a/src/tests/handlers.ts b/src/tests/handlers.ts index f6f2507e0..1b8bfb1e2 100644 --- a/src/tests/handlers.ts +++ b/src/tests/handlers.ts @@ -298,6 +298,20 @@ const updateContractorInvoiceScheduleHandler = http.patch( }, ); +const previewContractorInvoiceHandler = http.post( + '*/v1/employments/*/contractor-invoices/preview', + async () => { + return HttpResponse.json({ + data: { + contractor_invoice_preview: { + name: 'invoice-preview.pdf', + content: 'data:application/pdf;base64,JVBERi0xLjQK', + }, + }, + }); + }, +); + export const defaultHandlers = [ identityHandler, legalEntitiesHandler, @@ -330,4 +344,5 @@ export const defaultHandlers = [ contractOriginHandler, createContractorInvoiceScheduleHandler, updateContractorInvoiceScheduleHandler, + previewContractorInvoiceHandler, ];