diff --git a/modules/ppcp-api-client/services.php b/modules/ppcp-api-client/services.php index 280438b80..515968463 100644 --- a/modules/ppcp-api-client/services.php +++ b/modules/ppcp-api-client/services.php @@ -79,6 +79,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData; use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer; +use WooCommerce\PayPalCommerce\WcGateway\Helper\EnvironmentConfig; return array( 'api.host' => function( ContainerInterface $container ) : string { @@ -879,4 +880,54 @@ return array( 'api.partner_merchant_id-sandbox' => static function( ContainerInterface $container ) : string { return CONNECT_WOO_SANDBOX_MERCHANT_ID; }, + 'api.endpoint.login-seller-production' => static function ( ContainerInterface $container ) : LoginSeller { + return new LoginSeller( + $container->get( 'api.paypal-host-production' ), + $container->get( 'api.partner_merchant_id-production' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + 'api.endpoint.login-seller-sandbox' => static function ( ContainerInterface $container ) : LoginSeller { + return new LoginSeller( + $container->get( 'api.paypal-host-sandbox' ), + $container->get( 'api.partner_merchant_id-sandbox' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + 'api.env.paypal-host' => static function ( ContainerInterface $container ) : EnvironmentConfig { + /** + * Environment specific API host names. + * + * @type EnvironmentConfig + */ + return EnvironmentConfig::create( + 'string', + $container->get( 'api.paypal-host-production' ), + $container->get( 'api.paypal-host-sandbox' ) + ); + }, + 'api.env.endpoint.login-seller' => static function ( ContainerInterface $container ) : EnvironmentConfig { + /** + * Environment specific LoginSeller API instances. + * + * @type EnvironmentConfig + */ + return EnvironmentConfig::create( + LoginSeller::class, + $container->get( 'api.endpoint.login-seller-production' ), + $container->get( 'api.endpoint.login-seller-sandbox' ) + ); + }, + 'api.env.endpoint.partner-referrals' => static function ( ContainerInterface $container ) : EnvironmentConfig { + /** + * Environment specific PartnerReferrals API instances. + * + * @type EnvironmentConfig + */ + return EnvironmentConfig::create( + PartnerReferrals::class, + $container->get( 'api.endpoint.partner-referrals-production' ), + $container->get( 'api.endpoint.partner-referrals-sandbox' ) + ); + }, ); diff --git a/modules/ppcp-applepay/src/ApplepayModule.php b/modules/ppcp-applepay/src/ApplepayModule.php index aa9876069..dc7b3cf11 100644 --- a/modules/ppcp-applepay/src/ApplepayModule.php +++ b/modules/ppcp-applepay/src/ApplepayModule.php @@ -184,21 +184,17 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule add_filter( 'woocommerce_paypal_payments_rest_common_merchant_data', - function( array $merchant_data ) use ( $c ): array { - if ( ! isset( $merchant_data['features'] ) ) { - $merchant_data['features'] = array(); - } - + function( array $features ) use ( $c ): array { $product_status = $c->get( 'applepay.apple-product-status' ); assert( $product_status instanceof AppleProductStatus ); $apple_pay_enabled = $product_status->is_active(); - $merchant_data['features']['apple_pay'] = array( + $features['apple_pay'] = array( 'enabled' => $apple_pay_enabled, ); - return $merchant_data; + return $features; } ); diff --git a/modules/ppcp-googlepay/src/GooglepayModule.php b/modules/ppcp-googlepay/src/GooglepayModule.php index 01d5f8fae..dd7320011 100644 --- a/modules/ppcp-googlepay/src/GooglepayModule.php +++ b/modules/ppcp-googlepay/src/GooglepayModule.php @@ -234,21 +234,17 @@ class GooglepayModule implements ServiceModule, ExtendingModule, ExecutableModul add_filter( 'woocommerce_paypal_payments_rest_common_merchant_data', - function ( array $merchant_data ) use ( $c ): array { - if ( ! isset( $merchant_data['features'] ) ) { - $merchant_data['features'] = array(); - } - + function ( array $features ) use ( $c ): array { $product_status = $c->get( 'googlepay.helpers.apm-product-status' ); assert( $product_status instanceof ApmProductStatus ); $google_pay_enabled = $product_status->is_active(); - $merchant_data['features']['google_pay'] = array( + $features['google_pay'] = array( 'enabled' => $google_pay_enabled, ); - return $merchant_data; + return $features; } ); diff --git a/modules/ppcp-onboarding/services.php b/modules/ppcp-onboarding/services.php index 56a49be8e..aca3bed0a 100644 --- a/modules/ppcp-onboarding/services.php +++ b/modules/ppcp-onboarding/services.php @@ -14,14 +14,12 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\LoginSeller; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\Onboarding\Assets\OnboardingAssets; use WooCommerce\PayPalCommerce\Onboarding\Endpoint\LoginSellerEndpoint; use WooCommerce\PayPalCommerce\Onboarding\Endpoint\UpdateSignupLinksEndpoint; use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingSendOnlyNoticeRenderer; use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingRenderer; -use WooCommerce\PayPalCommerce\Onboarding\OnboardingRESTController; return array( 'api.sandbox-host' => static function ( ContainerInterface $container ): string { @@ -144,26 +142,6 @@ return array( ); }, - 'api.endpoint.login-seller-production' => static function ( ContainerInterface $container ) : LoginSeller { - - $logger = $container->get( 'woocommerce.logger.woocommerce' ); - return new LoginSeller( - $container->get( 'api.paypal-host-production' ), - $container->get( 'api.partner_merchant_id-production' ), - $logger - ); - }, - - 'api.endpoint.login-seller-sandbox' => static function ( ContainerInterface $container ) : LoginSeller { - - $logger = $container->get( 'woocommerce.logger.woocommerce' ); - return new LoginSeller( - $container->get( 'api.paypal-host-sandbox' ), - $container->get( 'api.partner_merchant_id-sandbox' ), - $logger - ); - }, - 'onboarding.endpoint.login-seller' => static function ( ContainerInterface $container ) : LoginSellerEndpoint { $request_data = $container->get( 'button.request-data' ); diff --git a/modules/ppcp-settings/package.json b/modules/ppcp-settings/package.json index 47e69347c..5cdb7be5c 100644 --- a/modules/ppcp-settings/package.json +++ b/modules/ppcp-settings/package.json @@ -9,6 +9,7 @@ "devDependencies": { "@wordpress/data": "^10.10.0", "@wordpress/data-controls": "^4.10.0", + "@wordpress/icons": "^10.14.0", "@wordpress/scripts": "^30.3.0", "classnames": "^2.5.1" }, diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js index 959b71bfe..239a088b7 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/BusyStateWrapper.js @@ -24,6 +24,7 @@ const BusyContext = createContext( false ); * @param {boolean} props.busySpinner - Allows disabling the spinner in busy-state. * @param {string} props.className - Additional class names for the wrapper. * @param {Function} props.onBusy - Callback to process child props when busy. + * @param {boolean} props.isBusy - Optional. Additional condition to determine if the component is busy. */ const BusyStateWrapper = ( { children, @@ -31,11 +32,12 @@ const BusyStateWrapper = ( { busySpinner = true, className = '', onBusy = () => ( { disabled: true } ), + isBusy = false, } ) => { - const { isBusy } = CommonHooks.useBusyState(); + const { isBusy: globalIsBusy } = CommonHooks.useBusyState(); const hasBusyParent = useContext( BusyContext ); - const isBusyComponent = isBusy && enabled; + const isBusyComponent = ( isBusy || globalIsBusy ) && enabled; const showSpinner = busySpinner && isBusyComponent && ! hasBusyParent; const wrapperClassName = classNames( 'ppcp-r-busy-wrapper', className, { diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AdvancedOptionsForm.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AdvancedOptionsForm.js index 6aabd15fd..4d1891735 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AdvancedOptionsForm.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/AdvancedOptionsForm.js @@ -1,208 +1,13 @@ -import { __, sprintf } from '@wordpress/i18n'; -import { Button, TextControl } from '@wordpress/components'; -import { - useRef, - useState, - useEffect, - useMemo, - useCallback, -} from '@wordpress/element'; - -import classNames from 'classnames'; - -import SettingsToggleBlock from '../../../ReusableComponents/SettingsToggleBlock'; import Separator from '../../../ReusableComponents/Separator'; -import DataStoreControl from '../../../ReusableComponents/DataStoreControl'; -import { CommonHooks } from '../../../../data'; -import { - useSandboxConnection, - useManualConnection, -} from '../../../../hooks/useHandleConnections'; - -import ConnectionButton from './ConnectionButton'; -import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; - -const FORM_ERRORS = { - noClientId: __( - 'Please enter your Client ID', - 'woocommerce-paypal-payments' - ), - noClientSecret: __( - 'Please enter your Secret Key', - 'woocommerce-paypal-payments' - ), - invalidClientId: __( - 'Please enter a valid Client ID', - 'woocommerce-paypal-payments' - ), -}; +import SandboxConnectionForm from './SandboxConnectionForm'; +import ManualConnectionForm from './ManualConnectionForm'; const AdvancedOptionsForm = () => { - const [ clientValid, setClientValid ] = useState( false ); - const [ secretValid, setSecretValid ] = useState( false ); - - const { isBusy } = CommonHooks.useBusyState(); - const { isSandboxMode, setSandboxMode } = useSandboxConnection(); - const { - handleConnectViaIdAndSecret, - isManualConnectionMode, - setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - } = useManualConnection(); - - const refClientId = useRef( null ); - const refClientSecret = useRef( null ); - - const validateManualConnectionForm = useCallback( () => { - const checks = [ - { - ref: refClientId, - valid: () => clientId, - errorMessage: FORM_ERRORS.noClientId, - }, - { - ref: refClientId, - valid: () => clientValid, - errorMessage: FORM_ERRORS.invalidClientId, - }, - { - ref: refClientSecret, - valid: () => clientSecret && secretValid, - errorMessage: FORM_ERRORS.noClientSecret, - }, - ]; - - for ( const { ref, valid, errorMessage } of checks ) { - if ( valid() ) { - continue; - } - - ref?.current?.focus(); - throw new Error( errorMessage ); - } - }, [ clientId, clientSecret, clientValid, secretValid ] ); - - const handleManualConnect = useCallback( - () => - handleConnectViaIdAndSecret( { - validation: validateManualConnectionForm, - } ), - [ handleConnectViaIdAndSecret, validateManualConnectionForm ] - ); - - useEffect( () => { - setClientValid( ! clientId || /^A[\w-]{79}$/.test( clientId ) ); - setSecretValid( clientSecret && clientSecret.length > 0 ); - }, [ clientId, clientSecret ] ); - - const clientIdLabel = useMemo( - () => - isSandboxMode - ? __( 'Sandbox Client ID', 'woocommerce-paypal-payments' ) - : __( 'Live Client ID', 'woocommerce-paypal-payments' ), - [ isSandboxMode ] - ); - - const secretKeyLabel = useMemo( - () => - isSandboxMode - ? __( 'Sandbox Secret Key', 'woocommerce-paypal-payments' ) - : __( 'Live Secret Key', 'woocommerce-paypal-payments' ), - [ isSandboxMode ] - ); - - const advancedUsersDescription = sprintf( - // translators: %s: Link to PayPal REST application guide - __( - 'For advanced users: Connect a custom PayPal REST app for full control over your integration. For more information on creating a PayPal REST application, click here.', - 'woocommerce-paypal-payments' - ), - 'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input' - ); - return ( <> - - - - - + - ( { - disabled: true, - label: props.label + ' ...', - } ) } - > - - - { clientValid || ( -

- { FORM_ERRORS.invalidClientId } -

- ) } - - -
-
+ ); }; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js index ad6a7dcef..7cbc504e0 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js @@ -1,15 +1,45 @@ import { Button } from '@wordpress/components'; - +import { useEffect } from '@wordpress/element'; import classNames from 'classnames'; - -import { CommonHooks } from '../../../../data'; import { openSignup } from '../../../ReusableComponents/Icons'; -import { - useProductionConnection, - useSandboxConnection, -} from '../../../../hooks/useHandleConnections'; +import { useHandleOnboardingButton } from '../../../../hooks/useHandleConnections'; import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; +/** + * Button component that outputs a placeholder button when no onboardingUrl is present yet - the + * placeholder button looks identical to the working button, but has no href, target, or + * custom connection attributes. + * + * @param {Object} props + * @param {string} props.className + * @param {string} props.variant + * @param {boolean} props.showIcon + * @param {?string} props.href + * @param {Element} props.children + */ +const ButtonOrPlaceholder = ( { + className, + variant, + showIcon, + href, + children, +} ) => { + const buttonProps = { + className, + variant, + icon: showIcon ? openSignup : null, + }; + + if ( href ) { + buttonProps.href = href; + buttonProps.target = 'PPFrame'; + buttonProps[ 'data-paypal-button' ] = 'true'; + buttonProps[ 'data-paypal-onboard-button' ] = 'true'; + } + + return ; +}; + const ConnectionButton = ( { title, isSandbox = false, @@ -17,31 +47,45 @@ const ConnectionButton = ( { showIcon = true, className = '', } ) => { - const { handleSandboxConnect } = useSandboxConnection(); - const { handleProductionConnect } = useProductionConnection(); + const { + onboardingUrl, + scriptLoaded, + setCompleteHandler, + removeCompleteHandler, + } = useHandleOnboardingButton( isSandbox ); const buttonClassName = classNames( 'ppcp-r-connection-button', className, { 'sandbox-mode': isSandbox, 'live-mode': ! isSandbox, } ); + const environment = isSandbox ? 'sandbox' : 'production'; - const handleConnectClick = async () => { - if ( isSandbox ) { - await handleSandboxConnect(); - } else { - await handleProductionConnect(); + useEffect( () => { + if ( scriptLoaded && onboardingUrl ) { + window.PAYPAL.apps.Signup.render(); + setCompleteHandler( environment ); } - }; + + return () => { + removeCompleteHandler(); + }; + }, [ + scriptLoaded, + onboardingUrl, + environment, + setCompleteHandler, + removeCompleteHandler, + ] ); return ( - - + ); }; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js new file mode 100644 index 000000000..ca0257159 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js @@ -0,0 +1,188 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from '@wordpress/element'; +import { Button, TextControl } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import classNames from 'classnames'; + +import SettingsToggleBlock from '../../../ReusableComponents/SettingsToggleBlock'; +import DataStoreControl from '../../../ReusableComponents/DataStoreControl'; +import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; +import { + useDirectAuthentication, + useSandboxConnection, +} from '../../../../hooks/useHandleConnections'; +import { OnboardingHooks } from '../../../../data'; + +const FORM_ERRORS = { + noClientId: __( + 'Please enter your Client ID', + 'woocommerce-paypal-payments' + ), + noClientSecret: __( + 'Please enter your Secret Key', + 'woocommerce-paypal-payments' + ), + invalidClientId: __( + 'Please enter a valid Client ID', + 'woocommerce-paypal-payments' + ), +}; + +const ManualConnectionForm = () => { + const [ clientValid, setClientValid ] = useState( false ); + const [ secretValid, setSecretValid ] = useState( false ); + const { isSandboxMode } = useSandboxConnection(); + const { + manualClientId, + setManualClientId, + manualClientSecret, + setManualClientSecret, + } = OnboardingHooks.useManualConnectionForm(); + const { + handleDirectAuthentication, + isManualConnectionMode, + setManualConnectionMode, + } = useDirectAuthentication(); + const refClientId = useRef( null ); + const refClientSecret = useRef( null ); + + // Form data validation and sanitation. + const getManualConnectionDetails = useCallback( () => { + const checks = [ + { + ref: refClientId, + valid: () => manualClientId, + errorMessage: FORM_ERRORS.noClientId, + }, + { + ref: refClientId, + valid: () => clientValid, + errorMessage: FORM_ERRORS.invalidClientId, + }, + { + ref: refClientSecret, + valid: () => manualClientSecret && secretValid, + errorMessage: FORM_ERRORS.noClientSecret, + }, + ]; + + for ( const { ref, valid, errorMessage } of checks ) { + if ( valid() ) { + continue; + } + + ref?.current?.focus(); + throw new Error( errorMessage ); + } + + return { + clientId: manualClientId, + clientSecret: manualClientSecret, + isSandbox: isSandboxMode, + }; + }, [ + manualClientId, + manualClientSecret, + isSandboxMode, + clientValid, + secretValid, + ] ); + + // On-the-fly form validation. + useEffect( () => { + setClientValid( + ! manualClientId || /^A[\w-]{79}$/.test( manualClientId ) + ); + setSecretValid( manualClientSecret && manualClientSecret.length > 0 ); + }, [ manualClientId, manualClientSecret ] ); + + // Environment-specific field labels. + const clientIdLabel = useMemo( + () => + isSandboxMode + ? __( 'Sandbox Client ID', 'woocommerce-paypal-payments' ) + : __( 'Live Client ID', 'woocommerce-paypal-payments' ), + [ isSandboxMode ] + ); + + const secretKeyLabel = useMemo( + () => + isSandboxMode + ? __( 'Sandbox Secret Key', 'woocommerce-paypal-payments' ) + : __( 'Live Secret Key', 'woocommerce-paypal-payments' ), + [ isSandboxMode ] + ); + + // Translations with placeholders. + const advancedUsersDescription = sprintf( + // translators: %s: Link to PayPal REST application guide + __( + 'For advanced users: Connect a custom PayPal REST app for full control over your integration. For more information on creating a PayPal REST application, click here.', + 'woocommerce-paypal-payments' + ), + 'https://woocommerce.com/document/woocommerce-paypal-payments/#manual-credential-input' + ); + + // Button click handler. + const handleManualConnect = useCallback( + () => handleDirectAuthentication( getManualConnectionDetails ), + [ handleDirectAuthentication, getManualConnectionDetails ] + ); + + return ( + ( { + disabled: true, + label: props.label + ' ...', + } ) } + > + + + { clientValid || ( +

+ { FORM_ERRORS.invalidClientId } +

+ ) } + + +
+
+ ); +}; + +export default ManualConnectionForm; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js index 3c12e1206..817b26f3e 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/Navigation.js @@ -1,7 +1,6 @@ import { Button, Icon } from '@wordpress/components'; import { chevronLeft } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; - import classNames from 'classnames'; import { OnboardingHooks } from '../../../../data'; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/SandboxConnectionForm.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/SandboxConnectionForm.js new file mode 100644 index 000000000..39b115b9d --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/SandboxConnectionForm.js @@ -0,0 +1,42 @@ +import { __ } from '@wordpress/i18n'; + +import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; +import SettingsToggleBlock from '../../../ReusableComponents/SettingsToggleBlock'; +import { useSandboxConnection } from '../../../../hooks/useHandleConnections'; +import ConnectionButton from './ConnectionButton'; + +const SandboxConnectionForm = () => { + const { isSandboxMode, setSandboxMode } = useSandboxConnection(); + + return ( + + + + + + ); +}; + +export default SandboxConnectionForm; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js index 5215f51f9..25e713bcd 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js @@ -17,7 +17,7 @@ const TabOverview = () => { const [ todosData, setTodosData ] = useState( todosDataDefault ); const [ isRefreshing, setIsRefreshing ] = useState( false ); - const { merchant } = useMerchantInfo(); + const { merchantFeatures } = useMerchantInfo(); const { refreshFeatureStatuses, setActiveModal } = useDispatch( STORE_NAME ); @@ -30,13 +30,13 @@ const TabOverview = () => { // Map merchant features status to our config const features = useMemo( () => { return featuresData.map( ( feature ) => { - const merchantFeature = merchant?.features?.[ feature.id ]; + const merchantFeature = merchantFeatures?.[ feature.id ]; return { ...feature, enabled: merchantFeature?.enabled ?? false, }; } ); - }, [ featuresData, merchant?.features ] ); + }, [ featuresData, merchantFeatures ] ); const refreshHandler = async () => { setIsRefreshing( true ); diff --git a/modules/ppcp-settings/resources/js/data/common/action-types.js b/modules/ppcp-settings/resources/js/data/common/action-types.js index ffafa7984..8ae56b20c 100644 --- a/modules/ppcp-settings/resources/js/data/common/action-types.js +++ b/modules/ppcp-settings/resources/js/data/common/action-types.js @@ -19,11 +19,11 @@ export default { // Controls - always start with "DO_". DO_PERSIST_DATA: 'COMMON:DO_PERSIST_DATA', - DO_MANUAL_CONNECTION: 'COMMON:DO_MANUAL_CONNECTION', - DO_SANDBOX_LOGIN: 'COMMON:DO_SANDBOX_LOGIN', - DO_PRODUCTION_LOGIN: 'COMMON:DO_PRODUCTION_LOGIN', + DO_DIRECT_API_AUTHENTICATION: 'COMMON:DO_DIRECT_API_AUTHENTICATION', + DO_OAUTH_AUTHENTICATION: 'COMMON:DO_OAUTH_AUTHENTICATION', + DO_GENERATE_ONBOARDING_URL: 'COMMON:DO_GENERATE_ONBOARDING_URL', DO_REFRESH_MERCHANT: 'COMMON:DO_REFRESH_MERCHANT', - DO_REFRESH_FEATURES: 'DO_REFRESH_FEATURES', + DO_REFRESH_FEATURES: 'COMMON:DO_REFRESH_FEATURES', DO_RESUBSCRIBE_WEBHOOKS: 'COMMON:DO_RESUBSCRIBE_WEBHOOKS', DO_START_WEBHOOK_SIMULATION: 'COMMON:DO_START_WEBHOOK_SIMULATION', DO_CHECK_WEBHOOK_SIMULATION_STATE: diff --git a/modules/ppcp-settings/resources/js/data/common/actions.js b/modules/ppcp-settings/resources/js/data/common/actions.js index 828a91731..c859fe0be 100644 --- a/modules/ppcp-settings/resources/js/data/common/actions.js +++ b/modules/ppcp-settings/resources/js/data/common/actions.js @@ -123,28 +123,6 @@ export const setManualConnectionMode = ( useManualConnection ) => ( { payload: { useManualConnection }, } ); -/** - * Persistent. Changes the "client ID" value. - * - * @param {string} clientId - * @return {Action} The action. - */ -export const setClientId = ( clientId ) => ( { - type: ACTION_TYPES.SET_PERSISTENT, - payload: { clientId }, -} ); - -/** - * Persistent. Changes the "client secret" value. - * - * @param {string} clientSecret - * @return {Action} The action. - */ -export const setClientSecret = ( clientSecret ) => ( { - type: ACTION_TYPES.SET_PERSISTENT, - payload: { clientSecret }, -} ); - /** * Side effect. Saves the persistent details to the WP database. * @@ -161,8 +139,12 @@ export const persist = function* () { * * @return {Action} The action. */ -export const connectToSandbox = function* () { - return yield { type: ACTION_TYPES.DO_SANDBOX_LOGIN }; +export const sandboxOnboardingUrl = function* () { + return yield { + type: ACTION_TYPES.DO_GENERATE_ONBOARDING_URL, + useSandbox: true, + products: [ 'EXPRESS_CHECKOUT' ], + }; }; /** @@ -171,27 +153,65 @@ export const connectToSandbox = function* () { * @param {string[]} products Which products/features to display in the ISU popup. * @return {Action} The action. */ -export const connectToProduction = function* ( products = [] ) { - return yield { type: ACTION_TYPES.DO_PRODUCTION_LOGIN, products }; +export const productionOnboardingUrl = function* ( products = [] ) { + return yield { + type: ACTION_TYPES.DO_GENERATE_ONBOARDING_URL, + useSandbox: false, + products, + }; }; /** - * Side effect. Initiates a manual connection attempt using the provided client ID and secret. + * Side effect. Initiates a direct connection attempt using the provided client ID and secret. * + * This action accepts parameters instead of fetching data from the Redux state because the + * values (ID and secret) are not managed by a central redux store, but might come from private + * component state. + * + * @param {string} clientId - AP client ID (always 80-characters, starting with "A"). + * @param {string} clientSecret - API client secret. + * @param {boolean} useSandbox - Whether the credentials are for a sandbox account. * @return {Action} The action. */ -export const connectViaIdAndSecret = function* () { - const { clientId, clientSecret, useSandbox } = - yield select( STORE_NAME ).persistentData(); - +export const authenticateWithCredentials = function* ( + clientId, + clientSecret, + useSandbox +) { return yield { - type: ACTION_TYPES.DO_MANUAL_CONNECTION, + type: ACTION_TYPES.DO_DIRECT_API_AUTHENTICATION, clientId, clientSecret, useSandbox, }; }; +/** + * Side effect. Completes the ISU login by authenticating the user via the one time sharedId and + * authCode provided by PayPal. + * + * This action accepts parameters instead of fetching data from the Redux state because all + * parameters are dynamically generated during the authentication process, and not managed by our + * Redux store. + * + * @param {string} sharedId - OAuth client ID; called "sharedId" to prevent confusion with the API client ID. + * @param {string} authCode - OAuth authorization code provided during onboarding. + * @param {boolean} useSandbox - Whether the credentials are for a sandbox account. + * @return {Action} The action. + */ +export const authenticateWithOAuth = function* ( + sharedId, + authCode, + useSandbox +) { + return yield { + type: ACTION_TYPES.DO_OAUTH_AUTHENTICATION, + sharedId, + authCode, + useSandbox, + }; +}; + /** * Side effect. Clears and refreshes the merchant data via a REST request. * diff --git a/modules/ppcp-settings/resources/js/data/common/constants.js b/modules/ppcp-settings/resources/js/data/common/constants.js index a44d6f295..49abba6db 100644 --- a/modules/ppcp-settings/resources/js/data/common/constants.js +++ b/modules/ppcp-settings/resources/js/data/common/constants.js @@ -35,14 +35,25 @@ export const REST_HYDRATE_MERCHANT_PATH = '/wc/v3/wc_paypal/common/merchant'; export const REST_PERSIST_PATH = '/wc/v3/wc_paypal/common'; /** - * REST path to perform the manual connection check, using client ID and secret, + * REST path to perform the manual connection authentication, using client ID and secret. * * Used by: Controls - * See: ConnectManualRestEndpoint.php + * See: AuthenticateRestEndpoint.php * * @type {string} */ -export const REST_MANUAL_CONNECTION_PATH = '/wc/v3/wc_paypal/connect_manual'; +export const REST_DIRECT_AUTHENTICATION_PATH = + '/wc/v3/wc_paypal/authenticate/direct'; + +/** + * REST path to perform the ISU authentication check, using shared ID and authCode. + * + * Used by: Controls + * See: AuthenticateRestEndpoint.php + * + * @type {string} + */ +export const REST_ISU_AUTHENTICATION_PATH = '/wc/v3/wc_paypal/authenticate/isu'; /** * REST path to generate an ISU URL for the PayPal-login. diff --git a/modules/ppcp-settings/resources/js/data/common/controls.js b/modules/ppcp-settings/resources/js/data/common/controls.js index 1bb48c334..62d4a8f84 100644 --- a/modules/ppcp-settings/resources/js/data/common/controls.js +++ b/modules/ppcp-settings/resources/js/data/common/controls.js @@ -10,11 +10,12 @@ import apiFetch from '@wordpress/api-fetch'; import { + REST_PERSIST_PATH, + REST_DIRECT_AUTHENTICATION_PATH, REST_CONNECTION_URL_PATH, REST_HYDRATE_MERCHANT_PATH, - REST_MANUAL_CONNECTION_PATH, - REST_PERSIST_PATH, REST_REFRESH_FEATURES_PATH, + REST_ISU_AUTHENTICATION_PATH, REST_WEBHOOKS, REST_WEBHOOKS_SIMULATE, } from './constants'; @@ -33,15 +34,15 @@ export const controls = { } }, - async [ ACTION_TYPES.DO_SANDBOX_LOGIN ]() { + async [ ACTION_TYPES.DO_GENERATE_ONBOARDING_URL ]( { + products, + useSandbox, + } ) { try { return apiFetch( { path: REST_CONNECTION_URL_PATH, method: 'POST', - data: { - environment: 'sandbox', - products: [ 'EXPRESS_CHECKOUT' ], // Sandbox always uses EXPRESS_CHECKOUT. - }, + data: { useSandbox, products }, } ); } catch ( e ) { return { @@ -51,32 +52,14 @@ export const controls = { } }, - async [ ACTION_TYPES.DO_PRODUCTION_LOGIN ]( { products } ) { - try { - return apiFetch( { - path: REST_CONNECTION_URL_PATH, - method: 'POST', - data: { - environment: 'production', - products, - }, - } ); - } catch ( e ) { - return { - success: false, - error: e, - }; - } - }, - - async [ ACTION_TYPES.DO_MANUAL_CONNECTION ]( { + async [ ACTION_TYPES.DO_DIRECT_API_AUTHENTICATION ]( { clientId, clientSecret, useSandbox, } ) { try { return await apiFetch( { - path: REST_MANUAL_CONNECTION_PATH, + path: REST_DIRECT_AUTHENTICATION_PATH, method: 'POST', data: { clientId, @@ -92,6 +75,29 @@ export const controls = { } }, + async [ ACTION_TYPES.DO_OAUTH_AUTHENTICATION ]( { + sharedId, + authCode, + useSandbox, + } ) { + try { + return await apiFetch( { + path: REST_ISU_AUTHENTICATION_PATH, + method: 'POST', + data: { + sharedId, + authCode, + useSandbox, + }, + } ); + } catch ( e ) { + return { + success: false, + error: e, + }; + } + }, + async [ ACTION_TYPES.DO_REFRESH_MERCHANT ]() { try { return await apiFetch( { path: REST_HYDRATE_MERCHANT_PATH } ); diff --git a/modules/ppcp-settings/resources/js/data/common/hooks.js b/modules/ppcp-settings/resources/js/data/common/hooks.js index 724f2cb6c..4aea08caa 100644 --- a/modules/ppcp-settings/resources/js/data/common/hooks.js +++ b/modules/ppcp-settings/resources/js/data/common/hooks.js @@ -28,11 +28,10 @@ const useHooks = () => { persist, setSandboxMode, setManualConnectionMode, - setClientId, - setClientSecret, - connectToSandbox, - connectToProduction, - connectViaIdAndSecret, + sandboxOnboardingUrl, + productionOnboardingUrl, + authenticateWithCredentials, + authenticateWithOAuth, setActiveModal, startWebhookSimulation, checkWebhookSimulationState, @@ -43,19 +42,26 @@ const useHooks = () => { const activeModal = useTransient( 'activeModal' ); // Persistent accessors. - const clientId = usePersistent( 'clientId' ); - const clientSecret = usePersistent( 'clientSecret' ); const isSandboxMode = usePersistent( 'useSandbox' ); const isManualConnectionMode = usePersistent( 'useManualConnection' ); - const webhooks = usePersistent( 'webhooks' ); const merchant = useSelect( ( select ) => select( STORE_NAME ).merchant(), [] ); + + // Read-only properties. const wooSettings = useSelect( ( select ) => select( STORE_NAME ).wooSettings(), [] ); + const features = useSelect( + ( select ) => select( STORE_NAME ).features(), + [] + ); + const webhooks = useSelect( + ( select ) => select( STORE_NAME ).webhooks(), + [] + ); const savePersistent = async ( setter, value ) => { setter( value ); @@ -74,19 +80,13 @@ const useHooks = () => { setManualConnectionMode: ( state ) => { return savePersistent( setManualConnectionMode, state ); }, - clientId, - setClientId: ( value ) => { - return savePersistent( setClientId, value ); - }, - clientSecret, - setClientSecret: ( value ) => { - return savePersistent( setClientSecret, value ); - }, - connectToSandbox, - connectToProduction, - connectViaIdAndSecret, + sandboxOnboardingUrl, + productionOnboardingUrl, + authenticateWithCredentials, + authenticateWithOAuth, merchant, wooSettings, + features, webhooks, startWebhookSimulation, checkWebhookSimulationState, @@ -94,36 +94,30 @@ const useHooks = () => { }; export const useSandbox = () => { - const { isSandboxMode, setSandboxMode, connectToSandbox } = useHooks(); + const { isSandboxMode, setSandboxMode, sandboxOnboardingUrl } = useHooks(); - return { isSandboxMode, setSandboxMode, connectToSandbox }; + return { isSandboxMode, setSandboxMode, sandboxOnboardingUrl }; }; export const useProduction = () => { - const { connectToProduction } = useHooks(); + const { productionOnboardingUrl } = useHooks(); - return { connectToProduction }; + return { productionOnboardingUrl }; }; -export const useManualConnection = () => { +export const useAuthentication = () => { const { isManualConnectionMode, setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - connectViaIdAndSecret, + authenticateWithCredentials, + authenticateWithOAuth, } = useHooks(); return { isManualConnectionMode, setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - connectViaIdAndSecret, + authenticateWithCredentials, + authenticateWithOAuth, }; }; @@ -150,7 +144,7 @@ export const useWebhooks = () => { }; }; export const useMerchantInfo = () => { - const { merchant } = useHooks(); + const { merchant, features } = useHooks(); const { refreshMerchantData } = useDispatch( STORE_NAME ); const verifyLoginStatus = useCallback( async () => { @@ -166,6 +160,7 @@ export const useMerchantInfo = () => { return { merchant, // Merchant details + features, // Eligible merchant features verifyLoginStatus, // Callback }; }; diff --git a/modules/ppcp-settings/resources/js/data/common/reducer.js b/modules/ppcp-settings/resources/js/data/common/reducer.js index 79d38a833..e9ff90a42 100644 --- a/modules/ppcp-settings/resources/js/data/common/reducer.js +++ b/modules/ppcp-settings/resources/js/data/common/reducer.js @@ -23,20 +23,36 @@ const defaultTransient = Object.freeze( { isSandbox: false, id: '', email: '', + clientId: '', + clientSecret: '', } ), wooSettings: Object.freeze( { storeCountry: '', storeCurrency: '', } ), + + features: Object.freeze( { + save_paypal_and_venmo: { + enabled: false, + }, + advanced_credit_and_debit_cards: { + enabled: false, + }, + apple_pay: { + enabled: false, + }, + google_pay: { + enabled: false, + }, + } ), + + webhooks: Object.freeze( [] ), } ); const defaultPersistent = Object.freeze( { useSandbox: false, useManualConnection: false, - clientId: '', - clientSecret: '', - webhooks: [], } ); // Reducer logic. @@ -84,22 +100,25 @@ const commonReducer = createReducer( defaultTransient, defaultPersistent, { [ ACTION_TYPES.DO_REFRESH_MERCHANT ]: ( state ) => ( { ...state, merchant: Object.freeze( { ...defaultTransient.merchant } ), + features: Object.freeze( { ...defaultTransient.features } ), } ), [ ACTION_TYPES.HYDRATE ]: ( state, payload ) => { const newState = setPersistent( state, payload.data ); // Populate read-only properties. - [ 'wooSettings', 'merchant' ].forEach( ( key ) => { - if ( ! payload[ key ] ) { - return; - } + [ 'wooSettings', 'merchant', 'features', 'webhooks' ].forEach( + ( key ) => { + if ( ! payload[ key ] ) { + return; + } - newState[ key ] = Object.freeze( { - ...newState[ key ], - ...payload[ key ], - } ); - } ); + newState[ key ] = Object.freeze( { + ...newState[ key ], + ...payload[ key ], + } ); + } + ); return newState; }, diff --git a/modules/ppcp-settings/resources/js/data/common/selectors.js b/modules/ppcp-settings/resources/js/data/common/selectors.js index 96393942a..4716550bb 100644 --- a/modules/ppcp-settings/resources/js/data/common/selectors.js +++ b/modules/ppcp-settings/resources/js/data/common/selectors.js @@ -16,8 +16,14 @@ export const persistentData = ( state ) => { }; export const transientData = ( state ) => { - const { data, merchant, wooSettings, ...transientState } = - getState( state ); + const { + data, + merchant, + features, + wooSettings, + webhooks, + ...transientState + } = getState( state ); return transientState || EMPTY_OBJ; }; @@ -30,6 +36,10 @@ export const merchant = ( state ) => { return getState( state ).merchant || EMPTY_OBJ; }; +export const features = ( state ) => { + return getState( state ).features || EMPTY_OBJ; +}; + export const wooSettings = ( state ) => { return getState( state ).wooSettings || EMPTY_OBJ; }; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/actions.js b/modules/ppcp-settings/resources/js/data/onboarding/actions.js index dcf401995..e9bf8ed5f 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/actions.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/actions.js @@ -47,6 +47,28 @@ export const setIsReady = ( isReady ) => ( { payload: { isReady }, } ); +/** + * Transient. Sets the "manualClientId" value. + * + * @param {string} manualClientId + * @return {Action} The action. + */ +export const setManualClientId = ( manualClientId ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { manualClientId }, +} ); + +/** + * Transient. Sets the "manualClientSecret" value. + * + * @param {string} manualClientSecret + * @return {Action} The action. + */ +export const setManualClientSecret = ( manualClientSecret ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { manualClientSecret }, +} ); + /** * Persistent.Set the "onboarding completed" flag which shows or hides the wizard. * diff --git a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js index e8582821e..c4308c0fa 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js @@ -30,6 +30,8 @@ const useHooks = () => { setStep, setCompleted, setIsCasualSeller, + setManualClientId, + setManualClientSecret, setAreOptionalPaymentMethodsEnabled, setProducts, } = useDispatch( STORE_NAME ); @@ -43,6 +45,8 @@ const useHooks = () => { // Transient accessors. const isReady = useTransient( 'isReady' ); + const manualClientId = useTransient( 'manualClientId' ); + const manualClientSecret = useTransient( 'manualClientSecret' ); // Persistent accessors. const step = usePersistent( 'step' ); @@ -73,6 +77,14 @@ const useHooks = () => { setIsCasualSeller: ( value ) => { return savePersistent( setIsCasualSeller, value ); }, + manualClientId, + setManualClientId: ( value ) => { + return savePersistent( setManualClientId, value ); + }, + manualClientSecret, + setManualClientSecret: ( value ) => { + return savePersistent( setManualClientSecret, value ); + }, areOptionalPaymentMethodsEnabled, setAreOptionalPaymentMethodsEnabled: ( value ) => { return savePersistent( setAreOptionalPaymentMethodsEnabled, value ); @@ -88,6 +100,22 @@ const useHooks = () => { }; }; +export const useManualConnectionForm = () => { + const { + manualClientId, + setManualClientId, + manualClientSecret, + setManualClientSecret, + } = useHooks(); + + return { + manualClientId, + setManualClientId, + manualClientSecret, + setManualClientSecret, + }; +}; + export const useBusiness = () => { const { isCasualSeller, setIsCasualSeller } = useHooks(); diff --git a/modules/ppcp-settings/resources/js/data/onboarding/reducer.js b/modules/ppcp-settings/resources/js/data/onboarding/reducer.js index 2b16e2416..8d03f9fbf 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/reducer.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/reducer.js @@ -14,6 +14,8 @@ import ACTION_TYPES from './action-types'; const defaultTransient = Object.freeze( { isReady: false, + manualClientId: '', + manualClientSecret: '', // Read only values, provided by the server. flags: Object.freeze( { diff --git a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js index 2e0953437..9f3a7f35d 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js @@ -52,9 +52,6 @@ export const determineProducts = ( state ) => { * The store uses the Express-checkout product. */ derivedProducts.push( 'EXPRESS_CHECKOUT' ); - - // TODO: Add the "BCDC" product/feature - // Requirement: "EXPRESS_CHECKOUT with BCDC" } else { /** * Branch 3: Merchant is business, and can use CC payments. @@ -64,8 +61,7 @@ export const determineProducts = ( state ) => { } if ( canUseVaulting ) { - // TODO: Add the "Vaulting" product/feature - // Requirement: "... with Vault" + derivedProducts.push( 'ADVANCED_VAULTING' ); } return derivedProducts; diff --git a/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js b/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js index d34e74f42..f6837c488 100644 --- a/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js +++ b/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js @@ -1,9 +1,12 @@ import { __ } from '@wordpress/i18n'; import { useDispatch } from '@wordpress/data'; +import { useState, useEffect, useCallback, useRef } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; import { CommonHooks, OnboardingHooks } from '../data'; -import { openPopup } from '../utils/window'; + +const PAYPAL_PARTNER_SDK_URL = + 'https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js'; const MESSAGES = { CONNECTED: __( 'Connected to PayPal', 'woocommerce-paypal-payments' ), @@ -32,35 +35,137 @@ const MESSAGES = { const ACTIVITIES = { CONNECT_SANDBOX: 'ISU_LOGIN_SANDBOX', CONNECT_PRODUCTION: 'ISU_LOGIN_PRODUCTION', + CONNECT_ISU: 'ISU_LOGIN', CONNECT_MANUAL: 'MANUAL_LOGIN', }; -const handlePopupWithCompletion = ( url, onError ) => { - return new Promise( ( resolve ) => { - const popup = openPopup( url ); +export const useHandleOnboardingButton = ( isSandbox ) => { + const { sandboxOnboardingUrl } = CommonHooks.useSandbox(); + const { productionOnboardingUrl } = CommonHooks.useProduction(); + const products = OnboardingHooks.useDetermineProducts(); + const { withActivity } = CommonHooks.useBusyState(); + const { authenticateWithOAuth } = CommonHooks.useAuthentication(); + const [ onboardingUrl, setOnboardingUrl ] = useState( '' ); + const [ scriptLoaded, setScriptLoaded ] = useState( false ); + const timerRef = useRef( null ); - if ( ! popup ) { - onError( MESSAGES.POPUP_BLOCKED ); - resolve( false ); + useEffect( () => { + const fetchOnboardingUrl = async () => { + let res; + if ( isSandbox ) { + res = await sandboxOnboardingUrl(); + } else { + res = await productionOnboardingUrl( products ); + } + + if ( res.success && res.data ) { + setOnboardingUrl( res.data ); + } else { + console.error( 'Failed to fetch onboarding URL' ); + } + }; + + fetchOnboardingUrl(); + }, [ isSandbox, productionOnboardingUrl, products, sandboxOnboardingUrl ] ); + + useEffect( () => { + /** + * The partner.js script initializes all onboarding buttons in the onload event. + * When no buttons are present, a JS error is displayed; i.e. we should load this script + * only when the button is ready (with a valid href and data-attributes). + */ + if ( ! onboardingUrl ) { return; } - // Check popup state every 500ms - const checkPopup = setInterval( () => { - if ( popup.closed ) { - clearInterval( checkPopup ); - resolve( true ); - } - }, 500 ); + const script = document.createElement( 'script' ); + script.id = 'partner-js'; + script.src = PAYPAL_PARTNER_SDK_URL; + script.onload = () => { + setScriptLoaded( true ); + }; + document.body.appendChild( script ); return () => { - clearInterval( checkPopup ); + /** + * When the component is unmounted, remove the partner.js script, as well as the + * dynamic scripts it loaded (signup-js and rampConfig-js) + * + * This is important, as the onboarding button is only initialized during the onload + * event of those scripts; i.e. we need to load the scripts again, when the button is + * rendered again. + */ + const onboardingScripts = [ + 'partner-js', + 'signup-js', + 'rampConfig-js', + ]; - if ( popup && ! popup.closed ) { - popup.close(); - } + onboardingScripts.forEach( ( id ) => { + const el = document.querySelector( `script[id="${ id }"]` ); + + if ( el?.parentNode ) { + el.parentNode.removeChild( el ); + } + } ); }; - } ); + }, [ onboardingUrl ] ); + + const setCompleteHandler = useCallback( + ( environment ) => { + const onComplete = async ( authCode, sharedId ) => { + /** + * Until now, the full page is blocked by PayPal's semi-transparent, black overlay. + * But at this point, the overlay is removed, while we process the sharedId and + * authCode via a REST call. + * + * Note: The REST response is irrelevant, since PayPal will most likely refresh this + * frame before the REST endpoint returns a value. Using "withActivity" is more of a + * visual cue to the user that something is still processing in the background. + */ + await withActivity( + ACTIVITIES.CONNECT_ISU, + 'Validating the connection details', + async () => { + await authenticateWithOAuth( + sharedId, + authCode, + 'sandbox' === environment + ); + } + ); + }; + + const addHandler = () => { + const MiniBrowser = window.PAYPAL?.apps?.Signup?.MiniBrowser; + if ( ! MiniBrowser || MiniBrowser.onOnboardComplete ) { + return; + } + + MiniBrowser.onOnboardComplete = onComplete; + }; + + // Ensure the onComplete handler is not removed by a PayPal init script. + timerRef.current = setInterval( addHandler, 250 ); + }, + [ authenticateWithOAuth, withActivity ] + ); + + const removeCompleteHandler = useCallback( () => { + if ( timerRef.current ) { + clearInterval( timerRef.current ); + timerRef.current = null; + } + + delete window.PAYPAL?.apps?.Signup?.MiniBrowser?.onOnboardComplete; + }, [] ); + + return { + onboardingUrl, + scriptLoaded, + setCompleteHandler, + removeCompleteHandler, + }; }; const useConnectionBase = () => { @@ -92,104 +197,55 @@ const useConnectionBase = () => { }; }; -const useConnectionAttempt = ( connectFn, errorMessage ) => { - const { handleFailed, createErrorNotice, handleCompleted } = - useConnectionBase(); - - return async ( ...args ) => { - const res = await connectFn( ...args ); - - if ( ! res.success || ! res.data ) { - handleFailed( res, errorMessage ); - return false; - } - - const popupClosed = await handlePopupWithCompletion( - res.data, - createErrorNotice - ); - - if ( popupClosed ) { - await handleCompleted(); - } - - return popupClosed; - }; -}; - export const useSandboxConnection = () => { - const { connectToSandbox, isSandboxMode, setSandboxMode } = - CommonHooks.useSandbox(); - const { withActivity } = CommonHooks.useBusyState(); - const connectionAttempt = useConnectionAttempt( - connectToSandbox, - MESSAGES.SANDBOX_ERROR - ); - - const handleSandboxConnect = async () => { - return withActivity( - ACTIVITIES.CONNECT_SANDBOX, - 'Connecting to sandbox account', - connectionAttempt - ); - }; + const { isSandboxMode, setSandboxMode } = CommonHooks.useSandbox(); return { - handleSandboxConnect, isSandboxMode, setSandboxMode, }; }; -export const useProductionConnection = () => { - const { connectToProduction } = CommonHooks.useProduction(); - const { withActivity } = CommonHooks.useBusyState(); - const products = OnboardingHooks.useDetermineProducts(); - const connectionAttempt = useConnectionAttempt( - () => connectToProduction( products ), - MESSAGES.PRODUCTION_ERROR - ); - - const handleProductionConnect = async () => { - return withActivity( - ACTIVITIES.CONNECT_PRODUCTION, - 'Connecting to production account', - connectionAttempt - ); - }; - - return { handleProductionConnect }; -}; - -export const useManualConnection = () => { +export const useDirectAuthentication = () => { const { handleFailed, handleCompleted, createErrorNotice } = useConnectionBase(); const { withActivity } = CommonHooks.useBusyState(); const { - connectViaIdAndSecret, + authenticateWithCredentials, isManualConnectionMode, setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, - } = CommonHooks.useManualConnection(); + } = CommonHooks.useAuthentication(); - const handleConnectViaIdAndSecret = async ( { validation } = {} ) => { + const handleDirectAuthentication = async ( connectionDetails ) => { return withActivity( ACTIVITIES.CONNECT_MANUAL, 'Connecting manually via Client ID and Secret', async () => { - if ( 'function' === typeof validation ) { + let data; + + if ( 'function' === typeof connectionDetails ) { try { - validation(); + data = connectionDetails(); } catch ( exception ) { createErrorNotice( exception.message ); return; } + } else if ( 'object' === typeof connectionDetails ) { + data = connectionDetails; } - const res = await connectViaIdAndSecret(); + if ( ! data || ! data.clientId || ! data.clientSecret ) { + createErrorNotice( + 'Invalid connection details (clientID or clientSecret missing)' + ); + return; + } + + const res = await authenticateWithCredentials( + data.clientId, + data.clientSecret, + !! data.isSandbox + ); if ( res.success ) { await handleCompleted(); @@ -203,12 +259,8 @@ export const useManualConnection = () => { }; return { - handleConnectViaIdAndSecret, + handleDirectAuthentication, isManualConnectionMode, setManualConnectionMode, - clientId, - setClientId, - clientSecret, - setClientSecret, }; }; diff --git a/modules/ppcp-settings/resources/js/utils/window.js b/modules/ppcp-settings/resources/js/utils/window.js deleted file mode 100644 index 165874302..000000000 --- a/modules/ppcp-settings/resources/js/utils/window.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Opens the provided URL, preferably in a popup window. - * - * Popups are usually only supported on desktop devices, when the browser is not in fullscreen mode. - * - * @param {string} url - * @param {Object} options - * @param {string} options.name - * @param {number} options.width - * @param {number} options.height - * @param {boolean} options.resizeable - * @return {null|Window} Popup window instance, or null. - */ -export const openPopup = ( - url, - { name = '_blank', width = 450, height = 720, resizeable = false } = {} -) => { - width = Math.max( 100, Math.min( window.screen.width - 40, width ) ); - height = Math.max( 100, Math.min( window.screen.height - 40, height ) ); - - const left = ( window.screen.width - width ) / 2; - const top = ( window.screen.height - height ) / 2; - - const features = [ - `width=${ width }`, - `height=${ height }`, - `left=${ left }`, - `top=${ top }`, - `resizable=${ resizeable ? 'yes' : 'no' }`, - `scrollbars=yes`, - `status=no`, - ]; - - const popup = window.open( url, name, features.join( ',' ) ); - - if ( popup && ! popup.closed ) { - popup.focus(); - return popup; - } - - return null; -}; diff --git a/modules/ppcp-settings/services.php b/modules/ppcp-settings/services.php index a3a2872b5..080973039 100644 --- a/modules/ppcp-settings/services.php +++ b/modules/ppcp-settings/services.php @@ -10,20 +10,21 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; +use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings; use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings; use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile; +use WooCommerce\PayPalCommerce\Settings\Endpoint\AuthenticationRestEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\CommonRestEndpoint; -use WooCommerce\PayPalCommerce\Settings\Endpoint\ConnectManualRestEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\LoginLinkRestEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\OnboardingRestEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\RefreshFeatureStatusEndpoint; -use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Settings\Endpoint\WebhookSettingsEndpoint; +use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener; +use WooCommerce\PayPalCommerce\Settings\Service\AuthenticationManager; use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator; use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; -use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener; return array( 'settings.url' => static function ( ContainerInterface $container ) : string { @@ -79,17 +80,14 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, - 'settings.rest.connect_manual' => static function ( ContainerInterface $container ) : ConnectManualRestEndpoint { - return new ConnectManualRestEndpoint( - $container->get( 'api.paypal-host-production' ), - $container->get( 'api.paypal-host-sandbox' ), - $container->get( 'woocommerce.logger.woocommerce' ), - $container->get( 'settings.data.general' ) + 'settings.rest.connect_manual' => static function ( ContainerInterface $container ) : AuthenticationRestEndpoint { + return new AuthenticationRestEndpoint( + $container->get( 'settings.service.authentication_manager' ), ); }, 'settings.rest.login_link' => static function ( ContainerInterface $container ) : LoginLinkRestEndpoint { return new LoginLinkRestEndpoint( - $container->get( 'settings.service.connection-url-generators' ), + $container->get( 'settings.service.connection-url-generator' ), ); }, 'settings.rest.webhooks' => static function ( ContainerInterface $container ) : WebhookSettingsEndpoint { @@ -160,8 +158,8 @@ return array( return new ConnectionListener( $page_id, - $container->get( 'settings.data.common' ), $container->get( 'settings.service.onboarding-url-manager' ), + $container->get( 'settings.service.authentication_manager' ), $container->get( 'woocommerce.logger.woocommerce' ) ); }, @@ -174,33 +172,24 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, - 'settings.service.connection-url-generators' => static function ( ContainerInterface $container ) : array { - // Define available environments. - $environments = array( - 'production' => array( - 'partner_referrals' => $container->get( 'api.endpoint.partner-referrals-production' ), - ), - 'sandbox' => array( - 'partner_referrals' => $container->get( 'api.endpoint.partner-referrals-sandbox' ), - ), + 'settings.service.connection-url-generator' => static function ( ContainerInterface $container ) : ConnectionUrlGenerator { + return new ConnectionUrlGenerator( + $container->get( 'api.env.endpoint.partner-referrals' ), + $container->get( 'api.repository.partner-referrals-data' ), + $container->get( 'settings.service.onboarding-url-manager' ), + $container->get( 'woocommerce.logger.woocommerce' ) ); - - $generators = array(); - - // Instantiate URL generators for each environment. - foreach ( $environments as $environment => $config ) { - $generators[ $environment ] = new ConnectionUrlGenerator( - $config['partner_referrals'], - $container->get( 'api.repository.partner-referrals-data' ), - $environment, - $container->get( 'settings.service.onboarding-url-manager' ), - $container->get( 'woocommerce.logger.woocommerce' ) - ); - } - - return $generators; }, - 'settings.switch-ui.endpoint' => static function ( ContainerInterface $container ) : SwitchSettingsUiEndpoint { + 'settings.service.authentication_manager' => static function ( ContainerInterface $container ) : AuthenticationManager { + return new AuthenticationManager( + $container->get( 'settings.data.common' ), + $container->get( 'api.env.paypal-host' ), + $container->get( 'api.env.endpoint.login-seller' ), + $container->get( 'api.repository.partner-referrals-data' ), + $container->get( 'woocommerce.logger.woocommerce' ), + ); + }, + 'settings.ajax.switch_ui' => static function ( ContainerInterface $container ) : SwitchSettingsUiEndpoint { return new SwitchSettingsUiEndpoint( $container->get( 'woocommerce.logger.woocommerce' ), $container->get( 'button.request-data' ), diff --git a/modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php b/modules/ppcp-settings/src/Ajax/SwitchSettingsUiEndpoint.php similarity index 94% rename from modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php rename to modules/ppcp-settings/src/Ajax/SwitchSettingsUiEndpoint.php index 244c26dfe..04a6cc86e 100644 --- a/modules/ppcp-settings/src/Endpoint/SwitchSettingsUiEndpoint.php +++ b/modules/ppcp-settings/src/Ajax/SwitchSettingsUiEndpoint.php @@ -1,13 +1,13 @@ is_sandbox = $is_sandbox; + $this->client_id = $client_id; + $this->client_secret = $client_secret; + $this->merchant_id = $merchant_id; + $this->merchant_email = $merchant_email; + } +} diff --git a/modules/ppcp-settings/src/Data/AbstractDataModel.php b/modules/ppcp-settings/src/Data/AbstractDataModel.php index 780ad40bd..070015af2 100644 --- a/modules/ppcp-settings/src/Data/AbstractDataModel.php +++ b/modules/ppcp-settings/src/Data/AbstractDataModel.php @@ -122,5 +122,4 @@ abstract class AbstractDataModel { return $stripped_key ? "set_$stripped_key" : ''; } - } diff --git a/modules/ppcp-settings/src/Data/CommonSettings.php b/modules/ppcp-settings/src/Data/CommonSettings.php index 1894255ff..8e118ab89 100644 --- a/modules/ppcp-settings/src/Data/CommonSettings.php +++ b/modules/ppcp-settings/src/Data/CommonSettings.php @@ -10,6 +10,7 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings\Data; use RuntimeException; +use WooCommerce\PayPalCommerce\Settings\DTO\MerchantConnectionDTO; /** * Class CommonSettings @@ -41,11 +42,16 @@ class CommonSettings extends AbstractDataModel { * * @param string $country WooCommerce store country. * @param string $currency WooCommerce store currency. + * + * @throws RuntimeException When forgetting to define the OPTION_KEY in this class. */ public function __construct( string $country, string $currency ) { parent::__construct(); + $this->woo_settings['country'] = $country; $this->woo_settings['currency'] = $currency; + + $this->data['merchant_connected'] = $this->is_merchant_connected(); } /** @@ -55,16 +61,16 @@ class CommonSettings extends AbstractDataModel { */ protected function get_defaults() : array { return array( - 'use_sandbox' => false, - 'use_manual_connection' => false, - 'client_id' => '', - 'client_secret' => '', + 'use_sandbox' => false, // UI state, not a connection detail. + 'use_manual_connection' => false, // UI state, not a connection detail. // Details about connected merchant account. 'merchant_connected' => false, 'sandbox_merchant' => false, 'merchant_id' => '', 'merchant_email' => '', + 'client_id' => '', + 'client_secret' => '', ); } @@ -106,42 +112,6 @@ class CommonSettings extends AbstractDataModel { $this->data['use_manual_connection'] = $use_manual_connection; } - /** - * Gets the client ID. - * - * @return string - */ - public function get_client_id() : string { - return $this->data['client_id']; - } - - /** - * Sets the client ID. - * - * @param string $client_id The client ID. - */ - public function set_client_id( string $client_id ) : void { - $this->data['client_id'] = sanitize_text_field( $client_id ); - } - - /** - * Gets the client secret. - * - * @return string - */ - public function get_client_secret() : string { - return $this->data['client_secret']; - } - - /** - * Sets the client secret. - * - * @param string $client_secret The client secret. - */ - public function set_client_secret( string $client_secret ) : void { - $this->data['client_secret'] = sanitize_text_field( $client_secret ); - } - /** * Returns the list of read-only customization flags. * @@ -154,19 +124,48 @@ class CommonSettings extends AbstractDataModel { /** * Setter to update details of the connected merchant account. * - * Those details cannot be changed individually. - * - * @param bool $is_sandbox Whether the details are for a sandbox account. - * @param string $merchant_id The merchant ID. - * @param string $merchant_email The merchant's email. + * @param MerchantConnectionDTO $connection Connection details. * * @return void */ - public function set_merchant_data( bool $is_sandbox, string $merchant_id, string $merchant_email ) : void { - $this->data['sandbox_merchant'] = $is_sandbox; - $this->data['merchant_id'] = sanitize_text_field( $merchant_id ); - $this->data['merchant_email'] = sanitize_email( $merchant_email ); - $this->data['merchant_connected'] = true; + public function set_merchant_data( MerchantConnectionDTO $connection ) : void { + $this->data['sandbox_merchant'] = $connection->is_sandbox; + $this->data['merchant_id'] = sanitize_text_field( $connection->merchant_id ); + $this->data['merchant_email'] = sanitize_email( $connection->merchant_email ); + $this->data['client_id'] = sanitize_text_field( $connection->client_id ); + $this->data['client_secret'] = sanitize_text_field( $connection->client_secret ); + $this->data['merchant_connected'] = $this->is_merchant_connected(); + } + + /** + * Returns the full merchant connection DTO for the current connection. + * + * @return MerchantConnectionDTO All connection details. + */ + public function get_merchant_data() : MerchantConnectionDTO { + return new MerchantConnectionDTO( + $this->is_sandbox_merchant(), + $this->data['client_id'], + $this->data['client_secret'], + $this->data['merchant_id'], + $this->data['merchant_email'] + ); + } + + /** + * Reset all connection details to the initial, disconnected state. + * + * @return void + */ + public function reset_merchant_data() : void { + $defaults = $this->get_defaults(); + + $this->data['sandbox_merchant'] = $defaults['sandbox_merchant']; + $this->data['merchant_id'] = $defaults['merchant_id']; + $this->data['merchant_email'] = $defaults['merchant_email']; + $this->data['client_id'] = $defaults['client_id']; + $this->data['client_secret'] = $defaults['client_secret']; + $this->data['merchant_connected'] = false; } /** @@ -184,7 +183,10 @@ class CommonSettings extends AbstractDataModel { * @return bool */ public function is_merchant_connected() : bool { - return $this->data['merchant_connected'] && $this->data['merchant_id'] && $this->data['merchant_email']; + return $this->data['merchant_email'] + && $this->data['merchant_id'] + && $this->data['client_id'] + && $this->data['client_secret']; } /** diff --git a/modules/ppcp-settings/src/Endpoint/AuthenticationRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/AuthenticationRestEndpoint.php new file mode 100644 index 000000000..9d72ff88c --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/AuthenticationRestEndpoint.php @@ -0,0 +1,179 @@ + array( + 'js_name' => 'merchantId', + ), + 'merchant_email' => array( + 'js_name' => 'email', + ), + ); + + /** + * Constructor. + * + * @param AuthenticationManager $authentication_manager The authentication manager. + */ + public function __construct( AuthenticationManager $authentication_manager ) { + $this->authentication_manager = $authentication_manager; + } + + /** + * Configure REST API routes. + */ + public function register_routes() : void { + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/direct', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'connect_direct' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'clientId' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'minLength' => 80, + 'maxLength' => 80, + ), + 'clientSecret' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'useSandbox' => array( + 'required' => false, + 'type' => 'boolean', + 'default' => false, + 'sanitize_callback' => array( $this, 'to_boolean' ), + ), + ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/isu', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'connect_isu' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'sharedId' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'authCode' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'useSandbox' => array( + 'default' => 0, + 'type' => 'boolean', + 'sanitize_callback' => array( $this, 'to_boolean' ), + ), + ), + ) + ); + } + + /** + * Direct login: Retrieves merchantId and email using clientId and clientSecret. + * + * This is the "Manual Login" logic, when a merchant already knows their + * API credentials. + * + * @param WP_REST_Request $request Full data about the request. + */ + public function connect_direct( WP_REST_Request $request ) : WP_REST_Response { + $client_id = $request->get_param( 'clientId' ); + $client_secret = $request->get_param( 'clientSecret' ); + $use_sandbox = $request->get_param( 'useSandbox' ); + + try { + $this->authentication_manager->validate_id_and_secret( $client_id, $client_secret ); + $this->authentication_manager->authenticate_via_direct_api( $use_sandbox, $client_id, $client_secret ); + } catch ( Exception $exception ) { + return $this->return_error( $exception->getMessage() ); + } + + $account = $this->authentication_manager->get_account_details(); + $response = $this->sanitize_for_javascript( $this->response_map, $account ); + + return $this->return_success( $response ); + } + + /** + * ISU login: Retrieves clientId and clientSecret using a sharedId and authCode. + * + * This is the final step in the UI-driven login via the ISU popup, which + * is triggered by the LoginLinkRestEndpoint URL. + * + * @param WP_REST_Request $request Full data about the request. + */ + public function connect_isu( WP_REST_Request $request ) : WP_REST_Response { + $shared_id = $request->get_param( 'sharedId' ); + $auth_code = $request->get_param( 'authCode' ); + $use_sandbox = $request->get_param( 'useSandbox' ); + + try { + $this->authentication_manager->validate_id_and_auth_code( $shared_id, $auth_code ); + $this->authentication_manager->authenticate_via_oauth( $use_sandbox, $shared_id, $auth_code ); + } catch ( Exception $exception ) { + return $this->return_error( $exception->getMessage() ); + } + + $account = $this->authentication_manager->get_account_details(); + $response = $this->sanitize_for_javascript( $this->response_map, $account ); + + return $this->return_success( $response ); + } +} diff --git a/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php index c3b5d043e..761743817 100644 --- a/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php @@ -50,14 +50,6 @@ class CommonRestEndpoint extends RestEndpoint { 'js_name' => 'useManualConnection', 'sanitize' => 'to_boolean', ), - 'client_id' => array( - 'js_name' => 'clientId', - 'sanitize' => 'sanitize_text_field', - ), - 'client_secret' => array( - 'js_name' => 'clientSecret', - 'sanitize' => 'sanitize_text_field', - ), 'webhooks' => array( 'js_name' => 'webhooks', ), @@ -81,6 +73,12 @@ class CommonRestEndpoint extends RestEndpoint { 'merchant_email' => array( 'js_name' => 'email', ), + 'client_id' => array( + 'js_name' => 'clientId', + ), + 'client_secret' => array( + 'js_name' => 'clientSecret', + ), ); /** @@ -114,11 +112,9 @@ class CommonRestEndpoint extends RestEndpoint { $this->namespace, '/' . $this->rest_base, array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_details' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); @@ -126,11 +122,9 @@ class CommonRestEndpoint extends RestEndpoint { $this->namespace, '/' . $this->rest_base, array( - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'update_details' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); @@ -138,11 +132,9 @@ class CommonRestEndpoint extends RestEndpoint { $this->namespace, "/$this->rest_base/merchant", array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_merchant_details' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_merchant_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); } @@ -243,10 +235,12 @@ class CommonRestEndpoint extends RestEndpoint { $this->merchant_info_map ); - $extra_data['merchant'] = apply_filters( - 'woocommerce_paypal_payments_rest_common_merchant_data', - $extra_data['merchant'], - ); + if ( $this->settings->is_merchant_connected() ) { + $extra_data['features'] = apply_filters( + 'woocommerce_paypal_payments_rest_common_merchant_data', + array(), + ); + } // If no real data is available yet, use mock data. if ( empty( $extra_data['merchant'] ) || ( empty( $extra_data['merchant']['id'] ) && empty( $extra_data['merchant']['email'] ) ) ) { diff --git a/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php deleted file mode 100644 index 7046342a2..000000000 --- a/modules/ppcp-settings/src/Endpoint/ConnectManualRestEndpoint.php +++ /dev/null @@ -1,238 +0,0 @@ - array( - 'js_name' => 'clientId', - 'sanitize' => 'sanitize_text_field', - ), - 'client_secret' => array( - 'js_name' => 'clientSecret', - 'sanitize' => 'sanitize_text_field', - ), - 'use_sandbox' => array( - 'js_name' => 'useSandbox', - 'sanitize' => 'to_boolean', - ), - ); - - /** - * ConnectManualRestEndpoint constructor. - * - * @param string $live_host The API host for the live mode. - * @param string $sandbox_host The API host for the sandbox mode. - * @param LoggerInterface $logger The logger. - * @param GeneralSettings $settings Settings instance. - */ - public function __construct( - string $live_host, - string $sandbox_host, - LoggerInterface $logger, - GeneralSettings $settings - ) { - $this->live_host = $live_host; - $this->sandbox_host = $sandbox_host; - $this->logger = $logger; - $this->settings = $settings; - } - - /** - * Configure REST API routes. - */ - public function register_routes() { - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'connect_manual' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), - ) - ); - } - - /** - * Retrieves merchantId and email. - * - * @param WP_REST_Request $request Full data about the request. - */ - public function connect_manual( WP_REST_Request $request ) : WP_REST_Response { - $data = $this->sanitize_for_wordpress( - $request->get_params(), - $this->field_map - ); - - $client_id = $data['client_id'] ?? ''; - $client_secret = $data['client_secret'] ?? ''; - $use_sandbox = (bool) ( $data['use_sandbox'] ?? false ); - - if ( empty( $client_id ) || empty( $client_secret ) ) { - return $this->return_error( 'No client ID or secret provided.' ); - } - - try { - $payee = $this->request_payee( $client_id, $client_secret, $use_sandbox ); - } catch ( Exception $exception ) { - return $this->return_error( $exception->getMessage() ); - } - - if ( $use_sandbox ) { - $this->settings->set_is_sandbox( true ); - $this->settings->set_sandbox_client_id( $client_id ); - $this->settings->set_sandbox_client_secret( $client_secret ); - $this->settings->set_sandbox_merchant_id( $payee->merchant_id ); - $this->settings->set_sandbox_merchant_email( $payee->email_address ); - } else { - $this->settings->set_is_sandbox( false ); - $this->settings->set_live_client_id( $client_id ); - $this->settings->set_live_client_secret( $client_secret ); - $this->settings->set_live_merchant_id( $payee->merchant_id ); - $this->settings->set_live_merchant_email( $payee->email_address ); - } - $this->settings->save(); - - return $this->return_success( - array( - 'merchantId' => $payee->merchant_id, - 'email' => $payee->email_address, - ) - ); - } - - /** - * Retrieves the payee object with the merchant data - * by creating a minimal PayPal order. - * - * @throws Exception When failed to retrieve payee. - * - * phpcs:disable Squiz.Commenting - * phpcs:disable Generic.Commenting - * - * @param string $client_secret The client secret. - * @param bool $use_sandbox Whether to use the sandbox mode. - * @param string $client_id The client ID. - * - * @return stdClass The payee object. - */ - private function request_payee( - string $client_id, - string $client_secret, - bool $use_sandbox - ) : stdClass { - - $host = $use_sandbox ? $this->sandbox_host : $this->live_host; - - $bearer = new PayPalBearer( - new InMemoryCache(), - $host, - $client_id, - $client_secret, - $this->logger, - null - ); - - $orders = new Orders( - $host, - $bearer, - $this->logger - ); - - $request_body = array( - 'intent' => 'CAPTURE', - 'purchase_units' => array( - array( - 'amount' => array( - 'currency_code' => 'USD', - 'value' => 1.0, - ), - ), - ), - ); - - $response = $orders->create( $request_body ); - $body = json_decode( $response['body'] ); - - $order_id = $body->id; - - $order_response = $orders->order( $order_id ); - $order_body = json_decode( $order_response['body'] ); - - $pu = $order_body->purchase_units[0]; - $payee = $pu->payee; - if ( ! is_object( $payee ) ) { - throw new RuntimeException( 'Payee not found.' ); - } - if ( ! isset( $payee->merchant_id ) || ! isset( $payee->email_address ) ) { - throw new RuntimeException( 'Payee info not found.' ); - } - - return $payee; - } -} diff --git a/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php index 8ed204383..7ddee27a5 100644 --- a/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php @@ -15,7 +15,14 @@ use WP_REST_Request; use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator; /** - * REST controller that generates merchant login URLs. + * REST controller that generates merchant login URLs for PayPal. + * + * This endpoint is responsible solely for generating a URL that initiates + * the PayPal login flow. It does not handle the authentication itself. + * + * The generated URL is typically used to redirect merchants to PayPal's login page. + * After successful login, the authentication process is completed via the + * AuthenticationRestEndpoint. */ class LoginLinkRestEndpoint extends RestEndpoint { /** @@ -26,48 +33,47 @@ class LoginLinkRestEndpoint extends RestEndpoint { protected $rest_base = 'login_link'; /** - * Link generator list, with environment name as array key. + * Login-URL generator. * - * @var ConnectionUrlGenerator[] + * @var ConnectionUrlGenerator */ - protected array $url_generators; + protected ConnectionUrlGenerator $url_generator; /** * Constructor. * - * @param ConnectionUrlGenerator[] $url_generators Array of environment-specific URL generators. + * @param ConnectionUrlGenerator $url_generator Login-URL generator. */ - public function __construct( array $url_generators ) { - $this->url_generators = $url_generators; + public function __construct( ConnectionUrlGenerator $url_generator ) { + $this->url_generator = $url_generator; } /** * Configure REST API routes. */ - public function register_routes() { + public function register_routes() : void { register_rest_route( $this->namespace, '/' . $this->rest_base, array( - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'get_login_url' ), - 'permission_callback' => array( $this, 'check_permission' ), - 'args' => array( - 'environment' => array( - 'required' => true, - 'type' => 'string', - ), - 'products' => array( - 'required' => true, - 'type' => 'array', - 'items' => array( - 'type' => 'string', - ), - 'sanitize_callback' => function ( $products ) { - return array_map( 'sanitize_text_field', $products ); - }, + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'get_login_url' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'useSandbox' => array( + 'default' => 0, + 'type' => 'boolean', + 'sanitize_callback' => array( $this, 'to_boolean' ), + ), + 'products' => array( + 'required' => true, + 'type' => 'array', + 'items' => array( + 'type' => 'string', ), + 'sanitize_callback' => function ( $products ) { + return array_map( 'sanitize_text_field', $products ); + }, ), ), ) @@ -82,20 +88,11 @@ class LoginLinkRestEndpoint extends RestEndpoint { * @return WP_REST_Response The login URL or an error response. */ public function get_login_url( WP_REST_Request $request ) : WP_REST_Response { - $environment = $request->get_param( 'environment' ); + $use_sandbox = $request->get_param( 'useSandbox' ); $products = $request->get_param( 'products' ); - if ( ! isset( $this->url_generators[ $environment ] ) ) { - return new WP_REST_Response( - array( 'error' => 'Invalid environment specified.' ), - 400 - ); - } - - $url_generator = $this->url_generators[ $environment ]; - try { - $url = $url_generator->generate( $products ); + $url = $this->url_generator->generate( $products, $use_sandbox ); return $this->return_success( $url ); } catch ( \Exception $e ) { diff --git a/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php index d4273228f..018ab2dc2 100644 --- a/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/OnboardingRestEndpoint.php @@ -101,11 +101,9 @@ class OnboardingRestEndpoint extends RestEndpoint { $this->namespace, '/' . $this->rest_base, array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_details' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); @@ -113,11 +111,9 @@ class OnboardingRestEndpoint extends RestEndpoint { $this->namespace, '/' . $this->rest_base, array( - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'update_details' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_details' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); } diff --git a/modules/ppcp-settings/src/Endpoint/RefreshFeatureStatusEndpoint.php b/modules/ppcp-settings/src/Endpoint/RefreshFeatureStatusEndpoint.php index d8fc2760e..dfbfc3a3a 100644 --- a/modules/ppcp-settings/src/Endpoint/RefreshFeatureStatusEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/RefreshFeatureStatusEndpoint.php @@ -5,7 +5,7 @@ * @package WooCommerce\PayPalCommerce\Settings\Endpoint */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings\Endpoint; @@ -87,11 +87,9 @@ class RefreshFeatureStatusEndpoint extends RestEndpoint { $this->namespace, '/' . $this->rest_base, array( - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'refresh_status' ), - 'permission_callback' => array( $this, 'check_permission' ), - ), + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'refresh_status' ), + 'permission_callback' => array( $this, 'check_permission' ), ) ); } @@ -102,7 +100,7 @@ class RefreshFeatureStatusEndpoint extends RestEndpoint { * @param WP_REST_Request $request Full data about the request. * @return WP_REST_Response */ - public function refresh_status( WP_REST_Request $request ): WP_REST_Response { + public function refresh_status( WP_REST_Request $request ) : WP_REST_Response { $now = time(); $last_request_time = $this->cache->get( self::CACHE_KEY ) ?: 0; $seconds_missing = $last_request_time + self::TIMEOUT - $now; diff --git a/modules/ppcp-settings/src/Endpoint/RestEndpoint.php b/modules/ppcp-settings/src/Endpoint/RestEndpoint.php index 76626ac0c..6f1eb0e4f 100644 --- a/modules/ppcp-settings/src/Endpoint/RestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/RestEndpoint.php @@ -81,7 +81,7 @@ abstract class RestEndpoint extends WC_REST_Controller { } /** - * Sanitizes parameters based on a field mapping. + * Sanitizes and renames input parameters, based on a field mapping. * * This method iterates through a field map, applying sanitization methods * to the corresponding values in the input parameters array. @@ -122,7 +122,7 @@ abstract class RestEndpoint extends WC_REST_Controller { } /** - * Sanitizes data for JavaScript based on a field mapping. + * Sanitizes and renames data for JavaScript, based on a field mapping. * * This method transforms the input data array according to the provided field map, * renaming keys to their JavaScript equivalents as specified in the mapping. @@ -151,24 +151,28 @@ abstract class RestEndpoint extends WC_REST_Controller { } /** - * Convert a value to a boolean. + * Sanitation callback: Convert a value to a boolean. * - * @param mixed $value The value to convert. + * @param mixed $value The value to sanitize. * * @return bool|null The boolean value, or null if not set. */ - protected function to_boolean( $value ) : ?bool { + public function to_boolean( $value ) : ?bool { return $value !== null ? (bool) $value : null; } /** - * Convert a value to a number. + * Sanitation callback: Convert a value to a number. * - * @param mixed $value The value to convert. + * @param mixed $value The value to sanitize. * * @return int|float|null The numeric value, or null if not set. */ - protected function to_number( $value ) { - return $value !== null ? ( is_numeric( $value ) ? $value + 0 : null ) : null; + public function to_number( $value ) { + if ( $value !== null ) { + $value = is_numeric( $value ) ? $value + 0 : null; + } + + return $value; } } diff --git a/modules/ppcp-settings/src/Endpoint/WebhookSettingsEndpoint.php b/modules/ppcp-settings/src/Endpoint/WebhookSettingsEndpoint.php index c3116a1ed..69b346734 100644 --- a/modules/ppcp-settings/src/Endpoint/WebhookSettingsEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/WebhookSettingsEndpoint.php @@ -118,7 +118,7 @@ class WebhookSettingsEndpoint extends RestEndpoint { try { $webhook_list = ( $this->webhook_endpoint->list() )[0]; $webhook_events = array_map( - function ( stdClass $webhook ) { + static function ( stdClass $webhook ) { return strtolower( $webhook->name ); }, $webhook_list->event_types() diff --git a/modules/ppcp-settings/src/Handler/ConnectionListener.php b/modules/ppcp-settings/src/Handler/ConnectionListener.php index a24a82231..7b30b51a1 100644 --- a/modules/ppcp-settings/src/Handler/ConnectionListener.php +++ b/modules/ppcp-settings/src/Handler/ConnectionListener.php @@ -10,10 +10,11 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings\Handler; -use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings; +use Psr\Log\LoggerInterface; +use RuntimeException; +use WooCommerce\PayPalCommerce\Settings\Service\AuthenticationManager; use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager; use WooCommerce\WooCommerce\Logging\Logger\NullLogger; -use Psr\Log\LoggerInterface; /** * Provides a listener that handles merchant-connection requests. @@ -31,13 +32,6 @@ class ConnectionListener { */ private string $settings_page_id; - /** - * Access to connection settings. - * - * @var CommonSettings - */ - private CommonSettings $settings; - /** * Access to the onboarding URL manager. * @@ -45,6 +39,13 @@ class ConnectionListener { */ private OnboardingUrlManager $url_manager; + /** + * Authentication manager service, responsible to update connection details. + * + * @var AuthenticationManager + */ + private AuthenticationManager $authentication_manager; + /** * Logger instance, mainly used for debugging purposes. * @@ -62,16 +63,21 @@ class ConnectionListener { /** * Prepare the instance. * - * @param string $settings_page_id Current plugin settings page ID. - * @param CommonSettings $settings Access to saved connection details. - * @param OnboardingUrlManager $url_manager Get OnboardingURL instances. - * @param ?LoggerInterface $logger The logger, for debugging purposes. + * @param string $settings_page_id Current plugin settings page ID. + * @param OnboardingUrlManager $url_manager Get OnboardingURL instances. + * @param AuthenticationManager $authentication_manager Authentication manager service. + * @param ?LoggerInterface $logger The logger, for debugging purposes. */ - public function __construct( string $settings_page_id, CommonSettings $settings, OnboardingUrlManager $url_manager, LoggerInterface $logger = null ) { - $this->settings_page_id = $settings_page_id; - $this->settings = $settings; - $this->url_manager = $url_manager; - $this->logger = $logger ?: new NullLogger(); + public function __construct( + string $settings_page_id, + OnboardingUrlManager $url_manager, + AuthenticationManager $authentication_manager, + LoggerInterface $logger = null + ) { + $this->settings_page_id = $settings_page_id; + $this->url_manager = $url_manager; + $this->authentication_manager = $authentication_manager; + $this->logger = $logger ?: new NullLogger(); // Initialize as "guest", the real ID is provided via process(). $this->user_id = 0; @@ -82,6 +88,8 @@ class ConnectionListener { * * @param int $user_id The current user ID. * @param array $request Request details to process. + * + * @throws RuntimeException If the merchant ID does not match the ID previously set via OAuth. */ public function process( int $user_id, array $request ) : void { $this->user_id = $user_id; @@ -100,13 +108,13 @@ class ConnectionListener { return; } - $this->logger->info( 'Found merchant data in request', $data ); + $this->logger->info( 'Found OAuth merchant data in request', $data ); - $this->store_data( - $data['is_sandbox'], - $data['merchant_id'], - $data['merchant_email'] - ); + try { + $this->authentication_manager->finish_oauth_authentication( $data ); + } catch ( \Exception $e ) { + $this->logger->error( 'Failed to complete authentication: ' . $e->getMessage() ); + } } /** @@ -160,26 +168,11 @@ class ConnectionListener { } return array( - 'is_sandbox' => $this->settings->get_sandbox(), 'merchant_id' => $merchant_id, 'merchant_email' => $merchant_email, ); } - /** - * Persist the merchant details to the database. - * - * @param bool $is_sandbox Whether the details are for a sandbox account. - * @param string $merchant_id The anonymized merchant ID. - * @param string $merchant_email The merchant's email. - */ - protected function store_data( bool $is_sandbox, string $merchant_id, string $merchant_email ) : void { - $this->logger->info( "Save merchant details to the DB: $merchant_email ($merchant_id)" ); - - $this->settings->set_merchant_data( $is_sandbox, $merchant_id, $merchant_email ); - $this->settings->save(); - } - /** * Returns the sanitized connection token from the incoming request. * diff --git a/modules/ppcp-settings/src/Service/AuthenticationManager.php b/modules/ppcp-settings/src/Service/AuthenticationManager.php new file mode 100644 index 000000000..4b3f18786 --- /dev/null +++ b/modules/ppcp-settings/src/Service/AuthenticationManager.php @@ -0,0 +1,410 @@ + + */ + private EnvironmentConfig $connection_host; + + /** + * Login API handler instances, by environment. + * + * @var EnvironmentConfig + */ + private EnvironmentConfig $login_endpoint; + + /** + * Onboarding referrals data. + * + * @var PartnerReferralsData + */ + private PartnerReferralsData $referrals_data; + + /** + * Constructor. + * + * @param CommonSettings $common_settings Data model that stores the connection details. + * @param EnvironmentConfig $connection_host API host for direct authentication. + * @param EnvironmentConfig $login_endpoint API handler to fetch merchant credentials. + * @param PartnerReferralsData $referrals_data Partner referrals data. + * @param ?LoggerInterface $logger Logging instance. + */ + public function __construct( + CommonSettings $common_settings, + EnvironmentConfig $connection_host, + EnvironmentConfig $login_endpoint, + PartnerReferralsData $referrals_data, + ?LoggerInterface $logger = null + ) { + $this->common_settings = $common_settings; + $this->connection_host = $connection_host; + $this->login_endpoint = $login_endpoint; + $this->referrals_data = $referrals_data; + $this->logger = $logger ?: new NullLogger(); + } + + /** + * Returns details about the currently connected merchant. + * + * @return array + */ + public function get_account_details() : array { + return array( + 'is_sandbox' => $this->common_settings->is_sandbox_merchant(), + 'is_connected' => $this->common_settings->is_merchant_connected(), + 'merchant_id' => $this->common_settings->get_merchant_id(), + 'merchant_email' => $this->common_settings->get_merchant_email(), + ); + } + + /** + * Removes any connection details we currently have stored. + * + * @return void + */ + public function disconnect() : void { + $this->logger->info( 'Disconnecting merchant from PayPal...' ); + + $this->common_settings->reset_merchant_data(); + $this->common_settings->save(); + + /** + * Broadcast, that the plugin disconnected from PayPal. This allows other + * modules to clean up merchant-related details, such as eligibility flags. + */ + do_action( 'woocommerce_paypal_payments_merchant_disconnected' ); + } + + /** + * Checks if the provided ID and secret have a valid format. + * + * Part of the "Direct Connection" (Manual Connection) flow. + * + * On failure, an Exception is thrown, while a successful check does not + * generate any return value. + * + * @param string $client_id The client ID. + * @param string $client_secret The client secret. + * @return void + * @throws RuntimeException When invalid client ID or secret provided. + */ + public function validate_id_and_secret( string $client_id, string $client_secret ) : void { + if ( empty( $client_id ) ) { + throw new RuntimeException( 'No client ID provided.' ); + } + + if ( false === preg_match( '/^A[\w-]{79}$/', $client_secret ) ) { + throw new RuntimeException( 'Invalid client ID provided.' ); + } + + if ( empty( $client_secret ) ) { + throw new RuntimeException( 'No client secret provided.' ); + } + } + + /** + * Disconnects the current merchant, and then attempts to connect to a + * PayPal account using a client ID and secret. + * + * Part of the "Direct Connection" (Manual Connection) flow. + * + * @param bool $use_sandbox Whether to use the sandbox mode. + * @param string $client_id The client ID. + * @param string $client_secret The client secret. + * @return void + * @throws RuntimeException When failed to retrieve payee. + */ + public function authenticate_via_direct_api( bool $use_sandbox, string $client_id, string $client_secret ) : void { + $this->disconnect(); + + $this->logger->info( + 'Attempting manual connection to PayPal...', + array( + 'sandbox' => $use_sandbox, + 'client_id' => $client_id, + ) + ); + + $payee = $this->request_payee( $client_id, $client_secret, $use_sandbox ); + + $connection = new MerchantConnectionDTO( + $use_sandbox, + $client_id, + $client_secret, + $payee['merchant_id'], + $payee['email_address'] + ); + + $this->update_connection_details( $connection ); + } + + + /** + * Checks if the provided ID and auth-code have a valid format. + * + * Part of the "ISU Connection" (login via Popup) flow. + * + * On failure, an Exception is thrown, while a successful check does not + * generate any return value. Note, that we did not find official documentation + * on those values, so we only check if they are non-empty strings. + * + * @param string $shared_id The shared onboarding ID. + * @param string $auth_code The authorization code. + * @return void + * @throws RuntimeException When invalid shared ID or auth provided. + */ + public function validate_id_and_auth_code( string $shared_id, string $auth_code ) : void { + if ( empty( $shared_id ) ) { + throw new RuntimeException( 'No onboarding ID provided.' ); + } + + if ( empty( $auth_code ) ) { + throw new RuntimeException( 'No authorization code provided.' ); + } + } + + /** + * Disconnects the current merchant, and then attempts to connect to a + * PayPal account the onboarding ID and authorization ID. + * + * Part of the "ISU Connection" (login via Popup) flow. + * + * @param bool $use_sandbox Whether to use the sandbox mode. + * @param string $shared_id The OAuth client ID. + * @param string $auth_code The OAuth authorization code. + * @return void + * @throws RuntimeException When failed to retrieve payee. + */ + public function authenticate_via_oauth( bool $use_sandbox, string $shared_id, string $auth_code ) : void { + $this->disconnect(); + + $this->logger->info( + 'Attempting OAuth login to PayPal...', + array( + 'sandbox' => $use_sandbox, + 'shared_id' => $shared_id, + ) + ); + + $credentials = $this->get_credentials( $shared_id, $auth_code, $use_sandbox ); + + /** + * The merchant's email is set by `ConnectionListener`. That listener + * is invoked during the page reload, once the user clicks the blue + * "Return to Store" button in PayPal's login popup. + */ + $connection = $this->common_settings->get_merchant_data(); + + $connection->is_sandbox = $use_sandbox; + $connection->client_id = $credentials['client_id']; + $connection->client_secret = $credentials['client_secret']; + $connection->merchant_id = $credentials['merchant_id']; + + $this->update_connection_details( $connection ); + } + + /** + * Verifies the merchant details in the final OAuth redirect and extracts + * missing credentials from the URL. + * + * @param array $request_data Array of request parameters to process. + * @return void + * + * @throws RuntimeException Missing or invalid credentials. + */ + public function finish_oauth_authentication( array $request_data ) : void { + $merchant_id = $request_data['merchant_id']; + $merchant_email = $request_data['merchant_email']; + + if ( empty( $merchant_id ) || empty( $merchant_email ) ) { + throw new RuntimeException( 'Missing merchant ID or email in request' ); + } + + $connection = $this->common_settings->get_merchant_data(); + + if ( $connection->merchant_id && $connection->merchant_id !== $merchant_id ) { + throw new RuntimeException( 'Unexpected merchant ID in request' ); + } + + $connection->merchant_id = $merchant_id; + $connection->merchant_email = $merchant_email; + + $this->update_connection_details( $connection ); + } + + + // ---------------------------------------------------------------------------- + // Internal helper methods + + + /** + * Retrieves the payee object with the merchant data by creating a minimal PayPal order. + * + * Part of the "Direct Connection" (Manual Connection) flow. + * + * @param string $client_id The client ID. + * @param string $client_secret The client secret. + * @param bool $use_sandbox Whether to use the sandbox mode. + * + * @return array Payee details, containing 'merchant_id' and 'merchant_email' keys. + * @throws RuntimeException When failed to retrieve payee. + */ + private function request_payee( + string $client_id, + string $client_secret, + bool $use_sandbox + ) : array { + $host = $this->connection_host->get_value( $use_sandbox ); + + $bearer = new PayPalBearer( + new InMemoryCache(), + $host, + $client_id, + $client_secret, + $this->logger, + null + ); + + $orders = new Orders( + $host, + $bearer, + $this->logger + ); + + $request_body = array( + 'intent' => 'CAPTURE', + 'purchase_units' => array( + array( + 'amount' => array( + 'currency_code' => 'USD', + 'value' => 1.0, + ), + ), + ), + ); + + try { + $response = $orders->create( $request_body ); + $body = json_decode( $response['body'], false, 512, JSON_THROW_ON_ERROR ); + $order_id = $body->id; + + $order_response = $orders->order( $order_id ); + $order_body = json_decode( $order_response['body'], false, 512, JSON_THROW_ON_ERROR ); + } catch ( JsonException $exception ) { + // Cast JsonException to a RuntimeException. + throw new RuntimeException( 'Could not decode JSON response: ' . $exception->getMessage() ); + } catch ( Throwable $exception ) { + // Cast any other Throwable to a RuntimeException. + throw new RuntimeException( $exception->getMessage() ); + } + + $pu = $order_body->purchase_units[0]; + $payee = $pu->payee; + + if ( ! is_object( $payee ) ) { + throw new RuntimeException( 'Payee not found.' ); + } + if ( ! isset( $payee->merchant_id, $payee->email_address ) ) { + throw new RuntimeException( 'Payee info not found.' ); + } + + return array( + 'merchant_id' => $payee->merchant_id, + 'email_address' => $payee->email_address, + ); + } + + /** + * Fetches merchant API credentials using a shared onboarding ID and + * authorization code. + * + * Part of the "ISU Connection" (login via Popup) flow. + * + * @param string $shared_id The shared onboarding ID. + * @param string $auth_code The authorization code. + * @param bool $use_sandbox Whether to use the sandbox mode. + * @return array + * @throws RuntimeException When failed to fetch credentials. + */ + private function get_credentials( string $shared_id, string $auth_code, bool $use_sandbox ) : array { + $login_handler = $this->login_endpoint->get_value( $use_sandbox ); + $nonce = $this->referrals_data->nonce(); + + $response = $login_handler->credentials_for( $shared_id, $auth_code, $nonce ); + + return array( + 'client_id' => (string) ( $response->client_id ?? '' ), + 'client_secret' => (string) ( $response->client_secret ?? '' ), + 'merchant_id' => (string) ( $response->payer_id ?? '' ), + ); + } + + /** + * Stores the provided details in the data model. + * + * @param MerchantConnectionDTO $connection Connection details to persist. + * @return void + */ + private function update_connection_details( MerchantConnectionDTO $connection ) : void { + $this->logger->info( + 'Updating connection details', + (array) $connection + ); + + $this->common_settings->set_merchant_data( $connection ); + $this->common_settings->save(); + + if ( $this->common_settings->is_merchant_connected() ) { + $this->logger->info( 'Merchant successfully connected to PayPal' ); + + /** + * Broadcast that the plugin connected to a new PayPal merchant account. + * This is the right time to initialize merchant relative flags for the + * first time. + */ + do_action( 'woocommerce_paypal_payments_authenticated_merchant' ); + } + } +} diff --git a/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php index 028740cb9..62ee92dff 100644 --- a/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php +++ b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php @@ -12,9 +12,9 @@ namespace WooCommerce\PayPalCommerce\Settings\Service; use Exception; use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals; -use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData; use WooCommerce\WooCommerce\Logging\Logger\NullLogger; +use WooCommerce\PayPalCommerce\WcGateway\Helper\EnvironmentConfig; // TODO: Replace the OnboardingUrl with a new implementation for this module. use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl; @@ -26,9 +26,9 @@ class ConnectionUrlGenerator { /** * The partner referrals endpoint. * - * @var PartnerReferrals + * @var EnvironmentConfig */ - protected PartnerReferrals $partner_referrals; + protected EnvironmentConfig $partner_referrals; /** * The default partner referrals data. @@ -44,13 +44,6 @@ class ConnectionUrlGenerator { */ protected OnboardingUrlManager $url_manager; - /** - * Which environment is used for the connection URL. - * - * @var string - */ - protected string $environment = ''; - /** * The logger * @@ -63,36 +56,23 @@ class ConnectionUrlGenerator { * * Initializes the cache and logger properties of the class. * - * @param PartnerReferrals $partner_referrals PartnerReferrals for URL generation. + * @param EnvironmentConfig $partner_referrals PartnerReferrals for URL generation. * @param PartnerReferralsData $referrals_data Default partner referrals data. - * @param string $environment Environment that is used to generate the URL. - * ['production'|'sandbox']. * @param OnboardingUrlManager $url_manager Manages access to OnboardingUrl instances. * @param ?LoggerInterface $logger The logger object for logging messages. */ public function __construct( - PartnerReferrals $partner_referrals, + EnvironmentConfig $partner_referrals, PartnerReferralsData $referrals_data, - string $environment, OnboardingUrlManager $url_manager, ?LoggerInterface $logger = null ) { $this->partner_referrals = $partner_referrals; $this->referrals_data = $referrals_data; - $this->environment = $environment; $this->url_manager = $url_manager; $this->logger = $logger ?: new NullLogger(); } - /** - * Returns the environment for which the URL is being generated. - * - * @return string - */ - public function environment() : string { - return $this->environment; - } - /** * Generates a PayPal onboarding URL for merchant sign-up. * @@ -100,13 +80,14 @@ class ConnectionUrlGenerator { * It handles caching of the URL, generation of new URLs when necessary, * and works for both production and sandbox environments. * - * @param array $products An array of product identifiers to include in the sign-up process. - * These determine the PayPal onboarding experience. + * @param array $products An array of product identifiers to include in the sign-up process. + * These determine the PayPal onboarding experience. + * @param bool $use_sandbox Whether to generate a sandbox URL. * * @return string The generated PayPal onboarding URL. */ - public function generate( array $products = array() ) : string { - $cache_key = $this->cache_key( $products ); + public function generate( array $products = array(), bool $use_sandbox = false ) : string { + $cache_key = $this->cache_key( $products, $use_sandbox ); $user_id = get_current_user_id(); $onboarding_url = $this->url_manager->get( $cache_key, $user_id ); $cached_url = $this->try_get_from_cache( $onboarding_url, $cache_key ); @@ -119,7 +100,7 @@ class ConnectionUrlGenerator { $this->logger->info( 'Generating onboarding URL for: ' . $cache_key ); - $url = $this->generate_new_url( $products, $onboarding_url, $cache_key ); + $url = $this->generate_new_url( $use_sandbox, $products, $onboarding_url, $cache_key ); if ( $url ) { $this->persist_url( $onboarding_url, $url ); @@ -131,15 +112,18 @@ class ConnectionUrlGenerator { /** * Generates a cache key from the environment and sorted product array. * - * @param array $products Product identifiers that are part of the cache key. + * @param array $products Product identifiers that are part of the cache key. + * @param bool $for_sandbox Whether the cache contains a sandbox URL. * * @return string The cache key, defining the product list and environment. */ - protected function cache_key( array $products = array() ) : string { + protected function cache_key( array $products, bool $for_sandbox ) : string { + $environment = $for_sandbox ? 'sandbox' : 'production'; + // Sort products alphabetically, to improve cache implementation. sort( $products ); - return $this->environment() . '-' . implode( '-', $products ); + return $environment . '-' . implode( '-', $products ); } /** @@ -168,13 +152,14 @@ class ConnectionUrlGenerator { /** * Generates a new URL. * + * @param bool $for_sandbox Whether to generate a sandbox URL. * @param array $products The products array. * @param OnboardingUrl $onboarding_url The OnboardingUrl object. * @param string $cache_key The cache key. * * @return string The generated URL or an empty string on failure. */ - protected function generate_new_url( array $products, OnboardingUrl $onboarding_url, string $cache_key ) : string { + protected function generate_new_url( bool $for_sandbox, array $products, OnboardingUrl $onboarding_url, string $cache_key ) : string { $query_args = array( 'displayMode' => 'minibrowser' ); $onboarding_url->init(); @@ -189,7 +174,8 @@ class ConnectionUrlGenerator { $data = $this->prepare_referral_data( $products, $onboarding_token ); try { - $url = $this->partner_referrals->signup_link( $data ); + $referral = $this->partner_referrals->get_value( $for_sandbox ); + $url = $referral->signup_link( $data ); } catch ( Exception $e ) { $this->logger->warning( 'Could not generate an onboarding URL for: ' . $cache_key ); diff --git a/modules/ppcp-settings/src/SettingsModule.php b/modules/ppcp-settings/src/SettingsModule.php index b97db27ec..59f752545 100644 --- a/modules/ppcp-settings/src/SettingsModule.php +++ b/modules/ppcp-settings/src/SettingsModule.php @@ -9,8 +9,9 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings; +use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint; +use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile; use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint; -use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; @@ -86,7 +87,7 @@ class SettingsModule implements ServiceModule, ExecutableModule { } ); - $endpoint = $container->get( 'settings.switch-ui.endpoint' ) ? $container->get( 'settings.switch-ui.endpoint' ) : null; + $endpoint = $container->get( 'settings.ajax.switch_ui' ) ? $container->get( 'settings.ajax.switch_ui' ) : null; assert( $endpoint instanceof SwitchSettingsUiEndpoint ); add_action( @@ -203,6 +204,29 @@ class SettingsModule implements ServiceModule, ExecutableModule { } ); + add_action( + 'woocommerce_paypal_payments_merchant_disconnected', + static function () use ( $container ) : void { + $onboarding_profile = $container->get( 'settings.data.onboarding' ); + assert( $onboarding_profile instanceof OnboardingProfile ); + + $onboarding_profile->set_completed( false ); + $onboarding_profile->set_step( 0 ); + $onboarding_profile->save(); + } + ); + + add_action( + 'woocommerce_paypal_payments_authenticated_merchant', + static function () use ( $container ) : void { + $onboarding_profile = $container->get( 'settings.data.onboarding' ); + assert( $onboarding_profile instanceof OnboardingProfile ); + + $onboarding_profile->set_completed( true ); + $onboarding_profile->save(); + } + ); + return true; } diff --git a/modules/ppcp-settings/yarn.lock b/modules/ppcp-settings/yarn.lock index 6b623d53a..62a5e4ac9 100644 --- a/modules/ppcp-settings/yarn.lock +++ b/modules/ppcp-settings/yarn.lock @@ -2919,6 +2919,15 @@ sprintf-js "^1.1.1" tannin "^1.2.0" +"@wordpress/icons@^10.14.0": + version "10.14.0" + resolved "https://registry.yarnpkg.com/@wordpress/icons/-/icons-10.14.0.tgz#a27298b438653a9a502eb4ee3b02b42ce516da2e" + integrity sha512-4S1AaBeqvTpsTC23y0+4WPiSyz7j+b7vJ4vQ4nqnPeBF7ZeC8J/UXWQnEuKY38n8TiutXljgagkEqGNC9pF2Mw== + dependencies: + "@babel/runtime" "7.25.7" + "@wordpress/element" "*" + "@wordpress/primitives" "*" + "@wordpress/is-shallow-equal@*": version "5.11.0" resolved "https://registry.yarnpkg.com/@wordpress/is-shallow-equal/-/is-shallow-equal-5.11.0.tgz#2f273d6d4de24a66a7a8316b770cf832d22bfc37" @@ -2968,6 +2977,15 @@ resolved "https://registry.yarnpkg.com/@wordpress/prettier-config/-/prettier-config-4.11.0.tgz#6b3f9aa7e2698c0d78e644037c6778b5c1da12ce" integrity sha512-Aoc8+xWOyiXekodjaEjS44z85XK877LzHZqsQuhC0kNgneDLrKkwI5qNgzwzAMbJ9jI58MPqVISCOX0bDLUPbw== +"@wordpress/primitives@*": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@wordpress/primitives/-/primitives-4.14.0.tgz#1769f45bc541fd48be2d57626a9f6bdece39942a" + integrity sha512-IZibRVbvWoIQ+uynH0N5bmfWz83hD8lJj6jJFhSFuALK+4U5mRGg6tl0ZV0YllR6cjheD9UhTmfrAcOx+gQAjA== + dependencies: + "@babel/runtime" "7.25.7" + "@wordpress/element" "*" + clsx "^2.1.1" + "@wordpress/priority-queue@*": version "3.11.0" resolved "https://registry.yarnpkg.com/@wordpress/priority-queue/-/priority-queue-3.11.0.tgz#01e1570a7a29372bb1d07cd22fd9cbc5b5d03b09" @@ -3976,6 +3994,11 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" diff --git a/modules/ppcp-uninstall/services.php b/modules/ppcp-uninstall/services.php index 51ff935af..629f1164b 100644 --- a/modules/ppcp-uninstall/services.php +++ b/modules/ppcp-uninstall/services.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Uninstall; use WooCommerce\PayPalCommerce\ApiClient\Repository\PayPalRequestIdRepository; -use WooCommerce\PayPalCommerce\Settings\Endpoint\SwitchSettingsUiEndpoint; +use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint; use WooCommerce\PayPalCommerce\Uninstall\Assets\ClearDatabaseAssets; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway; diff --git a/modules/ppcp-wc-gateway/src/Helper/EnvironmentConfig.php b/modules/ppcp-wc-gateway/src/Helper/EnvironmentConfig.php new file mode 100644 index 000000000..1542de783 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Helper/EnvironmentConfig.php @@ -0,0 +1,79 @@ +production_value = $production_value; + $this->sandbox_value = $sandbox_value; + } + + /** + * Factory method to create a validated EnvironmentConfig. + * + * @template U + * @param string $data_type Expected type for the values (class name or primitive type). + * @param U $production_value Value for production environment. + * @param U $sandbox_value Value for the sandbox environment. + * @return self + */ + public static function create( string $data_type, $production_value, $sandbox_value ) : self { + assert( + gettype( $production_value ) === $data_type || $production_value instanceof $data_type, + "Production value must be of type '$data_type'" + ); + assert( + gettype( $sandbox_value ) === $data_type || $sandbox_value instanceof $data_type, + "Sandbox value must be of type '$data_type'" + ); + + return new self( $production_value, $sandbox_value ); + } + + /** + * Get the value for the specified environment. + * + * @param bool $for_sandbox Whether to get the sandbox value. + * @return T The value for the specified environment. + */ + public function get_value( bool $for_sandbox = false ) { + return $for_sandbox ? $this->sandbox_value : $this->production_value; + } +} diff --git a/modules/ppcp-wc-gateway/src/WCGatewayModule.php b/modules/ppcp-wc-gateway/src/WCGatewayModule.php index f754a3cbe..e4e13a91d 100644 --- a/modules/ppcp-wc-gateway/src/WCGatewayModule.php +++ b/modules/ppcp-wc-gateway/src/WCGatewayModule.php @@ -550,16 +550,12 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul add_filter( 'woocommerce_paypal_payments_rest_common_merchant_data', - function( array $merchant_data ) use ( $c ): array { - if ( ! isset( $merchant_data['features'] ) ) { - $merchant_data['features'] = array(); - } - + function( array $features ) use ( $c ): array { $billing_agreements_endpoint = $c->get( 'api.endpoint.billing-agreements' ); assert( $billing_agreements_endpoint instanceof BillingAgreementsEndpoint ); $reference_transactions_enabled = $billing_agreements_endpoint->reference_transaction_enabled(); - $merchant_data['features']['save_paypal_and_venmo'] = array( + $features['save_paypal_and_venmo'] = array( 'enabled' => $reference_transactions_enabled, ); @@ -567,11 +563,11 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul assert( $dcc_product_status instanceof DCCProductStatus ); $dcc_enabled = $dcc_product_status->dcc_is_active(); - $merchant_data['features']['advanced_credit_and_debit_cards'] = array( + $features['advanced_credit_and_debit_cards'] = array( 'enabled' => $dcc_enabled, ); - return $merchant_data; + return $features; } );