Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/woocommerce-paypal-payments/modules/ppcp-api-client/services.php
+++ b/woocommerce-paypal-payments/modules/ppcp-api-client/services.php
@@ -140,7 +140,10 @@
return new PayPalBearer($container->get('api.paypal-bearer-cache'), $container->get('api.host'), $container->get('api.key'), $container->get('api.secret'), $container->get('woocommerce.logger.woocommerce'), $container->get('settings.settings-provider'));
},
'api.endpoint.partners' => static function (ContainerInterface $container): PartnersEndpoint {
- return new PartnersEndpoint($container->get('api.host'), $container->get('api.bearer'), $container->get('woocommerce.logger.woocommerce'), $container->get('api.factory.sellerstatus'), $container->get('api.partner_merchant_id'), $container->get('api.merchant_id'), $container->get('api.helper.failure-registry'));
+ return new PartnersEndpoint($container->get('api.host'), $container->get('api.bearer'), $container->get('woocommerce.logger.woocommerce'), $container->get('api.factory.sellerstatus'), $container->get('api.partner_merchant_id'), $container->get('api.merchant_id'), $container->get('api.helper.failure-registry'), $container->get('api.partners-seller-status-cache'));
+ },
+ 'api.partners-seller-status-cache' => static function (ContainerInterface $container): Cache {
+ return new Cache('ppcp-seller-status-');
},
'api.factory.sellerstatus' => static function (ContainerInterface $container): SellerStatusFactory {
return new SellerStatusFactory();
--- a/woocommerce-paypal-payments/modules/ppcp-api-client/src/ApiModule.php
+++ b/woocommerce-paypal-payments/modules/ppcp-api-client/src/ApiModule.php
@@ -10,6 +10,7 @@
use WC_Order;
use WooCommercePayPalCommerceApiClientHelperCache;
+use WooCommercePayPalCommerceApiClientEndpointPartnersEndpoint;
use WooCommercePayPalCommerceApiClientHelperFailureRegistry;
use WooCommercePayPalCommerceApiClientHelperOrderTransient;
use WooCommercePayPalCommerceApiClientHelperPartnerAttribution;
@@ -74,6 +75,10 @@
if ($failure_registry instanceof FailureRegistry) {
$failure_registry->clear_failures(FailureRegistry::SELLER_STATUS_KEY);
}
+ $partners_endpoint = $c->has('api.endpoint.partners') ? $c->get('api.endpoint.partners') : null;
+ if ($partners_endpoint instanceof PartnersEndpoint) {
+ $partners_endpoint->clear_seller_status_cache();
+ }
}, 10, 2);
/**
* Flushes the API client caches.
--- a/woocommerce-paypal-payments/modules/ppcp-api-client/src/Endpoint/PartnersEndpoint.php
+++ b/woocommerce-paypal-payments/modules/ppcp-api-client/src/Endpoint/PartnersEndpoint.php
@@ -14,6 +14,7 @@
use WooCommercePayPalCommerceApiClientExceptionPayPalApiException;
use WooCommercePayPalCommerceApiClientExceptionRuntimeException;
use WooCommercePayPalCommerceApiClientFactorySellerStatusFactory;
+use WooCommercePayPalCommerceApiClientHelperCache;
use WooCommercePayPalCommerceApiClientHelperFailureRegistry;
/**
* Class PartnersEndpoint
@@ -64,6 +65,21 @@
*/
private $failure_registry;
/**
+ * The cache for seller status responses.
+ *
+ * @var Cache
+ */
+ private Cache $cache;
+ /**
+ * Cache lifetime for seller status responses, in seconds.
+ */
+ public const SELLER_STATUS_CACHE_TTL = 600;
+ // 10 minutes.
+ /**
+ * Cache key for the seller status response.
+ */
+ public const SELLER_STATUS_CACHE_KEY = 'seller_status';
+ /**
* PartnersEndpoint constructor.
*
* @param string $host The host.
@@ -73,8 +89,9 @@
* @param string $partner_id The partner ID.
* @param string $merchant_id The merchant ID.
* @param FailureRegistry $failure_registry The API failure registry.
+ * @param Cache $cache The cache for seller status responses.
*/
- public function __construct(string $host, Bearer $bearer, LoggerInterface $logger, SellerStatusFactory $seller_status_factory, string $partner_id, string $merchant_id, FailureRegistry $failure_registry)
+ public function __construct(string $host, Bearer $bearer, LoggerInterface $logger, SellerStatusFactory $seller_status_factory, string $partner_id, string $merchant_id, FailureRegistry $failure_registry, Cache $cache)
{
$this->host = $host;
$this->bearer = $bearer;
@@ -83,15 +100,23 @@
$this->partner_id = $partner_id;
$this->merchant_id = $merchant_id;
$this->failure_registry = $failure_registry;
+ $this->cache = $cache;
}
/**
* Returns the current seller status.
*
+ * Uses a transient cache to avoid redundant API calls. The cached response
+ * is returned for up to 10 minutes before a fresh request is made.
+ *
* @return SellerStatus
* @throws RuntimeException When request could not be fulfilled.
*/
public function seller_status(): SellerStatus
{
+ $cached = $this->cache->get(self::SELLER_STATUS_CACHE_KEY);
+ if ($cached instanceof SellerStatus) {
+ return $cached;
+ }
$url = trailingslashit($this->host) . 'v1/customer/partners/' . $this->partner_id . '/merchant-integrations/' . $this->merchant_id;
$bearer = $this->bearer->bearer();
$args = array('method' => 'GET', 'headers' => array('Authorization' => 'Bearer ' . $bearer->token(), 'Content-Type' => 'application/json'));
@@ -112,6 +137,17 @@
}
$this->failure_registry->clear_failures(FailureRegistry::SELLER_STATUS_KEY);
$status = $this->seller_status_factory->from_paypal_response($json);
+ $this->cache->set(self::SELLER_STATUS_CACHE_KEY, $status, self::SELLER_STATUS_CACHE_TTL);
return $status;
}
+ /**
+ * Clears the cached seller status response, forcing a fresh API call
+ * on the next invocation of seller_status().
+ *
+ * @return void
+ */
+ public function clear_seller_status_cache(): void
+ {
+ $this->cache->delete(self::SELLER_STATUS_CACHE_KEY);
+ }
}
--- a/woocommerce-paypal-payments/modules/ppcp-settings/src/Data/GeneralSettings.php
+++ b/woocommerce-paypal-payments/modules/ppcp-settings/src/Data/GeneralSettings.php
@@ -208,16 +208,18 @@
return SellerTypeEnum::BUSINESS === $this->data['seller_type'];
}
/**
- * Whether the merchant is a casual seller using a personal account.
+ * Whether the merchant is a casual seller (i.e., not a confirmed business).
*
- * Note: It's possible that the seller type is unknown, and both methods,
- * `is_casual_seller()` and `is_business_seller()` return false.
+ * Returns true for both explicitly personal accounts and unknown seller
+ * types. This prevents unresolvable UNKNOWN types from triggering
+ * repeated API calls in the seller-type resolution loop, while keeping
+ * the persisted value unchanged.
*
* @return bool
*/
public function is_casual_seller(): bool
{
- return SellerTypeEnum::PERSONAL === $this->data['seller_type'];
+ return SellerTypeEnum::BUSINESS !== $this->data['seller_type'];
}
/**
* Gets the currently connected merchant ID.
--- a/woocommerce-paypal-payments/modules/ppcp-settings/src/Service/Migration/MigrationManager.php
+++ b/woocommerce-paypal-payments/modules/ppcp-settings/src/Service/Migration/MigrationManager.php
@@ -61,7 +61,15 @@
$this->onboarding_profile->set_gateways_refreshed(true);
$this->onboarding_profile->set_gateways_synced(true, true);
$this->onboarding_profile->save();
- $migrations = array('general_settings' => $this->general_settings_migration, 'settings_tab' => $this->settings_tab_migration, 'styling' => $this->styling_settings_migration, 'payment' => $this->payment_settings_migration, 'fastlane' => $this->fastlane_settings_migration);
+ // General settings migration is critical — it resolves the seller type
+ // via the PayPal API. If it fails, abort so migration retries on next load.
+ try {
+ $this->general_settings_migration->migrate();
+ } catch (Exception $error) {
+ $this->logger->warning('Settings migration aborted: seller status API call failed. Will retry on next page load.', array('error_message' => $error->getMessage(), 'error_code' => $error->getCode()));
+ return;
+ }
+ $migrations = array('settings_tab' => $this->settings_tab_migration, 'styling' => $this->styling_settings_migration, 'payment' => $this->payment_settings_migration, 'fastlane' => $this->fastlane_settings_migration);
foreach ($migrations as $name => $migration) {
try {
$migration->migrate();
--- a/woocommerce-paypal-payments/modules/ppcp-settings/src/Service/Migration/SettingsMigration.php
+++ b/woocommerce-paypal-payments/modules/ppcp-settings/src/Service/Migration/SettingsMigration.php
@@ -8,7 +8,6 @@
declare (strict_types=1);
namespace WooCommercePayPalCommerceSettingsServiceMigration;
-use Exception;
use WooCommercePayPalCommerceVendorPsrLogLoggerInterface;
use WooCommercePayPalCommerceApiClientEndpointPartnersEndpoint;
use WooCommercePayPalCommerceSettingsDataGeneralSettings;
@@ -43,16 +42,13 @@
if (empty($this->settings['client_id']) || empty($this->settings['client_secret']) || empty($this->settings['merchant_id'])) {
return;
}
- $country = '';
- $seller_type = SellerTypeEnum::UNKNOWN;
- try {
- $seller_status = $this->partners_endpoint->seller_status();
- $country = $seller_status->country();
- $seller_type = $this->seller_type_resolver->resolve($seller_status);
- } catch (Exception $exception) {
- $this->logger->warning('Seller status API call failed during settings migration; using defaults.', array('error' => $exception->getMessage()));
- }
- $connection = new MerchantConnectionDTO(!empty($this->settings['sandbox_on']), $this->settings['client_id'], $this->settings['client_secret'], $this->settings['merchant_id'], $this->settings['merchant_email'] ?? '', $country, $seller_type);
+ // Save credentials first so they persist even if the API call fails.
+ $connection = new MerchantConnectionDTO(!empty($this->settings['sandbox_on']), $this->settings['client_id'], $this->settings['client_secret'], $this->settings['merchant_id'], $this->settings['merchant_email'] ?? '', '', SellerTypeEnum::UNKNOWN);
+ $this->general_settings->set_merchant_data($connection);
+ $this->general_settings->save();
+ // Resolve seller type — exception propagates so migration can retry.
+ $seller_status = $this->partners_endpoint->seller_status();
+ $connection = new MerchantConnectionDTO(!empty($this->settings['sandbox_on']), $this->settings['client_id'], $this->settings['client_secret'], $this->settings['merchant_id'], $this->settings['merchant_email'] ?? '', $seller_status->country(), $this->seller_type_resolver->resolve($seller_status));
$this->general_settings->set_merchant_data($connection);
$this->general_settings->save();
}
--- a/woocommerce-paypal-payments/modules/ppcp-settings/src/Service/Migration/StylingSettingsMigration.php
+++ b/woocommerce-paypal-payments/modules/ppcp-settings/src/Service/Migration/StylingSettingsMigration.php
@@ -32,6 +32,9 @@
}
public function migrate(): void
{
+ if (empty($this->settings) || !isset($this->settings['smart_button_locations'])) {
+ return;
+ }
$location_styles = array();
$styling_per_location = !empty($this->settings['smart_button_enable_styling_per_location']);
foreach ($this->locations_map() as $old_location => $new_location) {
--- a/woocommerce-paypal-payments/modules/ppcp-settings/src/Service/SellerTypeResolver.php
+++ b/woocommerce-paypal-payments/modules/ppcp-settings/src/Service/SellerTypeResolver.php
@@ -61,10 +61,13 @@
$general_settings->set_merchant_data($connection);
$general_settings->save();
do_action('woocommerce_paypal_payments_clear_apm_product_status');
+ return;
}
} catch (Exception $e) {
$logger->debug('Seller type resolution deferred; will retry in 1 hour.', array('error' => $e->getMessage()));
}
+ // Seller type still unknown — throttle retries to once per hour.
+ $failure_registry->add_failure(FailureRegistry::SELLER_STATUS_KEY);
}
/**
* Checks whether seller type resolution is needed.
@@ -81,7 +84,7 @@
if (!$general_settings->is_merchant_connected()) {
return false;
}
- return !$general_settings->is_business_seller() && !$general_settings->is_casual_seller();
+ return SellerTypeEnum::UNKNOWN === $general_settings->get_merchant_data()->seller_type;
}
/**
* Checks if a specific capability is active for the seller.
--- a/woocommerce-paypal-payments/modules/ppcp-settings/src/SettingsModule.php
+++ b/woocommerce-paypal-payments/modules/ppcp-settings/src/SettingsModule.php
@@ -108,14 +108,22 @@
}
);
add_action('admin_init', function () use ($container): void {
- if (get_option(MigrationManager::OPTION_NAME_MIGRATION_IS_DONE) !== '1') {
- $legacy_settings = (array) get_option('woocommerce-ppcp-settings', array());
- if (!empty($legacy_settings['client_id'])) {
- self::pre_populate_credentials($container);
- $migration_manager = $container->get('settings.service.data-migration');
- assert($migration_manager instanceof MigrationManager);
- $migration_manager->migrate();
- }
+ if (get_option(MigrationManager::OPTION_NAME_MIGRATION_IS_DONE) === '1') {
+ return;
+ }
+ $legacy_settings = (array) get_option('woocommerce-ppcp-settings', array());
+ if (empty($legacy_settings['client_id'])) {
+ return;
+ }
+ self::pre_populate_credentials($container);
+ $migration_manager = $container->get('settings.service.data-migration');
+ assert($migration_manager instanceof MigrationManager);
+ $migration_manager->migrate();
+ $migration_done = get_option(MigrationManager::OPTION_NAME_MIGRATION_IS_DONE);
+ if ((string) $migration_done !== '1') {
+ add_action('admin_notices', static function (): void {
+ printf('<div class="notice notice-warning"><p>%s</p></div>', esc_html__('PayPal Payments: Settings migration could not be completed because the PayPal API is temporarily unavailable. It will retry automatically on the next page load.', 'woocommerce-paypal-payments'));
+ });
}
});
// Resolve unknown seller type on all pages (not just admin), so frontend
--- a/woocommerce-paypal-payments/vendor/composer/autoload_real.php
+++ b/woocommerce-paypal-payments/vendor/composer/autoload_real.php
@@ -35,8 +35,8 @@
$filesToLoad = ComposerAutoloadComposerStaticInitf454a4dc4519aa3bafab294be971ae9b::$files;
$requireFile = Closure::bind(static function ($fileIdentifier, $file) {
- if (empty($GLOBALS['__composer_autoload_files_4985d6d6b38ea29d18629d80fcf13a2ea59ee908'][$fileIdentifier])) {
- $GLOBALS['__composer_autoload_files_4985d6d6b38ea29d18629d80fcf13a2ea59ee908'][$fileIdentifier] = true;
+ if (empty($GLOBALS['__composer_autoload_files_73e8612a12baf30946d5c75c6fb3bb4307f29413'][$fileIdentifier])) {
+ $GLOBALS['__composer_autoload_files_73e8612a12baf30946d5c75c6fb3bb4307f29413'][$fileIdentifier] = true;
require $file;
}
--- a/woocommerce-paypal-payments/vendor/composer/installed.php
+++ b/woocommerce-paypal-payments/vendor/composer/installed.php
@@ -1,5 +1,5 @@
<?php
namespace {
- return array('root' => array('name' => 'woocommerce/woocommerce-paypal-payments', 'pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', 'reference' => '38b2749ae7aa45c3f41683d0be4196a08680997d', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev' => false), 'versions' => array('psr/log' => array('pretty_version' => '1.1.4', 'version' => '1.1.4.0', 'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/log', 'aliases' => array(), 'dev_requirement' => false), 'ralouphie/getallheaders' => array('pretty_version' => '3.0.3', 'version' => '3.0.3.0', 'reference' => '120b605dfeb996808c31b6477290a714d356e822', 'type' => 'library', 'install_path' => __DIR__ . '/../ralouphie/getallheaders', 'aliases' => array(), 'dev_requirement' => false), 'symfony/polyfill-php80' => array('pretty_version' => 'v1.33.0', 'version' => '1.33.0.0', 'reference' => '0cc9dd0f17f61d8131e7df6b84bd344899fe2608', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-php80', 'aliases' => array(), 'dev_requirement' => false), 'wikimedia/composer-merge-plugin' => array('pretty_version' => 'v2.1.0', 'version' => '2.1.0.0', 'reference' => 'a03d426c8e9fb2c9c569d9deeb31a083292788bc', 'type' => 'composer-plugin', 'install_path' => __DIR__ . '/../wikimedia/composer-merge-plugin', 'aliases' => array(), 'dev_requirement' => false), 'woocommerce/woocommerce-paypal-payments' => array('pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', 'reference' => '38b2749ae7aa45c3f41683d0be4196a08680997d', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev_requirement' => false)));
+ return array('root' => array('name' => 'woocommerce/woocommerce-paypal-payments', 'pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', 'reference' => '82a09d9d71bc8d33cb0cf6149c19491ee9334ee9', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev' => false), 'versions' => array('psr/log' => array('pretty_version' => '1.1.4', 'version' => '1.1.4.0', 'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/log', 'aliases' => array(), 'dev_requirement' => false), 'ralouphie/getallheaders' => array('pretty_version' => '3.0.3', 'version' => '3.0.3.0', 'reference' => '120b605dfeb996808c31b6477290a714d356e822', 'type' => 'library', 'install_path' => __DIR__ . '/../ralouphie/getallheaders', 'aliases' => array(), 'dev_requirement' => false), 'symfony/polyfill-php80' => array('pretty_version' => 'v1.33.0', 'version' => '1.33.0.0', 'reference' => '0cc9dd0f17f61d8131e7df6b84bd344899fe2608', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-php80', 'aliases' => array(), 'dev_requirement' => false), 'wikimedia/composer-merge-plugin' => array('pretty_version' => 'v2.1.0', 'version' => '2.1.0.0', 'reference' => 'a03d426c8e9fb2c9c569d9deeb31a083292788bc', 'type' => 'composer-plugin', 'install_path' => __DIR__ . '/../wikimedia/composer-merge-plugin', 'aliases' => array(), 'dev_requirement' => false), 'woocommerce/woocommerce-paypal-payments' => array('pretty_version' => 'dev-trunk', 'version' => 'dev-trunk', 'reference' => '82a09d9d71bc8d33cb0cf6149c19491ee9334ee9', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev_requirement' => false)));
}
--- a/woocommerce-paypal-payments/woocommerce-paypal-payments.php
+++ b/woocommerce-paypal-payments/woocommerce-paypal-payments.php
@@ -4,7 +4,7 @@
* 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: 4.0.1
+ * Version: 4.0.2
* Author: PayPal
* Author URI: https://paypal.com/
* License: GPL-2.0
@@ -24,7 +24,7 @@
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', '2026-03-17');
+define('PAYPAL_INTEGRATION_DATE', '2026-04-01');
define('PPCP_PAYPAL_BN_CODE', 'Woo_PPCP');
!defined('CONNECT_WOO_CLIENT_ID') && define('CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P');
!defined('CONNECT_WOO_SANDBOX_CLIENT_ID') && define('CONNECT_WOO_SANDBOX_CLIENT_ID', 'AYmOHbt1VHg-OZ_oihPdzKEVbU3qg0qXonBcAztuzniQRaKE0w1Hr762cSFwd4n8wxOl-TCWohEa0XM_');