Merge branch 'trunk' into PCP-4854-phase-1-opt-in-via-banner

This commit is contained in:
Narek Zakarian 2025-06-26 17:54:29 +04:00
commit d812264529
No known key found for this signature in database
GPG key ID: 07AFD7E7A9C164A7
21 changed files with 1289 additions and 426 deletions

View file

@ -12,7 +12,7 @@ jobs:
name: PHP ${{ matrix.php-versions }} WC ${{ matrix.wc-versions }}
steps:
- uses: ddev/github-action-setup-ddev@v1
- uses: ddev/github-action-setup-ddev@1c7ef18595da42355373cb6d9417a6f44d758b93 # v1.10.1
with:
autostart: false

View file

@ -27,7 +27,7 @@ jobs:
create_archive:
needs: check_version
uses: inpsyde/reusable-workflows/.github/workflows/build-plugin-archive.yml@main
uses: inpsyde/reusable-workflows/.github/workflows/build-plugin-archive.yml@a9af34f34e95cbe18703198c7e972e97ebcd7473
with:
PHP_VERSION: 7.4
NODE_VERSION: 22

View file

@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # v2.34.1
with:
php-version: 7.4

View file

@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # v2.34.1
with:
php-version: ${{ matrix.php-versions }}
@ -22,7 +22,7 @@ jobs:
run: composer validate
- name: Install dependencies
uses: ramsey/composer-install@v1
uses: ramsey/composer-install@a7320a0581dcd0432930c48a0e7ced67e6ec17e8 # v1.3.0
with:
composer-options: "--prefer-dist"

View file

@ -14,7 +14,7 @@ jobs:
- name: Check spelling
id: spelling
uses: crate-ci/typos@v1.30.2
uses: crate-ci/typos@7bc041cbb7ca9167c9e0e4ccbb26f48eb0f9d4e0 # v1.30.2
with:
# Path to config file
config: .github/workflows-config/typos.toml

View file

@ -1,5 +1,21 @@
*** Changelog ***
= 3.0.7 - xxxx-xx-xx =
* Enhancement - Deprecate `application_context` in favor of `experience_context` object #3431
**NOTE**: If you were extending/modifying the `application_context` object programmatically, you may need to update your code to utilize `experience_context` for your customizations.
* Enhancement - Add Contact Module feature
* Enhancement - Add WooCommerce Tracks integration
* Enhancement - Onboarding notification for Firefox browser #3433
* Enhancement - Reset BN code on plugin uninstall #3471
* Enhancement - Add "Stay updated with PayPal" option in the old and new settings UI #3430
* Enhancement - Add French Territories to the supported ACDC countries list #3438
* Enhancement - Auto-enable logging during onboarding #3369
* Fix - DUPLICATE_INVOICE_ID in Sandbox due to missing invoice prefix #3435
* Fix - Subscription product could not be unlinked from PayPal Subscription #3429
* Fix - PayPal button greyed out on single product page for variable products with >2 attributes #3395
* Fix - APMs automatically enabled despite selecting "No, ..." during onboarding #3362
* Fix - Ditch items logic does not work when using saved card payment #3476
= 3.0.6 - 2025-05-27 =
* Enhancement - Implement 3D secure check for Google Pay #3163
* Enhancement - Add options for "Disable Credit Cards" and "Language" #3226

View file

@ -2145,7 +2145,7 @@ return array(
$feature_enabled = (bool) apply_filters(
// phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores -- feature flags use this convention
'woocommerce.feature-flags.woocommerce_paypal_payments.contact_module_enabled',
getenv( 'PCP_CONTACT_MODULE_ENABLED' ) === '1'
getenv( 'PCP_CONTACT_MODULE_ENABLED' ) !== '0'
);
/**

View file

@ -1,6 +1,6 @@
{
"name": "woocommerce-paypal-payments",
"version": "3.0.6",
"version": "3.0.7",
"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.8
Requires PHP: 7.4
Stable tag: 3.0.6
Stable tag: 3.0.7
License: GPLv2
License URI: http://www.gnu.org/licenses/gpl-2.0.html
@ -156,6 +156,22 @@ If you encounter issues with the PayPal buttons not appearing after an update, p
== Changelog ==
= 3.0.7 - xxxx-xx-xx =
* Enhancement - Deprecate `application_context` in favor of `experience_context` object #3431
**NOTE**: If you were extending/modifying the `application_context` object programmatically, you may need to update your code to utilize `experience_context` for your customizations.
* Enhancement - Add Contact Module feature
* Enhancement - Add WooCommerce Tracks integration
* Enhancement - Onboarding notification for Firefox browser #3433
* Enhancement - Reset BN code on plugin uninstall #3471
* Enhancement - Add "Stay updated with PayPal" option in the old and new settings UI #3430
* Enhancement - Add French Territories to the supported ACDC countries list #3438
* Enhancement - Auto-enable logging during onboarding #3369
* Fix - DUPLICATE_INVOICE_ID in Sandbox due to missing invoice prefix #3435
* Fix - Subscription product could not be unlinked from PayPal Subscription #3429
* Fix - PayPal button greyed out on single product page for variable products with >2 attributes #3395
* Fix - APMs automatically enabled despite selecting "No, ..." during onboarding #3362
* Fix - Ditch items logic does not work when using saved card payment #3476
= 3.0.6 - 2025-05-27 =
* Enhancement - Implement 3D secure check for Google Pay #3163
* Enhancement - Add options for "Disable Credit Cards" and "Language" #3226

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\Integration\Factories;
use WC_Coupon;
use WooCommerce\PayPalCommerce\Tests\Integration\Fixtures\DiscountPresets;
class CouponFactory
{
private array $created_coupon_ids = [];
/**
* @param string $preset_name
* @return WC_Coupon
* @throws \WC_Data_Exception
*/
public function createFromPreset(string $preset_name): WC_Coupon
{
$presets = DiscountPresets::get();
if (!isset($presets[$preset_name])) {
throw new \WC_Data_Exception('invalid_preset', "Coupon preset '{$preset_name}' not found");
}
$preset = $presets[$preset_name];
if (!isset($preset['coupon_code'])) {
throw new \WC_Data_Exception('invalid_preset', "Preset '{$preset_name}' is not a coupon");
}
return $this->createCoupon($preset);
}
/**
* @param array $preset
* @return WC_Coupon
*/
private function createCoupon(array $preset): WC_Coupon
{
$coupon = new WC_Coupon();
$coupon->set_code($preset['coupon_code']);
$coupon->set_discount_type($preset['type']);
$coupon->set_amount($preset['amount']);
$coupon->set_status('publish');
$coupon->save();
$this->created_coupon_ids[] = $coupon->get_id();
return $coupon;
}
/**
* @param string $coupon_code
* @return bool
*/
public function exists(string $coupon_code): bool
{
return (bool)wc_get_coupon_id_by_code($coupon_code);
}
/**
* @param string $coupon_code
* @return WC_Coupon|null
*/
public function getByCode(string $coupon_code): ?WC_Coupon
{
$coupon_id = wc_get_coupon_id_by_code($coupon_code);
return $coupon_id ? new WC_Coupon($coupon_id) : null;
}
/**
* Delete all created coupons
*/
public function cleanup(): void
{
foreach ($this->created_coupon_ids as $coupon_id) {
wp_delete_post($coupon_id, true);
}
$this->created_coupon_ids = [];
}
/**
* @return array
*/
public function getCreatedIds(): array
{
return $this->created_coupon_ids;
}
}

View file

@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\Integration\Factories;
use WC_Order;
use WC_Order_Item_Product;
use WC_Order_Item_Fee;
use WooCommerce\PayPalCommerce\Tests\Integration\Fixtures\ProductPresets;
use WooCommerce\PayPalCommerce\Tests\Integration\Fixtures\DiscountPresets;
class OrderFactory
{
private array $created_order_ids = [];
private ProductFactory $product_factory;
private CouponFactory $coupon_factory;
public function __construct(
ProductFactory $product_factory = null,
CouponFactory $coupon_factory = null
)
{
$this->product_factory = $product_factory ?? new ProductFactory();
$this->coupon_factory = $coupon_factory ?? new CouponFactory();
}
/**
* @param int $customer_id
* @param string $payment_method
* @param array $product_presets
* @param array $discount_presets
* @param bool $set_paid
* @return WC_Order
* @throws \WC_Data_Exception
*/
public function create(
int $customer_id,
string $payment_method,
array $product_presets,
array $discount_presets = [],
bool $set_paid = true
): WC_Order
{
$products = $this->resolveProductPresets($product_presets);
$discounts = $this->resolveDiscountPresets($discount_presets);
$order = wc_create_order([
'customer_id' => $customer_id,
'set_paid' => $set_paid,
]);
if (is_wp_error($order)) {
throw new \WC_Data_Exception('order_creation_failed', 'Failed to create order');
}
$this->setBillingAddress($order);
$this->addProductsToOrder($order, $products);
$this->applyDiscountsToOrder($order, $discounts);
$order->set_payment_method($payment_method);
$order->calculate_totals();
$order->save();
return $order;
}
/**
* @param WC_Order $order
*/
private function setBillingAddress(WC_Order $order): void
{
$order->set_billing_first_name('John');
$order->set_billing_last_name('Doe');
$order->set_billing_address_1('969 Market');
$order->set_billing_city('San Francisco');
$order->set_billing_state('CA');
$order->set_billing_postcode('94103');
$order->set_billing_country('US');
$order->set_billing_email('john.doe@example.com');
$order->set_billing_phone('(555) 555-5555');
}
/**
* @param WC_Order $order
* @param array $products
* @throws \WC_Data_Exception
*/
private function addProductsToOrder(WC_Order $order, array $products): void
{
foreach ($products as $product_data) {
$product_id = null;
if (!empty($product_data['sku'])) {
$product_sku = $product_data['sku'];
$product_id = wc_get_product_id_by_sku($product_sku);
}
if (!$product_id && isset($product_data['id'])) {
$product_id = $product_data['id'];
}
if (!$product_id) {
throw new \WC_Data_Exception('invalid_product', "Product not found - no valid SKU or ID provided");
}
$variation_id = $product_data['variation_id'] ?? 0;
$product_type = $product_data['type'] ?? 'simple';
$product = wc_get_product($variation_id ?: $product_id);
if (!$product) {
throw new \WC_Data_Exception('invalid_product', "Product {$product_id} not found");
}
// Use appropriate item class based on product type
$item = $this->createOrderItem($product_type, $product_data, $product);
if ($variation_id && $product->is_type('variation')) {
$item->set_variation_data($product->get_variation_attributes());
}
$order->add_item($item);
}
}
/**
* @param WC_Order $order
* @param array $discounts
*/
private function applyDiscountsToOrder(WC_Order $order, array $discounts): void
{
foreach ($discounts as $discount) {
if (isset($discount['coupon_code'])) {
$order->apply_coupon($discount['coupon_code']);
}
if (isset($discount['fee'])) {
$fee = new WC_Order_Item_Fee();
$fee->set_props([
'name' => $discount['fee']['name'],
'amount' => -abs($discount['fee']['amount']),
'total' => -abs($discount['fee']['amount']),
]);
$order->add_item($fee);
}
}
}
/**
* @param array $product_presets
* @return array
* @throws \WC_Data_Exception
*/
private function resolveProductPresets(array $product_presets): array
{
$available_presets = ProductPresets::get();
$products = [];
foreach ($product_presets as $preset) {
if (is_string($preset)) {
if (!isset($available_presets[$preset])) {
throw new \WC_Data_Exception('invalid_preset', "Product preset '{$preset}' not found");
}
$products[] = $available_presets[$preset];
} elseif (is_array($preset)) {
$preset_name = $preset['preset'];
$quantity = $preset['quantity'] ?? 1;
if (!isset($available_presets[$preset_name])) {
throw new \WC_Data_Exception('invalid_preset', "Product preset '{$preset_name}' not found");
}
$product_data = $available_presets[$preset_name];
$product_data['quantity'] = $quantity;
$products[] = $product_data;
}
}
return $products;
}
/**
* @param array $discount_presets
* @return array
* @throws \WC_Data_Exception
*/
private function resolveDiscountPresets(array $discount_presets): array
{
$available_presets = DiscountPresets::get();
$discounts = [];
foreach ($discount_presets as $preset) {
if (!isset($available_presets[$preset])) {
throw new \WC_Data_Exception('invalid_preset', "Discount preset '{$preset}' not found");
}
$discounts[] = $available_presets[$preset];
}
return $discounts;
}
/**
* Delete all created orders
*/
public function cleanup(): void
{
foreach ($this->created_order_ids as $order_id) {
wp_delete_post($order_id, true);
}
$this->created_order_ids = [];
}
/**
* @return array
*/
public function getCreatedIds(): array
{
return $this->created_order_ids;
}
/**
* @param string $product_type
* @param array $product_data
* @param \WC_Product $product
* @return \WC_Order_Item_Product
*/
private function createOrderItem(string $product_type, array $product_data, \WC_Product $product): \WC_Order_Item_Product
{
$item = new \WC_Order_Item_Product();
$item->set_props([
'product_id' => $product->get_id(),
'variation_id' => $product_data['variation_id'] ?? 0,
'quantity' => $product_data['quantity'],
'subtotal' => $product->get_price() * $product_data['quantity'],
'total' => $product->get_price() * $product_data['quantity'],
]);
return $item;
}
}

View file

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\Integration\Factories;
use WC_Product_Simple;
use WC_Product_Variable;
use WC_Product_Variation;
use WooCommerce\PayPalCommerce\Tests\Integration\Fixtures\ProductPresets;
class ProductFactory
{
private array $created_product_ids = [];
/**
* @param string $preset_name
* @return \WC_Product
* @throws \WC_Data_Exception
*/
public function createFromPreset(string $preset_name): \WC_Product
{
$presets = ProductPresets::get();
if (!isset($presets[$preset_name])) {
throw new \WC_Data_Exception('invalid_preset', "Product preset '{$preset_name}' not found");
}
$preset = $presets[$preset_name];
if (isset($preset['sku'])) {
$existing_product_id = wc_get_product_id_by_sku($preset['sku']);
if ($existing_product_id) {
$existing_product = wc_get_product($existing_product_id);
if ($existing_product) {
return $existing_product;
}
}
}
if (isset($preset['product_id'])) {
$existing_product = wc_get_product($preset['product_id']);
if ($existing_product) {
return $existing_product;
}
}
if ($preset['type'] === 'subscription' && !class_exists('\WC_Product_Subscription')) {
return $this->createSimpleProduct($preset);
}
switch ($preset['type']) {
case 'variable':
return $this->createVariableProduct($preset);
case 'subscription':
return $this->createSubscriptionProduct($preset);
case 'simple':
default:
return $this->createSimpleProduct($preset);
}
}
/**
* @param array $preset
* @return WC_Product_Simple
*/
private function createSimpleProduct(array $preset): WC_Product_Simple
{
$product = new WC_Product_Simple();
$product_id = wc_get_product_id_by_sku($preset['sku']);
$product->set_sku($preset['sku']);
$product->set_id($product_id);
$product->set_name($preset['name']);
$product->set_regular_price($preset['price']);
$product->set_status('publish');
$product->save();
$this->created_product_ids[] = $product_id;
return $product;
}
/**
* @param array $preset
* @return WC_Product_Variation
*/
private function createVariableProduct(array $preset): WC_Product_Variation
{
// Create parent variable product
$parent = new WC_Product_Variable();
$product_id = wc_get_product_id_by_sku($preset['sku']);
$parent->set_sku($preset['sku']);
$parent->set_id($product_id);
$parent->set_name($preset['name']);
$parent->set_status('publish');
$parent->save();
// Create variation
$variation = new WC_Product_Variation();
$variation->set_id($preset['variation_id']);
$variation->set_parent_id($preset['product_id']);
$variation->set_regular_price($preset['price']);
$variation->set_attributes(['color' => 'red']);
$variation->set_status('publish');
$variation->save();
$this->created_product_ids[] = $product_id;
$this->created_product_ids[] = $preset['variation_id'];
return $variation;
}
/**
* @param array $preset
* @return \WC_Product_Subscription
*/
private function createSubscriptionProduct(array $preset): \WC_Product_Subscription
{
$product = new \WC_Product_Subscription();
$product->set_props([
'name' => $preset['name'],
'regular_price' => $preset['price'],
'price' => $preset['price'],
'sku' => $preset['sku'],
'manage_stock' => false,
'tax_status' => 'taxable',
'downloadable' => false,
'virtual' => false,
'stock_status' => 'instock',
'weight' => '1.1',
'subscription_period' => $preset['subscription_period'],
'subscription_period_interval' => $preset['subscription_period_interval'],
'subscription_length' => $preset['subscription_length'],
'subscription_trial_period' => $preset['subscription_trial_period'],
'subscription_trial_length' => $preset['subscription_trial_length'],
'subscription_price' => $preset['subscription_price'],
'subscription_sign_up_fee' => $preset['subscription_sign_up_fee'],
]);
$product->set_status('publish');
$product->save();
$this->created_product_ids[] = $product->get_id();
return $product;
}
/**
* @param string $sku
* @return bool
*/
public function exists(string $sku): bool
{
$existing_product_id = wc_get_product_id_by_sku($sku);
return (bool)$existing_product_id;
}
/**
* Delete all created products
*/
public function cleanup(): void
{
foreach ($this->created_product_ids as $product_id) {
wp_delete_post($product_id, true);
}
$this->created_product_ids = [];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\Integration\Fixtures;
class DiscountPresets
{
public static function get(): array
{
return [
'percentage_10' => [
'coupon_code' => 'TEST10PERCENT',
'type' => 'percent',
'amount' => '10'
],
'fixed_5' => [
'coupon_code' => 'TEST5FIXED',
'type' => 'fixed_cart',
'amount' => '5.00'
],
'manual_discount' => [
'fee' => ['name' => 'Test Discount', 'amount' => 3.50]
],
];
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\Integration\Fixtures;
class ProductPresets
{
public static function get(): array
{
return [
'simple' => [
'sku' => 'DUMMY_SIMPLE_SKU_01',
'name' => 'Test Simple Product',
'price' => '10.00',
'quantity' => 1,
'type' => 'simple'
],
'simple_expensive' => [
'sku' => 'DUMMY_SIMPLE_SKU_02',
'name' => 'Test Expensive Product',
'price' => '199.99',
'quantity' => 1,
'type' => 'simple'
],
/*'variable' => [
'sku' => 'DUMMY_VARIABLE_SKU_01',
'variation_id' => 20002,
'name' => 'Test Variable Product',
'price' => '25.00',
'quantity' => 1,
'type' => 'variable'
],*/
'subscription' => [
'name' => 'Dummy Subscription Product',
'price' => '10.00',
'quantity' => 1,
'type' => 'subscription',
'sku' => 'DUMMY SUB SKU',
'subscription_period' => 'day',
'subscription_period_interval' => 1,
'subscription_length' => 0,
'subscription_trial_period' => '',
'subscription_trial_length' => 0,
'subscription_price' => 10,
'subscription_sign_up_fee' => 0,
]
];
}
}

View file

@ -18,6 +18,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit;
use WooCommerce\PayPalCommerce\Helper\RedirectorStub;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use WooCommerce\PayPalCommerce\PPCP;
use WooCommerce\PayPalCommerce\Tests\Integration\Traits\CreateTestOrders;
use WooCommerce\PayPalCommerce\Tests\Integration\Traits\CreateTestProducts;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule;
@ -25,97 +27,21 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
class IntegrationMockedTestCase extends TestCase
{
use MockeryPHPUnitIntegration;
use MockeryPHPUnitIntegration, CreateTestOrders, CreateTestProducts;
public function setUp(): void
{
parent::setUp();
$this->default_product_id = $this->createAProductIfNotProvided();
$this->customer_id = $this->createCustomerIfNotExists();
$this->createTestProducts();
$this->createTestCoupons();
}
/**
* @param int $customer_id
* @param string $payment_method
* @param int $product_id
* @param bool $set_paid
* @return \WC_Order|\WP_Error
* @throws \WC_Data_Exception
*/
public function getMockedOrder(int $customer_id, string $payment_method, int $product_id, bool $set_paid = true)
public function tearDown(): void
{
$order = wc_create_order([
'customer_id' => $customer_id,
'set_paid' => $set_paid,
'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' => $product_id,
'quantity' => 1
]
],
]);
$order->set_payment_method($payment_method);
// Make sure the order is properly saved
$order->save();
// Add the product to the order
$item = new WC_Order_Item_Product();
$item->set_props([
'product_id' => $product_id,
'quantity' => 1,
'subtotal' => 10,
'total' => 10,
]);
$order->add_item($item);
$order->calculate_totals();
$order->save();
return $order;
}
/**
* @param string $sku
* @return int
*/
public function createAProductIfNotProvided(string $sku = 'DUMMY SUB SKU'): int
{
$product_id = wc_get_product_id_by_sku($sku);
if (!$product_id) {
$product = new \WC_Product_Subscription();
$product->set_props([
'name' => 'Dummy Subscription Product',
'regular_price' => 10,
'price' => 10,
'sku' => 'DUMMY SUB SKU',
'manage_stock' => false,
'tax_status' => 'taxable',
'downloadable' => false,
'virtual' => false,
'stock_status' => 'instock',
'weight' => '1.1',
// Subscription-specific properties
'subscription_period' => 'month',
'subscription_period_interval' => 1,
'subscription_length' => 0, // 0 means unlimited
'subscription_trial_period' => '',
'subscription_trial_length' => 0,
'subscription_price' => 10,
'subscription_sign_up_fee' => 0,
]);
$product->save();
$product_id = $product->get_id();
}
return $product_id;
// This cleans up everything created during tests
//$this->cleanupTestData();
parent::tearDown();
}
/**
@ -219,11 +145,13 @@ class IntegrationMockedTestCase extends TestCase
*/
public function createSubscription(int $customer_id = 1, string $payment_method = 'ppcp-gateway', $sku = 'DUMMY SUB SKU'): WC_Subscription
{
// Create a product if not provided
$product_id = $this->createAProductIfNotProvided($sku);
$order = $this->getMockedOrder($customer_id, $payment_method, $product_id, $set_paid = true);
$product_id = wc_get_product_id_by_sku($sku);
$order = $this->getConfiguredOrder(
$this->customer_id,
$payment_method,
['subscription']
);
$subscription = new WC_Subscription();
$subscription->set_customer_id($customer_id);
$subscription->set_payment_method($payment_method);
@ -259,7 +187,14 @@ class IntegrationMockedTestCase extends TestCase
*/
protected function createRenewalOrder(int $customer_id, string $gateway_id, int $subscription_id): WC_Order
{
$renewal_order = $this->getMockedOrder($customer_id, $gateway_id, $this->default_product_id, false);
$renewal_order = $this->getConfiguredOrder(
$customer_id,
$gateway_id,
['subscription'],
[],
false
);
$renewal_order->update_meta_data('_subscription_renewal', $subscription_id);
$renewal_order->update_meta_data('_subscription_renewal', $subscription_id);
$renewal_order->save();
@ -273,21 +208,25 @@ class IntegrationMockedTestCase extends TestCase
* @param bool $success Whether the order was successful
* @return object The mocked OrderEndpoint
*/
public function mockOrderEndpoint(string $intent = 'CAPTURE', bool $success = true): object
public function mockOrderEndpoint(string $intent = 'CAPTURE', bool $order_success = true, bool $capture_success = true): object
{
$order_endpoint = \Mockery::mock(OrderEndpoint::class)->shouldIgnoreMissing();
$order_endpoint = \Mockery::mock(OrderEndpoint::class);
$order = \Mockery::mock(Order::class)->shouldIgnoreMissing();
$order->shouldReceive('id')->andReturn('TEST-ORDER-' . uniqid());
$order->shouldReceive('intent')->andReturn($intent);
$order_status = \Mockery::mock(OrderStatus::class)->shouldIgnoreMissing();
$order_status->shouldReceive('is')->andReturn($success);
$order_status->shouldReceive('name')->andReturn($success ? 'COMPLETED' : 'FAILED');
$order_status = \Mockery::mock(OrderStatus::class);
$order_status->shouldReceive('is')->andReturn($order_success);
$order_status->shouldReceive('name')->andReturn($order_success ? 'COMPLETED' : 'FAILED');
$order->shouldReceive('status')->andReturn($order_status);
$payment_source = \Mockery::mock(PaymentSource::class)->shouldIgnoreMissing();
$card_properties = new \stdClass();
$card_properties->brand = 'VISA';
$card_properties->last_digits = '1234';
$card_properties->expiry = '2026-12';
$payment_source = \Mockery::mock(PaymentSource::class);
$payment_source->shouldReceive('name')->andReturn('card');
$payment_source->shouldReceive('properties')->andReturn($card_properties);
$order->shouldReceive('payment_source')->andReturn($payment_source);
$purchase_unit = \Mockery::mock(PurchaseUnit::class)->shouldIgnoreMissing();
@ -297,7 +236,8 @@ class IntegrationMockedTestCase extends TestCase
$capture->shouldReceive('id')->andReturn('TEST-CAPTURE-' . uniqid());
$capture_status = \Mockery::mock(CaptureStatus::class)->shouldIgnoreMissing();
$capture_status->shouldReceive('name')->andReturn($success ? 'COMPLETED' : 'DECLINED');
$capture_status->shouldReceive('name')->andReturn($capture_success ? 'COMPLETED' : 'DECLINED');
$capture_status->shouldReceive('details')->andReturn(null);
$capture->shouldReceive('status')->andReturn($capture_status);
// Mock authorizations for AUTHORIZE intent
@ -307,8 +247,8 @@ class IntegrationMockedTestCase extends TestCase
$authorization->shouldReceive('id')->andReturn('TEST-AUTH-' . uniqid());
$auth_status = \Mockery::mock(\WooCommerce\PayPalCommerce\ApiClient\Entity\AuthorizationStatus::class)->shouldIgnoreMissing();
$auth_status->shouldReceive('name')->andReturn($success ? 'CREATED' : 'DENIED');
$auth_status->shouldReceive('is')->andReturn($success);
$auth_status->shouldReceive('name')->andReturn($capture_success ? 'CREATED' : 'DENIED');
$auth_status->shouldReceive('is')->andReturn($capture_success);
$authorization->shouldReceive('status')->andReturn($auth_status);
$payments->shouldReceive('authorizations')->andReturn([$authorization]);
$payments->shouldReceive('captures')->andReturn([]);
@ -329,7 +269,7 @@ class IntegrationMockedTestCase extends TestCase
$order_endpoint->shouldReceive('capture')->andReturn($order);
}
$order_endpoint->shouldReceive('order')->andReturn($order);
$order_endpoint->shouldReceive('patch_order_with')->andReturn($order);
return $order_endpoint;
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\Integration\Traits;
trait CleansTestData
{
/**
* Clean all test data created by factories
*/
protected function cleanupTestData(): void
{
if (isset($this->order_factory)) {
$this->order_factory->cleanup();
}
if (isset($this->product_factory)) {
$this->product_factory->cleanup();
}
if (isset($this->coupon_factory)) {
$this->coupon_factory->cleanup();
}
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\Integration\Traits;
use WC_Order;
use WooCommerce\PayPalCommerce\Tests\Integration\Factories\OrderFactory;
trait CreateTestOrders
{
use CreateTestProducts;
private OrderFactory $order_factory;
/**
* Initialize order factory
*/
protected function initializeOrderFactory(): void
{
if (!isset($this->product_factory)) {
$this->initializeFactories();
}
$this->order_factory = new OrderFactory($this->product_factory, $this->coupon_factory);
}
/**
* Create a configured order using presets
*/
protected function getConfiguredOrder(
int $customer_id,
string $payment_method,
array $product_presets,
array $discount_presets = [],
bool $set_paid = true
): WC_Order
{
if (!isset($this->order_factory)) {
$this->initializeOrderFactory();
}
return $this->order_factory->create(
$customer_id,
$payment_method,
$product_presets,
$discount_presets,
$set_paid
);
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\Integration\Traits;
use WooCommerce\PayPalCommerce\Tests\Integration\Factories\ProductFactory;
use WooCommerce\PayPalCommerce\Tests\Integration\Factories\CouponFactory;
use WooCommerce\PayPalCommerce\Tests\Integration\Fixtures\ProductPresets;
use WooCommerce\PayPalCommerce\Tests\Integration\Fixtures\DiscountPresets;
trait CreateTestProducts
{
private ProductFactory $product_factory;
private CouponFactory $coupon_factory;
/**
* Initialize factories
*/
protected function initializeFactories(): void
{
$this->product_factory = new ProductFactory();
$this->coupon_factory = new CouponFactory();
}
/**
* Create all test products from presets
* @throws \WC_Data_Exception
*/
protected function createTestProducts(): void
{
if (!isset($this->product_factory)) {
$this->initializeFactories();
}
foreach (array_keys(ProductPresets::get()) as $preset_name) {
$preset = ProductPresets::get()[$preset_name];
// Only create if doesn't exist
if (!$this->product_factory->exists($preset['sku'])) {
$this->product_factory->createFromPreset($preset_name);
}
}
}
/**
* Create all test coupons from presets
*/
protected function createTestCoupons(): void
{
if (!isset($this->coupon_factory)) {
$this->initializeFactories();
}
foreach (DiscountPresets::get() as $preset_name => $preset) {
// Only create coupons (skip manual fees)
if (isset($preset['coupon_code']) && !$this->coupon_factory->exists($preset['coupon_code'])) {
$this->coupon_factory->createFromPreset($preset_name);
}
}
}
}

View file

@ -0,0 +1,184 @@
<?php
namespace WooCommerce\PayPalCommerce\Tests\Integration\Transaction_tests;
use WC_Payment_Token;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentSource;
use WooCommerce\PayPalCommerce\Tests\Integration\IntegrationMockedTestCase;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
/**
* @group transactions
* @group skip-ci
*/
class CreditcardTransactionTest extends IntegrationMockedTestCase
{
public function setUp(): void
{
parent::setUp();
$this->mockPaymentTokensEndpoint = \Mockery::mock(PaymentTokensEndpoint::class);
}
/**
* Sets up a test container with common mocks
*
* @param OrderEndpoint $orderEndpoint
* @param array $additionalServices Additional services to override
* @return ContainerInterface
*/
protected function setupTestContainer(OrderEndpoint $orderEndpoint, array $additionalServices = []): ContainerInterface
{
$services = [
'api.endpoint.order' => function () use ($orderEndpoint) {
return $orderEndpoint;
},
];
return $this->bootstrapModule(array_merge($services, $additionalServices));
}
/**
* Creates a payment token and configures the mock endpoint to return it
*
* @param int $customer_id
* @param string $gateway_id
* @return WC_Payment_Token
*/
protected function setupPaymentToken(int $customer_id, string $gateway_id = PayPalGateway::ID): WC_Payment_Token
{
$paymentToken = $this->createAPaymentTokenForTheCustomer($customer_id, $gateway_id);
$this->mockPaymentTokensEndpoint->shouldReceive('payment_tokens_for_customer')
->andReturn([
[
'id' => $paymentToken->get_token(),
'payment_source' => new PaymentSource(
'card',
(object)[
'last_digits' => $paymentToken->get_last4(),
'brand' => $paymentToken->get_card_type(),
'expiry' => $paymentToken->get_expiry_year() . '-' . $paymentToken->get_expiry_month()
]
)
]
]);
return $paymentToken;
}
/**
* Data provider for different product and discount combinations
*/
public function paymentProcessingDataProvider(): array
{
return [
'simple product only' => [
'products' => ['simple'],
'discounts' => [],
'expected_status' => 'processing'
],
'expensive product' => [
'products' => ['simple_expensive'],
'discounts' => [],
'expected_status' => 'processing'
],
'multiple products' => [
'products' => [
['preset' => 'simple', 'quantity' => 2],
'simple_expensive'
],
'discounts' => [],
'expected_status' => 'processing'
],//TODO fix the discount logic is failing due to taxes
/*'simple product with percentage discount' => [
'products' => ['simple'],
'discounts' => ['percentage_10'],
'expected_status' => 'processing'
],
'simple product with fixed discount' => [
'products' => ['simple'],
'discounts' => ['fixed_5'],
'expected_status' => 'processing'
],*/
];
}
/**
* Tests credit card payment processing with different product combinations.
*
* GIVEN a WooCommerce order with various product and discount combinations
* AND valid PayPal order ID in POST data
* AND valid credit card form data
* WHEN the payment is processed through the credit card gateway
* THEN the payment should be successfully captured
* AND the order status should change to the expected status
* AND a transaction ID should be set on the order
*
* @dataProvider paymentProcessingDataProvider
*/
public function testProcessPayment(array $products, array $discounts, string $expected_status)
{
// Mock successful PayPal API response
$mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', false, true);
$this->setupTestContainer($mockOrderEndpoint);
// Create order with provided products and discounts
$order = $this->getConfiguredOrder(
$this->customer_id,
'ppcp-credit-card-gateway',
$products,
$discounts,
false
);
$paypal_order_id = 'TEST-PAYPAL-ORDER-' . uniqid();
// Set the PayPal order ID in POST data (simulating frontend submission)
$_POST['paypal_order_id'] = $paypal_order_id;
$order->update_meta_data('_paypal_order_id', $paypal_order_id);
$order->save();
// Mock the session handler to return null (forcing fallback to POST/meta)
$sessionHandler = \Mockery::mock(\WooCommerce\PayPalCommerce\Session\SessionHandler::class);
$sessionHandler->shouldReceive('order')->andReturn(null);
$sessionHandler->shouldReceive('destroy_session_data')->once();
// Add session handler to container overrides
$additionalServices = [
'session.handler' => function() use ($sessionHandler) {
return $sessionHandler;
}
];
$c = $this->setupTestContainer($mockOrderEndpoint, $additionalServices);
// Simulate credit card form data
$_POST['ppcp-credit-card-gateway-card-number'] = '4111111111111111';
$_POST['ppcp-credit-card-gateway-card-expiry'] = '12/25';
$_POST['ppcp-credit-card-gateway-card-cvc'] = '123';
// Get the gateway instance
$gateway = $c->get('wcgateway.credit-card-gateway');
// Process payment
$result = $gateway->process_payment($order->get_id());
// Assertions
$this->assertEquals('success', $result['result']);
$this->assertArrayHasKey('redirect', $result);
// Verify order status changed
$order = wc_get_order($order->get_id()); // Refresh order
$this->assertEquals($expected_status, $order->get_status());
$this->assertNotEmpty($order->get_transaction_id());
// Clean up POST data
unset($_POST['paypal_order_id']);
unset($_POST['ppcp-credit-card-gateway-card-number']);
unset($_POST['ppcp-credit-card-gateway-card-expiry']);
unset($_POST['ppcp-credit-card-gateway-card-cvc']);
}
}

View file

@ -25,12 +25,7 @@ class VaultingSubscriptionsTest extends IntegrationMockedTestCase
{
parent::setUp();
// Common mock setup
$this->mockPaymentTokensEndpoint = \Mockery::mock(PaymentTokensEndpoint::class);
// Create customer and default product that can be reused
$this->customer_id = $this->createCustomerIfNotExists();
$this->default_product_id = $this->createAProductIfNotProvided();
}
/**
@ -165,6 +160,7 @@ class VaultingSubscriptionsTest extends IntegrationMockedTestCase
}
}
/**
* Data provider for payment gateway tests
*/
@ -187,7 +183,7 @@ class VaultingSubscriptionsTest extends IntegrationMockedTestCase
*/
public function test_renewal_payment_processing(string $gateway_id)
{
$mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', true);
$mockOrderEndpoint = $this->mockOrderEndpoint();
$c = $this->setupTestContainer($mockOrderEndpoint);
$this->setupPaymentToken($this->customer_id, $gateway_id);
$subscription = $this->createSubscription($this->customer_id, $gateway_id);
@ -200,6 +196,7 @@ class VaultingSubscriptionsTest extends IntegrationMockedTestCase
$this->assertEquals('processing', $renewal_order->get_status(), 'The renewal order should be processing after successful payment');
$this->assertNotEmpty($renewal_order->get_transaction_id(), 'The renewal order should have a transaction ID');
}
/**
* Tests that renewal processing handles failed payments correctly.
*
@ -209,7 +206,7 @@ class VaultingSubscriptionsTest extends IntegrationMockedTestCase
*/
public function test_renewal_handles_failed_payment()
{
$mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', false);
$mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', false, false);
$c = $this->setupTestContainer($mockOrderEndpoint);
$this->setupPaymentToken($this->customer_id);
$subscription = $this->createSubscription($this->customer_id, PayPalGateway::ID);
@ -231,7 +228,7 @@ class VaultingSubscriptionsTest extends IntegrationMockedTestCase
public function test_authorize_only_subscription_renewal()
{
// Mock the OrderEndpoint with AUTHORIZE intent
$mockOrderEndpoint = $this->mockOrderEndpoint('AUTHORIZE', true);
$mockOrderEndpoint = $this->mockOrderEndpoint('AUTHORIZE', false, true);
$c = $this->setupTestContainer($mockOrderEndpoint);
// Setup payment token and subscription

View file

@ -3,7 +3,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: 3.0.6
* Version: 3.0.7
* Author: PayPal
* Author URI: https://paypal.com/
* License: GPL-2.0
@ -11,7 +11,7 @@
* Requires Plugins: woocommerce
* Requires at least: 6.5
* WC requires at least: 9.6
* WC tested up to: 9.8
* WC tested up to: 9.9
* 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', '2025-05-14' );
define( 'PAYPAL_INTEGRATION_DATE', '2025-06-25' );
define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' );
! defined( 'CONNECT_WOO_CLIENT_ID' ) && define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' );