🔀 Merge branch 'trunk'

This commit is contained in:
Philipp Stracker 2025-03-18 17:06:38 +01:00
commit b8ab931a7a
No known key found for this signature in database
98 changed files with 2589 additions and 936 deletions

40
.env.integration Normal file
View file

@ -0,0 +1,40 @@
PPCP_INTEGRATION_WP_DIR=${ROOT_DIR}/.ddev/wordpress
BASEURL="https://woocommerce-paypal-payments.ddev.site"
AUTHORIZATION="Bearer ABC123"
CHECKOUT_URL="/checkout"
CHECKOUT_PAGE_ID=7
CART_URL="/cart"
BLOCK_CHECKOUT_URL="/checkout-block"
BLOCK_CHECKOUT_PAGE_ID=22
BLOCK_CART_URL="/cart-block"
PRODUCT_URL="/product/prod"
PRODUCT_ID=123
SUBSCRIPTION_URL="/product/sub"
PAYPAL_SUBSCRIPTIONS_PRODUCT_ID=252
APM_ID="sofort"
WP_MERCHANT_USER="admin"
WP_MERCHANT_PASSWORD="admin"
WP_CUSTOMER_USER="customer"
WP_CUSTOMER_PASSWORD="password"
CUSTOMER_EMAIL="customer@example.com"
CUSTOMER_PASSWORD="password"
CUSTOMER_FIRST_NAME="John"
CUSTOMER_LAST_NAME="Doe"
CUSTOMER_COUNTRY="DE"
CUSTOMER_ADDRESS="street 1"
CUSTOMER_POSTCODE="12345"
CUSTOMER_CITY="city"
CUSTOMER_PHONE="1234567890"
CREDIT_CARD_NUMBER="1234567890"
CREDIT_CARD_EXPIRATION="01/2042"
CREDIT_CARD_CVV="123"

View file

@ -1,4 +1,4 @@
PPCP_E2E_WP_DIR=${ROOT_DIR}/.ddev/wordpress
PPCP_INTEGRATION_WP_DIR=${ROOT_DIR}/.ddev/wordpress
BASEURL="https://woocommerce-paypal-payments.ddev.site"
AUTHORIZATION="Bearer ABC123"

View file

@ -1,4 +1,4 @@
name: e2e tests
name: Integration tests
on: workflow_dispatch
@ -7,8 +7,8 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php-versions: ['7.4', '8.2']
wc-versions: ['6.9.4', '7.7.2']
php-versions: ['7.4']
wc-versions: ['9.7.1']
name: PHP ${{ matrix.php-versions }} WC ${{ matrix.wc-versions }}
steps:
@ -30,10 +30,10 @@ jobs:
run: ddev orchestrate -f
- name: Create config
run: cp -n .env.e2e.example .env.e2e
run: cp -n .env.integration.example .env.integration
- name: Setup tests
run: ddev php tests/e2e/PHPUnit/setup.php
run: ddev php tests/integration/PHPUnit/setup.php
- name: Run PHPUnit
run: ddev exec phpunit -c tests/e2e/phpunit.xml.dist
run: ddev exec phpunit -c tests/integration/phpunit.xml.dist

View file

@ -1,5 +1,21 @@
*** Changelog ***
= 3.0.0 - 2025-03-17 =
* Enhancement - Redesigned settings UI for new users #2908
* Enhancement - Enable Fastlane by default on new store setups when eligible #3199
* Enhancement - Enable support for advanced card payments and features for Hong Kong & Singapore #3089
* Fix - Dependency conflict with more recent psr/log versions on PHP8+ #2993
* Fix - PayPal Checkout Gateway subscription migration layer not renewing subscriptions #2699
* Fix - Fatal error when gateway settings initialized too early by third-party plugin #2766
* Fix - Next Payment date for Subscriptions not updating when processing a PayPal Subscriptions renewal order #2959
* Fix - Changing the subscription payment method to ACDC triggers error #2891
* Fix - Standard Card button not appearing in standalone gateway for free trial subscription products #2935
* Fix - Validation error when using Trustly payment method #3031
* Fix - Error in continuation mode due to wrong gateway selection on Checkout block #2996
* Fix - Error in error in PayLaterConfigurator #2989
* Tweak - Removed currency requirement for Vault v3 #2919
* Tweak - Update plugin author from WooCommerce to PayPal
= 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

View file

@ -41,7 +41,7 @@
"autoload-dev": {
"psr-4": {
"WooCommerce\\PayPalCommerce\\": "tests/PHPUnit/",
"WooCommerce\\PayPalCommerce\\Tests\\E2e\\": "tests/e2e/PHPUnit/"
"WooCommerce\\PayPalCommerce\\Tests\\Integration\\": "tests/integration/PHPUnit/"
}
},
"minimum-stability": "dev",

View file

@ -5,7 +5,7 @@
* @package WooCommerce\PayPalCommerce\ApiClient\Repository
*/
declare(strict_types=1);
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\ApiClient\Repository;
@ -18,43 +18,21 @@ class PartnerReferralsData {
/**
* The DCC Applies Helper object.
*
* @deprecated Deprecates with the new UI. In this class, the products are
* always explicit, and should not be deducted from the
* DccApplies state at this point.
* Remove this with the legacy UI code.
* @var DccApplies
*/
private $dcc_applies;
/**
* The list of products ('PPCP', 'EXPRESS_CHECKOUT').
*
* @var string[]
*/
private $products;
private DccApplies $dcc_applies;
/**
* PartnerReferralsData constructor.
*
* @param DccApplies $dcc_applies The DCC Applies helper.
*/
public function __construct(
DccApplies $dcc_applies
) {
public function __construct( DccApplies $dcc_applies ) {
$this->dcc_applies = $dcc_applies;
$this->products = array(
$this->dcc_applies->for_country_currency() ? 'PPCP' : 'EXPRESS_CHECKOUT',
);
}
/**
* Returns a new copy of this object with the given value set.
*
* @param string[] $products The list of products ('PPCP', 'EXPRESS_CHECKOUT').
* @return static
*/
public function with_products( array $products ): self {
$obj = clone $this;
$obj->products = $products;
return $obj;
}
/**
@ -62,82 +40,120 @@ class PartnerReferralsData {
*
* @return string
*/
public function nonce(): string {
public function nonce() : string {
return 'a1233wtergfsdt4365tzrshgfbaewa36AGa1233wtergfsdt4365tzrshgfbaewa36AG';
}
/**
* Returns the data.
*
* @param string[] $products The list of products to use ('PPCP', 'EXPRESS_CHECKOUT').
* Default is based on DCC availability.
* @param string $onboarding_token A security token to finalize the onboarding process.
* @param bool $use_subscriptions If the merchant requires subscription features.
* @param bool $use_card_payments If the merchant wants to process credit card payments.
* @return array
*/
public function data(): array {
public function data( array $products = array(), string $onboarding_token = '', bool $use_subscriptions = null, bool $use_card_payments = true ) : array {
if ( ! $products ) {
$products = array(
$this->dcc_applies->for_country_currency() ? 'PPCP' : 'EXPRESS_CHECKOUT',
);
}
/**
* Returns the partners referrals data.
* Filter the return-URL, which is called at the end of the OAuth onboarding
* process, when the merchant clicks the "Return to your shop" button.
*/
return apply_filters(
'ppcp_partner_referrals_data',
array(
'partner_config_override' => array(
/**
* Returns the URL which will be opened at the end of onboarding.
*/
'return_url' => apply_filters(
'woocommerce_paypal_payments_partner_config_override_return_url',
admin_url( 'admin.php?page=wc-settings&tab=checkout&section=ppcp-gateway' )
),
/**
* Returns the description of the URL which will be opened at the end of onboarding.
*/
'return_url_description' => apply_filters(
'woocommerce_paypal_payments_partner_config_override_return_url_description',
__( 'Return to your shop.', 'woocommerce-paypal-payments' )
),
'show_add_credit_card' => true,
$return_url = apply_filters(
'woocommerce_paypal_payments_partner_config_override_return_url',
admin_url( 'admin.php?page=wc-settings&tab=checkout&section=ppcp-gateway' )
);
/**
* Filter the label of the "Return to your shop" button.
* It's displayed on the very last page of the onboarding popup.
*/
$return_url_label = apply_filters(
'woocommerce_paypal_payments_partner_config_override_return_url_description',
__( 'Return to your shop.', 'woocommerce-paypal-payments' )
);
$capabilities = array();
$first_party_features = array(
'PAYMENT',
'REFUND',
'ADVANCED_TRANSACTIONS_SEARCH',
'TRACKING_SHIPMENT_READWRITE',
);
if ( true === $use_subscriptions ) {
$capabilities[] = 'PAYPAL_WALLET_VAULTING_ADVANCED';
$first_party_features[] = 'BILLING_AGREEMENT';
}
// Backwards compatibility. Keep those features in the legacy UI (null-value).
// Move this into the previous condition, once legacy code is removed.
if ( false !== $use_subscriptions ) {
$first_party_features[] = 'FUTURE_PAYMENT';
$first_party_features[] = 'VAULT';
}
if ( false === $use_subscriptions ) {
// Only use "ADVANCED_VAULTING" product for onboarding with subscriptions.
$products = array_filter(
$products,
static fn( $product ) => $product !== 'ADVANCED_VAULTING'
);
}
$payload = array(
'partner_config_override' => array(
'return_url' => $return_url,
'return_url_description' => $return_url_label,
'show_add_credit_card' => $use_card_payments,
),
'products' => $products,
'capabilities' => $capabilities,
'legal_consents' => array(
array(
'type' => 'SHARE_DATA_CONSENT',
'granted' => true,
),
'products' => $this->products,
'legal_consents' => array(
array(
'type' => 'SHARE_DATA_CONSENT',
'granted' => true,
),
),
'operations' => array(
array(
'operation' => 'API_INTEGRATION',
'api_integration_preference' => array(
'rest_api_integration' => array(
'integration_method' => 'PAYPAL',
'integration_type' => 'FIRST_PARTY',
'first_party_details' => array(
'features' => array(
'PAYMENT',
'FUTURE_PAYMENT',
'REFUND',
'ADVANCED_TRANSACTIONS_SEARCH',
'VAULT',
'TRACKING_SHIPMENT_READWRITE',
),
'seller_nonce' => $this->nonce(),
),
),
'operations' => array(
array(
'operation' => 'API_INTEGRATION',
'api_integration_preference' => array(
'rest_api_integration' => array(
'integration_method' => 'PAYPAL',
'integration_type' => 'FIRST_PARTY',
'first_party_details' => array(
'features' => $first_party_features,
'seller_nonce' => $this->nonce(),
),
),
),
),
)
),
);
}
/**
* Append the validation token to the return_url
*
* @param array $data The referral data.
* @param string $token The token to be appended.
* @return array
*/
public function append_onboarding_token( array $data, string $token ): array {
$data['partner_config_override']['return_url'] =
add_query_arg( 'ppcpToken', $token, $data['partner_config_override']['return_url'] );
return $data;
/**
* Filter the final partners referrals data collection.
*/
$payload = apply_filters( 'ppcp_partner_referrals_data', $payload );
// An empty array is not permitted.
if ( isset( $payload['capabilities'] ) && ! $payload['capabilities'] ) {
unset( $payload['capabilities'] );
}
// Add the nonce in the end, to maintain backwards compatibility of filters.
$payload['partner_config_override']['return_url'] = add_query_arg(
array( 'ppcpToken' => $onboarding_token ),
$payload['partner_config_override']['return_url']
);
return $payload;
}
}

View file

@ -1018,6 +1018,10 @@ class ApplePayButton implements ButtonInterface {
* @return void
*/
public function enqueue(): void {
if ( ! $this->is_enabled() ) {
return;
}
wp_register_script(
'wc-ppcp-applepay',
untrailingslashit( $this->module_url ) . '/assets/js/boot.js',

View file

@ -107,11 +107,9 @@ class AxoBlockModule implements ServiceModule, ExtendingModule, ExecutableModule
'woocommerce_blocks_payment_method_type_registration',
function( PaymentMethodRegistry $payment_method_registry ) use ( $c ): void {
/*
* Only register the method if we are not in the admin
* (to avoid two Debit & Credit Cards gateways in the
* checkout block in the editor: one from ACDC one from Axo).
* Only register the method if we are not in the admin or the customer is not logged in.
*/
if ( ! is_admin() ) {
if ( ! is_user_logged_in() ) {
$payment_method_registry->register( $c->get( 'axoblock.method' ) );
}
}

View file

@ -12,7 +12,7 @@ namespace WooCommerce\PayPalCommerce\Axo;
use WooCommerce\PayPalCommerce\Axo\Assets\AxoManager;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Axo\Helper\ApmApplies;
use WooCommerce\PayPalCommerce\Axo\Helper\SettingsNoticeGenerator;
use WooCommerce\PayPalCommerce\Axo\Helper\CompatibilityChecker;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
@ -38,8 +38,8 @@ return array(
);
},
'axo.helpers.settings-notice-generator' => static function ( ContainerInterface $container ) : SettingsNoticeGenerator {
return new SettingsNoticeGenerator( $container->get( 'axo.fastlane-incompatible-plugin-names' ) );
'axo.helpers.compatibility-checker' => static function ( ContainerInterface $container ) : CompatibilityChecker {
return new CompatibilityChecker( $container->get( 'axo.fastlane-incompatible-plugin-names' ) );
},
// If AXO is configured and onboarded.
@ -190,38 +190,38 @@ return array(
);
},
'axo.settings-conflict-notice' => static function ( ContainerInterface $container ) : string {
$settings_notice_generator = $container->get( 'axo.helpers.settings-notice-generator' );
assert( $settings_notice_generator instanceof SettingsNoticeGenerator );
$compatibility_checker = $container->get( 'axo.helpers.compatibility-checker' );
assert( $compatibility_checker instanceof CompatibilityChecker );
$settings = $container->get( 'wcgateway.settings' );
assert( $settings instanceof Settings );
return $settings_notice_generator->generate_settings_conflict_notice( $settings );
return $compatibility_checker->generate_settings_conflict_notice( $settings );
},
'axo.checkout-config-notice' => static function ( ContainerInterface $container ) : string {
$settings_notice_generator = $container->get( 'axo.helpers.settings-notice-generator' );
assert( $settings_notice_generator instanceof SettingsNoticeGenerator );
$compatibility_checker = $container->get( 'axo.helpers.compatibility-checker' );
assert( $compatibility_checker instanceof CompatibilityChecker );
return $settings_notice_generator->generate_checkout_notice();
return $compatibility_checker->generate_checkout_notice();
},
'axo.checkout-config-notice.raw' => static function ( ContainerInterface $container ) : string {
$settings_notice_generator = $container->get( 'axo.helpers.settings-notice-generator' );
assert( $settings_notice_generator instanceof SettingsNoticeGenerator );
$compatibility_checker = $container->get( 'axo.helpers.compatibility-checker' );
assert( $compatibility_checker instanceof CompatibilityChecker );
return $settings_notice_generator->generate_checkout_notice( true );
return $compatibility_checker->generate_checkout_notice( true );
},
'axo.incompatible-plugins-notice' => static function ( ContainerInterface $container ) : string {
$settings_notice_generator = $container->get( 'axo.helpers.settings-notice-generator' );
assert( $settings_notice_generator instanceof SettingsNoticeGenerator );
$settings_notice_generator = $container->get( 'axo.helpers.compatibility-checker' );
assert( $settings_notice_generator instanceof CompatibilityChecker );
return $settings_notice_generator->generate_incompatible_plugins_notice();
},
'axo.incompatible-plugins-notice.raw' => static function ( ContainerInterface $container ) : string {
$settings_notice_generator = new SettingsNoticeGenerator(
$settings_notice_generator = new CompatibilityChecker(
$container->get( 'axo.fastlane-incompatible-plugin-names' )
);

View file

@ -475,7 +475,7 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
* @return void
*/
private function add_feature_detection_tag( bool $axo_enabled ) {
$show_tag = is_checkout() || is_cart() || is_shop();
$show_tag = is_home() || is_checkout() || is_cart() || is_shop();
if ( ! $show_tag ) {
return;

View file

@ -1,7 +1,7 @@
<?php
/**
* Settings notice generator.
* Generates the settings notices.
* Fastlane compatibility checker.
* Detects compatibility issues and generates relevant notices.
*
* @package WooCommerce\PayPalCommerce\Axo\Helper
*/
@ -15,9 +15,9 @@ use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException;
/**
* Class SettingsNoticeGenerator
* Class CompatibilityChecker
*/
class SettingsNoticeGenerator {
class CompatibilityChecker {
/**
* The list of Fastlane incompatible plugin names.
*
@ -26,12 +26,88 @@ class SettingsNoticeGenerator {
protected array $incompatible_plugin_names;
/**
* SettingsNoticeGenerator constructor.
* Stores the result of checkout compatibility checks.
*
* @var array
*/
protected array $checkout_compatibility;
/**
* Stores whether DCC is enabled.
*
* @var bool|null
*/
protected ?bool $is_dcc_enabled = null;
/**
* CompatibilityChecker constructor.
*
* @param string[] $incompatible_plugin_names The list of Fastlane incompatible plugin names.
*/
public function __construct( array $incompatible_plugin_names ) {
$this->incompatible_plugin_names = $incompatible_plugin_names;
$this->checkout_compatibility = array(
'has_elementor_checkout' => null,
'has_classic_checkout' => null,
'has_block_checkout' => null,
);
}
/**
* Checks if the checkout uses Elementor.
*
* @return bool Whether the checkout uses Elementor.
*/
protected function has_elementor_checkout(): bool {
if ( $this->checkout_compatibility['has_elementor_checkout'] === null ) {
$this->checkout_compatibility['has_elementor_checkout'] = CartCheckoutDetector::has_elementor_checkout();
}
return $this->checkout_compatibility['has_elementor_checkout'];
}
/**
* Checks if the checkout uses classic checkout.
*
* @return bool Whether the checkout uses classic checkout.
*/
protected function has_classic_checkout(): bool {
if ( $this->checkout_compatibility['has_classic_checkout'] === null ) {
$this->checkout_compatibility['has_classic_checkout'] = CartCheckoutDetector::has_classic_checkout();
}
return $this->checkout_compatibility['has_classic_checkout'];
}
/**
* Checks if the checkout uses block checkout.
*
* @return bool Whether the checkout uses block checkout.
*/
protected function has_block_checkout(): bool {
if ( $this->checkout_compatibility['has_block_checkout'] === null ) {
$this->checkout_compatibility['has_block_checkout'] = CartCheckoutDetector::has_block_checkout();
}
return $this->checkout_compatibility['has_block_checkout'];
}
/**
* Checks if DCC is enabled.
*
* @param Settings $settings The plugin settings container.
* @return bool Whether DCC is enabled.
*/
protected function is_dcc_enabled( Settings $settings ): bool {
if ( $this->is_dcc_enabled === null ) {
try {
$this->is_dcc_enabled = $settings->has( 'dcc_enabled' ) && $settings->get( 'dcc_enabled' );
} catch ( NotFoundException $ignored ) {
$this->is_dcc_enabled = false;
}
}
return $this->is_dcc_enabled;
}
/**
@ -59,6 +135,30 @@ class SettingsNoticeGenerator {
);
}
/**
* Check if there aren't any incompatibilities that would prevent Fastlane from working properly.
*
* @return bool Whether the setup is compatible.
*/
public function is_fastlane_compatible(): bool {
// Check for incompatible plugins.
if ( ! empty( $this->incompatible_plugin_names ) ) {
return false;
}
// Check for checkout page incompatibilities.
if ( $this->has_elementor_checkout() ) {
return false;
}
if ( ! $this->has_classic_checkout() && ! $this->has_block_checkout() ) {
return false;
}
// No incompatibilities found.
return true;
}
/**
* Generates the checkout notice.
*
@ -66,15 +166,23 @@ class SettingsNoticeGenerator {
* @return string
*/
public function generate_checkout_notice( bool $raw_message = false ): string {
$notice_content = '';
// Check for checkout incompatibilities.
$has_checkout_incompatibility = $this->has_elementor_checkout() ||
( ! $this->has_classic_checkout() && ! $this->has_block_checkout() );
if ( ! $has_checkout_incompatibility ) {
return '';
}
$checkout_page_link = esc_url( get_edit_post_link( wc_get_page_id( 'checkout' ) ) ?? '' );
$block_checkout_docs_link = __(
'https://woocommerce.com/document/woocommerce-store-editing/customizing-cart-and-checkout/#using-the-cart-and-checkout-blocks',
'woocommerce-paypal-payments'
);
$notice_content = '';
if ( CartCheckoutDetector::has_elementor_checkout() ) {
if ( $this->has_elementor_checkout() ) {
$notice_content = sprintf(
/* translators: %1$s: URL to the Checkout edit page. %2$s: URL to the block checkout docs. */
__(
@ -84,7 +192,7 @@ class SettingsNoticeGenerator {
esc_url( $checkout_page_link ),
esc_url( $block_checkout_docs_link )
);
} elseif ( ! CartCheckoutDetector::has_classic_checkout() && ! CartCheckoutDetector::has_block_checkout() ) {
} elseif ( ! $this->has_classic_checkout() && ! $this->has_block_checkout() ) {
$notice_content = sprintf(
/* translators: %1$s: URL to the Checkout edit page. %2$s: URL to the block checkout docs. */
__(
@ -132,22 +240,14 @@ class SettingsNoticeGenerator {
* @return string
*/
public function generate_settings_conflict_notice( Settings $settings, bool $raw_message = false ) : string {
$notice_content = '';
$is_dcc_enabled = false;
try {
$is_dcc_enabled = $settings->has( 'dcc_enabled' ) && $settings->get( 'dcc_enabled' );
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
} catch ( NotFoundException $ignored ) {
// Never happens.
if ( $this->is_dcc_enabled( $settings ) ) {
return '';
}
if ( ! $is_dcc_enabled ) {
$notice_content = __(
'<span class="highlight">Warning:</span> To enable Fastlane and accelerate payments, the <strong>Advanced Card Processing</strong> payment method must also be enabled.',
'woocommerce-paypal-payments'
);
}
$notice_content = __(
'<span class="highlight">Warning:</span> To enable Fastlane and accelerate payments, the <strong>Advanced Card Processing</strong> payment method must also be enabled.',
'woocommerce-paypal-payments'
);
return $this->render_notice( $notice_content, true, $raw_message );
}

View file

@ -67,27 +67,6 @@ export const PayPalComponent = ( {
? `${ 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 ) {
@ -339,7 +318,6 @@ export const PayPalComponent = ( {
shouldskipFinalConfirmation,
getCheckoutRedirectUrl,
setGotoContinuationOnError,
enforcePaymentMethodForCart,
onSubmit,
onError,
onClose
@ -439,7 +417,6 @@ export const PayPalComponent = ( {
shouldskipFinalConfirmation,
getCheckoutRedirectUrl,
setGotoContinuationOnError,
enforcePaymentMethodForCart,
onSubmit,
onError,
onClose
@ -476,7 +453,6 @@ export const PayPalComponent = ( {
shouldskipFinalConfirmation,
getCheckoutRedirectUrl,
setGotoContinuationOnError,
enforcePaymentMethodForCart,
onSubmit,
onError,
onClose

View file

@ -81,12 +81,14 @@ export const paypalShippingToWc = ( shipping ) => {
export const paypalPayerToWc = ( payer ) => {
const firstName = payer?.name?.given_name ?? '';
const lastName = payer?.name?.surname ?? '';
const phone = payer?.phone?.phone_number?.national_number ?? '';
const address = payer.address ? paypalAddressToWc( payer.address ) : {};
return {
...address,
first_name: firstName,
last_name: lastName,
email: payer.email_address,
phone: phone
};
};

View file

@ -61,7 +61,6 @@ export const handleApprove = async (
shouldskipFinalConfirmation,
getCheckoutRedirectUrl,
setGotoContinuationOnError,
enforcePaymentMethodForCart,
onSubmit,
onError,
onClose
@ -132,7 +131,6 @@ export const handleApprove = async (
location.href = getCheckoutRedirectUrl();
} else {
setGotoContinuationOnError( true );
enforcePaymentMethodForCart();
onSubmit();
}
} catch ( err ) {
@ -171,7 +169,6 @@ export const handleApproveSubscription = async (
shouldskipFinalConfirmation,
getCheckoutRedirectUrl,
setGotoContinuationOnError,
enforcePaymentMethodForCart,
onSubmit,
onError,
onClose
@ -242,7 +239,6 @@ export const handleApproveSubscription = async (
location.href = getCheckoutRedirectUrl();
} else {
setGotoContinuationOnError( true );
enforcePaymentMethodForCart();
onSubmit();
}
} catch ( err ) {

View file

@ -28,13 +28,6 @@ return array(
);
},
'blocks.method' => static function ( ContainerInterface $container ): PayPalPaymentMethod {
/**
* Cart instance; might be null, esp. in customizer or in Block Editor.
*
* @var null|WC_Cart $cart
*/
$cart = WC()->cart;
return new PayPalPaymentMethod(
$container->get( 'blocks.url' ),
$container->get( 'ppcp.asset-version' ),
@ -53,7 +46,6 @@ return array(
$container->get( 'wcgateway.place-order-button-text' ),
$container->get( 'wcgateway.place-order-button-description' ),
$container->get( 'wcgateway.all-funding-sources' ),
$cart && $cart->needs_shipping()
);
},
'blocks.advanced-card-method' => static function( ContainerInterface $container ): AdvancedCardPaymentMethod {

View file

@ -23,7 +23,7 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData;
*/
class UpdateShippingEndpoint implements EndpointInterface {
const ENDPOINT = 'ppc-update-shipping';
const WC_STORE_API_ENDPOINT = '/wp-json/wc/store/cart/';
const WC_STORE_API_ENDPOINT = '/wp-json/wc/store/v1/cart/';
/**
* The Request Data Helper.

View file

@ -130,13 +130,6 @@ class PayPalPaymentMethod extends AbstractPaymentMethodType {
*/
private $all_funding_sources;
/**
* Whether shipping details must be collected during checkout; i.e. paying for physical goods?
*
* @var bool
*/
private $need_shipping;
/**
* Assets constructor.
*
@ -155,7 +148,6 @@ class PayPalPaymentMethod extends AbstractPaymentMethodType {
* @param string $place_order_button_text The text for the standard "Place order" button.
* @param string $place_order_button_description The text for additional "Place order" description.
* @param array $all_funding_sources All existing funding sources for PayPal buttons.
* @param bool $need_shipping Whether shipping details are required for the purchase.
*/
public function __construct(
string $module_url,
@ -172,8 +164,7 @@ class PayPalPaymentMethod extends AbstractPaymentMethodType {
bool $use_place_order,
string $place_order_button_text,
string $place_order_button_description,
array $all_funding_sources,
bool $need_shipping
array $all_funding_sources
) {
$this->name = PayPalGateway::ID;
$this->module_url = $module_url;
@ -191,7 +182,6 @@ class PayPalPaymentMethod extends AbstractPaymentMethodType {
$this->place_order_button_text = $place_order_button_text;
$this->place_order_button_description = $place_order_button_description;
$this->all_funding_sources = $all_funding_sources;
$this->need_shipping = $need_shipping;
}
/**
@ -258,6 +248,7 @@ class PayPalPaymentMethod extends AbstractPaymentMethodType {
&& $this->settings_status->is_smart_button_enabled_for_location( $script_data['context'] ?? 'block-checkout' );
$place_order_enabled = ( $this->use_place_order || $this->add_place_order_method )
&& ! $this->subscription_helper->cart_contains_subscription();
$cart = WC()->cart;
return array(
'id' => $this->gateway->id,
@ -284,7 +275,7 @@ class PayPalPaymentMethod extends AbstractPaymentMethodType {
),
),
'scriptData' => $script_data,
'needShipping' => $this->need_shipping,
'needShipping' => $cart && $cart->needs_shipping(),
);
}

View file

@ -21,8 +21,8 @@ export const handleShippingOptionsChange = async ( data, actions, config ) => {
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WC-Store-API-Nonce':
config.ajax.update_customer_shipping.wp_rest_nonce,
'Nonce':
config.ajax.update_customer_shipping.wp_rest_nonce,
},
body: JSON.stringify( {
rate_id: shippingOptionId,
@ -106,9 +106,9 @@ export const handleShippingAddressChange = async ( data, actions, config ) => {
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WC-Store-API-Nonce':
config.ajax.update_customer_shipping
.wp_rest_nonce,
'Nonce':
config.ajax.update_customer_shipping
.wp_rest_nonce,
},
body: JSON.stringify( {
shipping_address: cartData.shipping_address,

View file

@ -104,6 +104,6 @@ class DisabledFundingSources {
$disable_funding = $all_sources;
}
return $disable_funding;
return apply_filters( 'woocommerce_paypal_payments_disabled_funding_sources', $disable_funding );
}
}

View file

@ -199,8 +199,9 @@ class WooCommerceOrderCreator {
$shipping_options = null;
if ( $payer ) {
$address = $payer->address();
$payer_name = $payer->name();
$address = $payer->address();
$payer_name = $payer->name();
$payer_phone = $payer->phone();
$wc_email = null;
$wc_customer = WC()->customer;
@ -220,6 +221,7 @@ class WooCommerceOrderCreator {
'state' => $address ? $address->admin_area_1() : '',
'postcode' => $address ? $address->postal_code() : '',
'country' => $address ? $address->country_code() : '',
'phone' => $payer_phone ? $payer_phone->phone()->national_number() : '',
);
}

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Compat;
use WooCommerce\PayPalCommerce\Compat\Assets\CompatAssets;
use WooCommerce\PayPalCommerce\Compat\Settings\GeneralSettingsMapHelper;
use WooCommerce\PayPalCommerce\Compat\Settings\PaymentMethodSettingsMapHelper;
use WooCommerce\PayPalCommerce\Compat\Settings\SettingsMap;
use WooCommerce\PayPalCommerce\Compat\Settings\SettingsMapHelper;
use WooCommerce\PayPalCommerce\Compat\Settings\SettingsTabMapHelper;
@ -145,6 +146,9 @@ return array(
$general_map_helper = $container->get( 'compat.settings.general_map_helper' );
assert( $general_map_helper instanceof GeneralSettingsMapHelper );
$payment_methods_map_helper = $container->get( 'compat.settings.payment_methods_map_helper' );
assert( $payment_methods_map_helper instanceof PaymentMethodSettingsMapHelper );
return array(
new SettingsMap(
$container->get( 'settings.data.general' ),
@ -182,6 +186,10 @@ return array(
$container->get( 'settings.data.payment' ),
array()
),
new SettingsMap(
$container->get( 'settings.data.payment' ),
$payment_methods_map_helper->map()
),
);
},
'compat.settings.settings_map_helper' => static function( ContainerInterface $container ) : SettingsMapHelper {
@ -191,6 +199,7 @@ return array(
$container->get( 'compat.settings.settings_tab_map_helper' ),
$container->get( 'compat.settings.subscription_map_helper' ),
$container->get( 'compat.settings.general_map_helper' ),
$container->get( 'compat.settings.payment_methods_map_helper' ),
$container->get( 'wcgateway.settings.admin-settings-enabled' )
);
},
@ -206,4 +215,7 @@ return array(
'compat.settings.general_map_helper' => static function() : GeneralSettingsMapHelper {
return new GeneralSettingsMapHelper();
},
'compat.settings.payment_methods_map_helper' => static function() : PaymentMethodSettingsMapHelper {
return new PaymentMethodSettingsMapHelper();
},
);

View file

@ -0,0 +1,64 @@
<?php
/**
* A helper for mapping the old/new payment method settings.
*
* @package WooCommerce\PayPalCommerce\Compat\Settings
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Compat\Settings;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
/**
* A map of old to new payment method settings.
*
* @psalm-import-type newSettingsKey from SettingsMap
* @psalm-import-type oldSettingsKey from SettingsMap
*/
class PaymentMethodSettingsMapHelper {
/**
* Maps old setting keys to new payment method settings names.
*
* @psalm-return array<oldSettingsKey, newSettingsKey>
*/
public function map(): array {
return array(
'dcc_enabled' => CreditCardGateway::ID,
'axo_enabled' => AxoGateway::ID,
);
}
/**
* Retrieves the value of a mapped key from the new settings.
*
* @param string $old_key The key from the legacy settings.
* @return mixed The value of the mapped setting, (null if not found).
*/
public function mapped_value( string $old_key ): ?bool {
$payment_method = $this->map()[ $old_key ] ?? false;
if ( ! $payment_method ) {
return null;
}
return $this->is_gateway_enabled( $payment_method );
}
/**
* Checks if the payment gateway with the given name is enabled.
*
* @param string $gateway_name The gateway name.
* @return bool True if the payment gateway with the given name is enabled, otherwise false.
*/
protected function is_gateway_enabled( string $gateway_name ): bool {
$gateway_settings = get_option( "woocommerce_{$gateway_name}_settings", array() );
$gateway_enabled = $gateway_settings['enabled'] ?? false;
return $gateway_enabled === 'yes';
}
}

View file

@ -74,6 +74,13 @@ class SettingsMapHelper {
*/
protected GeneralSettingsMapHelper $general_settings_map_helper;
/**
* A helper for mapping old and new payment method settings.
*
* @var PaymentMethodSettingsMapHelper
*/
protected PaymentMethodSettingsMapHelper $payment_method_settings_map_helper;
/**
* Whether the new settings module is enabled.
*
@ -84,12 +91,13 @@ class SettingsMapHelper {
/**
* Constructor.
*
* @param SettingsMap[] $settings_map A list of settings maps containing key definitions.
* @param StylingSettingsMapHelper $styling_settings_map_helper A helper for mapping the old/new styling settings.
* @param SettingsTabMapHelper $settings_tab_map_helper A helper for mapping the old/new settings tab settings.
* @param SubscriptionSettingsMapHelper $subscription_map_helper A helper for mapping old and new subscription settings.
* @param GeneralSettingsMapHelper $general_settings_map_helper A helper for mapping old and new general settings.
* @param bool $new_settings_module_enabled Whether the new settings module is enabled.
* @param SettingsMap[] $settings_map A list of settings maps containing key definitions.
* @param StylingSettingsMapHelper $styling_settings_map_helper A helper for mapping the old/new styling settings.
* @param SettingsTabMapHelper $settings_tab_map_helper A helper for mapping the old/new settings tab settings.
* @param SubscriptionSettingsMapHelper $subscription_map_helper A helper for mapping old and new subscription settings.
* @param GeneralSettingsMapHelper $general_settings_map_helper A helper for mapping old and new general settings.
* @param PaymentMethodSettingsMapHelper $payment_method_settings_map_helper A helper for mapping old and new payment method settings.
* @param bool $new_settings_module_enabled Whether the new settings module is enabled.
* @throws RuntimeException When an old key has multiple mappings.
*/
public function __construct(
@ -98,15 +106,17 @@ class SettingsMapHelper {
SettingsTabMapHelper $settings_tab_map_helper,
SubscriptionSettingsMapHelper $subscription_map_helper,
GeneralSettingsMapHelper $general_settings_map_helper,
PaymentMethodSettingsMapHelper $payment_method_settings_map_helper,
bool $new_settings_module_enabled
) {
$this->validate_settings_map( $settings_map );
$this->settings_map = $settings_map;
$this->styling_settings_map_helper = $styling_settings_map_helper;
$this->settings_tab_map_helper = $settings_tab_map_helper;
$this->subscription_map_helper = $subscription_map_helper;
$this->general_settings_map_helper = $general_settings_map_helper;
$this->new_settings_module_enabled = $new_settings_module_enabled;
$this->settings_map = $settings_map;
$this->styling_settings_map_helper = $styling_settings_map_helper;
$this->settings_tab_map_helper = $settings_tab_map_helper;
$this->subscription_map_helper = $subscription_map_helper;
$this->general_settings_map_helper = $general_settings_map_helper;
$this->payment_method_settings_map_helper = $payment_method_settings_map_helper;
$this->new_settings_module_enabled = $new_settings_module_enabled;
}
/**
@ -203,6 +213,9 @@ class SettingsMapHelper {
? $this->subscription_map_helper->mapped_value( $old_key, $this->model_cache[ $model_id ] )
: $this->settings_tab_map_helper->mapped_value( $old_key, $this->model_cache[ $model_id ] );
case $model instanceof PaymentSettings:
return $this->payment_method_settings_map_helper->mapped_value( $old_key );
default:
return $this->model_cache[ $model_id ][ $new_key ] ?? null;
}

View file

@ -41,6 +41,8 @@ class SettingsTabMapHelper {
'intent' => '',
'vault_enabled_dcc' => 'save_card_details',
'blocks_final_review_enabled' => 'enable_pay_now',
'logging_enabled' => 'enable_logging',
'vault_enabled' => 'save_paypal_and_venmo',
);
}

View file

@ -27,7 +27,7 @@ class StylingSettingsMapHelper {
use ContextTrait;
protected const BUTTON_NAMES = array( GooglePayGateway::ID, ApplePayGateway::ID, 'pay-later' );
protected const BUTTON_NAMES = array( GooglePayGateway::ID, ApplePayGateway::ID );
/**
* Maps old setting keys to new setting style names.
@ -89,13 +89,13 @@ class StylingSettingsMapHelper {
return $this->mapped_disabled_funding_value( $styling_models, $payment_settings );
case 'googlepay_button_enabled':
return $this->mapped_button_enabled_value( $styling_models, GooglePayGateway::ID, $payment_settings );
return $this->mapped_button_enabled_value( $styling_models, GooglePayGateway::ID );
case 'applepay_button_enabled':
return $this->mapped_button_enabled_value( $styling_models, ApplePayGateway::ID, $payment_settings );
return $this->mapped_button_enabled_value( $styling_models, ApplePayGateway::ID );
case 'pay_later_button_enabled':
return $this->mapped_button_enabled_value( $styling_models, 'pay-later', $payment_settings );
return $this->mapped_pay_later_button_enabled_value( $styling_models, $payment_settings );
default:
foreach ( $this->locations_map() as $old_location_name => $new_location_name ) {
@ -255,30 +255,51 @@ class StylingSettingsMapHelper {
}
/**
* Retrieves the mapped enabled or disabled button value from the new settings.
* Retrieves the mapped enabled or disabled PayLater button value from the new settings.
*
* @param LocationStylingDTO[] $styling_models The list of location styling models.
* @param string $button_name The button name (see {@link self::BUTTON_NAMES}).
* @param AbstractDataModel|null $payment_settings The payment settings model.
* @return int The enabled (1) or disabled (0) state.
* @throws RuntimeException If an invalid button name is provided.
* @return int|null The enabled (1) or disabled (0) state or null if it should fall back to old settings value.
*/
protected function mapped_button_enabled_value( array $styling_models, string $button_name, ?AbstractDataModel $payment_settings ): ?int {
if ( is_null( $payment_settings ) ) {
protected function mapped_pay_later_button_enabled_value( array $styling_models, ?AbstractDataModel $payment_settings ): ?int {
if ( ! $payment_settings instanceof PaymentSettings ) {
return null;
}
$locations_to_context_map = $this->current_context_to_new_button_location_map();
$current_context = $locations_to_context_map[ $this->context() ] ?? '';
foreach ( $styling_models as $model ) {
if ( $model->enabled && $model->location === $current_context ) {
if ( in_array( 'pay-later', $model->methods, true ) && $payment_settings->get_paylater_enabled() ) {
return 1;
}
}
}
return 0;
}
/**
* Retrieves the mapped enabled or disabled button value from the new settings.
*
* @param LocationStylingDTO[] $styling_models The list of location styling models.
* @param string $button_name The button name (see {@link self::BUTTON_NAMES}).
* @return int The enabled (1) or disabled (0) state.
* @throws RuntimeException If an invalid button name is provided.
*/
protected function mapped_button_enabled_value( array $styling_models, string $button_name ): ?int {
if ( ! in_array( $button_name, self::BUTTON_NAMES, true ) ) {
throw new RuntimeException( 'Wrong button name is provided.' );
}
$locations_to_context_map = $this->current_context_to_new_button_location_map();
$current_context = $locations_to_context_map[ $this->context() ] ?? '';
assert( $payment_settings instanceof PaymentSettings );
foreach ( $styling_models as $model ) {
if ( $model->enabled && $model->location === $current_context ) {
if ( in_array( $button_name, $model->methods, true ) && $payment_settings->is_method_enabled( $button_name ) ) {
if ( in_array( $button_name, $model->methods, true ) && $this->is_gateway_enabled( $button_name ) ) {
return 1;
}
}
@ -307,4 +328,17 @@ class StylingSettingsMapHelper {
return 0;
}
/**
* Checks if the payment gateway with the given name is enabled.
*
* @param string $gateway_name The gateway name.
* @return bool True if the payment gateway with the given name is enabled, otherwise false.
*/
protected function is_gateway_enabled( string $gateway_name ): bool {
$gateway_settings = get_option( "woocommerce_{$gateway_name}_settings", array() );
$gateway_enabled = $gateway_settings['enabled'] ?? false;
return $gateway_enabled === 'yes';
}
}

View file

@ -326,9 +326,9 @@ window.ppcp_onboarding_productionCallback = function ( ...args ) {
isDisconnecting = true;
const saveButton = document.querySelector( '.woocommerce-save-button' );
saveButton.removeAttribute( 'disabled' );
saveButton.click();
const saveButton = document.querySelector( '.woocommerce-save-button' );
saveButton.removeAttribute( 'disabled' );
saveButton.click();
};
// Prevent the message about unsaved checkbox/radiobutton when reloading the page.
@ -345,9 +345,11 @@ window.ppcp_onboarding_productionCallback = function ( ...args ) {
const sandboxSwitchElement = document.querySelector( '#ppcp-sandbox_on' );
sandboxSwitchElement?.addEventListener( 'click', () => {
document.querySelector( '.woocommerce-save-button' )?.removeAttribute( 'disabled' );
});
sandboxSwitchElement?.addEventListener( 'click', () => {
document
.querySelector( '.woocommerce-save-button' )
?.removeAttribute( 'disabled' );
} );
const validate = () => {
const selectors = sandboxSwitchElement.checked
@ -389,7 +391,8 @@ window.ppcp_onboarding_productionCallback = function ( ...args ) {
const isSandboxInBackend =
PayPalCommerceGatewayOnboarding.current_env === 'sandbox';
if ( sandboxSwitchElement.checked !== isSandboxInBackend ) {
if ( sandboxSwitchElement?.checked !== isSandboxInBackend ) {
sandboxSwitchElement.checked = isSandboxInBackend;
}

View file

@ -103,12 +103,8 @@ class OnboardingRenderer {
'displayMode' => 'minibrowser',
);
$data = $this->partner_referrals_data
->with_products( $products )
->data();
$environment = $is_production ? 'production' : 'sandbox';
$product = 'PPCP' === $data['products'][0] ? 'ppcp' : 'express_checkout';
$product = strtolower( $products[0] ?? 'express_checkout' );
$cache_key = $environment . '-' . $product;
$onboarding_url = new OnboardingUrl( $this->cache, $cache_key, get_current_user_id() );
@ -122,8 +118,7 @@ class OnboardingRenderer {
$onboarding_url->init();
$data = $this->partner_referrals_data
->append_onboarding_token( $data, $onboarding_url->token() ?: '' );
$data = $this->partner_referrals_data->data( $products, $onboarding_url->token() ?: '' );
$url = $is_production ? $this->production_partner_referrals->signup_link( $data ) : $this->sandbox_partner_referrals->signup_link( $data );
$url = add_query_arg( $args, $url );

View file

@ -93,6 +93,29 @@
}
}
.ppcp-r-payment-methods {
.ppcp-highlight {
animation: ppcp-highlight-fade 2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid $color-blueberry;
border-radius: var(--container-border-radius);
position: relative;
z-index: 1;
}
@keyframes ppcp-highlight-fade {
0%, 20% {
background-color: rgba($color-blueberry, 0.08);
border-color: $color-blueberry;
border-width: 1px;
}
100% {
background-color: transparent;
border-color: $color-gray-300;
border-width: 1px;
}
}
}
// Disabled state styling.
.ppcp--method-item--disabled {
position: relative;

View file

@ -226,25 +226,91 @@
}
}
// Payment Methods
.ppcp-r-settings {
.ppcp-highlight {
position: relative;
z-index: 1;
.ppcp-highlight {
animation: ppcp-highlight-fade 2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid $color-blueberry;
border-radius: var(--container-border-radius);
position: relative;
z-index: 1;
}
&::before {
content: '';
position: absolute;
top: -8px;
left: -12px;
right: -12px;
bottom: -8px;
border: 1px solid $color-blueberry;
border-radius: 4px;
z-index: -1;
pointer-events: none;
animation: ppcp-setting-highlight-bg 2s cubic-bezier(0.4, 0, 0.2, 1);
animation-fill-mode: forwards;
}
@keyframes ppcp-highlight-fade {
0%, 20% {
background-color: rgba($color-blueberry, 0.08);
border-color: $color-blueberry;
border-width: 1px;
&::after {
content: '';
position: absolute;
top: -8px;
left: -12px;
width: 4px;
bottom: -8px;
background-color: $color-blueberry;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
z-index: -1;
pointer-events: none;
animation: ppcp-setting-highlight-accent 2s cubic-bezier(0.4, 0, 0.2, 1);
animation-fill-mode: forwards;
}
}
100% {
background-color: transparent;
border-color: $color-gray-300;
border-width: 1px;
@keyframes ppcp-setting-highlight-bg {
0%, 15% {
background-color: rgba($color-blueberry, 0.08);
border-color: $color-blueberry;
}
70% {
background-color: transparent;
border-color: transparent;
}
100% {
background-color: transparent;
border-color: transparent;
}
}
@keyframes ppcp-setting-highlight-accent {
0%, 15% {
opacity: 1;
}
70% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.ppcp-r-settings-section {
.ppcp--setting-row {
position: relative;
padding: 12px;
margin: 0 -12px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba($color-gray-100, 0.5);
}
}
}
// RTL support
html[dir="rtl"] {
.ppcp-highlight {
&::after {
left: auto;
right: -12px;
border-radius: 0 4px 4px 0;
}
}
}
}

View file

@ -2,13 +2,14 @@ import { ToggleControl } from '@wordpress/components';
import { Action, Description } from '../Elements';
const ControlToggleButton = ( {
id = '',
label,
description,
value,
onChange,
disabled = false,
} ) => (
<Action>
<Action id={ id }>
<ToggleControl
className="ppcp--control-toggle"
__nextHasNoMarginBottom

View file

@ -1,5 +1,7 @@
const Action = ( { children } ) => (
<div className="ppcp--action">{ children }</div>
const Action = ( { id, children } ) => (
<div className="ppcp--action" { ...( id ? { id } : {} ) }>
{ children }
</div>
);
export default Action;

View file

@ -1,20 +1,11 @@
import { Icon } from '@wordpress/components';
import data from '../../utils/data';
const PaymentMethodIcon = ( { icons, type } ) => {
const validIcon = Array.isArray( icons ) && icons.includes( type );
if ( validIcon || icons === 'all' ) {
return (
<Icon
icon={ data().getImage( 'icon-button-' + type + '.svg' ) }
className="ppcp--method-icon"
/>
);
}
return null;
};
const PaymentMethodIcon = ( { type } ) => (
<Icon
icon={ data().getImage( `icon-button-${ type }.svg` ) }
className="ppcp--method-icon"
/>
);
export default PaymentMethodIcon;

View file

@ -1,20 +1,11 @@
import PaymentMethodIcon from './PaymentMethodIcon';
const PaymentMethodIcons = ( props ) => {
return (
<div className="ppcp-r-payment-method-icons">
<PaymentMethodIcon type="paypal" icons={ props.icons } />
<PaymentMethodIcon type="venmo" icons={ props.icons } />
<PaymentMethodIcon type="visa" icons={ props.icons } />
<PaymentMethodIcon type="mastercard" icons={ props.icons } />
<PaymentMethodIcon type="amex" icons={ props.icons } />
<PaymentMethodIcon type="discover" icons={ props.icons } />
<PaymentMethodIcon type="apple-pay" icons={ props.icons } />
<PaymentMethodIcon type="google-pay" icons={ props.icons } />
<PaymentMethodIcon type="ideal" icons={ props.icons } />
<PaymentMethodIcon type="bancontact" icons={ props.icons } />
</div>
);
};
const PaymentMethodIcons = ( { icons = [] } ) => (
<div className="ppcp-r-payment-method-icons">
{ icons.map( ( type ) => (
<PaymentMethodIcon key={ type } type={ type } />
) ) }
</div>
);
export default PaymentMethodIcons;

View file

@ -1,7 +1,5 @@
import { ToggleControl, Icon, Button } from '@wordpress/components';
import { cog } from '@wordpress/icons';
import { useEffect } from '@wordpress/element';
import { useActiveHighlight } from '../../../data/common/hooks';
import SettingsBlock from '../SettingsBlock';
import PaymentMethodIcon from '../PaymentMethodIcon';
@ -16,26 +14,12 @@ const PaymentMethodItemBlock = ( {
disabledMessage,
warningMessages,
} ) => {
const { activeHighlight, setActiveHighlight } = useActiveHighlight();
const isHighlighted = activeHighlight === paymentMethod.id;
const hasWarning =
warningMessages && Object.keys( warningMessages ).length > 0;
// Reset the active highlight after 2 seconds
useEffect( () => {
if ( isHighlighted ) {
const timer = setTimeout( () => {
setActiveHighlight( null );
}, 2000 );
return () => clearTimeout( timer );
}
}, [ isHighlighted, setActiveHighlight ] );
// Determine class names based on states
const methodItemClasses = [
'ppcp--method-item',
isHighlighted ? 'ppcp-highlight' : '',
isDisabled ? 'ppcp--method-item--disabled' : '',
hasWarning && ! isDisabled ? 'ppcp--method-item--warning' : '',
]

View file

@ -7,7 +7,6 @@ const TodoSettingsBlock = ( {
todosData,
className = '',
setActiveModal,
setActiveHighlight,
onDismissTodo,
} ) => {
const [ dismissingIds, setDismissingIds ] = useState( new Set() );
@ -44,22 +43,23 @@ const TodoSettingsBlock = ( {
};
const handleClick = async ( todo ) => {
if ( todo.action.type === 'tab' ) {
const tabId = TAB_IDS[ todo.action.tab.toUpperCase() ];
await selectTab( tabId, todo.action.section );
} else if ( todo.action.type === 'external' ) {
window.open( todo.action.url, '_blank' );
const { action } = todo;
const highlight = Boolean( action.highlight );
// Handle different action types.
if ( action.type === 'tab' ) {
const tabId = TAB_IDS[ action.tab.toUpperCase() ];
await selectTab( tabId, action.section, highlight );
} else if ( action.type === 'external' ) {
window.open( action.url, '_blank' );
}
if ( todo.action.completeOnClick === true ) {
if ( action.completeOnClick ) {
await completeOnClick( todo.id );
}
if ( todo.action.modal ) {
setActiveModal( todo.action.modal );
}
if ( todo.action.highlight ) {
setActiveHighlight( todo.action.highlight );
if ( action.modal ) {
setActiveModal( action.modal );
}
};

View file

@ -8,7 +8,6 @@ import { usePaymentConfig } from '../hooks/usePaymentConfig';
const PaymentFlow = ( {
useAcdc,
isFastlane,
isPayLater,
storeCountry,
onlyOptional = false,
} ) => {
@ -18,7 +17,8 @@ const PaymentFlow = ( {
optionalTitle,
optionalDescription,
learnMoreConfig,
} = usePaymentConfig( storeCountry, isPayLater, useAcdc, isFastlane );
paypalCheckoutDescription,
} = usePaymentConfig( storeCountry, useAcdc, isFastlane );
if ( onlyOptional ) {
return (
@ -34,6 +34,7 @@ const PaymentFlow = ( {
<DefaultMethodsSection
methods={ includedMethods }
learnMoreConfig={ learnMoreConfig }
paypalCheckoutDescription={ paypalCheckoutDescription }
/>
<OptionalMethodsSection
@ -48,10 +49,17 @@ const PaymentFlow = ( {
export default PaymentFlow;
const DefaultMethodsSection = ( { methods, learnMoreConfig } ) => {
const DefaultMethodsSection = ( {
methods,
learnMoreConfig,
paypalCheckoutDescription,
} ) => {
return (
<div className="ppcp-r-welcome-docs__col">
<PayPalCheckout learnMore={ learnMoreConfig.PayPalCheckout } />
<PayPalCheckout
learnMore={ learnMoreConfig.PayPalCheckout }
description={ paypalCheckoutDescription }
/>
<BadgeBox
title={ __(
'Included in PayPal Checkout',

View file

@ -5,15 +5,15 @@ import BadgeBox from '../../../../ReusableComponents/BadgeBox';
const PayPalCheckout = ( {
learnMore = 'https://www.paypal.com/us/business/accept-payments/checkout',
description,
} ) => {
const title = __( 'PayPal Checkout', 'woocommerce-paypal-payments' );
return (
<BadgeBox
title={ __( 'PayPal Checkout', 'woocommerce-paypal-payments' ) }
title={ title }
textBadge={ <PricingTitleBadge item="checkout" /> }
description={ __(
'Our all-in-one checkout solution lets you offer PayPal, Venmo, Pay Later options, and more to help maximise conversion',
'woocommerce-paypal-payments'
) }
description={ description }
learnMoreLink={ learnMore }
/>
);

View file

@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n';
import PricingDescription from './PricingDescription';
import PaymentFlow from './PaymentFlow';
const WelcomeDocs = ( { useAcdc, isFastlane, isPayLater, storeCountry } ) => {
const WelcomeDocs = ( { useAcdc, isFastlane, storeCountry } ) => {
return (
<div className="ppcp-r-welcome-docs">
<h2 className="ppcp-r-welcome-docs__title">
@ -15,7 +15,6 @@ const WelcomeDocs = ( { useAcdc, isFastlane, isPayLater, storeCountry } ) => {
<PaymentFlow
useAcdc={ useAcdc }
isFastlane={ isFastlane }
isPayLater={ isPayLater }
storeCountry={ storeCountry }
/>
<PricingDescription />

View file

@ -59,29 +59,18 @@ const StepPaymentMethods = () => {
export default StepPaymentMethods;
const PaymentStepTitle = () => {
const { storeCountry } = CommonHooks.useWooSettings();
if ( 'US' === storeCountry ) {
return __(
'Add Expanded Checkout for More Ways to Pay',
'woocommerce-paypal-payments'
);
}
return __(
'Add optional payment methods to your Checkout',
'woocommerce-paypal-payments'
);
return __( 'Add Credit and Debit Cards', 'woocommerce-paypal-payments' );
};
const OptionalMethodDescription = () => {
const { isCasualSeller } = OnboardingHooks.useBusiness();
const { storeCountry, storeCurrency } = CommonHooks.useWooSettings();
const { canUseCardPayments } = OnboardingHooks.useFlags();
return (
<PaymentFlow
onlyOptional={ true }
useAcdc={ canUseCardPayments }
useAcdc={ ! isCasualSeller && canUseCardPayments }
isFastlane={ true }
isPayLater={ true }
storeCountry={ storeCountry }

View file

@ -9,12 +9,27 @@ import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper';
import OnboardingHeader from '../Components/OnboardingHeader';
import WelcomeDocs from '../Components/WelcomeDocs';
import AdvancedOptionsForm from '../Components/AdvancedOptionsForm';
import { usePaymentConfig } from '../hooks/usePaymentConfig';
const StepWelcome = ( { setStep, currentStep } ) => {
const { storeCountry } = CommonHooks.useWooSettings();
const { canUseCardPayments, canUseFastlane, canUsePayLater } =
OnboardingHooks.useFlags();
const nonAcdcIcons = [ 'paypal', 'visa', 'mastercard', 'amex', 'discover' ];
const { canUseCardPayments, canUseFastlane } = OnboardingHooks.useFlags();
const { acdcIcons, bcdcIcons } = usePaymentConfig(
storeCountry,
canUseCardPayments,
canUseFastlane
);
const onboardingHeaderDescription = canUseCardPayments
? __(
'Your all-in-one integration for PayPal checkout solutions that enable buyers to pay via PayPal, Pay Later, all major credit/debit cards, Apple Pay, Google Pay, and more.',
'woocommerce-paypal-payments'
)
: __(
'Your all-in-one integration for PayPal checkout solutions that enable buyers to pay via PayPal, Pay Later, all major credit/debit cards, and more.',
'woocommerce-paypal-payments'
);
return (
<div className="ppcp-r-page-welcome">
@ -23,19 +38,16 @@ const StepWelcome = ( { setStep, currentStep } ) => {
'Welcome to PayPal Payments',
'woocommerce-paypal-payments'
) }
description={ __(
'Your all-in-one integration for PayPal checkout solutions that enable buyers to pay via PayPal, Pay Later, all major credit/debit cards, Apple Pay, Google Pay, and more.',
'woocommerce-paypal-payments'
) }
description={ onboardingHeaderDescription }
/>
<div className="ppcp-r-inner-container">
<WelcomeFeatures />
<PaymentMethodIcons
icons={ canUseCardPayments ? 'all' : nonAcdcIcons }
icons={ canUseCardPayments ? acdcIcons : bcdcIcons }
/>
<p className="ppcp-r-button__description">
{ __(
`Click the button below to be guided through connecting your existing PayPal account or creating a new one.You will be able to choose the payment options that are right for your store.`,
'Click the button below to be guided through connecting your existing PayPal account or creating a new one. You will be able to choose the payment options that are right for your store.',
'woocommerce-paypal-payments'
) }
</p>
@ -56,7 +68,6 @@ const StepWelcome = ( { setStep, currentStep } ) => {
<WelcomeDocs
useAcdc={ canUseCardPayments }
isFastlane={ canUseFastlane }
isPayLater={ canUsePayLater }
storeCountry={ storeCountry }
/>
<Separator text={ __( 'or', 'woocommerce-paypal-payments' ) } />

View file

@ -28,6 +28,7 @@ const defaultConfig = {
// Extended: Items on right side for ACDC-flow.
extendedMethods: [
{ name: 'CardFields', Component: CardFields },
{ name: 'DigitalWallets', Component: DigitalWallets },
{ name: 'APMs', Component: AlternativePaymentMethods },
],
@ -41,6 +42,26 @@ const defaultConfig = {
'with additional application',
'woocommerce-paypal-payments'
),
// PayPal Checkout description.
paypalCheckoutDescription: __(
'Our all-in-one checkout solution lets you offer PayPal, Pay Later options, and more to help maximise conversion',
'woocommerce-paypal-payments'
),
// Icon groups.
bcdcIcons: [ 'paypal', 'visa', 'mastercard', 'amex', 'discover' ],
acdcIcons: [
'paypal',
'visa',
'mastercard',
'amex',
'discover',
'apple-pay',
'google-pay',
'ideal',
'bancontact',
],
};
const countrySpecificConfigs = {
@ -60,11 +81,35 @@ const countrySpecificConfigs = {
{ name: 'APMs', Component: AlternativePaymentMethods },
{ name: 'Fastlane', Component: Fastlane },
],
paypalCheckoutDescription: __(
'Our all-in-one checkout solution lets you offer PayPal, Venmo, Pay Later options, and more to help maximise conversion',
'woocommerce-paypal-payments'
),
optionalTitle: __( 'Expanded Checkout', 'woocommerce-paypal-payments' ),
optionalDescription: __(
'Accept debit/credit cards, PayPal, Apple Pay, Google Pay, and more. Note: Additional application required for more methods',
'woocommerce-paypal-payments'
),
bcdcIcons: [
'paypal',
'venmo',
'visa',
'mastercard',
'amex',
'discover',
],
acdcIcons: [
'paypal',
'venmo',
'visa',
'mastercard',
'amex',
'discover',
'apple-pay',
'google-pay',
'ideal',
'bancontact',
],
},
GB: {
includedMethods: [
@ -72,6 +117,12 @@ const countrySpecificConfigs = {
{ name: 'PayInThree', Component: PayInThree },
],
},
MX: {
extendedMethods: [
{ name: 'CardFields', Component: CardFields },
{ name: 'APMs', Component: AlternativePaymentMethods },
],
},
};
const filterMethods = ( methods, conditions ) => {
@ -80,22 +131,12 @@ const filterMethods = ( methods, conditions ) => {
);
};
export const usePaymentConfig = (
country,
isPayLater,
useAcdc,
isFastlane
) => {
export const usePaymentConfig = ( country, useAcdc, isFastlane ) => {
return useMemo( () => {
const countryConfig = countrySpecificConfigs[ country ] || {};
const config = { ...defaultConfig, ...countryConfig };
const learnMoreConfig = learnMoreLinks[ country ] || {};
// Filter the "left side" list. PayLater is conditional.
const includedMethods = filterMethods( config.includedMethods, [
( method ) => isPayLater || method.name !== 'PayLater',
] );
// Determine the "right side" items: Either BCDC or ACDC items.
const optionalMethods = useAcdc
? config.extendedMethods
@ -108,9 +149,10 @@ export const usePaymentConfig = (
return {
...config,
includedMethods,
optionalMethods: availableOptionalMethods,
learnMoreConfig,
acdcIcons: config.acdcIcons,
bcdcIcons: config.bcdcIcons,
};
}, [ country, isPayLater, useAcdc, isFastlane ] );
}, [ country, useAcdc, isFastlane ] );
};

View file

@ -3,7 +3,8 @@ import { FeatureSettingsBlock } from '../../../../../ReusableComponents/Settings
import { Content } from '../../../../../ReusableComponents/Elements';
import { TITLE_BADGE_POSITIVE } from '../../../../../ReusableComponents/TitleBadge';
import { selectTab, TAB_IDS } from '../../../../../../utils/tabSelector';
import { setActiveModal } from '../../../../../../data/common/actions';
import { useDispatch } from '@wordpress/data';
import { STORE_NAME as COMMON_STORE_NAME } from '../../../../../../data/common';
const FeatureItem = ( {
isBusy,
@ -14,6 +15,7 @@ const FeatureItem = ( {
enabled,
notes,
} ) => {
const { setActiveModal } = useDispatch( COMMON_STORE_NAME );
const getButtonUrl = ( button ) => {
if ( button.urls ) {
return isSandbox ? button.urls.sandbox : button.urls.live;
@ -30,9 +32,11 @@ const FeatureItem = ( {
);
const handleClick = async ( feature ) => {
if ( feature.action?.type === 'tab' ) {
const highlight = Boolean( feature.action?.highlight );
const tabId = TAB_IDS[ feature.action.tab.toUpperCase() ];
await selectTab( tabId, feature.action.section );
await selectTab( tabId, feature.action.section, highlight );
}
if ( feature.action?.modal ) {
setActiveModal( feature.action.modal );
}

View file

@ -15,8 +15,7 @@ const Todos = () => {
const [ isResetting, setIsResetting ] = useState( false );
const { todos, isReady: areTodosReady, dismissTodo } = useTodos();
// eslint-disable-next-line no-shadow
const { setActiveModal, setActiveHighlight } =
useDispatch( COMMON_STORE_NAME );
const { setActiveModal } = useDispatch( COMMON_STORE_NAME );
const { resetDismissedTodos, setDismissedTodos } =
useDispatch( TODOS_STORE_NAME );
const { createSuccessNotice } = useDispatch( noticesStore );
@ -76,7 +75,6 @@ const Todos = () => {
<TodoSettingsBlock
todosData={ todos }
setActiveModal={ setActiveModal }
setActiveHighlight={ setActiveHighlight }
onDismissTodo={ dismissTodo }
/>
</SettingsCard>

View file

@ -10,8 +10,9 @@ import { scrollAndHighlight } from '../../../../../utils/scrollAndHighlight';
* @param {string} props.parentName - Display name of the parent payment method
* @return {JSX.Element} The formatted message with link
*/
const DependencyMessage = ( { parentId, parentName } ) => {
// Using WordPress createInterpolateElement with proper React elements
const PaymentDependencyMessage = ( { parentId, parentName } ) => {
const displayName = parentName || parentId;
return createInterpolateElement(
/* translators: %s: payment method name */
__(
@ -28,7 +29,7 @@ const DependencyMessage = ( { parentId, parentName } ) => {
scrollAndHighlight( parentId );
} }
>
{ parentName }
{ displayName }
</a>
</strong>
),
@ -36,4 +37,4 @@ const DependencyMessage = ( { parentId, parentName } ) => {
);
};
export default DependencyMessage;
export default PaymentDependencyMessage;

View file

@ -1,21 +1,26 @@
import { useEffect } from '@wordpress/element';
import SettingsCard from '../../../../ReusableComponents/SettingsCard';
import { PaymentMethodsBlock } from '../../../../ReusableComponents/SettingsBlocks';
import usePaymentDependencyState from '../../../../../hooks/usePaymentDependencyState';
import DependencyMessage from './DependencyMessage';
import useSettingDependencyState from '../../../../../hooks/useSettingDependencyState';
import PaymentDependencyMessage from './PaymentDependencyMessage';
import SettingDependencyMessage from './SettingDependencyMessage';
import SpinnerOverlay from '../../../../ReusableComponents/SpinnerOverlay';
import { PaymentHooks, SettingsHooks } from '../../../../../data';
import { useNavigation } from '../../../../../hooks/useNavigation';
/**
* Renders a payment method card with dependency handling
*
* @param {Object} props - Component props
* @param {string} props.id - Unique identifier for the card
* @param {string} props.title - Title of the payment method card
* @param {string} props.description - Description of the payment method
* @param {string} props.icon - Icon path for the payment method
* @param {Array} props.methods - List of payment methods to display
* @param {Object} props.methodsMap - Map of all payment methods by ID
* @param {Function} props.onTriggerModal - Callback when a method is clicked
* @param {boolean} props.isDisabled - Whether the entire card is disabled
* @param {(string|JSX.Element)} props.disabledMessage - Message to show when disabled
* @param {Object} props - Component props
* @param {string} props.id - Unique identifier for the card
* @param {string} props.title - Title of the payment method card
* @param {string} props.description - Description of the payment method
* @param {string} props.icon - Icon path for the payment method
* @param {Array} props.methods - List of payment methods to display
* @param {Object} props.methodsMap - Map of all payment methods by ID
* @param {Function} props.onTriggerModal - Callback when a method is clicked
* @param {boolean} props.isDisabled - Whether the entire card is disabled
* @return {JSX.Element} The rendered component
*/
const PaymentMethodCard = ( {
@ -27,9 +32,27 @@ const PaymentMethodCard = ( {
methodsMap = {},
onTriggerModal,
isDisabled = false,
disabledMessage,
} ) => {
const dependencyState = usePaymentDependencyState( methods, methodsMap );
const { isReady: isPaymentStoreReady } = PaymentHooks.useStore();
const { isReady: isSettingsStoreReady } = SettingsHooks.useStore();
const { handleHighlightFromUrl } = useNavigation();
const paymentDependencies = usePaymentDependencyState(
methods,
methodsMap
);
const settingDependencies = useSettingDependencyState( methods );
useEffect( () => {
if ( isPaymentStoreReady && isSettingsStoreReady ) {
handleHighlightFromUrl();
}
}, [ handleHighlightFromUrl, isPaymentStoreReady, isSettingsStoreReady ] );
if ( ! isPaymentStoreReady || ! isSettingsStoreReady ) {
return <SpinnerOverlay asModal={ true } />;
}
return (
<SettingsCard
@ -41,25 +64,39 @@ const PaymentMethodCard = ( {
>
<PaymentMethodsBlock
paymentMethods={ methods.map( ( method ) => {
const dependency = dependencyState[ method.id ];
const paymentDependency =
paymentDependencies?.[ method.id ];
const settingDependency =
settingDependencies?.[ method.id ];
const dependencyMessage = dependency ? (
<DependencyMessage
parentId={ dependency.parentId }
parentName={ dependency.parentName }
/>
) : null;
let dependencyMessage = null;
let isMethodDisabled = method.isDisabled || isDisabled;
if ( paymentDependency ) {
dependencyMessage = (
<PaymentDependencyMessage
parentId={ paymentDependency.parentId }
parentName={ paymentDependency.parentName }
/>
);
isMethodDisabled = true;
} else if ( settingDependency?.isDisabled ) {
dependencyMessage = (
<SettingDependencyMessage
settingId={ settingDependency.settingId }
requiredValue={
settingDependency.requiredValue
}
methodId={ method.id }
/>
);
isMethodDisabled = true;
}
return {
...method,
isDisabled:
method.isDisabled ||
isDisabled ||
Boolean( dependency?.isDisabled ),
disabledMessage:
method.disabledMessage ||
dependencyMessage ||
disabledMessage,
isDisabled: isMethodDisabled,
disabledMessage: dependencyMessage,
};
} ) }
onTriggerModal={ onTriggerModal }

View file

@ -0,0 +1,113 @@
import { createInterpolateElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { selectTab, TAB_IDS } from '../../../../../utils/tabSelector';
import { scrollAndHighlight } from '../../../../../utils/scrollAndHighlight';
/**
* Transforms camelCase section IDs to kebab-case with ppcp prefix
*
* @param {string} sectionId - The original section ID in camelCase
* @return {string} The transformed section ID in kebab-case with ppcp prefix
*/
const transformSectionId = ( sectionId ) => {
if ( ! sectionId ) {
return sectionId;
}
// Convert camelCase to kebab-case.
// This regex finds capital letters and replaces them with "-lowercase".
const kebabCase = sectionId.replace( /([A-Z])/g, '-$1' ).toLowerCase();
// Add ppcp- prefix if it doesn't already have it.
const prefixed = kebabCase.startsWith( 'ppcp-' )
? kebabCase
: `ppcp-${ kebabCase }`;
return prefixed;
};
/**
* Creates a setting link element
*
* @param {Object} props - Component props
* @param {string} props.settingName - Display name for the setting
* @param {string} props.sectionId - Section ID to scroll to
* @return {JSX.Element} The formatted link element
*/
const SettingLink = ( { settingName, sectionId } ) => (
<strong>
<a
href="#"
onClick={ ( e ) => {
e.preventDefault();
if ( sectionId ) {
const tabId = TAB_IDS.SETTINGS;
// Transform the section ID before passing to selectTab.
const transformedSectionId =
transformSectionId( sectionId );
selectTab( tabId );
setTimeout( () => {
scrollAndHighlight( transformedSectionId );
}, 100 );
}
} }
>
{ settingName }
</a>
</strong>
);
/**
* Component to display a setting dependency message
*
* @param {Object} props - Component props
* @param {string} props.settingId - ID of the required setting
* @param {*} props.requiredValue - Required value for the setting
* @return {JSX.Element} The formatted message
*/
const SettingDependencyMessage = ( { settingId, requiredValue } ) => {
// Setting names mapping.
const settingNames = {
savePaypalAndVenmo: 'Save PayPal and Venmo',
};
// Get a human-friendly setting name.
const settingName = settingNames[ settingId ] || settingId;
const settingLink = (
<SettingLink settingName={ settingName } sectionId={ settingId } />
);
const templates = {
true: __(
'This payment method requires <settingLink /> to be enabled.',
'woocommerce-paypal-payments'
),
false: __(
'This payment method requires <settingLink /> to be disabled.',
'woocommerce-paypal-payments'
),
};
return typeof requiredValue === 'boolean'
? createInterpolateElement( templates[ requiredValue ], {
settingLink,
} )
: createInterpolateElement(
sprintf(
/* translators: %s: required setting value */
__(
'This payment method requires <settingLink /> to be set to "%s".',
'woocommerce-paypal-payments'
),
requiredValue
),
{ settingLink }
);
};
export default SettingDependencyMessage;

View file

@ -28,18 +28,18 @@ const SavePaymentMethods = () => {
className="ppcp--save-payment-methods"
>
<ControlToggleButton
id="ppcp-save-paypal-and-venmo"
label={ __(
'Save PayPal and Venmo',
'woocommerce-paypal-payments'
) }
description={ sprintf(
/* translators: 1: URL to Pay Later documentation, 2: URL to Alternative Payment Methods documentation */
/* translators: 1: URL to Pay Later documentation */
__(
'Securely store your customers\' PayPal accounts for a seamless checkout experience. <br />This will disable all <a target="_blank" rel="noreferrer" href="%1$s">Pay Later</a> features and <a target="_blank" rel="noreferrer" href="%2$s">Alternative Payment Methods</a> on your site.',
'Securely store your customers\' PayPal accounts for a seamless checkout experience. <br />This will disable the <a target="_blank" rel="noreferrer" href="%1$s">Pay Later</a> payment method on your site.',
'woocommerce-paypal-payments'
),
'https://woocommerce.com/document/woocommerce-paypal-payments/#pay-later',
'https://woocommerce.com/document/woocommerce-paypal-payments/#alternative-payment-methods'
'https://woocommerce.com/document/woocommerce-paypal-payments/#pay-later'
) }
value={
features.save_paypal_and_venmo.enabled

View file

@ -41,45 +41,27 @@ export function refresh() {
}
/**
* Side effect. Fetches the ISU-login URL for a sandbox account.
* Side effect. Fetches the ISU-login URL for an account.
*
* @param {string[]} [products=[]] Which products/features to display in the ISU popup.
* @param {Object} [options={}] Options to customize the onboarding workflow.
* @param isSandbox True if is sandbox, otherwise false.
* @return {Function} The thunk function.
*/
export function sandboxOnboardingUrl() {
export function onboardingUrl(
products = [],
options = {},
isSandbox = false
) {
return async () => {
try {
return apiFetch( {
path: REST_CONNECTION_URL_PATH,
method: 'POST',
data: {
useSandbox: true,
products: [ 'EXPRESS_CHECKOUT' ],
},
} );
} catch ( e ) {
return {
success: false,
error: e,
};
}
};
}
/**
* Side effect. Fetches the ISU-login URL for a production account.
*
* @param {string[]} products Which products/features to display in the ISU popup.
* @return {Function} The thunk function.
*/
export function productionOnboardingUrl( products = [] ) {
return async () => {
try {
return apiFetch( {
path: REST_CONNECTION_URL_PATH,
method: 'POST',
data: {
useSandbox: false,
useSandbox: isSandbox,
products,
options,
},
} );
} catch ( e ) {

View file

@ -74,15 +74,6 @@ export const setIsReady = ( isReady ) => setTransient( 'isReady', isReady );
export const setActiveModal = ( activeModal ) =>
setTransient( 'activeModal', activeModal );
/**
* Transient. Sets the active settings highlight.
*
* @param {string} activeHighlight
* @return {Action} The action.
*/
export const setActiveHighlight = ( activeHighlight ) =>
setTransient( 'activeHighlight', activeHighlight );
/**
* Persistent. Sets the sandbox mode on or off.
*

View file

@ -41,8 +41,6 @@ const useHooks = () => {
const { useTransient, usePersistent, dispatch, select } = useStoreData();
const {
persist,
sandboxOnboardingUrl,
productionOnboardingUrl,
authenticateWithCredentials,
authenticateWithOAuth,
startWebhookSimulation,
@ -51,11 +49,8 @@ const useHooks = () => {
// Transient accessors.
const [ activeModal, setActiveModal ] = useTransient( 'activeModal' );
const [ activeHighlight, setActiveHighlight ] =
useTransient( 'activeHighlight' );
// Persistent accessors.
const [ isSandboxMode, setSandboxMode ] = usePersistent( 'useSandbox' );
const [ isManualConnectionMode, setManualConnectionMode ] = usePersistent(
'useManualConnection'
);
@ -73,18 +68,10 @@ const useHooks = () => {
return {
activeModal,
setActiveModal,
activeHighlight,
setActiveHighlight,
isSandboxMode,
setSandboxMode: ( state ) => {
return savePersistent( setSandboxMode, state );
},
isManualConnectionMode,
setManualConnectionMode: ( state ) => {
return savePersistent( setManualConnectionMode, state );
},
sandboxOnboardingUrl,
productionOnboardingUrl,
authenticateWithCredentials,
authenticateWithOAuth,
wooSettings,
@ -109,15 +96,25 @@ export const useStore = () => {
};
export const useSandbox = () => {
const { isSandboxMode, setSandboxMode, sandboxOnboardingUrl } = useHooks();
const { dispatch, usePersistent } = useStoreData();
const [ isSandboxMode, setSandboxMode ] = usePersistent( 'useSandbox' );
const { onboardingUrl } = dispatch;
return { isSandboxMode, setSandboxMode, sandboxOnboardingUrl };
return {
isSandboxMode,
setSandboxMode: ( state ) => {
setSandboxMode( state );
return dispatch.persist();
},
onboardingUrl,
};
};
export const useProduction = () => {
const { productionOnboardingUrl } = useHooks();
const { dispatch } = useStoreData();
const { onboardingUrl } = dispatch;
return { productionOnboardingUrl };
return { onboardingUrl };
};
export const useAuthentication = () => {
@ -226,11 +223,6 @@ export const useActiveModal = () => {
return { activeModal, setActiveModal };
};
export const useActiveHighlight = () => {
const { activeHighlight, setActiveHighlight } = useHooks();
return { activeHighlight, setActiveHighlight };
};
/*
* Busy state management hooks
*/

View file

@ -24,3 +24,9 @@ export const PRODUCT_TYPES = {
PHYSICAL: 'physical',
SUBSCRIPTIONS: 'subscriptions',
};
export const PAYPAL_PRODUCTS = {
ACDC: 'PPCP',
BCDC: 'EXPRESS_CHECKOUT',
VAULTING: 'ADVANCED_VAULTING',
};

View file

@ -19,10 +19,6 @@ const useHooks = () => {
// Read-only flags and derived state.
const flags = useSelect( ( select ) => select( STORE_NAME ).flags(), [] );
const determineProducts = useSelect(
( select ) => select( STORE_NAME ).determineProducts(),
[]
);
// Transient accessors.
const [ isReady ] = useTransient( 'isReady' );
@ -80,7 +76,6 @@ const useHooks = () => {
);
return savePersistent( setProducts, validProducts );
},
determineProducts,
};
};
@ -141,9 +136,9 @@ export const useNavigationState = () => {
};
export const useDetermineProducts = () => {
const { determineProducts } = useHooks();
return determineProducts;
return useSelect( ( select ) => {
return select( STORE_NAME ).determineProductsAndCaps();
}, [] );
};
export const useFlags = () => {

View file

@ -7,6 +7,8 @@
* @file
*/
import { PAYPAL_PRODUCTS, PRODUCT_TYPES } from './configuration';
const EMPTY_OBJ = Object.freeze( {} );
const getState = ( state ) => state || EMPTY_OBJ;
@ -25,44 +27,79 @@ export const flags = ( state ) => {
};
/**
* Returns the products that we use for the production login link in the last onboarding step.
* Returns details about products and capabilities to use for the production login link in
* the last onboarding step.
*
* This selector does not return state-values, but uses the state to derive the products-array
* that should be returned.
*
* @param {{}} state
* @return {string[]} The ISU products, based on choices made in the onboarding wizard.
* @return {{products:string[], options:{}}} The ISU products, based on choices made in the onboarding wizard.
*/
export const determineProducts = ( state ) => {
const derivedProducts = [];
export const determineProductsAndCaps = ( state ) => {
/**
* An array of product-names that are used to build an onboarding URL via the
* PartnerReferrals API. To avoid confusion with the "products" property from the
* Redux store, this collection has a distinct name.
*
* On server-side, this value is referred to as "products" again.
*/
const apiModules = [];
const { isCasualSeller, areOptionalPaymentMethodsEnabled } =
/**
* Internal options that are parsed by the PartnerReferrals class to customize
* the API payload.
*/
const options = {
useSubscriptions: false,
useCardPayments: false,
};
const { isCasualSeller, areOptionalPaymentMethodsEnabled, products } =
persistentData( state );
const { canUseVaulting, canUseCardPayments } = flags( state );
const cardPaymentsEligibleAndSelected =
canUseCardPayments && areOptionalPaymentMethodsEnabled;
if ( ! canUseCardPayments || ! areOptionalPaymentMethodsEnabled ) {
if ( ! cardPaymentsEligibleAndSelected ) {
/**
* Branch 1: Credit Card Payments not available.
* The store uses the Express-checkout product.
*/
derivedProducts.push( 'EXPRESS_CHECKOUT' );
apiModules.push( PAYPAL_PRODUCTS.BCDC );
if ( products?.includes( PRODUCT_TYPES.SUBSCRIPTIONS ) ) {
options.useSubscriptions = true;
}
if ( canUseVaulting ) {
apiModules.push( PAYPAL_PRODUCTS.VAULTING );
}
} else if ( isCasualSeller ) {
/**
* Branch 2: Merchant has no business.
* The store uses the Express-checkout product.
*/
derivedProducts.push( 'EXPRESS_CHECKOUT' );
apiModules.push( PAYPAL_PRODUCTS.BCDC );
} else {
/**
* Branch 3: Merchant is business, and can use CC payments.
* The store uses the advanced PPCP product.
*
* This is the only branch that can use subscriptions.
*/
derivedProducts.push( 'PPCP' );
apiModules.push( PAYPAL_PRODUCTS.ACDC );
if ( products?.includes( PRODUCT_TYPES.SUBSCRIPTIONS ) ) {
options.useSubscriptions = true;
}
if ( canUseVaulting ) {
apiModules.push( PAYPAL_PRODUCTS.VAULTING );
}
}
if ( canUseVaulting ) {
derivedProducts.push( 'ADVANCED_VAULTING' );
}
options.useCardPayments = cardPaymentsEligibleAndSelected;
return derivedProducts;
return { products: apiModules, options };
};

View file

@ -0,0 +1,181 @@
import '@testing-library/jest-dom';
import { PRODUCT_TYPES } from './configuration';
import { determineProductsAndCaps } from './selectors';
describe( 'determineProductsAndCaps selector [casual seller]', () => {
const testCases = [
{
name: 'should return EXPRESS_CHECKOUT when card payments are not available',
state: {
data: {
isCasualSeller: true,
areOptionalPaymentMethodsEnabled: true,
},
flags: { canUseCardPayments: false, canUseVaulting: false },
},
expected: {
products: [ 'EXPRESS_CHECKOUT' ],
options: { useSubscriptions: false, useCardPayments: false },
},
},
{
name: 'should return EXPRESS_CHECKOUT when optional payment methods are disabled',
state: {
data: {
isCasualSeller: true,
areOptionalPaymentMethodsEnabled: false,
},
flags: { canUseCardPayments: true, canUseVaulting: false },
},
expected: {
products: [ 'EXPRESS_CHECKOUT' ],
options: { useSubscriptions: false, useCardPayments: false },
},
},
{
name: 'should return EXPRESS_CHECKOUT for casual sellers with card payments',
state: {
data: {
isCasualSeller: true,
areOptionalPaymentMethodsEnabled: true,
},
flags: { canUseCardPayments: true, canUseVaulting: false },
},
expected: {
products: [ 'EXPRESS_CHECKOUT' ],
options: { useSubscriptions: false, useCardPayments: true },
},
},
{
name: 'should return EXPRESS_CHECKOUT and ADVANCED_VAULTING when card payments are not available but vaulting is',
state: {
data: {
isCasualSeller: true,
areOptionalPaymentMethodsEnabled: true,
},
flags: { canUseCardPayments: false, canUseVaulting: true },
},
expected: {
products: [ 'EXPRESS_CHECKOUT' ],
options: { useSubscriptions: false, useCardPayments: false },
},
},
{
name: 'should ignore SUBSCRIPTION product for casual sellers',
state: {
data: {
isCasualSeller: true,
areOptionalPaymentMethodsEnabled: true,
products: [ PRODUCT_TYPES.SUBSCRIPTIONS ],
},
flags: { canUseCardPayments: false, canUseVaulting: true },
},
expected: {
products: [ 'EXPRESS_CHECKOUT' ],
options: { useSubscriptions: false, useCardPayments: false },
},
},
];
test.each( testCases )( '$name', ( { state, expected } ) => {
const result = determineProductsAndCaps( state );
expect( result ).toEqual( expected );
} );
} );
describe( 'determineProductsAndCaps selector [business seller]', () => {
const testCases = [
{
name: 'should return EXPRESS_CHECKOUT when card payments are not available',
state: {
data: {
isCasualSeller: false,
areOptionalPaymentMethodsEnabled: true,
},
flags: { canUseCardPayments: false, canUseVaulting: false },
},
expected: {
products: [ 'EXPRESS_CHECKOUT' ],
options: { useSubscriptions: false, useCardPayments: false },
},
},
{
name: 'should return EXPRESS_CHECKOUT when optional payment methods are disabled',
state: {
data: {
isCasualSeller: false,
areOptionalPaymentMethodsEnabled: false,
},
flags: { canUseCardPayments: true, canUseVaulting: false },
},
expected: {
products: [ 'EXPRESS_CHECKOUT' ],
options: { useSubscriptions: false, useCardPayments: false },
},
},
{
name: 'should return PPCP for business merchants with card payments',
state: {
data: {
isCasualSeller: false,
areOptionalPaymentMethodsEnabled: true,
},
flags: { canUseCardPayments: true, canUseVaulting: false },
},
expected: {
products: [ 'PPCP' ],
options: { useSubscriptions: false, useCardPayments: true },
},
},
{
name: 'should include ADVANCED_VAULTING when vaulting is available',
state: {
data: {
isCasualSeller: false,
areOptionalPaymentMethodsEnabled: true,
},
flags: { canUseCardPayments: true, canUseVaulting: true },
},
expected: {
products: [ 'PPCP', 'ADVANCED_VAULTING' ],
options: { useSubscriptions: false, useCardPayments: true },
},
},
{
name: 'should return EXPRESS_CHECKOUT and ADVANCED_VAULTING when card payments are not available but vaulting is',
state: {
data: {
isCasualSeller: false,
areOptionalPaymentMethodsEnabled: true,
products: [ PRODUCT_TYPES.VIRTUAL ],
},
flags: { canUseCardPayments: false, canUseVaulting: true },
},
expected: {
products: [ 'EXPRESS_CHECKOUT' ],
options: { useSubscriptions: false, useCardPayments: false },
},
},
{
name: 'should enable the SUBSCRIPTIONS option when a business seller selects the subscriptions-product',
state: {
data: {
isCasualSeller: false,
areOptionalPaymentMethodsEnabled: true,
products: [ PRODUCT_TYPES.SUBSCRIPTIONS ],
},
flags: { canUseCardPayments: true, canUseVaulting: true },
},
expected: {
products: [ 'PPCP', 'ADVANCED_VAULTING' ],
options: { useSubscriptions: true, useCardPayments: true },
},
},
];
test.each( testCases )( '$name', ( { state, expected } ) => {
const result = determineProductsAndCaps( state );
expect( result ).toEqual( expected );
} );
} );

View file

@ -8,6 +8,7 @@ import * as hooks from './hooks';
import * as resolvers from './resolvers';
import { initTodoSync } from '../sync/todo-state-sync';
import { initPaymentDependencySync } from '../sync/payment-methods-sync';
import { initSettingBasedPaymentMethodsSync } from '../sync/setting-based-payment-methods-sync';
/**
* Initializes and registers the settings store with WordPress data layer.
@ -30,6 +31,7 @@ export const initStore = () => {
// Initialize payment method dependency sync.
initPaymentDependencySync();
initSettingBasedPaymentMethodsSync();
return Boolean( wp.data.select( STORE_NAME ) );
};

View file

@ -44,6 +44,7 @@ const defaultPersistent = Object.freeze( {
threeDSecure: 'no-3d-secure',
fastlaneCardholderName: false,
fastlaneDisplayWatermark: false,
__meta: false,
} );
// Reducer logic.

View file

@ -61,8 +61,10 @@ export const initPaymentDependencySync = () => {
( [ key, method ] ) =>
key !== '__meta' &&
method &&
method.depends_on &&
method.depends_on.includes( changedId )
method.depends_on_payment_methods &&
method.depends_on_payment_methods.includes(
changedId
)
)
.map( ( [ key ] ) => key );
@ -115,11 +117,11 @@ const handleRestoreDependents = ( dependentIds, methods ) => {
const checkAllDependenciesSatisfied = ( methodId, methods ) => {
const method = methods[ methodId ];
if ( ! method || ! method.depends_on ) {
if ( ! method || ! method.depends_on_payment_methods ) {
return true;
}
return ! method.depends_on.some( ( parentId ) => {
return ! method.depends_on_payment_methods.some( ( parentId ) => {
const parent = methods[ parentId ];
return ! parent || parent.enabled === false;
} );

View file

@ -0,0 +1,158 @@
import { subscribe, select } from '@wordpress/data';
// Store names
const PAYMENT_STORE = 'wc/paypal/payment';
const SETTINGS_STORE = 'wc/paypal/settings';
// Track original states of methods affected by settings
const settingDependentStates = {};
/**
* Initialize setting dependency synchronization
*/
export const initSettingBasedPaymentMethodsSync = () => {
let previousSettingsState = null;
let isProcessing = false;
const unsubscribe = subscribe( () => {
if ( isProcessing ) {
return;
}
isProcessing = true;
try {
// Get both settings and payment stores
const settingsHooks = select( SETTINGS_STORE );
const paymentHooks = select( PAYMENT_STORE );
if ( ! settingsHooks || ! paymentHooks ) {
isProcessing = false;
return;
}
const settings = settingsHooks.persistentData();
const methods = paymentHooks.persistentData();
if ( ! settings || ! methods ) {
isProcessing = false;
return;
}
if ( ! previousSettingsState ) {
previousSettingsState = { ...settings };
isProcessing = false;
return;
}
// Find which settings changed
const changedSettings = Object.keys( settings ).filter(
( key ) =>
previousSettingsState[ key ] !== undefined &&
settings[ key ] !== previousSettingsState[ key ]
);
if ( changedSettings.length > 0 ) {
// Process affected payment methods for each changed setting
for ( const methodId in methods ) {
if ( methodId === '__meta' || ! methods[ methodId ] ) {
continue;
}
const method = methods[ methodId ];
// Skip methods without setting dependencies
if ( ! method.depends_on_settings?.settings ) {
continue;
}
const { settings: dependencySettings } =
method.depends_on_settings;
// Check if any of the changed settings affects this method
const relevantSettings = Object.values(
dependencySettings
).filter( ( setting ) =>
changedSettings.includes( setting.id )
);
if ( relevantSettings.length > 0 ) {
// Determine if method should be disabled based on new setting values
const shouldBeDisabled = relevantSettings.some(
( setting ) =>
settings[ setting.id ] !== setting.value
);
if ( shouldBeDisabled ) {
// Store original state before disabling
if ( ! ( methodId in settingDependentStates ) ) {
settingDependentStates[ methodId ] =
method.enabled;
}
// Disable the method
methods[ methodId ].enabled = false;
methods[ methodId ].isDisabled = true;
} else {
// Check if all setting dependencies are now satisfied
const allSettingsSatisfied = Object.values(
dependencySettings
).every(
( setting ) =>
settings[ setting.id ] === setting.value
);
// Also check payment method dependencies
const paymentDependenciesSatisfied =
checkPaymentDependenciesSatisfied(
methodId,
methods
);
// If all dependencies are satisfied, restore the original state
if (
allSettingsSatisfied &&
paymentDependenciesSatisfied &&
methodId in settingDependentStates
) {
methods[ methodId ].enabled =
settingDependentStates[ methodId ];
methods[ methodId ].isDisabled = false;
delete settingDependentStates[ methodId ];
}
}
}
}
}
previousSettingsState = { ...settings };
} catch ( error ) {
// Silent error handling
} finally {
isProcessing = false;
}
} );
return unsubscribe;
};
/**
* Check if all payment method dependencies are satisfied for a method
*
* @param {string} methodId - ID of the method to check
* @param {Object} methods - All payment methods
* @return {boolean} True if all dependencies are satisfied
*/
const checkPaymentDependenciesSatisfied = ( methodId, methods ) => {
const method = methods[ methodId ];
if ( ! method || ! method.depends_on_payment_methods ) {
return true;
}
return ! method.depends_on_payment_methods.some( ( parentId ) => {
const parent = methods[ parentId ];
return ! parent || parent.enabled === false;
} );
};
export default initSettingBasedPaymentMethodsSync;

View file

@ -28,23 +28,19 @@ const ACTIVITIES = {
};
export const useHandleOnboardingButton = ( isSandbox ) => {
const { sandboxOnboardingUrl } = CommonHooks.useSandbox();
const { productionOnboardingUrl } = CommonHooks.useProduction();
const products = OnboardingHooks.useDetermineProducts();
const { onboardingUrl } = isSandbox
? CommonHooks.useSandbox()
: CommonHooks.useProduction();
const { products, options } = OnboardingHooks.useDetermineProducts();
const { startActivity } = CommonHooks.useBusyState();
const { authenticateWithOAuth } = CommonHooks.useAuthentication();
const [ onboardingUrl, setOnboardingUrl ] = useState( '' );
const [ onboardingUrlState, setOnboardingUrl ] = useState( '' );
const [ scriptLoaded, setScriptLoaded ] = useState( false );
const timerRef = useRef( null );
useEffect( () => {
const fetchOnboardingUrl = async () => {
let res;
if ( isSandbox ) {
res = await sandboxOnboardingUrl();
} else {
res = await productionOnboardingUrl( products );
}
const res = await onboardingUrl( products, options, isSandbox );
if ( res.success && res.data ) {
setOnboardingUrl( res.data );
@ -54,7 +50,7 @@ export const useHandleOnboardingButton = ( isSandbox ) => {
};
fetchOnboardingUrl();
}, [ isSandbox, productionOnboardingUrl, products, sandboxOnboardingUrl ] );
}, [ isSandbox, products, options, onboardingUrl ] );
useEffect( () => {
/**
@ -62,7 +58,7 @@ export const useHandleOnboardingButton = ( isSandbox ) => {
* 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 ) {
if ( ! onboardingUrlState ) {
return;
}
@ -97,7 +93,7 @@ export const useHandleOnboardingButton = ( isSandbox ) => {
}
} );
};
}, [ onboardingUrl ] );
}, [ onboardingUrlState ] );
const setCompleteHandler = useCallback(
( environment ) => {
@ -119,7 +115,7 @@ export const useHandleOnboardingButton = ( isSandbox ) => {
await authenticateWithOAuth(
sharedId,
authCode,
'sandbox' === environment
environment === 'sandbox'
);
};
@ -148,7 +144,7 @@ export const useHandleOnboardingButton = ( isSandbox ) => {
}, [] );
return {
onboardingUrl,
onboardingUrl: onboardingUrlState,
scriptLoaded,
setCompleteHandler,
removeCompleteHandler,

View file

@ -1,3 +1,5 @@
import { scrollAndHighlight } from '../utils/scrollAndHighlight';
/**
* Navigate to the WooCommerce "Payments" settings tab, i.e. exit the settings app.
*/
@ -22,9 +24,38 @@ const goToPluginSettings = ( panel = null ) => {
window.location.href = url;
};
/**
* Check URL for highlight parameter and scroll to the element if present.
*
* @return {boolean} Whether a highlight parameter was found and processed
*/
const handleHighlightFromUrl = () => {
const urlParams = new URLSearchParams( window.location.search );
const elementId = urlParams.get( 'highlight' );
if ( elementId ) {
setTimeout( () => {
scrollAndHighlight( elementId );
// Clean up the URL by removing the highlight parameter.
urlParams.delete( 'highlight' );
const newUrl =
window.location.pathname +
( urlParams.toString() ? '?' + urlParams.toString() : '' ) +
window.location.hash;
window.history.replaceState( {}, document.title, newUrl );
}, 100 );
return true;
}
return false;
};
export const useNavigation = () => {
return {
goToWooCommercePaymentsTab,
goToPluginSettings,
handleHighlightFromUrl,
};
};

View file

@ -1,3 +1,6 @@
/**
* Custom hook to handle payment-method-based dependencies
*/
import { useSelect } from '@wordpress/data';
/**
@ -22,60 +25,54 @@ const getParentMethodName = ( parentId, methodsMap ) => {
* @return {Array} List of disabled parent IDs, empty if none
*/
const findDisabledParents = ( method, methodsMap ) => {
if ( ! method.depends_on?.length && ! method._disabledByDependency ) {
const dependencies = method.depends_on_payment_methods;
if ( ! dependencies || ! Array.isArray( dependencies ) ) {
return [];
}
const parents = method.depends_on || [];
return parents.filter( ( parentId ) => {
return dependencies.filter( ( parentId ) => {
const parent = methodsMap[ parentId ];
return parent && ! parent.enabled;
} );
};
/**
* Custom hook to handle payment method dependencies
* Hook to evaluate payment method dependencies
*
* @param {Array} methods - List of payment methods
* @param {Object} methodsMap - Map of payment methods by ID
* @return {Object} Dependency state object with methods that should be disabled
* @return {Object} Dependency state object keyed by method ID
*/
const usePaymentDependencyState = ( methods, methodsMap ) => {
return useSelect(
( select ) => {
const paymentStore = select( 'wc/paypal/payment' );
if ( ! paymentStore ) {
return {};
}
const result = {};
return useSelect( () => {
const result = {};
if ( methods && methodsMap && Object.keys( methodsMap ).length > 0 ) {
methods.forEach( ( method ) => {
const disabledParents = findDisabledParents(
method,
methodsMap
);
if ( disabledParents.length > 0 ) {
const parentId = disabledParents[ 0 ];
const parentName = getParentMethodName(
parentId,
if ( method && method.id ) {
const disabledParents = findDisabledParents(
method,
methodsMap
);
result[ method.id ] = {
isDisabled: true,
parentId,
parentName,
};
if ( disabledParents.length > 0 ) {
const parentId = disabledParents[ 0 ];
result[ method.id ] = {
isDisabled: true,
parentId,
parentName: getParentMethodName(
parentId,
methodsMap
),
};
}
}
} );
}
return result;
},
[ methods, methodsMap ]
);
return result;
}, [ methods, methodsMap ] );
};
export default usePaymentDependencyState;

View file

@ -0,0 +1,64 @@
/**
* Custom hook to handle setting-based payment method dependencies
*/
import { useSelect } from '@wordpress/data';
/**
* Check setting dependencies for methods
*
* @param {Array} methods - Array of methods to check
* @return {Object} Setting dependency states mapped by method ID
*/
const useSettingDependencyState = ( methods ) => {
const dependencyState = useSelect(
( select ) => {
const settingsStore = select( 'wc/paypal/settings' );
if ( ! settingsStore || ! methods?.length ) {
return null;
}
// Get settings data
const persistentData = settingsStore.persistentData();
const result = {};
// Process each method
methods.forEach( ( method ) => {
if ( ! method?.id || ! method.depends_on_settings ) {
return;
}
// Handle the settings object structure
if ( method.depends_on_settings.settings ) {
const settingsObj = method.depends_on_settings.settings;
for ( const [ settingId, settingData ] of Object.entries(
settingsObj
) ) {
const requiredId = settingData.id;
const requiredValue = settingData.value;
const actualValue = persistentData[ requiredId ];
// Check if dependency is satisfied
if ( actualValue !== requiredValue ) {
result[ method.id ] = {
isDisabled: true,
settingId: requiredId,
requiredValue,
};
break; // Stop checking once we find a failed dependency
}
}
}
} );
return result;
},
[ methods ]
);
return dependencyState;
};
export default useSettingDependencyState;

View file

@ -15,18 +15,19 @@ import { scrollAndHighlight } from './scrollAndHighlight';
*
* 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
* @param {string} tabId - The ID of the tab to select
* @param {string} [scrollToId] - Optional ID of the element to scroll to
* @param {boolean} highlight - Whether to highlight the element after scrolling to it
* @return {Promise} - Resolves when tab switch and scroll are complete
*/
export const selectTab = ( tabId, scrollToId ) => {
export const selectTab = ( tabId, scrollToId, highlight = false ) => {
return new Promise( ( resolve ) => {
const tab = document.getElementById( tabId );
if ( tab ) {
tab.click();
setTimeout( () => {
const targetId = scrollToId || 'ppcp-settings-container';
scrollAndHighlight( targetId, false ).then( resolve );
scrollAndHighlight( targetId, highlight ).then( resolve );
}, 100 );
} else {
console.error(

View file

@ -10,7 +10,18 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\Applepay\ApplePayGateway;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Button\Helper\MessagesApply;
use WooCommerce\PayPalCommerce\Googlepay\GooglePayGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\BancontactGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\BlikGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\EPSGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\IDealGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\MultibancoGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\MyBankGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\P24Gateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\TrustlyGateway;
use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Settings\Data\Definition\FeaturesDefinition;
use WooCommerce\PayPalCommerce\Settings\Data\Definition\PaymentMethodsDependenciesDefinition;
@ -37,6 +48,7 @@ use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener;
use WooCommerce\PayPalCommerce\Settings\Service\AuthenticationManager;
use WooCommerce\PayPalCommerce\Settings\Service\ConnectionUrlGenerator;
use WooCommerce\PayPalCommerce\Settings\Service\FeaturesEligibilityService;
use WooCommerce\PayPalCommerce\Settings\Service\GatewayRedirectService;
use WooCommerce\PayPalCommerce\Settings\Service\OnboardingUrlManager;
use WooCommerce\PayPalCommerce\Settings\Service\TodosEligibilityService;
use WooCommerce\PayPalCommerce\Settings\Service\TodosSortingAndFilteringService;
@ -45,6 +57,11 @@ use WooCommerce\PayPalCommerce\Settings\Service\DataSanitizer;
use WooCommerce\PayPalCommerce\Settings\Service\SettingsDataManager;
use WooCommerce\PayPalCommerce\Settings\Data\Definition\PaymentMethodsDefinition;
use WooCommerce\PayPalCommerce\PayLaterConfigurator\Factory\ConfigFactory;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\OXXO\OXXO;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayUponInvoice\PayUponInvoiceGateway;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\PayLaterConfigurator\Endpoint\SaveConfig;
use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment;
@ -181,7 +198,8 @@ return array(
'settings.rest.payment' => static function ( ContainerInterface $container ) : PaymentRestEndpoint {
return new PaymentRestEndpoint(
$container->get( 'settings.data.payment' ),
$container->get( 'settings.data.definition.methods' )
$container->get( 'settings.data.definition.methods' ),
$container->get( 'settings.data.definition.method_dependencies' )
);
},
'settings.rest.styling' => static function ( ContainerInterface $container ) : StylingRestEndpoint {
@ -380,12 +398,11 @@ return array(
return new PaymentMethodsDefinition(
$container->get( 'settings.data.payment' ),
$container->get( 'settings.data.definition.method_dependencies' ),
$axo_notices
);
},
'settings.data.definition.method_dependencies' => static function ( ContainerInterface $container ) : PaymentMethodsDependenciesDefinition {
return new PaymentMethodsDependenciesDefinition();
return new PaymentMethodsDependenciesDefinition( $container->get( 'wcgateway.settings' ) );
},
'settings.service.pay_later_status' => static function ( ContainerInterface $container ) : array {
$pay_later_endpoint = $container->get( 'settings.rest.pay_later_messaging' );
@ -539,7 +556,8 @@ return array(
return new FeaturesDefinition(
$container->get( 'settings.service.features_eligibilities' ),
$container->get( 'settings.data.general' ),
$merchant_capabilities
$merchant_capabilities,
$container->get( 'settings.data.settings' )
);
},
'settings.service.features_eligibilities' => static function( ContainerInterface $container ): FeaturesEligibilityService {
@ -566,4 +584,32 @@ return array(
$container->get( 'settings.data.todos' )
);
},
'settings.service.gateway-redirect' => static function (): GatewayRedirectService {
return new GatewayRedirectService();
},
/**
* Returns a list of all payment gateway IDs created by this plugin.
*
* @returns string[] The list of all gateway IDs.
*/
'settings.config.all-gateway-ids' => static function (): array {
return array(
PayPalGateway::ID,
CardButtonGateway::ID,
CreditCardGateway::ID,
AxoGateway::ID,
ApplePayGateway::ID,
GooglePayGateway::ID,
BancontactGateway::ID,
BlikGateway::ID,
EPSGateway::ID,
IDealGateway::ID,
MyBankGateway::ID,
P24Gateway::ID,
TrustlyGateway::ID,
MultibancoGateway::ID,
PayUponInvoiceGateway::ID,
OXXO::ID,
);
},
);

View file

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Settings\Data\Definition;
use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel;
use WooCommerce\PayPalCommerce\Settings\Service\FeaturesEligibilityService;
use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings;
@ -42,21 +43,31 @@ class FeaturesDefinition {
*/
protected array $merchant_capabilities;
/**
* The plugin settings.
*
* @var SettingsModel
*/
protected SettingsModel $plugin_settings;
/**
* Constructor.
*
* @param FeaturesEligibilityService $eligibilities The features eligibility service.
* @param GeneralSettings $settings The general settings service.
* @param array $merchant_capabilities The merchant capabilities.
* @param SettingsModel $plugin_settings The plugin settings.
*/
public function __construct(
FeaturesEligibilityService $eligibilities,
GeneralSettings $settings,
array $merchant_capabilities
array $merchant_capabilities,
SettingsModel $plugin_settings
) {
$this->eligibilities = $eligibilities;
$this->settings = $settings;
$this->merchant_capabilities = $merchant_capabilities;
$this->plugin_settings = $plugin_settings;
}
/**
@ -82,7 +93,7 @@ class FeaturesDefinition {
* @return array[] The array of all available features.
*/
public function all_available_features(): array {
$paylater_countries = array(
$paylater_countries = array(
'UK',
'ES',
'IT',
@ -91,8 +102,9 @@ class FeaturesDefinition {
'DE',
'AU',
);
$store_country = $this->settings->get_woo_settings()['country'];
$country_location = in_array( $store_country, $paylater_countries, true ) ? strtolower( $store_country ) : 'us';
$store_country = $this->settings->get_woo_settings()['country'];
$country_location = in_array( $store_country, $paylater_countries, true ) ? strtolower( $store_country ) : 'us';
$save_paypal_and_venmo = $this->plugin_settings->get_save_paypal_and_venmo();
return array(
'save_paypal_and_venmo' => array(
@ -104,8 +116,9 @@ class FeaturesDefinition {
'type' => 'secondary',
'text' => __( 'Configure', 'woocommerce-paypal-payments' ),
'action' => array(
'type' => 'tab',
'tab' => 'settings',
'type' => 'tab',
'tab' => 'settings',
'section' => 'ppcp-save-paypal-and-venmo',
),
'showWhen' => 'enabled',
'class' => 'small-button',
@ -207,7 +220,7 @@ class FeaturesDefinition {
'action' => array(
'type' => 'tab',
'tab' => 'payment_methods',
'section' => 'ppcp-card-payments-card',
'section' => 'ppcp-googlepay',
'highlight' => 'ppcp-googlepay',
'modal' => 'ppcp-googlepay',
),
@ -287,7 +300,7 @@ class FeaturesDefinition {
'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'
),
'enabled' => $this->merchant_capabilities['pay_later'],
'enabled' => $this->merchant_capabilities['pay_later'] && ! $save_paypal_and_venmo,
'buttons' => array(
array(
'type' => 'secondary',

View file

@ -40,13 +40,6 @@ class PaymentMethodsDefinition {
*/
private PaymentSettings $settings;
/**
* Payment method dependencies definition.
*
* @var PaymentMethodsDependenciesDefinition
*/
private PaymentMethodsDependenciesDefinition $dependencies_definition;
/**
* Conflict notices for Axo gateway.
*
@ -64,18 +57,15 @@ class PaymentMethodsDefinition {
/**
* Constructor.
*
* @param PaymentSettings $settings Payment methods data model.
* @param PaymentMethodsDependenciesDefinition $dependencies_definition Payment dependencies definition.
* @param array $axo_conflicts_notices Conflicts notices for Axo.
* @param PaymentSettings $settings Payment methods data model.
* @param array $axo_conflicts_notices Conflicts notices for Axo.
*/
public function __construct(
PaymentSettings $settings,
PaymentMethodsDependenciesDefinition $dependencies_definition,
array $axo_conflicts_notices = array()
) {
$this->settings = $settings;
$this->dependencies_definition = $dependencies_definition;
$this->axo_conflicts_notices = $axo_conflicts_notices;
$this->settings = $settings;
$this->axo_conflicts_notices = $axo_conflicts_notices;
}
/**
@ -94,26 +84,16 @@ class PaymentMethodsDefinition {
$result = array();
foreach ( $all_methods as $method ) {
$method_id = $method['id'];
// Add dependency info if applicable.
$depends_on = $this->dependencies_definition->get_parent_methods( $method_id );
if ( ! empty( $depends_on ) ) {
$method['depends_on'] = $depends_on;
}
$result[ $method_id ] = $this->build_method_definition(
$method_id,
$method['title'],
$method['description'],
$method['icon'],
$method['fields'] ?? array(),
$depends_on,
$method['warningMessages'] ?? array()
$method['warningMessages'] ?? array(),
);
}
// Add dependency maps to metadata.
$result['__meta'] = array(
'dependencies' => $this->dependencies_definition->get_dependencies(),
'dependents' => $this->dependencies_definition->get_dependents_map(),
);
return $result;
}
@ -121,14 +101,13 @@ class PaymentMethodsDefinition {
* Returns a new payment method configuration array that contains all
* common attributes which must be present in every method definition.
*
* @param string $gateway_id The payment method ID.
* @param string $title Admin-side payment method title.
* @param string $description Admin-side info about the payment method.
* @param string $icon Admin-side icon of the payment method.
* @param array|false $fields Optional. Additional fields to display in the edit modal.
* Setting this to false omits all fields.
* @param array $depends_on Optional. IDs of payment methods that this depends on.
* @param array $warning_messages Optional. Warning messages to display in the UI.
* @param string $gateway_id The payment method ID.
* @param string $title Admin-side payment method title.
* @param string $description Admin-side info about the payment method.
* @param string $icon Admin-side icon of the payment method.
* @param array|false $fields Optional. Additional fields to display in the edit modal.
* Setting this to false omits all fields.
* @param array $warning_messages Optional. Warning messages to display in the UI.
* @return array Payment method definition.
*/
private function build_method_definition(
@ -136,8 +115,7 @@ class PaymentMethodsDefinition {
string $title,
string $description,
string $icon,
$fields = array(),
array $depends_on = array(),
$fields = array(),
array $warning_messages = array()
) : array {
$gateway = $this->wc_gateways[ $gateway_id ] ?? null;
@ -156,11 +134,6 @@ class PaymentMethodsDefinition {
'warningMessages' => $warning_messages,
);
// Add dependency information if provided - ensure it's included directly in the config.
if ( ! empty( $depends_on ) ) {
$config['depends_on'] = $depends_on;
}
if ( is_array( $fields ) ) {
$config['fields'] = array_merge(
array(

View file

@ -9,6 +9,8 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Data\Definition;
use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\Applepay\ApplePayGateway;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Googlepay\GooglePayGateway;
@ -29,19 +31,35 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayUponInvoice\PayUponInvoiceGa
/**
* Class PaymentMethodsDependenciesDefinition
*
* Defines dependency relationships between payment methods.
* Defines dependency relationships between payment methods and settings.
*/
class PaymentMethodsDependenciesDefinition {
/**
* Get all payment method dependencies
* Current settings values
*
* @var Settings
*/
private Settings $settings;
/**
* Constructor
*
* @param Settings $settings Settings instance.
*/
public function __construct( Settings $settings ) {
$this->settings = $settings;
}
/**
* Get payment method to payment method dependencies
*
* Maps dependent method ID => array of parent method IDs.
* A dependent method is disabled if ANY of its required parents is disabled.
*
* @return array The dependency relationships between payment methods
*/
public function get_dependencies(): array {
public function get_payment_method_dependencies(): array {
$dependencies = array(
CardButtonGateway::ID => array( PayPalGateway::ID ),
CreditCardGateway::ID => array( PayPalGateway::ID ),
@ -69,43 +87,114 @@ class PaymentMethodsDependenciesDefinition {
}
/**
* Create a mapping from parent methods to their dependent methods
* Get setting to payment method dependencies.
*
* @return array Parent-to-child dependency map
* Maps method ID => array of required settings with their values.
* A method is disabled if ANY of its required settings doesn't match the required value.
*
* @return array The dependency relationships between settings and payment methods
*/
public function get_dependents_map(): array {
$result = array();
$dependencies = $this->get_dependencies();
public function get_setting_dependencies(): array {
$dependencies = array(
'pay-later' => array(
'savePaypalAndVenmo' => false,
),
);
foreach ( $dependencies as $child_id => $parent_ids ) {
foreach ( $parent_ids as $parent_id ) {
if ( ! isset( $result[ $parent_id ] ) ) {
$result[ $parent_id ] = array();
}
$result[ $parent_id ][] = $child_id;
}
}
return $result;
return apply_filters(
'woocommerce_paypal_payments_setting_dependencies',
$dependencies
);
}
/**
* Get all parent methods that a method depends on
* Get method setting dependencies
*
* Returns the setting dependencies for a specific method ID.
*
* @param string $method_id Method ID to check.
* @return array Setting dependencies for the method or empty array if none exist
*/
public function get_method_setting_dependencies( string $method_id ): array {
$setting_dependencies = $this->get_setting_dependencies();
return $setting_dependencies[ $method_id ] ?? array();
}
/**
* Add dependency information to the payment method definitions
*
* @param array $methods Payment method definitions.
* @return array Payment method definitions with dependency information
*/
public function add_dependency_info_to_methods( array $methods ): array {
foreach ( $methods as $method_id => &$method ) {
// Skip the __meta key.
if ( $method_id === '__meta' ) {
continue;
}
// Add payment method dependency info if applicable.
$payment_method_dependencies = $this->get_method_payment_method_dependencies( $method_id );
if ( ! empty( $payment_method_dependencies ) ) {
$method['depends_on_payment_methods'] = $payment_method_dependencies;
}
// Check if this method has setting dependencies.
$method_setting_dependencies = $this->get_method_setting_dependencies( $method_id );
if ( ! empty( $method_setting_dependencies ) ) {
$settings = array();
foreach ( $method_setting_dependencies as $setting_id => $required_value ) {
$settings[ $setting_id ] = array(
'id' => $setting_id,
'value' => $required_value,
);
}
$method['depends_on_settings'] = array(
'settings' => $settings,
);
}
}
// Add global metadata about settings that affect dependencies.
if ( ! isset( $methods['__meta'] ) ) {
$methods['__meta'] = array();
}
$methods['__meta']['settings_affecting_methods'] = $this->get_all_dependent_settings();
return $methods;
}
/**
* Get payment method dependencies for a specific method
*
* @param string $method_id Method ID to check.
* @return array Array of parent method IDs
*/
public function get_parent_methods( string $method_id ): array {
return $this->get_dependencies()[ $method_id ] ?? array();
public function get_method_payment_method_dependencies( string $method_id ): array {
return $this->get_payment_method_dependencies()[ $method_id ] ?? array();
}
/**
* Get methods that depend on a parent method
* Get all settings that affect payment methods
*
* @param string $parent_id Parent method ID.
* @return array Array of dependent method IDs
* @return array Array of unique setting keys that affect payment methods
*/
public function get_dependent_methods( string $parent_id ): array {
return $this->get_dependents_map()[ $parent_id ] ?? array();
public function get_all_dependent_settings(): array {
$settings = array();
$dependencies = $this->get_setting_dependencies();
foreach ( $dependencies as $method_settings ) {
if ( isset( $method_settings['settings'] ) ) {
foreach ( $method_settings['settings'] as $setting_data ) {
if ( ! in_array( $setting_data['id'], $settings, true ) ) {
$settings[] = $setting_data['id'];
}
}
}
}
return $settings;
}
}

View file

@ -105,12 +105,6 @@ class PaymentSettings extends AbstractDataModel {
return $this->get_paylater_enabled();
default:
if (
! did_filter( 'woocommerce_payment_gateways' )
|| doing_filter( 'woocommerce_payment_gateways' )
) {
return true;
}
$gateway = $this->get_gateway( $method_id );
if ( $gateway ) {

View file

@ -71,7 +71,7 @@ class SettingsModel extends AbstractDataModel {
'soft_descriptor' => '',
// Enum-type string values.
'subtotal_adjustment' => 'skip_details', // Options: [correction|no_details].
'subtotal_adjustment' => 'correction', // Options: [correction|no_details].
'landing_page' => 'any', // Options: [any|login|guest_checkout].
'button_language' => '', // empty or a language locale code.

View file

@ -9,11 +9,11 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Endpoint;
use Exception;
use WP_REST_Server;
use WP_REST_Response;
use WP_REST_Request;
use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings;
use WooCommerce\PayPalCommerce\Settings\Service\InternalRestService;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnersEndpoint;
/**
@ -257,9 +257,17 @@ class CommonRestEndpoint extends RestEndpoint {
* @return WP_REST_Response Seller details, provided by PayPal's API.
*/
public function get_seller_account_info() : WP_REST_Response {
$seller_status = $this->partners_endpoint->seller_status();
try {
$seller_status = $this->partners_endpoint->seller_status();
return $this->return_success( array( 'country' => $seller_status->country() ) );
$seller_data = array(
'country' => $seller_status->country(),
);
return $this->return_success( $seller_data );
} catch ( Exception $ex ) {
return $this->return_error( $ex->getMessage() );
}
}
/**

View file

@ -82,6 +82,16 @@ class LoginLinkRestEndpoint extends RestEndpoint {
return array_map( 'sanitize_text_field', $products );
},
),
'options' => array(
'requires' => false,
'type' => 'array',
'items' => array(
'type' => 'bool',
),
'sanitize_callback' => function ( $flags ) {
return array_map( array( $this, 'to_boolean' ), $flags );
},
),
),
)
);
@ -97,9 +107,10 @@ class LoginLinkRestEndpoint extends RestEndpoint {
public function get_login_url( WP_REST_Request $request ) : WP_REST_Response {
$use_sandbox = $request->get_param( 'useSandbox' );
$products = $request->get_param( 'products' );
$flags = (array) $request->get_param( 'options' );
try {
$url = $this->url_generator->generate( $products, $use_sandbox );
$url = $this->url_generator->generate( $products, $flags, $use_sandbox );
return $this->return_success( $url );
} catch ( \Exception $e ) {

View file

@ -30,6 +30,7 @@ use WP_REST_Request;
use WooCommerce\PayPalCommerce\Applepay\ApplePayGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\EPSGateway;
use WooCommerce\PayPalCommerce\Settings\Data\Definition\PaymentMethodsDefinition;
use WooCommerce\PayPalCommerce\Settings\Data\Definition\PaymentMethodsDependenciesDefinition;
/**
* REST controller for the "Payment Methods" settings tab.
@ -50,14 +51,21 @@ class PaymentRestEndpoint extends RestEndpoint {
*
* @var PaymentSettings
*/
protected PaymentSettings $settings;
protected PaymentSettings $payment_settings;
/**
* The payment method details.
*
* @var PaymentMethodsDefinition
*/
protected PaymentMethodsDefinition $methods_definition;
protected PaymentMethodsDefinition $payment_methods_definition;
/**
* The payment method dependencies.
*
* @var PaymentMethodsDependenciesDefinition
*/
protected PaymentMethodsDependenciesDefinition $payment_methods_dependencies;
/**
* Field mapping for request to profile transformation.
@ -86,12 +94,18 @@ class PaymentRestEndpoint extends RestEndpoint {
/**
* Constructor.
*
* @param PaymentSettings $settings The settings instance.
* @param PaymentMethodsDefinition $methods_definition Payment Method details.
* @param PaymentSettings $payment_settings The settings instance.
* @param PaymentMethodsDefinition $payment_methods_definition Payment Method details.
* @param PaymentMethodsDependenciesDefinition $payment_methods_dependencies The payment method dependencies.
*/
public function __construct( PaymentSettings $settings, PaymentMethodsDefinition $methods_definition ) {
$this->settings = $settings;
$this->methods_definition = $methods_definition;
public function __construct(
PaymentSettings $payment_settings,
PaymentMethodsDefinition $payment_methods_definition,
PaymentMethodsDependenciesDefinition $payment_methods_dependencies
) {
$this->payment_settings = $payment_settings;
$this->payment_methods_definition = $payment_methods_definition;
$this->payment_methods_dependencies = $payment_methods_dependencies;
}
/**
@ -100,7 +114,10 @@ class PaymentRestEndpoint extends RestEndpoint {
* @return array[]
*/
protected function gateways() : array {
return $this->methods_definition->get_definitions();
$methods = $this->payment_methods_definition->get_definitions();
// Add dependency information to the methods.
return $this->payment_methods_dependencies->add_dependency_info_to_methods( $methods );
}
/**
@ -147,45 +164,48 @@ class PaymentRestEndpoint extends RestEndpoint {
* @return WP_REST_Response The current payment methods details.
*/
public function get_details() : WP_REST_Response {
$gateway_settings = array();
$all_methods = $this->gateways();
$gateway_settings = array();
$all_payment_methods = $this->gateways();
// First extract __meta if present.
if ( isset( $all_methods['__meta'] ) ) {
$gateway_settings['__meta'] = $all_methods['__meta'];
if ( isset( $all_payment_methods['__meta'] ) ) {
$gateway_settings['__meta'] = $all_payment_methods['__meta'];
}
foreach ( $all_methods as $key => $method ) {
foreach ( $all_payment_methods as $key => $payment_method ) {
// Skip the __meta key as we've already handled it.
if ( $key === '__meta' ) {
continue;
}
$gateway_settings[ $key ] = array(
'id' => $method['id'],
'title' => $method['title'],
'description' => $method['description'],
'enabled' => $method['enabled'],
'icon' => $method['icon'],
'itemTitle' => $method['itemTitle'],
'itemDescription' => $method['itemDescription'],
'warningMessages' => $method['warningMessages'],
'id' => $payment_method['id'],
'title' => $payment_method['title'],
'description' => $payment_method['description'],
'enabled' => $payment_method['enabled'],
'icon' => $payment_method['icon'],
'itemTitle' => $payment_method['itemTitle'],
'itemDescription' => $payment_method['itemDescription'],
'warningMessages' => $payment_method['warningMessages'],
);
if ( isset( $method['fields'] ) ) {
$gateway_settings[ $key ]['fields'] = $method['fields'];
if ( isset( $payment_method['fields'] ) ) {
$gateway_settings[ $key ]['fields'] = $payment_method['fields'];
}
// Preserve dependency information.
if ( isset( $method['depends_on'] ) ) {
$gateway_settings[ $key ]['depends_on'] = $method['depends_on'];
if ( isset( $payment_method['depends_on_payment_methods'] ) ) {
$gateway_settings[ $key ]['depends_on_payment_methods'] = $payment_method['depends_on_payment_methods'];
}
if ( isset( $payment_method['depends_on_settings'] ) ) {
$gateway_settings[ $key ]['depends_on_settings'] = $payment_method['depends_on_settings'];
}
}
$gateway_settings['paypalShowLogo'] = $this->settings->get_paypal_show_logo();
$gateway_settings['threeDSecure'] = $this->settings->get_three_d_secure();
$gateway_settings['fastlaneCardholderName'] = $this->settings->get_fastlane_cardholder_name();
$gateway_settings['fastlaneDisplayWatermark'] = $this->settings->get_fastlane_display_watermark();
$gateway_settings['paypalShowLogo'] = $this->payment_settings->get_paypal_show_logo();
$gateway_settings['threeDSecure'] = $this->payment_settings->get_three_d_secure();
$gateway_settings['fastlaneCardholderName'] = $this->payment_settings->get_fastlane_cardholder_name();
$gateway_settings['fastlaneDisplayWatermark'] = $this->payment_settings->get_fastlane_display_watermark();
return $this->return_success( apply_filters( 'woocommerce_paypal_payments_payment_methods', $gateway_settings ) );
}
@ -202,21 +222,21 @@ class PaymentRestEndpoint extends RestEndpoint {
$all_methods = $this->gateways();
foreach ( $all_methods as $key => $value ) {
$new_data = $request_data[ $key ];
$new_data = $request_data[ $key ] ?? null;
if ( ! $new_data ) {
continue;
}
if ( isset( $new_data['enabled'] ) ) {
$this->settings->toggle_method_state( $key, $new_data['enabled'] );
$this->payment_settings->toggle_method_state( $key, $new_data['enabled'] );
}
if ( isset( $new_data['title'] ) ) {
$this->settings->set_method_title( $key, sanitize_text_field( $new_data['title'] ) );
$this->payment_settings->set_method_title( $key, sanitize_text_field( $new_data['title'] ) );
}
if ( isset( $new_data['description'] ) ) {
$this->settings->set_method_description( $key, wp_kses_post( $new_data['description'] ) );
$this->payment_settings->set_method_description( $key, wp_kses_post( $new_data['description'] ) );
}
}
@ -225,8 +245,8 @@ class PaymentRestEndpoint extends RestEndpoint {
$this->field_map
);
$this->settings->from_array( $wp_data );
$this->settings->save();
$this->payment_settings->from_array( $wp_data );
$this->payment_settings->save();
return $this->get_details();
}

View file

@ -446,9 +446,24 @@ class AuthenticationManager {
try {
$endpoint = CommonRestEndpoint::seller_account_route( true );
$details = $this->rest_service->get_response( $endpoint );
$response = $this->rest_service->get_response( $endpoint );
if ( ! $response['success'] ) {
$this->enrichment_failed( 'Server failed to provide data', $response );
return;
}
$details = $response['data'];
} catch ( Throwable $exception ) {
$this->logger->warning( 'Could not determine merchant country: ' . $exception->getMessage() );
$this->enrichment_failed( $exception->getMessage() );
return;
}
if ( ! isset( $details['country'] ) ) {
$this->enrichment_failed( 'Missing country in merchant details' );
return;
}
@ -463,6 +478,26 @@ class AuthenticationManager {
$this->common_settings->save();
}
/**
* When the `enrich_merchant_details()` call fails, this method might
* set up a cron task to retry the attempt after some time.
*
* @param string $reason Reason for the failure, will be logged.
* @param mixed $details Optional. Additional details to log.
* @return void
*/
private function enrichment_failed( string $reason, $details = null ) : void {
$this->logger->warning(
'Failed to enrich merchant details: ' . $reason,
array(
'reason' => $reason,
'details' => $details,
)
);
// TODO: Schedule a cron task to retry the enrichment, e.g. with wp_schedule_single_event().
}
/**
* Stores the provided details in the data model.
*

View file

@ -82,12 +82,13 @@ class ConnectionUrlGenerator {
*
* @param array $products An array of product identifiers to include in the sign-up process.
* These determine the PayPal onboarding experience.
* @param array $flags Onboarding choices that will customize the ISU payload.
* @param bool $use_sandbox Whether to generate a sandbox URL.
*
* @return string The generated PayPal onboarding URL.
*/
public function generate( array $products = array(), bool $use_sandbox = false ) : string {
$cache_key = $this->cache_key( $products, $use_sandbox );
public function generate( array $products = array(), array $flags = array(), bool $use_sandbox = false ) : string {
$cache_key = $this->cache_key( $products, $flags, $use_sandbox );
$user_id = get_current_user_id();
$onboarding_url = $this->url_manager->get( $cache_key, $user_id );
$cached_url = $this->try_get_from_cache( $onboarding_url, $cache_key );
@ -100,7 +101,7 @@ class ConnectionUrlGenerator {
$this->logger->info( 'Generating onboarding URL for: ' . $cache_key );
$url = $this->generate_new_url( $use_sandbox, $products, $onboarding_url, $cache_key );
$url = $this->generate_new_url( $use_sandbox, $products, $flags, $onboarding_url, $cache_key );
if ( $url ) {
$this->persist_url( $onboarding_url, $url );
@ -112,18 +113,28 @@ class ConnectionUrlGenerator {
/**
* Generates a cache key from the environment and sorted product array.
*
* Q: Why do we cache the connection URL?
* A: The URL is generated by a partner-referrals API, i.e. it requires a
* remote request; caching the response avoids unnecessary API calls.
*
* @param array $products Product identifiers that are part of the cache key.
* @param array $flags Onboarding flags.
* @param bool $for_sandbox Whether the cache contains a sandbox URL.
*
* @return string The cache key, defining the product list and environment.
*/
protected function cache_key( array $products, bool $for_sandbox ) : string {
protected function cache_key( array $products, array $flags, bool $for_sandbox ) : string {
$environment = $for_sandbox ? 'sandbox' : 'production';
// Sort products alphabetically, to improve cache implementation.
sort( $products );
return $environment . '-' . implode( '-', $products );
// Extract the names of active flags.
$active_flags = array_keys( array_filter( $flags ) );
return strtolower(
$environment . '-' . implode( '-', $products ) . '-' . implode( '-', $active_flags )
);
}
/**
@ -162,12 +173,13 @@ class ConnectionUrlGenerator {
*
* @param bool $for_sandbox Whether to generate a sandbox URL.
* @param array $products The products array.
* @param array $flags Onboarding flags.
* @param OnboardingUrl $onboarding_url The OnboardingUrl object.
* @param string $cache_key The cache key.
*
* @return string The generated URL or an empty string on failure.
*/
protected function generate_new_url( bool $for_sandbox, array $products, OnboardingUrl $onboarding_url, string $cache_key ) : string {
protected function generate_new_url( bool $for_sandbox, array $products, array $flags, OnboardingUrl $onboarding_url, string $cache_key ) : string {
$query_args = array( 'displayMode' => 'minibrowser' );
$onboarding_url->init();
@ -179,7 +191,7 @@ class ConnectionUrlGenerator {
return '';
}
$data = $this->prepare_referral_data( $products, $onboarding_token );
$data = $this->prepare_referral_data( $products, $flags, $onboarding_token );
try {
$referral = $this->partner_referrals->get_value( $for_sandbox );
@ -197,16 +209,18 @@ class ConnectionUrlGenerator {
* Prepares the referral data.
*
* @param array $products The products array.
* @param array $flags Onboarding flags.
* @param string $onboarding_token The onboarding token.
*
* @return array The prepared referral data.
*/
protected function prepare_referral_data( array $products, string $onboarding_token ) : array {
$data = $this->referrals_data
->with_products( $products )
->data();
return $this->referrals_data->append_onboarding_token( $data, $onboarding_token );
protected function prepare_referral_data( array $products, array $flags, string $onboarding_token ) : array {
return $this->referrals_data->data(
$products,
$onboarding_token,
(bool) ( $flags['useSubscriptions'] ?? false ),
(bool) ( $flags['useCardPayments'] ?? false )
);
}
/**

View file

@ -0,0 +1,129 @@
<?php
/**
* Provides gateway redirect handling logic.
*
* @package WooCommerce\PayPalCommerce\Settings\Service
*/
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Service;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Applepay\ApplePayGateway;
use WooCommerce\PayPalCommerce\Googlepay\GooglePayGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\BancontactGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\BlikGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\EPSGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\IDealGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\MultibancoGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\MyBankGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\P24Gateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\TrustlyGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\OXXO\OXXO;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayUponInvoice\PayUponInvoiceGateway;
/**
* GatewayRedirectService class. Handles redirects from individual gateway
* settings URLs to the new Settings UI page.
*/
class GatewayRedirectService {
/**
* List of gateways to redirect.
*
* @var string[]
*/
private array $gateways;
/**
* Constructor.
*/
public function __construct() {
$this->gateways = array(
AxoGateway::ID,
GooglePayGateway::ID,
ApplePayGateway::ID,
CreditCardGateway::ID,
CardButtonGateway::ID,
BancontactGateway::ID,
BlikGateway::ID,
EPSGateway::ID,
IDealGateway::ID,
MyBankGateway::ID,
P24Gateway::ID,
TrustlyGateway::ID,
MultibancoGateway::ID,
OXXO::ID,
PayUponInvoiceGateway::ID,
);
}
/**
* Register hooks.
*
* @return void
*/
public function register(): void {
add_action(
'admin_init',
array( $this, 'handle_redirects' )
);
}
/**
* Handle redirects for gateway settings pages.
*
* @return void
*/
public function handle_redirects(): void {
if ( ! is_admin() ) {
return;
}
// Get current URL parameters.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash
// The sanitize_get_param method handles unslashing and sanitization internally.
$page = isset( $_GET['page'] ) ? $this->sanitize_get_param( $_GET['page'] ) : '';
$tab = isset( $_GET['tab'] ) ? $this->sanitize_get_param( $_GET['tab'] ) : '';
$section = isset( $_GET['section'] ) ? $this->sanitize_get_param( $_GET['section'] ) : '';
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.MissingUnslash
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// phpcs:enable WordPress.Security.NonceVerification.Recommended
// Check if we're on a WooCommerce settings page and checkout tab.
if ( $page !== 'wc-settings' || $tab !== 'checkout' ) {
return;
}
// Check if we're on one of the gateway settings pages we want to redirect.
if ( in_array( $section, $this->gateways, true ) ) {
$redirect_url = admin_url(
sprintf(
'admin.php?page=wc-settings&tab=checkout&section=ppcp-gateway&panel=payment-methods&highlight=%s',
$section
)
);
wp_safe_redirect( $redirect_url );
exit;
}
}
/**
* Sanitizes a GET parameter that could be string or array.
*
* @param mixed $param The parameter to sanitize.
* @return string The sanitized parameter.
*/
private function sanitize_get_param( $param ): string {
if ( is_array( $param ) ) {
return '';
}
return sanitize_text_field( wp_unslash( $param ) );
}
}

View file

@ -10,6 +10,7 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings\Service;
use Throwable;
use RuntimeException;
use Psr\Log\LoggerInterface;
use WP_Http_Cookie;
@ -48,6 +49,7 @@ class InternalRestService {
*
* @param string $endpoint The endpoint for which the token is generated.
* @return mixed The REST response.
* @throws RuntimeException In case the remote request fails, an exception is thrown.
*/
public function get_response( string $endpoint ) {
$rest_url = rest_url( $endpoint );
@ -69,9 +71,11 @@ class InternalRestService {
);
if ( is_wp_error( $response ) ) {
$this->logger->error( 'Internal REST error', array( 'response' => $response ) );
// Error: The wp_remote_request() call failed (timeout or similar).
$error = new RuntimeException( 'Internal REST error' );
$this->logger->error( $error->getMessage(), array( 'response' => $response ) );
return array();
throw $error;
}
$body = wp_remote_retrieve_body( $response );
@ -79,26 +83,22 @@ class InternalRestService {
try {
$json = json_decode( $body, true, 512, JSON_THROW_ON_ERROR );
} catch ( Throwable $exception ) {
// Error: The returned body-string is not valid JSON.
$error = new RuntimeException( 'Internal REST error: Invalid JSON response' );
$this->logger->error(
'Internal REST error: Invalid JSON response',
$error->getMessage(),
array(
'error' => $exception->getMessage(),
'response_body' => $body,
)
);
return array();
throw $error;
}
if ( ! $json || empty( $json['success'] ) ) {
$this->logger->error( 'Internal REST error: Invalid response', array( 'json' => $json ) );
$this->logger->info( 'Internal REST success!', array( 'json' => $json ) );
return array();
}
$this->logger->info( 'Internal REST success', array( 'data' => $json['data'] ) );
return $json['data'];
return $json;
}
/**

View file

@ -171,6 +171,19 @@ class SettingsDataManager {
$this->onboarding_profile->set_setup_done( true );
$this->onboarding_profile->save();
/**
* Fires after the core merchant configuration was applied.
*
* This action indicates that a merchant completed the onboarding wizard.
* The flags contain several choices which the merchant took during the
* onboarding wizard, and provide additional context on which defaults
* should be applied for the new merchant.
*
* Other modules or integrations can use this hook to initialize
* additional plugin settings on first merchant login.
*/
do_action( 'woocommerce_paypal_payments_apply_default_configuration', $flags );
}
/**
@ -253,12 +266,7 @@ class SettingsDataManager {
if ( $flags->is_business_seller && $flags->use_subscriptions ) {
$this->payment_settings->set_save_paypal_and_venmo( true );
if ( $flags->use_card_payments ) {
$this->payment_settings->set_save_card_details( true );
} else {
$this->payment_settings->set_save_card_details( false );
}
$this->payment_settings->set_save_card_details( true );
}
$this->payment_settings->save();

View file

@ -10,10 +10,9 @@ declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\Settings;
use WC_Payment_Gateway;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnersEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
use WooCommerce\PayPalCommerce\Applepay\Assets\AppleProductStatus;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Googlepay\Helper\ApmProductStatus;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\BancontactGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\BlikGateway;
@ -24,10 +23,12 @@ use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\MyBankGateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\P24Gateway;
use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\TrustlyGateway;
use WooCommerce\PayPalCommerce\Settings\Ajax\SwitchSettingsUiEndpoint;
use WooCommerce\PayPalCommerce\Settings\Data\Definition\PaymentMethodsDefinition;
use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile;
use WooCommerce\PayPalCommerce\Settings\Data\TodosModel;
use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener;
use WooCommerce\PayPalCommerce\Settings\Service\GatewayRedirectService;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule;
@ -35,6 +36,7 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\OXXO\OXXO;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayUponInvoice\PayUponInvoiceGateway;
use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCProductStatus;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
@ -42,6 +44,8 @@ use WooCommerce\PayPalCommerce\Settings\Service\SettingsDataManager;
use WooCommerce\PayPalCommerce\Settings\DTO\ConfigurationFlagsDTO;
use WooCommerce\PayPalCommerce\Settings\Enum\ProductChoicesEnum;
use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings;
use WooCommerce\PayPalCommerce\Settings\Data\PaymentSettings;
use WooCommerce\PayPalCommerce\Axo\Helper\CompatibilityChecker;
/**
* Class SettingsModule
@ -423,8 +427,9 @@ class SettingsModule implements ServiceModule, ExecutableModule {
*
* @psalm-suppress MissingClosureParamType
*/
static function ( $methods ) use ( $container ) : array {
if ( ! is_array( $methods ) ) {
function ( $methods ) use ( $container ) : array {
$is_onboarded = $container->get( 'api.merchant_id' ) !== '';
if ( ! is_array( $methods ) || ! $is_onboarded ) {
return $methods;
}
@ -445,6 +450,56 @@ class SettingsModule implements ServiceModule, ExecutableModule {
$methods[] = $applepay_gateway;
$methods[] = $axo_gateway;
$is_payments_page = $container->get( 'wcgateway.is-wc-payments-page' );
$all_gateway_ids = $container->get( 'settings.config.all-gateway-ids' );
if ( $is_payments_page ) {
$methods = array_filter(
$methods,
function ( $method ) use ( $all_gateway_ids ): bool {
if ( ! is_object( $method )
|| $method->id === PayPalGateway::ID
|| ! in_array( $method->id, $all_gateway_ids, true )
) {
return true;
}
if ( ! $this->is_gateway_enabled( $method->id ) ) {
return false;
}
return true;
}
);
}
return $methods;
},
99
);
// Remove the Fastlane gateway if the customer is logged in, ensuring that we don't interfere with the Fastlane gateway status in the settings UI.
add_filter(
'woocommerce_available_payment_gateways',
/**
* Param types removed to avoid third-party issues.
*
* @psalm-suppress MissingClosureParamType
*/
static function ( $methods ) use ( $container ) : array {
if ( ! is_array( $methods ) ) {
return $methods;
}
if ( is_user_logged_in() && ! is_admin() ) {
foreach ( $methods as $key => $method ) {
if ( $method instanceof WC_Payment_Gateway && $method->id === 'ppcp-axo-gateway' ) {
unset( $methods[ $key ] );
break;
}
}
}
return $methods;
}
);
@ -502,24 +557,78 @@ class SettingsModule implements ServiceModule, ExecutableModule {
2
);
add_filter( 'woocommerce_paypal_payments_axo_gateway_should_update_enabled', '__return_false' );
if ( is_admin() ) {
add_filter( 'woocommerce_paypal_payments_axo_gateway_should_update_enabled', '__return_false' );
add_filter(
'woocommerce_paypal_payments_axo_gateway_title',
function ( string $title, WC_Payment_Gateway $gateway ) {
return $gateway->get_option( 'title', $title );
},
10,
2
);
add_filter(
'woocommerce_paypal_payments_axo_gateway_description',
function ( string $description, WC_Payment_Gateway $gateway ) {
return $gateway->get_option( 'description', $description );
},
10,
2
);
}
/**
* Unsets the BCDC black button if merchant is eligible for ACDC.
*/
add_filter(
'woocommerce_paypal_payments_axo_gateway_title',
function ( string $title, WC_Payment_Gateway $gateway ) {
return $gateway->get_option( 'title', $title );
},
10,
2
'woocommerce_paypal_payments_disabled_funding_sources',
/**
* Unsets the BCDC black button if merchant is eligible for ACDC.
*
* @param int[]|string[]|mixed $disable_funding The disabled funding sources.
* @return int[]|string[]|mixed The disabled funding sources.
*
* @psalm-suppress MissingClosureParamType
*/
static function ( $disable_funding ) use ( $container ) {
if ( ! is_array( $disable_funding ) || in_array( 'card', $disable_funding, true ) ) {
return $disable_funding;
}
$dcc_product_status = $container->get( 'wcgateway.helper.dcc-product-status' );
assert( $dcc_product_status instanceof DCCProductStatus );
if ( $dcc_product_status->is_active() ) {
$disable_funding[] = 'card';
}
return $disable_funding;
}
);
add_filter(
'woocommerce_paypal_payments_axo_gateway_description',
function ( string $description, WC_Payment_Gateway $gateway ) {
return $gateway->get_option( 'description', $description );
},
10,
2
// Enable Fastlane after onboarding if the store is compatible.
add_action(
'woocommerce_paypal_payments_apply_default_configuration',
static function () use ( $container ) {
$compatibility_checker = $container->get( 'axo.helpers.compatibility-checker' );
assert( $compatibility_checker instanceof CompatibilityChecker );
$payment_settings = $container->get( 'settings.data.payment' );
assert( $payment_settings instanceof PaymentSettings );
if ( $compatibility_checker->is_fastlane_compatible() ) {
$payment_settings->toggle_method_state( AxoGateway::ID, true );
}
$payment_settings->save();
}
);
// Redirect payment method links in the WC Payment Gateway to the new UI Payment Methods tab.
$gateway_redirect_service = $container->get( 'settings.service.gateway-redirect' );
assert( $gateway_redirect_service instanceof GatewayRedirectService );
$gateway_redirect_service->register();
return true;
}
@ -542,4 +651,17 @@ class SettingsModule implements ServiceModule, ExecutableModule {
protected function render_content() : void {
echo '<div id="ppcp-settings-container"></div>';
}
/**
* Checks if the payment gateway with the given name is enabled.
*
* @param string $gateway_name The gateway name.
* @return bool True if the payment gateway with the given name is enabled, otherwise false.
*/
protected function is_gateway_enabled( string $gateway_name ): bool {
$gateway_settings = get_option( "woocommerce_{$gateway_name}_settings", array() );
$gateway_enabled = $gateway_settings['enabled'] ?? false;
return $gateway_enabled === 'yes';
}
}

View file

@ -100,6 +100,8 @@ class UninstallModule implements ServiceModule, ExtendingModule, ExecutableModul
$clear_db->clear_scheduled_actions( $scheduled_action_names );
$clear_db->clear_actions( $action_names );
update_option( 'woocommerce-ppcp-is-new-merchant', '1' );
wp_send_json_success();
return true;
} catch ( Exception $error ) {

View file

@ -458,9 +458,14 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul
);
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command(
'pcp settings',
$c->get( 'wcgateway.cli.settings.command' )
add_action(
'init',
function() use ( $c ) {
\WP_CLI::add_command(
'pcp settings',
$c->get( 'wcgateway.cli.settings.command' )
);
}
);
}

View file

@ -1,6 +1,6 @@
{
"name": "woocommerce-paypal-payments",
"version": "2.9.6",
"version": "3.0.0",
"description": "WooCommerce PayPal Payments",
"repository": "https://github.com/woocommerce/woocommerce-paypal-payments",
"license": "GPL-2.0",

View file

@ -4,7 +4,7 @@ Tags: woocommerce, paypal, payments, ecommerce, credit card
Requires at least: 6.5
Tested up to: 6.7
Requires PHP: 7.4
Stable tag: 2.9.6
Stable tag: 3.0.0
License: GPLv2
License URI: http://www.gnu.org/licenses/gpl-2.0.html
@ -113,7 +113,7 @@ If you like this extension, please [leave a review on WordPress.org](https://wor
To install and configure WooCommerce PayPal Payments, you will need:
* WordPress Version 6.3 or newer (installed)
* WooCommerce Version 6.9 or newer (installed and activated)
* WooCommerce Version 9.6 or newer (installed and activated)
* PHP Version 7.4 or newer
* PayPal business **or** personal account
@ -156,6 +156,22 @@ If you encounter issues with the PayPal buttons not appearing after an update, p
== Changelog ==
= 3.0.0 - 2025-03-17 =
* Enhancement - Redesigned settings UI for new users #2908
* Enhancement - Enable Fastlane by default on new store setups when eligible #3199
* Enhancement - Enable support for advanced card payments and features for Hong Kong & Singapore #3089
* Fix - Dependency conflict with more recent psr/log versions on PHP8+ #2993
* Fix - PayPal Checkout Gateway subscription migration layer not renewing subscriptions #2699
* Fix - Fatal error when gateway settings initialized too early by third-party plugin #2766
* Fix - Next Payment date for Subscriptions not updating when processing a PayPal Subscriptions renewal order #2959
* Fix - Changing the subscription payment method to ACDC triggers error #2891
* Fix - Standard Card button not appearing in standalone gateway for free trial subscription products #2935
* Fix - Validation error when using Trustly payment method #3031
* Fix - Error in continuation mode due to wrong gateway selection on Checkout block #2996
* Fix - Error in error in PayLaterConfigurator #2989
* Tweak - Removed currency requirement for Vault v3 #2919
* Tweak - Update plugin author from WooCommerce to PayPal
= 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

View file

@ -0,0 +1,221 @@
<?php
declare( strict_types = 1 );
namespace WooCommerce\PayPalCommerce\ApiClient\Repository;
use Mockery;
use WooCommerce\PayPalCommerce\TestCase;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
use function Brain\Monkey\Functions\when;
/**
* @group api
* @group onboarding
*/
class PartnerReferralsDataTest extends TestCase {
/**
* Expected nonce that should appear in the payload.
*/
private const DEFAULT_NONCE = 'a1233wtergfsdt4365tzrshgfbaewa36AGa1233wtergfsdt4365tzrshgfbaewa36AG';
/**
* Sample URL which is used to mock the `admin_url()` response, used to build the return URL.
* Specifically, we want to verify the $path which is appended to the admin URL.
*/
private const ADMIN_URL = 'https://example.com/wp-admin/';
/**
* A sample token that we add to the return URL.
* We pass this const to the `->data()` method to ensure it's appended at the end of the
* return URL as-is.
*/
private const TOKEN = 'SECURE_TOKEN';
/**
* Expected return URL to see at in the payload, including the ppcpToken.
*/
private const RETURN_URL = 'https://example.com/wp-admin/admin.php?page=wc-settings&tab=checkout&section=ppcp-gateway&ppcpToken=SECURE_TOKEN';
private $testee;
private $dccApplies;
public function setUp() : void {
parent::setUp();
$this->dccApplies = Mockery::mock( DccApplies::class );
$this->testee = new PartnerReferralsData( $this->dccApplies );
when( 'admin_url' )->alias( static fn( string $path ) => self::ADMIN_URL . $path );
when( 'add_query_arg' )->justReturn( self::RETURN_URL );
}
/**
* Base structure of the API payload. Each test should modify the returned
* value of the method to meet its expectations.
*
* This avoids repeating the full structure, while also highlighting the
* specific changes that different params will generate.
*
* @return array
*/
private function getBaseExpectedArray() : array {
return [
'partner_config_override' => [
'return_url' => self::RETURN_URL,
'return_url_description' => 'Return to your shop.',
'show_add_credit_card' => true,
],
'legal_consents' => [
[
'type' => 'SHARE_DATA_CONSENT',
'granted' => true,
],
],
'operations' => [
[
'operation' => 'API_INTEGRATION',
'api_integration_preference' => [
'rest_api_integration' => [
'integration_method' => 'PAYPAL',
'integration_type' => 'FIRST_PARTY',
'first_party_details' => [
'features' => [
'PAYMENT',
'REFUND',
'ADVANCED_TRANSACTIONS_SEARCH',
'TRACKING_SHIPMENT_READWRITE',
],
'seller_nonce' => self::DEFAULT_NONCE,
],
],
],
],
],
];
}
/**
* Data provider for testing flag combinations.
*
* @return array[] Test cases with [has_subscriptions, has_cards, expected_changes]
*/
public function flagCombinationsProvider() : array {
return [
'with subscriptions and cards' => [
true, // With subscription?
true, // With cards?
[
'capabilities' => [ 'PAYPAL_WALLET_VAULTING_ADVANCED' ],
'show_add_credit_card' => true,
'has_vault_features' => true,
],
],
'with subscriptions, no cards' => [
true, // With subscription?
false, // With cards?
[
'capabilities' => [ 'PAYPAL_WALLET_VAULTING_ADVANCED' ],
'show_add_credit_card' => false,
'has_vault_features' => true,
],
],
'no subscriptions, with cards' => [
false, // With subscription?
true, // With cards?
[
'show_add_credit_card' => true,
'has_vault_features' => false,
],
],
'no subscriptions, no cards' => [
false, // With subscription?
false, // With cards?
[
'show_add_credit_card' => false,
'has_vault_features' => false,
],
],
];
}
/**
* Ensure the default "products" are derived from the DccApplies response.
*/
public function testDefaultValues() : void {
/**
* Case 1: The data() method gets no parameters, and the DccApplies check
* returns TRUE. Onboarding payload should indicate "PPCP".
*/
$this->dccApplies->expects( 'for_country_currency' )->andReturn( true );
$result = $this->testee->data();
$this->assertEquals( [ 'PPCP' ], $result['products'] );
/**
* Case 2: The data() method gets no parameters, and the DccApplies check
* returns FALSE. Onboarding payload should indicate "EXPRESS_CHECKOUT".
*/
$this->dccApplies->expects( 'for_country_currency' )->andReturn( false );
$result = $this->testee->data();
$this->assertEquals( [ 'EXPRESS_CHECKOUT' ], $result['products'] );
}
/**
* Ensure the generated API payload is stable and contains the expected values.
*
* The test only verifies the "products" and "token" arguments, as those are the
* core params present in the legacy and new UI.
*/
public function testDataStructure() : void {
/**
* Undefined subscription: Keep vaulting in first-party, but don't add the capability.
*/
$result = $this->testee->data( [ 'PPCP' ], self::TOKEN );
$this->dccApplies->shouldNotHaveReceived( 'for_country_currency' );
$expected = $this->getBaseExpectedArray();
$expected['products'] = [ 'PPCP' ];
$expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'][] = 'FUTURE_PAYMENT';
$expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'][] = 'VAULT';
$this->assertArrayNotHasKey( 'capabilities', $expected );
$this->assertEquals( $expected, $result );
}
/**
* Test how different flag combinations affect the data structure.
* Those flags are present in the new UI.
*
* @dataProvider flagCombinationsProvider
*/
public function testDataStructureWithFlags( bool $has_subscriptions, bool $has_cards, array $expected_changes ) : void {
$result = $this->testee->data( [ 'PPCP' ], self::TOKEN, $has_subscriptions, $has_cards );
$expected = $this->getBaseExpectedArray();
$expected['products'] = [ 'PPCP' ];
if ( isset( $expected_changes['capabilities'] ) ) {
$expected['capabilities'] = $expected_changes['capabilities'];
} else {
$this->assertArrayNotHasKey( 'capabilities', $expected );
}
$expected['partner_config_override']['show_add_credit_card'] = $expected_changes['show_add_credit_card'];
if ( $has_subscriptions ) {
$expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'][] = 'BILLING_AGREEMENT';
}
if ( $expected_changes['has_vault_features'] ) {
$expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'][] = 'FUTURE_PAYMENT';
$expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'][] = 'VAULT';
} else {
// Double-check that the features are not present in our expected array
$this->assertNotContains( 'FUTURE_PAYMENT', $expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'] );
$this->assertNotContains( 'VAULT', $expected['operations'][0]['api_integration_preference']['rest_api_integration']['first_party_details']['features'] );
}
$this->assertEquals( $expected, $result );
}
}

View file

@ -1,107 +0,0 @@
<?php
namespace WooCommerce\PayPalCommerce\Tests\E2e;
use WooCommerce\PayPalCommerce\PayPalSubscriptions\RenewalHandler;
class PayPalSubscriptionsRenewalTest extends TestCase
{
public function test_parent_order()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
// Simulates receiving webhook 1 minute after subscription start.
$subscription = $this->createSubscription('-1 minute');
$handler->process([$subscription], 'TRANSACTION-ID');
$renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) );
$this->assertEquals(count($renewal), 0);
}
public function test_renewal_order()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
// Simulates receiving webhook 9 hours after subscription start.
$subscription = $this->createSubscription('-9 hour');
$handler->process([$subscription], 'TRANSACTION-ID');
$renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) );
$this->assertEquals(count($renewal), 1);
}
private function createSubscription(string $startDate)
{
$args = [
'method' => 'POST',
'headers' => [
'Authorization' => 'Basic ' . base64_encode( 'admin:admin' ),
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'customer_id' => 1,
'set_paid' => true,
'payment_method' => 'ppcp-gateway',
'billing' => [
'first_name' => 'John',
'last_name' => 'Doe',
'address_1' => '969 Market',
'address_2' => '',
'city' => 'San Francisco',
'state' => 'CA',
'postcode' => '94103',
'country' => 'US',
'email' => 'john.doe@example.com',
'phone' => '(555) 555-5555'
],
'line_items' => [
[
'product_id' => 156,
'quantity' => 1
]
],
]),
];
$response = wp_remote_request(
'https://woocommerce-paypal-payments.ddev.site/wp-json/wc/v3/orders',
$args
);
$body = json_decode( $response['body'] );
$args = [
'method' => 'POST',
'headers' => [
'Authorization' => 'Basic ' . base64_encode( 'admin:admin' ),
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'start_date' => gmdate( 'Y-m-d H:i:s', strtotime($startDate) ),
'parent_id' => $body->id,
'customer_id' => 1,
'status' => 'active',
'billing_period' => 'day',
'billing_interval' => 1,
'payment_method' => 'ppcp-gateway',
'line_items' => [
[
'product_id' => $_ENV['PAYPAL_SUBSCRIPTIONS_PRODUCT_ID'],
'quantity' => 1
]
],
]),
];
$response = wp_remote_request(
'https://woocommerce-paypal-payments.ddev.site/wp-json/wc/v3/subscriptions?per_page=1',
$args
);
$body = json_decode( $response['body'] );
return wcs_get_subscription($body->id);
}
}

View file

@ -1,70 +0,0 @@
<?php
namespace WooCommerce\PayPalCommerce\Tests\E2e;
use WooCommerce\PayPalCommerce\Compat\Settings\SettingsMap;
use WooCommerce\PayPalCommerce\Compat\Settings\SettingsMapHelper;
use WooCommerce\PayPalCommerce\Settings\Data\AbstractDataModel;
use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
class SettingsTest extends TestCase {
private Settings $settings;
protected function setUp(): void {
$commonSettingsModel = $this->createMock(AbstractDataModel::class);
$commonSettingsModel->method('to_array')->willReturn([
'use_sandbox' => 'yes',
'client_id' => 'abc123',
'client_secret' => 'secret123',
]);
$generalSettingsModel = $this->createMock(AbstractDataModel::class);
$generalSettingsModel->method('to_array')->willReturn([
'is_sandbox' => 'no',
'live_client_id' => 'live_id_123',
'live_client_secret' => 'live_secret_123',
]);
$settingsMap = [
new SettingsMap(
$commonSettingsModel,
[
'client_id' => 'client_id',
'client_secret' => 'client_secret',
]
),
new SettingsMap(
$generalSettingsModel,
[
'is_sandbox' => 'sandbox_on',
'live_client_id' => 'client_id_production',
'live_client_secret' => 'client_secret_production',
]
),
];
$settingsMapHelper = new SettingsMapHelper($settingsMap);
$this->settings = new Settings(
['cart', 'checkout'],
'PayPal Credit Gateway',
['checkout'],
['cart'],
$settingsMapHelper
);
}
public function testGetMappedValue() {
$value = $this->settings->get('sandbox_on');
$this->assertEquals('no', $value);
}
public function testGetThrowsNotFoundExceptionForInvalidKey() {
$this->expectException(NotFoundException::class);
$this->settings->get('invalid_key');
}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
define('E2E_TESTS_ROOT_DIR', dirname(__DIR__));
define('ROOT_DIR', dirname(dirname(E2E_TESTS_ROOT_DIR)));
require_once ROOT_DIR . '/vendor/autoload.php';
if (file_exists(ROOT_DIR . '/.env.e2e')) {
$dotenv = Dotenv\Dotenv::createImmutable(ROOT_DIR, '.env.e2e');
$dotenv->load();
}
if (!isset($_ENV['PPCP_E2E_WP_DIR'])) {
exit('Copy .env.e2e.example to .env.e2e or define the environment variables.' . PHP_EOL);
}
$wpRootDir = str_replace('${ROOT_DIR}', ROOT_DIR, $_ENV['PPCP_E2E_WP_DIR']);
define('WP_ROOT_DIR', $wpRootDir);
$_SERVER['HTTP_HOST'] = ''; // just to avoid a warning
require_once WP_ROOT_DIR . '/wp-load.php';

View file

@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\E2e\Order;
namespace WooCommerce\PayPalCommerce\Tests\Integration\Order;
use Exception;
use WC_Cart;
@ -15,7 +15,7 @@ use WC_Product;
use WC_Product_Simple;
use WC_Session;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\Tests\E2e\TestCase;
use WooCommerce\PayPalCommerce\Tests\Integration\TestCase;
class PurchaseUnitTest extends TestCase
{

View file

@ -1,6 +1,6 @@
<?php
namespace WooCommerce\PayPalCommerce\Tests\E2e;
namespace WooCommerce\PayPalCommerce\Tests\Integration;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\Orders;

View file

@ -0,0 +1,88 @@
<?php
namespace WooCommerce\PayPalCommerce\Tests\Integration;
use WC_Product_Simple;
use WooCommerce\PayPalCommerce\PayPalSubscriptions\RenewalHandler;
class PayPalSubscriptionsRenewalTest extends TestCase {
public function test_renewal_order_is_not_created_just_after_receiving_webhook() {
$c = $this->getContainer();
$handler = new RenewalHandler( $c->get( 'woocommerce.logger.woocommerce' ) );
// Simulates receiving webhook 1 minute after subscription start.
$subscription = $this->createSubscription( '-1 minute' );
$handler->process( [ $subscription ], 'TRANSACTION-ID' );
$renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) );
$this->assertEquals( count( $renewal ), 0 );
}
public function test_renewal_order_is_created_when_receiving_webhook_nine_hours_later() {
$c = $this->getContainer();
$handler = new RenewalHandler( $c->get( 'woocommerce.logger.woocommerce' ) );
// Simulates receiving webhook 9 hours after subscription start.
$subscription = $this->createSubscription( '-9 hour' );
$handler->process( [ $subscription ], 'TRANSACTION-ID' );
$renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) );
$this->assertEquals( count( $renewal ), 1 );
}
private function createSubscription( string $startDate ) {
$order = wc_create_order( [
'customer_id' => 1,
'set_paid' => true,
'payment_method' => 'ppcp-gateway',
'billing' => [
'first_name' => 'John',
'last_name' => 'Doe',
'address_1' => '969 Market',
'address_2' => '',
'city' => 'San Francisco',
'state' => 'CA',
'postcode' => '94103',
'country' => 'US',
'email' => 'john.doe@example.com',
'phone' => '(555) 555-5555'
],
'line_items' => [
[
'product_id' => 42,
'quantity' => 1
]
],
] );
$product = new WC_Product_Simple();
$product->set_props([
'name' => 'Dummy Product',
'regular_price' => 10,
'price' => 10,
'sku' => 'DUMMY SKU',
'manage_stock' => false,
'tax_status' => 'taxable',
'downloadable' => false,
'virtual' => false,
'stock_status' => 'instock',
'weight' => '1.1',
]);
return wcs_create_subscription([
'start_date' => gmdate( 'Y-m-d H:i:s', strtotime($startDate) ),
'parent_id' => $order->get_id(),
'customer_id' => 1,
'status' => 'active',
'billing_period' => 'day',
'billing_interval' => 1,
'payment_method' => 'ppcp-gateway',
'line_items' => [
[
'product_id' => $product->get_id(),
'quantity' => 1
]
],
]);
}
}

View file

@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\E2e;
namespace WooCommerce\PayPalCommerce\Tests\Integration;
use WC_Payment_Token_CC;
use WC_Payment_Tokens;

View file

@ -0,0 +1,84 @@
<?php
namespace WooCommerce\PayPalCommerce\Tests\Integration;
use Mockery;
use WooCommerce\PayPalCommerce\Compat\Settings\GeneralSettingsMapHelper;
use WooCommerce\PayPalCommerce\Compat\Settings\PaymentMethodSettingsMapHelper;
use WooCommerce\PayPalCommerce\Compat\Settings\SettingsMap;
use WooCommerce\PayPalCommerce\Compat\Settings\SettingsMapHelper;
use WooCommerce\PayPalCommerce\Compat\Settings\SettingsTabMapHelper;
use WooCommerce\PayPalCommerce\Compat\Settings\StylingSettingsMapHelper;
use WooCommerce\PayPalCommerce\Compat\Settings\SubscriptionSettingsMapHelper;
use WooCommerce\PayPalCommerce\Settings\Data\AbstractDataModel;
use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
class SettingsTest extends TestCase {
private Settings $settings;
protected function setUp(): void {
$commonSettingsModel = $this->createMock( AbstractDataModel::class );
$commonSettingsModel->method( 'to_array' )->willReturn( [
'use_sandbox' => 'yes',
'client_id' => 'abc123',
'client_secret' => 'secret123',
] );
$generalSettingsModel = $this->createMock( AbstractDataModel::class );
$generalSettingsModel->method( 'to_array' )->willReturn( [
'is_sandbox' => 'no',
'live_client_id' => 'live_id_123',
'live_client_secret' => 'live_secret_123',
] );
$settingsMap = [
new SettingsMap(
$commonSettingsModel,
[
'client_id' => 'client_id',
'client_secret' => 'client_secret',
]
),
new SettingsMap(
$generalSettingsModel,
[
'is_sandbox' => 'sandbox_on',
'live_client_id' => 'client_id_production',
'live_client_secret' => 'client_secret_production',
]
),
];
$settingsMapHelper = new SettingsMapHelper(
$settingsMap,
Mockery::mock( StylingSettingsMapHelper::class ),
Mockery::mock( SettingsTabMapHelper::class ),
Mockery::mock( SubscriptionSettingsMapHelper::class ),
Mockery::mock( GeneralSettingsMapHelper::class ),
Mockery::mock( PaymentMethodSettingsMapHelper::class ),
true
);
$this->settings = new Settings(
[ 'cart', 'checkout' ],
'PayPal Credit Gateway',
[ 'checkout' ],
[ 'cart' ],
$settingsMapHelper
);
}
public function testGetMappedValue() {
$value = $this->settings->get( 'pay_later_messaging_enabled' );
$this->assertTrue( $value );
}
public function testGetThrowsNotFoundExceptionForInvalidKey() {
$this->expectException( NotFoundException::class );
$this->settings->get( 'invalid_key' );
}
}

View file

@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\E2e;
namespace WooCommerce\PayPalCommerce\Tests\Integration;
use WooCommerce\PayPalCommerce\PPCP;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;

View file

@ -1,11 +1,11 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\E2e\Validation;
namespace WooCommerce\PayPalCommerce\Tests\Integration\Validation;
use WooCommerce\PayPalCommerce\Button\Exception\ValidationException;
use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator;
use WooCommerce\PayPalCommerce\Tests\E2e\TestCase;
use WooCommerce\PayPalCommerce\Tests\Integration\TestCase;
class ValidationTest extends TestCase
{

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
define('INTEGRATION_TESTS_ROOT_DIR', dirname(__DIR__));
define('ROOT_DIR', dirname(dirname(INTEGRATION_TESTS_ROOT_DIR)));
require_once ROOT_DIR . '/vendor/autoload.php';
if (file_exists(ROOT_DIR . '/.env.integration')) {
$dotenv = Dotenv\Dotenv::createImmutable(ROOT_DIR, '.env.integration');
$dotenv->load();
}
if (!isset($_ENV['PPCP_INTEGRATION_WP_DIR'])) {
exit('Copy .env.integration.example to .env.integration or define the environment variables.' . PHP_EOL);
}
$wpRootDir = str_replace('${ROOT_DIR}', ROOT_DIR, $_ENV['PPCP_INTEGRATION_WP_DIR']);
define('WP_ROOT_DIR', $wpRootDir);
$_SERVER['HTTP_HOST'] = ''; // just to avoid a warning
require_once WP_ROOT_DIR . '/wp-load.php';

View file

@ -32,6 +32,6 @@ require WP_ROOT_DIR . '/wp-admin/includes/class-wp-importer.php';
require WP_ROOT_DIR . '/wp-content/plugins/woocommerce/includes/admin/importers/class-wc-tax-rate-importer.php';
$taxImporter = new WC_Tax_Rate_Importer();
$taxImporter->import(E2E_TESTS_ROOT_DIR . '/data/tax_rates.csv');
$taxImporter->import(INTEGRATION_TESTS_ROOT_DIR . '/data/tax_rates.csv');
echo PHP_EOL;

View file

@ -3,15 +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.6
* Author: WooCommerce
* Author URI: https://woocommerce.com/
* Version: 3.0.0
* Author: PayPal
* Author URI: https://paypal.com/
* License: GPL-2.0
* Requires PHP: 7.4
* Requires Plugins: woocommerce
* Requires at least: 6.5
* WC requires at least: 9.2
* WC tested up to: 9.5
* WC requires at least: 9.6
* WC tested up to: 9.7
* Text Domain: woocommerce-paypal-payments
*
* @package WooCommerce\PayPalCommerce
@ -27,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-31' );
define( 'PAYPAL_INTEGRATION_DATE', '2025-03-13' );
define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' );
! defined( 'CONNECT_WOO_CLIENT_ID' ) && define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' );