diff --git a/changelog/dev-include-connect-js b/changelog/dev-include-connect-js new file mode 100644 index 00000000000..218ec497755 --- /dev/null +++ b/changelog/dev-include-connect-js @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Added Embdedded KYC, currently behind feature flag. diff --git a/client/globals.d.ts b/client/globals.d.ts index f00b521943a..44d94baa01b 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -16,6 +16,7 @@ declare global { paymentTimeline: boolean; isDisputeIssuerEvidenceEnabled: boolean; isPaymentOverviewWidgetEnabled?: boolean; + isEmbeddedKycEnabled?: boolean; }; fraudServices: unknown[]; testMode: boolean; diff --git a/client/index.js b/client/index.js index 9a530a54010..a5eb2d8376c 100644 --- a/client/index.js +++ b/client/index.js @@ -29,6 +29,7 @@ import CapitalPage from 'capital'; import OverviewPage from 'overview'; import DocumentsPage from 'documents'; import OnboardingPage from 'onboarding'; +import OnboardingKycPage from 'onboarding/kyc'; import FraudProtectionAdvancedSettingsPage from './settings/fraud-protection/advanced-settings'; import { getTasks } from 'overview/task-list/tasks'; @@ -69,6 +70,26 @@ addFilter( capability: 'manage_woocommerce', } ); + // Currently under feature flag. + if ( + wcpaySettings && + wcpaySettings.featureFlags.isEmbeddedKycEnabled + ) { + pages.push( { + container: OnboardingKycPage, + path: '/payments/onboarding/kyc', + wpOpenMenu: menuID, + breadcrumbs: [ + rootLink, + __( 'Continue onboarding', 'woocommerce-payments' ), + ], + navArgs: { + id: 'wc-payments-continue-onboarding', + }, + capability: 'manage_woocommerce', + } ); + } + pages.push( { container: OverviewPage, path: '/payments/overview', diff --git a/client/onboarding/index.tsx b/client/onboarding/index.tsx index 5def61ebd4b..62e179bb7c2 100644 --- a/client/onboarding/index.tsx +++ b/client/onboarding/index.tsx @@ -13,11 +13,12 @@ import { getMccFromIndustry } from 'onboarding/utils'; import { OnboardingForm } from './form'; import Step from './step'; import BusinessDetails from './steps/business-details'; +import EmbeddedKyc from './steps/embedded-kyc'; import StoreDetails from './steps/store-details'; -import LoadingStep from './steps/loading'; import { trackStarted } from './tracking'; import { getAdminUrl } from 'wcpay/utils'; import './style.scss'; +import LoadingStep from 'wcpay/onboarding/steps/loading'; const OnboardingStepper = () => { const handleExit = () => { @@ -47,7 +48,13 @@ const OnboardingStepper = () => { - + { wcpaySettings?.featureFlags?.isEmbeddedKycEnabled ? ( + + + + ) : ( + + ) } ); }; diff --git a/client/onboarding/kyc/appearance.ts b/client/onboarding/kyc/appearance.ts new file mode 100644 index 00000000000..db0260c302f --- /dev/null +++ b/client/onboarding/kyc/appearance.ts @@ -0,0 +1,62 @@ +/* eslint-disable max-len */ + +/** + * Customised appearance variables for the external KYC flow. + */ +export default { + variables: { + colorPrimary: '#3C2861', + colorBackground: '#FFFFFF', + buttonPrimaryColorBackground: '#3858E9', + buttonPrimaryColorBorder: '#3858E9', + buttonPrimaryColorText: '#FFFFFF', + buttonSecondaryColorBackground: '#FFFFFF', + buttonSecondaryColorBorder: '#3858E9', + buttonSecondaryColorText: '#3858E9', + colorText: '#101517', + colorSecondaryText: '#50575E', + actionPrimaryColorText: '#3858E9', + actionSecondaryColorText: '#101517', + colorBorder: '#DDDDDD', + formHighlightColorBorder: '#3858E9', + formAccentColor: '#3858E9', + colorDanger: '#CC1818', + offsetBackgroundColor: '#F0F0F0', + formBackgroundColor: '#FFFFFF', + badgeNeutralColorText: '#2C3338', + badgeNeutralColorBackground: '#F6F7F7', + badgeNeutralColorBorder: '#F6F7F7', + badgeSuccessColorText: '#005C12', + badgeSuccessColorBackground: '#EDFAEF', + badgeSuccessColorBorder: '#EDFAEF', + badgeWarningColorText: '#614200', + badgeWarningColorBackground: '#FCF9E8', + badgeWarningColorBorder: '#FCF9E8', + badgeDangerColorText: '#8A2424', + badgeDangerColorBackground: '#FCF0F1', + badgeDangerColorBorder: '#FCF0F1', + borderRadius: '2px', + buttonBorderRadius: '2px', + formBorderRadius: '2px', + badgeBorderRadius: '2px', + overlayBorderRadius: '8px', + spacingUnit: '10px', + fontFamily: + "-apple-system, BlinkMacSystemFont, 'system-ui', 'Segoe UI', 'Helvetica Neue', 'Helvetica', 'Roboto', 'Arial', sans-serif", + fontSizeBase: '16px', + headingXlFontSize: '32px', + headingXlFontWeight: '400', + headingLgFontSize: '24px', + headingLgFontWeight: '400', + headingMdFontSize: '20px', + headingMdFontWeight: '400', + headingSmFontSize: '16px', + headingSmFontWeight: '600', + headingXsFontSize: '12px', + headingXsFontWeight: '600', + bodyMdFontWeight: '400', + bodyMdFontSize: '16px', + bodySmFontSize: '14px', + bodySmFontWeight: '400', + }, +}; diff --git a/client/onboarding/kyc/index.tsx b/client/onboarding/kyc/index.tsx new file mode 100644 index 00000000000..d2e9a4b5140 --- /dev/null +++ b/client/onboarding/kyc/index.tsx @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import React, { useEffect } from 'react'; +import { closeSmall, Icon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import Logo from 'assets/images/woopayments.svg'; +import Page from 'components/page'; +import { OnboardingContextProvider } from 'onboarding/context'; +import EmbeddedKyc from 'onboarding/steps/embedded-kyc'; +import strings from 'onboarding/strings'; +import { getConnectUrl } from 'utils'; + +const OnboardingKycPage: React.FC = () => { + const handleExit = () => { + const urlParams = new URLSearchParams( window.location.search ); + + window.location.href = getConnectUrl( + { + source: + urlParams.get( 'source' )?.replace( /[^\w-]+/g, '' ) || + 'unknown', + }, + 'WCPAY_ONBOARDING_KYC' + ); + }; + + useEffect( () => { + // Remove loading class and add those required for full screen. + document.body.classList.remove( 'woocommerce-admin-is-loading' ); + document.body.classList.add( 'woocommerce-admin-full-screen' ); + document.body.classList.add( 'is-wp-toolbar-disabled' ); + document.body.classList.add( 'wcpay-onboarding__body' ); + + // Remove full screen classes on unmount. + return () => { + document.body.classList.remove( 'woocommerce-admin-full-screen' ); + document.body.classList.remove( 'is-wp-toolbar-disabled' ); + document.body.classList.remove( 'wcpay-onboarding__body' ); + }; + }, [] ); + return ( + + +
+ + WooPayments + +
+
+
+ +
+
+
+
+ ); +}; +export default OnboardingKycPage; diff --git a/client/onboarding/step.tsx b/client/onboarding/step.tsx index c6f4aee0b17..92ed2e4e3d4 100644 --- a/client/onboarding/step.tsx +++ b/client/onboarding/step.tsx @@ -17,9 +17,10 @@ import './style.scss'; interface Props { name: OnboardingSteps; + showHeading?: boolean; } -const Step: React.FC< Props > = ( { name, children } ) => { +const Step: React.FC< Props > = ( { name, children, showHeading = true } ) => { const { trackAbandoned } = useTrackAbandoned(); const { prevStep, exit } = useStepperContext(); const handleExit = () => { @@ -36,7 +37,9 @@ const Step: React.FC< Props > = ( { name, children } ) => {
-

- { strings.steps[ name ].heading } -

-

- { strings.steps[ name ].subheading } -

+ { showHeading && ( + <> +

+ { strings.steps[ name ].heading } +

+

+ { strings.steps[ name ].subheading } +

+ + ) }
{ children }
diff --git a/client/onboarding/steps/embedded-kyc.tsx b/client/onboarding/steps/embedded-kyc.tsx new file mode 100644 index 00000000000..1ca2c9c6a92 --- /dev/null +++ b/client/onboarding/steps/embedded-kyc.tsx @@ -0,0 +1,234 @@ +/** + * External dependencies + */ +import React, { 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'; +import { getConnectUrl, getOverviewUrl } from 'wcpay/utils'; + +type AccountKycSessionData = AccountKycSession; + +interface FinalizeResponse { + success: boolean; + params: Record< string, string >; +} + +interface Props { + continueKyc?: boolean; +} + +const EmbeddedKyc: React.FC< Props > = ( { continueKyc = false } ) => { + const { data } = useOnboardingContext(); + const [ publishableKey, setPublishableKey ] = useState( '' ); + const [ locale, setLocale ] = useState( '' ); + const [ clientSecret, setClientSecret ] = useState< + ( () => Promise< string > ) | null + >( null ); + const [ + stripeConnectInstance, + setStripeConnectInstance, + ] = 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 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; + } + }; + + const fetchKeys = async () => { + // By default, we assume the merchant is not eligible for PO. + let isEligible = false; + + // If we are resuming an onboarding session, we don't need to check for PO eligibility again. + if ( ! continueKyc ) { + isEligible = await isEligibleForPo(); + } + + const path = addQueryArgs( + `${ NAMESPACE }/onboarding/kyc/session`, + { + self_assessment: fromDotNotation( data ), + progressive: isEligible, + } + ); + 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 { + setLoading( false ); + setLoadErrorMessage( + __( + "Failed to create account session. Please check that you're using the latest version of WooCommerce Payments.", + 'woocommerce-payments' + ) + ); + } + }; + + fetchKeys(); + }, [ data, continueKyc ] ); + + // Initialize the Stripe Connect instance only once when publishableKey and clientSecret are ready + useEffect( () => { + if ( publishableKey && clientSecret && ! stripeConnectInstance ) { + const stripeInstance = loadConnectAndInitialize( { + publishableKey: publishableKey, + fetchClientSecret: clientSecret, // Pass the function returning the Promise + appearance: { + // See all possible variables below + overlays: 'drawer', + variables: appearance.variables, + }, + locale: locale.replace( '_', '-' ), + } ); + + setStripeConnectInstance( stripeInstance ); + } + }, [ publishableKey, clientSecret, stripeConnectInstance, locale ] ); + + return ( + <> + { loading && } + { loadErrorMessage && ( + { loadErrorMessage } + ) } + { 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' + ); + } + } } + /> + + ) } + + ); +}; + +export default EmbeddedKyc; diff --git a/client/onboarding/steps/test/embedded-onboarding.tsx b/client/onboarding/steps/test/embedded-onboarding.tsx new file mode 100644 index 00000000000..bd3ee34b1bd --- /dev/null +++ b/client/onboarding/steps/test/embedded-onboarding.tsx @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import apiFetch from '@wordpress/api-fetch'; +import { loadConnectAndInitialize } from '@stripe/connect-js'; + +/** + * Internal dependencies + */ +import EmbeddedKyc from '../embedded-kyc'; + +jest.mock( '@wordpress/api-fetch' ); +jest.mock( '@stripe/connect-js', () => ( { + loadConnectAndInitialize: jest.fn(), +} ) ); + +// Mock data, setData from OnboardingContext +const data = { + country: 'US', + business_type: 'individual', + mcc: 'most_popular__software_services', + annual_revenue: 'less_than_250k', + go_live_timeframe: 'within_1month', +}; +const setData = jest.fn(); + +jest.mock( '../../context', () => ( { + useOnboardingContext: jest.fn( () => ( { + data, + setData, + } ) ), +} ) ); + +jest.mock( 'components/stepper', () => ( { + useStepperContext: jest.fn( () => ( { + currentStep: 'loading', + } ) ), +} ) ); + +describe( 'EmbeddedOnboarding', () => { + beforeEach( () => { + jest.clearAllMocks(); // Clear all mocks between tests + } ); + + it( 'should show error if account session fails', async () => { + jest.mocked( apiFetch ).mockResolvedValueOnce( { + result: 'eligible', + data: [], + } ); + + jest.mocked( apiFetch ).mockResolvedValueOnce( { + success: false, + } ); + + render( ); + + await waitFor( () => + expect( + screen.getByText( /Failed to create account session/i ) + ).toBeInTheDocument() + ); + } ); + + it( 'should initialize Stripe Connect when publishableKey and clientSecret are set', async () => { + jest.mocked( apiFetch ).mockResolvedValueOnce( { + result: 'eligible', + data: [], + } ); + + const mockAccountSessionData = { + publishableKey: 'test_publishable_key', + clientSecret: 'test_client_secret', + locale: 'en_US', + }; + jest.mocked( apiFetch ).mockResolvedValueOnce( mockAccountSessionData ); + + render( ); + + await waitFor( () => + expect( loadConnectAndInitialize ).toHaveBeenCalledWith( { + publishableKey: mockAccountSessionData.publishableKey, + fetchClientSecret: expect.any( Function ), + appearance: { + overlays: 'drawer', + variables: expect.any( Object ), + }, + locale: 'en-US', // Locale should be formatted correctly + } ) + ); + } ); +} ); diff --git a/client/onboarding/strings.tsx b/client/onboarding/strings.tsx index 02ea32d2998..432e4aae21d 100644 --- a/client/onboarding/strings.tsx +++ b/client/onboarding/strings.tsx @@ -44,6 +44,16 @@ export default { 'woocommerce-payments' ), }, + embedded: { + heading: __( + 'One last step! Verify your identity with our partner', + 'woocommerce-payments' + ), + subheading: __( + 'This info will verify your account', + 'woocommerce-payments' + ), + }, }, fields: { country: __( diff --git a/client/onboarding/style.scss b/client/onboarding/style.scss index 7a5efb46b0c..eb0c84771f6 100644 --- a/client/onboarding/style.scss +++ b/client/onboarding/style.scss @@ -36,6 +36,10 @@ body.wcpay-onboarding__body { &:last-child { justify-self: end; } + + &.hide { + visibility: hidden; + } } &-logo { diff --git a/client/onboarding/types.ts b/client/onboarding/types.ts index 2f57aa08e94..c2781390524 100644 --- a/client/onboarding/types.ts +++ b/client/onboarding/types.ts @@ -2,7 +2,7 @@ * Internal dependencies */ -export type OnboardingSteps = 'business' | 'store' | 'loading'; +export type OnboardingSteps = 'business' | 'store' | 'embedded' | 'loading'; export type OnboardingFields = { country?: string; @@ -13,6 +13,15 @@ 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 { result: 'eligible' | 'not_eligible'; } @@ -55,3 +64,13 @@ export interface MccsDisplayTreeItem { mcc?: number; keywords?: string[]; } + +export interface AccountKycSession { + clientSecret: string; + expiresAt: number; + accountId: string; + isLive: boolean; + accountCreated: boolean; + publishableKey: string; + locale: string; +} diff --git a/client/overview/index.js b/client/overview/index.js index 5a2cc84ba52..f6d5afd4a72 100644 --- a/client/overview/index.js +++ b/client/overview/index.js @@ -190,6 +190,7 @@ const OverviewPage = () => { + { showConnectionSuccess && } { ! accountRejected && ! accountUnderReview && ( diff --git a/client/utils/index.js b/client/utils/index.js index bc24bf44f4f..ba5b0f9493a 100644 --- a/client/utils/index.js +++ b/client/utils/index.js @@ -100,6 +100,40 @@ export const getDocumentUrl = ( documentId ) => { ); }; +export const getConnectUrl = ( urlParams, from ) => { + // Ensure urlParams is an object. + const queryParams = typeof urlParams === 'object' ? urlParams : {}; + + const baseParams = { + page: 'wc-admin', + path: '/payments/connect', + source: queryParams.source?.replace( /[^\w-]+/g, '' ) || 'unknown', + from: from, + }; + + // Merge queryParams and baseParams into baseParams, ensuring baseParams takes precedence. + const params = { ...queryParams, ...baseParams }; + + return getAdminUrl( params ); +}; + +export const getOverviewUrl = ( urlParams, from ) => { + // Ensure urlParams is an object. + const queryParams = typeof urlParams === 'object' ? urlParams : {}; + + const baseParams = { + page: 'wc-admin', + path: '/payments/overview', + source: queryParams.source?.replace( /[^\w-]+/g, '' ) || 'unknown', + from: from, + }; + + // Merge queryParams and baseParams into baseParams, ensuring baseParams takes precedence. + const params = { ...queryParams, ...baseParams }; + + return getAdminUrl( params ); +}; + /** * Returns the URL to the WooPayments settings. * diff --git a/includes/admin/class-wc-payments-admin.php b/includes/admin/class-wc-payments-admin.php index 448d85831b2..85f3439ba85 100644 --- a/includes/admin/class-wc-payments-admin.php +++ b/includes/admin/class-wc-payments-admin.php @@ -347,6 +347,23 @@ public function add_payments_menu() { remove_submenu_page( 'wc-admin&path=/payments/connect', 'wc-admin&path=/payments/onboarding' ); } + // Register /payments/onboarding/kyc only when we have a Stripe account, but the Stripe KYC is not finished (details not submitted). + if ( WC_Payments_Features::is_embedded_kyc_enabled() && $this->account->is_stripe_connected() && ! $this->account->is_details_submitted() ) { + wc_admin_register_page( + [ + 'id' => 'wc-payments-onboarding-kyc', + 'title' => __( 'Continue onboarding', 'woocommerce-payments' ), + 'parent' => 'wc-payments', + 'path' => '/payments/onboarding/kyc', + 'capability' => 'manage_woocommerce', + 'nav_args' => [ + 'parent' => 'wc-payments', + ], + ] + ); + remove_submenu_page( 'wc-admin&path=/payments/connect', 'wc-admin&path=/payments/onboarding/kyc' ); + } + if ( $should_render_full_menu ) { if ( $this->account->is_card_present_eligible() && $this->account->has_card_readers_available() ) { $this->admin_child_pages['wc-payments-card-readers'] = [ @@ -598,6 +615,15 @@ public function enqueue_payments_scripts() { wp_enqueue_style( 'WCPAY_ADMIN_SETTINGS' ); } + // Enqueue the onboarding scripts if the user is on the onboarding page. + if ( WC_Payments_Utils::is_onboarding_page() ) { + wp_localize_script( + 'WCPAY_ONBOARDING_SETTINGS', + 'wcpayOnboardingSettings', + [] + ); + } + // TODO: Try to enqueue the JS and CSS bundles lazily (will require changes on WC-Admin). $current_screen = get_current_screen() ? get_current_screen()->base : null; if ( wc_admin_is_registered_page() || 'widgets' === $current_screen ) { @@ -849,6 +875,7 @@ private function get_js_settings(): array { // Set this flag for use in the front-end to alter messages and notices if on-boarding has been disabled. 'onBoardingDisabled' => WC_Payments_Account::is_on_boarding_disabled(), 'onboardingFieldsData' => $this->onboarding_service->get_fields_data( get_user_locale() ), + 'onboardingEmbeddedKycInProgress' => $this->onboarding_service->is_embedded_kyc_in_progress(), 'errorMessage' => $error_message, 'featureFlags' => $this->get_frontend_feature_flags(), 'isSubscriptionsActive' => class_exists( 'WC_Subscriptions' ) && version_compare( WC_Subscriptions::$version, '2.2.0', '>=' ), diff --git a/includes/admin/class-wc-rest-payments-onboarding-controller.php b/includes/admin/class-wc-rest-payments-onboarding-controller.php index bfad476ce87..1a9553d46c8 100644 --- a/includes/admin/class-wc-rest-payments-onboarding-controller.php +++ b/includes/admin/class-wc-rest-payments-onboarding-controller.php @@ -5,8 +5,7 @@ * @package WooCommerce\Payments\Admin */ -use WCPay\Exceptions\API_Exception; -use WCPay\Exceptions\Rest_Request_Exception; +use WCPay\Logger; defined( 'ABSPATH' ) || exit; @@ -49,6 +48,87 @@ public function __construct( * Configure REST API routes. */ public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/kyc/session', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_embedded_kyc_session' ], + 'permission_callback' => [ $this, 'check_permission' ], + 'args' => [ + 'progressive' => [ + 'required' => false, + 'description' => 'Whether the session is for progressive onboarding.', + 'type' => 'string', + ], + 'collect_payout_requirements' => [ + 'required' => false, + 'description' => 'Whether the session is for collecting payout requirements.', + 'type' => 'string', + ], + 'self_assessment' => [ + 'required' => false, + 'description' => 'The self-assessment data.', + 'type' => 'object', + 'properties' => [ + 'country' => [ + 'type' => 'string', + 'description' => 'The country code where the company is legally registered.', + 'required' => true, + ], + 'business_type' => [ + 'type' => 'string', + 'description' => 'The company incorporation type.', + 'required' => true, + ], + 'mcc' => [ + 'type' => 'string', + 'description' => 'The merchant category code. This can either be a true MCC or an MCCs tree item id from the onboarding form.', + 'required' => true, + ], + 'annual_revenue' => [ + 'type' => 'string', + 'description' => 'The estimated annual revenue bucket id.', + 'required' => true, + ], + 'go_live_timeframe' => [ + 'type' => 'string', + 'description' => 'The timeframe bucket for the estimated first live transaction.', + 'required' => true, + ], + 'url' => [ + 'type' => 'string', + 'description' => 'The URL of the store.', + 'required' => true, + ], + ], + ], + ], + ] + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/kyc/finalize', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'finalize_embedded_kyc' ], + 'permission_callback' => [ $this, 'check_permission' ], + 'args' => [ + 'source' => [ + 'required' => false, + 'description' => 'The very first entry point the merchant entered our onboarding flow.', + 'type' => 'string', + ], + 'from' => [ + 'required' => false, + 'description' => 'The previous step in the onboarding flow leading the merchant to arrive at the current step.', + 'type' => 'string', + ], + ], + ] + ); + register_rest_route( $this->namespace, '/' . $this->rest_base . '/business_types', @@ -116,6 +196,72 @@ public function register_routes() { ); } + /** + * Create an account embedded KYC session via the API. + * + * @param WP_REST_Request $request Request object. + * + * @return WP_Error|WP_REST_Response + */ + public function get_embedded_kyc_session( WP_REST_Request $request ) { + $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' ) + ); + + if ( $account_session ) { + $account_session['locale'] = get_user_locale(); + } + + // Set the onboarding in progress option. + $this->onboarding_service->set_embedded_kyc_in_progress(); + + return rest_ensure_response( $account_session ); + } + + /** + * Finalize the embedded KYC session via the API. + * + * @param WP_REST_Request $request Request object. + * + * @return WP_Error|WP_HTTP_Response|WP_REST_Response + */ + public function finalize_embedded_kyc( WP_REST_Request $request ) { + $source = $request->get_param( 'source' ) ?? ''; + $from = $request->get_param( 'from' ) ?? ''; + $actioned_notes = WC_Payments_Onboarding_Service::get_actioned_notes(); + + // Call the API to finalize the onboarding. + try { + $response = $this->onboarding_service->finalize_embedded_kyc( + get_user_locale(), + $source, + $actioned_notes + ); + } catch ( Exception $e ) { + return new WP_Error( self::RESULT_BAD_REQUEST, $e->getMessage(), [ 'status' => 400 ] ); + } + + // Handle some post-onboarding tasks and get the redirect params. + $finalize = WC_Payments::get_account_service()->finalize_embedded_connection( + $response['mode'], + [ + 'promo' => $response['promotion_id'] ?? '', + 'from' => $from, + 'source' => $source, + ] + ); + + // Return the response, the client will handle the redirect. + return rest_ensure_response( + array_merge( + $response, + $finalize + ) + ); + } + /** * Get business types via API. * diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php index 84620ca71c7..3f8fbd60789 100644 --- a/includes/class-wc-payments-account.php +++ b/includes/class-wc-payments-account.php @@ -9,8 +9,6 @@ exit; // Exit if accessed directly. } -use Automattic\WooCommerce\Admin\Notes\DataStore; -use Automattic\WooCommerce\Admin\Notes\Note; use WCPay\Constants\Country_Code; use WCPay\Constants\Currency_Code; use WCPay\Core\Server\Request\Get_Account; @@ -30,6 +28,7 @@ class WC_Payments_Account { const ONBOARDING_DISABLED_TRANSIENT = 'wcpay_on_boarding_disabled'; const ONBOARDING_STARTED_TRANSIENT = 'wcpay_on_boarding_started'; const ONBOARDING_STATE_TRANSIENT = 'wcpay_stripe_onboarding_state'; + const EMBEDDED_KYC_IN_PROGRESS_OPTION = 'wcpay_onboarding_embedded_kyc_in_progress'; const ERROR_MESSAGE_TRANSIENT = 'wcpay_error_message'; const INSTANT_DEPOSITS_REMINDER_ACTION = 'wcpay_instant_deposit_reminder'; const TRACKS_EVENT_ACCOUNT_CONNECT_START = 'wcpay_account_connect_start'; @@ -61,11 +60,11 @@ class WC_Payments_Account { private $action_scheduler_service; /** - * WC_Payments_Session_Service instance for working with session information + * WC_Payments_Onboarding_Service instance for working with onboarding business logic * - * @var WC_Payments_Session_Service + * @var WC_Payments_Onboarding_Service */ - private $session_service; + private $onboarding_service; /** * WC_Payments_Redirect_Service instance for handling redirects business logic @@ -80,20 +79,20 @@ class WC_Payments_Account { * @param WC_Payments_API_Client $payments_api_client Payments API client. * @param Database_Cache $database_cache Database cache util. * @param WC_Payments_Action_Scheduler_Service $action_scheduler_service Action scheduler service. - * @param WC_Payments_Session_Service $session_service Session service. + * @param WC_Payments_Onboarding_Service $onboarding_service Onboarding service. * @param WC_Payments_Redirect_Service $redirect_service Redirect service. */ public function __construct( WC_Payments_API_Client $payments_api_client, Database_Cache $database_cache, WC_Payments_Action_Scheduler_Service $action_scheduler_service, - WC_Payments_Session_Service $session_service, + WC_Payments_Onboarding_Service $onboarding_service, WC_Payments_Redirect_Service $redirect_service ) { $this->payments_api_client = $payments_api_client; $this->database_cache = $database_cache; $this->action_scheduler_service = $action_scheduler_service; - $this->session_service = $session_service; + $this->onboarding_service = $onboarding_service; $this->redirect_service = $redirect_service; } @@ -932,6 +931,25 @@ public function maybe_redirect_from_connect_page(): bool { return false; } + // There are certain cases where it is best to refresh the account data + // to be sure we are dealing with the current account state on the Connect page: + // - When the merchant is coming from the onboarding wizard it is best to refresh the account data because + // the merchant might have started the embedded Stripe KYC. + // - When the merchant is coming from the embedded KYC, definitely refresh the account data. + // The account data shouldn't be refreshed with force disconnected option enabled. + if ( ! WC_Payments_Utils::force_disconnected_enabled() + && in_array( + WC_Payments_Onboarding_Service::get_from(), + [ + WC_Payments_Onboarding_Service::FROM_ONBOARDING_WIZARD, + WC_Payments_Onboarding_Service::FROM_ONBOARDING_KYC, + ], + true + ) ) { + + $this->refresh_account_data(); + } + // If everything is in good working condition, redirect to Payments Overview page. if ( $this->has_working_jetpack_connection() && $this->is_stripe_account_valid() ) { $this->redirect_service->redirect_to_overview_page( WC_Payments_Onboarding_Service::FROM_CONNECT_PAGE ); @@ -1174,6 +1192,7 @@ public function maybe_handle_onboarding() { || ( WC_Payments_Onboarding_Service::FROM_STRIPE === $from && ! empty( $_GET['wcpay-connection-error'] ) ) ) { delete_transient( self::ONBOARDING_STATE_TRANSIENT ); + delete_option( self::EMBEDDED_KYC_IN_PROGRESS_OPTION ); } // Make changes to account data as instructed by action GET params. @@ -1400,6 +1419,7 @@ public function maybe_handle_onboarding() { if ( $create_test_drive_account ) { // Since there should be no Stripe KYC needed, make sure we start with a clean state. delete_transient( self::ONBOARDING_STATE_TRANSIENT ); + delete_option( self::EMBEDDED_KYC_IN_PROGRESS_OPTION ); // If we have the auto_start_test_drive_onboarding flag, we redirect to the Connect page // to let the JS logic take control and orchestrate things. @@ -1482,7 +1502,7 @@ public function maybe_handle_onboarding() { if ( $create_test_drive_account && ! empty( $redirect_to ) ) { wp_send_json_success( [ 'redirect_to' => $redirect_to ] ); } else { - // Redirect the user to where our Stripe onboarding instructed. + // Redirect the user to where our Stripe onboarding instructed (or to our own embedded Stripe KYC). $this->redirect_service->redirect_to( $redirect_to ); } } catch ( API_Exception $e ) { @@ -1561,6 +1581,7 @@ private function cleanup_on_account_reset() { // Discard any ongoing onboarding session. delete_transient( self::ONBOARDING_STATE_TRANSIENT ); delete_transient( self::ONBOARDING_STARTED_TRANSIENT ); + delete_option( self::EMBEDDED_KYC_IN_PROGRESS_OPTION ); delete_transient( 'woopay_enabled_by_default' ); // Clear the cache to avoid stale data. @@ -1744,6 +1765,24 @@ private function get_onboarding_return_url( string $wcpay_connect_from ): string } } + /** + * Get the URL to the embedded onboarding KYC page. + * + * @param array $additional_args Additional query args to add to the URL. + * + * @return string + */ + private function get_onboarding_kyc_url( array $additional_args = [] ): string { + $params = [ + 'page' => 'wc-admin', + 'path' => '/payments/onboarding/kyc', + ]; + + $params = array_merge( $params, $additional_args ); + + return admin_url( add_query_arg( $params, 'admin.php' ) ); + } + /** * Initializes the onboarding flow by fetching the URL from the API and redirecting to it. * @@ -1775,83 +1814,29 @@ private function init_stripe_onboarding( string $setup_mode, string $wcpay_conne WC_Payments_Onboarding_Service::set_onboarding_eligibility_modal_dismissed(); } + // If we are in the middle of an embedded onboarding, go to the KYC page. + // In this case, we don't need to generate a return URL from Stripe, and we + // can rely on the JS logic to generate the session. + // Currently under feature flag. + if ( WC_Payments_Features::is_embedded_kyc_enabled() && $this->onboarding_service->is_embedded_kyc_in_progress() ) { + // We want to carry over the connect link from value because with embedded KYC + // there is no interim step for the user. + $additional_args['from'] = WC_Payments_Onboarding_Service::get_from(); + + return $this->get_onboarding_kyc_url( $additional_args ); + } + + // Else, go on with the normal onboarding redirect logic. $return_url = $this->get_onboarding_return_url( $wcpay_connect_from ); if ( ! empty( $additional_args ) ) { $return_url = add_query_arg( $additional_args, $return_url ); } - $home_url = get_home_url(); - // If the site is running on localhost, use a bogus URL. This is to avoid Stripe's errors. - // wp_http_validate_url does not check that, unfortunately. - $home_is_localhost = 'localhost' === wp_parse_url( $home_url, PHP_URL_HOST ); - $fallback_url = ( 'live' !== $setup_mode || $home_is_localhost ) ? 'https://wcpay.test' : null; - - $current_user = get_userdata( get_current_user_id() ); - - // The general account data. - $account_data = [ - 'setup_mode' => $setup_mode, - // We use the store base country to create a customized account. - 'country' => WC()->countries->get_base_country() ?? null, - 'url' => ! $home_is_localhost && wp_http_validate_url( $home_url ) ? $home_url : $fallback_url, - 'business_name' => get_bloginfo( 'name' ), - ]; - - // Gather all the account data depending on the request context. - // Onboarding self-assessment data. $self_assessment_data = isset( $_GET['self_assessment'] ) ? wc_clean( wp_unslash( $_GET['self_assessment'] ) ) : []; - if ( ! empty( $self_assessment_data ) ) { - $business_type = $self_assessment_data['business_type'] ?? null; - $account_data = WC_Payments_Utils::array_merge_recursive_distinct( - $account_data, - [ - // Overwrite the country if the merchant chose a different one than the Woo base location. - 'country' => $self_assessment_data['country'] ?? null, - 'email' => $self_assessment_data['email'] ?? null, - 'business_name' => $self_assessment_data['business_name'] ?? null, - 'url' => $self_assessment_data['url'] ?? null, - 'mcc' => $self_assessment_data['mcc'] ?? null, - 'business_type' => $business_type, - 'company' => [ - 'structure' => 'company' === $business_type ? ( $self_assessment_data['company']['structure'] ?? null ) : null, - ], - 'individual' => [ - 'first_name' => $self_assessment_data['individual']['first_name'] ?? null, - 'last_name' => $self_assessment_data['individual']['last_name'] ?? null, - 'phone' => $self_assessment_data['phone'] ?? null, - ], - 'store' => [ - 'annual_revenue' => $self_assessment_data['annual_revenue'] ?? null, - 'go_live_timeframe' => $self_assessment_data['go_live_timeframe'] ?? null, - ], - ] - ); - } elseif ( 'test_drive' === $setup_mode ) { - $account_data = WC_Payments_Utils::array_merge_recursive_distinct( - $account_data, - [ - 'individual' => [ - 'first_name' => $current_user->first_name ?? null, - 'last_name' => $current_user->last_name ?? null, - ], - ] - ); - + if ( 'test_drive' === $setup_mode ) { // If we get to the overview page, we want to show the success message. $return_url = add_query_arg( 'wcpay-sandbox-success', 'true', $return_url ); } elseif ( 'test' === $setup_mode ) { - $account_data = WC_Payments_Utils::array_merge_recursive_distinct( - $account_data, - [ - 'business_type' => 'individual', - 'mcc' => '5734', - 'individual' => [ - 'first_name' => $current_user->first_name ?? null, - 'last_name' => $current_user->last_name ?? null, - ], - ] - ); - // If we get to the overview page, we want to show the success message. $return_url = add_query_arg( 'wcpay-sandbox-success', 'true', $return_url ); } @@ -1861,7 +1846,8 @@ private function init_stripe_onboarding( string $setup_mode, string $wcpay_conne 'site_locale' => get_locale(), ]; - $user_data = $this->get_onboarding_user_data(); + $user_data = $this->onboarding_service->get_onboarding_user_data(); + $account_data = $this->onboarding_service->get_account_data( $setup_mode, $self_assessment_data ); $onboarding_data = $this->payments_api_client->get_onboarding_data( 'live' === $setup_mode, @@ -1869,7 +1855,7 @@ private function init_stripe_onboarding( string $setup_mode, string $wcpay_conne $site_data, WC_Payments_Utils::array_filter_recursive( $user_data ), // nosemgrep: audit.php.lang.misc.array-filter-no-callback -- output of array_filter is escaped. WC_Payments_Utils::array_filter_recursive( $account_data ), // nosemgrep: audit.php.lang.misc.array-filter-no-callback -- output of array_filter is escaped. - $this->get_actioned_notes(), + WC_Payments_Onboarding_Service::get_actioned_notes(), $progressive, $collect_payout_requirements ); @@ -1888,6 +1874,7 @@ private function init_stripe_onboarding( string $setup_mode, string $wcpay_conne // Clean up any existing onboarding state. delete_transient( self::ONBOARDING_STATE_TRANSIENT ); + delete_option( self::EMBEDDED_KYC_IN_PROGRESS_OPTION ); return add_query_arg( [ 'wcpay-connection-success' => '1' ], @@ -1920,6 +1907,50 @@ public function maybe_activate_woopay() { } } + /** + * Handle the finalization of an embedded onboarding. This includes updating the cache, setting the gateway mode, + * tracking the event, and redirecting the user to the overview page. + * + * @param string $mode The mode in which the account was created. Either 'test' or 'live'. + * @param array $additional_args Additional query args to add to the redirect URLs. + * + * @return array Returns whether the operation was successful, along with the URL params to handle the redirect. + */ + public function finalize_embedded_connection( string $mode, array $additional_args = [] ): array { + // Clear the account cache. + $this->clear_cache(); + + // Set the gateway options. + $gateway = WC_Payments::get_gateway(); + $gateway->update_option( 'enabled', 'yes' ); + $gateway->update_option( 'test_mode', 'live' !== $mode ? 'yes' : 'no' ); + + // Store a state after completing KYC for tracks. This is stored temporarily in option because + // user might not have agreed to TOS yet. + update_option( '_wcpay_onboarding_stripe_connected', [ 'is_existing_stripe_account' => false ] ); + + // Track account connection finish. + $event_properties = [ + 'mode' => 'test' === $mode ? 'test' : 'live', + 'incentive' => ! empty( $additional_args['promo'] ) ? sanitize_text_field( $additional_args['promo'] ) : '', + 'from' => ! empty( $additional_args['from'] ) ? sanitize_text_field( $additional_args['from'] ) : '', + 'source' => ! empty( $additional_args['source'] ) ? sanitize_text_field( $additional_args['source'] ) : '', + ]; + + $this->tracks_event( + self::TRACKS_EVENT_ACCOUNT_CONNECT_FINISHED, + $event_properties + ); + + $params = $additional_args; + + $params['wcpay-connection-success'] = '1'; + return [ + 'success' => true, + 'params' => $params, + ]; + } + /** * Once the API redirects back to the site after the onboarding flow, verifies the parameters and stores the data. * @@ -2204,59 +2235,6 @@ public function get_latest_tos_agreement() { : null; } - /** - * Returns an array containing the names of all the WCPay related notes that have been actioned. - * - * @return array - */ - private function get_actioned_notes(): array { - $wcpay_note_names = []; - - try { - /** - * Data Store for admin notes - * - * @var DataStore $data_store - */ - $data_store = WC_Data_Store::load( 'admin-note' ); - } catch ( Exception $e ) { - // Don't stop the on-boarding process if something goes wrong here. Log the error and return the empty array - // of actioned notes. - Logger::error( $e ); - return $wcpay_note_names; - } - - // Fetch the last 10 actioned wcpay-promo admin notifications. - $add_like_clause = function ( $where_clause ) { - return $where_clause . " AND name like 'wcpay-promo-%'"; - }; - - add_filter( 'woocommerce_note_where_clauses', $add_like_clause ); - - $wcpay_promo_notes = $data_store->get_notes( - [ - 'status' => [ Note::E_WC_ADMIN_NOTE_ACTIONED ], - 'is_deleted' => false, - 'per_page' => 10, - ] - ); - - remove_filter( 'woocommerce_note_where_clauses', $add_like_clause ); - - // If we didn't get an array back from the data store, return an empty array of results. - if ( ! is_array( $wcpay_promo_notes ) ) { - return $wcpay_note_names; - } - - // Copy the name of each note into the results. - foreach ( (array) $wcpay_promo_notes as $wcpay_note ) { - $note = new Note( $wcpay_note->note_id ); - $wcpay_note_names[] = $note->get_name(); - } - - return $wcpay_note_names; - } - /** * Gets the account country. * @@ -2455,24 +2433,4 @@ public function get_lifetime_total_payment_volume(): int { $account = $this->get_cached_account_data(); return (int) ! empty( $account ) && isset( $account['lifetime_total_payment_volume'] ) ? $account['lifetime_total_payment_volume'] : 0; } - - /** - * Get user data to send to the onboarding flow. - * - * @return array The user data. - */ - private function get_onboarding_user_data(): array { - return [ - 'user_id' => get_current_user_id(), - 'sift_session_id' => $this->session_service->get_sift_session_id(), - 'ip_address' => \WC_Geolocation::get_ip_address(), - 'browser' => [ - 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? wc_clean( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', - 'accept_language' => isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? wc_clean( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '', - 'content_language' => empty( get_user_locale() ) ? 'en-US' : str_replace( '_', '-', get_user_locale() ), - ], - 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', - 'onboarding_source' => WC_Payments_Onboarding_Service::get_source(), - ]; - } } diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index 161cd8e3935..8df7d249206 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -32,6 +32,7 @@ class WC_Payments_Features { const TOKENIZED_CART_PRB_FLAG_NAME = '_wcpay_feature_tokenized_cart_prb'; const PAYMENT_OVERVIEW_WIDGET_FLAG_NAME = '_wcpay_feature_payment_overview_widget'; const WOOPAY_GLOBAL_THEME_SUPPORT_FLAG_NAME = '_wcpay_feature_woopay_global_theme_support'; + const EMBEDDED_KYC_FLAG_NAME = '_wcpay_feature_embedded_kyc'; /** * Indicates whether card payments are enabled for this (Stripe) account. @@ -75,6 +76,15 @@ public static function is_customer_multi_currency_enabled() { return '1' === get_option( '_wcpay_feature_customer_multi_currency', '1' ); } + /** + * Checks whether Embedded KYC is enabled. + * + * @return bool + */ + public static function is_embedded_kyc_enabled(): bool { + return '1' === get_option( self::EMBEDDED_KYC_FLAG_NAME, '0' ); + } + /** * Checks whether WCPay Subscriptions is enabled. * @@ -387,6 +397,7 @@ public static function to_array() { 'isDisputeIssuerEvidenceEnabled' => self::is_dispute_issuer_evidence_enabled(), 'isPaymentOverviewWidgetEnabled' => self::is_payment_overview_widget_ui_enabled(), 'isStripeEceEnabled' => self::is_stripe_ece_enabled(), + 'isEmbeddedKycEnabled' => self::is_embedded_kyc_enabled(), ] ); } diff --git a/includes/class-wc-payments-onboarding-service.php b/includes/class-wc-payments-onboarding-service.php index 56bb49f6607..e4332f42ab9 100644 --- a/includes/class-wc-payments-onboarding-service.php +++ b/includes/class-wc-payments-onboarding-service.php @@ -9,8 +9,11 @@ exit; // Exit if accessed directly. } +use Automattic\WooCommerce\Admin\Notes\DataStore; +use Automattic\WooCommerce\Admin\Notes\Note; use WCPay\Database_Cache; use WCPay\Exceptions\API_Exception; +use WCPay\Logger; /** * Class handling onboarding related business logic. @@ -52,6 +55,7 @@ class WC_Payments_Onboarding_Service { const FROM_OVERVIEW_PAGE = 'WCPAY_OVERVIEW'; const FROM_ACCOUNT_DETAILS = 'WCPAY_ACCOUNT_DETAILS'; const FROM_ONBOARDING_WIZARD = 'WCPAY_ONBOARDING_WIZARD'; + const FROM_ONBOARDING_KYC = 'WCPAY_ONBOARDING_KYC'; // The embedded Stripe KYC step/page. const FROM_SETTINGS = 'WCPAY_SETTINGS'; const FROM_PAYOUTS = 'WCPAY_PAYOUTS'; const FROM_TEST_TO_LIVE = 'WCPAY_TEST_TO_LIVE'; @@ -62,6 +66,7 @@ class WC_Payments_Onboarding_Service { const FROM_WPCOM = 'WPCOM'; const FROM_WPCOM_CONNECTION = 'WPCOM_CONNECTION'; const FROM_STRIPE = 'STRIPE'; + const FROM_STRIPE_EMBEDDED = 'STRIPE_EMBEDDED'; /** * Client for making requests to the WooCommerce Payments API @@ -77,15 +82,24 @@ class WC_Payments_Onboarding_Service { */ private $database_cache; + /** + * Session service. + * + * @var WC_Payments_Session_Service instance for working with session information + */ + private $session_service; + /** * Class constructor * - * @param WC_Payments_API_Client $payments_api_client Payments API client. - * @param Database_Cache $database_cache Database cache util. + * @param WC_Payments_API_Client $payments_api_client Payments API client. + * @param Database_Cache $database_cache Database cache util. + * @param WC_Payments_Session_Service $session_service Session service. */ - public function __construct( WC_Payments_API_Client $payments_api_client, Database_Cache $database_cache ) { + public function __construct( WC_Payments_API_Client $payments_api_client, Database_Cache $database_cache, WC_Payments_Session_Service $session_service ) { $this->payments_api_client = $payments_api_client; $this->database_cache = $database_cache; + $this->session_service = $session_service; } /** @@ -136,6 +150,109 @@ function () use ( $locale ) { ); } + /** + * Retrieve the embedded KYC session and handle initial account creation (if necessary). + * + * Will return the session key used to initialise the embedded onboarding session. + * + * @param array $self_assessment_data Self assessment data. + * @param boolean $progressive Whether the onboarding is progressive. + * @param boolean $collect_payout_requirements Whether to collect payout requirements. + * + * @return array Session data. + * + * @throws API_Exception + */ + public function create_embedded_kyc_session( array $self_assessment_data, bool $progressive = false, bool $collect_payout_requirements = false ): array { + if ( ! $this->payments_api_client->is_server_connected() ) { + return []; + } + $setup_mode = WC_Payments::mode()->is_live() ? 'live' : 'test'; + + // Make sure the onboarding test mode DB flag is set. + self::set_test_mode( 'live' !== $setup_mode ); + + if ( ! $collect_payout_requirements ) { + // Clear onboarding related account options if this is an initial onboarding attempt. + self::clear_account_options(); + } else { + // Since we assume user has already either gotten here from the eligibility modal, + // or has already dismissed it, we should set the modal as dismissed so it doesn't display again. + self::set_onboarding_eligibility_modal_dismissed(); + } + + $site_data = [ + 'site_username' => wp_get_current_user()->user_login, + 'site_locale' => get_locale(), + ]; + $user_data = $this->get_onboarding_user_data(); + $account_data = $this->get_account_data( $setup_mode, $self_assessment_data ); + $actioned_notes = self::get_actioned_notes(); + + try { + $account_session = $this->payments_api_client->initialize_onboarding_embedded_kyc( + 'live' === $setup_mode, + $site_data, + array_filter( $user_data ), // nosemgrep: audit.php.lang.misc.array-filter-no-callback -- output of array_filter is escaped. + array_filter( $account_data ), // nosemgrep: audit.php.lang.misc.array-filter-no-callback -- output of array_filter is escaped. + $actioned_notes, + $progressive, + $collect_payout_requirements + ); + } catch ( API_Exception $e ) { + // If we fail to create the session, return an empty array. + return []; + } + + return [ + 'clientSecret' => $account_session['client_secret'] ?? '', + 'expiresAt' => $account_session['expires_at'] ?? 0, + 'accountId' => $account_session['account_id'] ?? '', + 'isLive' => $account_session['is_live'] ?? false, + 'accountCreated' => $account_session['account_created'] ?? false, + 'publishableKey' => $account_session['publishable_key'] ?? '', + ]; + } + + /** + * Finalize the embedded KYC session. + * + * @param string $locale The locale to use to i18n the data. + * @param string $source The source of the onboarding flow. + * @param array $actioned_notes The actioned notes for this onboarding. + * + * @return array Containing the following keys: success, account_id, mode. + * + * @throws API_Exception + */ + public function finalize_embedded_kyc( string $locale, string $source, array $actioned_notes ): array { + if ( ! $this->payments_api_client->is_server_connected() ) { + return [ + 'success' => false, + ]; + } + + $result = $this->payments_api_client->finalize_onboarding_embedded_kyc( $locale, $source, $actioned_notes ); + + $success = $result['success'] ?? false; + $details_submitted = $result['details_submitted'] ?? false; + + if ( ! $result || ! $success ) { + throw new API_Exception( __( 'Failed to finalize onboarding session.', 'woocommerce-payments' ), 'wcpay-onboarding-finalize-error', 400 ); + } + + // Clear the onboarding in progress option, since the onboarding flow is now complete. + $this->clear_embedded_kyc_in_progress(); + + return [ + 'success' => $success, + 'details_submitted' => $details_submitted, + 'account_id' => $result['account_id'] ?? '', + 'mode' => $result['mode'], + 'promotion_id' => $result['promotion_id'] ?? null, + ]; + } + /** * Gets and caches the business types per country from the server. * @@ -215,6 +332,183 @@ public function add_admin_body_classes( string $classes = '' ): string { return $classes; } + /** + * Get account data for onboarding from self assestment data. + * + * @param string $setup_mode Setup mode. + * @param array $self_assessment_data Self assessment data. + * + * @return array Account data. + */ + public function get_account_data( string $setup_mode, array $self_assessment_data ): array { + $home_url = get_home_url(); + // If the site is running on localhost, use a bogus URL. This is to avoid Stripe's errors. + // wp_http_validate_url does not check that, unfortunately. + $home_is_localhost = 'localhost' === wp_parse_url( $home_url, PHP_URL_HOST ); + $fallback_url = ( 'live' !== $setup_mode || $home_is_localhost ) ? 'https://wcpay.test' : null; + $current_user = get_userdata( get_current_user_id() ); + + // The general account data. + $account_data = [ + 'setup_mode' => $setup_mode, + // We use the store base country to create a customized account. + 'country' => WC()->countries->get_base_country() ?? null, + 'url' => ! $home_is_localhost && wp_http_validate_url( $home_url ) ? $home_url : $fallback_url, + 'business_name' => get_bloginfo( 'name' ), + ]; + + if ( ! empty( $self_assessment_data ) ) { + $business_type = $self_assessment_data['business_type'] ?? null; + $account_data = WC_Payments_Utils::array_merge_recursive_distinct( + $account_data, + [ + // Overwrite the country if the merchant chose a different one than the Woo base location. + 'country' => $self_assessment_data['country'] ?? null, + 'email' => $self_assessment_data['email'] ?? null, + 'business_name' => $self_assessment_data['business_name'] ?? null, + 'url' => $self_assessment_data['url'] ?? null, + 'mcc' => $self_assessment_data['mcc'] ?? null, + 'business_type' => $business_type, + 'company' => [ + 'structure' => 'company' === $business_type ? ( $self_assessment_data['company']['structure'] ?? null ) : null, + ], + 'individual' => [ + 'first_name' => $self_assessment_data['individual']['first_name'] ?? null, + 'last_name' => $self_assessment_data['individual']['last_name'] ?? null, + 'phone' => $self_assessment_data['phone'] ?? null, + ], + 'store' => [ + 'annual_revenue' => $self_assessment_data['annual_revenue'] ?? null, + 'go_live_timeframe' => $self_assessment_data['go_live_timeframe'] ?? null, + ], + ] + ); + } elseif ( 'test_drive' === $setup_mode ) { + $account_data = WC_Payments_Utils::array_merge_recursive_distinct( + $account_data, + [ + 'individual' => [ + 'first_name' => $current_user->first_name ?? null, + 'last_name' => $current_user->last_name ?? null, + ], + ] + ); + } elseif ( 'test' === $setup_mode ) { + $account_data = WC_Payments_Utils::array_merge_recursive_distinct( + $account_data, + [ + 'business_type' => 'individual', + 'mcc' => '5734', + 'individual' => [ + 'first_name' => $current_user->first_name ?? null, + 'last_name' => $current_user->last_name ?? null, + ], + ] + ); + } + return $account_data; + } + + /** + * Get user data to send to the onboarding flow. + * + * @return array The user data. + */ + public function get_onboarding_user_data(): array { + return [ + 'user_id' => get_current_user_id(), + 'sift_session_id' => $this->session_service->get_sift_session_id(), + 'ip_address' => \WC_Geolocation::get_ip_address(), + 'browser' => [ + 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? wc_clean( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', + 'accept_language' => isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? wc_clean( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '', + 'content_language' => empty( get_user_locale() ) ? 'en-US' : str_replace( '_', '-', get_user_locale() ), + ], + 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', + 'onboarding_source' => self::get_source(), + ]; + } + + /** + * Determine whether an embedded KYC flow is in progress. + * + * @return bool True if embedded KYC is in progress, false otherwise. + */ + public function is_embedded_kyc_in_progress(): bool { + return in_array( get_option( WC_Payments_Account::EMBEDDED_KYC_IN_PROGRESS_OPTION, 'no' ), [ 'yes', '1' ], true ); + } + + /** + * Mark the embedded KYC flow as in progress. + * + * @return bool Whether we successfully marked the flow as in progress. + */ + public function set_embedded_kyc_in_progress(): bool { + return update_option( WC_Payments_Account::EMBEDDED_KYC_IN_PROGRESS_OPTION, 'yes' ); + } + + /** + * Clear any embedded KYC in progress flags. + * + * @return boolean Whether we successfully cleared the flags. + */ + public function clear_embedded_kyc_in_progress(): bool { + return delete_option( WC_Payments_Account::EMBEDDED_KYC_IN_PROGRESS_OPTION ); + } + + /** + * Get actioned notes. + * + * @return array + */ + public static function get_actioned_notes(): array { + $wcpay_note_names = []; + + try { + /** + * Data Store for admin notes + * + * @var DataStore $data_store + */ + $data_store = WC_Data_Store::load( 'admin-note' ); + } catch ( Exception $e ) { + // Don't stop the on-boarding process if something goes wrong here. Log the error and return the empty array + // of actioned notes. + Logger::error( $e ); + return $wcpay_note_names; + } + + // Fetch the last 10 actioned wcpay-promo admin notifications. + $add_like_clause = function ( $where_clause ) { + return $where_clause . " AND name like 'wcpay-promo-%'"; + }; + + add_filter( 'woocommerce_note_where_clauses', $add_like_clause ); + + $wcpay_promo_notes = $data_store->get_notes( + [ + 'status' => [ Note::E_WC_ADMIN_NOTE_ACTIONED ], + 'is_deleted' => false, + 'per_page' => 10, + ] + ); + + remove_filter( 'woocommerce_note_where_clauses', $add_like_clause ); + + // If we didn't get an array back from the data store, return an empty array of results. + if ( ! is_array( $wcpay_promo_notes ) ) { + return $wcpay_note_names; + } + + // Copy the name of each note into the results. + foreach ( (array) $wcpay_promo_notes as $wcpay_note ) { + $note = new Note( $wcpay_note->note_id ); + $wcpay_note_names[] = $note->get_name(); + } + + return $wcpay_note_names; + } + /** * Clear any account options we may want to reset when a new onboarding flow is initialised. * Currently, just deletes the option which stores whether the eligibility modal has been dismissed. diff --git a/includes/class-wc-payments-utils.php b/includes/class-wc-payments-utils.php index 4593fe65019..0b2506d73b6 100644 --- a/includes/class-wc-payments-utils.php +++ b/includes/class-wc-payments-utils.php @@ -554,6 +554,19 @@ public static function is_payments_settings_page(): bool { ); } + /** + * Checks if the currently displayed page is the WooPayments onboarding page. + * + * @return bool + */ + public static function is_onboarding_page(): bool { + return ( + is_admin() + && isset( $_GET['page'] ) && 'wc-admin' === $_GET['page'] // phpcs:ignore WordPress.Security.NonceVerification + && isset( $_GET['path'] ) && '/payments/onboarding' === $_GET['path'] // phpcs:ignore WordPress.Security.NonceVerification + ); + } + /** * Converts a locale to the closest supported by Stripe.js. * diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 2a5f8711bec..cd8854afa93 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -505,7 +505,8 @@ public static function init() { self::$action_scheduler_service = new WC_Payments_Action_Scheduler_Service( self::$api_client, self::$order_service ); self::$session_service = new WC_Payments_Session_Service( self::$api_client ); self::$redirect_service = new WC_Payments_Redirect_Service( self::$api_client ); - self::$account = new WC_Payments_Account( self::$api_client, self::$database_cache, self::$action_scheduler_service, self::$session_service, self::$redirect_service ); + self::$onboarding_service = new WC_Payments_Onboarding_Service( self::$api_client, self::$database_cache, self::$session_service ); + self::$account = new WC_Payments_Account( self::$api_client, self::$database_cache, self::$action_scheduler_service, self::$onboarding_service, self::$redirect_service ); self::$customer_service = new WC_Payments_Customer_Service( self::$api_client, self::$account, self::$database_cache, self::$session_service, self::$order_service ); self::$token_service = new WC_Payments_Token_Service( self::$api_client, self::$customer_service ); self::$remote_note_service = new WC_Payments_Remote_Note_Service( WC_Data_Store::load( 'admin-note' ) ); @@ -514,7 +515,6 @@ public static function init() { self::$localization_service = new WC_Payments_Localization_Service(); self::$failed_transaction_rate_limiter = new Session_Rate_Limiter( Session_Rate_Limiter::SESSION_KEY_DECLINED_CARD_REGISTRY, 5, 10 * MINUTE_IN_SECONDS ); self::$order_success_page = new WC_Payments_Order_Success_Page(); - self::$onboarding_service = new WC_Payments_Onboarding_Service( self::$api_client, self::$database_cache ); self::$woopay_util = new WooPay_Utilities(); self::$woopay_tracker = new WooPay_Tracker( self::get_wc_payments_http() ); self::$incentives_service = new WC_Payments_Incentives_Service( self::$database_cache ); diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index d360b76f07f..3ec8bc40e4b 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -970,6 +970,64 @@ public function get_onboarding_data( bool $live_account, string $return_url, arr return $this->request( $request_args, self::ONBOARDING_API . '/init', self::POST, true, true ); } + /** + * Initialize the onboarding embedded KYC flow, returning a session object which is used by the frontend. + * + * @param bool $live_account Whether to create live account. + * @param array $site_data Site data. + * @param array $user_data User data. + * @param array $account_data Account data to be prefilled. + * @param array $actioned_notes Actioned notes to be sent. + * @param bool $progressive Whether progressive onboarding should be enabled for this onboarding. + * @param bool $collect_payout_requirements Whether we need to collect payout requirements. + * + * @return array + * + * @throws API_Exception + */ + public function initialize_onboarding_embedded_kyc( bool $live_account, array $site_data = [], array $user_data = [], array $account_data = [], array $actioned_notes = [], bool $progressive = false, bool $collect_payout_requirements = false ): array { + $request_args = apply_filters( + 'wc_payments_get_onboarding_data_args', + [ + 'site_data' => $site_data, + 'user_data' => $user_data, + 'account_data' => $account_data, + 'actioned_notes' => $actioned_notes, + 'create_live_account' => $live_account, + 'progressive' => $progressive, + 'collect_payout_requirements' => $collect_payout_requirements, + ] + ); + + $session = $this->request( $request_args, self::ONBOARDING_API . '/embedded', self::POST, true, true ); + + if ( ! is_array( $session ) ) { + return []; + } + + return $session; + } + + /** + * Finalize the onboarding embedded KYC flow. + * + * @param string $locale The locale to use to i18n the data. + * @param string $source The source of the onboarding flow. + * @param array $actioned_notes The actioned notes on the account related to this onboarding. + * @return array + * + * @throws API_Exception + */ + public function finalize_onboarding_embedded_kyc( string $locale, string $source, array $actioned_notes ): array { + $request_args = [ + 'locale' => $locale, + 'source' => $source, + 'actioned_notes' => $actioned_notes, + ]; + + return $this->request( $request_args, self::ONBOARDING_API . '/embedded/finalize', self::POST, true, true ); + } + /** * Get the fields data to be used by the onboarding flow. * diff --git a/package-lock.json b/package-lock.json index 096544d3a4a..03592cffe24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "dependencies": { "@automattic/interpolate-components": "1.2.1", "@fingerprintjs/fingerprintjs": "3.4.1", + "@stripe/connect-js": "3.3.12", + "@stripe/react-connect-js": "3.3.13", "@stripe/react-stripe-js": "2.5.1", "@stripe/stripe-js": "1.15.1", "@woocommerce/explat": "2.3.0", @@ -9155,6 +9157,21 @@ } } }, + "node_modules/@stripe/connect-js": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/@stripe/connect-js/-/connect-js-3.3.12.tgz", + "integrity": "sha512-hXbgvGq9Lb6BYgsb8lcbjL76Yqsxr0yAj6T9ZFTfUK0O4otI5GSEWum9do9rf/E5OfYy6fR1FG/77Jve2w1o6Q==" + }, + "node_modules/@stripe/react-connect-js": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/@stripe/react-connect-js/-/react-connect-js-3.3.13.tgz", + "integrity": "sha512-kMxYjeQUcl/ixu/mSeX5QGIr/MuP+YxFSEBdb8j6w+tbK82tmcjyFDgoQTQwVXNqUV6jI66Kks3XcfpPRfeiJA==", + "peerDependencies": { + "@stripe/connect-js": ">=3.3.11", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@stripe/react-stripe-js": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.5.1.tgz", diff --git a/package.json b/package.json index e77b6facd24..6f11833d574 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,8 @@ "dependencies": { "@automattic/interpolate-components": "1.2.1", "@fingerprintjs/fingerprintjs": "3.4.1", + "@stripe/connect-js": "3.3.12", + "@stripe/react-connect-js": "3.3.13", "@stripe/react-stripe-js": "2.5.1", "@stripe/stripe-js": "1.15.1", "@woocommerce/explat": "2.3.0", diff --git a/tests/unit/admin/test-class-wc-rest-payments-onboarding-controller.php b/tests/unit/admin/test-class-wc-rest-payments-onboarding-controller.php index e7bc456c46b..07a00a2b690 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-onboarding-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-onboarding-controller.php @@ -140,4 +140,87 @@ public function test_get_progressive_onboarding_not_eligible() { $response->get_data() ); } + + public function test_get_embedded_kyc_session() { + $kyc_session = [ + 'clientSecret' => 'accs_secret__XXX', + 'expiresAt' => time() + 120, + 'accountId' => 'acct_XXX', + 'isLive' => false, + 'accountCreated' => true, + 'publishableKey' => 'pk_test_XXX', + ]; + + $this->mock_onboarding_service + ->expects( $this->once() ) + ->method( 'create_embedded_kyc_session' ) + ->willReturn( + $kyc_session + ); + + $this->mock_onboarding_service + ->expects( $this->once() ) + ->method( 'set_embedded_kyc_in_progress' ); + + $request = new WP_REST_Request( 'GET' ); + $request->set_query_params( + [ + 'progressive' => true, + 'create_live_account' => true, + ] + ); + + $response = $this->controller->get_embedded_kyc_session( $request ); + $this->assertSame( 200, $response->status ); + $this->assertSame( + array_merge( + $kyc_session, + [ + 'locale' => 'en_US', + ] + ), + $response->get_data() + ); + } + + public function test_finalize_embedded_kyc() { + $response_data = [ + 'success' => true, + 'account_id' => 'acct_1PvxJQQujq4nxoo6', + 'details_submitted' => true, + 'mode' => 'test', + 'promotion_id' => null, + ]; + $this->mock_onboarding_service + ->expects( $this->once() ) + ->method( 'finalize_embedded_kyc' ) + ->willReturn( + $response_data + ); + + $request = new WP_REST_Request( 'POST' ); + $request->set_body_params( + [ + 'source' => 'embedded', + 'from' => 'wcpay-connect', + ] + ); + + $response = $this->controller->finalize_embedded_kyc( $request ); + $this->assertSame( 200, $response->status ); + $this->assertSame( + array_merge( + $response_data, + [ + 'params' => [ + 'promo' => '', + 'from' => 'wcpay-connect', + 'source' => 'embedded', + 'wcpay-connection-success' => '1', + ], + ] + ), + $response->get_data() + ); + } } diff --git a/tests/unit/test-class-wc-payments-account-capital.php b/tests/unit/test-class-wc-payments-account-capital.php index 240d2bc2e51..cf48a9ceb26 100644 --- a/tests/unit/test-class-wc-payments-account-capital.php +++ b/tests/unit/test-class-wc-payments-account-capital.php @@ -50,11 +50,11 @@ class WC_Payments_Account_Capital_Test extends WCPAY_UnitTestCase { private $mock_action_scheduler_service; /** - * Mock WC_Payments_Session_Service. + * Mock WC_Payments_Onboarding_Service. * - * @var WC_Payments_Session_Service|MockObject + * @var WC_Payments_Onboarding_Service|MockObject */ - private $mock_session_service; + private $mock_onboarding_service; /** * Mock WC_Payments_Redirect_Service. @@ -80,13 +80,13 @@ public function set_up() { $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); $this->mock_database_cache = $this->createMock( Database_Cache::class ); $this->mock_action_scheduler_service = $this->createMock( WC_Payments_Action_Scheduler_Service::class ); - $this->mock_session_service = $this->createMock( WC_Payments_Session_Service::class ); + $this->mock_onboarding_service = $this->createMock( WC_Payments_Onboarding_Service::class ); $this->mock_redirect_service = $this->createMock( WC_Payments_Redirect_Service::class ); // Mock WC_Payments_Account without redirect_to to prevent headers already sent error. $this->wcpay_account = $this->getMockBuilder( WC_Payments_Account::class ) ->setMethods( [ 'init_hooks' ] ) - ->setConstructorArgs( [ $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_session_service, $this->mock_redirect_service ] ) + ->setConstructorArgs( [ $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_onboarding_service, $this->mock_redirect_service ] ) ->getMock(); $this->wcpay_account->init_hooks(); } @@ -105,7 +105,7 @@ public function tear_down() { public function test_maybe_redirect_by_get_param_will_run() { $wcpay_account = $this->getMockBuilder( WC_Payments_Account::class ) ->setMethodsExcept( [ 'init_hooks' ] ) - ->setConstructorArgs( [ $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_session_service, $this->mock_redirect_service ] ) + ->setConstructorArgs( [ $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_onboarding_service, $this->mock_redirect_service ] ) ->getMock(); $wcpay_account->init_hooks(); diff --git a/tests/unit/test-class-wc-payments-account-link.php b/tests/unit/test-class-wc-payments-account-link.php index 4ab2c92a8af..e8711f10b88 100644 --- a/tests/unit/test-class-wc-payments-account-link.php +++ b/tests/unit/test-class-wc-payments-account-link.php @@ -48,11 +48,11 @@ class WC_Payments_Account_Server_Links_Test extends WCPAY_UnitTestCase { private $mock_action_scheduler_service; /** - * Mock WC_Payments_Session_Service. + * Mock WC_Payments_Onboarding_Service. * - * @var WC_Payments_Session_Service|MockObject + * @var WC_Payments_Onboarding_Service|MockObject */ - private $mock_session_service; + private $mock_onboarding_service; /** * Mock WC_Payments_Redirect_Service. @@ -78,13 +78,13 @@ public function set_up() { $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); $this->mock_database_cache = $this->createMock( Database_Cache::class ); $this->mock_action_scheduler_service = $this->createMock( WC_Payments_Action_Scheduler_Service::class ); - $this->mock_session_service = $this->createMock( WC_Payments_Session_Service::class ); + $this->mock_onboarding_service = $this->createMock( WC_Payments_Onboarding_Service::class ); $this->mock_redirect_service = $this->createMock( WC_Payments_Redirect_Service::class ); // Mock WC_Payments_Account without redirect_to to prevent headers already sent error. $this->wcpay_account = $this->getMockBuilder( WC_Payments_Account::class ) ->setMethods( [ 'init_hooks' ] ) - ->setConstructorArgs( [ $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_session_service, $this->mock_redirect_service ] ) + ->setConstructorArgs( [ $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_onboarding_service, $this->mock_redirect_service ] ) ->getMock(); $this->wcpay_account->init_hooks(); diff --git a/tests/unit/test-class-wc-payments-account.php b/tests/unit/test-class-wc-payments-account.php index 61215b93466..fa685451897 100644 --- a/tests/unit/test-class-wc-payments-account.php +++ b/tests/unit/test-class-wc-payments-account.php @@ -51,11 +51,11 @@ class WC_Payments_Account_Test extends WCPAY_UnitTestCase { private $mock_action_scheduler_service; /** - * Mock WC_Payments_Session_Service. + * Mock WC_Payments_Onboarding_Service. * - * @var WC_Payments_Session_Service|MockObject + * @var WC_Payments_Onboarding_Service|MockObject */ - private $mock_session_service; + private $mock_onboarding_service; /** * Mock WC_Payments_Redirect_Service. @@ -82,10 +82,10 @@ public function set_up() { $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); $this->mock_database_cache = $this->createMock( Database_Cache::class ); $this->mock_action_scheduler_service = $this->createMock( WC_Payments_Action_Scheduler_Service::class ); - $this->mock_session_service = $this->createMock( WC_Payments_Session_Service::class ); + $this->mock_onboarding_service = $this->createMock( WC_Payments_Onboarding_Service::class ); $this->mock_redirect_service = $this->createMock( WC_Payments_Redirect_Service::class ); - $this->wcpay_account = new WC_Payments_Account( $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_session_service, $this->mock_redirect_service ); + $this->wcpay_account = new WC_Payments_Account( $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_onboarding_service, $this->mock_redirect_service ); $this->wcpay_account->init_hooks(); } @@ -95,6 +95,7 @@ public function tear_down() { unset( $_GET ); unset( $_REQUEST ); parent::tear_down(); + delete_option( '_wcpay_feature_embedded_kyc' ); } public function test_filters_registered_properly() { @@ -268,7 +269,7 @@ public function test_maybe_handle_onboarding_connect_from_known_from( ->disableOriginalConstructor() ->onlyMethods( [ 'redirect_to' ] ) ->getMock(); - $wcpay_account = new WC_Payments_Account( $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_session_service, $mock_redirect_service ); + $wcpay_account = new WC_Payments_Account( $this->mock_api_client, $this->mock_database_cache, $this->mock_action_scheduler_service, $this->mock_onboarding_service, $mock_redirect_service ); $_GET['wcpay-connect'] = 'connect-from'; $_REQUEST['_wpnonce'] = wp_create_nonce( 'wcpay-connect' ); @@ -796,6 +797,71 @@ public function test_maybe_handle_onboarding_init_stripe_onboarding() { $this->wcpay_account->maybe_handle_onboarding(); } + public function test_maybe_handle_onboarding_init_embedded_kyc() { + // Arrange. + // We need to be in the WP admin dashboard. + $this->set_is_admin( true ); + // Test as an admin user. + wp_set_current_user( 1 ); + + $_GET['wcpay-connect'] = 'connect-from'; + $_REQUEST['_wpnonce'] = wp_create_nonce( 'wcpay-connect' ); + // Set the request as if the user is on some bogus page. It doesn't matter. + $_GET['page'] = 'wc-admin'; + $_GET['path'] = '/payments/some-bogus-page'; + // We need to come from the onboarding wizard to initialize an account! + $_GET['from'] = WC_Payments_Onboarding_Service::FROM_ONBOARDING_WIZARD; + $_GET['source'] = WC_Payments_Onboarding_Service::SOURCE_WCADMIN_INCENTIVE_PAGE; + // Make sure important flags are carried over. + $_GET['promo'] = 'incentive_id'; + $_GET['progressive'] = 'true'; + // There is no `test_mode` param and no test mode is set. It should end up as a live mode onboarding. + + // The Jetpack connection is in working order. + $this->mock_jetpack_connection(); + + $this->mock_database_cache + ->expects( $this->any() ) + ->method( 'get_or_add' ) + ->willReturn( [] ); // Empty array means no Stripe account connected. + + // Assert. + $this->mock_redirect_service + ->expects( $this->never() ) + ->method( 'redirect_to_overview_page' ); + $this->mock_redirect_service + ->expects( $this->never() ) + ->method( 'redirect_to_connect_page' ); + $this->mock_redirect_service + ->expects( $this->never() ) + ->method( 'redirect_to_onboarding_wizard' ); + + update_option( '_wcpay_feature_embedded_kyc', '1' ); + + // If embedded KYC is in progress, we expect different URL. + $this->mock_onboarding_service + ->expects( $this->once() ) + ->method( 'is_embedded_kyc_in_progress' ) + ->willReturn( true ); + + $this->mock_api_client + ->expects( $this->never() ) + ->method( 'get_onboarding_data' ); + + $this->mock_redirect_service + ->expects( $this->once() ) + ->method( 'redirect_to' ) + ->with( + $this->logicalOr( + $this->stringContains( 'page=wc-admin&path=/payments/onboarding/kyc' ), + $this->stringContains( 'page=wc-admin&path=%2Fpayments%2Fonboarding%2Fkyc' ) + ) + ); + + // Act. + $this->wcpay_account->maybe_handle_onboarding(); + } + public function test_maybe_handle_onboarding_init_stripe_onboarding_existing_account() { // Arrange. // We need to be in the WP admin dashboard. diff --git a/tests/unit/test-class-wc-payments-features.php b/tests/unit/test-class-wc-payments-features.php index 217bb7be513..e9405375d8f 100644 --- a/tests/unit/test-class-wc-payments-features.php +++ b/tests/unit/test-class-wc-payments-features.php @@ -30,6 +30,7 @@ class WC_Payments_Features_Test extends WCPAY_UnitTestCase { '_wcpay_feature_documents' => 'documents', '_wcpay_feature_auth_and_capture' => 'isAuthAndCaptureEnabled', '_wcpay_feature_stripe_ece' => 'isStripeEceEnabled', + '_wcpay_feature_embedded_kyc' => 'isEmbeddedKycEnabled', ]; public function set_up() { @@ -300,6 +301,35 @@ public function test_is_frt_review_feature_active_returns_false_when_flag_is_not $this->assertFalse( WC_Payments_Features::is_frt_review_feature_active() ); } + public function test_is_embedded_kyc_enabled_returns_true() { + add_filter( + 'pre_option_' . WC_Payments_Features::EMBEDDED_KYC_FLAG_NAME, + function ( $pre_option, $option, $default ) { + return '1'; + }, + 10, + 3 + ); + $this->assertTrue( WC_Payments_Features::is_embedded_kyc_enabled() ); + } + + public function test_is_embedded_kyc_enabled_returns_false_when_flag_is_false() { + add_filter( + 'pre_option_' . WC_Payments_Features::EMBEDDED_KYC_FLAG_NAME, + function ( $pre_option, $option, $default ) { + return '0'; + }, + 10, + 3 + ); + $this->assertFalse( WC_Payments_Features::is_embedded_kyc_enabled() ); + $this->assertArrayNotHasKey( 'isEmbeddedKycEnabled', WC_Payments_Features::to_array() ); + } + + public function test_is_embedded_kyc_enabled_returns_false_when_flag_is_not_set() { + $this->assertFalse( WC_Payments_Features::is_embedded_kyc_enabled() ); + } + private function setup_enabled_flags( array $enabled_flags ) { foreach ( array_keys( self::FLAG_OPTION_NAME_TO_FRONTEND_KEY_MAPPING ) as $flag ) { add_filter( diff --git a/tests/unit/test-class-wc-payments-onboarding-service.php b/tests/unit/test-class-wc-payments-onboarding-service.php index 7ec162205e4..a8d9686f763 100644 --- a/tests/unit/test-class-wc-payments-onboarding-service.php +++ b/tests/unit/test-class-wc-payments-onboarding-service.php @@ -34,6 +34,13 @@ class WC_Payments_Onboarding_Service_Test extends WCPAY_UnitTestCase { */ private $mock_database_cache; + /** + * Mock WC_Payments_Session_Service + * + * @var MockObject + */ + private $mock_session_service; + /** * Example business types array. * @@ -129,10 +136,11 @@ class WC_Payments_Onboarding_Service_Test extends WCPAY_UnitTestCase { public function set_up() { parent::set_up(); - $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); - $this->mock_database_cache = $this->createMock( Database_Cache::class ); + $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); + $this->mock_database_cache = $this->createMock( Database_Cache::class ); + $this->mock_session_service = $this->createMock( WC_Payments_Session_Service::class ); - $this->onboarding_service = new WC_Payments_Onboarding_Service( $this->mock_api_client, $this->mock_database_cache ); + $this->onboarding_service = new WC_Payments_Onboarding_Service( $this->mock_api_client, $this->mock_database_cache, $this->mock_session_service ); $this->onboarding_service->init_hooks(); } @@ -202,6 +210,18 @@ public function test_set_test_mode() { delete_option( WC_Payments_Onboarding_Service::TEST_MODE_OPTION ); } + public function test_is_embedded_kyc_in_progress() { + $this->assertFalse( $this->onboarding_service->is_embedded_kyc_in_progress() ); + + $this->onboarding_service->set_embedded_kyc_in_progress(); + + $this->assertTrue( $this->onboarding_service->is_embedded_kyc_in_progress() ); + + $this->onboarding_service->clear_embedded_kyc_in_progress(); + + $this->assertFalse( $this->onboarding_service->is_embedded_kyc_in_progress() ); + } + /** * @dataProvider data_get_from */