diff --git a/changelog/dev-session-fixes b/changelog/dev-session-fixes new file mode 100644 index 00000000000..aa73f7a216c --- /dev/null +++ b/changelog/dev-session-fixes @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Some refactors to embedded KYC logic. diff --git a/client/onboarding/steps/embedded-kyc.tsx b/client/onboarding/steps/embedded-kyc.tsx index 68981261efd..dd41c11b335 100644 --- a/client/onboarding/steps/embedded-kyc.tsx +++ b/client/onboarding/steps/embedded-kyc.tsx @@ -1,51 +1,40 @@ /** * External dependencies */ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { loadConnectAndInitialize, StripeConnectInstance, } from '@stripe/connect-js'; -import { LoadError } from '@stripe/connect-js/types/config'; import { ConnectAccountOnboarding, ConnectComponentsProvider, } from '@stripe/react-connect-js'; -import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; -import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ -import { NAMESPACE } from 'data/constants'; import appearance from '../kyc/appearance'; import BannerNotice from 'wcpay/components/banner-notice'; import LoadBar from 'wcpay/components/load-bar'; import { useOnboardingContext } from 'wcpay/onboarding/context'; import { - AccountKycSession, - PoEligibleData, - PoEligibleResult, -} from 'wcpay/onboarding/types'; -import { fromDotNotation } from 'wcpay/onboarding/utils'; + createAccountSession, + finalizeOnboarding, + isPoEligible, +} from 'wcpay/onboarding/utils'; import { getConnectUrl, getOverviewUrl } from 'wcpay/utils'; -type AccountKycSessionData = AccountKycSession; - -interface FinalizeResponse { - success: boolean; - params: Record< string, string >; -} - interface Props { continueKyc?: boolean; } +// TODO: extract this logic and move it to a generic component to be used for all embedded components, not just onboarding. const EmbeddedKyc: React.FC< Props > = ( { continueKyc = false } ) => { const { data } = useOnboardingContext(); - const [ publishableKey, setPublishableKey ] = useState( '' ); const [ locale, setLocale ] = useState( '' ); + const [ publishableKey, setPublishableKey ] = useState( '' ); const [ clientSecret, setClientSecret ] = useState< ( () => Promise< string > ) | null >( null ); @@ -55,101 +44,81 @@ const EmbeddedKyc: React.FC< Props > = ( { continueKyc = false } ) => { ] = useState< StripeConnectInstance | null >( null ); const [ loading, setLoading ] = useState( true ); const [ loadErrorMessage, setLoadErrorMessage ] = useState( '' ); - const onLoaderStart = () => { - setLoading( false ); - }; - const onLoadError = ( loadError: LoadError ) => { - setLoadErrorMessage( loadError.error.message || 'Unknown error' ); - }; - useEffect( () => { - const isEligibleForPo = async () => { - if ( - ! data.country || - ! data.business_type || - ! data.mcc || - ! data.annual_revenue || - ! data.go_live_timeframe - ) { - return false; + const fetchAccountSession = useCallback( async () => { + try { + const isEligible = ! continueKyc && ( await isPoEligible( data ) ); + const accountSession = await createAccountSession( + data, + isEligible + ); + if ( accountSession && accountSession.clientSecret ) { + return accountSession; // Return the full account session object } - const eligibilityDetails: PoEligibleData = { - business: { - country: data.country, - type: data.business_type, - mcc: data.mcc, - }, - store: { - annual_revenue: data.annual_revenue, - go_live_timeframe: data.go_live_timeframe, - }, - }; - try { - const eligibleResult = await apiFetch< PoEligibleResult >( { - path: '/wc/v3/payments/onboarding/router/po_eligible', - method: 'POST', - data: eligibilityDetails, - } ); - - return 'eligible' === eligibleResult.result; - } catch ( error ) { - // Fall back to full KYC scenario. - return false; - } - }; + setLoading( false ); + setLoadErrorMessage( + __( + "Failed to create account session. Please check that you're using the latest version of WooPayments.", + 'woocommerce-payments' + ) + ); + } catch ( error ) { + setLoading( false ); + setLoadErrorMessage( + __( + 'Failed to retrieve account session. Please try again later.', + 'woocommerce-payments' + ) + ); + } - const fetchKeys = async () => { - // By default, we assume the merchant is not eligible for PO. - let isEligible = false; + // Return null if an error occurred. + return null; + }, [ continueKyc, data ] ); - // If we are resuming an onboarding session, we don't need to check for PO eligibility again. - if ( ! continueKyc ) { - isEligible = await isEligibleForPo(); - } + // Function to fetch clientSecret for use in Stripe auto-refresh or initialization + const fetchClientSecret = useCallback( async () => { + const accountSession = await fetchAccountSession(); + if ( accountSession ) { + return accountSession.clientSecret; // Only return the clientSecret + } + throw new Error( 'Error fetching the client secret' ); + }, [ fetchAccountSession ] ); - const path = addQueryArgs( - `${ NAMESPACE }/onboarding/kyc/session`, - { - self_assessment: fromDotNotation( data ), - progressive: isEligible, + // Effect to fetch the publishable key and clientSecret on initial render + useEffect( () => { + const fetchKeys = async () => { + try { + const accountSession = await fetchAccountSession(); + if ( accountSession ) { + setLocale( accountSession.locale ); + setPublishableKey( accountSession.publishableKey ); + setClientSecret( () => fetchClientSecret ); } - ); - const accountSession = await apiFetch< AccountKycSessionData >( { - path: path, - method: 'GET', - } ); - if ( - accountSession.publishableKey && - accountSession.clientSecret - ) { - setPublishableKey( accountSession.publishableKey ); - setLocale( accountSession.locale ); - setClientSecret( () => () => - Promise.resolve( accountSession.clientSecret ) - ); // Ensure clientSecret is wrapped as a function returning a Promise - } else { + } catch ( error ) { setLoading( false ); setLoadErrorMessage( __( - "Failed to create account session. Please check that you're using the latest version of WooPayments.", + 'Failed to create account session. Please check that you are using the latest version of WooPayments.', 'woocommerce-payments' ) ); + } finally { + setLoading( false ); } }; fetchKeys(); - }, [ data, continueKyc ] ); + }, [ data, continueKyc, fetchAccountSession, fetchClientSecret ] ); - // Initialize the Stripe Connect instance only once when publishableKey and clientSecret are ready + // Effect to initialize the Stripe Connect instance once publishableKey and clientSecret are ready. useEffect( () => { if ( publishableKey && clientSecret && ! stripeConnectInstance ) { const stripeInstance = loadConnectAndInitialize( { - publishableKey: publishableKey, - fetchClientSecret: clientSecret, // Pass the function returning the Promise + publishableKey, + fetchClientSecret, appearance: { - // See all possible variables below overlays: 'drawer', variables: appearance.variables, }, @@ -158,7 +127,48 @@ const EmbeddedKyc: React.FC< Props > = ( { continueKyc = false } ) => { setStripeConnectInstance( stripeInstance ); } - }, [ publishableKey, clientSecret, stripeConnectInstance, locale ] ); + }, [ + publishableKey, + clientSecret, + stripeConnectInstance, + fetchClientSecret, + locale, + ] ); + + const handleOnExit = async () => { + const urlParams = new URLSearchParams( window.location.search ); + const urlSource = + urlParams.get( 'source' )?.replace( /[^\w-]+/g, '' ) || 'unknown'; + + try { + const response = await finalizeOnboarding( urlSource ); + if ( response.success ) { + window.location.href = getOverviewUrl( + { + ...response.params, + 'wcpay-connection-success': '1', + }, + 'WCPAY_ONBOARDING_WIZARD' + ); + } else { + window.location.href = getConnectUrl( + { + ...response.params, + 'wcpay-connection-error': '1', + }, + 'WCPAY_ONBOARDING_WIZARD' + ); + } + } catch ( error ) { + window.location.href = getConnectUrl( + { + 'wcpay-connection-error': '1', + source: urlSource, + }, + 'WCPAY_ONBOARDING_WIZARD' + ); + } + }; return ( <> @@ -171,59 +181,13 @@ const EmbeddedKyc: React.FC< Props > = ( { continueKyc = false } ) => { connectInstance={ stripeConnectInstance } > { - const urlParams = new URLSearchParams( - window.location.search - ); - const urlSource = - urlParams - .get( 'source' ) - ?.replace( /[^\w-]+/g, '' ) || 'unknown'; - try { - const response = await apiFetch< - FinalizeResponse - >( { - path: `${ NAMESPACE }/onboarding/kyc/finalize`, - method: 'POST', - data: { - source: urlSource, - from: 'WCPAY_ONBOARDING_WIZARD', - clientSecret: clientSecret, - }, - } ); - - if ( response.success ) { - window.location.href = getOverviewUrl( - { - ...response.params, - 'wcpay-connection-success': '1', - }, - 'WCPAY_ONBOARDING_WIZARD' - ); - } else { - // If a non-success response is received we should redirect to the Connect page with an error flag: - window.location.href = getConnectUrl( - { - ...response.params, - 'wcpay-connection-error': '1', - }, - 'WCPAY_ONBOARDING_WIZARD' - ); - } - } catch ( error ) { - // If an error response is received we should redirect to the Connect page with an error flag: - // Note that this should never happen, since we always expect a response from the server. - window.location.href = getConnectUrl( - { - 'wcpay-connection-error': '1', - source: urlSource, - }, - 'WCPAY_ONBOARDING_WIZARD' - ); - } - } } + onLoaderStart={ () => setLoading( false ) } + onLoadError={ ( loadError ) => + setLoadErrorMessage( + loadError.error.message || 'Unknown error' + ) + } + onExit={ handleOnExit } /> ) } diff --git a/client/onboarding/steps/loading.tsx b/client/onboarding/steps/loading.tsx index c7b08dc905b..876591205e1 100644 --- a/client/onboarding/steps/loading.tsx +++ b/client/onboarding/steps/loading.tsx @@ -9,7 +9,7 @@ import apiFetch from '@wordpress/api-fetch'; * Internal dependencies */ import { useOnboardingContext } from '../context'; -import { PoEligibleData, PoEligibleResult } from '../types'; +import { PoEligibleData, PoEligibleResponse } from '../types'; import { fromDotNotation } from '../utils'; import { trackRedirected, useTrackAbandoned } from '../tracking'; import LoadBar from 'components/load-bar'; @@ -45,7 +45,7 @@ const LoadingStep: React.FC< Props > = () => { go_live_timeframe: data.go_live_timeframe, }, }; - const eligibleResult = await apiFetch< PoEligibleResult >( { + const eligibleResult = await apiFetch< PoEligibleResponse >( { path: '/wc/v3/payments/onboarding/router/po_eligible', method: 'POST', data: eligibilityDetails, diff --git a/client/onboarding/types.ts b/client/onboarding/types.ts index c2781390524..9a07a3063b3 100644 --- a/client/onboarding/types.ts +++ b/client/onboarding/types.ts @@ -13,16 +13,7 @@ export type OnboardingFields = { go_live_timeframe?: string; }; -export interface OnboardingProps { - country?: string; - type?: string; - structure?: string; - mcc?: string; - annual_revenue?: string; - go_live_timeframe?: string; -} - -export interface PoEligibleResult { +export interface PoEligibleResponse { result: 'eligible' | 'not_eligible'; } @@ -74,3 +65,8 @@ export interface AccountKycSession { publishableKey: string; locale: string; } + +export interface FinalizeOnboardingResponse { + success: boolean; + params: Record< string, string >; +} diff --git a/client/onboarding/utils.ts b/client/onboarding/utils.ts index a427f3c3a93..efab0971875 100644 --- a/client/onboarding/utils.ts +++ b/client/onboarding/utils.ts @@ -2,13 +2,23 @@ * External dependencies */ import { set, toPairs } from 'lodash'; +import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ +import { NAMESPACE } from 'data/constants'; import { ListItem } from 'components/grouped-select-control'; import businessTypeDescriptionStrings from './translations/descriptions'; -import { Country } from './types'; +import { + AccountKycSession, + Country, + OnboardingFields, + PoEligibleData, + PoEligibleResponse, + FinalizeOnboardingResponse, +} from './types'; +import { addQueryArgs } from '@wordpress/url'; export const fromDotNotation = ( record: Record< string, unknown > @@ -17,6 +27,9 @@ export const fromDotNotation = ( return value != null ? set( result, key, value ) : result; }, {} ); +const hasUndefinedValues = ( obj: Record< string, any > ): boolean => + Object.values( obj ).some( ( value ) => value === undefined ); + export const getAvailableCountries = (): Country[] => Object.entries( wcpaySettings?.connect.availableCountries || [] ) .map( ( [ key, name ] ) => ( { key, name, types: [] } ) ) @@ -42,6 +55,86 @@ export const getBusinessTypes = (): Country[] => { ); }; +/** + * Make an API request to create an account session. + * + * @param data The form data. + * @param isPoEligible Whether the user is eligible for a PO account. + * @param collectPayoutRequirements Whether to collect payout requirements. + */ +export const createAccountSession = async ( + data: OnboardingFields, + isPoEligible: boolean, + collectPayoutRequirements = false +): Promise< AccountKycSession > => { + return await apiFetch< AccountKycSession >( { + path: addQueryArgs( `${ NAMESPACE }/onboarding/kyc/session`, { + self_assessment: fromDotNotation( data ), + progressive: isPoEligible, + collect_payout_requirements: collectPayoutRequirements, + } ), + method: 'GET', + } ); +}; + +/** + * Make an API request to finalize the onboarding process. + * + * @param urlSource The source URL. + */ +export const finalizeOnboarding = async ( urlSource: string ) => { + return await apiFetch< FinalizeOnboardingResponse >( { + path: `${ NAMESPACE }/onboarding/kyc/finalize`, + method: 'POST', + data: { + source: urlSource, + from: 'WCPAY_ONBOARDING_WIZARD', + }, + } ); +}; + +/** + * Make an API request to determine if the user is eligible for a PO account. + * + * @param onboardingFields The form data, used to determine eligibility. + */ +export const isPoEligible = async ( + onboardingFields: OnboardingFields +): Promise< boolean > => { + // Check if any required property is undefined + if ( + hasUndefinedValues( { + country: onboardingFields.country, + business_type: onboardingFields.business_type, + mcc: onboardingFields.mcc, + annual_revenue: onboardingFields.annual_revenue, + go_live_timeframe: onboardingFields.go_live_timeframe, + } ) + ) { + return false; + } + + const eligibilityData: PoEligibleData = { + business: { + country: onboardingFields.country as string, + type: onboardingFields.business_type as string, + mcc: onboardingFields.mcc as string, + }, + store: { + annual_revenue: onboardingFields.annual_revenue as string, + go_live_timeframe: onboardingFields.go_live_timeframe as string, + }, + }; + + const response: PoEligibleResponse = await apiFetch( { + path: `${ NAMESPACE }/onboarding/router/po_eligible`, + method: 'POST', + data: eligibilityData, + } ); + + return response.result === 'eligible'; +}; + /** * Get the MCC code for the selected industry. * diff --git a/includes/admin/class-wc-rest-payments-onboarding-controller.php b/includes/admin/class-wc-rest-payments-onboarding-controller.php index 1a9553d46c8..da65bf5770c 100644 --- a/includes/admin/class-wc-rest-payments-onboarding-controller.php +++ b/includes/admin/class-wc-rest-payments-onboarding-controller.php @@ -204,10 +204,14 @@ public function register_routes() { * @return WP_Error|WP_REST_Response */ public function get_embedded_kyc_session( WP_REST_Request $request ) { + $self_assessment_data = ! empty( $request->get_param( 'self_assessment' ) ) ? wc_clean( wp_unslash( $request->get_param( 'self_assessment' ) ) ) : []; + $progressive = ! empty( $request->get_param( 'progressive' ) ) && 'true' === $request->get_param( 'progressive' ); + $collect_payout_requirements = ! empty( $request->get_param( 'collect_payout_requirements' ) ) && 'true' === $request->get_param( 'collect_payout_requirements' ); + $account_session = $this->onboarding_service->create_embedded_kyc_session( - ! empty( $request->get_param( 'self_assessment' ) ) ? wc_clean( wp_unslash( $request->get_param( 'self_assessment' ) ) ) : [], - ! empty( $request->get_param( 'progressive' ) ) && 'true' === $request->get_param( 'progressive' ), - ! empty( $request->get_param( 'collect_payout_requirements' ) ) && 'true' === $request->get_param( 'collect_payout_requirements' ) + $self_assessment_data, + $progressive, + $collect_payout_requirements ); if ( $account_session ) {