diff --git a/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php b/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php index af8bae174..01a91f9b6 100644 --- a/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php +++ b/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php @@ -5,7 +5,7 @@ * @package WooCommerce\PayPalCommerce\ApiClient\Repository */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\ApiClient\Repository; @@ -18,43 +18,21 @@ class PartnerReferralsData { /** * The DCC Applies Helper object. * + * @deprecated Deprecates with the new UI. In this class, the products are + * always explicit, and should not be deducted from the + * DccApplies state at this point. + * Remove this with the legacy UI code. * @var DccApplies */ - private $dcc_applies; - - /** - * The list of products ('PPCP', 'EXPRESS_CHECKOUT'). - * - * @var string[] - */ - private $products; + private DccApplies $dcc_applies; /** * PartnerReferralsData constructor. * * @param DccApplies $dcc_applies The DCC Applies helper. */ - public function __construct( - DccApplies $dcc_applies - ) { + public function __construct( DccApplies $dcc_applies ) { $this->dcc_applies = $dcc_applies; - $this->products = array( - $this->dcc_applies->for_country_currency() ? 'PPCP' : 'EXPRESS_CHECKOUT', - ); - } - - /** - * Returns a new copy of this object with the given value set. - * - * @param string[] $products The list of products ('PPCP', 'EXPRESS_CHECKOUT'). - * @return static - */ - public function with_products( array $products ): self { - $obj = clone $this; - - $obj->products = $products; - - return $obj; } /** @@ -62,82 +40,119 @@ class PartnerReferralsData { * * @return string */ - public function nonce(): string { + public function nonce() : string { return 'a1233wtergfsdt4365tzrshgfbaewa36AGa1233wtergfsdt4365tzrshgfbaewa36AG'; } /** * Returns the data. * + * @param string[] $products The list of products to use ('PPCP', 'EXPRESS_CHECKOUT'). + * Default is based on DCC availability. + * @param string $onboarding_token A security token to finalize the onboarding process. + * @param bool $use_subscriptions If the merchant requires subscription features. + * @param bool $use_card_payments If the merchant wants to process credit card payments. * @return array */ - public function data(): array { + public function data( array $products = array(), string $onboarding_token = '', bool $use_subscriptions = null, bool $use_card_payments = true ) : array { + if ( ! $products ) { + $products = array( + $this->dcc_applies->for_country_currency() ? 'PPCP' : 'EXPRESS_CHECKOUT', + ); + } + /** - * Returns the partners referrals data. + * Filter the return-URL, which is called at the end of the OAuth onboarding + * process, when the merchant clicks the "Return to your shop" button. */ - return apply_filters( - 'ppcp_partner_referrals_data', - array( - 'partner_config_override' => array( - /** - * Returns the URL which will be opened at the end of onboarding. - */ - 'return_url' => apply_filters( - 'woocommerce_paypal_payments_partner_config_override_return_url', - admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=ppcp-gateway' ) - ), - /** - * Returns the description of the URL which will be opened at the end of onboarding. - */ - 'return_url_description' => apply_filters( - 'woocommerce_paypal_payments_partner_config_override_return_url_description', - __( 'Return to your shop.', 'woocommerce-paypal-payments' ) - ), - 'show_add_credit_card' => true, + $return_url = apply_filters( + 'woocommerce_paypal_payments_partner_config_override_return_url', + admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=ppcp-gateway' ) + ); + + /** + * Filter the label of the "Return to your shop" button. + * It's displayed on the very last page of the onboarding popup. + */ + $return_url_label = apply_filters( + 'woocommerce_paypal_payments_partner_config_override_return_url_description', + __( 'Return to your shop.', 'woocommerce-paypal-payments' ) + ); + + $capabilities = array(); + $first_party_features = array( + 'PAYMENT', + 'REFUND', + 'ADVANCED_TRANSACTIONS_SEARCH', + 'TRACKING_SHIPMENT_READWRITE', + ); + + if ( true === $use_subscriptions ) { + $capabilities[] = 'PAYPAL_WALLET_VAULTING_ADVANCED'; + } + + // Backwards compatibility. Keep those features in the legacy UI (null-value). + // Move this into the previous condition, once legacy code is removed. + if ( false !== $use_subscriptions ) { + $first_party_features[] = 'FUTURE_PAYMENT'; + $first_party_features[] = 'VAULT'; + } + + if ( false === $use_subscriptions ) { + // Only use "ADVANCED_VAULTING" product for onboarding with subscriptions. + $products = array_filter( + $products, + static fn( $product ) => $product !== 'ADVANCED_VAULTING' + ); + } + + $payload = array( + 'partner_config_override' => array( + 'return_url' => $return_url, + 'return_url_description' => $return_url_label, + 'show_add_credit_card' => $use_card_payments, + ), + 'products' => $products, + 'capabilities' => $capabilities, + 'legal_consents' => array( + array( + 'type' => 'SHARE_DATA_CONSENT', + 'granted' => true, ), - 'products' => $this->products, - 'legal_consents' => array( - array( - 'type' => 'SHARE_DATA_CONSENT', - 'granted' => true, - ), - ), - 'operations' => array( - array( - 'operation' => 'API_INTEGRATION', - 'api_integration_preference' => array( - 'rest_api_integration' => array( - 'integration_method' => 'PAYPAL', - 'integration_type' => 'FIRST_PARTY', - 'first_party_details' => array( - 'features' => array( - 'PAYMENT', - 'FUTURE_PAYMENT', - 'REFUND', - 'ADVANCED_TRANSACTIONS_SEARCH', - 'VAULT', - 'TRACKING_SHIPMENT_READWRITE', - ), - 'seller_nonce' => $this->nonce(), - ), + ), + 'operations' => array( + array( + 'operation' => 'API_INTEGRATION', + 'api_integration_preference' => array( + 'rest_api_integration' => array( + 'integration_method' => 'PAYPAL', + 'integration_type' => 'FIRST_PARTY', + 'first_party_details' => array( + 'features' => $first_party_features, + 'seller_nonce' => $this->nonce(), ), ), ), ), - ) + ), ); - } - /** - * Append the validation token to the return_url - * - * @param array $data The referral data. - * @param string $token The token to be appended. - * @return array - */ - public function append_onboarding_token( array $data, string $token ): array { - $data['partner_config_override']['return_url'] = - add_query_arg( 'ppcpToken', $token, $data['partner_config_override']['return_url'] ); - return $data; + /** + * Filter the final partners referrals data collection. + */ + $payload = apply_filters( 'ppcp_partner_referrals_data', $payload ); + + // An empty array is not permitted. + if ( isset( $payload['capabilities'] ) && ! $payload['capabilities'] ) { + unset( $payload['capabilities'] ); + } + + // Add the nonce in the end, to maintain backwards compatibility of filters. + $payload['partner_config_override']['return_url'] = add_query_arg( + array( 'ppcpToken' => $onboarding_token ), + $payload['partner_config_override']['return_url'] + ); + + return $payload; } } diff --git a/modules/ppcp-onboarding/src/Render/OnboardingRenderer.php b/modules/ppcp-onboarding/src/Render/OnboardingRenderer.php index 081f1599b..13189fd77 100644 --- a/modules/ppcp-onboarding/src/Render/OnboardingRenderer.php +++ b/modules/ppcp-onboarding/src/Render/OnboardingRenderer.php @@ -103,12 +103,8 @@ class OnboardingRenderer { 'displayMode' => 'minibrowser', ); - $data = $this->partner_referrals_data - ->with_products( $products ) - ->data(); - $environment = $is_production ? 'production' : 'sandbox'; - $product = 'PPCP' === $data['products'][0] ? 'ppcp' : 'express_checkout'; + $product = strtolower( $products[0] ?? 'express_checkout' ); $cache_key = $environment . '-' . $product; $onboarding_url = new OnboardingUrl( $this->cache, $cache_key, get_current_user_id() ); @@ -122,8 +118,7 @@ class OnboardingRenderer { $onboarding_url->init(); - $data = $this->partner_referrals_data - ->append_onboarding_token( $data, $onboarding_url->token() ?: '' ); + $data = $this->partner_referrals_data->data( $products, $onboarding_url->token() ?: '' ); $url = $is_production ? $this->production_partner_referrals->signup_link( $data ) : $this->sandbox_partner_referrals->signup_link( $data ); $url = add_query_arg( $args, $url ); diff --git a/modules/ppcp-settings/resources/js/data/common/actions-thunk.js b/modules/ppcp-settings/resources/js/data/common/actions-thunk.js index d49aa914a..f3f394bc0 100644 --- a/modules/ppcp-settings/resources/js/data/common/actions-thunk.js +++ b/modules/ppcp-settings/resources/js/data/common/actions-thunk.js @@ -68,10 +68,11 @@ export function sandboxOnboardingUrl() { /** * Side effect. Fetches the ISU-login URL for a production account. * - * @param {string[]} products Which products/features to display in the ISU popup. + * @param {string[]} [products=[]] Which products/features to display in the ISU popup. + * @param {Object} [options={}] Options to customize the onboarding workflow. * @return {Function} The thunk function. */ -export function productionOnboardingUrl( products = [] ) { +export function productionOnboardingUrl( products = [], options = {} ) { return async () => { try { return apiFetch( { @@ -80,6 +81,7 @@ export function productionOnboardingUrl( products = [] ) { data: { useSandbox: false, products, + options, }, } ); } catch ( e ) { diff --git a/modules/ppcp-settings/resources/js/data/common/hooks.js b/modules/ppcp-settings/resources/js/data/common/hooks.js index 6fe0a90f5..cb6dc9974 100644 --- a/modules/ppcp-settings/resources/js/data/common/hooks.js +++ b/modules/ppcp-settings/resources/js/data/common/hooks.js @@ -41,8 +41,6 @@ const useHooks = () => { const { useTransient, usePersistent, dispatch, select } = useStoreData(); const { persist, - sandboxOnboardingUrl, - productionOnboardingUrl, authenticateWithCredentials, authenticateWithOAuth, startWebhookSimulation, @@ -53,7 +51,6 @@ const useHooks = () => { const [ activeModal, setActiveModal ] = useTransient( 'activeModal' ); // Persistent accessors. - const [ isSandboxMode, setSandboxMode ] = usePersistent( 'useSandbox' ); const [ isManualConnectionMode, setManualConnectionMode ] = usePersistent( 'useManualConnection' ); @@ -71,16 +68,10 @@ const useHooks = () => { return { activeModal, setActiveModal, - isSandboxMode, - setSandboxMode: ( state ) => { - return savePersistent( setSandboxMode, state ); - }, isManualConnectionMode, setManualConnectionMode: ( state ) => { return savePersistent( setManualConnectionMode, state ); }, - sandboxOnboardingUrl, - productionOnboardingUrl, authenticateWithCredentials, authenticateWithOAuth, wooSettings, @@ -105,13 +96,23 @@ export const useStore = () => { }; export const useSandbox = () => { - const { isSandboxMode, setSandboxMode, sandboxOnboardingUrl } = useHooks(); + const { dispatch, usePersistent } = useStoreData(); + const [ isSandboxMode, setSandboxMode ] = usePersistent( 'useSandbox' ); + const { sandboxOnboardingUrl, persist } = dispatch; - return { isSandboxMode, setSandboxMode, sandboxOnboardingUrl }; + return { + isSandboxMode, + setSandboxMode: ( state ) => { + setSandboxMode( state ); + return persist(); + }, + sandboxOnboardingUrl, + }; }; export const useProduction = () => { - const { productionOnboardingUrl } = useHooks(); + const { dispatch } = useStoreData(); + const { productionOnboardingUrl } = dispatch; return { productionOnboardingUrl }; }; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/configuration.js b/modules/ppcp-settings/resources/js/data/onboarding/configuration.js index 4b31689b5..280fee3af 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/configuration.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/configuration.js @@ -24,3 +24,9 @@ export const PRODUCT_TYPES = { PHYSICAL: 'physical', SUBSCRIPTIONS: 'subscriptions', }; + +export const PAYPAL_PRODUCTS = { + ACDC: 'PPCP', + BCDC: 'EXPRESS_CHECKOUT', + VAULTING: 'ADVANCED_VAULTING', +}; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js index 7c9ba1ee9..4d7549f5a 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/hooks.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/hooks.js @@ -19,10 +19,6 @@ const useHooks = () => { // Read-only flags and derived state. const flags = useSelect( ( select ) => select( STORE_NAME ).flags(), [] ); - const determineProducts = useSelect( - ( select ) => select( STORE_NAME ).determineProducts(), - [] - ); // Transient accessors. const [ isReady ] = useTransient( 'isReady' ); @@ -80,7 +76,6 @@ const useHooks = () => { ); return savePersistent( setProducts, validProducts ); }, - determineProducts, }; }; @@ -141,9 +136,9 @@ export const useNavigationState = () => { }; export const useDetermineProducts = () => { - const { determineProducts } = useHooks(); - - return determineProducts; + return useSelect( ( select ) => { + return select( STORE_NAME ).determineProductsAndCaps(); + }, [] ); }; export const useFlags = () => { diff --git a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js index 9f3a7f35d..62579ac09 100644 --- a/modules/ppcp-settings/resources/js/data/onboarding/selectors.js +++ b/modules/ppcp-settings/resources/js/data/onboarding/selectors.js @@ -7,6 +7,8 @@ * @file */ +import { PAYPAL_PRODUCTS, PRODUCT_TYPES } from './configuration'; + const EMPTY_OBJ = Object.freeze( {} ); const getState = ( state ) => state || EMPTY_OBJ; @@ -25,44 +27,71 @@ export const flags = ( state ) => { }; /** - * Returns the products that we use for the production login link in the last onboarding step. + * Returns details about products and capabilities to use for the production login link in + * the last onboarding step. * * This selector does not return state-values, but uses the state to derive the products-array * that should be returned. * * @param {{}} state - * @return {string[]} The ISU products, based on choices made in the onboarding wizard. + * @return {{products:string[], options:{}}} The ISU products, based on choices made in the onboarding wizard. */ -export const determineProducts = ( state ) => { - const derivedProducts = []; +export const determineProductsAndCaps = ( state ) => { + /** + * An array of product-names that are used to build an onboarding URL via the + * PartnerReferrals API. To avoid confusion with the "products" property from the + * Redux store, this collection has a distinct name. + * + * On server-side, this value is referred to as "products" again. + */ + const apiModules = []; - const { isCasualSeller, areOptionalPaymentMethodsEnabled } = + /** + * Internal options that are parsed by the PartnerReferrals class to customize + * the API payload. + */ + const options = { + useSubscriptions: false, + useCardPayments: false, + }; + + const { isCasualSeller, areOptionalPaymentMethodsEnabled, products } = persistentData( state ); const { canUseVaulting, canUseCardPayments } = flags( state ); + const cardPaymentsEligibleAndSelected = + canUseCardPayments && areOptionalPaymentMethodsEnabled; - if ( ! canUseCardPayments || ! areOptionalPaymentMethodsEnabled ) { + if ( ! cardPaymentsEligibleAndSelected ) { /** * Branch 1: Credit Card Payments not available. * The store uses the Express-checkout product. */ - derivedProducts.push( 'EXPRESS_CHECKOUT' ); + apiModules.push( PAYPAL_PRODUCTS.BCDC ); } else if ( isCasualSeller ) { /** * Branch 2: Merchant has no business. * The store uses the Express-checkout product. */ - derivedProducts.push( 'EXPRESS_CHECKOUT' ); + apiModules.push( PAYPAL_PRODUCTS.BCDC ); } else { /** * Branch 3: Merchant is business, and can use CC payments. * The store uses the advanced PPCP product. + * + * This is the only branch that can use subscriptions. */ - derivedProducts.push( 'PPCP' ); + apiModules.push( PAYPAL_PRODUCTS.ACDC ); + + if ( products?.includes( PRODUCT_TYPES.SUBSCRIPTIONS ) ) { + options.useSubscriptions = true; + } + + if ( canUseVaulting ) { + apiModules.push( PAYPAL_PRODUCTS.VAULTING ); + } } - if ( canUseVaulting ) { - derivedProducts.push( 'ADVANCED_VAULTING' ); - } + options.useCardPayments = cardPaymentsEligibleAndSelected; - return derivedProducts; + return { products: apiModules, options }; }; diff --git a/modules/ppcp-settings/resources/js/data/onboarding/selectors.test.js b/modules/ppcp-settings/resources/js/data/onboarding/selectors.test.js new file mode 100644 index 000000000..77febe32d --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/onboarding/selectors.test.js @@ -0,0 +1,181 @@ +import '@testing-library/jest-dom'; + +import { PRODUCT_TYPES } from './configuration'; +import { determineProductsAndCaps } from './selectors'; + +describe( 'determineProductsAndCaps selector [casual seller]', () => { + const testCases = [ + { + name: 'should return EXPRESS_CHECKOUT when card payments are not available', + state: { + data: { + isCasualSeller: true, + areOptionalPaymentMethodsEnabled: true, + }, + flags: { canUseCardPayments: false, canUseVaulting: false }, + }, + expected: { + products: [ 'EXPRESS_CHECKOUT' ], + options: { useSubscriptions: false, useCardPayments: false }, + }, + }, + { + name: 'should return EXPRESS_CHECKOUT when optional payment methods are disabled', + state: { + data: { + isCasualSeller: true, + areOptionalPaymentMethodsEnabled: false, + }, + flags: { canUseCardPayments: true, canUseVaulting: false }, + }, + expected: { + products: [ 'EXPRESS_CHECKOUT' ], + options: { useSubscriptions: false, useCardPayments: false }, + }, + }, + { + name: 'should return EXPRESS_CHECKOUT for casual sellers with card payments', + state: { + data: { + isCasualSeller: true, + areOptionalPaymentMethodsEnabled: true, + }, + flags: { canUseCardPayments: true, canUseVaulting: false }, + }, + expected: { + products: [ 'EXPRESS_CHECKOUT' ], + options: { useSubscriptions: false, useCardPayments: true }, + }, + }, + { + name: 'should return EXPRESS_CHECKOUT and ADVANCED_VAULTING when card payments are not available but vaulting is', + state: { + data: { + isCasualSeller: true, + areOptionalPaymentMethodsEnabled: true, + }, + flags: { canUseCardPayments: false, canUseVaulting: true }, + }, + expected: { + products: [ 'EXPRESS_CHECKOUT' ], + options: { useSubscriptions: false, useCardPayments: false }, + }, + }, + { + name: 'should ignore SUBSCRIPTION product for casual sellers', + state: { + data: { + isCasualSeller: true, + areOptionalPaymentMethodsEnabled: true, + products: [ PRODUCT_TYPES.SUBSCRIPTIONS ], + }, + flags: { canUseCardPayments: false, canUseVaulting: true }, + }, + expected: { + products: [ 'EXPRESS_CHECKOUT' ], + options: { useSubscriptions: false, useCardPayments: false }, + }, + }, + ]; + + test.each( testCases )( '$name', ( { state, expected } ) => { + const result = determineProductsAndCaps( state ); + expect( result ).toEqual( expected ); + } ); +} ); + +describe( 'determineProductsAndCaps selector [business seller]', () => { + const testCases = [ + { + name: 'should return EXPRESS_CHECKOUT when card payments are not available', + state: { + data: { + isCasualSeller: false, + areOptionalPaymentMethodsEnabled: true, + }, + flags: { canUseCardPayments: false, canUseVaulting: false }, + }, + expected: { + products: [ 'EXPRESS_CHECKOUT' ], + options: { useSubscriptions: false, useCardPayments: false }, + }, + }, + { + name: 'should return EXPRESS_CHECKOUT when optional payment methods are disabled', + state: { + data: { + isCasualSeller: false, + areOptionalPaymentMethodsEnabled: false, + }, + flags: { canUseCardPayments: true, canUseVaulting: false }, + }, + expected: { + products: [ 'EXPRESS_CHECKOUT' ], + options: { useSubscriptions: false, useCardPayments: false }, + }, + }, + { + name: 'should return PPCP for business merchants with card payments', + state: { + data: { + isCasualSeller: false, + areOptionalPaymentMethodsEnabled: true, + }, + flags: { canUseCardPayments: true, canUseVaulting: false }, + }, + expected: { + products: [ 'PPCP' ], + options: { useSubscriptions: false, useCardPayments: true }, + }, + }, + { + name: 'should include ADVANCED_VAULTING when vaulting is available', + state: { + data: { + isCasualSeller: false, + areOptionalPaymentMethodsEnabled: true, + }, + flags: { canUseCardPayments: true, canUseVaulting: true }, + }, + expected: { + products: [ 'PPCP', 'ADVANCED_VAULTING' ], + options: { useSubscriptions: false, useCardPayments: true }, + }, + }, + { + name: 'should return EXPRESS_CHECKOUT and ADVANCED_VAULTING when card payments are not available but vaulting is', + state: { + data: { + isCasualSeller: false, + areOptionalPaymentMethodsEnabled: true, + products: [ PRODUCT_TYPES.VIRTUAL ], + }, + flags: { canUseCardPayments: false, canUseVaulting: true }, + }, + expected: { + products: [ 'EXPRESS_CHECKOUT' ], + options: { useSubscriptions: false, useCardPayments: false }, + }, + }, + { + name: 'should enable the SUBSCRIPTIONS option when a business seller selects the subscriptions-product', + state: { + data: { + isCasualSeller: false, + areOptionalPaymentMethodsEnabled: true, + products: [ PRODUCT_TYPES.SUBSCRIPTIONS ], + }, + flags: { canUseCardPayments: true, canUseVaulting: true }, + }, + expected: { + products: [ 'PPCP', 'ADVANCED_VAULTING' ], + options: { useSubscriptions: true, useCardPayments: true }, + }, + }, + ]; + + test.each( testCases )( '$name', ( { state, expected } ) => { + const result = determineProductsAndCaps( state ); + expect( result ).toEqual( expected ); + } ); +} ); diff --git a/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js b/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js index e98345149..f74d6b8fe 100644 --- a/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js +++ b/modules/ppcp-settings/resources/js/hooks/useHandleConnections.js @@ -30,7 +30,7 @@ const ACTIVITIES = { export const useHandleOnboardingButton = ( isSandbox ) => { const { sandboxOnboardingUrl } = CommonHooks.useSandbox(); const { productionOnboardingUrl } = CommonHooks.useProduction(); - const products = OnboardingHooks.useDetermineProducts(); + const { products, options } = OnboardingHooks.useDetermineProducts(); const { startActivity } = CommonHooks.useBusyState(); const { authenticateWithOAuth } = CommonHooks.useAuthentication(); const [ onboardingUrl, setOnboardingUrl ] = useState( '' ); @@ -43,7 +43,7 @@ export const useHandleOnboardingButton = ( isSandbox ) => { if ( isSandbox ) { res = await sandboxOnboardingUrl(); } else { - res = await productionOnboardingUrl( products ); + res = await productionOnboardingUrl( products, options ); } if ( res.success && res.data ) { diff --git a/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php b/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php index b9972349f..3d6c45843 100644 --- a/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/LoginLinkRestEndpoint.php @@ -82,6 +82,16 @@ class LoginLinkRestEndpoint extends RestEndpoint { return array_map( 'sanitize_text_field', $products ); }, ), + 'options' => array( + 'requires' => false, + 'type' => 'array', + 'items' => array( + 'type' => 'bool', + ), + 'sanitize_callback' => function ( $flags ) { + return array_map( array( $this, 'to_boolean' ), $flags ); + }, + ), ), ) ); @@ -97,9 +107,10 @@ class LoginLinkRestEndpoint extends RestEndpoint { public function get_login_url( WP_REST_Request $request ) : WP_REST_Response { $use_sandbox = $request->get_param( 'useSandbox' ); $products = $request->get_param( 'products' ); + $flags = (array) $request->get_param( 'options' ); try { - $url = $this->url_generator->generate( $products, $use_sandbox ); + $url = $this->url_generator->generate( $products, $flags, $use_sandbox ); return $this->return_success( $url ); } catch ( \Exception $e ) { diff --git a/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php index 279821bee..c3204ccf1 100644 --- a/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php +++ b/modules/ppcp-settings/src/Service/ConnectionUrlGenerator.php @@ -82,12 +82,13 @@ class ConnectionUrlGenerator { * * @param array $products An array of product identifiers to include in the sign-up process. * These determine the PayPal onboarding experience. + * @param array $flags Onboarding choices that will customize the ISU payload. * @param bool $use_sandbox Whether to generate a sandbox URL. * * @return string The generated PayPal onboarding URL. */ - public function generate( array $products = array(), bool $use_sandbox = false ) : string { - $cache_key = $this->cache_key( $products, $use_sandbox ); + public function generate( array $products = array(), array $flags = array(), bool $use_sandbox = false ) : string { + $cache_key = $this->cache_key( $products, $flags, $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 ); @@ -100,7 +101,7 @@ class ConnectionUrlGenerator { $this->logger->info( 'Generating onboarding URL for: ' . $cache_key ); - $url = $this->generate_new_url( $use_sandbox, $products, $onboarding_url, $cache_key ); + $url = $this->generate_new_url( $use_sandbox, $products, $flags, $onboarding_url, $cache_key ); if ( $url ) { $this->persist_url( $onboarding_url, $url ); @@ -112,18 +113,28 @@ class ConnectionUrlGenerator { /** * Generates a cache key from the environment and sorted product array. * + * Q: Why do we cache the connection URL? + * A: The URL is generated by a partner-referrals API, i.e. it requires a + * remote request; caching the response avoids unnecessary API calls. + * * @param array $products Product identifiers that are part of the cache key. + * @param array $flags Onboarding flags. * @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, bool $for_sandbox ) : string { + protected function cache_key( array $products, array $flags, bool $for_sandbox ) : string { $environment = $for_sandbox ? 'sandbox' : 'production'; // Sort products alphabetically, to improve cache implementation. sort( $products ); - return $environment . '-' . implode( '-', $products ); + // Extract the names of active flags. + $active_flags = array_keys( array_filter( $flags ) ); + + return strtolower( + $environment . '-' . implode( '-', $products ) . '-' . implode( '-', $active_flags ) + ); } /** @@ -162,12 +173,13 @@ class ConnectionUrlGenerator { * * @param bool $for_sandbox Whether to generate a sandbox URL. * @param array $products The products array. + * @param array $flags Onboarding flags. * @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( bool $for_sandbox, array $products, OnboardingUrl $onboarding_url, string $cache_key ) : string { + protected function generate_new_url( bool $for_sandbox, array $products, array $flags, OnboardingUrl $onboarding_url, string $cache_key ) : string { $query_args = array( 'displayMode' => 'minibrowser' ); $onboarding_url->init(); @@ -179,7 +191,7 @@ class ConnectionUrlGenerator { return ''; } - $data = $this->prepare_referral_data( $products, $onboarding_token ); + $data = $this->prepare_referral_data( $products, $flags, $onboarding_token ); try { $referral = $this->partner_referrals->get_value( $for_sandbox ); @@ -197,16 +209,18 @@ class ConnectionUrlGenerator { * Prepares the referral data. * * @param array $products The products array. + * @param array $flags Onboarding flags. * @param string $onboarding_token The onboarding token. * * @return array The prepared referral data. */ - protected function prepare_referral_data( array $products, string $onboarding_token ) : array { - $data = $this->referrals_data - ->with_products( $products ) - ->data(); - - return $this->referrals_data->append_onboarding_token( $data, $onboarding_token ); + protected function prepare_referral_data( array $products, array $flags, string $onboarding_token ) : array { + return $this->referrals_data->data( + $products, + $onboarding_token, + (bool) ( $flags['useSubscriptions'] ?? false ), + (bool) ( $flags['useCardPayments'] ?? false ) + ); } /** diff --git a/tests/PHPUnit/ApiClient/Repository/PartnerReferralsDataTest.php b/tests/PHPUnit/ApiClient/Repository/PartnerReferralsDataTest.php new file mode 100644 index 000000000..11bd0a138 --- /dev/null +++ b/tests/PHPUnit/ApiClient/Repository/PartnerReferralsDataTest.php @@ -0,0 +1,217 @@ +data()` method to ensure it's appended at the end of the + * return URL as-is. + */ + private const TOKEN = 'SECURE_TOKEN'; + + /** + * Expected return URL to see at in the payload, including the ppcpToken. + */ + private const RETURN_URL = 'https://example.com/wp-admin/admin.php?page=wc-settings&tab=checkout§ion=ppcp-gateway&ppcpToken=SECURE_TOKEN'; + + private $testee; + private $dccApplies; + + public function setUp() : void { + parent::setUp(); + + $this->dccApplies = Mockery::mock( DccApplies::class ); + $this->testee = new PartnerReferralsData( $this->dccApplies ); + + when( 'admin_url' )->alias( static fn( string $path ) => self::ADMIN_URL . $path ); + when( 'add_query_arg' )->justReturn( self::RETURN_URL ); + } + + /** + * Base structure of the API payload. Each test should modify the returned + * value of the method to meet its expectations. + * + * This avoids repeating the full structure, while also highlighting the + * specific changes that different params will generate. + * + * @return array + */ + private function getBaseExpectedArray() : array { + return [ + 'partner_config_override' => [ + 'return_url' => self::RETURN_URL, + 'return_url_description' => 'Return to your shop.', + 'show_add_credit_card' => true, + ], + 'legal_consents' => [ + [ + 'type' => 'SHARE_DATA_CONSENT', + 'granted' => true, + ], + ], + 'operations' => [ + [ + 'operation' => 'API_INTEGRATION', + 'api_integration_preference' => [ + 'rest_api_integration' => [ + 'integration_method' => 'PAYPAL', + 'integration_type' => 'FIRST_PARTY', + 'first_party_details' => [ + 'features' => [ + 'PAYMENT', + 'REFUND', + 'ADVANCED_TRANSACTIONS_SEARCH', + 'TRACKING_SHIPMENT_READWRITE', + ], + 'seller_nonce' => self::DEFAULT_NONCE, + ], + ], + ], + ], + ], + ]; + } + + /** + * Data provider for testing flag combinations. + * + * @return array[] Test cases with [has_subscriptions, has_cards, expected_changes] + */ + public function flagCombinationsProvider() : array { + return [ + 'with subscriptions and cards' => [ + true, // With subscription? + true, // With cards? + [ + 'capabilities' => [ 'PAYPAL_WALLET_VAULTING_ADVANCED' ], + 'show_add_credit_card' => true, + 'has_vault_features' => true, + ], + ], + 'with subscriptions, no cards' => [ + true, // With subscription? + false, // With cards? + [ + 'capabilities' => [ 'PAYPAL_WALLET_VAULTING_ADVANCED' ], + 'show_add_credit_card' => false, + 'has_vault_features' => true, + ], + ], + 'no subscriptions, with cards' => [ + false, // With subscription? + true, // With cards? + [ + 'show_add_credit_card' => true, + 'has_vault_features' => false, + ], + ], + 'no subscriptions, no cards' => [ + false, // With subscription? + false, // With cards? + [ + 'show_add_credit_card' => false, + 'has_vault_features' => false, + ], + ], + ]; + } + + /** + * Ensure the default "products" are derived from the DccApplies response. + */ + public function testDefaultValues() : void { + /** + * Case 1: The data() method gets no parameters, and the DccApplies check + * returns TRUE. Onboarding payload should indicate "PPCP". + */ + $this->dccApplies->expects( 'for_country_currency' )->andReturn( true ); + $result = $this->testee->data(); + $this->assertEquals( [ 'PPCP' ], $result['products'] ); + + /** + * Case 2: The data() method gets no parameters, and the DccApplies check + * returns FALSE. Onboarding payload should indicate "EXPRESS_CHECKOUT". + */ + $this->dccApplies->expects( 'for_country_currency' )->andReturn( false ); + $result = $this->testee->data(); + $this->assertEquals( [ 'EXPRESS_CHECKOUT' ], $result['products'] ); + } + + /** + * Ensure the generated API payload is stable and contains the expected values. + * + * The test only verifies the "products" and "token" arguments, as those are the + * core params present in the legacy and new UI. + */ + public function testDataStructure() : void { + /** + * Undefined subscription: Keep vaulting in first-party, but don't add the capability. + */ + $result = $this->testee->data( [ 'PPCP' ], self::TOKEN ); + $this->dccApplies->shouldNotHaveReceived( 'for_country_currency' ); + + $expected = $this->getBaseExpectedArray(); + + $expected['products'] = [ 'PPCP' ]; + + $expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'][] = 'FUTURE_PAYMENT'; + $expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'][] = 'VAULT'; + + $this->assertArrayNotHasKey( 'capabilities', $expected ); + $this->assertEquals( $expected, $result ); + } + + /** + * Test how different flag combinations affect the data structure. + * Those flags are present in the new UI. + * + * @dataProvider flagCombinationsProvider + */ + public function testDataStructureWithFlags( bool $has_subscriptions, bool $has_cards, array $expected_changes ) : void { + $result = $this->testee->data( [ 'PPCP' ], self::TOKEN, $has_subscriptions, $has_cards ); + $expected = $this->getBaseExpectedArray(); + + $expected['products'] = [ 'PPCP' ]; + + if ( isset( $expected_changes['capabilities'] ) ) { + $expected['capabilities'] = $expected_changes['capabilities']; + } else { + $this->assertArrayNotHasKey( 'capabilities', $expected ); + } + + $expected['partner_config_override']['show_add_credit_card'] = $expected_changes['show_add_credit_card']; + + if ( $expected_changes['has_vault_features'] ) { + $expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'][] = 'FUTURE_PAYMENT'; + $expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'][] = 'VAULT'; + } else { + // Double-check that the features are not present in our expected array + $this->assertNotContains( 'FUTURE_PAYMENT', $expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'] ); + $this->assertNotContains( 'VAULT', $expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'] ); + } + + $this->assertEquals( $expected, $result ); + } +}