diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 041455441..9fc8d4a29 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -27,7 +27,7 @@ web_environment: - ADMIN_USER=admin - ADMIN_PASS=admin - ADMIN_EMAIL=admin@example.com - - WC_VERSION=7.7.2 + - WC_VERSION=9.5.1 # Key features of ddev's config.yaml: diff --git a/changelog.txt b/changelog.txt index b0ffc609c..2e1d3420c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,22 +1,34 @@ *** Changelog *** += 2.9.6 - 2025-01-06 = +* Fix - NOT_ENABLED_TO_VAULT_PAYMENT_SOURCE on PayPal transactions when using ACDC Vaulting without PayPal Vault approval #2955 +* Fix - Express buttons for Free Trial Subscription products on Block Cart/Checkout trigger CANNOT_BE_ZERO_OR_NEGATIVE error #2872 +* Fix - String translations not applied to Card Fields on Block Checkout #2934 +* Fix - Fastlane component included in script when Fastlane is disabled #2911 +* Fix - Zero amount line items may trigger CANNOT_BE_ZERO_OR_NEGATIVE error after rounding error #2906 +* Fix - “Save changes” is grey and unclickable when switching from Sandbox to Live #2895 +* Fix - plugin queries variations when button/messaging is disabled on single product page #2896 +* Fix - Use get_id instead of get_order_number on setting custom_id (author @0verscore) #2930 +* Enhancement - Improve fraud response order notes for Advanced Card Processing transactions #2905 +* Tweak - Update the minimum plugin requirements to WordPress 6.5 & WooCommerce 9.2 #2920 + = 2.9.5 - 2024-12-10 = -Fix - Early translation loading triggers `Function _load_textdomain_just_in_time was called incorrectly.` notice #2816 -Fix - ACDC card fields not loading and payment not successful when Classic Checkout Smart Button Location disabled #2852 -Fix - ACDC gateway does not appear for guests when is Fastlane enabled and a subscription product is in the cart #2745 -Fix - "Voide authorization" button does not appear for Apple Pay/Google Pay orders when payment buttons are separated #2752 -Fix - Additional payment tokens saved with new customer_id #2820 -Fix - Vaulted payment method may not be displayed in PayPal button for return buyer #2809 -Fix - Conflict with EasyShip plugin due to shipping methods loading too early #2845 -Fix - Restore accidentally removed ACDC currencies #2838 -Enhancement - Native gateway icon for PayPal & Pay upon Invoice gateways #2712 -Enhancement - Allow disabling specific card types for Fastlane #2704 -Enhancement - Fastlane Insights SDK implementation for block Checkout #2737 -Enhancement - Hide split local APMs in Payments settings tab when PayPal is not enabled #2703 -Enhancement - Do not load split local APMs on Checkout when PayPal is not enabled #2792 -Enhancement - Add support for Button Options in the Block Checkout for Apple Pay & Google Pay buttons #2797 #2772 -Enhancement - Disable “Add payment method” button while saving ACDC payment #2794 -Enhancement - Sanitize soft_descriptor field #2846 #2854 +* Fix - Early translation loading triggers `Function _load_textdomain_just_in_time was called incorrectly.` notice #2816 +* Fix - ACDC card fields not loading and payment not successful when Classic Checkout Smart Button Location disabled #2852 +* Fix - ACDC gateway does not appear for guests when is Fastlane enabled and a subscription product is in the cart #2745 +* Fix - "Voide authorization" button does not appear for Apple Pay/Google Pay orders when payment buttons are separated #2752 +* Fix - Additional payment tokens saved with new customer_id #2820 +* Fix - Vaulted payment method may not be displayed in PayPal button for return buyer #2809 +* Fix - Conflict with EasyShip plugin due to shipping methods loading too early #2845 +* Fix - Restore accidentally removed ACDC currencies #2838 +* Enhancement - Native gateway icon for PayPal & Pay upon Invoice gateways #2712 +* Enhancement - Allow disabling specific card types for Fastlane #2704 +* Enhancement - Fastlane Insights SDK implementation for block Checkout #2737 +* Enhancement - Hide split local APMs in Payments settings tab when PayPal is not enabled #2703 +* Enhancement - Do not load split local APMs on Checkout when PayPal is not enabled #2792 +* Enhancement - Add support for Button Options in the Block Checkout for Apple Pay & Google Pay buttons #2797 #2772 +* Enhancement - Disable “Add payment method” button while saving ACDC payment #2794 +* Enhancement - Sanitize soft_descriptor field #2846 #2854 = 2.9.4 - 2024-11-11 = * Fix - Apple Pay button preview missing in Standard payment and Advanced Processing tabs #2755 diff --git a/modules/ppcp-api-client/services.php b/modules/ppcp-api-client/services.php index 280438b80..b2f489da5 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 { @@ -115,19 +116,13 @@ return array( return 'WC-'; }, 'api.bearer' => static function ( ContainerInterface $container ): Bearer { - $cache = new Cache( 'ppcp-paypal-bearer' ); - $key = $container->get( 'api.key' ); - $secret = $container->get( 'api.secret' ); - $host = $container->get( 'api.host' ); - $logger = $container->get( 'woocommerce.logger.woocommerce' ); - $settings = $container->get( 'wcgateway.settings' ); return new PayPalBearer( - $cache, - $host, - $key, - $secret, - $logger, - $settings + $container->get( 'api.paypal-bearer-cache' ), + $container->get( 'api.host' ), + $container->get( 'api.key' ), + $container->get( 'api.secret' ), + $container->get( 'woocommerce.logger.woocommerce' ), + $container->get( 'wcgateway.settings' ) ); }, 'api.endpoint.partners' => static function ( ContainerInterface $container ) : PartnersEndpoint { @@ -839,6 +834,9 @@ return array( $container->get( 'wcgateway.settings' ) ); }, + 'api.paypal-bearer-cache' => static function( ContainerInterface $container ): Cache { + return new Cache( 'ppcp-paypal-bearer' ); + }, 'api.client-credentials-cache' => static function( ContainerInterface $container ): Cache { return new Cache( 'ppcp-client-credentials-cache' ); }, @@ -879,4 +877,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-api-client/src/ApiModule.php b/modules/ppcp-api-client/src/ApiModule.php index c75c63e6b..a2880761c 100644 --- a/modules/ppcp-api-client/src/ApiModule.php +++ b/modules/ppcp-api-client/src/ApiModule.php @@ -20,6 +20,8 @@ use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameI use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; +use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; +use WooCommerce\PayPalCommerce\ApiClient\Authentication\SdkClientToken; /** * Class ApiModule @@ -103,6 +105,34 @@ class ApiModule implements ServiceModule, ExtendingModule, ExecutableModule { 2 ); + /** + * Flushes the API client caches. + */ + add_action( + 'woocommerce_paypal_payments_flush_api_cache', + static function () use ( $c ) { + $caches = array( + 'api.paypal-bearer-cache' => array( + PayPalBearer::CACHE_KEY, + ), + 'api.client-credentials-cache' => array( + SdkClientToken::CACHE_KEY, + ), + ); + + foreach ( $caches as $cache_id => $keys ) { + $cache = $c->get( $cache_id ); + assert( $cache instanceof Cache ); + + foreach ( $keys as $key ) { + if ( $cache->has( $key ) ) { + $cache->delete( $key ); + } + } + } + } + ); + return true; } } diff --git a/modules/ppcp-api-client/src/Entity/FraudProcessorResponse.php b/modules/ppcp-api-client/src/Entity/FraudProcessorResponse.php index 2cc7c5480..baecabf73 100644 --- a/modules/ppcp-api-client/src/Entity/FraudProcessorResponse.php +++ b/modules/ppcp-api-client/src/Entity/FraudProcessorResponse.php @@ -17,44 +17,44 @@ class FraudProcessorResponse { /** * The AVS response code. * - * @var string|null + * @var string */ - protected $avs_code; + protected string $avs_code; /** * The CVV response code. * - * @var string|null + * @var string */ - protected $cvv_code; + protected string $cvv2_code; /** * FraudProcessorResponse constructor. * * @param string|null $avs_code The AVS response code. - * @param string|null $cvv_code The CVV response code. + * @param string|null $cvv2_code The CVV response code. */ - public function __construct( ?string $avs_code, ?string $cvv_code ) { - $this->avs_code = $avs_code; - $this->cvv_code = $cvv_code; + public function __construct( ?string $avs_code, ?string $cvv2_code ) { + $this->avs_code = (string) $avs_code; + $this->cvv2_code = (string) $cvv2_code; } /** * Returns the AVS response code. * - * @return string|null + * @return string */ - public function avs_code(): ?string { + public function avs_code(): string { return $this->avs_code; } /** * Returns the CVV response code. * - * @return string|null + * @return string */ - public function cvv_code(): ?string { - return $this->cvv_code; + public function cvv_code(): string { + return $this->cvv2_code; } /** @@ -64,11 +64,99 @@ class FraudProcessorResponse { */ public function to_array(): array { return array( - 'avs_code' => $this->avs_code() ?: '', + 'avs_code' => $this->avs_code(), + 'cvv2_code' => $this->cvv_code(), + // For backwards compatibility. 'address_match' => $this->avs_code() === 'M' ? 'Y' : 'N', 'postal_match' => $this->avs_code() === 'M' ? 'Y' : 'N', 'cvv_match' => $this->cvv_code() === 'M' ? 'Y' : 'N', ); } + /** + * Retrieves the AVS (Address Verification System) code messages based on the AVS response code. + * + * Provides human-readable descriptions for various AVS response codes + * and returns the corresponding message for the given code. + * + * @return string The AVS response code message. If the code is not found, an error message is returned. + */ + public function get_avs_code_message(): string { + if ( ! $this->avs_code() ) { + return ''; + } + $messages = array( + /* Visa, Mastercard, Discover, American Express */ + 'A' => 'A: Address - Address only (no ZIP code)', + 'B' => 'B: International "A" - Address only (no ZIP code)', + 'C' => 'C: International "N" - None. The transaction is declined.', + 'D' => 'D: International "X" - Address and Postal Code', + 'E' => 'E: Not allowed for MOTO (Internet/Phone) transactions - Not applicable. The transaction is declined.', + 'F' => 'F: UK-specific "X" - Address and Postal Code', + 'G' => 'G: Global Unavailable - Not applicable', + 'I' => 'I: International Unavailable - Not applicable', + 'M' => 'M: Address - Address and Postal Code', + 'N' => 'N: No - None. The transaction is declined.', + 'P' => 'P: Postal (International "Z") - Postal Code only (no Address)', + 'R' => 'R: Retry - Not applicable', + 'S' => 'S: Service not Supported - Not applicable', + 'U' => 'U: Unavailable / Address not checked, or acquirer had no response. Service not available.', + 'W' => 'W: Whole ZIP - Nine-digit ZIP code (no Address)', + 'X' => 'X: Exact match - Address and nine-digit ZIP code)', + 'Y' => 'Y: Yes - Address and five-digit ZIP', + 'Z' => 'Z: ZIP - Five-digit ZIP code (no Address)', + /* Maestro */ + '0' => '0: All the address information matched.', + '1' => '1: None of the address information matched. The transaction is declined.', + '2' => '2: Part of the address information matched.', + '3' => '3: The merchant did not provide AVS information. Not processed.', + '4' => '4: Address not checked, or acquirer had no response. Service not available.', + ); + + /** + * Psalm suppress + * + * @psalm-suppress PossiblyNullArrayOffset + * @psalm-suppress PossiblyNullArgument + */ + return $messages[ $this->avs_code() ] ?? sprintf( '%s: Error', $this->avs_code() ); + } + + /** + * Retrieves the CVV2 code message based on the CVV code provided. + * + * This method maps CVV response codes to their corresponding descriptive messages. + * + * @return string The descriptive message corresponding to the CVV2 code, or a formatted error message if the code is unrecognized. + */ + public function get_cvv2_code_message(): string { + if ( ! $this->cvv_code() ) { + return ''; + } + $messages = array( + /* Visa, Mastercard, Discover, American Express */ + 'E' => 'E: Error - Unrecognized or Unknown response', + 'I' => 'I: Invalid or Null', + 'M' => 'M: Match or CSC', + 'N' => 'N: No match', + 'P' => 'P: Not processed', + 'S' => 'S: Service not supported', + 'U' => 'U: Unknown - Issuer is not certified', + 'X' => 'X: No response / Service not available', + /* Maestro */ + '0' => '0: Matched CVV2', + '1' => '1: No match', + '2' => '2: The merchant has not implemented CVV2 code handling', + '3' => '3: Merchant has indicated that CVV2 is not present on card', + '4' => '4: Service not available', + ); + + /** + * Psalm suppress + * + * @psalm-suppress PossiblyNullArrayOffset + * @psalm-suppress PossiblyNullArgument + */ + return $messages[ $this->cvv_code() ] ?? sprintf( '%s: Error', $this->cvv_code() ); + } } diff --git a/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php b/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php index 7cb0c048f..1d222f605 100644 --- a/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php +++ b/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php @@ -178,6 +178,11 @@ class PurchaseUnitSanitizer { // Get a more intelligent adjustment mechanism. $increment = ( new MoneyFormatter() )->minimum_increment( $item['unit_amount']['currency_code'] ); + // not floor items that will be negative then. + if ( (float) $item['unit_amount']['value'] < $increment ) { + continue; + } + $this->purchase_unit['items'][ $index ]['unit_amount'] = ( new Money( ( (float) $item['unit_amount']['value'] ) - $increment, $item['unit_amount']['currency_code'] 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-axo-block/src/AxoBlockModule.php b/modules/ppcp-axo-block/src/AxoBlockModule.php index c8216bf62..af94976a3 100644 --- a/modules/ppcp-axo-block/src/AxoBlockModule.php +++ b/modules/ppcp-axo-block/src/AxoBlockModule.php @@ -92,7 +92,10 @@ class AxoBlockModule implements ServiceModule, ExtendingModule, ExecutableModule */ add_filter( 'woocommerce_paypal_payments_sdk_components_hook', - function( $components ) { + function( $components ) use ( $c ) { + if ( ! $c->has( 'axo.available' ) || ! $c->get( 'axo.available' ) ) { + return $components; + } $components[] = 'fastlane'; return $components; } diff --git a/modules/ppcp-axo/services.php b/modules/ppcp-axo/services.php index 121b17805..ca427700d 100644 --- a/modules/ppcp-axo/services.php +++ b/modules/ppcp-axo/services.php @@ -44,7 +44,9 @@ return array( // If AXO is configured and onboarded. 'axo.available' => static function ( ContainerInterface $container ): bool { - return true; + $settings = $container->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + return $settings->has( 'axo_enabled' ) && $settings->get( 'axo_enabled' ); }, 'axo.url' => static function ( ContainerInterface $container ): string { diff --git a/modules/ppcp-axo/src/AxoModule.php b/modules/ppcp-axo/src/AxoModule.php index 3ac0ff157..21ee84a35 100644 --- a/modules/ppcp-axo/src/AxoModule.php +++ b/modules/ppcp-axo/src/AxoModule.php @@ -246,7 +246,13 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { */ add_filter( 'woocommerce_paypal_payments_sdk_components_hook', - function( $components ) { + function( $components ) use ( $c ) { + $dcc_configuration = $c->get( 'wcgateway.configuration.dcc' ); + assert( $dcc_configuration instanceof DCCGatewayConfiguration ); + + if ( ! $dcc_configuration->use_fastlane() ) { + return $components; + } $components[] = 'fastlane'; return $components; } @@ -255,14 +261,18 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { add_action( 'wp_head', function () use ( $c ) { - // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript - echo ''; - // Add meta tag to allow feature-detection of the site's AXO payment state. $dcc_configuration = $c->get( 'wcgateway.configuration.dcc' ); assert( $dcc_configuration instanceof DCCGatewayConfiguration ); - $this->add_feature_detection_tag( $dcc_configuration->use_fastlane() ); + if ( $dcc_configuration->use_fastlane() ) { + // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript + echo ''; + + $this->add_feature_detection_tag( true ); + } else { + $this->add_feature_detection_tag( false ); + } } ); diff --git a/modules/ppcp-blocks/resources/js/Components/block-editor-paypal.js b/modules/ppcp-blocks/resources/js/Components/block-editor-paypal.js new file mode 100644 index 000000000..37d99539c --- /dev/null +++ b/modules/ppcp-blocks/resources/js/Components/block-editor-paypal.js @@ -0,0 +1,52 @@ +import { useMemo } from '@wordpress/element'; +import { normalizeStyleForFundingSource } from '../../../../ppcp-button/resources/js/modules/Helper/Style'; +import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js'; + +export const BlockEditorPayPalComponent = ( { + config, + fundingSource, + buttonAttributes, +} ) => { + const urlParams = useMemo( + () => ( { + clientId: 'test', + ...config.scriptData.url_params, + dataNamespace: 'ppcp-blocks-editor-paypal-buttons', + components: 'buttons', + } ), + [] + ); + + const style = useMemo( () => { + const configStyle = normalizeStyleForFundingSource( + config.scriptData.button.style, + fundingSource + ); + + if ( buttonAttributes ) { + return { + ...configStyle, + height: buttonAttributes.height + ? Number( buttonAttributes.height ) + : configStyle.height, + borderRadius: buttonAttributes.borderRadius + ? Number( buttonAttributes.borderRadius ) + : configStyle.borderRadius, + }; + } + + return configStyle; + }, [ fundingSource, buttonAttributes ] ); + + return ( + + false } + /> + + ); +}; diff --git a/modules/ppcp-blocks/resources/js/Components/card-fields.js b/modules/ppcp-blocks/resources/js/Components/card-fields.js index f47b18f35..30de01bb1 100644 --- a/modules/ppcp-blocks/resources/js/Components/card-fields.js +++ b/modules/ppcp-blocks/resources/js/Components/card-fields.js @@ -3,10 +3,10 @@ import { useEffect, useState } from '@wordpress/element'; import { PayPalScriptProvider, PayPalCardFieldsProvider, - PayPalNameField, - PayPalNumberField, - PayPalExpiryField, - PayPalCVVField, + PayPalNameField, + PayPalNumberField, + PayPalExpiryField, + PayPalCVVField, } from '@paypal/react-paypal-js'; import { CheckoutHandler } from './checkout-handler'; @@ -19,11 +19,7 @@ import { import { cartHasSubscriptionProducts } from '../Helper/Subscription'; import { __ } from '@wordpress/i18n'; -export function CardFields( { - config, - eventRegistration, - emitResponse, -} ) { +export function CardFields( { config, eventRegistration, emitResponse } ) { const { onPaymentSetup } = eventRegistration; const { responseTypes } = emitResponse; @@ -96,16 +92,36 @@ export function CardFields( { console.error( err ); } } > - - -
-
- -
-
- -
-
+ + +
+
+ +
+
+ +
+
{ + const { PaymentMethodIcons } = components; + + return ( + <> + + + + ); +}; diff --git a/modules/ppcp-blocks/resources/js/Components/paypal.js b/modules/ppcp-blocks/resources/js/Components/paypal.js new file mode 100644 index 000000000..c9bb1ad88 --- /dev/null +++ b/modules/ppcp-blocks/resources/js/Components/paypal.js @@ -0,0 +1,493 @@ +import { useEffect, useState } from '@wordpress/element'; +import { loadPayPalScript } from '../../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading'; +import { + mergeWcAddress, + paypalAddressToWc, + paypalOrderToWcAddresses, +} from '../Helper/Address'; +import { convertKeysToSnakeCase } from '../Helper/Helper'; +import buttonModuleWatcher from '../../../../ppcp-button/resources/js/modules/ButtonModuleWatcher'; +import { normalizeStyleForFundingSource } from '../../../../ppcp-button/resources/js/modules/Helper/Style'; +import { + cartHasSubscriptionProducts, + isPayPalSubscription, +} from '../Helper/Subscription'; +import { + createOrder, + createSubscription, + createVaultSetupToken, + handleApprove, + handleApproveSubscription, + onApproveSavePayment, +} from '../paypal-config'; + +const PAYPAL_GATEWAY_ID = 'ppcp-gateway'; + +const namespace = 'ppcpBlocksPaypalExpressButtons'; +let registeredContext = false; +let paypalScriptPromise = null; + +export const PayPalComponent = ( { + config, + onClick, + onClose, + onSubmit, + onError, + eventRegistration, + emitResponse, + activePaymentMethod, + shippingData, + isEditing, + fundingSource, + buttonAttributes, +} ) => { + const { onPaymentSetup, onCheckoutFail, onCheckoutValidation } = + eventRegistration; + const { responseTypes } = emitResponse; + + const [ paypalOrder, setPaypalOrder ] = useState( null ); + const [ continuationFilled, setContinuationFilled ] = useState( false ); + const [ gotoContinuationOnError, setGotoContinuationOnError ] = + useState( false ); + + const [ paypalScriptLoaded, setPaypalScriptLoaded ] = useState( false ); + + if ( ! paypalScriptLoaded ) { + if ( ! paypalScriptPromise ) { + // for editor, since canMakePayment was not called + paypalScriptPromise = loadPayPalScript( + namespace, + config.scriptData + ); + } + paypalScriptPromise.then( () => setPaypalScriptLoaded( true ) ); + } + + const methodId = fundingSource + ? `${ config.id }-${ fundingSource }` + : config.id; + + /** + * The block cart displays express checkout buttons. Those buttons are handled by the + * PAYPAL_GATEWAY_ID method on the server ("PayPal Smart Buttons"). + * + * A possible bug in WooCommerce does not use the correct payment method ID for the express + * payment buttons inside the cart, but sends the ID of the _first_ active payment method. + * + * This function uses an internal WooCommerce dispatcher method to set the correct method ID. + */ + const enforcePaymentMethodForCart = () => { + // Do nothing, unless we're handling block cart express payment buttons. + if ( 'cart-block' !== config.scriptData.context ) { + return; + } + + // Set the active payment method to PAYPAL_GATEWAY_ID. + wp.data + .dispatch( 'wc/store/payment' ) + .__internalSetActivePaymentMethod( PAYPAL_GATEWAY_ID, {} ); + }; + + useEffect( () => { + // fill the form if in continuation (for product or mini-cart buttons) + if ( continuationFilled || ! config.scriptData.continuation?.order ) { + return; + } + + try { + const paypalAddresses = paypalOrderToWcAddresses( + config.scriptData.continuation.order + ); + const wcAddresses = wp.data + .select( 'wc/store/cart' ) + .getCustomerData(); + const addresses = mergeWcAddress( wcAddresses, paypalAddresses ); + + wp.data + .dispatch( 'wc/store/cart' ) + .setBillingAddress( addresses.billingAddress ); + + if ( shippingData.needsShipping ) { + wp.data + .dispatch( 'wc/store/cart' ) + .setShippingAddress( addresses.shippingAddress ); + } + } catch ( err ) { + // sometimes the PayPal address is missing, skip in this case. + console.error( err ); + } + + // this useEffect should run only once, but adding this in case of some kind of full re-rendering + setContinuationFilled( true ); + }, [ shippingData, continuationFilled ] ); + + const getCheckoutRedirectUrl = () => { + const checkoutUrl = new URL( config.scriptData.redirect ); + // sometimes some browsers may load some kind of cached version of the page, + // so adding a parameter to avoid that + checkoutUrl.searchParams.append( + 'ppcp-continuation-redirect', + new Date().getTime().toString() + ); + return checkoutUrl.toString(); + }; + + useEffect( () => { + const unsubscribe = onCheckoutValidation( () => { + if ( config.scriptData.continuation ) { + return true; + } + if ( + gotoContinuationOnError && + wp.data.select( 'wc/store/validation' ).hasValidationErrors() + ) { + location.href = getCheckoutRedirectUrl(); + return { type: responseTypes.ERROR }; + } + + return true; + } ); + return unsubscribe; + }, [ onCheckoutValidation, gotoContinuationOnError ] ); + + const handleClick = ( data, actions ) => { + if ( isEditing ) { + return actions.reject(); + } + + window.ppcpFundingSource = data.fundingSource; + + onClick(); + }; + + const shouldHandleShippingInPayPal = () => { + return shouldskipFinalConfirmation() && config.needShipping; + }; + + const shouldskipFinalConfirmation = () => { + if ( config.finalReviewEnabled ) { + return false; + } + + return ( + window.ppcpFundingSource !== 'venmo' || + ! config.scriptData.vaultingEnabled + ); + }; + + let handleShippingOptionsChange = null; + let handleShippingAddressChange = null; + + if ( shippingData.needsShipping && shouldHandleShippingInPayPal() ) { + handleShippingOptionsChange = async ( data, actions ) => { + try { + const shippingOptionId = data.selectedShippingOption?.id; + if ( shippingOptionId ) { + await wp.data + .dispatch( 'wc/store/cart' ) + .selectShippingRate( shippingOptionId ); + await shippingData.setSelectedRates( shippingOptionId ); + } + + const res = await fetch( config.ajax.update_shipping.endpoint, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( { + nonce: config.ajax.update_shipping.nonce, + order_id: data.orderID, + } ), + } ); + + const json = await res.json(); + + if ( ! json.success ) { + throw new Error( json.data.message ); + } + } catch ( e ) { + console.error( e ); + + actions.reject(); + } + }; + + handleShippingAddressChange = async ( data, actions ) => { + try { + const address = paypalAddressToWc( + convertKeysToSnakeCase( data.shippingAddress ) + ); + + await wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { + shipping_address: address, + } ); + + await shippingData.setShippingAddress( address ); + + const res = await fetch( config.ajax.update_shipping.endpoint, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( { + nonce: config.ajax.update_shipping.nonce, + order_id: data.orderID, + } ), + } ); + + const json = await res.json(); + + if ( ! json.success ) { + throw new Error( json.data.message ); + } + } catch ( e ) { + console.error( e ); + + actions.reject(); + } + }; + } + + useEffect( () => { + if ( activePaymentMethod !== methodId ) { + return; + } + + const unsubscribeProcessing = onPaymentSetup( () => { + if ( + cartHasSubscriptionProducts( config.scriptData ) && + config.scriptData.is_free_trial_cart + ) { + return { + type: responseTypes.SUCCESS, + }; + } + + if ( config.scriptData.continuation ) { + return { + type: responseTypes.SUCCESS, + meta: { + paymentMethodData: { + paypal_order_id: + config.scriptData.continuation.order_id, + funding_source: + window.ppcpFundingSource ?? 'paypal', + }, + }, + }; + } + + const addresses = paypalOrderToWcAddresses( paypalOrder ); + + return { + type: responseTypes.SUCCESS, + meta: { + paymentMethodData: { + paypal_order_id: paypalOrder.id, + funding_source: window.ppcpFundingSource ?? 'paypal', + }, + ...addresses, + }, + }; + } ); + return () => { + unsubscribeProcessing(); + }; + }, [ onPaymentSetup, paypalOrder, activePaymentMethod ] ); + + useEffect( () => { + if ( activePaymentMethod !== methodId ) { + return; + } + const unsubscribe = onCheckoutFail( ( { processingResponse } ) => { + console.error( processingResponse ); + if ( onClose ) { + onClose(); + } + if ( config.scriptData.continuation ) { + return true; + } + if ( shouldskipFinalConfirmation() ) { + location.href = getCheckoutRedirectUrl(); + } + return true; + } ); + return unsubscribe; + }, [ onCheckoutFail, onClose, activePaymentMethod ] ); + + if ( config.scriptData.continuation ) { + return ( +
+ ); + } + + if ( ! registeredContext ) { + buttonModuleWatcher.registerContextBootstrap( + config.scriptData.context, + { + createOrder: ( data ) => { + return createOrder( data, config, onError, onClose ); + }, + onApprove: ( data, actions ) => { + return handleApprove( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose + ); + }, + } + ); + registeredContext = true; + } + + const style = normalizeStyleForFundingSource( + config.scriptData.button.style, + fundingSource + ); + + if ( typeof buttonAttributes !== 'undefined' ) { + style.height = buttonAttributes?.height + ? Number( buttonAttributes.height ) + : style.height; + style.borderRadius = buttonAttributes?.borderRadius + ? Number( buttonAttributes.borderRadius ) + : style.borderRadius; + } + + if ( ! paypalScriptLoaded ) { + return null; + } + + const PayPalButton = ppcpBlocksPaypalExpressButtons.Buttons.driver( + 'react', + { React, ReactDOM } + ); + + const getOnShippingOptionsChange = ( fundingSource ) => { + if ( fundingSource === 'venmo' ) { + return null; + } + + return ( data, actions ) => { + shouldHandleShippingInPayPal() + ? handleShippingOptionsChange( data, actions ) + : null; + }; + }; + + const getOnShippingAddressChange = ( fundingSource ) => { + if ( fundingSource === 'venmo' ) { + return null; + } + + return ( data, actions ) => { + const shippingAddressChange = shouldHandleShippingInPayPal() + ? handleShippingAddressChange( data, actions ) + : null; + + return shippingAddressChange; + }; + }; + + if ( + cartHasSubscriptionProducts( config.scriptData ) && + config.scriptData.is_free_trial_cart + ) { + return ( + createVaultSetupToken( config ) } + onApprove={ ( { vaultSetupToken } ) => + onApproveSavePayment( vaultSetupToken, config, onSubmit ) + } + /> + ); + } + + if ( isPayPalSubscription( config.scriptData ) ) { + return ( + + createSubscription( data, actions, config ) + } + onApprove={ ( data, actions ) => + handleApproveSubscription( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose + ) + } + onShippingOptionsChange={ getOnShippingOptionsChange( + fundingSource + ) } + onShippingAddressChange={ getOnShippingAddressChange( + fundingSource + ) } + /> + ); + } + + return ( + + createOrder( data, config, onError, onClose ) + } + onApprove={ ( data, actions ) => + handleApprove( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose + ) + } + onShippingOptionsChange={ getOnShippingOptionsChange( + fundingSource + ) } + onShippingAddressChange={ getOnShippingAddressChange( + fundingSource + ) } + /> + ); +}; diff --git a/modules/ppcp-blocks/resources/js/checkout-block.js b/modules/ppcp-blocks/resources/js/checkout-block.js index b0f971dd2..3ddc2cd92 100644 --- a/modules/ppcp-blocks/resources/js/checkout-block.js +++ b/modules/ppcp-blocks/resources/js/checkout-block.js @@ -1,750 +1,26 @@ -import { useEffect, useState, useMemo } from '@wordpress/element'; import { registerExpressPaymentMethod, registerPaymentMethod, } from '@woocommerce/blocks-registry'; import { __ } from '@wordpress/i18n'; -import { - mergeWcAddress, - paypalAddressToWc, - paypalOrderToWcAddresses, - paypalSubscriptionToWcAddresses, -} from './Helper/Address'; -import { convertKeysToSnakeCase } from './Helper/Helper'; import { cartHasSubscriptionProducts, isPayPalSubscription, } from './Helper/Subscription'; import { loadPayPalScript } from '../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading'; -import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js'; -import { normalizeStyleForFundingSource } from '../../../ppcp-button/resources/js/modules/Helper/Style'; -import buttonModuleWatcher from '../../../ppcp-button/resources/js/modules/ButtonModuleWatcher'; import BlockCheckoutMessagesBootstrap from './Bootstrap/BlockCheckoutMessagesBootstrap'; +import { PayPalComponent } from './Components/paypal'; +import { BlockEditorPayPalComponent } from './Components/block-editor-paypal'; +import { PaypalLabel } from './Components/paypal-label'; const namespace = 'ppcpBlocksPaypalExpressButtons'; const config = wc.wcSettings.getSetting( 'ppcp-gateway_data' ); window.ppcpFundingSource = config.fundingSource; -let registeredContext = false; let paypalScriptPromise = null; -const PAYPAL_GATEWAY_ID = 'ppcp-gateway'; - -const PayPalComponent = ( { - onClick, - onClose, - onSubmit, - onError, - eventRegistration, - emitResponse, - activePaymentMethod, - shippingData, - isEditing, - fundingSource, - buttonAttributes, -} ) => { - const { onPaymentSetup, onCheckoutFail, onCheckoutValidation } = - eventRegistration; - const { responseTypes } = emitResponse; - - const [ paypalOrder, setPaypalOrder ] = useState( null ); - const [ continuationFilled, setContinuationFilled ] = useState( false ); - const [ gotoContinuationOnError, setGotoContinuationOnError ] = - useState( false ); - - const [ paypalScriptLoaded, setPaypalScriptLoaded ] = useState( false ); - - if ( ! paypalScriptLoaded ) { - if ( ! paypalScriptPromise ) { - // for editor, since canMakePayment was not called - paypalScriptPromise = loadPayPalScript( - namespace, - config.scriptData - ); - } - paypalScriptPromise.then( () => setPaypalScriptLoaded( true ) ); - } - - const methodId = fundingSource - ? `${ config.id }-${ fundingSource }` - : config.id; - - /** - * The block cart displays express checkout buttons. Those buttons are handled by the - * PAYPAL_GATEWAY_ID method on the server ("PayPal Smart Buttons"). - * - * A possible bug in WooCommerce does not use the correct payment method ID for the express - * payment buttons inside the cart, but sends the ID of the _first_ active payment method. - * - * This function uses an internal WooCommerce dispatcher method to set the correct method ID. - */ - const enforcePaymentMethodForCart = () => { - // Do nothing, unless we're handling block cart express payment buttons. - if ( 'cart-block' !== config.scriptData.context ) { - return; - } - - // Set the active payment method to PAYPAL_GATEWAY_ID. - wp.data - .dispatch( 'wc/store/payment' ) - .__internalSetActivePaymentMethod( PAYPAL_GATEWAY_ID, {} ); - }; - - useEffect( () => { - // fill the form if in continuation (for product or mini-cart buttons) - if ( continuationFilled || ! config.scriptData.continuation?.order ) { - return; - } - - try { - const paypalAddresses = paypalOrderToWcAddresses( - config.scriptData.continuation.order - ); - const wcAddresses = wp.data - .select( 'wc/store/cart' ) - .getCustomerData(); - const addresses = mergeWcAddress( wcAddresses, paypalAddresses ); - - wp.data - .dispatch( 'wc/store/cart' ) - .setBillingAddress( addresses.billingAddress ); - - if ( shippingData.needsShipping ) { - wp.data - .dispatch( 'wc/store/cart' ) - .setShippingAddress( addresses.shippingAddress ); - } - } catch ( err ) { - // sometimes the PayPal address is missing, skip in this case. - console.log( err ); - } - - // this useEffect should run only once, but adding this in case of some kind of full re-rendering - setContinuationFilled( true ); - }, [ shippingData, continuationFilled ] ); - - const createOrder = async ( data, actions ) => { - try { - const requestBody = { - nonce: config.scriptData.ajax.create_order.nonce, - bn_code: '', - context: config.scriptData.context, - payment_method: 'ppcp-gateway', - funding_source: window.ppcpFundingSource ?? 'paypal', - createaccount: false, - ...( data?.paymentSource && { - payment_source: data.paymentSource, - } ), - }; - - const res = await fetch( - config.scriptData.ajax.create_order.endpoint, - { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( requestBody ), - } - ); - - const json = await res.json(); - - if ( ! json.success ) { - if ( json.data?.details?.length > 0 ) { - throw new Error( - json.data.details - .map( ( d ) => `${ d.issue } ${ d.description }` ) - .join( '
' ) - ); - } else if ( json.data?.message ) { - throw new Error( json.data.message ); - } - - throw new Error( config.scriptData.labels.error.generic ); - } - return json.data.id; - } catch ( err ) { - console.error( err ); - - onError( err.message ); - - onClose(); - - throw err; - } - }; - - const createSubscription = async ( data, actions ) => { - let planId = config.scriptData.subscription_plan_id; - if ( - config.scriptData - .variable_paypal_subscription_variation_from_cart !== '' - ) { - planId = - config.scriptData - .variable_paypal_subscription_variation_from_cart; - } - - return actions.subscription.create( { - plan_id: planId, - } ); - }; - - const handleApproveSubscription = async ( data, actions ) => { - try { - const subscription = await actions.subscription.get(); - - if ( subscription ) { - const addresses = - paypalSubscriptionToWcAddresses( subscription ); - - const promises = [ - // save address on server - wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { - billing_address: addresses.billingAddress, - shipping_address: addresses.shippingAddress, - } ), - ]; - if ( shouldHandleShippingInPayPal() ) { - // set address in UI - promises.push( - wp.data - .dispatch( 'wc/store/cart' ) - .setBillingAddress( addresses.billingAddress ) - ); - if ( shippingData.needsShipping ) { - promises.push( - wp.data - .dispatch( 'wc/store/cart' ) - .setShippingAddress( addresses.shippingAddress ) - ); - } - } - await Promise.all( promises ); - } - - setPaypalOrder( subscription ); - - const res = await fetch( - config.scriptData.ajax.approve_subscription.endpoint, - { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.scriptData.ajax.approve_subscription - .nonce, - order_id: data.orderID, - subscription_id: data.subscriptionID, - } ), - } - ); - - const json = await res.json(); - - if ( ! json.success ) { - if ( - typeof actions !== 'undefined' && - typeof actions.restart !== 'undefined' - ) { - return actions.restart(); - } - if ( json.data?.message ) { - throw new Error( json.data.message ); - } - - throw new Error( config.scriptData.labels.error.generic ); - } - - if ( ! shouldskipFinalConfirmation() ) { - location.href = getCheckoutRedirectUrl(); - } else { - setGotoContinuationOnError( true ); - enforcePaymentMethodForCart(); - onSubmit(); - } - } catch ( err ) { - console.error( err ); - - onError( err.message ); - - onClose(); - - throw err; - } - }; - - const getCheckoutRedirectUrl = () => { - const checkoutUrl = new URL( config.scriptData.redirect ); - // sometimes some browsers may load some kind of cached version of the page, - // so adding a parameter to avoid that - checkoutUrl.searchParams.append( - 'ppcp-continuation-redirect', - new Date().getTime().toString() - ); - return checkoutUrl.toString(); - }; - - const handleApprove = async ( data, actions ) => { - try { - const order = await actions.order.get(); - - if ( order ) { - const addresses = paypalOrderToWcAddresses( order ); - - const promises = [ - // save address on server - wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { - billing_address: addresses.billingAddress, - shipping_address: addresses.shippingAddress, - } ), - ]; - if ( shouldHandleShippingInPayPal() ) { - // set address in UI - promises.push( - wp.data - .dispatch( 'wc/store/cart' ) - .setBillingAddress( addresses.billingAddress ) - ); - if ( shippingData.needsShipping ) { - promises.push( - wp.data - .dispatch( 'wc/store/cart' ) - .setShippingAddress( addresses.shippingAddress ) - ); - } - } - await Promise.all( promises ); - } - - setPaypalOrder( order ); - - const res = await fetch( - config.scriptData.ajax.approve_order.endpoint, - { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.scriptData.ajax.approve_order.nonce, - order_id: data.orderID, - funding_source: window.ppcpFundingSource ?? 'paypal', - } ), - } - ); - - const json = await res.json(); - - if ( ! json.success ) { - if ( - typeof actions !== 'undefined' && - typeof actions.restart !== 'undefined' - ) { - return actions.restart(); - } - if ( json.data?.message ) { - throw new Error( json.data.message ); - } - - throw new Error( config.scriptData.labels.error.generic ); - } - - if ( ! shouldskipFinalConfirmation() ) { - location.href = getCheckoutRedirectUrl(); - } else { - setGotoContinuationOnError( true ); - enforcePaymentMethodForCart(); - onSubmit(); - } - } catch ( err ) { - console.error( err ); - - onError( err.message ); - - onClose(); - - throw err; - } - }; - - useEffect( () => { - const unsubscribe = onCheckoutValidation( () => { - if ( config.scriptData.continuation ) { - return true; - } - if ( - gotoContinuationOnError && - wp.data.select( 'wc/store/validation' ).hasValidationErrors() - ) { - location.href = getCheckoutRedirectUrl(); - return { type: responseTypes.ERROR }; - } - - return true; - } ); - return unsubscribe; - }, [ onCheckoutValidation, gotoContinuationOnError ] ); - - const handleClick = ( data, actions ) => { - if ( isEditing ) { - return actions.reject(); - } - - window.ppcpFundingSource = data.fundingSource; - - onClick(); - }; - - const shouldHandleShippingInPayPal = () => { - return shouldskipFinalConfirmation() && config.needShipping; - }; - - const shouldskipFinalConfirmation = () => { - if ( config.finalReviewEnabled ) { - return false; - } - - return ( - window.ppcpFundingSource !== 'venmo' || - ! config.scriptData.vaultingEnabled - ); - }; - - let handleShippingOptionsChange = null; - let handleShippingAddressChange = null; - let handleSubscriptionShippingOptionsChange = null; - let handleSubscriptionShippingAddressChange = null; - - if ( shippingData.needsShipping && shouldHandleShippingInPayPal() ) { - handleShippingOptionsChange = async ( data, actions ) => { - try { - const shippingOptionId = data.selectedShippingOption?.id; - if ( shippingOptionId ) { - await wp.data - .dispatch( 'wc/store/cart' ) - .selectShippingRate( shippingOptionId ); - await shippingData.setSelectedRates( shippingOptionId ); - } - - const res = await fetch( config.ajax.update_shipping.endpoint, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.ajax.update_shipping.nonce, - order_id: data.orderID, - } ), - } ); - - const json = await res.json(); - - if ( ! json.success ) { - throw new Error( json.data.message ); - } - } catch ( e ) { - console.error( e ); - - actions.reject(); - } - }; - - handleShippingAddressChange = async ( data, actions ) => { - try { - const address = paypalAddressToWc( - convertKeysToSnakeCase( data.shippingAddress ) - ); - - await wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { - shipping_address: address, - } ); - - await shippingData.setShippingAddress( address ); - - const res = await fetch( config.ajax.update_shipping.endpoint, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.ajax.update_shipping.nonce, - order_id: data.orderID, - } ), - } ); - - const json = await res.json(); - - if ( ! json.success ) { - throw new Error( json.data.message ); - } - } catch ( e ) { - console.error( e ); - - actions.reject(); - } - }; - - handleSubscriptionShippingOptionsChange = async ( data, actions ) => { - try { - const shippingOptionId = data.selectedShippingOption?.id; - if ( shippingOptionId ) { - await wp.data - .dispatch( 'wc/store/cart' ) - .selectShippingRate( shippingOptionId ); - await shippingData.setSelectedRates( shippingOptionId ); - } - } catch ( e ) { - console.error( e ); - - actions.reject(); - } - }; - - handleSubscriptionShippingAddressChange = async ( data, actions ) => { - try { - const address = paypalAddressToWc( - convertKeysToSnakeCase( data.shippingAddress ) - ); - - await wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { - shipping_address: address, - } ); - - await shippingData.setShippingAddress( address ); - - const res = await fetch( config.ajax.update_shipping.endpoint, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify( { - nonce: config.ajax.update_shipping.nonce, - order_id: data.orderID, - } ), - } ); - - const json = await res.json(); - - if ( ! json.success ) { - throw new Error( json.data.message ); - } - } catch ( e ) { - console.error( e ); - - actions.reject(); - } - }; - } - - useEffect( () => { - if ( activePaymentMethod !== methodId ) { - return; - } - - const unsubscribeProcessing = onPaymentSetup( () => { - if ( config.scriptData.continuation ) { - return { - type: responseTypes.SUCCESS, - meta: { - paymentMethodData: { - paypal_order_id: - config.scriptData.continuation.order_id, - funding_source: - window.ppcpFundingSource ?? 'paypal', - }, - }, - }; - } - - const addresses = paypalOrderToWcAddresses( paypalOrder ); - - return { - type: responseTypes.SUCCESS, - meta: { - paymentMethodData: { - paypal_order_id: paypalOrder.id, - funding_source: window.ppcpFundingSource ?? 'paypal', - }, - ...addresses, - }, - }; - } ); - return () => { - unsubscribeProcessing(); - }; - }, [ onPaymentSetup, paypalOrder, activePaymentMethod ] ); - - useEffect( () => { - if ( activePaymentMethod !== methodId ) { - return; - } - const unsubscribe = onCheckoutFail( ( { processingResponse } ) => { - console.error( processingResponse ); - if ( onClose ) { - onClose(); - } - if ( config.scriptData.continuation ) { - return true; - } - if ( shouldskipFinalConfirmation() ) { - location.href = getCheckoutRedirectUrl(); - } - return true; - } ); - return unsubscribe; - }, [ onCheckoutFail, onClose, activePaymentMethod ] ); - - if ( config.scriptData.continuation ) { - return ( -
- ); - } - - if ( ! registeredContext ) { - buttonModuleWatcher.registerContextBootstrap( - config.scriptData.context, - { - createOrder: () => { - return createOrder(); - }, - onApprove: ( data, actions ) => { - return handleApprove( data, actions ); - }, - } - ); - registeredContext = true; - } - - const style = normalizeStyleForFundingSource( - config.scriptData.button.style, - fundingSource - ); - - if ( typeof buttonAttributes !== 'undefined' ) { - style.height = buttonAttributes?.height - ? Number( buttonAttributes.height ) - : style.height; - style.borderRadius = buttonAttributes?.borderRadius - ? Number( buttonAttributes.borderRadius ) - : style.borderRadius; - } - - if ( ! paypalScriptLoaded ) { - return null; - } - - const PayPalButton = ppcpBlocksPaypalExpressButtons.Buttons.driver( - 'react', - { React, ReactDOM } - ); - - const getOnShippingOptionsChange = ( fundingSource ) => { - if ( fundingSource === 'venmo' ) { - return null; - } - - return ( data, actions ) => { - shouldHandleShippingInPayPal() - ? handleShippingOptionsChange( data, actions ) - : null; - }; - }; - - const getOnShippingAddressChange = ( fundingSource ) => { - if ( fundingSource === 'venmo' ) { - return null; - } - - return ( data, actions ) => { - const shippingAddressChange = shouldHandleShippingInPayPal() - ? handleShippingAddressChange( data, actions ) - : null; - - return shippingAddressChange; - }; - }; - - if ( isPayPalSubscription( config.scriptData ) ) { - return ( - - ); - } - - return ( - - ); -}; - -const BlockEditorPayPalComponent = ( { fundingSource, buttonAttributes } ) => { - const urlParams = useMemo( - () => ( { - clientId: 'test', - ...config.scriptData.url_params, - dataNamespace: 'ppcp-blocks-editor-paypal-buttons', - components: 'buttons', - } ), - [] - ); - - const style = useMemo( () => { - const configStyle = normalizeStyleForFundingSource( - config.scriptData.button.style, - fundingSource - ); - - if ( buttonAttributes ) { - return { - ...configStyle, - height: buttonAttributes.height - ? Number( buttonAttributes.height ) - : configStyle.height, - borderRadius: buttonAttributes.borderRadius - ? Number( buttonAttributes.borderRadius ) - : configStyle.borderRadius, - }; - } - - return configStyle; - }, [ fundingSource, buttonAttributes ] ); - - return ( - - false } - /> - - ); -}; - const features = [ 'products' ]; -let block_enabled = true; +let blockEnabled = true; if ( cartHasSubscriptionProducts( config.scriptData ) ) { // Don't show buttons on block cart page if using vault v2 and user is not logged in @@ -754,7 +30,17 @@ if ( cartHasSubscriptionProducts( config.scriptData ) ) { ! isPayPalSubscription( config.scriptData ) && // using vaulting ! config.scriptData?.save_payment_methods?.id_token // not vault v3 ) { - block_enabled = false; + blockEnabled = false; + } + + // Don't show buttons on block cart page if user is not logged in and cart contains free trial product + if ( + ! config.scriptData.user.is_logged && + config.scriptData.context === 'cart-block' && + cartHasSubscriptionProducts( config.scriptData ) && + config.scriptData.is_free_trial_cart + ) { + blockEnabled = false; } // Don't render if vaulting disabled and is in vault subscription mode @@ -762,7 +48,7 @@ if ( cartHasSubscriptionProducts( config.scriptData ) ) { ! isPayPalSubscription( config.scriptData ) && ! config.scriptData.can_save_vault_token ) { - block_enabled = false; + blockEnabled = false; } // Don't render buttons if in subscription mode and product not associated with a PayPal subscription @@ -770,13 +56,21 @@ if ( cartHasSubscriptionProducts( config.scriptData ) ) { isPayPalSubscription( config.scriptData ) && ! config.scriptData.subscription_product_allowed ) { - block_enabled = false; + blockEnabled = false; + } + + // Don't show buttons if cart contains free trial product and the stroe is not eligible for saving payment methods. + if ( + ! config.scriptData.vault_v3_enabled && + config.scriptData.is_free_trial_cart + ) { + blockEnabled = false; } features.push( 'subscriptions' ); } -if ( block_enabled ) { +if ( blockEnabled ) { if ( config.placeOrderEnabled && ! config.scriptData.continuation ) { let descriptionElement = (
{ - const { PaymentMethodIcons } = components; - - return ( - <> - - - - ); - }; - registerPaymentMethod( { name: config.id, label: , @@ -837,8 +116,13 @@ if ( block_enabled ) { registerPaymentMethod( { name: config.id, label:
, - content: , - edit: , + content: , + edit: ( + + ), ariaLabel: config.title, canMakePayment: () => { return true; @@ -848,10 +132,11 @@ if ( block_enabled ) { }, } ); } else if ( config.smartButtonsEnabled ) { - for ( const fundingSource of [ - 'paypal', - ...config.enabledFundingSources, - ] ) { + const fundingSources = config.scriptData.is_free_trial_cart + ? [ 'paypal' ] + : [ 'paypal', ...config.enabledFundingSources ]; + + for ( const fundingSource of fundingSources ) { registerExpressPaymentMethod( { name: `${ config.id }-${ fundingSource }`, title: 'PayPal', @@ -866,12 +151,14 @@ if ( block_enabled ) { ), content: ( ), edit: ( ), diff --git a/modules/ppcp-blocks/resources/js/paypal-config.js b/modules/ppcp-blocks/resources/js/paypal-config.js new file mode 100644 index 000000000..d78ee14db --- /dev/null +++ b/modules/ppcp-blocks/resources/js/paypal-config.js @@ -0,0 +1,316 @@ +import { + paypalOrderToWcAddresses, + paypalSubscriptionToWcAddresses, +} from './Helper/Address'; + +export const createOrder = async ( data, config, onError, onClose ) => { + try { + const requestBody = { + nonce: config.scriptData.ajax.create_order.nonce, + bn_code: '', + context: config.scriptData.context, + payment_method: 'ppcp-gateway', + funding_source: window.ppcpFundingSource ?? 'paypal', + createaccount: false, + ...( data?.paymentSource && { + payment_source: data.paymentSource, + } ), + }; + + const res = await fetch( config.scriptData.ajax.create_order.endpoint, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( requestBody ), + } ); + + const json = await res.json(); + + if ( ! json.success ) { + if ( json.data?.details?.length > 0 ) { + throw new Error( + json.data.details + .map( ( d ) => `${ d.issue } ${ d.description }` ) + .join( '
' ) + ); + } else if ( json.data?.message ) { + throw new Error( json.data.message ); + } + + throw new Error( config.scriptData.labels.error.generic ); + } + + return json.data.id; + } catch ( err ) { + console.error( err ); + + onError( err.message ); + + onClose(); + + throw err; + } +}; + +export const handleApprove = async ( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose +) => { + try { + const order = await actions.order.get(); + + if ( order ) { + const addresses = paypalOrderToWcAddresses( order ); + + const promises = [ + // save address on server + wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { + billing_address: addresses.billingAddress, + shipping_address: addresses.shippingAddress, + } ), + ]; + if ( shouldHandleShippingInPayPal() ) { + // set address in UI + promises.push( + wp.data + .dispatch( 'wc/store/cart' ) + .setBillingAddress( addresses.billingAddress ) + ); + if ( shippingData.needsShipping ) { + promises.push( + wp.data + .dispatch( 'wc/store/cart' ) + .setShippingAddress( addresses.shippingAddress ) + ); + } + } + await Promise.all( promises ); + } + + setPaypalOrder( order ); + + const res = await fetch( + config.scriptData.ajax.approve_order.endpoint, + { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( { + nonce: config.scriptData.ajax.approve_order.nonce, + order_id: data.orderID, + funding_source: window.ppcpFundingSource ?? 'paypal', + } ), + } + ); + + const json = await res.json(); + + if ( ! json.success ) { + if ( + typeof actions !== 'undefined' && + typeof actions.restart !== 'undefined' + ) { + return actions.restart(); + } + if ( json.data?.message ) { + throw new Error( json.data.message ); + } + + throw new Error( config.scriptData.labels.error.generic ); + } + + if ( ! shouldskipFinalConfirmation() ) { + location.href = getCheckoutRedirectUrl(); + } else { + setGotoContinuationOnError( true ); + enforcePaymentMethodForCart(); + onSubmit(); + } + } catch ( err ) { + console.error( err ); + + onError( err.message ); + + onClose(); + + throw err; + } +}; + +export const createSubscription = async ( data, actions, config ) => { + let planId = config.scriptData.subscription_plan_id; + if ( + config.scriptData.variable_paypal_subscription_variation_from_cart !== + '' + ) { + planId = + config.scriptData.variable_paypal_subscription_variation_from_cart; + } + + return actions.subscription.create( { + plan_id: planId, + } ); +}; + +export const handleApproveSubscription = async ( + data, + actions, + config, + shouldHandleShippingInPayPal, + shippingData, + setPaypalOrder, + shouldskipFinalConfirmation, + getCheckoutRedirectUrl, + setGotoContinuationOnError, + enforcePaymentMethodForCart, + onSubmit, + onError, + onClose +) => { + try { + const subscription = await actions.subscription.get(); + + if ( subscription ) { + const addresses = paypalSubscriptionToWcAddresses( subscription ); + + const promises = [ + // save address on server + wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { + billing_address: addresses.billingAddress, + shipping_address: addresses.shippingAddress, + } ), + ]; + if ( shouldHandleShippingInPayPal() ) { + // set address in UI + promises.push( + wp.data + .dispatch( 'wc/store/cart' ) + .setBillingAddress( addresses.billingAddress ) + ); + if ( shippingData.needsShipping ) { + promises.push( + wp.data + .dispatch( 'wc/store/cart' ) + .setShippingAddress( addresses.shippingAddress ) + ); + } + } + await Promise.all( promises ); + } + + setPaypalOrder( subscription ); + + const res = await fetch( + config.scriptData.ajax.approve_subscription.endpoint, + { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify( { + nonce: config.scriptData.ajax.approve_subscription.nonce, + order_id: data.orderID, + subscription_id: data.subscriptionID, + } ), + } + ); + + const json = await res.json(); + + if ( ! json.success ) { + if ( + typeof actions !== 'undefined' && + typeof actions.restart !== 'undefined' + ) { + return actions.restart(); + } + if ( json.data?.message ) { + throw new Error( json.data.message ); + } + + throw new Error( config.scriptData.labels.error.generic ); + } + + if ( ! shouldskipFinalConfirmation() ) { + location.href = getCheckoutRedirectUrl(); + } else { + setGotoContinuationOnError( true ); + enforcePaymentMethodForCart(); + onSubmit(); + } + } catch ( err ) { + console.error( err ); + + onError( err.message ); + + onClose(); + + throw err; + } +}; + +export const createVaultSetupToken = async ( config ) => { + return fetch( config.scriptData.ajax.create_setup_token.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( { + nonce: config.scriptData.ajax.create_setup_token.nonce, + payment_method: 'ppcp-gateway', + } ), + } ) + .then( ( response ) => response.json() ) + .then( ( result ) => { + return result.data.id; + } ) + .catch( ( err ) => { + console.error( err ); + } ); +}; + +export const onApproveSavePayment = async ( + vaultSetupToken, + config, + onSubmit +) => { + let endpoint = + config.scriptData.ajax.create_payment_token_for_guest.endpoint; + let bodyContent = { + nonce: config.scriptData.ajax.create_payment_token_for_guest.nonce, + vault_setup_token: vaultSetupToken, + }; + + if ( config.scriptData.user.is_logged_in ) { + endpoint = config.scriptData.ajax.create_payment_token.endpoint; + + bodyContent = { + nonce: config.scriptData.ajax.create_payment_token.nonce, + vault_setup_token: vaultSetupToken, + is_free_trial_cart: config.scriptData.is_free_trial_cart, + }; + } + + const response = await fetch( endpoint, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( bodyContent ), + } ); + + const result = await response.json(); + if ( result.success === true ) { + onSubmit(); + } + + console.error( result ); +}; diff --git a/modules/ppcp-blocks/src/AdvancedCardPaymentMethod.php b/modules/ppcp-blocks/src/AdvancedCardPaymentMethod.php index c52d7e601..222d53e22 100644 --- a/modules/ppcp-blocks/src/AdvancedCardPaymentMethod.php +++ b/modules/ppcp-blocks/src/AdvancedCardPaymentMethod.php @@ -97,11 +97,16 @@ class AdvancedCardPaymentMethod extends AbstractPaymentMethodType { wp_register_script( 'ppcp-advanced-card-checkout-block', trailingslashit( $this->module_url ) . 'assets/js/advanced-card-checkout-block.js', - array(), + array( 'wp-i18n' ), $this->version, true ); + wp_set_script_translations( + 'ppcp-advanced-card-checkout-block', + 'woocommerce-paypal-payments' + ); + return array( 'ppcp-advanced-card-checkout-block' ); } diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index a08c20b27..fd1b93404 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -1910,7 +1910,7 @@ document.querySelector("#payment").before(document.querySelector(".ppcp-messages $in_stock = $product->is_in_stock(); - if ( $product->is_type( 'variable' ) ) { + if ( ! $in_stock && $product->is_type( 'variable' ) ) { /** * The method is defined in WC_Product_Variable class. * diff --git a/modules/ppcp-compat/services.php b/modules/ppcp-compat/services.php index 07a6da27f..7982366e0 100644 --- a/modules/ppcp-compat/services.php +++ b/modules/ppcp-compat/services.php @@ -121,7 +121,7 @@ return array( * * @returns SettingsMap[] */ - 'compat.setting.new-to-old-map' => function( ContainerInterface $container ) : array { + 'compat.setting.new-to-old-map' => static function( ContainerInterface $container ) : array { $are_new_settings_enabled = $container->get( 'wcgateway.settings.admin-settings-enabled' ); if ( ! $are_new_settings_enabled ) { return array(); @@ -129,7 +129,7 @@ return array( return array( new SettingsMap( - $container->get( 'settings.data.common' ), + $container->get( 'settings.data.general' ), array( 'client_id' => 'client_id', 'client_secret' => 'client_secret', @@ -137,16 +137,23 @@ return array( ), new SettingsMap( $container->get( 'settings.data.general' ), + /** + * The new GeneralSettings class stores the current connection + * details, without adding an environment-suffix (no `_sandbox` + * or `_production` in the field name) + * Only the `sandbox_merchant` flag indicates, which environment + * the credentials are used for. + */ array( - 'is_sandbox' => 'sandbox_on', - 'live_client_id' => 'client_id_production', - 'live_client_secret' => 'client_secret_production', - 'live_merchant_id' => 'merchant_id_production', - 'live_merchant_email' => 'merchant_email_production', - 'sandbox_client_id' => 'client_id_sandbox', - 'sandbox_client_secret' => 'client_secret_sandbox', - 'sandbox_merchant_id' => 'merchant_id_sandbox', - 'sandbox_merchant_email' => 'merchant_email_sandbox', + 'is_sandbox' => 'sandbox_merchant', + 'live_client_id' => 'client_id', + 'live_client_secret' => 'client_secret', + 'live_merchant_id' => 'merchant_id', + 'live_merchant_email' => 'merchant_email', + 'sandbox_client_id' => 'client_id', + 'sandbox_client_secret' => 'client_secret', + 'sandbox_merchant_id' => 'merchant_id', + 'sandbox_merchant_email' => 'merchant_email', ) ), ); 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/resources/js/onboarding.js b/modules/ppcp-onboarding/resources/js/onboarding.js index 5a6ab333a..06197afd6 100644 --- a/modules/ppcp-onboarding/resources/js/onboarding.js +++ b/modules/ppcp-onboarding/resources/js/onboarding.js @@ -345,6 +345,10 @@ window.ppcp_onboarding_productionCallback = function ( ...args ) { const sandboxSwitchElement = document.querySelector( '#ppcp-sandbox_on' ); + sandboxSwitchElement?.addEventListener( 'click', () => { + document.querySelector( '.woocommerce-save-button' )?.removeAttribute( 'disabled' ); + }); + const validate = () => { const selectors = sandboxSwitchElement.checked ? sandboxCredentialElementsSelectors 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-save-payment-methods/src/Endpoint/CreatePaymentToken.php b/modules/ppcp-save-payment-methods/src/Endpoint/CreatePaymentToken.php index 434a08925..b89d17e53 100644 --- a/modules/ppcp-save-payment-methods/src/Endpoint/CreatePaymentToken.php +++ b/modules/ppcp-save-payment-methods/src/Endpoint/CreatePaymentToken.php @@ -96,7 +96,7 @@ class CreatePaymentToken implements EndpointInterface { $customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); - $result = $this->payment_method_tokens_endpoint->create_payment_token( $payment_source, $customer_id ); + $result = $this->payment_method_tokens_endpoint->create_payment_token( $payment_source, (string) $customer_id ); if ( is_user_logged_in() && isset( $result->customer->id ) ) { $current_user_id = get_current_user_id(); diff --git a/modules/ppcp-save-payment-methods/src/Endpoint/CreateSetupToken.php b/modules/ppcp-save-payment-methods/src/Endpoint/CreateSetupToken.php index 6952feb43..0b53f0828 100644 --- a/modules/ppcp-save-payment-methods/src/Endpoint/CreateSetupToken.php +++ b/modules/ppcp-save-payment-methods/src/Endpoint/CreateSetupToken.php @@ -105,7 +105,7 @@ class CreateSetupToken implements EndpointInterface { $customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); - $result = $this->payment_method_tokens_endpoint->setup_tokens( $payment_source, $customer_id ); + $result = $this->payment_method_tokens_endpoint->setup_tokens( $payment_source, (string) $customer_id ); wp_send_json_success( $result ); return true; diff --git a/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php b/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php index c76162193..c5a8776d0 100644 --- a/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php +++ b/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php @@ -66,396 +66,382 @@ class SavePaymentMethodsModule implements ServiceModule, ExtendingModule, Execut add_action( 'woocommerce_paypal_payments_gateway_migrate_on_update', function() use ( $c ) { + $settings = $c->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + $billing_agreements_endpoint = $c->get( 'api.endpoint.billing-agreements' ); assert( $billing_agreements_endpoint instanceof BillingAgreementsEndpoint ); - $reference_transaction_enabled = $billing_agreements_endpoint->reference_transaction_enabled(); if ( $reference_transaction_enabled !== true ) { - $c->get( 'wcgateway.settings' )->set( 'vault_enabled', false ); - $c->get( 'wcgateway.settings' )->persist(); + $settings->set( 'vault_enabled', false ); + $settings->persist(); } } ); - add_filter( - 'woocommerce_paypal_payments_localized_script_data', - function( array $localized_script_data ) use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return $localized_script_data; - } - $subscriptions_helper = $c->get( 'wc-subscriptions.helper' ); - assert( $subscriptions_helper instanceof SubscriptionHelper ); - if ( ! is_user_logged_in() && ! $subscriptions_helper->cart_contains_subscription() ) { - return $localized_script_data; - } - - $api = $c->get( 'api.user-id-token' ); - assert( $api instanceof UserIdToken ); - - $logger = $c->get( 'woocommerce.logger.woocommerce' ); - assert( $logger instanceof LoggerInterface ); - - return $this->add_id_token_to_script_data( $api, $logger, $localized_script_data ); - } - ); - - // Adds attributes needed to save payment method. - add_filter( - 'ppcp_create_order_request_body_data', - function( array $data, string $payment_method, array $request_data ) use ( $c ): array { - if ( ! self::vault_enabled( $c ) ) { - return $data; - } - if ( $payment_method === CreditCardGateway::ID ) { - $save_payment_method = $request_data['save_payment_method'] ?? false; - if ( $save_payment_method ) { - $data['payment_source'] = array( - 'card' => array( - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - ), - ), - ), - ); - - $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); - if ( ! $target_customer_id ) { - $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); - } - - if ( $target_customer_id ) { - $data['payment_source']['card']['attributes']['customer'] = array( - 'id' => $target_customer_id, - ); - } - } - } - - if ( $payment_method === PayPalGateway::ID ) { - $funding_source = $request_data['funding_source'] ?? null; - - if ( $funding_source && $funding_source === 'venmo' ) { - $data['payment_source'] = array( - 'venmo' => array( - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - 'usage_type' => 'MERCHANT', - 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), - ), - ), - ), - ); - } elseif ( $funding_source && $funding_source === 'apple_pay' ) { - $data['payment_source'] = array( - 'apple_pay' => array( - 'stored_credential' => array( - 'payment_initiator' => 'CUSTOMER', - 'payment_type' => 'RECURRING', - ), - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - ), - ), - ), - ); - } else { - $data['payment_source'] = array( - 'paypal' => array( - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - 'usage_type' => 'MERCHANT', - 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), - ), - ), - ), - ); - } - } - - return $data; - }, - 10, - 3 - ); - - add_action( - 'woocommerce_paypal_payments_after_order_processor', - function( WC_Order $wc_order, Order $order ) use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - $payment_source = $order->payment_source(); - assert( $payment_source instanceof PaymentSource ); - - $payment_vault_attributes = $payment_source->properties()->attributes->vault ?? null; - if ( $payment_vault_attributes ) { - $customer_id = $payment_vault_attributes->customer->id ?? ''; - $token_id = $payment_vault_attributes->id ?? ''; - if ( ! $customer_id || ! $token_id ) { - return; - } - - update_user_meta( $wc_order->get_customer_id(), '_ppcp_target_customer_id', $customer_id ); - - $wc_payment_tokens = $c->get( 'vaulting.wc-payment-tokens' ); - assert( $wc_payment_tokens instanceof WooCommercePaymentTokens ); - - if ( $wc_order->get_payment_method() === CreditCardGateway::ID ) { - $token = new \WC_Payment_Token_CC(); - $token->set_token( $token_id ); - $token->set_user_id( $wc_order->get_customer_id() ); - $token->set_gateway_id( CreditCardGateway::ID ); - - $token->set_last4( $payment_source->properties()->last_digits ?? '' ); - $expiry = explode( '-', $payment_source->properties()->expiry ?? '' ); - $token->set_expiry_year( $expiry[0] ?? '' ); - $token->set_expiry_month( $expiry[1] ?? '' ); - $token->set_card_type( $payment_source->properties()->brand ?? '' ); - - $token->save(); - } - - if ( $wc_order->get_payment_method() === PayPalGateway::ID ) { - switch ( $payment_source->name() ) { - case 'venmo': - $wc_payment_tokens->create_payment_token_venmo( - $wc_order->get_customer_id(), - $token_id, - $payment_source->properties()->email_address ?? '' - ); - break; - case 'apple_pay': - $wc_payment_tokens->create_payment_token_applepay( - $wc_order->get_customer_id(), - $token_id - ); - break; - case 'paypal': - default: - $wc_payment_tokens->create_payment_token_paypal( - $wc_order->get_customer_id(), - $token_id, - $payment_source->properties()->email_address ?? '' - ); - break; - } - } - } - }, - 10, - 2 - ); - - add_filter( - 'woocommerce_paypal_payments_disable_add_payment_method', - function ( bool $value ) use ( $c ): bool { - if ( ! self::vault_enabled( $c ) ) { - return $value; - } - return false; - } - ); - - add_filter( - 'woocommerce_paypal_payments_should_render_card_custom_fields', - function ( bool $value ) use ( $c ): bool { - if ( ! self::vault_enabled( $c ) ) { - return $value; - } - return false; - } - ); - add_action( - 'wp_enqueue_scripts', - function() use ( $c ) { - if ( ! is_user_logged_in() || ! ( $this->is_add_payment_method_page() || $this->is_subscription_change_payment_method_page() ) || ! self::vault_enabled( $c ) ) { - return; + 'after_setup_theme', + function () use ( $c ) { + $settings = $c->get( 'wcgateway.settings' ); + if ( + ( ! $settings->has( 'vault_enabled' ) || ! $settings->get( 'vault_enabled' ) ) + && ( ! $settings->has( 'vault_enabled_dcc' ) || ! $settings->get( 'vault_enabled_dcc' ) ) + ) { + return true; } - $module_url = $c->get( 'save-payment-methods.module.url' ); - wp_enqueue_script( - 'ppcp-add-payment-method', - untrailingslashit( $module_url ) . '/assets/js/add-payment-method.js', - array( 'jquery' ), - $c->get( 'ppcp.asset-version' ), - true + add_filter( + 'woocommerce_paypal_payments_localized_script_data', + function ( array $localized_script_data ) use ( $c ) { + $subscriptions_helper = $c->get( 'wc-subscriptions.helper' ); + assert( $subscriptions_helper instanceof SubscriptionHelper ); + if ( ! is_user_logged_in() && ! $subscriptions_helper->cart_contains_subscription() ) { + return $localized_script_data; + } + + $api = $c->get( 'api.user-id-token' ); + assert( $api instanceof UserIdToken ); + + $logger = $c->get( 'woocommerce.logger.woocommerce' ); + assert( $logger instanceof LoggerInterface ); + + return $this->add_id_token_to_script_data( $api, $logger, $localized_script_data ); + } ); - $api = $c->get( 'api.user-id-token' ); - assert( $api instanceof UserIdToken ); + // Adds attributes needed to save payment method. + add_filter( + 'ppcp_create_order_request_body_data', + function ( array $data, string $payment_method, array $request_data ) use ( $c ): array { + $settings = $c->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + if ( $payment_method === CreditCardGateway::ID ) { + if ( ! $settings->has( 'vault_enabled_dcc' ) || ! $settings->get( 'vault_enabled_dcc' ) ) { + return $data; + } - try { - $target_customer_id = ''; - if ( is_user_logged_in() ) { - $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); - if ( ! $target_customer_id ) { - $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); + $save_payment_method = $request_data['save_payment_method'] ?? false; + if ( $save_payment_method ) { + $data['payment_source'] = array( + 'card' => array( + 'attributes' => array( + 'vault' => array( + 'store_in_vault' => 'ON_SUCCESS', + ), + ), + ), + ); + + $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); + if ( ! $target_customer_id ) { + $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); + } + + if ( $target_customer_id ) { + $data['payment_source']['card']['attributes']['customer'] = array( + 'id' => $target_customer_id, + ); + } + } + } + + if ( $payment_method === PayPalGateway::ID ) { + if ( ! $settings->has( 'vault_enabled' ) || ! $settings->get( 'vault_enabled' ) ) { + return $data; + } + + $funding_source = $request_data['funding_source'] ?? null; + + if ( $funding_source && $funding_source === 'venmo' ) { + $data['payment_source'] = array( + 'venmo' => array( + 'attributes' => array( + 'vault' => array( + 'store_in_vault' => 'ON_SUCCESS', + 'usage_type' => 'MERCHANT', + 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), + ), + ), + ), + ); + } elseif ( $funding_source && $funding_source === 'apple_pay' ) { + $data['payment_source'] = array( + 'apple_pay' => array( + 'stored_credential' => array( + 'payment_initiator' => 'CUSTOMER', + 'payment_type' => 'RECURRING', + ), + 'attributes' => array( + 'vault' => array( + 'store_in_vault' => 'ON_SUCCESS', + ), + ), + ), + ); + } else { + $data['payment_source'] = array( + 'paypal' => array( + 'attributes' => array( + 'vault' => array( + 'store_in_vault' => 'ON_SUCCESS', + 'usage_type' => 'MERCHANT', + 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), + ), + ), + ), + ); + } + } + + return $data; + }, + 10, + 3 + ); + + add_action( + 'woocommerce_paypal_payments_after_order_processor', + function ( WC_Order $wc_order, Order $order ) use ( $c ) { + $payment_source = $order->payment_source(); + assert( $payment_source instanceof PaymentSource ); + + $payment_vault_attributes = $payment_source->properties()->attributes->vault ?? null; + if ( $payment_vault_attributes ) { + $customer_id = $payment_vault_attributes->customer->id ?? ''; + $token_id = $payment_vault_attributes->id ?? ''; + if ( ! $customer_id || ! $token_id ) { + return; + } + + update_user_meta( $wc_order->get_customer_id(), '_ppcp_target_customer_id', $customer_id ); + + $wc_payment_tokens = $c->get( 'vaulting.wc-payment-tokens' ); + assert( $wc_payment_tokens instanceof WooCommercePaymentTokens ); + + if ( $wc_order->get_payment_method() === CreditCardGateway::ID ) { + $token = new \WC_Payment_Token_CC(); + $token->set_token( $token_id ); + $token->set_user_id( $wc_order->get_customer_id() ); + $token->set_gateway_id( CreditCardGateway::ID ); + + $token->set_last4( $payment_source->properties()->last_digits ?? '' ); + $expiry = explode( '-', $payment_source->properties()->expiry ?? '' ); + $token->set_expiry_year( $expiry[0] ?? '' ); + $token->set_expiry_month( $expiry[1] ?? '' ); + $token->set_card_type( $payment_source->properties()->brand ?? '' ); + + $token->save(); + } + + if ( $wc_order->get_payment_method() === PayPalGateway::ID ) { + switch ( $payment_source->name() ) { + case 'venmo': + $wc_payment_tokens->create_payment_token_venmo( + $wc_order->get_customer_id(), + $token_id, + $payment_source->properties()->email_address ?? '' + ); + break; + case 'apple_pay': + $wc_payment_tokens->create_payment_token_applepay( + $wc_order->get_customer_id(), + $token_id + ); + break; + case 'paypal': + default: + $wc_payment_tokens->create_payment_token_paypal( + $wc_order->get_customer_id(), + $token_id, + $payment_source->properties()->email_address ?? '' + ); + break; + } + } + } + }, + 10, + 2 + ); + + add_filter( 'woocommerce_paypal_payments_disable_add_payment_method', '__return_false' ); + add_filter( 'woocommerce_paypal_payments_should_render_card_custom_fields', '__return_false' ); + + add_action( + 'wp_enqueue_scripts', + function () use ( $c ) { + if ( ! is_user_logged_in() || ! ( $this->is_add_payment_method_page() || $this->is_subscription_change_payment_method_page() ) ) { + return; + } + + $module_url = $c->get( 'save-payment-methods.module.url' ); + wp_enqueue_script( + 'ppcp-add-payment-method', + untrailingslashit( $module_url ) . '/assets/js/add-payment-method.js', + array( 'jquery' ), + $c->get( 'ppcp.asset-version' ), + true + ); + + $api = $c->get( 'api.user-id-token' ); + assert( $api instanceof UserIdToken ); + + try { + $target_customer_id = ''; + if ( is_user_logged_in() ) { + $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); + if ( ! $target_customer_id ) { + $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); + } + } + + $id_token = $api->id_token( $target_customer_id ); + + $settings = $c->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + + $verification_method = + $settings->has( '3d_secure_contingency' ) + ? apply_filters( 'woocommerce_paypal_payments_three_d_secure_contingency', $settings->get( '3d_secure_contingency' ) ) + : ''; + + $change_payment_method = wc_clean( wp_unslash( $_GET['change_payment_method'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification + + wp_localize_script( + 'ppcp-add-payment-method', + 'ppcp_add_payment_method', + array( + 'client_id' => $c->get( 'button.client_id' ), + 'merchant_id' => $c->get( 'api.merchant_id' ), + 'id_token' => $id_token, + 'payment_methods_page' => wc_get_account_endpoint_url( 'payment-methods' ), + 'view_subscriptions_page' => wc_get_account_endpoint_url( 'view-subscription' ), + 'is_subscription_change_payment_page' => $this->is_subscription_change_payment_method_page(), + 'subscription_id_to_change_payment' => $this->is_subscription_change_payment_method_page() ? (int) $change_payment_method : 0, + 'error_message' => __( 'Could not save payment method.', 'woocommerce-paypal-payments' ), + 'verification_method' => $verification_method, + 'ajax' => array( + 'create_setup_token' => array( + 'endpoint' => \WC_AJAX::get_endpoint( CreateSetupToken::ENDPOINT ), + 'nonce' => wp_create_nonce( CreateSetupToken::nonce() ), + ), + 'create_payment_token' => array( + 'endpoint' => \WC_AJAX::get_endpoint( CreatePaymentToken::ENDPOINT ), + 'nonce' => wp_create_nonce( CreatePaymentToken::nonce() ), + ), + 'subscription_change_payment_method' => array( + 'endpoint' => \WC_AJAX::get_endpoint( SubscriptionChangePaymentMethod::ENDPOINT ), + 'nonce' => wp_create_nonce( SubscriptionChangePaymentMethod::nonce() ), + ), + ), + 'labels' => array( + 'error' => array( + 'generic' => __( + 'Something went wrong. Please try again or choose another payment source.', + 'woocommerce-paypal-payments' + ), + ), + ), + ) + ); + } catch ( RuntimeException $exception ) { + $logger = $c->get( 'woocommerce.logger.woocommerce' ); + assert( $logger instanceof LoggerInterface ); + + $error = $exception->getMessage(); + if ( is_a( $exception, PayPalApiException::class ) ) { + $error = $exception->get_details( $error ); + } + + $logger->error( $error ); } } + ); - $id_token = $api->id_token( $target_customer_id ); + add_action( + 'woocommerce_add_payment_method_form_bottom', + function () { + if ( ! is_user_logged_in() || ! is_add_payment_method_page() ) { + return; + } - $settings = $c->get( 'wcgateway.settings' ); - assert( $settings instanceof Settings ); - - $verification_method = - $settings->has( '3d_secure_contingency' ) - ? apply_filters( 'woocommerce_paypal_payments_three_d_secure_contingency', $settings->get( '3d_secure_contingency' ) ) - : ''; - - $change_payment_method = wc_clean( wp_unslash( $_GET['change_payment_method'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification - - wp_localize_script( - 'ppcp-add-payment-method', - 'ppcp_add_payment_method', - array( - 'client_id' => $c->get( 'button.client_id' ), - 'merchant_id' => $c->get( 'api.merchant_id' ), - 'id_token' => $id_token, - 'payment_methods_page' => wc_get_account_endpoint_url( 'payment-methods' ), - 'view_subscriptions_page' => wc_get_account_endpoint_url( 'view-subscription' ), - 'is_subscription_change_payment_page' => $this->is_subscription_change_payment_method_page(), - 'subscription_id_to_change_payment' => $this->is_subscription_change_payment_method_page() ? (int) $change_payment_method : 0, - 'error_message' => __( 'Could not save payment method.', 'woocommerce-paypal-payments' ), - 'verification_method' => $verification_method, - 'ajax' => array( - 'create_setup_token' => array( - 'endpoint' => \WC_AJAX::get_endpoint( CreateSetupToken::ENDPOINT ), - 'nonce' => wp_create_nonce( CreateSetupToken::nonce() ), - ), - 'create_payment_token' => array( - 'endpoint' => \WC_AJAX::get_endpoint( CreatePaymentToken::ENDPOINT ), - 'nonce' => wp_create_nonce( CreatePaymentToken::nonce() ), - ), - 'subscription_change_payment_method' => array( - 'endpoint' => \WC_AJAX::get_endpoint( SubscriptionChangePaymentMethod::ENDPOINT ), - 'nonce' => wp_create_nonce( SubscriptionChangePaymentMethod::nonce() ), - ), - ), - 'labels' => array( - 'error' => array( - 'generic' => __( - 'Something went wrong. Please try again or choose another payment source.', - 'woocommerce-paypal-payments' - ), - ), - ), - ) - ); - } catch ( RuntimeException $exception ) { - $logger = $c->get( 'woocommerce.logger.woocommerce' ); - assert( $logger instanceof LoggerInterface ); - - $error = $exception->getMessage(); - if ( is_a( $exception, PayPalApiException::class ) ) { - $error = $exception->get_details( $error ); + echo '
'; } + ); - $logger->error( $error ); - } - } - ); + add_action( + 'wc_ajax_' . CreateSetupToken::ENDPOINT, + static function () use ( $c ) { + $endpoint = $c->get( 'save-payment-methods.endpoint.create-setup-token' ); + assert( $endpoint instanceof CreateSetupToken ); - add_action( - 'woocommerce_add_payment_method_form_bottom', - function () use ( $c ) { - if ( ! is_user_logged_in() || ! is_add_payment_method_page() || ! self::vault_enabled( $c ) ) { - return; - } - - echo '
'; - } - ); - - add_action( - 'wc_ajax_' . CreateSetupToken::ENDPOINT, - static function () use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - $endpoint = $c->get( 'save-payment-methods.endpoint.create-setup-token' ); - assert( $endpoint instanceof CreateSetupToken ); - - $endpoint->handle_request(); - } - ); - - add_action( - 'wc_ajax_' . CreatePaymentToken::ENDPOINT, - static function () use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - $endpoint = $c->get( 'save-payment-methods.endpoint.create-payment-token' ); - assert( $endpoint instanceof CreatePaymentToken ); - - $endpoint->handle_request(); - } - ); - - add_action( - 'wc_ajax_' . CreatePaymentTokenForGuest::ENDPOINT, - static function () use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - $endpoint = $c->get( 'save-payment-methods.endpoint.create-payment-token-for-guest' ); - assert( $endpoint instanceof CreatePaymentTokenForGuest ); - - $endpoint->handle_request(); - } - ); - - add_action( - 'woocommerce_paypal_payments_before_delete_payment_token', - function( string $token_id ) use ( $c ) { - if ( ! self::vault_enabled( $c ) ) { - return; - } - try { - $endpoint = $c->get( 'api.endpoint.payment-tokens' ); - assert( $endpoint instanceof PaymentTokensEndpoint ); - - $endpoint->delete( $token_id ); - } catch ( RuntimeException $exception ) { - $logger = $c->get( 'woocommerce.logger.woocommerce' ); - assert( $logger instanceof LoggerInterface ); - - $error = $exception->getMessage(); - if ( is_a( $exception, PayPalApiException::class ) ) { - $error = $exception->get_details( $error ); + $endpoint->handle_request(); } + ); - $logger->error( $error ); - } - } - ); + add_action( + 'wc_ajax_' . CreatePaymentToken::ENDPOINT, + static function () use ( $c ) { + $endpoint = $c->get( 'save-payment-methods.endpoint.create-payment-token' ); + assert( $endpoint instanceof CreatePaymentToken ); - add_filter( - 'woocommerce_paypal_payments_credit_card_gateway_supports', - function( array $supports ) use ( $c ): array { - if ( ! self::vault_enabled( $c ) ) { - return $supports; - } - $supports[] = 'tokenization'; - $supports[] = 'add_payment_method'; + $endpoint->handle_request(); + } + ); - return $supports; - } - ); + add_action( + 'wc_ajax_' . CreatePaymentTokenForGuest::ENDPOINT, + static function () use ( $c ) { + $endpoint = $c->get( 'save-payment-methods.endpoint.create-payment-token-for-guest' ); + assert( $endpoint instanceof CreatePaymentTokenForGuest ); - add_filter( - 'woocommerce_paypal_payments_save_payment_methods_eligible', - function( bool $value ) use ( $c ): bool { - if ( ! self::vault_enabled( $c ) ) { - return $value; - } - return true; + $endpoint->handle_request(); + } + ); + + add_action( + 'woocommerce_paypal_payments_before_delete_payment_token', + function( string $token_id ) use ( $c ) { + try { + $endpoint = $c->get( 'api.endpoint.payment-tokens' ); + assert( $endpoint instanceof PaymentTokensEndpoint ); + + $endpoint->delete( $token_id ); + } catch ( RuntimeException $exception ) { + $logger = $c->get( 'woocommerce.logger.woocommerce' ); + assert( $logger instanceof LoggerInterface ); + + $error = $exception->getMessage(); + if ( is_a( $exception, PayPalApiException::class ) ) { + $error = $exception->get_details( $error ); + } + + $logger->error( $error ); + } + } + ); + + add_filter( + 'woocommerce_paypal_payments_credit_card_gateway_supports', + function( array $supports ) use ( $c ): array { + $settings = $c->get( 'wcgateway.settings' ); + assert( $settings instanceof ContainerInterface ); + + if ( $settings->has( 'vault_enabled_dcc' ) && $settings->get( 'vault_enabled_dcc' ) ) { + $supports[] = 'tokenization'; + $supports[] = 'add_payment_method'; + } + + return $supports; + } + ); + + add_filter( + 'woocommerce_paypal_payments_save_payment_methods_eligible', + function() { + return true; + } + ); } ); @@ -502,24 +488,4 @@ class SavePaymentMethodsModule implements ServiceModule, ExtendingModule, Execut return $localized_script_data; } - - /** - * Checks whether the vault functionality is enabled based on configuration settings. - * - * @param ContainerInterface $container The dependency injection container from which settings can be retrieved. - * - * @return bool Returns true if either 'vault_enabled' or 'vault_enabled_dcc' settings are enabled; otherwise, false. - */ - private static function vault_enabled( ContainerInterface $container ): bool { - $settings = $container->get( 'wcgateway.settings' ); - assert( $settings instanceof Settings ); - - if ( - ( ! $settings->has( 'vault_enabled' ) || ! $settings->get( 'vault_enabled' ) ) - && ( ! $settings->has( 'vault_enabled_dcc' ) || ! $settings->get( 'vault_enabled_dcc' ) ) - ) { - return false; - } - return true; - } } 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/css/components/reusable-components/_payment-method-modal.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_payment-method-modal.scss index df0cde379..1efe54236 100644 --- a/modules/ppcp-settings/resources/css/components/reusable-components/_payment-method-modal.scss +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_payment-method-modal.scss @@ -119,8 +119,6 @@ background-color: $color-white; &::before { - transform: translate(3px, 3px); - border-width: 6px; border-color: $color-blueberry; } } diff --git a/modules/ppcp-settings/resources/css/components/screens/_settings.scss b/modules/ppcp-settings/resources/css/components/screens/_settings.scss index b98a5f7e1..4315d3f1a 100644 --- a/modules/ppcp-settings/resources/css/components/screens/_settings.scss +++ b/modules/ppcp-settings/resources/css/components/screens/_settings.scss @@ -11,10 +11,6 @@ } // Todo List and Feature Items -.ppcp-r-tab-overview-todo { - margin: 0 0 48px 0; -} - .ppcp-r-todo-item { position: relative; display: flex; @@ -22,6 +18,15 @@ gap: 18px; width: 100%; + &:hover { + cursor: pointer; + .ppcp-r-todo-item__inner { + .ppcp-r-todo-item__description { + color: $color-text-text; + } + } + } + &:not(:last-child) { border-bottom: 1px solid $color-gray-400; padding-bottom: 16px; @@ -66,6 +71,14 @@ @include font(13, 20, 400); color: $color-blueberry; } + + &__icon { + border: 1px dashed #949494; + background: #fff; + border-radius: 50%; + width: 24px; + height: 24px; + } } .ppcp-r-feature-item { @@ -101,6 +114,8 @@ span { font-weight: 500; } + + margin-top:24px; } } @@ -231,6 +246,10 @@ } // Settings Card and Block Styles +.ppcp-r-settings-card { + margin: 0 0 48px 0; +} + .ppcp-r-settings-card__content { > .ppcp-r-settings-block { &:not(:last-child) { 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/ReusableComponents/Icons.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons.js index 3344c3ceb..ddf3606dc 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Icons.js @@ -1 +1,3 @@ export { default as openSignup } from './Icons/open-signup'; +export const NOTIFICATION_SUCCESS = '✔️'; +export const NOTIFICATION_ERROR = '❌'; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/PricingTitleBadge.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/PricingTitleBadge.js index 33825d96b..5603d10e4 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/PricingTitleBadge.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/PricingTitleBadge.js @@ -5,26 +5,29 @@ import { countryPriceInfo } from '../../utils/countryPriceInfo'; import { formatPrice } from '../../utils/formatPrice'; import TitleBadge, { TITLE_BADGE_INFO } from './TitleBadge'; -const getFixedAmount = ( currency, priceList ) => { - if ( priceList[ currency ] ) { - return formatPrice( priceList[ currency ], currency ); +const getFixedAmount = ( currency, priceList, itemFixedAmount ) => { + if ( priceList[ currency ] ) { + const sum = priceList[ currency ] + itemFixedAmount; + return formatPrice( sum, currency ); } const [ defaultCurrency, defaultPrice ] = Object.entries( priceList )[ 0 ]; - - return formatPrice( defaultPrice, defaultCurrency ); + const sum = defaultPrice + itemFixedAmount; + return formatPrice( sum, defaultCurrency ); }; const PricingTitleBadge = ( { item } ) => { - const { storeCountry } = CommonHooks.useWooSettings(); + const { storeCountry, storeCurrency } = CommonHooks.useWooSettings(); const infos = countryPriceInfo[ storeCountry ]; + const itemKey = item.split(' ')[0]; // Extract the first word, fastlane has more than one - if ( ! infos || ! infos[ item ] ) { + if ( ! infos || ! infos[ itemKey ] ) { return null; } - const percentage = infos[ item ].toFixed( 2 ); - const fixedAmount = getFixedAmount( storeCountry, infos.fixedFee ); + const percentage = typeof infos[itemKey] === 'number' ? infos[itemKey].toFixed(2) : infos[itemKey]['percentage'].toFixed(2); + const itemFixedAmount = infos[itemKey]['fixedFee'] ? infos[itemKey]['fixedFee'] : 0; + const fixedAmount = getFixedAmount( storeCurrency, infos.fixedFee, itemFixedAmount ); const label = sprintf( __( 'from %1$s%% + %2$s', 'woocommerce-paypal-payments' ), diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/ButtonSettingsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/ButtonSettingsBlock.js index e66223ba5..eabdde934 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/ButtonSettingsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/ButtonSettingsBlock.js @@ -1,6 +1,6 @@ import { Button } from '@wordpress/components'; import SettingsBlock from './SettingsBlock'; -import { Header, Title, Action, Description } from './SettingsBlockElements'; +import { Action, Description, Header, Title } from './SettingsBlockElements'; const ButtonSettingsBlock = ( { title, description, ...props } ) => ( @@ -10,6 +10,7 @@ const ButtonSettingsBlock = ( { title, description, ...props } ) => ( diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/FeatureSettingsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/FeatureSettingsBlock.js index e2681a909..3f747bda7 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/FeatureSettingsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/FeatureSettingsBlock.js @@ -19,6 +19,28 @@ const FeatureSettingsBlock = ( { title, description, ...props } ) => { ); }; + const renderButton = ( button ) => { + const buttonElement = ( + + ); + + return button.urls ? ( + + { buttonElement } + + ) : ( + buttonElement + ); + }; + return (
@@ -35,15 +57,7 @@ const FeatureSettingsBlock = ( { title, description, ...props } ) => {
- { props.actionProps?.buttons.map( ( button ) => ( - - ) ) } + { props.actionProps?.buttons.map( renderButton ) }
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodItemBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodItemBlock.js index cdad46b3e..cf26431a3 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodItemBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodItemBlock.js @@ -1,51 +1,50 @@ -import { useState } from '@wordpress/element'; import { ToggleControl } from '@wordpress/components'; import SettingsBlock from './SettingsBlock'; import PaymentMethodIcon from '../PaymentMethodIcon'; import data from '../../../utils/data'; +import { hasSettings } from '../../Screens/Overview/TabSettingsElements/Blocks/PaymentMethods'; -const PaymentMethodItemBlock = ( props ) => { - const [ toggleIsChecked, setToggleIsChecked ] = useState( false ); - const [ modalIsVisible, setModalIsVisible ] = useState( false ); - const Modal = props?.modal; +const PaymentMethodItemBlock = ( { + id, + title, + description, + icon, + onTriggerModal, + onSelect, + isSelected, +} ) => { + // Only show settings icon if this method has fields configured + const hasModal = hasSettings( id ); return ( - <> - -
-
- - - { props.title } - -
-

- { props.description } -

-
- - { Modal && ( -
setModalIsVisible( true ) } - > - { data().getImage( 'icon-settings.svg' ) } -
- ) } -
+ +
+
+ + + { title } +
- - { Modal && modalIsVisible && ( - - ) } - +

+ { description } +

+
+ + { hasModal && onTriggerModal && ( +
+ { data().getImage( 'icon-settings.svg' ) } +
+ ) } +
+
+
); }; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodsBlock.js index 3ffb91051..17f610660 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/PaymentMethodsBlock.js @@ -2,14 +2,21 @@ import { useState, useCallback } from '@wordpress/element'; import SettingsBlock from './SettingsBlock'; import PaymentMethodItemBlock from './PaymentMethodItemBlock'; -const PaymentMethodsBlock = ( { paymentMethods, className = '' } ) => { - const [ selectedMethod, setSelectedMethod ] = useState( null ); +const PaymentMethodsBlock = ( { + paymentMethods, + className = '', + onTriggerModal, +} ) => { + const [ selectedMethods, setSelectedMethods ] = useState( {} ); const handleSelect = useCallback( ( methodId, isSelected ) => { - setSelectedMethod( isSelected ? methodId : null ); + setSelectedMethods( ( prev ) => ( { + ...prev, + [ methodId ]: isSelected, + } ) ); }, [] ); - if ( paymentMethods.length === 0 ) { + if ( ! paymentMethods?.length ) { return null; } @@ -21,10 +28,15 @@ const PaymentMethodsBlock = ( { paymentMethods, className = '' } ) => { handleSelect( paymentMethod.id, checked ) } + onTriggerModal={ () => + onTriggerModal?.( paymentMethod.id ) + } /> ) ) } diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlockElements.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlockElements.js index 296c2c2ad..ec3ba31ae 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlockElements.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/SettingsBlockElements.js @@ -33,8 +33,10 @@ export const Header = ( { children, className = '' } ) => ( ); // Card Elements -export const Content = ( { children } ) => ( -
{ children }
+export const Content = ( { children, id = '' } ) => ( +
+ { children } +
); export const ContentWrapper = ( { children } ) => ( diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/TodoSettingsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/TodoSettingsBlock.js index 4f9b01644..9ddf59196 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/TodoSettingsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlocks/TodoSettingsBlock.js @@ -1,13 +1,4 @@ -import { PayPalCheckbox, handleCheckboxState } from '../Fields'; -import data from '../../../utils/data'; - -const TodoSettingsBlock = ( { - todos, - setTodos, - todosData, - setTodosData, - className = '', -} ) => { +const TodoSettingsBlock = ( { todosData, className = '' } ) => { if ( todosData.length === 0 ) { return null; } @@ -16,54 +7,33 @@ const TodoSettingsBlock = ( {
- { todosData.map( ( todo ) => ( - - ) ) } + { todosData + .slice( 0, 5 ) + .filter( ( todo ) => { + return ! todo.isCompleted(); + } ) + .map( ( todo ) => ( + + ) ) }
); }; const TodoItem = ( props ) => { return ( -
+
- +
- { props.description } + { props.title }
-
- removeTodo( - props.value, - props.todosData, - props.changeTodos - ) - } - > - { data().getImage( 'icon-close.svg' ) } -
); }; -const removeTodo = ( todoValue, todosData, changeTodos ) => { - changeTodos( todosData.filter( ( todo ) => todo.value !== todoValue ) ); -}; - export default TodoSettingsBlock; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsCard.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsCard.js index aeb0e3561..a72253301 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsCard.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsCard.js @@ -1,6 +1,7 @@ import { Content, ContentWrapper } from './SettingsBlocks'; const SettingsCard = ( { + id, className: extraClassName, title, description, @@ -17,8 +18,10 @@ const SettingsCard = ( { if ( contentItems ) { return ( - { contentItems.map( ( item, index ) => ( - { item } + { contentItems.map( ( item ) => ( + + { item } + ) ) } ); @@ -33,7 +36,7 @@ const SettingsCard = ( { }; return ( -
+
diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/TabNavigation.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/TabNavigation.js index e22711255..e6561f56f 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/TabNavigation.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/TabNavigation.js @@ -1,4 +1,6 @@ import { useCallback, useEffect, useState } from '@wordpress/element'; + +// TODO: Migrate to Tabs (TabPanel v2) once its API is publicly available, as it provides programmatic tab switching support: https://github.com/WordPress/gutenberg/issues/52997 import { TabPanel } from '@wordpress/components'; import { getQuery, updateQueryString } from '../../utils/navigation'; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js index 89d4455e1..3f4a6ff02 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/WelcomeDocs/AcdcFlow.js @@ -53,6 +53,9 @@ const AcdcFlow = ( { isFastlane, isPayLater, storeCountry } ) => { imageBadge={ [ 'icon-payment-method-paypal-small.svg', ] } + textBadge={ + + } description={ sprintf( // translators: %s: Link to PayPal business fees guide __( 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/Modals/ModalAcdc.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalAcdc.js deleted file mode 100644 index 3d924dcbe..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalAcdc.js +++ /dev/null @@ -1,62 +0,0 @@ -import PaymentMethodModal from '../../../ReusableComponents/PaymentMethodModal'; -import { __ } from '@wordpress/i18n'; -import { Button } from '@wordpress/components'; -import { useState } from '@wordpress/element'; -import { RadioControl } from '@wordpress/components'; - -const ModalAcdc = ( { setModalIsVisible } ) => { - const [ threeDSecure, setThreeDSecure ] = useState( 'no-3d-secure' ); - const acdcOptions = [ - { - label: __( 'No 3D Secure', 'woocommerce-paypal-payments' ), - value: 'no-3d-secure', - }, - { - label: __( 'Only when required', 'woocommerce-paypal-payments' ), - value: 'only-required-3d-secure', - }, - { - label: __( - 'Always require 3D Secure', - 'woocommerce-paypal-payments' - ), - value: 'always-3d-secure', - }, - ]; - - return ( - - - { __( '3D Secure', 'woocommerce-paypal-payments' ) } - -

- { __( - 'Authenticate cardholders through their card issuers to reduce fraud and improve transaction security. Successful 3D Secure authentication can shift liability for fraudulent chargebacks to the card issuer.', - 'woocommerce-paypal-payments' - ) } -

-
- - -
- -
-
-
- ); -}; - -export default ModalAcdc; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalFastlane.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalFastlane.js deleted file mode 100644 index 466f3b57f..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalFastlane.js +++ /dev/null @@ -1,63 +0,0 @@ -import PaymentMethodModal from '../../../ReusableComponents/PaymentMethodModal'; -import { __ } from '@wordpress/i18n'; -import { Button, ToggleControl } from '@wordpress/components'; -import { PayPalRdb } from '../../../ReusableComponents/Fields'; -import { useState } from '@wordpress/element'; - -const ModalFastlane = ( { setModalIsVisible } ) => { - const [ fastlaneSettings, setFastlaneSettings ] = useState( { - cardholderName: false, - displayWatermark: false, - } ); - - const updateFormValue = ( key, value ) => { - setFastlaneSettings( { ...fastlaneSettings, [ key ]: value } ); - }; - - return ( - -
-
- - updateFormValue( 'cardholderName', newValue ) - } - label={ __( - 'Display cardholder name', - 'woocommerce-paypal-payments' - ) } - id="ppcp-r-fastlane-settings-cardholder" - /> -
-
- - updateFormValue( 'displayWatermark', newValue ) - } - label={ __( - 'Display Fastlane Watermark', - 'woocommerce-paypal-payments' - ) } - id="ppcp-r-fastlane-settings-watermark" - /> -
-
- -
-
-
- ); -}; - -export default ModalFastlane; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalPayPal.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalPayPal.js deleted file mode 100644 index 780f7d9f1..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/Modals/ModalPayPal.js +++ /dev/null @@ -1,76 +0,0 @@ -import PaymentMethodModal from '../../../ReusableComponents/PaymentMethodModal'; -import { __ } from '@wordpress/i18n'; -import { ToggleControl, Button, TextControl } from '@wordpress/components'; -import { useState } from '@wordpress/element'; - -const ModalPayPal = ( { setModalIsVisible } ) => { - const [ paypalSettings, setPaypalSettings ] = useState( { - checkoutPageTitle: 'PayPal', - checkoutPageDescription: 'Pay via PayPal', - showLogo: false, - } ); - - const updateFormValue = ( key, value ) => { - setPaypalSettings( { ...paypalSettings, [ key ]: value } ); - }; - - return ( - -
-
- - updateFormValue( 'checkoutPageTitle', newValue ) - } - /> -
-
- - updateFormValue( - 'checkoutPageDescription', - newValue - ) - } - /> -
-
-
-
- -
-
-
- ); -}; - -export default ModalPayPal; 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 102426d0a..99da335ad 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabOverview.js @@ -1,8 +1,9 @@ import { __ } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; +import { useState, useMemo } from '@wordpress/element'; import { Button, Icon } from '@wordpress/components'; import { useDispatch } from '@wordpress/data'; import { reusableBlock } from '@wordpress/icons'; +import { store as noticesStore } from '@wordpress/notices'; import SettingsCard from '../../ReusableComponents/SettingsCard'; import TodoSettingsBlock from '../../ReusableComponents/SettingsBlocks/TodoSettingsBlock'; @@ -10,39 +11,76 @@ import FeatureSettingsBlock from '../../ReusableComponents/SettingsBlocks/Featur import { TITLE_BADGE_POSITIVE } from '../../ReusableComponents/TitleBadge'; import { useMerchantInfo } from '../../../data/common/hooks'; import { STORE_NAME } from '../../../data/common'; +import Features from './TabSettingsElements/Blocks/Features'; +import { todosData } from '../../../data/settings/tab-overview-todos-data'; +import { + NOTIFICATION_ERROR, + NOTIFICATION_SUCCESS, +} from '../../ReusableComponents/Icons'; const TabOverview = () => { - const [ todos, setTodos ] = useState( [] ); - const [ todosData, setTodosData ] = useState( todosDataDefault ); const [ isRefreshing, setIsRefreshing ] = useState( false ); - const { merchant } = useMerchantInfo(); - const { refreshFeatureStatuses } = useDispatch( STORE_NAME ); + const { merchant, merchantFeatures } = useMerchantInfo(); + const { refreshFeatureStatuses, setActiveModal } = + useDispatch( STORE_NAME ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); - const features = featuresDefault.map( ( feature ) => { - const merchantFeature = merchant?.features?.[ feature.id ]; - return { - ...feature, - enabled: merchantFeature?.enabled ?? false, - }; - } ); + // Get the features data with access to setActiveModal + const featuresData = useMemo( + () => Features.getFeatures( setActiveModal ), + [ setActiveModal ] + ); + + // Map merchant features status to our config + const features = useMemo( () => { + return featuresData.map( ( feature ) => { + const merchantFeature = merchantFeatures?.[ feature.id ]; + return { + ...feature, + enabled: merchantFeature?.enabled ?? false, + }; + } ); + }, [ featuresData, merchantFeatures ] ); const refreshHandler = async () => { setIsRefreshing( true ); + try { + const result = await refreshFeatureStatuses(); + if ( result && ! result.success ) { + const errorMessage = sprintf( + /* translators: %s: error message */ + __( + 'Operation failed: %s Check WooCommerce logs for more details.', + 'woocommerce-paypal-payments' + ), + result.message || + __( 'Unknown error', 'woocommerce-paypal-payments' ) + ); - const result = await refreshFeatureStatuses(); - - // TODO: Implement the refresh logic, remove this debug code -- PCP-4024 - if ( result && ! result.success ) { - console.error( - 'Failed to refresh features:', - result.message || 'Unknown error' - ); - } else { - console.log( 'Features refreshed successfully.' ); + createErrorNotice( errorMessage, { + icon: NOTIFICATION_ERROR, + } ); + console.error( + 'Failed to refresh features:', + result.message || 'Unknown error' + ); + } else { + createSuccessNotice( + __( + 'Features refreshed successfully.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_SUCCESS, + } + ); + console.log( 'Features refreshed successfully.' ); + } + } finally { + setIsRefreshing( false ); } - - setIsRefreshing( false ); }; return ( @@ -59,12 +97,7 @@ const TabOverview = () => { 'woocommerce-paypal-payments' ) } > - + ) } @@ -72,16 +105,16 @@ const TabOverview = () => { className="ppcp-r-tab-overview-features" title={ __( 'Features', 'woocommerce-paypal-payments' ) } description={ -
+ <>

{ __( - 'Enable additional features…', + 'Enable additional features and capabilities on your WooCommerce store.', 'woocommerce-paypal-payments' ) }

{ __( - 'Click Refresh…', + 'Click Refresh to update your current features after making changes.', 'woocommerce-paypal-payments' ) }

@@ -101,204 +134,106 @@ const TabOverview = () => { 'woocommerce-paypal-payments' ) } -
+ } - contentItems={ features.map( ( feature ) => ( + contentItems={ features.map( ( feature ) => { + return ( + + ! button.showWhen || // Learn more buttons + ( feature.enabled && + button.showWhen === + 'enabled' ) || + ( ! feature.enabled && + button.showWhen === 'disabled' ) + ) + .map( ( button ) => ( { + ...button, + url: button.urls + ? merchant?.isSandbox + ? button.urls.sandbox + : button.urls.live + : button.url, + } ) ), + isBusy: isRefreshing, + enabled: feature.enabled, + notes: feature.notes, + badge: feature.enabled + ? { + text: __( + 'Active', + 'woocommerce-paypal-payments' + ), + type: TITLE_BADGE_POSITIVE, + } + : undefined, + } } + /> + ); + } ) } + /> + + - ) ) } + />, + , + ] } />
); }; -// TODO: This list should be refactored into a separate module, maybe utils/thingsToDoNext.js -const todosDataDefault = [ - { - value: 'paypal_later_messaging', - description: __( - 'Enable Pay Later messaging', - 'woocommerce-paypal-payments' - ), - }, - { - value: 'capture_authorized_payments', - description: __( - 'Capture authorized payments', - 'woocommerce-paypal-payments' - ), - }, - { - value: 'enable_google_pay', - description: __( 'Enable Google Pay', 'woocommerce-paypal-payments' ), - }, - { - value: 'paypal_shortcut', - description: __( - 'Add PayPal shortcut to the Cart page', - 'woocommerce-paypal-payments' - ), - }, - { - value: 'advanced_cards', - description: __( - 'Add Advanced Cards to Blocks Checkout', - 'woocommerce-paypal-payments' - ), - }, -]; - -// TODO: Hardcoding this list here is not the best idea. Can we move this to a REST API response? -const featuresDefault = [ - { - id: 'save_paypal_and_venmo', - title: __( 'Save PayPal and Venmo', 'woocommerce-paypal-payments' ), - description: __( - 'Securely save PayPal and Venmo payment methods for subscriptions or return buyers.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Configure', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, - { - id: 'advanced_credit_and_debit_cards', - title: __( - 'Advanced Credit and Debit Cards', - 'woocommerce-paypal-payments' - ), - description: __( - 'Process major credit and debit cards including Visa, Mastercard, American Express and Discover.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Configure', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, - { - id: 'alternative_payment_methods', - title: __( - 'Alternative Payment Methods', - 'woocommerce-paypal-payments' - ), - description: __( - 'Offer global, country-specific payment options for your customers.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Apply', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, - { - id: 'google_pay', - title: __( 'Google Pay', 'woocommerce-paypal-payments' ), - description: __( - 'Let customers pay using their Google Pay wallet.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Configure', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - notes: [ - __( '¹PayPal Q2 Earnings-2021.', 'woocommerce-paypal-payments' ), - ], - }, - { - id: 'apple_pay', - title: __( 'Apple Pay', 'woocommerce-paypal-payments' ), - description: __( - 'Let customers pay using their Apple Pay wallet.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( - 'Domain registration', - 'woocommerce-paypal-payments' - ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, - { - id: 'pay_later_messaging', - title: __( 'Pay Later Messaging', 'woocommerce-paypal-payments' ), - description: __( - 'Let customers know they can buy now and pay later with PayPal. Adding this messaging can boost conversion rates and increase cart sizes by 39%¹, with no extra cost to you—plus, you get paid up front.', - 'woocommerce-paypal-payments' - ), - buttons: [ - { - type: 'secondary', - text: __( 'Configure', 'woocommerce-paypal-payments' ), - url: '#', - }, - { - type: 'tertiary', - text: __( 'Learn more', 'woocommerce-paypal-payments' ), - url: '#', - }, - ], - }, -]; - export default TabOverview; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPaymentMethods.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPaymentMethods.js index c1576da10..63d627692 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPaymentMethods.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabPaymentMethods.js @@ -4,12 +4,12 @@ import { useMemo } from '@wordpress/element'; import SettingsCard from '../../ReusableComponents/SettingsCard'; import PaymentMethodsBlock from '../../ReusableComponents/SettingsBlocks/PaymentMethodsBlock'; import { CommonHooks } from '../../../data'; -import ModalPayPal from './Modals/ModalPayPal'; -import ModalFastlane from './Modals/ModalFastlane'; -import ModalAcdc from './Modals/ModalAcdc'; +import { useActiveModal } from '../../../data/common/hooks'; +import Modal from './TabSettingsElements/Blocks/Modal'; const TabPaymentMethods = () => { const { storeCountry, storeCurrency } = CommonHooks.useWooSettings(); + const { activeModal, setActiveModal } = useActiveModal(); const filteredPaymentMethods = useMemo( () => { const contextProps = { storeCountry, storeCurrency }; @@ -30,9 +30,24 @@ const TabPaymentMethods = () => { }; }, [ storeCountry, storeCurrency ] ); + const getActiveMethod = () => { + if ( ! activeModal ) { + return null; + } + + const allMethods = [ + ...filteredPaymentMethods.payPalCheckout, + ...filteredPaymentMethods.onlineCardPayments, + ...filteredPaymentMethods.alternative, + ]; + + return allMethods.find( ( method ) => method.id === activeModal ); + }; + return (
{ > { > { > + + { activeModal && ( + setActiveModal( null ) } + onSave={ ( methodId, settings ) => { + console.log( + 'Saving settings for:', + methodId, + settings + ); + setActiveModal( null ); + } } + /> + ) }
); }; @@ -98,7 +133,6 @@ const paymentMethodsPayPalCheckout = [ 'woocommerce-paypal-payments' ), icon: 'payment-method-paypal', - modal: ModalPayPal, }, { id: 'venmo', @@ -111,9 +145,9 @@ const paymentMethodsPayPalCheckout = [ }, { id: 'paypal_credit', - title: __( 'PayPal Credit', 'woocommerce-paypal-payments' ), + title: __( 'Pay Later', 'woocommerce-paypal-payments' ), description: __( - 'Get paid in full at checkout while giving your customers the option to pay interest free if paid within 6 months on orders over $99.', + 'Get paid in full at checkout while giving your customers the flexibility to pay in installments over time with no late fees.', 'woocommerce-paypal-payments' ), icon: 'payment-method-paypal', @@ -144,7 +178,6 @@ const paymentMethodsOnlineCardPayments = [ 'woocommerce-paypal-payments' ), icon: 'payment-method-advanced-cards', - modal: ModalAcdc, }, { id: 'fastlane', @@ -154,10 +187,9 @@ const paymentMethodsOnlineCardPayments = [ 'woocommerce-paypal-payments' ), icon: 'payment-method-fastlane', - modal: ModalFastlane, }, { - id: 'apply_pay', + id: 'apple_pay', title: __( 'Apple Pay', 'woocommerce-paypal-payments' ), description: __( 'Allow customers to pay via their Apple Pay digital wallet.', diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/ConnectionDetails.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/ConnectionDetails.js new file mode 100644 index 000000000..b1b5fcb4d --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/ConnectionDetails.js @@ -0,0 +1,41 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { + AccordionSettingsBlock, + RadioSettingsBlock, + InputSettingsBlock, +} from '../../../../ReusableComponents/SettingsBlocks'; +import { + sandboxData, + productionData, +} from '../../../../../data/settings/connection-details-data'; + +const ConnectionDetails = ( { settings, updateFormValue } ) => { + const isSandbox = settings.sandboxConnected; + + const modeConfig = isSandbox + ? productionData( { settings, updateFormValue } ) + : sandboxData( { settings, updateFormValue } ); + + const modeKey = isSandbox ? 'productionMode' : 'sandboxMode'; + + return ( + + + + ); +}; + +export default ConnectionDetails; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Features.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Features.js new file mode 100644 index 000000000..0529cba66 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Features.js @@ -0,0 +1,297 @@ +import { __ } from '@wordpress/i18n'; +import { TAB_IDS, selectTab } from '../../../../../utils/tabSelector'; + +const Features = { + getFeatures: ( setActiveModal ) => [ + { + id: 'save_paypal_and_venmo', + title: __( 'Save PayPal and Venmo', 'woocommerce-paypal-payments' ), + description: __( + 'Securely save PayPal and Venmo payment methods for subscriptions or return buyers.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-paypal-checkout-card' + ).then( () => { + setActiveModal( 'paypal' ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/bizsignup/entry?product=ADVANCED_VAULTING', + live: 'https://www.paypal.com/bizsignup/entry?product=ADVANCED_VAULTING', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + { + id: 'advanced_credit_and_debit_cards', + title: __( + 'Advanced Credit and Debit Cards', + 'woocommerce-paypal-payments' + ), + description: __( + 'Process major credit and debit cards including Visa, Mastercard, American Express and Discover.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-card-payments-card' + ).then( () => { + setActiveModal( + 'advanced_credit_and_debit_card_payments' + ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/bizsignup/entry?product=ppcp', + live: 'https://www.paypal.com/bizsignup/entry?product=ppcp', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + { + id: 'alternative_payment_methods', + title: __( + 'Alternative Payment Methods', + 'woocommerce-paypal-payments' + ), + description: __( + 'Offer global, country-specific payment options for your customers.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-alternative-payments-card' + ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + { + id: 'google_pay', + title: __( 'Google Pay', 'woocommerce-paypal-payments' ), + description: __( + 'Let customers pay using their Google Pay wallet.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-card-payments-card' + ).then( () => { + setActiveModal( 'google_pay' ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/bizsignup/add-product?product=payment_methods&capabilities=GOOGLE_PAY', + live: 'https://www.paypal.com/bizsignup/add-product?product=payment_methods&capabilities=GOOGLE_PAY', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + notes: [ + __( + '¹PayPal Q2 Earnings-2021.', + 'woocommerce-paypal-payments' + ), + ], + }, + { + id: 'apple_pay', + title: __( 'Apple Pay', 'woocommerce-paypal-payments' ), + description: __( + 'Let customers pay using their Apple Pay wallet.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-card-payments-card' + ).then( () => { + setActiveModal( 'apple_pay' ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( + 'Domain registration', + 'woocommerce-paypal-payments' + ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/uccservicing/apm/applepay', + live: 'https://www.paypal.com/uccservicing/apm/applepay', + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: + 'https://www.sandbox.paypal.com/bizsignup/add-product?product=payment_methods&capabilities=APPLE_PAY', + live: 'https://www.paypal.com/bizsignup/add-product?product=payment_methods&capabilities=APPLE_PAY', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + { + id: 'pay_later_messaging', + title: __( 'Pay Later Messaging', 'woocommerce-paypal-payments' ), + description: __( + 'Let customers know they can buy now and pay later with PayPal. Adding this messaging can boost conversion rates and increase cart sizes by 39%¹, with no extra cost to you—plus, you get paid up front.', + 'woocommerce-paypal-payments' + ), + buttons: [ + { + type: 'secondary', + text: __( 'Configure', 'woocommerce-paypal-payments' ), + onClick: () => { + selectTab( + TAB_IDS.PAYMENT_METHODS, + 'ppcp-paypal-checkout-card' + ).then( () => { + setActiveModal( 'paypal' ); + } ); + }, + showWhen: 'enabled', + class: 'small-button', + }, + { + type: 'secondary', + text: __( 'Apply', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + showWhen: 'disabled', + class: 'small-button', + }, + { + type: 'tertiary', + text: __( 'Learn more', 'woocommerce-paypal-payments' ), + urls: { + sandbox: '#', + live: '#', + }, + class: 'small-button', + }, + ], + }, + ], +}; + +export default Features; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Modal.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Modal.js new file mode 100644 index 000000000..c8be1c761 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Modal.js @@ -0,0 +1,131 @@ +import { __ } from '@wordpress/i18n'; +import { + Button, + TextControl, + ToggleControl, + RadioControl, +} from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import PaymentMethodModal from '../../../../ReusableComponents/PaymentMethodModal'; +import { getPaymentMethods } from './PaymentMethods'; + +const Modal = ( { method, setModalIsVisible, onSave } ) => { + const [ settings, setSettings ] = useState( () => { + if ( ! method?.id ) { + return {}; + } + + const methodConfig = getPaymentMethods( method ); + if ( ! methodConfig?.fields ) { + return {}; + } + + const initialSettings = {}; + Object.entries( methodConfig.fields ).forEach( ( [ key, field ] ) => { + initialSettings[ key ] = field.default; + } ); + return initialSettings; + } ); + + if ( ! method?.id ) { + return null; + } + + const methodConfig = getPaymentMethods( method ); + if ( ! methodConfig?.fields ) { + return null; + } + + const renderField = ( key, field ) => { + switch ( field.type ) { + case 'text': + return ( +
+ + setSettings( ( prev ) => ( { + ...prev, + [ key ]: value, + } ) ) + } + /> +
+ ); + + case 'toggle': + return ( +
+ + setSettings( ( prev ) => ( { + ...prev, + [ key ]: value, + } ) ) + } + /> +
+ ); + + case 'radio': + return ( + <> + + { field.label } + + { field.description && ( +

+ { field.description } +

+ ) } +
+ + setSettings( ( prev ) => ( { + ...prev, + [ key ]: value, + } ) ) + } + /> +
+ + ); + + default: + return null; + } + }; + + const handleSave = () => { + onSave?.( method.id, settings ); + setModalIsVisible( false ); + }; + + return ( + +
+ { Object.entries( methodConfig.fields ).map( + ( [ key, field ] ) => renderField( key, field ) + ) } + +
+ +
+
+
+ ); +}; + +export default Modal; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/PaymentMethods.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/PaymentMethods.js new file mode 100644 index 000000000..21ae3f028 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/PaymentMethods.js @@ -0,0 +1,179 @@ +import { __, sprintf } from '@wordpress/i18n'; + +const createStandardFields = ( methodId, defaultTitle ) => ( { + checkoutPageTitle: { + type: 'text', + default: defaultTitle, + label: __( 'Checkout page title', 'woocommerce-paypal-payments' ), + }, + checkoutPageDescription: { + type: 'text', + default: sprintf( + /* translators: %s: payment method title */ + __( 'Pay with %s', 'woocommerce-paypal-payments' ), + defaultTitle + ), + label: __( 'Checkout page description', 'woocommerce-paypal-payments' ), + }, +} ); + +const paymentMethods = { + // PayPal Checkout methods + paypal: { + fields: { + ...createStandardFields( 'paypal', 'PayPal' ), + showLogo: { + type: 'toggle', + default: false, + label: __( 'Show logo', 'woocommerce-paypal-payments' ), + }, + }, + }, + venmo: { + fields: createStandardFields( 'venmo', 'Venmo' ), + }, + paypal_credit: { + fields: createStandardFields( 'paypal_credit', 'PayPal Credit' ), + }, + credit_and_debit_card_payments: { + fields: createStandardFields( + 'credit_and_debit_card_payments', + __( + 'Credit and debit card payments', + 'woocommerce-paypal-payments' + ) + ), + }, + + // Online Card Payments + advanced_credit_and_debit_card_payments: { + fields: { + ...createStandardFields( + 'advanced_credit_and_debit_card_payments', + __( + 'Advanced Credit and Debit Card Payments', + 'woocommerce-paypal-payments' + ) + ), + threeDSecure: { + type: 'radio', + default: 'no-3d-secure', + label: __( '3D Secure', 'woocommerce-paypal-payments' ), + description: __( + 'Authenticate cardholders through their card issuers to reduce fraud and improve transaction security. Successful 3D Secure authentication can shift liability for fraudulent chargebacks to the card issuer.', + 'woocommerce-paypal-payments' + ), + options: [ + { + label: __( + 'No 3D Secure', + 'woocommerce-paypal-payments' + ), + value: 'no-3d-secure', + }, + { + label: __( + 'Only when required', + 'woocommerce-paypal-payments' + ), + value: 'only-required-3d-secure', + }, + { + label: __( + 'Always require 3D Secure', + 'woocommerce-paypal-payments' + ), + value: 'always-3d-secure', + }, + ], + }, + }, + }, + fastlane: { + fields: { + ...createStandardFields( 'fastlane', 'Fastlane by PayPal' ), + cardholderName: { + type: 'toggle', + default: false, + label: __( + 'Display cardholder name', + 'woocommerce-paypal-payments' + ), + }, + displayWatermark: { + type: 'toggle', + default: false, + label: __( + 'Display Fastlane Watermark', + 'woocommerce-paypal-payments' + ), + }, + }, + }, + + // Digital Wallets + apple_pay: { + fields: createStandardFields( 'apple_pay', 'Apple Pay' ), + }, + google_pay: { + fields: createStandardFields( 'google_pay', 'Google Pay' ), + }, + + // Alternative Payment Methods + bancontact: { + fields: createStandardFields( 'bancontact', 'Bancontact' ), + }, + ideal: { + fields: createStandardFields( 'ideal', 'iDEAL' ), + }, + eps: { + fields: createStandardFields( 'eps', 'eps' ), + }, + blik: { + fields: createStandardFields( 'blik', 'BLIK' ), + }, + mybank: { + fields: createStandardFields( 'mybank', 'MyBank' ), + }, + przelewy24: { + fields: createStandardFields( 'przelewy24', 'Przelewy24' ), + }, + trustly: { + fields: createStandardFields( 'trustly', 'Trustly' ), + }, + multibanco: { + fields: createStandardFields( 'multibanco', 'Multibanco' ), + }, + pui: { + fields: createStandardFields( 'pui', 'Pay upon Invoice' ), + }, + oxxo: { + fields: createStandardFields( 'oxxo', 'OXXO' ), + }, +}; + +// Function to get configuration for a payment method +export const getPaymentMethods = ( method ) => { + if ( ! method?.id ) { + return null; + } + + // If method has specific config, return it + if ( paymentMethods[ method.id ] ) { + return { + ...paymentMethods[ method.id ], + icon: method.icon, + }; + } + + // Return standard config for new payment methods + return { + fields: createStandardFields( method.id, method.title ), + icon: method.icon, + }; +}; + +// Function to check if a method has settings defined +export const hasSettings = ( methodId ) => { + return Boolean( methodId && paymentMethods[ methodId ] ); +}; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Sandbox.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Sandbox.js deleted file mode 100644 index 93a4a7d0d..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Sandbox.js +++ /dev/null @@ -1,202 +0,0 @@ -import { __, sprintf } from '@wordpress/i18n'; -import { Button } from '@wordpress/components'; -import { - AccordionSettingsBlock, - ButtonSettingsBlock, - RadioSettingsBlock, - ToggleSettingsBlock, - InputSettingsBlock, -} from '../../../../ReusableComponents/SettingsBlocks'; -import TitleBadge, { - TITLE_BADGE_POSITIVE, -} from '../../../../ReusableComponents/TitleBadge'; -import ConnectionInfo, { - connectionStatusDataDefault, -} from '../../../../ReusableComponents/ConnectionInfo'; - -const Sandbox = ( { settings, updateFormValue } ) => { - const className = settings.sandboxConnected - ? 'ppcp-r-settings-block--sandbox-connected' - : 'ppcp-r-settings-block--sandbox-disconnected'; - - return ( - - { settings.sandboxConnected && ( - - } - > -
- - - -
-
- ) } - { ! settings.sandboxConnected && ( - - updateFormValue( - 'sandboxConnected', - true - ) - } - > - { __( - 'Connect Sandbox Account', - 'woocommerce-paypal-payments' - ) } - - ), - }, - { - id: 'manual_connect', - value: 'manual_connect', - label: __( - 'Manual Connect', - 'woocommerce-paypal-payments' - ), - description: sprintf( - __( - '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' - ), - '#' - ), - additionalContent: ( - <> - - - - - ), - }, - ] } - actionProps={ { - name: 'paypal_connect_sandbox', - key: 'sandboxMode', - currentValue: settings.sandboxMode, - callback: updateFormValue, - } } - /> - ) } -
- ); -}; - -export default Sandbox; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting.js deleted file mode 100644 index 82cd635dc..000000000 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting.js +++ /dev/null @@ -1,168 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { - Header, - Title, - Description, - AccordionSettingsBlock, - ToggleSettingsBlock, - ButtonSettingsBlock, -} from '../../../../ReusableComponents/SettingsBlocks'; -import SettingsBlock from '../../../../ReusableComponents/SettingsBlocks/SettingsBlock'; - -const Troubleshooting = ( { updateFormValue, settings } ) => { - return ( - - - -
- - { __( - 'Subscribed PayPal webhooks', - 'woocommerce-paypal-payments' - ) } - - - { __( - 'The following PayPal webhooks are subscribed. More information about the webhooks is available in the', - 'woocommerce-paypal-payments' - ) }{ ' ' } - - { __( - 'Webhook Status documentation', - 'woocommerce-paypal-payments' - ) } - - . - -
- -
- - - console.log( - 'Resubscribe webhooks', - 'woocommerce-paypal-payments' - ), - value: __( - 'Resubscribe webhooks', - 'woocommerce-paypal-payments' - ), - } } - /> - - - console.log( - 'Simulate webhooks', - 'woocommerce-paypal-payments' - ), - value: __( - 'Simulate webhooks', - 'woocommerce-paypal-payments' - ), - } } - /> -
- ); -}; - -const hooksExampleData = () => { - return { - url: 'https://www.rt3.tech/wordpress/paypal-ux-testin/index.php?rest_route=/paypal/v1/incoming', - hooks: [ - 'billing plan pricing-change activated', - 'billing plan updated', - 'billing subscription cancelled', - 'catalog product updated', - 'checkout order approved', - 'checkout order completed', - 'checkout payment-approval reversed', - 'payment authorization voided', - 'payment capture completed', - 'payment capture denied', - 'payment capture pending', - 'payment capture refunded', - 'payment capture reversed', - 'payment order cancelled', - 'payment sale completed', - 'payment sale refunded', - 'vault payment-token created', - 'vault payment-token deleted', - ], - }; -}; - -const HooksTable = ( { data } ) => { - return ( - - - - - - - - - - - - - -
- { __( 'URL', 'woocommerce-paypal-payments' ) } - - { __( - 'Tracked events', - 'woocommerce-paypal-payments' - ) } -
{ data?.url } - { data.hooks.map( ( hook, index ) => ( - - { hook }{ ' ' } - { index !== data.hooks.length - 1 && ',' } - - ) ) } -
- ); -}; - -export default Troubleshooting; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/HooksTableBlock.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/HooksTableBlock.js new file mode 100644 index 000000000..856f1969d --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/HooksTableBlock.js @@ -0,0 +1,47 @@ +import { __ } from '@wordpress/i18n'; +import { CommonHooks } from '../../../../../../data'; +import { Title } from '../../../../../ReusableComponents/SettingsBlocks'; + +const HooksTableBlock = () => { + const { webhooks } = CommonHooks.useWebhooks(); + const { url, events } = webhooks; + + if ( ! url || ! events?.length ) { + return
...
; + } + + return ( + <> + + + + ); +}; + +const WebhookUrl = ( { url } ) => { + return ( +
+ + { __( 'Notification URL', 'woocommerce-paypal-payments' ) } + +

{ url }

+
+ ); +}; + +const WebhookEvents = ( { events } ) => { + return ( +
+ + { __( 'Subscribed Events', 'woocommerce-paypal-payments' ) } + +
    + { events.map( ( event, index ) => ( +
  • { event }
  • + ) ) } +
+
+ ); +}; + +export default HooksTableBlock; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/ResubscribeBlock.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/ResubscribeBlock.js new file mode 100644 index 000000000..373ec57c4 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/ResubscribeBlock.js @@ -0,0 +1,72 @@ +import { useState } from '@wordpress/element'; +import { STORE_NAME } from '../../../../../../data/common'; +import { ButtonSettingsBlock } from '../../../../../ReusableComponents/SettingsBlocks'; +import { __ } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +import { + NOTIFICATION_ERROR, + NOTIFICATION_SUCCESS, +} from '../../../../../ReusableComponents/Icons'; + +const ResubscribeBlock = () => { + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const [ resubscribing, setResubscribing ] = useState( false ); + + const { resubscribeWebhooks } = useDispatch( STORE_NAME ); + + const startResubscribingWebhooks = async () => { + setResubscribing( true ); + try { + await resubscribeWebhooks(); + } catch ( error ) { + setResubscribing( false ); + createErrorNotice( + __( + 'Operation failed. Check WooCommerce logs for more details.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_ERROR, + } + ); + return; + } + + setResubscribing( false ); + createSuccessNotice( + __( + 'Webhooks were successfully re-subscribed.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_SUCCESS, + } + ); + }; + + return ( + startResubscribingWebhooks(), + value: __( + 'Resubscribe webhooks', + 'woocommerce-paypal-payments' + ), + } } + /> + ); +}; + +export default ResubscribeBlock; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/SimulationBlock.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/SimulationBlock.js new file mode 100644 index 000000000..2ae430e76 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/SimulationBlock.js @@ -0,0 +1,129 @@ +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { ButtonSettingsBlock } from '../../../../../ReusableComponents/SettingsBlocks'; +import { useDispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +import { CommonHooks } from '../../../../../../data'; +import { + NOTIFICATION_ERROR, + NOTIFICATION_SUCCESS, +} from '../../../../../ReusableComponents/Icons'; + +const SimulationBlock = () => { + const { + createSuccessNotice, + createInfoNotice, + createErrorNotice, + removeNotice, + } = useDispatch( noticesStore ); + const { startWebhookSimulation, checkWebhookSimulationState } = + CommonHooks.useWebhooks(); + const [ simulating, setSimulating ] = useState( false ); + const sleep = ( ms ) => { + return new Promise( ( resolve ) => setTimeout( resolve, ms ) ); + }; + const startSimulation = async ( maxRetries ) => { + const webhookInfoNoticeId = 'paypal-webhook-simulation-info-notice'; + const triggerWebhookInfoNotice = () => { + createInfoNotice( + __( + 'Waiting for the webhook to arrive…', + 'woocommerce-paypal-payments' + ), + { + id: webhookInfoNoticeId, + } + ); + }; + + const stopSimulation = () => { + removeNotice( webhookInfoNoticeId ); + setSimulating( false ); + }; + + setSimulating( true ); + + triggerWebhookInfoNotice(); + + try { + await startWebhookSimulation(); + } catch ( error ) { + console.error( error ); + setSimulating( false ); + createErrorNotice( + __( + 'Operation failed. Check WooCommerce logs for more details.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_ERROR, + } + ); + return; + } + + for ( let i = 0; i < maxRetries; i++ ) { + await sleep( 2000 ); + + const simulationStateResponse = await checkWebhookSimulationState(); + try { + if ( ! simulationStateResponse.success ) { + console.error( + 'Simulation state query failed: ' + + simulationStateResponse?.data + ); + continue; + } + + if ( simulationStateResponse?.data?.state === 'received' ) { + createSuccessNotice( + __( + 'The webhook was received successfully.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_SUCCESS, + } + ); + stopSimulation(); + return; + } + removeNotice( webhookInfoNoticeId ); + triggerWebhookInfoNotice(); + } catch ( error ) { + console.error( error ); + } + } + stopSimulation(); + createErrorNotice( + __( + 'Looks like the webhook cannot be received. Check that your website is accessible from the internet.', + 'woocommerce-paypal-payments' + ), + { + icon: NOTIFICATION_ERROR, + } + ); + }; + + return ( + <> + startSimulation( 30 ), + value: __( + 'Simulate webhooks', + 'woocommerce-paypal-payments' + ), + } } + /> + + ); +}; +export default SimulationBlock; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/Troubleshooting.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/Troubleshooting.js new file mode 100644 index 000000000..4dc0d1fc6 --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/Blocks/Troubleshooting/Troubleshooting.js @@ -0,0 +1,69 @@ +import { __ } from '@wordpress/i18n'; +import { + AccordionSettingsBlock, + Description, + Header, + Title, + ToggleSettingsBlock, +} from '../../../../../ReusableComponents/SettingsBlocks'; +import SettingsBlock from '../../../../../ReusableComponents/SettingsBlocks/SettingsBlock'; + +import SimulationBlock from './SimulationBlock'; +import ResubscribeBlock from './ResubscribeBlock'; +import HooksTableBlock from './HooksTableBlock'; + +const Troubleshooting = ( { updateFormValue, settings } ) => { + return ( + + + +
+ + { __( 'Webhooks', 'woocommerce-paypal-payments' ) } + + + { __( + 'The following PayPal webhooks are subscribed. More information about the webhooks is available in the', + 'woocommerce-paypal-payments' + ) }{ ' ' } + + { __( + 'Webhook Status documentation', + 'woocommerce-paypal-payments' + ) } + + . + +
+ + + +
+
+ ); +}; + +export default Troubleshooting; diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ExpertSettings.js b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ExpertSettings.js index 56a8e63c6..0bc2914f3 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ExpertSettings.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Overview/TabSettingsElements/ExpertSettings.js @@ -4,8 +4,8 @@ import { Content, ContentWrapper, } from '../../../ReusableComponents/SettingsBlocks'; -import Sandbox from './Blocks/Sandbox'; -import Troubleshooting from './Blocks/Troubleshooting'; +import ConnectionDetails from './Blocks/ConnectionDetails'; +import Troubleshooting from './Blocks/Troubleshooting/Troubleshooting'; import PaypalSettings from './Blocks/PaypalSettings'; import OtherSettings from './Blocks/OtherSettings'; @@ -27,7 +27,7 @@ const ExpertSettings = ( { updateFormValue, settings } ) => { > - 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 ac08cdcf7..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,9 +19,13 @@ 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: + 'COMMON: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 ccbf34ce0..c859fe0be 100644 --- a/modules/ppcp-settings/resources/js/data/common/actions.js +++ b/modules/ppcp-settings/resources/js/data/common/actions.js @@ -7,7 +7,7 @@ * @file */ -import { dispatch, select } from '@wordpress/data'; +import { select } from '@wordpress/data'; import ACTION_TYPES from './action-types'; import { STORE_NAME } from './constants'; @@ -47,6 +47,17 @@ export const setIsReady = ( isReady ) => ( { payload: { isReady }, } ); +/** + * Transient. Sets the active settings tab. + * + * @param {string} activeModal + * @return {Action} The action. + */ +export const setActiveModal = ( activeModal ) => ( { + type: ACTION_TYPES.SET_TRANSIENT, + payload: { activeModal }, +} ); + /** * Transient. Changes the "saving" flag. * @@ -112,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. * @@ -150,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' ], + }; }; /** @@ -160,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. * @@ -214,3 +245,48 @@ export const refreshFeatureStatuses = function* () { return result; }; + +/** + * Persistent. Changes the "webhooks" value. + * + * @param {string} webhooks + * @return {Action} The action. + */ +export const setWebhooks = ( webhooks ) => ( { + type: ACTION_TYPES.SET_PERSISTENT, + payload: { webhooks }, +} ); + +/** + * Side effect + * Refreshes subscribed webhooks via a REST request + * + * @return {Action} The action. + */ +export const resubscribeWebhooks = function* () { + const result = yield { type: ACTION_TYPES.DO_RESUBSCRIBE_WEBHOOKS }; + + if ( result && result.success ) { + yield hydrate( result ); + } + + return result; +}; + +/** + * Side effect. Starts webhook simulation. + * + * @return {Action} The action. + */ +export const startWebhookSimulation = function* () { + return yield { type: ACTION_TYPES.DO_START_WEBHOOK_SIMULATION }; +}; + +/** + * Side effect. Checks webhook simulation. + * + * @return {Action} The action. + */ +export const checkWebhookSimulationState = function* () { + return yield { type: ACTION_TYPES.DO_CHECK_WEBHOOK_SIMULATION_STATE }; +}; diff --git a/modules/ppcp-settings/resources/js/data/common/constants.js b/modules/ppcp-settings/resources/js/data/common/constants.js index c67b1fef0..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. @@ -54,6 +65,26 @@ export const REST_MANUAL_CONNECTION_PATH = '/wc/v3/wc_paypal/connect_manual'; */ export const REST_CONNECTION_URL_PATH = '/wc/v3/wc_paypal/login_link'; +/** + * REST path to fetch webhooks data or resubscribe webhooks, + * + * Used by: Controls + * See: WebhookSettingsEndpoint.php + * + * @type {string} + */ +export const REST_WEBHOOKS = '/wc/v3/wc_paypal/webhook_settings'; + +/** + * REST path to start webhook simulation and observe the state, + * + * Used by: Controls + * See: WebhookSettingsEndpoint.php + * + * @type {string} + */ +export const REST_WEBHOOKS_SIMULATE = '/wc/v3/wc_paypal/webhook_simulate'; + /** * REST path to refresh the feature status. * diff --git a/modules/ppcp-settings/resources/js/data/common/controls.js b/modules/ppcp-settings/resources/js/data/common/controls.js index a088660b9..62d4a8f84 100644 --- a/modules/ppcp-settings/resources/js/data/common/controls.js +++ b/modules/ppcp-settings/resources/js/data/common/controls.js @@ -11,10 +11,13 @@ import apiFetch from '@wordpress/api-fetch'; import { REST_PERSIST_PATH, - REST_MANUAL_CONNECTION_PATH, + REST_DIRECT_AUTHENTICATION_PATH, REST_CONNECTION_URL_PATH, REST_HYDRATE_MERCHANT_PATH, REST_REFRESH_FEATURES_PATH, + REST_ISU_AUTHENTICATION_PATH, + REST_WEBHOOKS, + REST_WEBHOOKS_SIMULATE, } from './constants'; import ACTION_TYPES from './action-types'; @@ -31,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 { @@ -49,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, @@ -90,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 } ); @@ -115,4 +123,24 @@ export const controls = { }; } }, + + async [ ACTION_TYPES.DO_RESUBSCRIBE_WEBHOOKS ]() { + return await apiFetch( { + method: 'POST', + path: REST_WEBHOOKS, + } ); + }, + + async [ ACTION_TYPES.DO_START_WEBHOOK_SIMULATION ]() { + return await apiFetch( { + method: 'POST', + path: REST_WEBHOOKS_SIMULATE, + } ); + }, + + async [ ACTION_TYPES.DO_CHECK_WEBHOOK_SIMULATION_STATE ]() { + return await apiFetch( { + path: REST_WEBHOOKS_SIMULATE, + } ); + }, }; diff --git a/modules/ppcp-settings/resources/js/data/common/hooks.js b/modules/ppcp-settings/resources/js/data/common/hooks.js index 8eaaa3924..4aea08caa 100644 --- a/modules/ppcp-settings/resources/js/data/common/hooks.js +++ b/modules/ppcp-settings/resources/js/data/common/hooks.js @@ -9,7 +9,6 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { useCallback } from '@wordpress/element'; - import { STORE_NAME } from './constants'; const useTransient = ( key ) => @@ -29,30 +28,40 @@ const useHooks = () => { persist, setSandboxMode, setManualConnectionMode, - setClientId, - setClientSecret, - connectToSandbox, - connectToProduction, - connectViaIdAndSecret, + sandboxOnboardingUrl, + productionOnboardingUrl, + authenticateWithCredentials, + authenticateWithOAuth, + setActiveModal, + startWebhookSimulation, + checkWebhookSimulationState, } = useDispatch( STORE_NAME ); // Transient accessors. const isReady = useTransient( 'isReady' ); + const activeModal = useTransient( 'activeModal' ); // Persistent accessors. - const clientId = usePersistent( 'clientId' ); - const clientSecret = usePersistent( 'clientSecret' ); const isSandboxMode = usePersistent( 'useSandbox' ); const isManualConnectionMode = usePersistent( 'useManualConnection' ); - 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 ); @@ -61,6 +70,8 @@ const useHooks = () => { return { isReady, + activeModal, + setActiveModal, isSandboxMode, setSandboxMode: ( state ) => { return savePersistent( setSandboxMode, state ); @@ -69,53 +80,44 @@ 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, }; }; 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, }; }; @@ -125,8 +127,24 @@ export const useWooSettings = () => { return wooSettings; }; +export const useWebhooks = () => { + const { + webhooks, + setWebhooks, + registerWebhooks, + startWebhookSimulation, + checkWebhookSimulationState, + } = useHooks(); + return { + webhooks, + setWebhooks, + registerWebhooks, + startWebhookSimulation, + checkWebhookSimulationState, + }; +}; export const useMerchantInfo = () => { - const { merchant } = useHooks(); + const { merchant, features } = useHooks(); const { refreshMerchantData } = useDispatch( STORE_NAME ); const verifyLoginStatus = useCallback( async () => { @@ -142,10 +160,16 @@ export const useMerchantInfo = () => { return { merchant, // Merchant details + features, // Eligible merchant features verifyLoginStatus, // Callback }; }; +export const useActiveModal = () => { + const { activeModal, setActiveModal } = useHooks(); + return { activeModal, setActiveModal }; +}; + // -- Not using the `useHooks()` data provider -- export const useBusyState = () => { diff --git a/modules/ppcp-settings/resources/js/data/common/reducer.js b/modules/ppcp-settings/resources/js/data/common/reducer.js index 7d3f5697f..e9ff90a42 100644 --- a/modules/ppcp-settings/resources/js/data/common/reducer.js +++ b/modules/ppcp-settings/resources/js/data/common/reducer.js @@ -15,6 +15,7 @@ import ACTION_TYPES from './action-types'; const defaultTransient = Object.freeze( { isReady: false, activities: new Map(), + activeModal: '', // Read only values, provided by the server via hydrate. merchant: Object.freeze( { @@ -22,19 +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: '', } ); // Reducer logic. @@ -82,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/resolvers.js b/modules/ppcp-settings/resources/js/data/common/resolvers.js index ceebca53f..2c1f0d3c2 100644 --- a/modules/ppcp-settings/resources/js/data/common/resolvers.js +++ b/modules/ppcp-settings/resources/js/data/common/resolvers.js @@ -12,7 +12,7 @@ import { dispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { apiFetch } from '@wordpress/data-controls'; -import { STORE_NAME, REST_HYDRATE_PATH } from './constants'; +import { STORE_NAME, REST_HYDRATE_PATH, REST_WEBHOOKS } from './constants'; export const resolvers = { /** @@ -21,6 +21,11 @@ export const resolvers = { *persistentData() { try { const result = yield apiFetch( { path: REST_HYDRATE_PATH } ); + const webhooks = yield apiFetch( { path: REST_WEBHOOKS } ); + + if ( webhooks.success && webhooks.data ) { + result.webhooks = webhooks.data; + } yield dispatch( STORE_NAME ).hydrate( result ); yield dispatch( STORE_NAME ).setIsReady( true ); diff --git a/modules/ppcp-settings/resources/js/data/common/selectors.js b/modules/ppcp-settings/resources/js/data/common/selectors.js index fde5d8c9e..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,14 @@ 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; }; + +export const webhooks = ( state ) => { + return getState( state ).webhooks || 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/data/settings/connection-details-data.js b/modules/ppcp-settings/resources/js/data/settings/connection-details-data.js new file mode 100644 index 000000000..65eeb62c2 --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings/connection-details-data.js @@ -0,0 +1,178 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { InputSettingsBlock } from '../../Components/ReusableComponents/SettingsBlocks'; +import { Button } from '@wordpress/components'; + +/** + * Generates options for the environment mode settings. + * + * @param {Object} config - Configuration for the mode. + * @param {Object} settings - Current settings. + * @param {Function} updateFormValue - Callback to update settings. + * @return {Array} Options array. + */ +const generateOptions = ( config, settings, updateFormValue ) => [ + { + id: `${ config.mode }_mode`, + value: `${ config.mode }_mode`, + label: config.labelTitle, + description: config.labelDescription, + additionalContent: ( + + ), + }, + { + id: 'manual_connect', + value: 'manual_connect', + label: __( 'Manual Connect', 'woocommerce-paypal-payments' ), + description: sprintf( + __( + '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' + ), + '#' + ), + additionalContent: ( + <> + + + + + ), + }, +]; + +/** + * Generates data for a given mode (sandbox or production). + * + * @param {Object} config - Configuration for the mode. + * @param {Object} settings - Current settings. + * @param {Function} updateFormValue - Callback to update settings. + * @return {Object} Mode configuration. + */ +const generateModeData = ( config, settings, updateFormValue ) => ( { + title: config.title, + description: config.description, + connectTitle: __( + `Connect ${ config.label } Account`, + 'woocommerce-paypal-payments' + ), + connectDescription: config.connectDescription, + options: generateOptions( config, settings, updateFormValue ), +} ); + +export const sandboxData = ( { settings = {}, updateFormValue = () => {} } ) => + generateModeData( + { + mode: 'sandbox', + label: 'Sandbox', + title: __( 'Sandbox', 'woocommerce-paypal-payments' ), + description: __( + "Test your site in PayPal's Sandbox environment.", + 'woocommerce-paypal-payments' + ), + connectDescription: __( + 'Connect a PayPal Sandbox account in order to test your website. Transactions made will not result in actual money movement. Do not fulfil orders completed in Sandbox mode.', + 'woocommerce-paypal-payments' + ), + labelTitle: __( 'Sandbox Mode', 'woocommerce-paypal-payments' ), + labelDescription: __( + 'Activate Sandbox mode to safely test PayPal with sample data. Once your store is ready to go live, you can easily switch to your production account.', + 'woocommerce-paypal-payments' + ), + buttonText: __( + 'Connect Sandbox Account', + 'woocommerce-paypal-payments' + ), + clientIdTitle: __( + 'Sandbox Client ID', + 'woocommerce-paypal-payments' + ), + secretKeyTitle: __( + 'Sandbox Secret Key', + 'woocommerce-paypal-payments' + ), + }, + settings, + updateFormValue + ); + +export const productionData = ( { + settings = {}, + updateFormValue = () => {}, +} ) => + generateModeData( + { + mode: 'production', + label: 'Live', + title: __( 'Live Payments', 'woocommerce-paypal-payments' ), + description: __( + 'Your site is currently configured in Sandbox mode to test payments. When you are ready, launch your site and receive live payments via PayPal.', + 'woocommerce-paypal-payments' + ), + connectDescription: __( + 'Connect a live PayPal account to launch your site and receive live payments via PayPal. PayPal will guide you through the setup process.', + 'woocommerce-paypal-payments' + ), + labelTitle: __( 'Production Mode', 'woocommerce-paypal-payments' ), + labelDescription: __( + 'Activate Production mode to connect your live account and receive live payments via PayPal. Stay connected in Sandbox mode to continue testing payments before going live.', + 'woocommerce-paypal-payments' + ), + buttonText: __( + 'Set up and connect live PayPal Account', + 'woocommerce-paypal-payments' + ), + clientIdTitle: __( + 'Live Account Client ID', + 'woocommerce-paypal-payments' + ), + secretKeyTitle: __( + 'Live Account Secret Key', + 'woocommerce-paypal-payments' + ), + }, + settings, + updateFormValue + ); diff --git a/modules/ppcp-settings/resources/js/data/settings/tab-overview-todos-data.js b/modules/ppcp-settings/resources/js/data/settings/tab-overview-todos-data.js new file mode 100644 index 000000000..818f7180f --- /dev/null +++ b/modules/ppcp-settings/resources/js/data/settings/tab-overview-todos-data.js @@ -0,0 +1,170 @@ +import { __ } from '@wordpress/i18n'; +import { selectTab, TAB_IDS } from '../../utils/tabSelector'; + +export const todosData = [ + { + id: 'enable_fastlane', + title: __( 'Enable Fastlane', 'woocommerce-paypal-payments' ), + description: __( + 'Accelerate your guest checkout with Fastlane by PayPal.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.PAYMENT_METHODS, 'ppcp-card-payments-card' ); + }, + }, + { + id: 'enable_credit_debit_cards', + title: __( + 'Enable Credit and Debit Cards on your checkout', + 'woocommerce-paypal-payments' + ), + description: __( + 'Credit and Debit Cards is now available for Blocks checkout pages.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.PAYMENT_METHODS, 'ppcp-card-payments-card' ); + }, + }, + { + id: 'enable_pay_later_messaging', + title: __( + 'Enable Pay Later messaging', + 'woocommerce-paypal-payments' + ), + description: __( + 'Show Pay Later messaging to boost conversion rate and increase cart size.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.OVERVIEW, 'pay_later_messaging' ); + }, + }, + { + id: 'configure_paypal_subscription', + title: __( + 'Configure a PayPal Subscription', + 'woocommerce-paypal-payments' + ), + description: __( + 'Connect a subscriptions-type product from WooCommerce with PayPal.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + console.log( + 'Take merchant to product list, filtered with subscription-type products' + ); + }, + }, + { + id: 'register_domain_apple_pay', + title: __( + 'Register Domain for Apple Pay', + 'woocommerce-paypal-payments' + ), + description: __( + 'To enable Apple Pay, you must register your domain with PayPal.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.OVERVIEW, 'apple_pay' ); + }, + }, + { + id: 'add_digital_wallets_to_account', + title: __( + 'Add digital wallets to your account', + 'woocommerce-paypal-payments' + ), + description: __( + 'Add the ability to accept Apple Pay & Google Pay to your PayPal account.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + console.log( + 'Take merchant to PayPal to enable Apple Pay & Google Pay' + ); + }, + }, + { + id: 'add_apple_pay_to_account', + title: __( + 'Add Apple Pay to your account', + 'woocommerce-paypal-payments' + ), + description: __( + 'Add the ability to accept Apple Pay to your PayPal account.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + console.log( 'Take merchant to PayPal to enable Apple Pay' ); + }, + }, + { + id: 'add_google_pay_to_account', + title: __( + 'Add Google Pay to your account', + 'woocommerce-paypal-payments' + ), + description: __( + 'Add the ability to accept Google Pay to your PayPal account.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + console.log( 'Take merchant to PayPal to enable Google Pay' ); + }, + }, + { + id: 'enable_apple_pay', + title: __( 'Enable Apple Pay', 'woocommerce-paypal-payments' ), + description: __( + 'Allow your buyers to check out via Apple Pay.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.OVERVIEW, 'apple_pay' ); + }, + }, + { + id: 'enable_google_pay', + title: __( 'Enable Google Pay', 'woocommerce-paypal-payments' ), + description: __( + 'Allow your buyers to check out via Google Pay.', + 'woocommerce-paypal-payments' + ), + isCompleted: () => { + return false; + }, + onClick: () => { + selectTab( TAB_IDS.OVERVIEW, 'google_pay' ); + }, + }, +]; 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/countryPriceInfo.js b/modules/ppcp-settings/resources/js/utils/countryPriceInfo.js index 17504453b..c5cf52a3e 100644 --- a/modules/ppcp-settings/resources/js/utils/countryPriceInfo.js +++ b/modules/ppcp-settings/resources/js/utils/countryPriceInfo.js @@ -2,81 +2,139 @@ export const countryPriceInfo = { US: { fixedFee: { USD: 0.49, + GBP: 0.39, + CAD: 0.59, + AUD: 0.59, + EUR: 0.39, }, checkout: 3.49, - ccf: 2.59, - dw: 2.59, - apm: 2.59, - fastlane: 2.59, + plater: 4.99, + ccf: { + percentage: 2.59, + fixedFee: 0.29, + }, + dw: { + percentage: 2.59, + fixedFee: 0.29, + }, + apm: { + percentage: 2.89, + fixedFee: 0.29, + }, + fast: { + percentage: 2.59, + fixedFee: 0.29, + }, standardCardFields: 2.99, }, UK: { fixedFee: { GPB: 0.3, + USD: 0.3, + CAD: 0.3, + AUD: 0.3, + EUR: 0.35, }, checkout: 2.9, + plater: 2.9, ccf: 1.2, dw: 1.2, + fast: 1.2, apm: 1.2, standardCardFields: 1.2, }, CA: { fixedFee: { CAD: 0.3, + USD: 0.3, + GBP: 0.2, + AUD: 0.3, + EUR: 0.35, }, checkout: 2.9, ccf: 2.7, dw: 2.7, + fast: 2.7, apm: 2.9, standardCardFields: 2.9, }, AU: { fixedFee: { AUD: 0.3, + USD: 0.3, + GBP: 0.2, + CAD: 0.3, + EUR: 0.35, }, checkout: 2.6, + plater: 2.6, ccf: 1.75, dw: 1.75, + fast: 1.75, apm: 2.6, standardCardFields: 2.6, }, FR: { fixedFee: { EUR: 0.35, + USD: 0.3, + GBP: 0.3, + CAD: 0.3, + AUD: 0.3, }, checkout: 2.9, + plater: 2.9, ccf: 1.2, dw: 1.2, + fast: 1.2, apm: 1.2, standardCardFields: 1.2, }, IT: { fixedFee: { - EUR: 0.35, + EUR: 0.35, + USD: 0.3, + GBP: 0.3, + CAD: 0.3, + AUD: 0.3, }, checkout: 3.4, + plater: 3.4, ccf: 1.2, dw: 1.2, + fast: 1.2, apm: 1.2, standardCardFields: 1.2, }, DE: { fixedFee: { EUR: 0.39, + USD: 0.49, + GBP: 0.29, + CAD: 0.59, + AUD: 0.59, }, checkout: 2.99, + plater: 2.99, ccf: 2.99, dw: 2.99, + fast: 2.99, apm: 2.99, standardCardFields: 2.99, }, ES: { fixedFee: { - EUR: 0.35, + EUR: 0.35, + USD: 0.3, + GBP: 0.3, + CAD: 0.3, + AUD: 0.3, }, checkout: 2.9, + plater: 2.9, ccf: 1.2, dw: 1.2, + fast: 1.2, apm: 1.2, standardCardFields: 1.2, }, diff --git a/modules/ppcp-settings/resources/js/utils/tabSelector.js b/modules/ppcp-settings/resources/js/utils/tabSelector.js new file mode 100644 index 000000000..c8b1c5350 --- /dev/null +++ b/modules/ppcp-settings/resources/js/utils/tabSelector.js @@ -0,0 +1,59 @@ +// Tab panel IDs +export const TAB_IDS = { + OVERVIEW: 'tab-panel-0-overview', + PAYMENT_METHODS: 'tab-panel-0-payment-methods', + SETTINGS: 'tab-panel-0-settings', + STYLING: 'tab-panel-0-styling', +}; + +/** + * Select a tab by simulating a click event and scroll to specified element, + * accounting for navigation container height + * + * TODO: Once the TabPanel gets migrated to Tabs (TabPanel v2) we need to remove this in favor of programmatic tab switching: https://github.com/WordPress/gutenberg/issues/52997 + * + * @param {string} tabId - The ID of the tab to select + * @param {string} [scrollToId] - Optional ID of the element to scroll to + * @return {Promise} - Resolves when tab switch and scroll are complete + */ +export const selectTab = ( tabId, scrollToId ) => { + return new Promise( ( resolve ) => { + const tab = document.getElementById( tabId ); + if ( tab ) { + tab.click(); + setTimeout( () => { + const scrollTarget = scrollToId + ? document.getElementById( scrollToId ) + : document.getElementById( 'ppcp-settings-container' ); + + if ( scrollTarget ) { + const navContainer = document.querySelector( + '.ppcp-r-navigation-container' + ); + const navHeight = navContainer + ? navContainer.offsetHeight + : 0; + + // Get the current scroll position and element's position relative to viewport + const rect = scrollTarget.getBoundingClientRect(); + + // Calculate the final position with offset + const scrollPosition = + rect.top + window.scrollY - ( navHeight + 55 ); + + window.scrollTo( { + top: scrollPosition, + behavior: 'smooth', + } ); + + // Resolve after scroll animation + setTimeout( resolve, 300 ); + } else { + resolve(); + } + }, 100 ); + } else { + resolve(); + } + } ); +}; 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 9629fd8dd..6fc9d67e3 100644 --- a/modules/ppcp-settings/services.php +++ b/modules/ppcp-settings/services.php @@ -10,19 +10,20 @@ declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Settings; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; -use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings; +use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint; 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 { @@ -57,10 +58,7 @@ return array( ); }, 'settings.data.general' => static function ( ContainerInterface $container ) : GeneralSettings { - return new GeneralSettings(); - }, - 'settings.data.common' => static function ( ContainerInterface $container ) : CommonSettings { - return new CommonSettings( + return new GeneralSettings( $container->get( 'api.shop.country' ), $container->get( 'api.shop.currency.getter' )->get(), $container->get( 'wcgateway.is-send-only-country' ) @@ -70,7 +68,7 @@ return array( return new OnboardingRestEndpoint( $container->get( 'settings.data.onboarding' ) ); }, 'settings.rest.common' => static function ( ContainerInterface $container ) : CommonRestEndpoint { - return new CommonRestEndpoint( $container->get( 'settings.data.common' ) ); + return new CommonRestEndpoint( $container->get( 'settings.data.general' ) ); }, 'settings.rest.refresh_feature_status' => static function ( ContainerInterface $container ) : RefreshFeatureStatusEndpoint { return new RefreshFeatureStatusEndpoint( @@ -79,17 +77,21 @@ 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 { + return new WebhookSettingsEndpoint( + $container->get( 'api.endpoint.webhook' ), + $container->get( 'webhook.registrar' ), + $container->get( 'webhook.status.simulation' ) ); }, 'settings.casual-selling.supported-countries' => static function ( ContainerInterface $container ) : array { @@ -153,8 +155,9 @@ 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( 'http.redirector' ), $container->get( 'woocommerce.logger.woocommerce' ) ); }, @@ -167,33 +170,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.general' ), + $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 deleted file mode 100644 index cee01f881..000000000 --- a/modules/ppcp-settings/src/Data/CommonSettings.php +++ /dev/null @@ -1,209 +0,0 @@ -woo_settings['country'] = $country; - $this->woo_settings['currency'] = $currency; - $this->data['is_current_country_send_only'] = $is_current_country_send_only; - } - - /** - * Get default values for the model. - * - * @return array - */ - protected function get_defaults() : array { - return array( - 'use_sandbox' => false, - 'use_manual_connection' => false, - 'client_id' => '', - 'client_secret' => '', - - // Details about connected merchant account. - 'merchant_connected' => false, - 'sandbox_merchant' => false, - 'merchant_id' => '', - 'merchant_email' => '', - ); - } - - // ----- - - /** - * Gets the 'use sandbox' setting. - * - * @return bool - */ - public function get_sandbox() : bool { - return (bool) $this->data['use_sandbox']; - } - - /** - * Sets the 'use sandbox' setting. - * - * @param bool $use_sandbox Whether to use sandbox mode. - */ - public function set_sandbox( bool $use_sandbox ) : void { - $this->data['use_sandbox'] = $use_sandbox; - } - - /** - * Gets the 'use manual connection' setting. - * - * @return bool - */ - public function get_manual_connection() : bool { - return (bool) $this->data['use_manual_connection']; - } - - /** - * Sets the 'use manual connection' setting. - * - * @param bool $use_manual_connection Whether to use manual connection. - */ - public function set_manual_connection( bool $use_manual_connection ) : void { - $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. - * - * @return array - */ - public function get_woo_settings() : array { - return $this->woo_settings; - } - - /** - * 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. - * - * @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; - } - - /** - * Whether the currently connected merchant is a sandbox account. - * - * @return bool - */ - public function is_sandbox_merchant() : bool { - return $this->data['sandbox_merchant']; - } - - /** - * Whether the merchant successfully logged into their PayPal account. - * - * @return bool - */ - public function is_merchant_connected() : bool { - return $this->data['merchant_connected'] && $this->data['merchant_id'] && $this->data['merchant_email']; - } - - /** - * Gets the currently connected merchant ID. - * - * @return string - */ - public function get_merchant_id() : string { - return $this->data['merchant_id']; - } - - /** - * Gets the currently connected merchant's email. - * - * @return string - */ - public function get_merchant_email() : string { - return $this->data['merchant_email']; - } -} diff --git a/modules/ppcp-settings/src/Data/GeneralSettings.php b/modules/ppcp-settings/src/Data/GeneralSettings.php index 8fe7ee872..8970c6c05 100644 --- a/modules/ppcp-settings/src/Data/GeneralSettings.php +++ b/modules/ppcp-settings/src/Data/GeneralSettings.php @@ -1,6 +1,6 @@ woo_settings['country'] = $country; + $this->woo_settings['currency'] = $currency; + + $this->data['is_current_country_send_only'] = $is_send_only_country; + $this->data['merchant_connected'] = $this->is_merchant_connected(); + } /** * Get default values for the model. @@ -31,161 +64,150 @@ class GeneralSettings extends AbstractDataModel { */ protected function get_defaults() : array { return array( - 'is_sandbox' => false, - 'live_client_id' => '', - 'live_client_secret' => '', - 'live_merchant_id' => '', - 'live_merchant_email' => '', - 'sandbox_client_id' => '', - 'sandbox_client_secret' => '', - 'sandbox_merchant_id' => '', - 'sandbox_merchant_email' => '', + 'use_sandbox' => false, // UI state, not a connection detail. + 'use_manual_connection' => false, // UI state, not a connection detail. + 'is_current_country_send_only' => false, // Read-only flag. + + // Details about connected merchant account. + 'merchant_connected' => false, + 'sandbox_merchant' => false, + 'merchant_id' => '', + 'merchant_email' => '', + 'client_id' => '', + 'client_secret' => '', ); } // ----- /** - * Gets the 'is_sandbox' flag. - */ - public function is_sandbox() : bool { - return (bool) $this->data['is_sandbox']; - } - - /** - * Sets the 'is_sandbox' flag. + * Gets the 'use sandbox' setting. * - * @param bool $value The value to set. + * @return bool */ - public function set_is_sandbox( bool $value ) : void { - $this->data['is_sandbox'] = $value; + public function get_sandbox() : bool { + return (bool) $this->data['use_sandbox']; } /** - * Gets the live client ID. - */ - public function live_client_id() : string { - return $this->data['live_client_id']; - } - - /** - * Sets the live client ID. + * Sets the 'use sandbox' setting. * - * @param string $value The value to set. + * @param bool $use_sandbox Whether to use sandbox mode. */ - public function set_live_client_id( string $value ) : void { - $this->data['live_client_id'] = sanitize_text_field( $value ); + public function set_sandbox( bool $use_sandbox ) : void { + $this->data['use_sandbox'] = $use_sandbox; } /** - * Gets the live client secret. - */ - public function live_client_secret() : string { - return $this->data['live_client_secret']; - } - - /** - * Sets the live client secret. + * Gets the 'use manual connection' setting. * - * @param string $value The value to set. + * @return bool */ - public function set_live_client_secret( string $value ) : void { - $this->data['live_client_secret'] = sanitize_text_field( $value ); + public function get_manual_connection() : bool { + return (bool) $this->data['use_manual_connection']; } /** - * Gets the live merchant ID. - */ - public function live_merchant_id() : string { - return $this->data['live_merchant_id']; - } - - /** - * Sets the live merchant ID. + * Sets the 'use manual connection' setting. * - * @param string $value The value to set. + * @param bool $use_manual_connection Whether to use manual connection. */ - public function set_live_merchant_id( string $value ) : void { - $this->data['live_merchant_id'] = sanitize_text_field( $value ); + public function set_manual_connection( bool $use_manual_connection ) : void { + $this->data['use_manual_connection'] = $use_manual_connection; } /** - * Gets the live merchant email. - */ - public function live_merchant_email() : string { - return $this->data['live_merchant_email']; - } - - /** - * Sets the live merchant email. + * Returns the list of read-only customization flags. * - * @param string $value The value to set. + * @return array */ - public function set_live_merchant_email( string $value ) : void { - $this->data['live_merchant_email'] = sanitize_email( $value ); + public function get_woo_settings() : array { + return $this->woo_settings; } /** - * Gets the sandbox client ID. - */ - public function sandbox_client_id() : string { - return $this->data['sandbox_client_id']; - } - - /** - * Sets the sandbox client ID. + * Setter to update details of the connected merchant account. * - * @param string $value The value to set. - */ - public function set_sandbox_client_id( string $value ) : void { - $this->data['sandbox_client_id'] = sanitize_text_field( $value ); - } - - /** - * Gets the sandbox client secret. - */ - public function sandbox_client_secret() : string { - return $this->data['sandbox_client_secret']; - } - - /** - * Sets the sandbox client secret. + * @param MerchantConnectionDTO $connection Connection details. * - * @param string $value The value to set. + * @return void */ - public function set_sandbox_client_secret( string $value ) : void { - $this->data['sandbox_client_secret'] = sanitize_text_field( $value ); + 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(); } /** - * Gets the sandbox merchant ID. - */ - public function sandbox_merchant_id() : string { - return $this->data['sandbox_merchant_id']; - } - - /** - * Sets the sandbox merchant ID. + * Returns the full merchant connection DTO for the current connection. * - * @param string $value The value to set. + * @return MerchantConnectionDTO All connection details. */ - public function set_sandbox_merchant_id( string $value ) : void { - $this->data['sandbox_merchant_id'] = sanitize_text_field( $value ); + 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'] + ); } /** - * Gets the sandbox merchant email. - */ - public function sandbox_merchant_email() : string { - return $this->data['sandbox_merchant_email']; - } - - /** - * Sets the sandbox merchant email. + * Reset all connection details to the initial, disconnected state. * - * @param string $value The value to set. + * @return void */ - public function set_sandbox_merchant_email( string $value ) : void { - $this->data['sandbox_merchant_email'] = sanitize_email( $value ); + 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; + } + + /** + * Whether the currently connected merchant is a sandbox account. + * + * @return bool + */ + public function is_sandbox_merchant() : bool { + return $this->data['sandbox_merchant']; + } + + /** + * Whether the merchant successfully logged into their PayPal account. + * + * @return bool + */ + public function is_merchant_connected() : bool { + return $this->data['merchant_email'] + && $this->data['merchant_id'] + && $this->data['client_id'] + && $this->data['client_secret']; + } + + /** + * Gets the currently connected merchant ID. + * + * @return string + */ + public function get_merchant_id() : string { + return $this->data['merchant_id']; + } + + /** + * Gets the currently connected merchant's email. + * + * @return string + */ + public function get_merchant_email() : string { + return $this->data['merchant_email']; } } 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 178814e8d..589e0ea01 100644 --- a/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php +++ b/modules/ppcp-settings/src/Endpoint/CommonRestEndpoint.php @@ -12,7 +12,7 @@ namespace WooCommerce\PayPalCommerce\Settings\Endpoint; use WP_REST_Server; use WP_REST_Response; use WP_REST_Request; -use WooCommerce\PayPalCommerce\Settings\Data\CommonSettings; +use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings; /** * REST controller for "common" settings, which are used and modified by @@ -32,9 +32,9 @@ class CommonRestEndpoint extends RestEndpoint { /** * The settings instance. * - * @var CommonSettings + * @var GeneralSettings */ - protected CommonSettings $settings; + protected GeneralSettings $settings; /** * Field mapping for request to profile transformation. @@ -50,13 +50,8 @@ 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', ), ); @@ -78,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', + ), 'is_current_country_send_only' => array( 'js_name' => 'isCurrentCountrySendOnly', ), @@ -100,9 +101,9 @@ class CommonRestEndpoint extends RestEndpoint { /** * Constructor. * - * @param CommonSettings $settings The settings instance. + * @param GeneralSettings $settings The settings instance. */ - public function __construct( CommonSettings $settings ) { + public function __construct( GeneralSettings $settings ) { $this->settings = $settings; } @@ -114,11 +115,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 +125,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 +135,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' ), ) ); } @@ -209,10 +204,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(), + ); + } return $extra_data; } 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 new file mode 100644 index 000000000..df227264a --- /dev/null +++ b/modules/ppcp-settings/src/Endpoint/WebhookSettingsEndpoint.php @@ -0,0 +1,205 @@ +webhook_endpoint = $webhook_endpoint; + $this->webhook_registrar = $webhook_registrar; + $this->webhook_simulation = $webhook_simulation; + } + + /** + * Configure REST API routes. + */ + public function register_routes() : void { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_webhooks' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'resubscribe_webhooks' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_simulate_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'check_simulated_webhook_state' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'simulate_webhooks_start' ), + 'permission_callback' => array( $this, 'check_permission' ), + ), + ) + ); + } + + /** + * Returns a webhook endpoint URL and list of subscribed webhooks + * + * @return WP_REST_Response + */ + public function get_webhooks() : WP_REST_Response { + $webhooks = $this->get_webhook_data(); + if ( ! $webhooks ) { + return $this->return_error( 'No webhooks found.' ); + } + + try { + $webhook_url = $webhooks->url(); + $webhook_events = array_map( + static fn( stdClass $webhooks ) => strtolower( $webhooks->name ), + $webhooks->event_types() + ); + } catch ( Throwable $error ) { + return $this->return_error( $error->getMessage() ); + } + + return $this->return_success( + array( + 'url' => $webhook_url, + 'events' => $webhook_events, + ) + ); + } + + /** + * Re-subscribes webhooks and returns webhooks + * + * @return WP_REST_Response + */ + public function resubscribe_webhooks() : WP_REST_Response { + if ( ! $this->webhook_registrar->register() ) { + return $this->return_error( 'Webhook subscription failed.' ); + } + + return $this->get_webhooks(); + } + + /** + * Starts webhook simulation + * + * @return WP_REST_Response + */ + public function simulate_webhooks_start() : WP_REST_Response { + try { + $this->webhook_simulation->start(); + + return $this->return_success( array() ); + } catch ( \Exception $error ) { + return $this->return_error( $error->getMessage() ); + } + } + + /** + * Checks webhook simulation state + * + * @return WP_REST_Response + */ + public function check_simulated_webhook_state() : WP_REST_Response { + try { + $state = $this->webhook_simulation->get_state(); + + return $this->return_success( + array( 'state' => $state ) + ); + + } catch ( \Exception $error ) { + return $this->return_error( $error->getMessage() ); + } + } + + + /** + * Retrieves the Webhooks API response object. + * + * @return Webhook|null The webhook data instance, or null. + */ + private function get_webhook_data() : ?Webhook { + try { + $api_response = $this->webhook_endpoint->list(); + + return $api_response[0] ?? null; + } catch ( Throwable $error ) { + return null; + } + } +} diff --git a/modules/ppcp-settings/src/Handler/ConnectionListener.php b/modules/ppcp-settings/src/Handler/ConnectionListener.php index a24a82231..11e946317 100644 --- a/modules/ppcp-settings/src/Handler/ConnectionListener.php +++ b/modules/ppcp-settings/src/Handler/ConnectionListener.php @@ -10,10 +10,13 @@ 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; +use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; +use WooCommerce\PayPalCommerce\Http\RedirectorInterface; /** * Provides a listener that handles merchant-connection requests. @@ -31,13 +34,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 +41,21 @@ class ConnectionListener { */ private OnboardingUrlManager $url_manager; + /** + * Authentication manager service, responsible to update connection details. + * + * @var AuthenticationManager + */ + private AuthenticationManager $authentication_manager; + + /** + * A redirector-instance to redirect the merchant after authentication. + * ™ + * + * @var RedirectorInterface + */ + private RedirectorInterface $redirector; + /** * Logger instance, mainly used for debugging purposes. * @@ -62,16 +73,24 @@ 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 RedirectorInterface $redirector Redirect-handler. + * @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, + RedirectorInterface $redirector, + LoggerInterface $logger = null + ) { + $this->settings_page_id = $settings_page_id; + $this->url_manager = $url_manager; + $this->authentication_manager = $authentication_manager; + $this->redirector = $redirector; + $this->logger = $logger ?: new NullLogger(); // Initialize as "guest", the real ID is provided via process(). $this->user_id = 0; @@ -82,6 +101,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 +121,15 @@ 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() ); + } + + $this->redirect_after_authentication(); } /** @@ -117,7 +140,7 @@ class ConnectionListener { * * @return bool True, if the request contains valid connection details. */ - protected function is_valid_request( array $request ) : bool { + private function is_valid_request( array $request ) : bool { if ( $this->user_id < 1 || ! $this->settings_page_id ) { return false; } @@ -149,7 +172,7 @@ class ConnectionListener { * @return array Structured array with 'is_sandbox', 'merchant_id', and 'merchant_email' keys, * or an empty array on failure. */ - protected function extract_data( array $request ) : array { + private function extract_data( array $request ) : array { $this->logger->info( 'Extracting connection data from request...' ); $merchant_id = $this->get_merchant_id_from_request( $request ); @@ -160,24 +183,20 @@ 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. + * Redirects the browser page at the end of the authentication flow. * - * @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. + * @return void */ - 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)" ); + private function redirect_after_authentication() : void { + $redirect_url = $this->get_onboarding_redirect_url(); - $this->settings->set_merchant_data( $is_sandbox, $merchant_id, $merchant_email ); - $this->settings->save(); + $this->redirector->redirect( $redirect_url ); } /** @@ -187,7 +206,7 @@ class ConnectionListener { * * @return string The sanitized token, or an empty string. */ - protected function get_token_from_request( array $request ) : string { + private function get_token_from_request( array $request ) : string { return $this->sanitize_string( $request['ppcpToken'] ?? '' ); } @@ -198,7 +217,7 @@ class ConnectionListener { * * @return string The sanitized merchant ID, or an empty string. */ - protected function get_merchant_id_from_request( array $request ) : string { + private function get_merchant_id_from_request( array $request ) : string { return $this->sanitize_string( $request['merchantIdInPayPal'] ?? '' ); } @@ -213,7 +232,7 @@ class ConnectionListener { * * @return string The sanitized merchant email, or an empty string. */ - protected function get_merchant_email_from_request( array $request ) : string { + private function get_merchant_email_from_request( array $request ) : string { return $this->sanitize_merchant_email( $request['merchantId'] ?? '' ); } @@ -224,7 +243,7 @@ class ConnectionListener { * * @return string Sanitized value. */ - protected function sanitize_string( string $value ) : string { + private function sanitize_string( string $value ) : string { return trim( sanitize_text_field( wp_unslash( $value ) ) ); } @@ -235,7 +254,22 @@ class ConnectionListener { * * @return string Sanitized email address. */ - protected function sanitize_merchant_email( string $email ) : string { + private function sanitize_merchant_email( string $email ) : string { return sanitize_text_field( str_replace( ' ', '+', $email ) ); } + + /** + * Returns the URL opened at the end of onboarding. + * + * @return string + */ + private function get_onboarding_redirect_url() : string { + /** + * The URL opened at the end of onboarding after saving the merchant ID/email. + */ + return apply_filters( + 'woocommerce_paypal_payments_onboarding_redirect_url', + admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=ppcp-gateway&ppcp-tab=' . Settings::CONNECTION_TAB_ID ) + ); + } } diff --git a/modules/ppcp-settings/src/Service/AuthenticationManager.php b/modules/ppcp-settings/src/Service/AuthenticationManager.php new file mode 100644 index 000000000..96d7f3bc1 --- /dev/null +++ b/modules/ppcp-settings/src/Service/AuthenticationManager.php @@ -0,0 +1,429 @@ + + */ + 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 GeneralSettings $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( + GeneralSettings $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' ); + + /** + * Request to flush caches after disconnecting the merchant. While there + * is no need for it here, it's good house-keeping practice to clean up. + */ + do_action( 'woocommerce_paypal_payments_flush_api_cache' ); + } + + /** + * 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' ); + + /** + * Request to flush caches before authenticating the merchant, to + * ensure the new merchant does not use stale data from previous + * connections. + */ + do_action( 'woocommerce_paypal_payments_flush_api_cache' ); + + /** + * 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' ); + + /** + * Subscribe the new merchant to relevant PayPal webhooks. + */ + do_action( WebhookRegistrar::EVENT_HOOK ); + } + } +} 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 f2d7267e0..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( @@ -181,6 +182,7 @@ class SettingsModule implements ServiceModule, ExecutableModule { $container->get( 'settings.rest.common' ), $container->get( 'settings.rest.connect_manual' ), $container->get( 'settings.rest.login_link' ), + $container->get( 'settings.rest.webhooks' ), $container->get( 'settings.rest.refresh_feature_status' ), ); @@ -202,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/Gateway/CreditCardGateway.php b/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php index 5082bd86f..c461c3611 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php @@ -466,7 +466,7 @@ class CreditCardGateway extends \WC_Payment_Gateway_CC { continue; } - $custom_id = $wc_order->get_order_number(); + $custom_id = (string) $wc_order->get_id(); $invoice_id = $this->prefix . $wc_order->get_order_number(); $create_order = $this->capture_card_payment->create_order( $token->get_token(), $custom_id, $invoice_id, $wc_order ); 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/Processor/CreditCardOrderInfoHandlingTrait.php b/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php index 51a3a741c..c730136ab 100644 --- a/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php +++ b/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php @@ -96,52 +96,35 @@ trait CreditCardOrderInfoHandlingTrait { return; } - $fraud_responses = $fraud->to_array(); $card_brand = $payment_source->properties()->brand ?? __( 'N/A', 'woocommerce-paypal-payments' ); $card_last_digits = $payment_source->properties()->last_digits ?? __( 'N/A', 'woocommerce-paypal-payments' ); - $avs_response_order_note_title = __( 'Address Verification Result', 'woocommerce-paypal-payments' ); + $response_order_note_title = __( 'PayPal Advanced Card Processing Verification:', 'woocommerce-paypal-payments' ); /* translators: %1$s is AVS order note title, %2$s is AVS order note result markup */ - $avs_response_order_note_format = __( '%1$s %2$s', 'woocommerce-paypal-payments' ); - $avs_response_order_note_result_format = '
    -
  • %1$s
  • -
      -
    • %2$s
    • -
    • %3$s
    • -
    -
  • %4$s
  • -
  • %5$s
  • -
'; - $avs_response_order_note_result = sprintf( - $avs_response_order_note_result_format, - /* translators: %s is fraud AVS code */ - sprintf( __( 'AVS: %s', 'woocommerce-paypal-payments' ), esc_html( $fraud_responses['avs_code'] ) ), - /* translators: %s is fraud AVS address match */ - sprintf( __( 'Address Match: %s', 'woocommerce-paypal-payments' ), esc_html( $fraud_responses['address_match'] ) ), - /* translators: %s is fraud AVS postal match */ - sprintf( __( 'Postal Match: %s', 'woocommerce-paypal-payments' ), esc_html( $fraud_responses['postal_match'] ) ), - /* translators: %s is card brand */ - sprintf( __( 'Card Brand: %s', 'woocommerce-paypal-payments' ), esc_html( $card_brand ) ), - /* translators: %s card last digits */ - sprintf( __( 'Card Last Digits: %s', 'woocommerce-paypal-payments' ), esc_html( $card_last_digits ) ) + $response_order_note_format = __( '%1$s %2$s', 'woocommerce-paypal-payments' ); + $response_order_note_result_format = '
    +
  • %1$s
  • +
  • %2$s
  • +
  • %3$s
  • +
'; + $response_order_note_result = sprintf( + $response_order_note_result_format, + /* translators: %1$s is card brand and %2$s card last 4 digits */ + sprintf( __( 'Card: %1$s (%2$s)', 'woocommerce-paypal-payments' ), $card_brand, $card_last_digits ), + /* translators: %s is fraud AVS message */ + sprintf( __( 'AVS: %s', 'woocommerce-paypal-payments' ), $fraud->get_avs_code_message() ), + /* translators: %s is fraud CVV message */ + sprintf( __( 'CVV: %s', 'woocommerce-paypal-payments' ), $fraud->get_cvv2_code_message() ), ); - $avs_response_order_note = sprintf( - $avs_response_order_note_format, - esc_html( $avs_response_order_note_title ), - wp_kses_post( $avs_response_order_note_result ) + $response_order_note = sprintf( + $response_order_note_format, + esc_html( $response_order_note_title ), + wp_kses_post( $response_order_note_result ) ); - $wc_order->add_order_note( $avs_response_order_note ); - - $cvv_response_order_note_format = '
  • %1$s
'; - $cvv_response_order_note = sprintf( - $cvv_response_order_note_format, - /* translators: %s is fraud CVV match */ - sprintf( __( 'CVV2 Match: %s', 'woocommerce-paypal-payments' ), esc_html( $fraud_responses['cvv_match'] ) ) - ); - $wc_order->add_order_note( $cvv_response_order_note ); + $wc_order->add_order_note( $response_order_note ); $meta_details = array_merge( - $fraud_responses, + $fraud->to_array(), array( 'card_brand' => $card_brand, 'card_last_digits' => $card_last_digits, 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; } ); diff --git a/package.json b/package.json index 6dc952728..cf4a58b7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-paypal-payments", - "version": "2.9.5", + "version": "2.9.6", "description": "WooCommerce PayPal Payments", "repository": "https://github.com/woocommerce/woocommerce-paypal-payments", "license": "GPL-2.0", diff --git a/readme.txt b/readme.txt index e11d40263..fc2caf2ed 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ === WooCommerce PayPal Payments === Contributors: woocommerce, automattic, syde Tags: woocommerce, paypal, payments, ecommerce, credit card -Requires at least: 6.3 +Requires at least: 6.5 Tested up to: 6.7 Requires PHP: 7.4 -Stable tag: 2.9.5 +Stable tag: 2.9.6 License: GPLv2 License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -179,23 +179,35 @@ If you encounter issues with the PayPal buttons not appearing after an update, p == Changelog == += 2.9.6 - 2025-01-06 = +* Fix - NOT_ENABLED_TO_VAULT_PAYMENT_SOURCE on PayPal transactions when using ACDC Vaulting without PayPal Vault approval #2955 +* Fix - Express buttons for Free Trial Subscription products on Block Cart/Checkout trigger CANNOT_BE_ZERO_OR_NEGATIVE error #2872 +* Fix - String translations not applied to Card Fields on Block Checkout #2934 +* Fix - Fastlane component included in script when Fastlane is disabled #2911 +* Fix - Zero amount line items may trigger CANNOT_BE_ZERO_OR_NEGATIVE error after rounding error #2906 +* Fix - “Save changes” is grey and unclickable when switching from Sandbox to Live #2895 +* Fix - plugin queries variations when button/messaging is disabled on single product page #2896 +* Fix - Use get_id instead of get_order_number on setting custom_id (author @0verscore) #2930 +* Enhancement - Improve fraud response order notes for Advanced Card Processing transactions #2905 +* Tweak - Update the minimum plugin requirements to WordPress 6.5 & WooCommerce 9.2 #2920 + = 2.9.5 - 2024-12-10 = -Fix - Early translation loading triggers `Function _load_textdomain_just_in_time was called incorrectly.` notice #2816 -Fix - ACDC card fields not loading and payment not successful when Classic Checkout Smart Button Location disabled #2852 -Fix - ACDC gateway does not appear for guests when is Fastlane enabled and a subscription product is in the cart #2745 -Fix - "Voide authorization" button does not appear for Apple Pay/Google Pay orders when payment buttons are separated #2752 -Fix - Additional payment tokens saved with new customer_id #2820 -Fix - Vaulted payment method may not be displayed in PayPal button for return buyer #2809 -Fix - Conflict with EasyShip plugin due to shipping methods loading too early #2845 -Fix - Restore accidentally removed ACDC currencies #2838 -Enhancement - Native gateway icon for PayPal & Pay upon Invoice gateways #2712 -Enhancement - Allow disabling specific card types for Fastlane #2704 -Enhancement - Fastlane Insights SDK implementation for block Checkout #2737 -Enhancement - Hide split local APMs in Payments settings tab when PayPal is not enabled #2703 -Enhancement - Do not load split local APMs on Checkout when PayPal is not enabled #2792 -Enhancement - Add support for Button Options in the Block Checkout for Apple Pay & Google Pay buttons #2797 #2772 -Enhancement - Disable “Add payment method” button while saving ACDC payment #2794 -Enhancement - Sanitize soft_descriptor field #2846 #2854 +* Fix - Early translation loading triggers `Function _load_textdomain_just_in_time was called incorrectly.` notice #2816 +* Fix - ACDC card fields not loading and payment not successful when Classic Checkout Smart Button Location disabled #2852 +* Fix - ACDC gateway does not appear for guests when is Fastlane enabled and a subscription product is in the cart #2745 +* Fix - "Voide authorization" button does not appear for Apple Pay/Google Pay orders when payment buttons are separated #2752 +* Fix - Additional payment tokens saved with new customer_id #2820 +* Fix - Vaulted payment method may not be displayed in PayPal button for return buyer #2809 +* Fix - Conflict with EasyShip plugin due to shipping methods loading too early #2845 +* Fix - Restore accidentally removed ACDC currencies #2838 +* Enhancement - Native gateway icon for PayPal & Pay upon Invoice gateways #2712 +* Enhancement - Allow disabling specific card types for Fastlane #2704 +* Enhancement - Fastlane Insights SDK implementation for block Checkout #2737 +* Enhancement - Hide split local APMs in Payments settings tab when PayPal is not enabled #2703 +* Enhancement - Do not load split local APMs on Checkout when PayPal is not enabled #2792 +* Enhancement - Add support for Button Options in the Block Checkout for Apple Pay & Google Pay buttons #2797 #2772 +* Enhancement - Disable “Add payment method” button while saving ACDC payment #2794 +* Enhancement - Sanitize soft_descriptor field #2846 #2854 = 2.9.4 - 2024-11-11 = * Fix - Apple Pay button preview missing in Standard payment and Advanced Processing tabs #2755 diff --git a/src/FilePathPluginFactory.php b/src/FilePathPluginFactory.php index e62fe1add..34f028ffa 100644 --- a/src/FilePathPluginFactory.php +++ b/src/FilePathPluginFactory.php @@ -85,7 +85,7 @@ class FilePathPluginFactory implements FilePathPluginFactoryInterface { 'Title' => '', 'Description' => '', 'TextDomain' => '', - 'RequiresWP' => '6.3', + 'RequiresWP' => '6.5', 'RequiresPHP' => '7.4', ), $plugin_data diff --git a/woocommerce-paypal-payments.php b/woocommerce-paypal-payments.php index 1544895ca..d9e1a2277 100644 --- a/woocommerce-paypal-payments.php +++ b/woocommerce-paypal-payments.php @@ -3,14 +3,15 @@ * Plugin Name: WooCommerce PayPal Payments * Plugin URI: https://woocommerce.com/products/woocommerce-paypal-payments/ * Description: PayPal's latest complete payments processing solution. Accept PayPal, Pay Later, credit/debit cards, alternative digital wallets local payment types and bank accounts. Turn on only PayPal options or process a full suite of payment methods. Enable global transaction with extensive currency and country coverage. - * Version: 2.9.5 + * Version: 2.9.6 * Author: WooCommerce * Author URI: https://woocommerce.com/ * License: GPL-2.0 * Requires PHP: 7.4 * Requires Plugins: woocommerce - * WC requires at least: 6.9 - * WC tested up to: 9.4 + * Requires at least: 6.5 + * WC requires at least: 9.2 + * WC tested up to: 9.5 * Text Domain: woocommerce-paypal-payments * * @package WooCommerce\PayPalCommerce @@ -26,7 +27,7 @@ define( 'PAYPAL_API_URL', 'https://api-m.paypal.com' ); define( 'PAYPAL_URL', 'https://www.paypal.com' ); define( 'PAYPAL_SANDBOX_API_URL', 'https://api-m.sandbox.paypal.com' ); define( 'PAYPAL_SANDBOX_URL', 'https://www.sandbox.paypal.com' ); -define( 'PAYPAL_INTEGRATION_DATE', '2024-12-02' ); +define( 'PAYPAL_INTEGRATION_DATE', '2024-12-31' ); define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); ! defined( 'CONNECT_WOO_CLIENT_ID' ) && define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' );