diff --git a/.editorconfig b/.editorconfig index 9920ff350..c0dc2d80d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,3 +17,7 @@ indent_style = space [*.yml] indent_size = 2 + +[*.php] +ij_php_variable_naming_style = snake_case +ij_php_getters_setters_naming_style = snake_case diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index e02629141..252f363ad 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -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 diff --git a/.github/workflows/package-new.yml b/.github/workflows/package-new.yml index 2b12eca03..01303699e 100644 --- a/.github/workflows/package-new.yml +++ b/.github/workflows/package-new.yml @@ -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 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index c3e07d1bb..aa66cb1fa 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -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 diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 00ff24fcb..1eaa00b41 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -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" diff --git a/.github/workflows/spell-check.yml b/.github/workflows/spell-check.yml index 14c28abac..78ea83c0d 100644 --- a/.github/workflows/spell-check.yml +++ b/.github/workflows/spell-check.yml @@ -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 diff --git a/changelog.txt b/changelog.txt index 77916063c..6a44ae2af 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,36 @@ *** Changelog *** += 3.0.8 - 2025-07-28 = +* Enhancement - Migration from Legacy Settings to New Settings as opt-in via banner & button #3491 +* Enhancement - Replace call to `billing-agreements/agreement-tokens` with checking the capabilities for Reference Transactions #3495 +* Enhancement - Add Fastlane 3D Secure support #3493 +* Enhancement - Improved PHP 8.4 compatibility #3534 +* Fix - `INVALID_REQUEST` error due to wrong `landing_page` value after upgrade to 3.0.7 #3521 +* Fix - Incorrect Amount via Express Payment for WooCommerce Product Bundles #3516 +* Fix - Onboarding failed via "Connect to PayPal" in new UI due to race condition #3385 +* Fix - Fatal error when PayPal Payments is active without WooCommerce #3502 +* Fix - PayPal Subscription transaction failed in various scenarios #3515 +* Fix - Rounding differences potentially lead to order failure (author @luzat) #3373 +* Fix - Google Pay payment on block checkout may fail when ACDC is default payment selection #3506 +* Fix - Product Prices Disappear in some cases when WooCommerce Subscriptions is active #3519 + += 3.0.7 - 2025-07-01 = +* Enhancement - Remove `application_context` in favor of `experience_context` object #3431 + **NOTE**: If you were 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 +* Fix - billing-agreements endpoint called too frequently when not enabled for Reference Transactions #3459 + = 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 diff --git a/composer.lock b/composer.lock index 180c71364..56340e11f 100644 --- a/composer.lock +++ b/composer.lock @@ -438,26 +438,26 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.30.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -498,7 +498,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" }, "funding": [ { @@ -514,7 +514,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "wikimedia/composer-merge-plugin", @@ -545,10 +545,10 @@ }, "type": "composer-plugin", "extra": { + "class": "Wikimedia\\Composer\\Merge\\V2\\MergePlugin", "branch-alias": { "dev-master": "2.x-dev" - }, - "class": "Wikimedia\\Composer\\Merge\\V2\\MergePlugin" + } }, "autoload": { "psr-4": { @@ -788,20 +788,20 @@ }, { "name": "antecedent/patchwork", - "version": "2.1.28", + "version": "2.2.1", "source": { "type": "git", "url": "https://github.com/antecedent/patchwork.git", - "reference": "6b30aff81ebadf0f2feb9268d3e08385cebcc08d" + "reference": "1bf183a3e1bd094f231a2128b9ecc5363c269245" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antecedent/patchwork/zipball/6b30aff81ebadf0f2feb9268d3e08385cebcc08d", - "reference": "6b30aff81ebadf0f2feb9268d3e08385cebcc08d", + "url": "https://api.github.com/repos/antecedent/patchwork/zipball/1bf183a3e1bd094f231a2128b9ecc5363c269245", + "reference": "1bf183a3e1bd094f231a2128b9ecc5363c269245", "shasum": "" }, "require": { - "php": ">=5.4.0" + "php": ">=7.1.0" }, "require-dev": { "phpunit/phpunit": ">=4" @@ -830,22 +830,22 @@ ], "support": { "issues": "https://github.com/antecedent/patchwork/issues", - "source": "https://github.com/antecedent/patchwork/tree/2.1.28" + "source": "https://github.com/antecedent/patchwork/tree/2.2.1" }, - "time": "2024-02-06T09:26:11+00:00" + "time": "2024-12-11T10:19:54+00:00" }, { "name": "brain/monkey", - "version": "2.6.1", + "version": "2.6.2", "source": { "type": "git", "url": "https://github.com/Brain-WP/BrainMonkey.git", - "reference": "a31c84515bb0d49be9310f52ef1733980ea8ffbb" + "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/a31c84515bb0d49be9310f52ef1733980ea8ffbb", - "reference": "a31c84515bb0d49be9310f52ef1733980ea8ffbb", + "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/d95a9d895352c30f47604ad1b825ab8fa9d1a373", + "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373", "shasum": "" }, "require": { @@ -861,8 +861,8 @@ "type": "library", "extra": { "branch-alias": { - "dev-version/1": "1.x-dev", - "dev-master": "2.0.x-dev" + "dev-master": "2.x-dev", + "dev-version/1": "1.x-dev" } }, "autoload": { @@ -902,7 +902,7 @@ "issues": "https://github.com/Brain-WP/BrainMonkey/issues", "source": "https://github.com/Brain-WP/BrainMonkey" }, - "time": "2021-11-11T15:53:55+00:00" + "time": "2024-08-29T20:15:04+00:00" }, { "name": "composer/package-versions-deprecated", @@ -979,16 +979,16 @@ }, { "name": "composer/pcre", - "version": "3.3.1", + "version": "3.3.2", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4" + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "shasum": "" }, "require": { @@ -998,19 +998,19 @@ "phpstan/phpstan": "<1.11.10" }, "require-dev": { - "phpstan/phpstan": "^1.11.10", - "phpstan/phpstan-strict-rules": "^1.1", + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", "phpunit/phpunit": "^8 || ^9" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - }, "phpstan": { "includes": [ "extension.neon" ] + }, + "branch-alias": { + "dev-main": "3.x-dev" } }, "autoload": { @@ -1038,7 +1038,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.1" + "source": "https://github.com/composer/pcre/tree/3.3.2" }, "funding": [ { @@ -1054,28 +1054,28 @@ "type": "tidelift" } ], - "time": "2024-08-27T18:44:43+00:00" + "time": "2024-11-12T16:29:46+00:00" }, { "name": "composer/semver", - "version": "3.4.2", + "version": "3.4.3", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "c51258e759afdb17f1fd1fe83bc12baaef6309d6" + "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/c51258e759afdb17f1fd1fe83bc12baaef6309d6", - "reference": "c51258e759afdb17f1fd1fe83bc12baaef6309d6", + "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", "shasum": "" }, "require": { "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.4", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", "extra": { @@ -1119,7 +1119,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.2" + "source": "https://github.com/composer/semver/tree/3.4.3" }, "funding": [ { @@ -1135,7 +1135,7 @@ "type": "tidelift" } ], - "time": "2024-07-12T11:35:52+00:00" + "time": "2024-09-19T14:15:21+00:00" }, { "name": "composer/xdebug-handler", @@ -1432,29 +1432,30 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.3", + "version": "1.1.5", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab" + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, "require-dev": { - "doctrine/coding-standard": "^9", - "phpstan/phpstan": "1.4.10 || 1.10.15", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psalm/plugin-phpunit": "0.18.4", - "psr/log": "^1 || ^2 || ^3", - "vimeo/psalm": "4.30.0 || 5.12.0" + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "psr/log": "^1 || ^2 || ^3" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -1462,7 +1463,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + "Doctrine\\Deprecations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1473,9 +1474,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.3" + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" }, - "time": "2024-01-30T19:34:25+00:00" + "time": "2025-04-07T20:06:18+00:00" }, { "name": "doctrine/instantiator", @@ -1594,16 +1595,16 @@ }, { "name": "felixfbecker/language-server-protocol", - "version": "v1.5.2", + "version": "v1.5.3", "source": { "type": "git", "url": "https://github.com/felixfbecker/php-language-server-protocol.git", - "reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842" + "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/6e82196ffd7c62f7794d778ca52b69feec9f2842", - "reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842", + "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/a9e113dbc7d849e35b8776da39edaf4313b7b6c9", + "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9", "shasum": "" }, "require": { @@ -1644,9 +1645,9 @@ ], "support": { "issues": "https://github.com/felixfbecker/php-language-server-protocol/issues", - "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.2" + "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.3" }, - "time": "2022-03-02T22:36:06+00:00" + "time": "2024-04-30T00:40:11+00:00" }, { "name": "graham-campbell/result-type", @@ -1712,20 +1713,20 @@ }, { "name": "hamcrest/hamcrest-php", - "version": "v2.0.1", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", "shasum": "" }, "require": { - "php": "^5.3|^7.0|^8.0" + "php": "^7.4|^8.0" }, "replace": { "cordoval/hamcrest-php": "*", @@ -1733,8 +1734,8 @@ "kodova/hamcrest-php": "*" }, "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { @@ -1757,9 +1758,9 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" }, - "time": "2020-07-09T08:09:16+00:00" + "time": "2025-04-30T06:54:44+00:00" }, { "name": "inpsyde/composer-assets-compiler", @@ -1792,10 +1793,10 @@ "extra": { "class": "Inpsyde\\AssetsCompiler\\Composer\\Plugin", "branch-alias": { - "dev-master": "2.x-dev", "dev-v1.x": "1.x-dev", "dev-v2.x": "2.x-dev", - "dev-v3.x": "3.x-dev" + "dev-v3.x": "3.x-dev", + "dev-master": "2.x-dev" } }, "autoload": { @@ -1828,31 +1829,34 @@ }, { "name": "inpsyde/modularity", - "version": "1.10.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/inpsyde/modularity.git", - "reference": "2119d0e32706741a3c6dc0a85d908ec19ebf142e" + "reference": "e1ca1c81b7b663355906b586525d21ac5d46bc65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/inpsyde/modularity/zipball/2119d0e32706741a3c6dc0a85d908ec19ebf142e", - "reference": "2119d0e32706741a3c6dc0a85d908ec19ebf142e", + "url": "https://api.github.com/repos/inpsyde/modularity/zipball/e1ca1c81b7b663355906b586525d21ac5d46bc65", + "reference": "e1ca1c81b7b663355906b586525d21ac5d46bc65", "shasum": "" }, "require": { "ext-json": "*", - "php": ">=7.4 <8.4", + "php": ">=7.4", "psr/container": "^1.1.0 || ^2" }, "require-dev": { "brain/monkey": "^2.6.1", - "inpsyde/php-coding-standards": "^2@dev", - "inpsyde/wp-stubs-versions": "dev-latest", + "inpsyde/wp-stubs-versions": "6.7", "mikey179/vfsstream": "^v1.6.11", + "phpstan/phpstan": "^2.1.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpstan/phpstan-mockery": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0.4", "phpunit/phpunit": "^9.6.19", - "roots/wordpress-no-content": "@dev", - "vimeo/psalm": "^5.24.0" + "swissspidy/phpstan-no-private": "^v1.0.0", + "syde/phpcs": "^1.0.0" }, "type": "library", "extra": { @@ -1871,18 +1875,18 @@ ], "authors": [ { - "name": "Inpsyde GmbH", - "email": "hello@inpsyde.com", - "homepage": "https://inpsyde.com/", + "name": "Syde GmbH", + "email": "hello@syde.com", + "homepage": "https://syde.com/", "role": "Company" } ], "description": "Modular PSR-11 implementation for WordPress plugins, themes or libraries.", "support": { "issues": "https://github.com/inpsyde/modularity/issues", - "source": "https://github.com/inpsyde/modularity/tree/1.10.0" + "source": "https://github.com/inpsyde/modularity/tree/1.12.0" }, - "time": "2024-09-03T10:42:50+00:00" + "time": "2025-05-09T12:13:17+00:00" }, { "name": "mockery/mockery", @@ -1969,16 +1973,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.0", + "version": "1.13.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c" + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", "shasum": "" }, "require": { @@ -2017,7 +2021,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" }, "funding": [ { @@ -2025,20 +2029,20 @@ "type": "tidelift" } ], - "time": "2024-06-12T14:39:25+00:00" + "time": "2025-04-29T12:36:36+00:00" }, { "name": "netresearch/jsonmapper", - "version": "v4.4.1", + "version": "v4.5.0", "source": { "type": "git", "url": "https://github.com/cweiske/jsonmapper.git", - "reference": "132c75c7dd83e45353ebb9c6c9f591952995bbf0" + "reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/132c75c7dd83e45353ebb9c6c9f591952995bbf0", - "reference": "132c75c7dd83e45353ebb9c6c9f591952995bbf0", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8e76efb98ee8b6afc54687045e1b8dba55ac76e5", + "reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5", "shasum": "" }, "require": { @@ -2074,22 +2078,22 @@ "support": { "email": "cweiske@cweiske.de", "issues": "https://github.com/cweiske/jsonmapper/issues", - "source": "https://github.com/cweiske/jsonmapper/tree/v4.4.1" + "source": "https://github.com/cweiske/jsonmapper/tree/v4.5.0" }, - "time": "2024-01-31T06:18:54+00:00" + "time": "2024-09-08T10:13:13+00:00" }, { "name": "nikic/php-parser", - "version": "v4.19.1", + "version": "v4.19.4", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "4e1b88d21c69391150ace211e9eaf05810858d0b" + "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4e1b88d21c69391150ace211e9eaf05810858d0b", - "reference": "4e1b88d21c69391150ace211e9eaf05810858d0b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/715f4d25e225bc47b293a8b997fe6ce99bf987d2", + "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2", "shasum": "" }, "require": { @@ -2098,7 +2102,7 @@ }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" }, "bin": [ "bin/php-parse" @@ -2130,9 +2134,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.4" }, - "time": "2024-03-17T08:10:35+00:00" + "time": "2024-09-29T15:01:53+00:00" }, { "name": "openlss/lib-array2xml", @@ -2532,21 +2536,22 @@ }, { "name": "phpcompatibility/phpcompatibility-wp", - "version": "2.1.5", + "version": "2.1.7", "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", - "reference": "01c1ff2704a58e46f0cb1ca9d06aee07b3589082" + "reference": "5bfbbfbabb3df2b9a83e601de9153e4a7111962c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/01c1ff2704a58e46f0cb1ca9d06aee07b3589082", - "reference": "01c1ff2704a58e46f0cb1ca9d06aee07b3589082", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/5bfbbfbabb3df2b9a83e601de9153e4a7111962c", + "reference": "5bfbbfbabb3df2b9a83e601de9153e4a7111962c", "shasum": "" }, "require": { "phpcompatibility/php-compatibility": "^9.0", - "phpcompatibility/phpcompatibility-paragonie": "^1.0" + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0" @@ -2596,9 +2601,13 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" } ], - "time": "2024-04-24T21:37:59+00:00" + "time": "2025-05-12T16:38:37+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -2655,16 +2664,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.4.1", + "version": "5.6.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c" + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c", - "reference": "9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/92dde6a5919e34835c506ac8c523ef095a95ed62", + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62", "shasum": "" }, "require": { @@ -2673,17 +2682,17 @@ "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", "webmozart/assert": "^1.9.1" }, "require-dev": { - "mockery/mockery": "~1.3.5", + "mockery/mockery": "~1.3.5 || ~1.6.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.8", "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^5.13" + "psalm/phar": "^5.26" }, "type": "library", "extra": { @@ -2713,29 +2722,29 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.4.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.2" }, - "time": "2024-05-21T05:55:05+00:00" + "time": "2025-04-13T19:20:35+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.8.2", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "153ae662783729388a584b4361f2545e4d841e3c" + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/153ae662783729388a584b4361f2545e4d841e3c", - "reference": "153ae662783729388a584b4361f2545e4d841e3c", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", "php": "^7.3 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.13" + "phpstan/phpdoc-parser": "^1.18|^2.0" }, "require-dev": { "ext-tokenizer": "*", @@ -2771,9 +2780,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.8.2" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" }, - "time": "2024-02-23T11:10:43+00:00" + "time": "2024-11-09T15:12:26+00:00" }, { "name": "phpoption/phpoption", @@ -2852,30 +2861,30 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.30.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "5ceb0e384997db59f38774bf79c2a6134252c08f" + "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/5ceb0e384997db59f38774bf79c2a6134252c08f", - "reference": "5ceb0e384997db59f38774bf79c2a6134252c08f", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "doctrine/annotations": "^2.0", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^5.3.0", "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.5", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", "symfony/process": "^5.2" }, "type": "library", @@ -2893,9 +2902,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.30.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" }, - "time": "2024-08-29T09:54:52+00:00" + "time": "2025-02-19T13:28:12+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3218,16 +3227,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.20", + "version": "9.6.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "49d7820565836236411f5dc002d16dd689cde42f" + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/49d7820565836236411f5dc002d16dd689cde42f", - "reference": "49d7820565836236411f5dc002d16dd689cde42f", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", "shasum": "" }, "require": { @@ -3238,11 +3247,11 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.31", + "phpunit/php-code-coverage": "^9.2.32", "phpunit/php-file-iterator": "^3.0.6", "phpunit/php-invoker": "^3.1.1", "phpunit/php-text-template": "^2.0.4", @@ -3301,7 +3310,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.20" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.23" }, "funding": [ { @@ -3312,12 +3321,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-07-10T11:45:39+00:00" + "time": "2025-05-02T06:40:34+00:00" }, { "name": "sebastian/cli-parser", @@ -4284,16 +4301,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.10.2", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017" + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/86e5f5dd9a840c46810ebe5ff1885581c42a3017", - "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", "shasum": "" }, "require": { @@ -4358,22 +4375,26 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2024-07-21T23:26:44+00:00" + "time": "2025-06-17T22:17:01+00:00" }, { "name": "symfony/console", - "version": "v5.4.43", + "version": "v5.4.47", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e86f8554de667c16dde8aeb89a3990cfde924df9" + "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e86f8554de667c16dde8aeb89a3990cfde924df9", - "reference": "e86f8554de667c16dde8aeb89a3990cfde924df9", + "url": "https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed", + "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed", "shasum": "" }, "require": { @@ -4443,7 +4464,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.43" + "source": "https://github.com/symfony/console/tree/v5.4.47" }, "funding": [ { @@ -4459,20 +4480,20 @@ "type": "tidelift" } ], - "time": "2024-08-13T16:31:56+00:00" + "time": "2024-11-06T11:30:55+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v2.5.3", + "version": "v2.5.4", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "80d075412b557d41002320b96a096ca65aa2c98d" + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/80d075412b557d41002320b96a096ca65aa2c98d", - "reference": "80d075412b557d41002320b96a096ca65aa2c98d", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", "shasum": "" }, "require": { @@ -4480,12 +4501,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -4510,7 +4531,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.3" + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" }, "funding": [ { @@ -4526,24 +4547,24 @@ "type": "tidelift" } ], - "time": "2023-01-24T14:02:46+00:00" + "time": "2024-09-25T14:11:13+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.30.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540" + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/0424dff1c58f028c451efff2045f5d92410bd540", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-ctype": "*" @@ -4554,8 +4575,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -4589,7 +4610,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -4605,24 +4626,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.30.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a" + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/64647a7c30b2283f5d49b874d84a18fc22054b7a", - "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" @@ -4630,8 +4651,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -4667,7 +4688,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -4683,24 +4704,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.30.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb" + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/a95281b0be0d9ab48050ebd988b967875cdb9fdb", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" @@ -4708,8 +4729,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -4748,7 +4769,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -4764,24 +4785,25 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.30.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-iconv": "*", + "php": ">=7.2" }, "provide": { "ext-mbstring": "*" @@ -4792,8 +4814,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -4828,7 +4850,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -4844,30 +4866,30 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.30.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "ec444d3f3f6505bb28d11afa41e75faadebc10a1" + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/ec444d3f3f6505bb28d11afa41e75faadebc10a1", - "reference": "ec444d3f3f6505bb28d11afa41e75faadebc10a1", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -4904,7 +4926,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.32.0" }, "funding": [ { @@ -4920,20 +4942,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/service-contracts", - "version": "v2.5.3", + "version": "v2.5.4", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "a2329596ddc8fd568900e3fc76cba42489ecc7f3" + "reference": "f37b419f7aea2e9abf10abd261832cace12e3300" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/a2329596ddc8fd568900e3fc76cba42489ecc7f3", - "reference": "a2329596ddc8fd568900e3fc76cba42489ecc7f3", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f37b419f7aea2e9abf10abd261832cace12e3300", + "reference": "f37b419f7aea2e9abf10abd261832cace12e3300", "shasum": "" }, "require": { @@ -4949,12 +4971,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -4987,7 +5009,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v2.5.3" + "source": "https://github.com/symfony/service-contracts/tree/v2.5.4" }, "funding": [ { @@ -5003,20 +5025,20 @@ "type": "tidelift" } ], - "time": "2023-04-21T15:04:16+00:00" + "time": "2024-09-25T14:11:13+00:00" }, { "name": "symfony/string", - "version": "v5.4.43", + "version": "v5.4.47", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "8be1d484951ff5ca995eaf8edcbcb8b9a5888450" + "reference": "136ca7d72f72b599f2631aca474a4f8e26719799" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/8be1d484951ff5ca995eaf8edcbcb8b9a5888450", - "reference": "8be1d484951ff5ca995eaf8edcbcb8b9a5888450", + "url": "https://api.github.com/repos/symfony/string/zipball/136ca7d72f72b599f2631aca474a4f8e26719799", + "reference": "136ca7d72f72b599f2631aca474a4f8e26719799", "shasum": "" }, "require": { @@ -5073,7 +5095,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.4.43" + "source": "https://github.com/symfony/string/tree/v5.4.47" }, "funding": [ { @@ -5089,7 +5111,7 @@ "type": "tidelift" } ], - "time": "2024-08-01T10:24:28+00:00" + "time": "2024-11-10T20:33:58+00:00" }, { "name": "theseer/tokenizer", @@ -5213,10 +5235,10 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.x-dev", - "dev-3.x": "3.x-dev", + "dev-1.x": "1.x-dev", "dev-2.x": "2.x-dev", - "dev-1.x": "1.x-dev" + "dev-3.x": "3.x-dev", + "dev-master": "4.x-dev" } }, "autoload": { @@ -5251,16 +5273,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.1", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2" + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", "shasum": "" }, "require": { @@ -5319,7 +5341,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.1" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" }, "funding": [ { @@ -5331,7 +5353,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:52:34+00:00" + "time": "2025-04-30T23:37:27+00:00" }, { "name": "webmozart/assert", @@ -5541,8 +5563,8 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "php-stubs/woocommerce-stubs": 0, - "php-stubs/wordpress-stubs": 0 + "php-stubs/wordpress-stubs": 0, + "php-stubs/woocommerce-stubs": 0 }, "prefer-stable": true, "prefer-lowest": false, @@ -5550,7 +5572,7 @@ "php": "^7.4 | ^8.0", "ext-json": "*" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "7.4" }, diff --git a/modules.php b/modules.php index 0d65644f2..c1f2dfb04 100644 --- a/modules.php +++ b/modules.php @@ -33,6 +33,7 @@ return function ( string $root_dir ): iterable { ( require "$modules_dir/ppcp-blocks/module.php" )(), ( require "$modules_dir/ppcp-paypal-subscriptions/module.php" )(), ( require "$modules_dir/ppcp-local-alternative-payment-methods/module.php" )(), + ( require "$modules_dir/ppcp-settings/module.php" )(), ); // phpcs:disable WordPress.NamingConventions.ValidHookName.UseUnderscores @@ -91,15 +92,5 @@ return function ( string $root_dir ): iterable { $modules[] = ( require "$modules_dir/ppcp-axo-block/module.php" )(); } - $show_new_ux = '1' === get_option( 'woocommerce-ppcp-is-new-merchant' ); - $preview_new_ux = '1' === getenv( 'PCP_SETTINGS_ENABLED' ); - - if ( apply_filters( - 'woocommerce.feature-flags.woocommerce_paypal_payments.settings_enabled', - $show_new_ux || $preview_new_ux - ) ) { - $modules[] = ( require "$modules_dir/ppcp-settings/module.php" )(); - } - return $modules; }; diff --git a/modules/ppcp-admin-notices/src/Renderer/Renderer.php b/modules/ppcp-admin-notices/src/Renderer/Renderer.php index d1bc3ddf6..527d83dd4 100644 --- a/modules/ppcp-admin-notices/src/Renderer/Renderer.php +++ b/modules/ppcp-admin-notices/src/Renderer/Renderer.php @@ -80,7 +80,7 @@ class Renderer implements RendererInterface { printf( '

%s

', - $message->type(), + esc_attr( $message->type() ), ( $message->is_dismissible() ) ? 'is-dismissible' : '', ( $message->wrapper() ? sprintf( 'data-ppcp-wrapper="%s"', esc_attr( $message->wrapper() ) ) : '' ), // Use `empty()` in condition, to avoid false phpcs warning. diff --git a/modules/ppcp-api-client/factories.php b/modules/ppcp-api-client/factories.php index 6ed926353..629b8b103 100644 --- a/modules/ppcp-api-client/factories.php +++ b/modules/ppcp-api-client/factories.php @@ -16,7 +16,8 @@ return array( 'wcgateway.builder.experience-context' => static function ( ContainerInterface $container ): ExperienceContextBuilder { return new ExperienceContextBuilder( - $container->get( 'wcgateway.settings' ) + $container->get( 'wcgateway.settings' ), + $container->get( 'wcgateway.shipping.callback.factory.url' ) ); }, ); diff --git a/modules/ppcp-api-client/services.php b/modules/ppcp-api-client/services.php index d2b9f9ddd..92d5319d8 100644 --- a/modules/ppcp-api-client/services.php +++ b/modules/ppcp-api-client/services.php @@ -9,46 +9,33 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\ApiClient; +use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\ClientCredentials; +use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer; +use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\SdkClientToken; use WooCommerce\PayPalCommerce\ApiClient\Authentication\UserIdToken; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\Orders; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentMethodTokensEndpoint; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint; -use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory; -use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter; -use WooCommerce\PayPalCommerce\ApiClient\Helper\FailureRegistry; -use WooCommerce\PayPalCommerce\ApiClient\Helper\PartnerAttribution; -use WooCommerce\PayPalCommerce\Common\Pattern\SingletonDecorator; +use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingPlans; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingSubscriptions; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\CatalogProducts; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingPlans; -use WooCommerce\PayPalCommerce\ApiClient\Factory\BillingCycleFactory; -use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentPreferencesFactory; -use WooCommerce\PayPalCommerce\ApiClient\Factory\RefundFactory; -use WooCommerce\PayPalCommerce\ApiClient\Factory\PlanFactory; -use WooCommerce\PayPalCommerce\ApiClient\Factory\ProductFactory; -use WooCommerce\PayPalCommerce\ApiClient\Factory\RefundPayerFactory; -use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerPayableBreakdownFactory; -use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingOptionFactory; -use WooCommerce\PayPalCommerce\Session\SessionHandler; -use WooCommerce\PayPalCommerce\Settings\Service\BrandedExperience\ActivationDetector; -use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; -use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; -use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\IdentityToken; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\LoginSeller; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Endpoint\Orders; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnersEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentMethodTokensEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokenEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\WebhookEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Factory\AddressFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\AmountFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\AuthorizationFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\BillingCycleFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\CaptureFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ContactPreferenceFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ExchangeRateFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\FraudProcessorResponseFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ItemFactory; @@ -57,31 +44,46 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\OrderFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PatchCollectionFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PayeeFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentPreferencesFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentsFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentTokenActionLinksFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentTokenFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\PlanFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PlatformFeeFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ProductFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\RefundFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\RefundPayerFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerPayableBreakdownFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerReceivableBreakdownFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerStatusFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingOptionFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ReturnUrlFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookEventFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory; +use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; +use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter; use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; +use WooCommerce\PayPalCommerce\ApiClient\Helper\FailureRegistry; use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderHelper; use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient; +use WooCommerce\PayPalCommerce\ApiClient\Helper\PartnerAttribution; use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer; use WooCommerce\PayPalCommerce\ApiClient\Repository\CustomerRepository; use WooCommerce\PayPalCommerce\ApiClient\Repository\OrderRepository; use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData; use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository; -use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; -use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer; -use WooCommerce\PayPalCommerce\WcGateway\Helper\EnvironmentConfig; -use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; +use WooCommerce\PayPalCommerce\Common\Pattern\SingletonDecorator; +use WooCommerce\PayPalCommerce\Session\SessionHandler; +use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel; use WooCommerce\PayPalCommerce\Settings\Enum\InstallationPathEnum; +use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; +use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; +use WooCommerce\PayPalCommerce\WcGateway\Helper\EnvironmentConfig; +use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; return array( 'api.host' => static function( ContainerInterface $container ) : string { @@ -273,13 +275,10 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, - 'api.endpoint.billing-agreements' => static function ( ContainerInterface $container ): BillingAgreementsEndpoint { - return new BillingAgreementsEndpoint( - $container->get( 'api.host' ), - $container->get( 'api.bearer' ), - $container->get( 'woocommerce.logger.woocommerce' ) - ); - }, + 'api.reference-transaction-status' => static fn ( ContainerInterface $container ): ReferenceTransactionStatus => new ReferenceTransactionStatus( + $container->get( 'api.endpoint.partners' ), + $container->get( 'api.reference-transaction-status-cache' ) + ), 'api.endpoint.catalog-products' => static function ( ContainerInterface $container ): CatalogProducts { return new CatalogProducts( $container->get( 'api.host' ), @@ -330,6 +329,22 @@ return array( $container->get( 'api.endpoint.order' ) ); }, + 'api.factory.contact-preference' => static function ( ContainerInterface $container ): ContactPreferenceFactory { + if ( $container->has( 'settings.data.settings' ) ) { + $settings = $container->get( 'settings.data.settings' ); + assert( $settings instanceof SettingsModel ); + + $contact_module_active = $settings->get_enable_contact_module(); + } else { + // #legacy-ui: Auto-enable the feature; can be disabled via eligibility hook. + $contact_module_active = true; + } + + return new ContactPreferenceFactory( + $contact_module_active, + $container->get( 'settings.merchant-details' ) + ); + }, 'api.factory.payment-token' => static function ( ContainerInterface $container ) : PaymentTokenFactory { return new PaymentTokenFactory(); }, @@ -396,6 +411,9 @@ return array( $container->get( 'api.factory.shipping-option' ) ); }, + 'api.factory.return-url' => static function ( ContainerInterface $container ): ReturnUrlFactory { + return new ReturnUrlFactory(); + }, 'api.factory.shipping-preference' => static function ( ContainerInterface $container ): ShippingPreferenceFactory { return new ShippingPreferenceFactory(); }, @@ -682,6 +700,11 @@ return array( 'GB' => $default_currencies, 'US' => $default_currencies, 'NO' => $default_currencies, + 'YT' => $default_currencies, + 'RE' => $default_currencies, + 'GP' => $default_currencies, + 'GF' => $default_currencies, + 'MQ' => $default_currencies, ) ); }, @@ -761,6 +784,11 @@ return array( 'amex' => array( 'JPY' ), 'jcb' => array( 'JPY' ), ), + 'YT' => $mastercard_visa_amex, // Mayotte. + 'RE' => $mastercard_visa_amex, // Reunion. + 'GP' => $mastercard_visa_amex, // Guadelope. + 'GF' => $mastercard_visa_amex, // French Guiana. + 'MQ' => $mastercard_visa_amex, // Martinique. ) ); }, @@ -848,6 +876,9 @@ return array( 'api.user-id-token-cache' => static function( ContainerInterface $container ): Cache { return new Cache( 'ppcp-id-token-cache' ); }, + 'api.reference-transaction-status-cache' => static function( ContainerInterface $container ): Cache { + return new Cache( 'ppcp-reference-transaction-status-cache' ); + }, 'api.user-id-token' => static function( ContainerInterface $container ): UserIdToken { return new UserIdToken( $container->get( 'api.host' ), diff --git a/modules/ppcp-api-client/src/Endpoint/BillingAgreementsEndpoint.php b/modules/ppcp-api-client/src/Endpoint/BillingAgreementsEndpoint.php deleted file mode 100644 index 954be01b5..000000000 --- a/modules/ppcp-api-client/src/Endpoint/BillingAgreementsEndpoint.php +++ /dev/null @@ -1,147 +0,0 @@ -host = $host; - $this->bearer = $bearer; - $this->logger = $logger; - } - - /** - * Creates a billing agreement token. - * - * @param string $description The description. - * @param string $return_url The return URL. - * @param string $cancel_url The cancel URL. - * - * @throws RuntimeException If the request fails. - * @throws PayPalApiException If the request fails. - */ - public function create_token( string $description, string $return_url, string $cancel_url ): stdClass { - $data = array( - 'description' => $description, - 'payer' => array( - 'payment_method' => 'PAYPAL', - ), - 'plan' => array( - 'type' => 'MERCHANT_INITIATED_BILLING', - 'merchant_preferences' => array( - 'return_url' => $return_url, - 'cancel_url' => $cancel_url, - 'skip_shipping_address' => true, - ), - ), - ); - - $bearer = $this->bearer->bearer(); - $url = trailingslashit( $this->host ) . 'v1/billing-agreements/agreement-tokens'; - $args = array( - 'method' => 'POST', - 'headers' => array( - 'Authorization' => 'Bearer ' . $bearer->token(), - 'Content-Type' => 'application/json', - ), - 'body' => wp_json_encode( $data ), - ); - $response = $this->request( $url, $args ); - - if ( is_wp_error( $response ) || ! is_array( $response ) ) { - throw new RuntimeException( 'Not able to create a billing agreement token.' ); - } - - $json = json_decode( $response['body'] ); - $status_code = (int) wp_remote_retrieve_response_code( $response ); - if ( 201 !== $status_code ) { - throw new PayPalApiException( - $json, - $status_code - ); - } - - return $json; - } - - /** - * Checks if reference transactions are enabled in account. - * - * @throws RuntimeException If the request fails (no auth, no connection, etc.). - */ - public function reference_transaction_enabled(): bool { - try { - if ( wc_string_to_bool( get_transient( 'ppcp_reference_transaction_enabled' ) ) === true ) { - return true; - } - - $this->is_request_logging_enabled = false; - - try { - $this->create_token( - 'Checking if reference transactions are enabled', - 'https://example.com/return', - 'https://example.com/cancel' - ); - } finally { - $this->is_request_logging_enabled = true; - set_transient( 'ppcp_reference_transaction_enabled', true, MONTH_IN_SECONDS ); - } - - return true; - } catch ( Exception $exception ) { - delete_transient( 'ppcp_reference_transaction_enabled' ); - return false; - } - } -} diff --git a/modules/ppcp-api-client/src/Endpoint/OrderEndpoint.php b/modules/ppcp-api-client/src/Endpoint/OrderEndpoint.php index 69b49e9e3..7c62ef1a9 100644 --- a/modules/ppcp-api-client/src/Endpoint/OrderEndpoint.php +++ b/modules/ppcp-api-client/src/Endpoint/OrderEndpoint.php @@ -176,10 +176,10 @@ class OrderEndpoint { public function create( array $items, string $shipping_preference, - Payer $payer = null, + ?Payer $payer = null, string $payment_method = '', array $request_data = array(), - PaymentSource $payment_source = null + ?PaymentSource $payment_source = null ): Order { $bearer = $this->bearer->bearer(); $data = array( @@ -444,9 +444,7 @@ class OrderEndpoint { } $response = $this->request( $url, $args ); if ( is_wp_error( $response ) ) { - $error = new RuntimeException( - __( 'Could not retrieve order.', 'woocommerce-paypal-payments' ) - ); + $error = new RuntimeException( 'Could not retrieve order.' ); $this->logger->warning( $error->getMessage() ); throw $error; @@ -456,7 +454,7 @@ class OrderEndpoint { if ( 404 === $status_code || empty( $response['body'] ) ) { $error = new RuntimeException( - __( 'Could not retrieve order.', 'woocommerce-paypal-payments' ), + 'Could not retrieve order.', 404 ); $this->logger->warning( @@ -585,9 +583,6 @@ class OrderEndpoint { $data = array( 'payment_source' => $payment_source, 'processing_instruction' => 'ORDER_COMPLETE_ON_PAYMENT_APPROVAL', - 'application_context' => array( - 'locale' => 'es-MX', - ), ); $args = array( diff --git a/modules/ppcp-api-client/src/Endpoint/WebhookEndpoint.php b/modules/ppcp-api-client/src/Endpoint/WebhookEndpoint.php index 3df4c3def..1156dfd86 100644 --- a/modules/ppcp-api-client/src/Endpoint/WebhookEndpoint.php +++ b/modules/ppcp-api-client/src/Endpoint/WebhookEndpoint.php @@ -113,9 +113,7 @@ class WebhookEndpoint { $response = $this->request( $url, $args ); if ( is_wp_error( $response ) ) { - throw new RuntimeException( - __( 'Not able to create a webhook.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'Not able to create a webhook.' ); } $json = json_decode( $response['body'] ); @@ -151,9 +149,7 @@ class WebhookEndpoint { $response = $this->request( $url, $args ); if ( is_wp_error( $response ) ) { - throw new RuntimeException( - __( 'Not able to load webhooks list.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'Not able to load webhooks list.' ); } $json = json_decode( $response['body'] ); @@ -195,9 +191,7 @@ class WebhookEndpoint { $response = $this->request( $url, $args ); if ( $response instanceof WP_Error ) { - throw new RuntimeException( - __( 'Not able to delete the webhook.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'Not able to delete the webhook.' ); } $status_code = (int) wp_remote_retrieve_response_code( $response ); @@ -250,9 +244,7 @@ class WebhookEndpoint { $response = $this->request( $url, $args ); if ( is_wp_error( $response ) ) { - throw new RuntimeException( - __( 'Not able to simulate webhook.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'Not able to simulate webhook.' ); } $json = json_decode( $response['body'] ); $status_code = (int) wp_remote_retrieve_response_code( $response ); @@ -312,9 +304,7 @@ class WebhookEndpoint { ); $response = $this->request( $url, $args ); if ( is_wp_error( $response ) ) { - $error = new RuntimeException( - __( 'Not able to verify webhook event.', 'woocommerce-paypal-payments' ) - ); + $error = new RuntimeException( 'Not able to verify webhook event.' ); $this->logger->log( 'warning', $error->getMessage(), @@ -340,9 +330,7 @@ class WebhookEndpoint { public function verify_current_request_for_webhook( Webhook $webhook ): bool { if ( ! $webhook->id() ) { - $error = new RuntimeException( - __( 'Not a valid webhook to verify.', 'woocommerce-paypal-payments' ) - ); + $error = new RuntimeException( 'Not a valid webhook to verify.' ); $this->logger->log( 'warning', $error->getMessage(), array( 'webhook' => $webhook ) ); throw $error; } @@ -369,11 +357,7 @@ class WebhookEndpoint { $error = new RuntimeException( sprintf( - // translators: %s is the headers key. - __( - 'Not a valid webhook event. Header %s is missing', - 'woocommerce-paypal-payments' - ), + 'Not a valid webhook event. Header %s is missing', $key ) ); diff --git a/modules/ppcp-api-client/src/Entity/Amount.php b/modules/ppcp-api-client/src/Entity/Amount.php index 0b7c022c6..04cf854b6 100644 --- a/modules/ppcp-api-client/src/Entity/Amount.php +++ b/modules/ppcp-api-client/src/Entity/Amount.php @@ -41,7 +41,7 @@ class Amount { * @param Money $money The money. * @param AmountBreakdown|null $breakdown The breakdown. */ - public function __construct( Money $money, AmountBreakdown $breakdown = null ) { + public function __construct( Money $money, ?AmountBreakdown $breakdown = null ) { $this->money = $money; $this->breakdown = $breakdown; } diff --git a/modules/ppcp-api-client/src/Entity/AuthorizationStatus.php b/modules/ppcp-api-client/src/Entity/AuthorizationStatus.php index 1275cd878..d771995ca 100644 --- a/modules/ppcp-api-client/src/Entity/AuthorizationStatus.php +++ b/modules/ppcp-api-client/src/Entity/AuthorizationStatus.php @@ -62,8 +62,7 @@ class AuthorizationStatus { if ( ! in_array( $status, self::VALID_STATUS, true ) ) { throw new RuntimeException( sprintf( - // translators: %s is the current status. - __( '%s is not a valid status', 'woocommerce-paypal-payments' ), + '%s is not a valid status', $status ) ); diff --git a/modules/ppcp-api-client/src/Entity/CallbackConfig.php b/modules/ppcp-api-client/src/Entity/CallbackConfig.php new file mode 100644 index 000000000..ed1c5f1cc --- /dev/null +++ b/modules/ppcp-api-client/src/Entity/CallbackConfig.php @@ -0,0 +1,44 @@ +events = $events; + $this->url = $url; + } + + /** + * Returns the object as array. + */ + public function to_array(): array { + return array( + 'callback_events' => $this->events, + 'callback_url' => $this->url, + ); + } +} diff --git a/modules/ppcp-api-client/src/Entity/ExperienceContext.php b/modules/ppcp-api-client/src/Entity/ExperienceContext.php index e596f1304..c6f4cbf0d 100644 --- a/modules/ppcp-api-client/src/Entity/ExperienceContext.php +++ b/modules/ppcp-api-client/src/Entity/ExperienceContext.php @@ -30,6 +30,9 @@ class ExperienceContext { public const PAYMENT_METHOD_UNRESTRICTED = 'UNRESTRICTED'; public const PAYMENT_METHOD_IMMEDIATE_PAYMENT_REQUIRED = 'IMMEDIATE_PAYMENT_REQUIRED'; + public const CONTACT_PREFERENCE_NO_CONTACT_INFO = 'NO_CONTACT_INFO'; + public const CONTACT_PREFERENCE_UPDATE_CONTACT_INFO = 'UPDATE_CONTACT_INFO'; + /** * The return url. */ @@ -70,6 +73,17 @@ class ExperienceContext { */ private ?string $payment_method_preference = null; + /** + * Controls the contact module, and when defined, the API response will + * include additional details in the `purchase_units[].shipping` object. + */ + private ?string $contact_preference = null; + + /** + * The callback config. + */ + private ?CallbackConfig $order_update_callback_config = null; + /** * Returns the return URL. */ @@ -161,6 +175,10 @@ class ExperienceContext { * @param string|null $new_value The value to set. */ public function with_landing_page( ?string $new_value ): ExperienceContext { + if ( $new_value && strtoupper( $new_value ) === 'BILLING' ) { + $new_value = self::LANDING_PAGE_GUEST_CHECKOUT; + } + $obj = clone $this; $obj->landing_page = $new_value; @@ -224,6 +242,47 @@ class ExperienceContext { return $obj; } + /** + * Returns the contact preference. + */ + public function contact_preference(): ?string { + return $this->contact_preference; + } + + /** + * Sets the contact preference. + * + * This preference is only available for the payment source 'paypal' and 'venmo'. + * https://developer.paypal.com/docs/api/orders/v2/#definition-paypal_wallet_experience_context + * + * @param string|null $new_value The value to set. + */ + public function with_contact_preference( ?string $new_value ): ExperienceContext { + $obj = clone $this; + + $obj->contact_preference = $new_value; + return $obj; + } + + /** + * Returns the callback config. + */ + public function order_update_callback_config(): ?CallbackConfig { + return $this->order_update_callback_config; + } + + /** + * Sets the callback config. + * + * @param CallbackConfig|null $new_value The value to set. + */ + public function with_order_update_callback_config( ?CallbackConfig $new_value ): ExperienceContext { + $obj = clone $this; + + $obj->order_update_callback_config = $new_value; + return $obj; + } + /** * Returns the object as array. */ @@ -236,6 +295,9 @@ class ExperienceContext { if ( $value === null ) { continue; } + if ( is_object( $value ) && method_exists( $value, 'to_array' ) ) { + $value = $value->to_array(); + } $data[ $prop->getName() ] = $value; } diff --git a/modules/ppcp-api-client/src/Entity/Item.php b/modules/ppcp-api-client/src/Entity/Item.php index 25478d5b0..90a270cf1 100644 --- a/modules/ppcp-api-client/src/Entity/Item.php +++ b/modules/ppcp-api-client/src/Entity/Item.php @@ -114,13 +114,13 @@ class Item { Money $unit_amount, int $quantity, string $description = '', - Money $tax = null, + ?Money $tax = null, string $sku = '', string $category = 'PHYSICAL_GOODS', string $url = '', string $image_url = '', float $tax_rate = 0, - string $cart_item_key = null + ?string $cart_item_key = null ) { $this->name = $name; diff --git a/modules/ppcp-api-client/src/Entity/Order.php b/modules/ppcp-api-client/src/Entity/Order.php index a9cca343f..ebacf46e4 100644 --- a/modules/ppcp-api-client/src/Entity/Order.php +++ b/modules/ppcp-api-client/src/Entity/Order.php @@ -9,6 +9,8 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\ApiClient\Entity; +use DateTime; + /** * Class Order */ @@ -25,7 +27,7 @@ class Order { /** * The create time. * - * @var \DateTime|null + * @var DateTime|null */ private $create_time; @@ -60,7 +62,7 @@ class Order { /** * The update time. * - * @var \DateTime|null + * @var DateTime|null */ private $update_time; @@ -70,6 +72,10 @@ class Order { * @var PaymentSource|null */ private $payment_source; + /** + * @var mixed|null + */ + private $links; /** * Order constructor. @@ -82,18 +88,19 @@ class Order { * @param PaymentSource|null $payment_source The payment source. * @param Payer|null $payer The payer. * @param string $intent The intent. - * @param \DateTime|null $create_time The create time. - * @param \DateTime|null $update_time The update time. + * @param DateTime|null $create_time The create time. + * @param DateTime|null $update_time The update time. */ public function __construct( string $id, array $purchase_units, OrderStatus $order_status, - PaymentSource $payment_source = null, - Payer $payer = null, + ?PaymentSource $payment_source = null, + ?Payer $payer = null, string $intent = 'CAPTURE', - \DateTime $create_time = null, - \DateTime $update_time = null + ?DateTime $create_time = null, + ?DateTime $update_time = null, + $links = null ) { $this->id = $id; @@ -104,6 +111,7 @@ class Order { $this->create_time = $create_time; $this->update_time = $update_time; $this->payment_source = $payment_source; + $this->links = $links; } /** @@ -118,7 +126,7 @@ class Order { /** * Returns the create time. * - * @return \DateTime|null + * @return DateTime|null */ public function create_time() { return $this->create_time; @@ -127,7 +135,7 @@ class Order { /** * Returns the update time. * - * @return \DateTime|null + * @return DateTime|null */ public function update_time() { return $this->update_time; @@ -179,6 +187,15 @@ class Order { return $this->payment_source; } + /** + * Returns the links. + * + * @return mixed|null + */ + public function links() { + return $this->links; + } + /** * Returns the object as array. * @@ -206,6 +223,10 @@ class Order { $order['update_time'] = $this->update_time()->format( 'Y-m-d\TH:i:sO' ); } + if ( $this->links ) { + $order['links'] = $this->links(); + } + return $order; } } diff --git a/modules/ppcp-api-client/src/Entity/OrderStatus.php b/modules/ppcp-api-client/src/Entity/OrderStatus.php index 387830dd3..92106417e 100644 --- a/modules/ppcp-api-client/src/Entity/OrderStatus.php +++ b/modules/ppcp-api-client/src/Entity/OrderStatus.php @@ -51,8 +51,7 @@ class OrderStatus { if ( ! in_array( $status, self::VALID_STATUS, true ) ) { throw new RuntimeException( sprintf( - // translators: %s is the current status. - __( '%s is not a valid status', 'woocommerce-paypal-payments' ), + '%s is not a valid status', $status ) ); diff --git a/modules/ppcp-api-client/src/Entity/Payer.php b/modules/ppcp-api-client/src/Entity/Payer.php index 45164c1f5..a1d73c0f1 100644 --- a/modules/ppcp-api-client/src/Entity/Payer.php +++ b/modules/ppcp-api-client/src/Entity/Payer.php @@ -9,6 +9,8 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\ApiClient\Entity; +use DateTime; + /** * Class Payer * The customer who sends the money. @@ -39,7 +41,7 @@ class Payer { /** * The birth date. * - * @var \DateTime|null + * @var DateTime|null */ private $birthdate; @@ -71,7 +73,7 @@ class Payer { * @param string $email_address The email. * @param string $payer_id The payer id. * @param Address|null $address The address. - * @param \DateTime|null $birthdate The birth date. + * @param DateTime|null $birthdate The birth date. * @param PhoneWithType|null $phone The phone. * @param PayerTaxInfo|null $tax_info The tax info. */ @@ -79,10 +81,10 @@ class Payer { ?PayerName $name, string $email_address, string $payer_id, - Address $address = null, - \DateTime $birthdate = null, - PhoneWithType $phone = null, - PayerTaxInfo $tax_info = null + ?Address $address = null, + ?DateTime $birthdate = null, + ?PhoneWithType $phone = null, + ?PayerTaxInfo $tax_info = null ) { $this->name = $name; @@ -133,7 +135,7 @@ class Payer { /** * Returns the birth date. * - * @return \DateTime|null + * @return DateTime|null */ public function birthdate() { return $this->birthdate; diff --git a/modules/ppcp-api-client/src/Entity/PayerTaxInfo.php b/modules/ppcp-api-client/src/Entity/PayerTaxInfo.php index cb3bff074..e3917ba30 100644 --- a/modules/ppcp-api-client/src/Entity/PayerTaxInfo.php +++ b/modules/ppcp-api-client/src/Entity/PayerTaxInfo.php @@ -51,8 +51,7 @@ class PayerTaxInfo { if ( ! in_array( $type, self::VALID_TYPES, true ) ) { throw new RuntimeException( sprintf( - // translators: %s is the current type. - __( '%s is not a valid tax type.', 'woocommerce-paypal-payments' ), + '%s is not a valid tax type.', $type ) ); diff --git a/modules/ppcp-api-client/src/Entity/PaymentToken.php b/modules/ppcp-api-client/src/Entity/PaymentToken.php index 1598d65c7..65796d6b8 100644 --- a/modules/ppcp-api-client/src/Entity/PaymentToken.php +++ b/modules/ppcp-api-client/src/Entity/PaymentToken.php @@ -50,9 +50,7 @@ class PaymentToken { */ public function __construct( string $id, stdClass $source, string $type = self::TYPE_PAYMENT_METHOD_TOKEN ) { if ( ! in_array( $type, self::get_valid_types(), true ) ) { - throw new RuntimeException( - __( 'Not a valid payment source type.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'Not a valid payment source type.' ); } $this->id = $id; $this->type = $type; diff --git a/modules/ppcp-api-client/src/Entity/PurchaseUnit.php b/modules/ppcp-api-client/src/Entity/PurchaseUnit.php index 1f0c5572d..a6ca155a6 100644 --- a/modules/ppcp-api-client/src/Entity/PurchaseUnit.php +++ b/modules/ppcp-api-client/src/Entity/PurchaseUnit.php @@ -109,13 +109,13 @@ class PurchaseUnit { public function __construct( Amount $amount, array $items = array(), - Shipping $shipping = null, + ?Shipping $shipping = null, string $reference_id = 'default', string $description = '', string $custom_id = '', string $invoice_id = '', string $soft_descriptor = '', - Payments $payments = null + ?Payments $payments = null ) { $this->amount = $amount; diff --git a/modules/ppcp-api-client/src/Entity/RefundCapture.php b/modules/ppcp-api-client/src/Entity/RefundCapture.php index bca9b663f..cef578a5c 100644 --- a/modules/ppcp-api-client/src/Entity/RefundCapture.php +++ b/modules/ppcp-api-client/src/Entity/RefundCapture.php @@ -54,7 +54,7 @@ class RefundCapture { Capture $capture, string $invoice_id, string $note_to_payer = '', - Amount $amount = null + ?Amount $amount = null ) { $this->capture = $capture; $this->invoice_id = $invoice_id; diff --git a/modules/ppcp-api-client/src/Entity/Shipping.php b/modules/ppcp-api-client/src/Entity/Shipping.php index eeb4001dc..70944a06b 100644 --- a/modules/ppcp-api-client/src/Entity/Shipping.php +++ b/modules/ppcp-api-client/src/Entity/Shipping.php @@ -28,6 +28,16 @@ class Shipping { */ private $address; + /** + * Custom contact email address, usually added via the Contact Module. + */ + private ?string $email_address = null; + + /** + * Custom contact phone number, usually added via the Contact Module. + */ + private ?Phone $phone_number = null; + /** * Shipping methods. * @@ -38,14 +48,24 @@ class Shipping { /** * Shipping constructor. * - * @param string $name The name. - * @param Address $address The address. - * @param ShippingOption[] $options Shipping methods. + * @param string $name The name. + * @param Address $address The address. + * @param string|null $email_address Contact email. + * @param Phone|null $phone_number Contact phone. + * @param ShippingOption[] $options Shipping methods. */ - public function __construct( string $name, Address $address, array $options = array() ) { - $this->name = $name; - $this->address = $address; - $this->options = $options; + public function __construct( + string $name, + Address $address, + ?string $email_address = null, + ?Phone $phone_number = null, + array $options = array() + ) { + $this->name = $name; + $this->address = $address; + $this->email_address = $email_address; + $this->phone_number = $phone_number; + $this->options = $options; } /** @@ -66,6 +86,24 @@ class Shipping { return $this->address; } + /** + * Returns the contact email address, or null. + * + * @return null|string + */ + public function email_address() : ?string { + return $this->email_address; + } + + /** + * Returns the contact phone number, or null. + * + * @return null|Phone + */ + public function phone_number() : ?Phone { + return $this->phone_number; + } + /** * Returns the shipping methods. * @@ -87,6 +125,17 @@ class Shipping { ), 'address' => $this->address()->to_array(), ); + + $contact_email = $this->email_address(); + $contact_phone = $this->phone_number(); + + if ( $contact_email ) { + $result['email_address'] = $contact_email; + } + if ( $contact_phone ) { + $result['phone_number'] = $contact_phone->to_array(); + } + if ( $this->options ) { $result['options'] = array_map( function ( ShippingOption $opt ): array { diff --git a/modules/ppcp-api-client/src/Exception/PayPalApiException.php b/modules/ppcp-api-client/src/Exception/PayPalApiException.php index 11405b22b..296a7ae89 100644 --- a/modules/ppcp-api-client/src/Exception/PayPalApiException.php +++ b/modules/ppcp-api-client/src/Exception/PayPalApiException.php @@ -36,9 +36,9 @@ class PayPalApiException extends RuntimeException { * @param stdClass|null $response The JSON object. * @param int $status_code The HTTP status code. */ - public function __construct( stdClass $response = null, int $status_code = 0 ) { + public function __construct( ?stdClass $response = null, int $status_code = 0 ) { if ( is_null( $response ) ) { - $response = new \stdClass(); + $response = new stdClass(); } if ( ! isset( $response->message ) ) { $response->message = sprintf( @@ -63,7 +63,7 @@ class PayPalApiException extends RuntimeException { /** * The JSON response object. * - * @var \stdClass $response + * @var stdClass $response */ $this->response = $response; $this->status_code = $status_code; diff --git a/modules/ppcp-api-client/src/Factory/AmountFactory.php b/modules/ppcp-api-client/src/Factory/AmountFactory.php index 2db9b8a0a..fdcdfd8a9 100644 --- a/modules/ppcp-api-client/src/Factory/AmountFactory.php +++ b/modules/ppcp-api-client/src/Factory/AmountFactory.php @@ -15,6 +15,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\Item; use WooCommerce\PayPalCommerce\ApiClient\Entity\Money; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity\CartTotals; use WooCommerce\PayPalCommerce\WcSubscriptions\FreeTrialHandlerTrait; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; @@ -111,6 +112,24 @@ class AmountFactory { return $amount; } + /** + * Returns an Amount object based off a WooCommerce cart object from the Store API. + */ + public function from_store_api_cart( CartTotals $cart_totals ): Amount { + return new Amount( + $cart_totals->total_price()->to_paypal(), + new AmountBreakdown( + $cart_totals->total_items()->to_paypal(), + $cart_totals->total_shipping()->to_paypal(), + $cart_totals->total_tax()->to_paypal(), + null, + null, + null, + $cart_totals->total_discount()->to_paypal(), + ) + ); + } + /** * Returns an Amount object based off a WooCommerce order. * @@ -179,12 +198,15 @@ class AmountFactory { /** * Returns an Amount object based off a PayPal Response. * - * @param \stdClass $data The JSON object. + * @param mixed $data The JSON object. * - * @return Amount - * @throws RuntimeException When JSON object is malformed. + * @return Amount|null */ - public function from_paypal_response( \stdClass $data ): Amount { + public function from_paypal_response( $data ) { + if ( null === $data || ! $data instanceof \stdClass ) { + return null; + } + $money = $this->money_factory->from_paypal_response( $data ); $breakdown = ( isset( $data->breakdown ) ) ? $this->break_down( $data->breakdown ) : null; return new Amount( $money, $breakdown ); @@ -223,8 +245,7 @@ class AmountFactory { if ( ! isset( $item->value ) || ! is_numeric( $item->value ) ) { throw new RuntimeException( sprintf( - // translators: %s is the current breakdown key. - __( 'No value given for breakdown %s', 'woocommerce-paypal-payments' ), + 'No value given for breakdown %s', $key ) ); @@ -232,8 +253,7 @@ class AmountFactory { if ( ! isset( $item->currency_code ) ) { throw new RuntimeException( sprintf( - // translators: %s is the current breakdown key. - __( 'No currency given for breakdown %s', 'woocommerce-paypal-payments' ), + 'No currency given for breakdown %s', $key ) ); diff --git a/modules/ppcp-api-client/src/Factory/AuthorizationFactory.php b/modules/ppcp-api-client/src/Factory/AuthorizationFactory.php index d16aee2fb..3b6d32194 100644 --- a/modules/ppcp-api-client/src/Factory/AuthorizationFactory.php +++ b/modules/ppcp-api-client/src/Factory/AuthorizationFactory.php @@ -45,15 +45,11 @@ class AuthorizationFactory { */ public function from_paypal_response( \stdClass $data ): Authorization { if ( ! isset( $data->id ) ) { - throw new RuntimeException( - __( 'Does not contain an id.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'Does not contain an id.' ); } if ( ! isset( $data->status ) ) { - throw new RuntimeException( - __( 'Does not contain status.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'Does not contain status.' ); } $reason = $data->status_details->reason ?? null; diff --git a/modules/ppcp-api-client/src/Factory/CaptureFactory.php b/modules/ppcp-api-client/src/Factory/CaptureFactory.php index 0c833fca6..7f479f30e 100644 --- a/modules/ppcp-api-client/src/Factory/CaptureFactory.php +++ b/modules/ppcp-api-client/src/Factory/CaptureFactory.php @@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory; use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture; use WooCommerce\PayPalCommerce\ApiClient\Entity\CaptureStatus; use WooCommerce\PayPalCommerce\ApiClient\Entity\CaptureStatusDetails; +use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; /** * Class CaptureFactory @@ -63,6 +64,7 @@ class CaptureFactory { * @param \stdClass $data The PayPal response. * * @return Capture + * @throws RuntimeException When capture amount data is invalid. */ public function from_paypal_response( \stdClass $data ) : Capture { $reason = $data->status_details->reason ?? null; @@ -74,13 +76,18 @@ class CaptureFactory { $this->fraud_processor_response_factory->from_paypal_response( $data->processor_response ) : null; + $amount = $this->amount_factory->from_paypal_response( $data->amount ); + if ( null === $amount ) { + throw new RuntimeException( 'Invalid capture amount data.' ); + } + return new Capture( (string) $data->id, new CaptureStatus( (string) $data->status, $reason ? new CaptureStatusDetails( $reason ) : null ), - $this->amount_factory->from_paypal_response( $data->amount ), + $amount, (bool) $data->final_capture, (string) $data->seller_protection->status, (string) $data->invoice_id, diff --git a/modules/ppcp-api-client/src/Factory/ContactPreferenceFactory.php b/modules/ppcp-api-client/src/Factory/ContactPreferenceFactory.php new file mode 100644 index 000000000..286297ae8 --- /dev/null +++ b/modules/ppcp-api-client/src/Factory/ContactPreferenceFactory.php @@ -0,0 +1,72 @@ +is_contact_module_active = $is_contact_module_active; + $this->merchant_details = $merchant_details; + } + + /** + * Returns contact_preference for the given state. + * + * @param string $payment_source_key Name of the payment_source. + * @return string|null + */ + public function from_state( string $payment_source_key ) : ?string { + $payment_sources_with_contact = array( 'paypal', 'venmo' ); + + /** + * In case the payment-source does not support the contact-info preference + * we return null to remove the property from the context. + */ + if ( ! in_array( $payment_source_key, $payment_sources_with_contact, true ) ) { + return null; + } + + if ( ! $this->is_contact_module_active ) { + return ExperienceContext::CONTACT_PREFERENCE_NO_CONTACT_INFO; + } + + if ( ! $this->merchant_details->is_eligible_for( MerchantDetails::FEATURE_CONTACT_MODULE ) ) { + return ExperienceContext::CONTACT_PREFERENCE_NO_CONTACT_INFO; + } + + return ExperienceContext::CONTACT_PREFERENCE_UPDATE_CONTACT_INFO; + } +} diff --git a/modules/ppcp-api-client/src/Factory/ExperienceContextBuilder.php b/modules/ppcp-api-client/src/Factory/ExperienceContextBuilder.php index e688183d9..9d826e365 100644 --- a/modules/ppcp-api-client/src/Factory/ExperienceContextBuilder.php +++ b/modules/ppcp-api-client/src/Factory/ExperienceContextBuilder.php @@ -11,9 +11,11 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory; use WC_AJAX; use WC_Order; +use WooCommerce\PayPalCommerce\ApiClient\Entity\CallbackConfig; use WooCommerce\PayPalCommerce\ApiClient\Entity\ExperienceContext; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Shipping\ShippingCallbackUrlFactory; /** * Class ExperienceContextBuilder @@ -30,15 +32,16 @@ class ExperienceContextBuilder { */ private ContainerInterface $settings; - /** - * ExperienceContextBuilder constructor. - * - * @param ContainerInterface $settings The settings. - */ - public function __construct( ContainerInterface $settings ) { + private ShippingCallbackUrlFactory $shipping_callback_url_factory; + + public function __construct( + ContainerInterface $settings, + ShippingCallbackUrlFactory $shipping_callback_url_factory + ) { $this->experience_context = new ExperienceContext(); - $this->settings = $settings; + $this->settings = $settings; + $this->shipping_callback_url_factory = $shipping_callback_url_factory; } /** @@ -97,6 +100,34 @@ class ExperienceContextBuilder { return $builder; } + /** + * Uses a custom return URL. + * + * @param string $url The return URL. + */ + public function with_custom_return_url( string $url ): ExperienceContextBuilder { + $builder = clone $this; + + $builder->experience_context = $builder->experience_context + ->with_return_url( $url ); + + return $builder; + } + + /** + * Uses a custom cancel URL. + * + * @param string $url The cancel URL. + */ + public function with_custom_cancel_url( string $url ): ExperienceContextBuilder { + $builder = clone $this; + + $builder->experience_context = $builder->experience_context + ->with_cancel_url( $url ); + + return $builder; + } + /** * Uses the current brand name from the settings. */ @@ -161,6 +192,37 @@ class ExperienceContextBuilder { return $builder; } + /** + * Uses the server-side shipping callback configuration. + */ + public function with_shipping_callback(): ExperienceContextBuilder { + $builder = clone $this; + + $builder->experience_context = $builder->experience_context + ->with_order_update_callback_config( + new CallbackConfig( + array( CallbackConfig::EVENT_SHIPPING_ADDRESS, CallbackConfig::EVENT_SHIPPING_OPTIONS ), + $this->shipping_callback_url_factory->create() + ) + ); + + return $builder; + } + + /** + * Applies a custom contact preference to the experience context. + * + * @param string|null $preference The new preference to apply. + */ + public function with_contact_preference( ?string $preference = null ) : ExperienceContextBuilder { + $builder = clone $this; + + $builder->experience_context = $builder->experience_context + ->with_contact_preference( $preference ); + + return $builder; + } + /** * Returns the ExperienceContext. */ diff --git a/modules/ppcp-api-client/src/Factory/ItemFactory.php b/modules/ppcp-api-client/src/Factory/ItemFactory.php index 3861b4c9d..0d9250c8f 100644 --- a/modules/ppcp-api-client/src/Factory/ItemFactory.php +++ b/modules/ppcp-api-client/src/Factory/ItemFactory.php @@ -179,19 +179,13 @@ class ItemFactory { */ public function from_paypal_response( \stdClass $data ): Item { if ( ! isset( $data->name ) ) { - throw new RuntimeException( - __( 'No name for item given', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No name for item given' ); } if ( ! isset( $data->quantity ) || ! is_numeric( $data->quantity ) ) { - throw new RuntimeException( - __( 'No quantity for item given', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No quantity for item given' ); } if ( ! isset( $data->unit_amount->value ) || ! isset( $data->unit_amount->currency_code ) ) { - throw new RuntimeException( - __( 'No money values for item given', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No money values for item given' ); } $unit_amount = new Money( (float) $data->unit_amount->value, $data->unit_amount->currency_code ); diff --git a/modules/ppcp-api-client/src/Factory/OrderFactory.php b/modules/ppcp-api-client/src/Factory/OrderFactory.php index ece13a36a..5fc910932 100644 --- a/modules/ppcp-api-client/src/Factory/OrderFactory.php +++ b/modules/ppcp-api-client/src/Factory/OrderFactory.php @@ -68,7 +68,8 @@ class OrderFactory { $order->payer(), $order->intent(), $order->create_time(), - $order->update_time() + $order->update_time(), + $order->links() ); } @@ -81,70 +82,153 @@ class OrderFactory { * @throws RuntimeException When JSON object is malformed. */ public function from_paypal_response( \stdClass $order_data ): Order { - if ( ! isset( $order_data->id ) ) { - throw new RuntimeException( - __( 'Order does not contain an id.', 'woocommerce-paypal-payments' ) - ); - } - if ( ! isset( $order_data->purchase_units ) || ! is_array( $order_data->purchase_units ) ) { - throw new RuntimeException( - __( 'Order does not contain items.', 'woocommerce-paypal-payments' ) - ); - } - if ( ! isset( $order_data->status ) ) { - throw new RuntimeException( - __( 'Order does not contain status.', 'woocommerce-paypal-payments' ) - ); - } - if ( ! isset( $order_data->intent ) ) { - throw new RuntimeException( - __( 'Order does not contain intent.', 'woocommerce-paypal-payments' ) - ); - } + $this->validate_order_id( $order_data ); - $purchase_units = array_map( - function ( \stdClass $data ): PurchaseUnit { - return $this->purchase_unit_factory->from_paypal_response( $data ); - }, - $order_data->purchase_units - ); - - $create_time = ( isset( $order_data->create_time ) ) ? - \DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->create_time ) - : null; - $update_time = ( isset( $order_data->update_time ) ) ? - \DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->update_time ) - : null; - $payer = ( isset( $order_data->payer ) ) ? - $this->payer_factory->from_paypal_response( $order_data->payer ) - : null; - - $payment_source = null; - if ( isset( $order_data->payment_source ) ) { - $json_encoded_payment_source = wp_json_encode( $order_data->payment_source ); - if ( $json_encoded_payment_source ) { - $payment_source_as_array = json_decode( $json_encoded_payment_source, true ); - if ( $payment_source_as_array ) { - $name = array_key_first( $payment_source_as_array ); - if ( $name ) { - $payment_source = new PaymentSource( - $name, - $order_data->payment_source->$name - ); - } - } - } - } + $purchase_units = $this->create_purchase_units( $order_data ); + $status = $this->create_order_status( $order_data ); + $intent = $this->get_intent( $order_data ); + $timestamps = $this->create_timestamps( $order_data ); + $payer = $this->create_payer( $order_data ); + $payment_source = $this->create_payment_source( $order_data ); + $links = $order_data->links ?? null; return new Order( $order_data->id, $purchase_units, - new OrderStatus( $order_data->status ), + $status, $payment_source, $payer, - $order_data->intent, - $create_time, - $update_time + $intent, + $timestamps['create_time'], + $timestamps['update_time'], + $links + ); + } + + /** + * Validates that the order data contains a required ID. + * + * @param \stdClass $order_data The order data. + * + * @throws RuntimeException When ID is missing. + */ + private function validate_order_id( \stdClass $order_data ): void { + if ( ! isset( $order_data->id ) ) { + throw new RuntimeException( 'Order does not contain an id.' ); + } + } + + /** + * Creates purchase units from order data. + * + * @param \stdClass $order_data The order data. + * + * @return array Array of PurchaseUnit objects. + */ + private function create_purchase_units( \stdClass $order_data ): array { + if ( ! isset( $order_data->purchase_units ) || ! is_array( $order_data->purchase_units ) ) { + return array(); + } + + $purchase_units = array(); + foreach ( $order_data->purchase_units as $data ) { + $purchase_unit = $this->purchase_unit_factory->from_paypal_response( $data ); + if ( null !== $purchase_unit ) { + $purchase_units[] = $purchase_unit; + } + } + + return $purchase_units; + } + + /** + * Creates order status from order data. + * + * @param \stdClass $order_data The order data. + * + * @return OrderStatus + */ + private function create_order_status( \stdClass $order_data ): OrderStatus { + $status_value = $order_data->status ?? 'PAYER_ACTION_REQUIRED'; + return new OrderStatus( $status_value ); + } + + /** + * Gets the intent from order data. + * + * @param \stdClass $order_data The order data. + * + * @return string + */ + private function get_intent( \stdClass $order_data ): string { + return $order_data->intent ?? 'CAPTURE'; + } + + /** + * Creates timestamps from order data. + * + * @param \stdClass $order_data The order data. + * + * @return array Array with 'create_time' and 'update_time' keys. + */ + private function create_timestamps( \stdClass $order_data ): array { + $create_time = isset( $order_data->create_time ) ? + \DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->create_time ) : + null; + + $update_time = isset( $order_data->update_time ) ? + \DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->update_time ) : + null; + + return array( + 'create_time' => $create_time, + 'update_time' => $update_time, + ); + } + + /** + * Creates payer from order data. + * + * @param \stdClass $order_data The order data. + * + * @return mixed Payer object or null. + */ + private function create_payer( \stdClass $order_data ) { + return isset( $order_data->payer ) ? + $this->payer_factory->from_paypal_response( $order_data->payer ) : + null; + } + + /** + * Creates payment source from order data. + * + * @param \stdClass $order_data The order data. + * + * @return PaymentSource|null + */ + private function create_payment_source( \stdClass $order_data ): ?PaymentSource { + if ( ! isset( $order_data->payment_source ) ) { + return null; + } + + $json_encoded_payment_source = wp_json_encode( $order_data->payment_source ); + if ( ! $json_encoded_payment_source ) { + return null; + } + + $payment_source_as_array = json_decode( $json_encoded_payment_source, true ); + if ( ! $payment_source_as_array ) { + return null; + } + + $source_name = array_key_first( $payment_source_as_array ); + if ( ! $source_name ) { + return null; + } + + return new PaymentSource( + $source_name, + $order_data->payment_source->$source_name ); } } diff --git a/modules/ppcp-api-client/src/Factory/PaymentTokenFactory.php b/modules/ppcp-api-client/src/Factory/PaymentTokenFactory.php index e1bbbc7ed..114245ae4 100644 --- a/modules/ppcp-api-client/src/Factory/PaymentTokenFactory.php +++ b/modules/ppcp-api-client/src/Factory/PaymentTokenFactory.php @@ -27,9 +27,7 @@ class PaymentTokenFactory { */ public function from_paypal_response( $data ): PaymentToken { if ( ! isset( $data->id ) ) { - throw new RuntimeException( - __( 'No id for payment token given', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No id for payment token given' ); } return new PaymentToken( diff --git a/modules/ppcp-api-client/src/Factory/PlanFactory.php b/modules/ppcp-api-client/src/Factory/PlanFactory.php index bbf332d10..9580696b1 100644 --- a/modules/ppcp-api-client/src/Factory/PlanFactory.php +++ b/modules/ppcp-api-client/src/Factory/PlanFactory.php @@ -57,24 +57,16 @@ class PlanFactory { */ public function from_paypal_response( stdClass $data ): Plan { if ( ! isset( $data->id ) ) { - throw new RuntimeException( - __( 'No id for given plan', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No id for given plan' ); } if ( ! isset( $data->name ) ) { - throw new RuntimeException( - __( 'No name for plan given', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No name for plan given' ); } if ( ! isset( $data->product_id ) ) { - throw new RuntimeException( - __( 'No product id for given plan', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No product id for given plan' ); } if ( ! isset( $data->billing_cycles ) ) { - throw new RuntimeException( - __( 'No billing cycles for given plan', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No billing cycles for given plan' ); } $billing_cycles = array(); diff --git a/modules/ppcp-api-client/src/Factory/ProductFactory.php b/modules/ppcp-api-client/src/Factory/ProductFactory.php index a34602a21..85eeb98cb 100644 --- a/modules/ppcp-api-client/src/Factory/ProductFactory.php +++ b/modules/ppcp-api-client/src/Factory/ProductFactory.php @@ -28,14 +28,10 @@ class ProductFactory { */ public function from_paypal_response( stdClass $data ): Product { if ( ! isset( $data->id ) ) { - throw new RuntimeException( - __( 'No id for product given', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No id for product given' ); } if ( ! isset( $data->name ) ) { - throw new RuntimeException( - __( 'No name for product given', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No name for product given' ); } return new Product( diff --git a/modules/ppcp-api-client/src/Factory/PurchaseUnitFactory.php b/modules/ppcp-api-client/src/Factory/PurchaseUnitFactory.php index 84231f8d8..e0be1954d 100644 --- a/modules/ppcp-api-client/src/Factory/PurchaseUnitFactory.php +++ b/modules/ppcp-api-client/src/Factory/PurchaseUnitFactory.php @@ -88,7 +88,7 @@ class PurchaseUnitFactory { PaymentsFactory $payments_factory, string $prefix = 'WC-', string $soft_descriptor = '', - PurchaseUnitSanitizer $sanitizer = null + ?PurchaseUnitSanitizer $sanitizer = null ) { $this->amount_factory = $amount_factory; @@ -219,17 +219,20 @@ class PurchaseUnitFactory { * * @param \stdClass $data The JSON object. * - * @return PurchaseUnit + * @return ?PurchaseUnit * @throws RuntimeException When JSON object is malformed. */ - public function from_paypal_response( \stdClass $data ): PurchaseUnit { + public function from_paypal_response( \stdClass $data ): ?PurchaseUnit { if ( ! isset( $data->reference_id ) || ! is_string( $data->reference_id ) ) { - throw new RuntimeException( - __( 'No reference ID given.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No reference ID given.' ); + } + + $amount_data = $data->amount ?? null; + $amount = $this->amount_factory->from_paypal_response( $amount_data ); + if ( null === $amount ) { + return null; } - $amount = $this->amount_factory->from_paypal_response( $data->amount ); $description = ( isset( $data->description ) ) ? $data->description : ''; $custom_id = ( isset( $data->custom_id ) ) ? $data->custom_id : ''; $invoice_id = ( isset( $data->invoice_id ) ) ? $data->invoice_id : ''; diff --git a/modules/ppcp-api-client/src/Factory/RefundFactory.php b/modules/ppcp-api-client/src/Factory/RefundFactory.php index 63530feaf..e9cbbe542 100644 --- a/modules/ppcp-api-client/src/Factory/RefundFactory.php +++ b/modules/ppcp-api-client/src/Factory/RefundFactory.php @@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory; use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund; use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundStatus; use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundStatusDetails; +use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; /** * Class RefundFactory @@ -62,6 +63,7 @@ class RefundFactory { * @param \stdClass $data The PayPal response. * * @return Refund + * @throws RuntimeException When refund amount data is invalid. */ public function from_paypal_response( \stdClass $data ) : Refund { $reason = $data->status_details->reason ?? null; @@ -73,13 +75,18 @@ class RefundFactory { $this->refund_payer_factory->from_paypal_response( $data->payer ) : null; + $amount = $this->amount_factory->from_paypal_response( $data->amount ); + if ( null === $amount ) { + throw new RuntimeException( 'Invalid refund amount data.' ); + } + return new Refund( (string) $data->id, new RefundStatus( (string) $data->status, $reason ? new RefundStatusDetails( $reason ) : null ), - $this->amount_factory->from_paypal_response( $data->amount ), + $amount, (string) ( $data->invoice_id ?? '' ), (string) ( $data->custom_id ?? '' ), $seller_payable_breakdown, diff --git a/modules/ppcp-api-client/src/Factory/ReturnUrlFactory.php b/modules/ppcp-api-client/src/Factory/ReturnUrlFactory.php new file mode 100644 index 000000000..cba03352f --- /dev/null +++ b/modules/ppcp-api-client/src/Factory/ReturnUrlFactory.php @@ -0,0 +1,53 @@ +get_checkout_payment_url(); + } + } + + throw new RuntimeException( 'The order ID is invalid.' ); + default: + return wc_get_checkout_url(); + } + } +} diff --git a/modules/ppcp-api-client/src/Factory/ShippingFactory.php b/modules/ppcp-api-client/src/Factory/ShippingFactory.php index b85259394..36779fd2c 100644 --- a/modules/ppcp-api-client/src/Factory/ShippingFactory.php +++ b/modules/ppcp-api-client/src/Factory/ShippingFactory.php @@ -10,8 +10,8 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\ApiClient\Factory; use WooCommerce\PayPalCommerce\ApiClient\Entity\Shipping; -use WooCommerce\PayPalCommerce\ApiClient\Entity\ShippingOption; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Phone; /** * Class ShippingFactory @@ -60,9 +60,12 @@ class ShippingFactory { $customer->get_shipping_last_name() ); $address = $this->address_factory->from_wc_customer( $customer ); + return new Shipping( $full_name, $address, + null, + null, $with_shipping_options ? $this->shipping_option_factory->from_wc_cart() : array() ); } @@ -77,6 +80,7 @@ class ShippingFactory { public function from_wc_order( \WC_Order $order ): Shipping { $full_name = $order->get_formatted_shipping_full_name(); $address = $this->address_factory->from_wc_order( $order ); + return new Shipping( $full_name, $address @@ -93,23 +97,32 @@ class ShippingFactory { */ public function from_paypal_response( \stdClass $data ): Shipping { if ( ! isset( $data->name->full_name ) ) { - throw new RuntimeException( - __( 'No name was given for shipping.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No name was given for shipping.' ); } if ( ! isset( $data->address ) ) { - throw new RuntimeException( - __( 'No address was given for shipping.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No address was given for shipping.' ); } - $address = $this->address_factory->from_paypal_response( $data->address ); + $contact_phone = null; + $contact_email = null; + $address = $this->address_factory->from_paypal_response( $data->address ); + $options = array_map( array( $this->shipping_option_factory, 'from_paypal_response' ), $data->options ?? array() ); + + if ( isset( $data->phone_number->national_number ) ) { + $contact_phone = new Phone( $data->phone_number->national_number ); + } + if ( isset( $data->email_address ) ) { + $contact_email = $data->email_address; + } + return new Shipping( $data->name->full_name, $address, + $contact_email, + $contact_phone, $options ); } diff --git a/modules/ppcp-api-client/src/Factory/WebhookEventFactory.php b/modules/ppcp-api-client/src/Factory/WebhookEventFactory.php index 30db85516..df256b2df 100644 --- a/modules/ppcp-api-client/src/Factory/WebhookEventFactory.php +++ b/modules/ppcp-api-client/src/Factory/WebhookEventFactory.php @@ -40,14 +40,10 @@ class WebhookEventFactory { */ public function from_paypal_response( $data ): WebhookEvent { if ( ! isset( $data->id ) ) { - throw new RuntimeException( - __( 'ID for webhook event not found.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'ID for webhook event not found.' ); } if ( ! isset( $data->event_type ) ) { - throw new RuntimeException( - __( 'Event type for webhook event not found.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'Event type for webhook event not found.' ); } $create_time = ( isset( $data->create_time ) ) ? diff --git a/modules/ppcp-api-client/src/Factory/WebhookFactory.php b/modules/ppcp-api-client/src/Factory/WebhookFactory.php index 372817458..4a53efc92 100644 --- a/modules/ppcp-api-client/src/Factory/WebhookFactory.php +++ b/modules/ppcp-api-client/src/Factory/WebhookFactory.php @@ -59,19 +59,13 @@ class WebhookFactory { */ public function from_paypal_response( $data ): Webhook { if ( ! isset( $data->id ) ) { - throw new RuntimeException( - __( 'No id for webhook given.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No id for webhook given.' ); } if ( ! isset( $data->url ) ) { - throw new RuntimeException( - __( 'No URL for webhook given.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No URL for webhook given.' ); } if ( ! isset( $data->event_types ) ) { - throw new RuntimeException( - __( 'No event types for webhook given.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No event types for webhook given.' ); } return new Webhook( diff --git a/modules/ppcp-api-client/src/Helper/PartnerAttribution.php b/modules/ppcp-api-client/src/Helper/PartnerAttribution.php index 47861e5cf..35e1cfc4a 100644 --- a/modules/ppcp-api-client/src/Helper/PartnerAttribution.php +++ b/modules/ppcp-api-client/src/Helper/PartnerAttribution.php @@ -58,20 +58,22 @@ class PartnerAttribution { } /** - * Initializes the BN Code if not already set. - * - * This method ensures that the BN Code is only stored once during the initial setup. + * Initializes or updates the BN Code. * * @param string $installation_path The installation path used to determine the BN Code. + * @param bool $force_update Whether to force an update of the BN code if it already exists. */ - public function initialize_bn_code( string $installation_path ) : void { + public function initialize_bn_code( string $installation_path, bool $force_update = false ) : void { $selected_bn_code = $this->bn_codes[ $installation_path ] ?? ''; - - if ( ! $selected_bn_code || get_option( $this->bn_code_option_name ) ) { + if ( ! $selected_bn_code ) { + return; + } + + $existing_bn_code = get_option( $this->bn_code_option_name ); + if ( $existing_bn_code && ! $force_update ) { return; } - // This option is permanent and should not change. update_option( $this->bn_code_option_name, $selected_bn_code ); } diff --git a/modules/ppcp-api-client/src/Helper/ProductStatus.php b/modules/ppcp-api-client/src/Helper/ProductStatus.php index bab84ed24..0c29db33d 100644 --- a/modules/ppcp-api-client/src/Helper/ProductStatus.php +++ b/modules/ppcp-api-client/src/Helper/ProductStatus.php @@ -114,7 +114,7 @@ abstract class ProductStatus { * @param Settings|null $settings See description in {@see self::clear()}. * @return void */ - abstract protected function clear_state( Settings $settings = null ) : void; + abstract protected function clear_state( ?Settings $settings = null ) : void; /** * Whether the merchant has access to the feature. @@ -199,7 +199,7 @@ abstract class ProductStatus { * @param Settings|null $settings The settings object. * @return void */ - public function clear( Settings $settings = null ) : void { + public function clear( ?Settings $settings = null ) : void { $this->is_eligible = null; $this->has_request_failure = false; diff --git a/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php b/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php index 1d222f605..b5cd133b6 100644 --- a/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php +++ b/modules/ppcp-api-client/src/Helper/PurchaseUnitSanitizer.php @@ -81,7 +81,7 @@ class PurchaseUnitSanitizer { * @param string|null $mode The mismatch handling mode, ditch or extra_line. * @param string|null $extra_line_name The name of the extra line. */ - public function __construct( string $mode = null, string $extra_line_name = null ) { + public function __construct( ?string $mode = null, ?string $extra_line_name = null ) { if ( ! in_array( $mode, self::VALID_MODES, true ) ) { $mode = self::MODE_DITCH; @@ -193,10 +193,16 @@ class PurchaseUnitSanitizer { $item_mismatch = $this->calculate_item_mismatch(); if ( $item_mismatch > 0 ) { + // Use appropriate category to preserve purely digital or physical goods baskets. + $rounding_item_category = $this->determine_rounding_item_category(); + // Add extra line item with roundings. - $line_name = $this->extra_line_name; - $roundings_money = new Money( $item_mismatch, $this->currency_code() ); - $this->purchase_unit['items'][] = ( new Item( $line_name, $roundings_money, 1 ) )->to_array(); + $line_name = $this->extra_line_name; + $roundings_money = new Money( $item_mismatch, $this->currency_code() ); + + $this->purchase_unit['items'][] = ( + new Item( $line_name, $roundings_money, 1, '', null, '', $rounding_item_category ) + )->to_array(); $this->set_last_message( __( 'Item amount mismatch. Extra line added.', 'woocommerce-paypal-payments' ) @@ -217,6 +223,24 @@ class PurchaseUnitSanitizer { } } + /** + * Determines the appropriate category for rounding items based on existing items. + * + * @return string The category (Item::DIGITAL_GOODS or Item::PHYSICAL_GOODS) + */ + private function determine_rounding_item_category(): string { + // Check if all items are digital goods. + foreach ( $this->items() as $item ) { + $category = $item['category'] ?? Item::PHYSICAL_GOODS; + if ( $category !== Item::DIGITAL_GOODS ) { + return Item::PHYSICAL_GOODS; + } + } + + // All items are digital goods. + return Item::DIGITAL_GOODS; + } + /** * The sanitizes the purchase_unit items tax. * diff --git a/modules/ppcp-api-client/src/Helper/ReferenceTransactionStatus.php b/modules/ppcp-api-client/src/Helper/ReferenceTransactionStatus.php new file mode 100644 index 000000000..405f4f58f --- /dev/null +++ b/modules/ppcp-api-client/src/Helper/ReferenceTransactionStatus.php @@ -0,0 +1,64 @@ +partners_endpoint = $partners_endpoint; + $this->cache = $cache; + } + + /** + * Checks if reference transactions are enabled in the merchant account. + * + * This method verifies if the merchant has the PAYPAL_WALLET_VAULTING_ADVANCED + * capability active, which is required for processing reference transactions. + * + * @return bool True if reference transactions are enabled, false otherwise. + */ + public function reference_transaction_enabled(): bool { + if ( $this->cache->has( self::CACHE_KEY ) ) { + return (bool) $this->cache->get( self::CACHE_KEY ); + } + + try { + foreach ( $this->partners_endpoint->seller_status()->capabilities() as $capability ) { + if ( + $capability->name() === 'PAYPAL_WALLET_VAULTING_ADVANCED' && + $capability->status() === 'ACTIVE' + ) { + $this->cache->set( self::CACHE_KEY, true, MONTH_IN_SECONDS ); + return true; + } + } + } catch ( RuntimeException $exception ) { + $this->cache->set( self::CACHE_KEY, false, HOUR_IN_SECONDS ); + return false; + } + + $this->cache->set( self::CACHE_KEY, false, HOUR_IN_SECONDS ); + return false; + } +} diff --git a/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php b/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php index 72f639b64..40f51326b 100644 --- a/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php +++ b/modules/ppcp-api-client/src/Repository/PartnerReferralsData.php @@ -54,7 +54,12 @@ class PartnerReferralsData { * @param bool $use_card_payments If the merchant wants to process credit card payments. * @return array */ - public function data( array $products = array(), string $onboarding_token = '', bool $use_subscriptions = null, bool $use_card_payments = true ) : array { + public function data( + array $products = array(), + string $onboarding_token = '', + ?bool $use_subscriptions = null, + bool $use_card_payments = true + ) : array { $in_acdc_country = $this->dcc_applies->for_country_currency(); if ( ! $products ) { diff --git a/modules/ppcp-applepay/src/ApplepayModule.php b/modules/ppcp-applepay/src/ApplepayModule.php index 38c5b5cd1..dd9530533 100644 --- a/modules/ppcp-applepay/src/ApplepayModule.php +++ b/modules/ppcp-applepay/src/ApplepayModule.php @@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Applepay; use WC_Payment_Gateway; use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder; use WooCommerce\PayPalCommerce\Applepay\Assets\ApplePayButton; use WooCommerce\PayPalCommerce\Applepay\Assets\AppleProductStatus; use WooCommerce\PayPalCommerce\Applepay\Assets\PropertiesDictionary; @@ -54,7 +55,7 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule // Clears product status when appropriate. add_action( 'woocommerce_paypal_payments_clear_apm_product_status', - function( Settings $settings = null ) use ( $c ): void { + function( ?Settings $settings = null ) use ( $c ): void { $apm_status = $c->get( 'applepay.apple-product-status' ); assert( $apm_status instanceof AppleProductStatus ); $apm_status->clear( $settings ); @@ -198,6 +199,31 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule } ); + add_filter( + 'ppcp_create_order_request_body_data', + static function ( array $data, string $payment_method, array $request ) use ( $c ) : array { + + if ( $payment_method !== ApplePayGateway::ID ) { + return $data; + } + + $experience_context_builder = $c->get( 'wcgateway.builder.experience-context' ); + assert( $experience_context_builder instanceof ExperienceContextBuilder ); + + $data['payment_source'] = array( + 'apple_pay' => array( + 'experience_context' => $experience_context_builder + ->with_endpoint_return_urls() + ->build()->to_array(), + ), + ); + + return $data; + }, + 10, + 3 + ); + return true; } @@ -215,7 +241,8 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule $validation_string = $this->validation_string( $is_sandbox ); nocache_headers(); header( 'Content-Type: text/plain', true, 200 ); - echo $validation_string;// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $validation_string; exit; } } diff --git a/modules/ppcp-applepay/src/Assets/ApplePayDataObjectHttp.php b/modules/ppcp-applepay/src/Assets/ApplePayDataObjectHttp.php index 9b08ecd0b..dad4993d5 100644 --- a/modules/ppcp-applepay/src/Assets/ApplePayDataObjectHttp.php +++ b/modules/ppcp-applepay/src/Assets/ApplePayDataObjectHttp.php @@ -160,7 +160,7 @@ class ApplePayDataObjectHttp { * @return void */ public function validation_data(): void { - $data = filter_input( INPUT_POST, 'validation', FILTER_VALIDATE_BOOL ); + $data = filter_input( INPUT_POST, 'validation', FILTER_VALIDATE_BOOLEAN ); if ( ! $data ) { return; } diff --git a/modules/ppcp-applepay/src/Assets/AppleProductStatus.php b/modules/ppcp-applepay/src/Assets/AppleProductStatus.php index c7ff102fe..2ffa6fbf6 100644 --- a/modules/ppcp-applepay/src/Assets/AppleProductStatus.php +++ b/modules/ppcp-applepay/src/Assets/AppleProductStatus.php @@ -101,7 +101,7 @@ class AppleProductStatus extends ProductStatus { } /** {@inheritDoc} */ - protected function clear_state( Settings $settings = null ) : void { + protected function clear_state( ?Settings $settings = null ) : void { if ( null === $settings ) { $settings = $this->settings; } diff --git a/modules/ppcp-axo/services.php b/modules/ppcp-axo/services.php index bd9882487..2d35fd136 100644 --- a/modules/ppcp-axo/services.php +++ b/modules/ppcp-axo/services.php @@ -99,7 +99,9 @@ return array( $container->get( 'api.factory.shipping-preference' ), $container->get( 'wcgateway.transaction-url-provider' ), $container->get( 'settings.environment' ), - $container->get( 'woocommerce.logger.woocommerce' ) + $container->get( 'woocommerce.logger.woocommerce' ), + $container->get( 'wcgateway.builder.experience-context' ), + $container->get( 'settings.data.settings' ) ); }, @@ -156,46 +158,63 @@ return array( * The matrix which countries and currency combinations can be used for AXO. */ 'axo.supported-country-currency-matrix' => static function ( ContainerInterface $container ) : array { + $matrix = array( + 'US' => array( + 'AUD', + 'CAD', + 'EUR', + 'GBP', + 'JPY', + 'USD', + ), + ); + + if ( $container->get( 'axo.uk.enabled' ) ) { + $matrix['GB'] = array( 'GBP' ); + } + /** * Returns which countries and currency combinations can be used for AXO. */ return apply_filters( 'woocommerce_paypal_payments_axo_supported_country_currency_matrix', - array( - 'US' => array( - 'AUD', - 'CAD', - 'EUR', - 'GBP', - 'JPY', - 'USD', - ), - ) + $matrix ); }, /** * The matrix which countries and card type combinations can be used for AXO. */ 'axo.supported-country-card-type-matrix' => static function ( ContainerInterface $container ) : array { + $matrix = array( + 'US' => array( + 'VISA', + 'MASTERCARD', + 'AMEX', + 'DISCOVER', + ), + 'CA' => array( + 'VISA', + 'MASTERCARD', + 'AMEX', + 'DISCOVER', + ), + ); + + if ( $container->get( 'axo.uk.enabled' ) ) { + $matrix['GB'] = array( + 'VISA', + 'MASTERCARD', + 'AMEX', + 'DISCOVER', + ); + } + /** * Returns which countries and card type combinations can be used for AXO. */ return apply_filters( 'woocommerce_paypal_payments_axo_supported_country_card_type_matrix', - array( - 'US' => array( - 'VISA', - 'MASTERCARD', - 'AMEX', - 'DISCOVER', - ), - 'CA' => array( - 'VISA', - 'MASTERCARD', - 'AMEX', - 'DISCOVER', - ), - ) + $matrix ); }, 'axo.settings-conflict-notice' => static function ( ContainerInterface $container ) : string { @@ -379,4 +398,17 @@ return array( ) ); }, + 'axo.uk.enabled' => static function ( ContainerInterface $container ): bool { + // phpcs:disable WordPress.NamingConventions.ValidHookName.UseUnderscores + /** + * Filter to determine if Fastlane UK with 3D Secure should be enabled. + * + * @param bool $enabled Whether Fastlane UK is enabled. + */ + return apply_filters( + 'woocommerce.feature-flags.woocommerce_paypal_payments.axo_uk_enabled', + getenv( 'PCP_AXO_UK_ENABLED' ) !== '0' + ); + // phpcs:enable WordPress.NamingConventions.ValidHookName.UseUnderscores + }, ); diff --git a/modules/ppcp-axo/src/Gateway/AxoGateway.php b/modules/ppcp-axo/src/Gateway/AxoGateway.php index d698455b5..0322431a9 100644 --- a/modules/ppcp-axo/src/Gateway/AxoGateway.php +++ b/modules/ppcp-axo/src/Gateway/AxoGateway.php @@ -11,13 +11,17 @@ namespace WooCommerce\PayPalCommerce\Axo\Gateway; use Psr\Log\LoggerInterface; use Exception; +use WC_AJAX; use WC_Order; use WC_Payment_Gateway; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentSource; +use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Gateway\GatewaySettingsRendererTrait; @@ -29,6 +33,8 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\ProcessPaymentTrait; use WooCommerce\PayPalCommerce\WcGateway\Exception\GatewayGenericException; use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\WcGateway\Helper\CardPaymentsConfiguration; +use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel; +use DomainException; /** * Class AXOGateway. @@ -129,6 +135,20 @@ class AxoGateway extends WC_Payment_Gateway { */ protected $session_handler; + /** + * The experience context builder. + * + * @var ExperienceContextBuilder + */ + protected $experience_context_builder; + + /** + * The settings model. + * + * @var SettingsModel + */ + protected $settings_model; + /** * AXOGateway constructor. * @@ -145,6 +165,8 @@ class AxoGateway extends WC_Payment_Gateway { * @param TransactionUrlProvider $transaction_url_provider The transaction url provider. * @param Environment $environment The environment. * @param LoggerInterface $logger The logger. + * @param ExperienceContextBuilder $experience_context_builder The experience context builder. + * @param SettingsModel $settings_model The settings model. */ public function __construct( SettingsRenderer $settings_renderer, @@ -159,17 +181,21 @@ class AxoGateway extends WC_Payment_Gateway { ShippingPreferenceFactory $shipping_preference_factory, TransactionUrlProvider $transaction_url_provider, Environment $environment, - LoggerInterface $logger + LoggerInterface $logger, + ExperienceContextBuilder $experience_context_builder, + SettingsModel $settings_model ) { $this->id = self::ID; - $this->settings_renderer = $settings_renderer; - $this->ppcp_settings = $ppcp_settings; - $this->dcc_configuration = $dcc_configuration; - $this->wcgateway_module_url = $wcgateway_module_url; - $this->session_handler = $session_handler; - $this->order_processor = $order_processor; - $this->card_icons = $card_icons; + $this->settings_renderer = $settings_renderer; + $this->ppcp_settings = $ppcp_settings; + $this->dcc_configuration = $dcc_configuration; + $this->wcgateway_module_url = $wcgateway_module_url; + $this->session_handler = $session_handler; + $this->order_processor = $order_processor; + $this->card_icons = $card_icons; + $this->experience_context_builder = $experience_context_builder; + $this->settings_model = $settings_model; $this->method_title = __( 'Fastlane Debit & Credit Cards', 'woocommerce-paypal-payments' ); $this->method_description = __( 'Fastlane accelerates the checkout experience for guest shoppers and autofills their details so they can pay in seconds. When enabled, Fastlane is presented as the default payment method for guests.', 'woocommerce-paypal-payments' ); @@ -234,12 +260,20 @@ class AxoGateway extends WC_Payment_Gateway { if ( ! is_a( $wc_order, WC_Order::class ) ) { return $this->handle_payment_failure( null, - new GatewayGenericException( new Exception( 'WC order was not found.' ) ) + new GatewayGenericException( new Exception( 'WC order was not found.' ) ), ); } + // phpcs:disable WordPress.Security.NonceVerification + + $axo_nonce = wc_clean( wp_unslash( $_POST['axo_nonce'] ?? '' ) ); + $token_param = wc_clean( wp_unslash( $_GET['token'] ?? '' ) ); + + if ( empty( $axo_nonce ) && ! empty( $token_param ) ) { + return $this->process_3ds_return( $wc_order, $token_param ); + } + try { - // phpcs:ignore WordPress.Security.NonceVerification.Missing $fastlane_member = wc_clean( wp_unslash( $_POST['fastlane_member'] ?? '' ) ); if ( $fastlane_member ) { $payment_method_title = __( 'Debit & Credit Cards (via Fastlane by PayPal)', 'woocommerce-paypal-payments' ); @@ -248,10 +282,37 @@ class AxoGateway extends WC_Payment_Gateway { } // The `axo_nonce` is not a WP nonce, but a card-token generated by the JS SDK. - // phpcs:ignore WordPress.Security.NonceVerification.Missing - $token = wc_clean( wp_unslash( $_POST['axo_nonce'] ?? '' ) ); + if ( empty( $axo_nonce ) ) { + return array( + 'result' => 'failure', + 'message' => __( 'No payment token provided. Please try again.', 'woocommerce-paypal-payments' ), + ); + } - $order = $this->create_paypal_order( $wc_order, $token ); + $order = $this->create_paypal_order( $wc_order, $axo_nonce ); + + // Check if 3DS verification is required. + $payer_action = $this->get_payer_action_url( $order ); + + // If 3DS verification is required, redirect with token in return URL. + if ( $payer_action ) { + $return_url = add_query_arg( + 'token', + $order->id(), + home_url( WC_AJAX::get_endpoint( ReturnUrlEndpoint::ENDPOINT ) ) + ); + + $redirect_url = add_query_arg( + 'redirect_uri', + rawurlencode( $return_url ), + $payer_action + ); + + return array( + 'result' => 'success', + 'redirect' => $redirect_url, + ); + } /** * This filter controls if the method 'process()' from OrderProcessor will be called. @@ -266,7 +327,11 @@ class AxoGateway extends WC_Payment_Gateway { $this->order_processor->process_captured_and_authorized( $wc_order, $order ); } } catch ( Exception $exception ) { - return $this->handle_payment_failure( $wc_order, $exception ); + $this->logger->error( '[AXO] Payment processing failed: ' . $exception->getMessage() ); + return array( + 'result' => 'failure', + 'message' => $this->get_user_friendly_error_message( $exception ), + ); } WC()->cart->empty_cart(); @@ -275,6 +340,104 @@ class AxoGateway extends WC_Payment_Gateway { 'result' => 'success', 'redirect' => $this->get_return_url( $wc_order ), ); + // phpcs:enable WordPress.Security.NonceVerification + } + + /** + * Process 3DS return scenario. + * + * @param WC_Order $wc_order The WooCommerce order. + * @param string $token The PayPal order token. + * + * @return array + */ + protected function process_3ds_return( WC_Order $wc_order, string $token ) : array { + try { + $paypal_order = $this->order_endpoint->order( $token ); + + if ( ! $paypal_order->status()->is( OrderStatus::COMPLETED ) ) { + return array( + 'result' => 'failure', + 'message' => __( '3D Secure authentication was not completed successfully. Please try again.', 'woocommerce-paypal-payments' ), + ); + } + + /** + * This filter controls if the method 'process()' from OrderProcessor will be called. + * So you can implement your own for example on subscriptions + * + * - true bool controls execution of 'OrderProcessor::process()' + * - $this \WC_Payment_Gateway + * - $wc_order \WC_Order + */ + $process = apply_filters( 'woocommerce_paypal_payments_before_order_process', true, $this, $wc_order ); + if ( $process ) { + $this->order_processor->process_captured_and_authorized( $wc_order, $paypal_order ); + } + } catch ( Exception $exception ) { + $this->logger->error( '[AXO] 3DS return processing failed: ' . $exception->getMessage() ); + return array( + 'result' => 'failure', + 'message' => $this->get_user_friendly_error_message( $exception ), + ); + } + + WC()->cart->empty_cart(); + + return array( + 'result' => 'success', + 'redirect' => $this->get_return_url( $wc_order ), + ); + } + + /** + * Convert exceptions to user-friendly messages. + */ + private function get_user_friendly_error_message( Exception $exception ) { + $error_message = $exception->getMessage(); + + // Handle specific error types with user-friendly messages. + if ( $exception instanceof DomainException ) { + if ( strpos( $error_message, 'Could not capture' ) !== false ) { + return __( '3D Secure authentication was unavailable or failed. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ); + } + } + + if ( strpos( $error_message, 'declined' ) !== false || + strpos( $error_message, 'PAYMENT_DENIED' ) !== false || + strpos( $error_message, 'INSTRUMENT_DECLINED' ) !== false || + strpos( $error_message, 'Payment provider declined' ) !== false ) { + return __( 'Your payment was declined after 3D Secure verification. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ); + } + + if ( strpos( $error_message, 'session' ) !== false || + strpos( $error_message, 'expired' ) !== false ) { + return __( 'Payment session expired. Please try your payment again.', 'woocommerce-paypal-payments' ); + } + + return __( 'There was an error processing your payment. Please try again or contact support.', 'woocommerce-paypal-payments' ); + } + + /** + * Extract payer action URL from PayPal order. + * + * @param Order $order The PayPal order. + * @return string The payer action URL or an empty string if not found. + */ + private function get_payer_action_url( Order $order ) : string { + $links = $order->links(); + + if ( ! $links ) { + return ''; + } + + foreach ( $links as $link ) { + if ( isset( $link->rel ) && $link->rel === 'payer-action' ) { + return $link->href ?? ''; + } + } + + return ''; } /** @@ -293,9 +456,8 @@ class AxoGateway extends WC_Payment_Gateway { 'checkout' ); - $payment_source_properties = (object) array( - 'single_use_token' => $payment_token, - ); + // Build payment source with 3DS verification if needed. + $payment_source_properties = $this->build_payment_source_properties( $payment_token ); $payment_source = new PaymentSource( 'card', @@ -306,12 +468,64 @@ class AxoGateway extends WC_Payment_Gateway { array( $purchase_unit ), $shipping_preference, null, - '', - array(), - $payment_source + self::ID, + $this->build_order_data(), + $payment_source, + $wc_order ); } + /** + * Build payment source properties. + * + * @param string $payment_token The payment token. + * @return object The payment source properties. + */ + protected function build_payment_source_properties( string $payment_token ): object { + $properties = array( + 'single_use_token' => $payment_token, + ); + + $three_d_secure = $this->settings_model->get_three_d_secure_enum(); + + if ( 'SCA_ALWAYS' === $three_d_secure || 'SCA_WHEN_REQUIRED' === $three_d_secure ) { + $properties['attributes'] = array( + 'verification' => array( + 'method' => $three_d_secure, + ), + ); + } + + return (object) $properties; + } + + /** + * Build additional order data for experience context and 3DS verification. + * + * @return array The order data. + */ + protected function build_order_data(): array { + $data = array(); + + $experience_context = $this->experience_context_builder + ->with_endpoint_return_urls() + ->with_current_brand_name() + ->with_current_locale() + ->build(); + + $data['experience_context'] = $experience_context->to_array(); + + $three_d_secure = $this->settings_model->get_three_d_secure_enum(); + + if ( $three_d_secure === 'SCA_ALWAYS' || $three_d_secure === 'SCA_WHEN_REQUIRED' ) { + $data['transaction_context'] = array( + 'soft_descriptor' => __( 'Card verification hold', 'woocommerce-paypal-payments' ), + ); + } + + return $data; + } + /** * Returns the icons of the gateway. * diff --git a/modules/ppcp-blocks/resources/js/Components/paypal.js b/modules/ppcp-blocks/resources/js/Components/paypal.js index 7257238e5..ffcd4d081 100644 --- a/modules/ppcp-blocks/resources/js/Components/paypal.js +++ b/modules/ppcp-blocks/resources/js/Components/paypal.js @@ -20,6 +20,7 @@ import { handleApproveSubscription, onApproveSavePayment, } from '../paypal-config'; +import { useRef } from 'react'; const PAYPAL_GATEWAY_ID = 'ppcp-gateway'; @@ -52,6 +53,8 @@ export const PayPalComponent = ( { const [ paypalScriptLoaded, setPaypalScriptLoaded ] = useState( false ); + const paypalButtonRef = useRef( null ); + if ( ! paypalScriptLoaded ) { if ( ! paypalScriptPromise ) { // for editor, since canMakePayment was not called @@ -139,6 +142,16 @@ export const PayPalComponent = ( { onClick(); }; + const handleButtonInit = () => { + if ( fundingSource === 'paypal' ) { + const buttonInstance = paypalButtonRef.current?.state?.parent; + + if ( buttonInstance?.hasReturned?.() ) { + buttonInstance.resume(); + } + } + }; + const shouldHandleShippingInPayPal = () => { return shouldskipFinalConfirmation() && config.needShipping; }; @@ -352,6 +365,10 @@ export const PayPalComponent = ( { ); const getOnShippingOptionsChange = ( fundingSource ) => { + if ( config.scriptData.server_side_shipping_callback.enabled ) { + return null; + } + if ( fundingSource === 'venmo' ) { return null; } @@ -364,6 +381,10 @@ export const PayPalComponent = ( { }; const getOnShippingAddressChange = ( fundingSource ) => { + if ( config.scriptData.server_side_shipping_callback.enabled ) { + return null; + } + if ( fundingSource === 'venmo' ) { return null; } @@ -377,6 +398,15 @@ export const PayPalComponent = ( { }; }; + const shouldEnableAppSwitch = () => { + // AppSwitch should only be enabled in Pay Now flows with server side shipping callback. + return ( + config.scriptData.appswitch.enabled && + ! config.scriptData.final_review_enabled && + config.scriptData.server_side_shipping_callback.enabled + ); + }; + if ( cartHasSubscriptionProducts( config.scriptData ) && config.scriptData.is_free_trial_cart @@ -434,8 +464,11 @@ export const PayPalComponent = ( { return ( { export const paypalPayerToWc = ( payer ) => { const firstName = payer?.name?.given_name ?? ''; const lastName = payer?.name?.surname ?? ''; - const phone = payer?.phone?.phone_number?.national_number ?? ''; + 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 + phone, }; }; @@ -115,7 +115,7 @@ export const paypalSubscriberToWc = ( subscriber ) => { * @return {Object} */ export const paypalOrderToWcShippingAddress = ( order ) => { - const shipping = order.purchase_units[ 0 ].shipping; + const shipping = order.purchase_units?.[ 0 ]?.shipping; if ( ! shipping ) { return {}; } diff --git a/modules/ppcp-blocks/resources/js/paypal-config.js b/modules/ppcp-blocks/resources/js/paypal-config.js index 363ee5078..103e49b23 100644 --- a/modules/ppcp-blocks/resources/js/paypal-config.js +++ b/modules/ppcp-blocks/resources/js/paypal-config.js @@ -2,6 +2,7 @@ import { paypalOrderToWcAddresses, paypalSubscriptionToWcAddresses, } from './Helper/Address'; +import ResumeFlowHelper from '../../../ppcp-button/resources/js/modules/Helper/ResumeFlowHelper'; export const createOrder = async ( data, config, onError, onClose ) => { try { @@ -66,35 +67,63 @@ export const handleApprove = async ( onClose ) => { try { - const order = await actions.order.get(); + let order; - if ( order ) { - const addresses = paypalOrderToWcAddresses( order ); + // actions.order.get is not available on the AppSwitch flow. + if ( ! ResumeFlowHelper.isResumeFlow() ) { + order = await actions.order.get(); + } else { + const res = await fetch( + config.scriptData.ajax.get_order.endpoint, + { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( { + nonce: config.scriptData.ajax.get_order.nonce, + order_id: data.orderID, + } ), + } + ); - const promises = [ - // save address on server - wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { - billing_address: addresses.billingAddress, - shipping_address: addresses.shippingAddress, - } ), - ]; - if ( shouldHandleShippingInPayPal() ) { - // set address in UI + const json = await res.json(); + + if ( ! json.success ) { + throw new Error( + json.data?.message || config.scriptData.labels.error.generic + ); + } + + order = json.data; + } + + const addresses = paypalOrderToWcAddresses( order ); + + const promises = [ + // save address on server + wp.data.dispatch( 'wc/store/cart' ).updateCustomerData( { + billing_address: addresses.billingAddress, + shipping_address: addresses.shippingAddress, + } ), + ]; + if ( shouldHandleShippingInPayPal() ) { + // set address in UI + promises.push( + wp.data + .dispatch( 'wc/store/cart' ) + .setBillingAddress( addresses.billingAddress ) + ); + if ( shippingData.needsShipping ) { promises.push( wp.data .dispatch( 'wc/store/cart' ) - .setBillingAddress( addresses.billingAddress ) + .setShippingAddress( addresses.shippingAddress ) ); - if ( shippingData.needsShipping ) { - promises.push( - wp.data - .dispatch( 'wc/store/cart' ) - .setShippingAddress( addresses.shippingAddress ) - ); - } } - await Promise.all( promises ); } + await Promise.all( promises ); setPaypalOrder( order ); diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/CartActionHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/CartActionHandler.js index 67fe573f8..5eb0a6b01 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/CartActionHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/CartActionHandler.js @@ -1,6 +1,7 @@ import onApprove from '../OnApproveHandler/onApproveForContinue.js'; import { payerData } from '../Helper/PayerData'; import { PaymentMethods } from '../Helper/CheckoutMethodState'; +import ResumeFlowHelper from '../Helper/ResumeFlowHelper'; class CartActionHandler { constructor( config, errorHandler ) { @@ -8,14 +9,14 @@ class CartActionHandler { this.errorHandler = errorHandler; } - subscriptionsConfiguration( subscription_plan_id ) { + subscriptionsConfiguration( subscriptionPlanId ) { return { createSubscription: ( data, actions ) => { return actions.subscription.create( { - plan_id: subscription_plan_id, + plan_id: subscriptionPlanId, } ); }, - onApprove: ( data, actions ) => { + onApprove: ( data ) => { fetch( this.config.ajax.approve_subscription.endpoint, { method: 'POST', credentials: 'same-origin', @@ -24,7 +25,7 @@ class CartActionHandler { order_id: data.orderID, subscription_id: data.subscriptionID, should_create_wc_order: - ! context.config.vaultingEnabled || + ! this.config.vaultingEnabled || data.paymentSource !== 'venmo', } ), } ) @@ -33,7 +34,6 @@ class CartActionHandler { } ) .then( ( data ) => { if ( ! data.success ) { - console.log( data ); throw Error( data.data.message ); } @@ -41,7 +41,7 @@ class CartActionHandler { location.href = orderReceivedUrl ? orderReceivedUrl - : context.config.redirect; + : this.config.redirect; } ); }, onError: ( err ) => { @@ -51,7 +51,7 @@ class CartActionHandler { } configuration() { - const createOrder = ( data, actions ) => { + const createOrder = () => { const payer = payerData(); const bnCode = typeof this.config.bn_codes[ this.config.context ] !== @@ -89,8 +89,15 @@ class CartActionHandler { return { createOrder, onApprove: onApprove( this, this.errorHandler ), - onError: ( error ) => { + onError: () => { this.errorHandler.genericError(); + + if ( ResumeFlowHelper.isResumeFlow() ) { + ResumeFlowHelper.cleanHashParams(); + jQuery( this.config.button.wrapper ).trigger( + 'ppcp-reload-buttons' + ); + } }, }; } diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js index 4c2fa9b2d..8a17927d7 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js @@ -3,6 +3,7 @@ import onApprove from '../OnApproveHandler/onApproveForPayNow.js'; import { payerData } from '../Helper/PayerData'; import { getCurrentPaymentMethod } from '../Helper/CheckoutMethodState'; import validateCheckoutForm from '../Helper/CheckoutFormValidation'; +import ResumeFlowHelper from '../Helper/ResumeFlowHelper'; class CheckoutActionHandler { constructor( config, errorHandler, spinner ) { @@ -170,6 +171,13 @@ class CheckoutActionHandler { } this.errorHandler.genericError(); + + if ( ResumeFlowHelper.isResumeFlow() ) { + ResumeFlowHelper.cleanHashParams(); + jQuery( this.config.button.wrapper ).trigger( + 'ppcp-reload-buttons' + ); + } }, }; } diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js index 8b1d7bf75..85a4888cf 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js @@ -5,6 +5,7 @@ import { payerData } from '../Helper/PayerData'; import { PaymentMethods } from '../Helper/CheckoutMethodState'; import CartHelper from '../Helper/CartHelper'; import FormHelper from '../Helper/FormHelper'; +import ResumeFlowHelper from '../Helper/ResumeFlowHelper'; class SingleProductActionHandler { constructor( config, updateCart, formElement, errorHandler ) { @@ -64,6 +65,13 @@ class SingleProductActionHandler { }, onError: ( err ) => { console.error( err ); + + if ( ResumeFlowHelper.isResumeFlow() ) { + ResumeFlowHelper.cleanHashParams(); + jQuery( this.config.button.wrapper ).trigger( + 'ppcp-reload-buttons' + ); + } }, }; } @@ -83,9 +91,16 @@ class SingleProductActionHandler { if ( this.isBookingProduct() && error.message ) { this.errorHandler.clear(); this.errorHandler.message( error.message ); - return; + } else { + this.errorHandler.genericError(); + } + + if ( ResumeFlowHelper.isResumeFlow() ) { + ResumeFlowHelper.cleanHashParams(); + jQuery( this.config.button.wrapper ).trigger( + 'ppcp-reload-buttons' + ); } - this.errorHandler.genericError(); }, onCancel: () => { // Could be used for every product type, diff --git a/modules/ppcp-button/resources/js/modules/Helper/ResumeFlowHelper.js b/modules/ppcp-button/resources/js/modules/Helper/ResumeFlowHelper.js new file mode 100644 index 000000000..9f0403be8 --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/ResumeFlowHelper.js @@ -0,0 +1,59 @@ +class ResumeFlowHelper { + static PAYPAL_PARAMS = [ + 'onApprove', + 'token', + 'PayerID', + 'payerID', + 'button_session_id', + 'billingToken', + 'orderID', + 'switch_initiated_time', + 'onCancel', + 'onError', + ]; + + static cleanHashParams() { + if ( ! window.location.hash ) { + return; + } + + const hashString = window.location.hash.substring( 1 ); + const params = hashString.split( '&' ); + + const cleanedParams = params.filter( ( param ) => { + const paramName = param.split( '=' )[ 0 ]; + return ! this.PAYPAL_PARAMS.includes( paramName ); + } ); + + if ( cleanedParams.length > 0 ) { + const newHash = '#' + cleanedParams.join( '&' ); + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search + newHash + ); + } else { + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search + ); + } + } + + static isResumeFlow() { + if ( ! window.location.hash ) { + return false; + } + + const hashString = window.location.hash.substring( 1 ); + const params = hashString.split( '&' ); + + return params.some( ( param ) => { + const paramName = param.split( '=' )[ 0 ]; + return paramName === 'switch_initiated_time'; + } ); + } +} + +export default ResumeFlowHelper; diff --git a/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js b/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js index 0bdff2bb8..428529a96 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js @@ -216,12 +216,13 @@ export default class PaymentButton { /** * Factory method to create a new PaymentButton while limiting a single instance per context. * - * @param {string} context - Button context name. - * @param {unknown} externalHandler - Handler object. - * @param {Object} buttonConfig - Payment button specific configuration. - * @param {Object} ppcpConfig - Plugin wide configuration object. - * @param {unknown} contextHandler - Handler object. - * @param {Object} buttonAttributes - Button attributes. + * @param {string} context - Button context name. + * @param {unknown} externalHandler - Handler object. + * @param {Object} buttonConfig - Payment button specific configuration. + * @param {Object} ppcpConfig - Plugin wide configuration object. + * @param {unknown} contextHandler - Handler object. + * @param {Object} buttonAttributes - Button attributes. + * @param {Function} onClick - Event from content component registered in registerExpressPaymentMethod. * @return {PaymentButton} The button instance. */ static createButton( @@ -230,7 +231,8 @@ export default class PaymentButton { buttonConfig, ppcpConfig, contextHandler, - buttonAttributes + buttonAttributes, + onClick = null ) { const buttonInstances = getInstances(); const instanceKey = `${ this.methodId }.${ context }`; @@ -242,7 +244,8 @@ export default class PaymentButton { buttonConfig, ppcpConfig, contextHandler, - buttonAttributes + buttonAttributes, + onClick ); buttonInstances.set( instanceKey, button ); @@ -286,12 +289,13 @@ export default class PaymentButton { * to avoid multiple button instances handling the same context. * * @private - * @param {string} context - Button context name. - * @param {Object} externalHandler - Handler object. - * @param {Object} buttonConfig - Payment button specific configuration. - * @param {Object} ppcpConfig - Plugin wide configuration object. - * @param {Object} contextHandler - Handler object. - * @param {Object} buttonAttributes - Button attributes. + * @param {string} context - Button context name. + * @param {Object} externalHandler - Handler object. + * @param {Object} buttonConfig - Payment button specific configuration. + * @param {Object} ppcpConfig - Plugin wide configuration object. + * @param {Object} contextHandler - Handler object. + * @param {Object} buttonAttributes - Button attributes. + * @param {Function} onClick - Event from content component registered in registerExpressPaymentMethod. */ constructor( context, @@ -299,7 +303,8 @@ export default class PaymentButton { buttonConfig = {}, ppcpConfig = {}, contextHandler = null, - buttonAttributes = {} + buttonAttributes = {}, + onClick = null ) { if ( this.methodId === PaymentButton.methodId ) { throw new Error( 'Cannot initialize the PaymentButton base class' ); @@ -318,6 +323,7 @@ export default class PaymentButton { this.#externalHandler = externalHandler; this.#contextHandler = contextHandler; this.#buttonAttributes = buttonAttributes; + this.onClick = onClick; this.#logger = new ConsoleLogger( methodName, context ); diff --git a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js index ab9da6a0b..b5fb3c1fb 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js @@ -144,7 +144,10 @@ class Renderer { }; // Check the condition and add the handler if needed - if ( this.shouldEnableShippingCallback() ) { + if ( + this.shouldEnableShippingCallback() && + ! this.defaultSettings.server_side_shipping_callback.enabled + ) { options.onShippingOptionsChange = ( data, actions ) => { const shippingOptionsChange = ! this.isVenmoButtonClickedWhenVaultingIsEnabled( @@ -175,6 +178,10 @@ class Renderer { }; } + if ( this.shouldEnableAppSwitch() ) { + options.appSwitchWhenAvailable = true; + } + return options; }; @@ -242,6 +249,15 @@ class Renderer { ); }; + shouldEnableAppSwitch = () => { + // AppSwitch should only be enabled in Pay Now flows with server side shipping callback. + return ( + this.defaultSettings.appswitch.enabled && + ! this.defaultSettings.final_review_enabled && + this.defaultSettings.server_side_shipping_callback.enabled + ); + }; + isAlreadyRendered( wrapper, fundingSource ) { return this.renderedSources.has( wrapper + ( fundingSource ?? '' ) ); } diff --git a/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js b/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js index 13d915df2..559bd0e19 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js @@ -64,7 +64,11 @@ class WidgetBuilder { return; } - btn.render( target ); + if ( btn.hasReturned() ) { + btn.resume(); + } else { + btn.render( target ); + } } renderAllButtons() { diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index d20e655a6..c4f305f84 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -29,6 +29,7 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\ApproveOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\ChangeCartEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\CreateOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\DataClientIdEndpoint; +use WooCommerce\PayPalCommerce\Button\Endpoint\GetOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData; use WooCommerce\PayPalCommerce\Button\Endpoint\StartPayPalVaultingEndpoint; use WooCommerce\PayPalCommerce\Button\Exception\RuntimeException; @@ -167,9 +168,12 @@ return array( $container->get( 'api.endpoint.payment-tokens' ), $container->get( 'woocommerce.logger.woocommerce' ), $container->get( 'button.handle-shipping-in-paypal' ), + $container->get( 'wcgateway.server-side-shipping-callback-enabled' ), + $container->get( 'wcgateway.appswitch-enabled' ), $container->get( 'button.helper.disabled-funding-sources' ), $container->get( 'wcgateway.configuration.card-configuration' ), - $container->get( 'api.helper.partner-attribution' ) + $container->get( 'api.helper.partner-attribution' ), + $container->get( 'blocks.settings.final_review_enabled' ) ); }, 'button.url' => static function ( ContainerInterface $container ): string { @@ -227,6 +231,8 @@ return array( $request_data, $purchase_unit_factory, $container->get( 'api.factory.shipping-preference' ), + $container->get( 'api.factory.return-url' ), + $container->get( 'api.factory.contact-preference' ), $container->get( 'wcgateway.builder.experience-context' ), $order_endpoint, $payer_factory, @@ -238,6 +244,7 @@ return array( $container->get( 'button.early-wc-checkout-validation-enabled' ), $container->get( 'button.pay-now-contexts' ), $container->get( 'button.handle-shipping-in-paypal' ), + $container->get( 'wcgateway.server-side-shipping-callback-enabled' ), $container->get( 'wcgateway.funding-sources-without-redirect' ), $logger ); @@ -327,6 +334,16 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, + 'button.endpoint.get-order' => static function ( ContainerInterface $container ): GetOrderEndpoint { + $request_data = $container->get( 'button.request-data' ); + $order_endpoint = $container->get( 'api.endpoint.order' ); + $logger = $container->get( 'woocommerce.logger.woocommerce' ); + return new GetOrderEndpoint( + $request_data, + $order_endpoint, + $logger + ); + }, 'button.helper.cart-products' => static function ( ContainerInterface $container ): CartProductsHelper { $data_store = \WC_Data_Store::load( 'product' ); return new CartProductsHelper( $data_store ); diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index a89edda5b..0eb3ce360 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -29,6 +29,7 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\CartScriptParamsEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\ChangeCartEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\CreateOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\DataClientIdEndpoint; +use WooCommerce\PayPalCommerce\Button\Endpoint\GetOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData; use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\SimulateCartEndpoint; @@ -253,6 +254,21 @@ class SmartButton implements SmartButtonInterface { */ protected PartnerAttribution $partner_attribution; + /** + * Whether the server-side shipping callback is enabled (feature flag). + */ + private bool $server_side_shipping_callback_enabled; + + /** + * Whether the AppSwitch is enabled (feature flag). + */ + private bool $appswitch_enabled; + + /** + * Whether the final review is enabled in blocks settings. + */ + private bool $final_review_enabled; + /** * SmartButton constructor. * @@ -279,9 +295,12 @@ class SmartButton implements SmartButtonInterface { * @param PaymentTokensEndpoint $payment_tokens_endpoint Payment tokens endpoint. * @param LoggerInterface $logger The logger. * @param bool $should_handle_shipping_in_paypal Whether the shipping should be handled in PayPal. + * @param bool $server_side_shipping_callback_enabled Whether the server-side shipping callback is enabled (feature flag). + * @param bool $appswitch_enabled Whether the AppSwitch is enabled (feature flag). * @param DisabledFundingSources $disabled_funding_sources List of funding sources to be disabled. * @param CardPaymentsConfiguration $dcc_configuration The DCC Gateway Configuration. * @param PartnerAttribution $partner_attribution The PayPal Partner Attribution Helper. + * @param bool $final_review_enabled Whether the final review is enabled in blocks settings. */ public function __construct( string $module_url, @@ -307,36 +326,42 @@ class SmartButton implements SmartButtonInterface { PaymentTokensEndpoint $payment_tokens_endpoint, LoggerInterface $logger, bool $should_handle_shipping_in_paypal, + bool $server_side_shipping_callback_enabled, + bool $appswitch_enabled, DisabledFundingSources $disabled_funding_sources, CardPaymentsConfiguration $dcc_configuration, - PartnerAttribution $partner_attribution + PartnerAttribution $partner_attribution, + bool $final_review_enabled ) { - $this->module_url = $module_url; - $this->version = $version; - $this->session_handler = $session_handler; - $this->settings = $settings; - $this->payer_factory = $payer_factory; - $this->client_id = $client_id; - $this->request_data = $request_data; - $this->dcc_applies = $dcc_applies; - $this->subscription_helper = $subscription_helper; - $this->messages_apply = $messages_apply; - $this->environment = $environment; - $this->payment_token_repository = $payment_token_repository; - $this->settings_status = $settings_status; - $this->currency = $currency; - $this->all_funding_sources = $all_funding_sources; - $this->basic_checkout_validation_enabled = $basic_checkout_validation_enabled; - $this->early_validation_enabled = $early_validation_enabled; - $this->pay_now_contexts = $pay_now_contexts; - $this->funding_sources_without_redirect = $funding_sources_without_redirect; - $this->vault_v3_enabled = $vault_v3_enabled; - $this->logger = $logger; - $this->payment_tokens_endpoint = $payment_tokens_endpoint; - $this->should_handle_shipping_in_paypal = $should_handle_shipping_in_paypal; - $this->disabled_funding_sources = $disabled_funding_sources; - $this->dcc_configuration = $dcc_configuration; - $this->partner_attribution = $partner_attribution; + $this->module_url = $module_url; + $this->version = $version; + $this->session_handler = $session_handler; + $this->settings = $settings; + $this->payer_factory = $payer_factory; + $this->client_id = $client_id; + $this->request_data = $request_data; + $this->dcc_applies = $dcc_applies; + $this->subscription_helper = $subscription_helper; + $this->messages_apply = $messages_apply; + $this->environment = $environment; + $this->payment_token_repository = $payment_token_repository; + $this->settings_status = $settings_status; + $this->currency = $currency; + $this->all_funding_sources = $all_funding_sources; + $this->basic_checkout_validation_enabled = $basic_checkout_validation_enabled; + $this->early_validation_enabled = $early_validation_enabled; + $this->pay_now_contexts = $pay_now_contexts; + $this->funding_sources_without_redirect = $funding_sources_without_redirect; + $this->vault_v3_enabled = $vault_v3_enabled; + $this->logger = $logger; + $this->payment_tokens_endpoint = $payment_tokens_endpoint; + $this->should_handle_shipping_in_paypal = $should_handle_shipping_in_paypal; + $this->server_side_shipping_callback_enabled = $server_side_shipping_callback_enabled; + $this->appswitch_enabled = $appswitch_enabled; + $this->disabled_funding_sources = $disabled_funding_sources; + $this->dcc_configuration = $dcc_configuration; + $this->partner_attribution = $partner_attribution; + $this->final_review_enabled = $final_review_enabled; } /** @@ -793,7 +818,7 @@ document.querySelector("#payment").before(document.querySelector(".ppcp-messages * @param string $gateway_id The gateway ID, like 'ppcp-gateway'. * @param string|null $action_name The action name to be called. */ - public function button_renderer( string $gateway_id, string $action_name = null ) { + public function button_renderer( string $gateway_id, ?string $action_name = null ) { $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); @@ -1173,6 +1198,10 @@ document.querySelector("#payment").before(document.querySelector(".ppcp-messages 'endpoint' => \WC_AJAX::get_endpoint( ApproveOrderEndpoint::ENDPOINT ), 'nonce' => wp_create_nonce( ApproveOrderEndpoint::nonce() ), ), + 'get_order' => array( + 'endpoint' => \WC_AJAX::get_endpoint( GetOrderEndpoint::ENDPOINT ), + 'nonce' => wp_create_nonce( GetOrderEndpoint::nonce() ), + ), 'approve_subscription' => array( 'endpoint' => \WC_AJAX::get_endpoint( ApproveSubscriptionEndpoint::ENDPOINT ), 'nonce' => wp_create_nonce( ApproveSubscriptionEndpoint::nonce() ), @@ -1341,10 +1370,17 @@ document.querySelector("#payment").before(document.querySelector(".ppcp-messages 'has_wc_card_payment_tokens' => $this->user_has_wc_card_payment_tokens( get_current_user_id() ), ), 'should_handle_shipping_in_paypal' => $this->should_handle_shipping_in_paypal && ! $this->is_checkout(), + 'server_side_shipping_callback' => array( + 'enabled' => $this->server_side_shipping_callback_enabled, + ), + 'appswitch' => array( + 'enabled' => $this->appswitch_enabled, + ), 'needShipping' => $this->need_shipping(), 'vaultingEnabled' => $this->settings->has( 'vault_enabled' ) && $this->settings->get( 'vault_enabled' ), 'productType' => null, 'manualRenewalEnabled' => $this->subscription_helper->accept_manual_renewals(), + 'final_review_enabled' => $this->final_review_enabled, ); if ( is_product() ) { @@ -1901,8 +1937,10 @@ document.querySelector("#payment").before(document.querySelector(".ppcp-messages $variations = $product->get_available_variations( 'objects' ); $in_stock = $this->has_in_stock_variation( $variations ); } + // phpcs:disable WordPress.Security.NonceVerification.Recommended $enable_button = ! $product->is_type( array( 'external', 'grouped' ) ) && $in_stock && ! ( ( $product->is_type( 'subscription' ) || $product->is_type( 'variable-subscription' ) ) && ! empty( $_GET['switch-subscription'] ) ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended /** * Allows to filter if PayPal buttons/messages can be rendered for the given product. @@ -1941,7 +1979,7 @@ document.querySelector("#payment").before(document.querySelector(".ppcp-messages * @param array $context_data The context data for this filter. * @return bool */ - public function is_button_disabled( string $context = null, array $context_data = array() ): bool { + public function is_button_disabled( ?string $context = null, array $context_data = array() ): bool { if ( null === $context ) { $context = $this->context(); } diff --git a/modules/ppcp-button/src/ButtonModule.php b/modules/ppcp-button/src/ButtonModule.php index cd2c8d381..cb3110329 100644 --- a/modules/ppcp-button/src/ButtonModule.php +++ b/modules/ppcp-button/src/ButtonModule.php @@ -19,6 +19,7 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\ApproveOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\ChangeCartEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\CreateOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\DataClientIdEndpoint; +use WooCommerce\PayPalCommerce\Button\Endpoint\GetOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\StartPayPalVaultingEndpoint; use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; @@ -217,5 +218,14 @@ class ButtonModule implements ServiceModule, ExtendingModule, ExecutableModule { $endpoint->handle_request(); } ); + + add_action( + 'wc_ajax_' . GetOrderEndpoint::ENDPOINT, + static function () use ( $container ) { + $endpoint = $container->get( 'button.endpoint.get-order' ); + assert( $endpoint instanceof GetOrderEndpoint ); + $endpoint->handle_request(); + } + ); } } diff --git a/modules/ppcp-button/src/Endpoint/ApproveOrderEndpoint.php b/modules/ppcp-button/src/Endpoint/ApproveOrderEndpoint.php index 8749fb4ba..f4ff90218 100644 --- a/modules/ppcp-button/src/Endpoint/ApproveOrderEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/ApproveOrderEndpoint.php @@ -173,9 +173,7 @@ class ApproveOrderEndpoint implements EndpointInterface { try { $data = $this->request_data->read_request( self::nonce() ); if ( ! isset( $data['order_id'] ) ) { - throw new RuntimeException( - __( 'No order id given', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No order id given' ); } $order = $this->api_endpoint->order( $data['order_id'] ); diff --git a/modules/ppcp-button/src/Endpoint/ApproveSubscriptionEndpoint.php b/modules/ppcp-button/src/Endpoint/ApproveSubscriptionEndpoint.php index d89a0a1af..ba737d328 100644 --- a/modules/ppcp-button/src/Endpoint/ApproveSubscriptionEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/ApproveSubscriptionEndpoint.php @@ -111,9 +111,7 @@ class ApproveSubscriptionEndpoint implements EndpointInterface { public function handle_request(): bool { $data = $this->request_data->read_request( $this->nonce() ); if ( ! isset( $data['order_id'] ) ) { - throw new RuntimeException( - __( 'No order id given', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'No order id given' ); } $order = $this->order_endpoint->order( $data['order_id'] ); diff --git a/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php b/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php index bfa8ba2fd..2f6bc375d 100644 --- a/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php @@ -76,7 +76,8 @@ class CartScriptParamsEndpoint implements EndpointInterface { wc_maybe_define_constant( 'WOOCOMMERCE_CART', true ); } - $include_shipping = (bool) wc_clean( wp_unslash( $_GET['shipping'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $include_shipping = (bool) wc_clean( wp_unslash( $_GET['shipping'] ?? '' ) ); $script_data = $this->smart_button->script_data(); if ( ! $script_data ) { @@ -155,7 +156,9 @@ class CartScriptParamsEndpoint implements EndpointInterface { 'description' => html_entity_decode( wp_strip_all_tags( wc_price( (float) $rate->get_cost(), array( 'currency' => get_woocommerce_currency() ) ) - ) + ), + ENT_QUOTES, + 'UTF-8' ), ); } diff --git a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php index df99b8022..818c22a4b 100644 --- a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php @@ -13,6 +13,7 @@ use Exception; use Psr\Log\LoggerInterface; use stdClass; use Throwable; +use WC_Order; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\Amount; use WooCommerce\PayPalCommerce\ApiClient\Entity\ExperienceContext; @@ -26,6 +27,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder; use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ReturnUrlFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory; use WooCommerce\PayPalCommerce\Button\Exception\ValidationException; use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator; @@ -37,6 +39,7 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ContactPreferenceFactory; /** * Class CreateOrderEndpoint @@ -68,6 +71,13 @@ class CreateOrderEndpoint implements EndpointInterface { */ private $shipping_preference_factory; + private ReturnUrlFactory $return_url_factory; + + /** + * The contact_preference factors. + */ + private ContactPreferenceFactory $contact_preference_factory; + /** * The ExperienceContextBuilder. */ @@ -157,6 +167,11 @@ class CreateOrderEndpoint implements EndpointInterface { */ private $handle_shipping_in_paypal; + /** + * Whether the server-side shipping callback is enabled (feature flag). + */ + private bool $server_side_shipping_callback_enabled; + /** * The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. * @@ -184,6 +199,8 @@ class CreateOrderEndpoint implements EndpointInterface { * @param RequestData $request_data The RequestData object. * @param PurchaseUnitFactory $purchase_unit_factory The PurchaseUnit factory. * @param ShippingPreferenceFactory $shipping_preference_factory The shipping_preference factory. + * @param ReturnUrlFactory $return_url_factory The return URL factory. + * @param ContactPreferenceFactory $contact_preference_factory The contact_preference factory. * @param ExperienceContextBuilder $experience_context_builder The ExperienceContextBuilder. * @param OrderEndpoint $order_endpoint The OrderEndpoint object. * @param PayerFactory $payer_factory The PayerFactory object. @@ -195,6 +212,7 @@ class CreateOrderEndpoint implements EndpointInterface { * @param bool $early_validation_enabled Whether to execute WC validation of the checkout form. * @param string[] $pay_now_contexts The contexts that should have the Pay Now button. * @param bool $handle_shipping_in_paypal If true, the shipping methods are sent to PayPal allowing the customer to select it inside the popup. + * @param bool $server_side_shipping_callback_enabled Whether the server-side shipping callback is enabled (feature flag). * @param string[] $funding_sources_without_redirect The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. * @param LoggerInterface $logger The logger. */ @@ -202,6 +220,8 @@ class CreateOrderEndpoint implements EndpointInterface { RequestData $request_data, PurchaseUnitFactory $purchase_unit_factory, ShippingPreferenceFactory $shipping_preference_factory, + ReturnUrlFactory $return_url_factory, + ContactPreferenceFactory $contact_preference_factory, ExperienceContextBuilder $experience_context_builder, OrderEndpoint $order_endpoint, PayerFactory $payer_factory, @@ -213,26 +233,30 @@ class CreateOrderEndpoint implements EndpointInterface { bool $early_validation_enabled, array $pay_now_contexts, bool $handle_shipping_in_paypal, + bool $server_side_shipping_callback_enabled, array $funding_sources_without_redirect, LoggerInterface $logger ) { - $this->request_data = $request_data; - $this->purchase_unit_factory = $purchase_unit_factory; - $this->shipping_preference_factory = $shipping_preference_factory; - $this->experience_context_builder = $experience_context_builder; - $this->api_endpoint = $order_endpoint; - $this->payer_factory = $payer_factory; - $this->session_handler = $session_handler; - $this->settings = $settings; - $this->early_order_handler = $early_order_handler; - $this->registration_needed = $registration_needed; - $this->card_billing_data_mode = $card_billing_data_mode; - $this->early_validation_enabled = $early_validation_enabled; - $this->pay_now_contexts = $pay_now_contexts; - $this->handle_shipping_in_paypal = $handle_shipping_in_paypal; - $this->funding_sources_without_redirect = $funding_sources_without_redirect; - $this->logger = $logger; + $this->request_data = $request_data; + $this->purchase_unit_factory = $purchase_unit_factory; + $this->shipping_preference_factory = $shipping_preference_factory; + $this->contact_preference_factory = $contact_preference_factory; + $this->return_url_factory = $return_url_factory; + $this->experience_context_builder = $experience_context_builder; + $this->api_endpoint = $order_endpoint; + $this->payer_factory = $payer_factory; + $this->session_handler = $session_handler; + $this->settings = $settings; + $this->early_order_handler = $early_order_handler; + $this->registration_needed = $registration_needed; + $this->card_billing_data_mode = $card_billing_data_mode; + $this->early_validation_enabled = $early_validation_enabled; + $this->pay_now_contexts = $pay_now_contexts; + $this->handle_shipping_in_paypal = $handle_shipping_in_paypal; + $this->server_side_shipping_callback_enabled = $server_side_shipping_callback_enabled; + $this->funding_sources_without_redirect = $funding_sources_without_redirect; + $this->logger = $logger; } /** @@ -259,7 +283,7 @@ class CreateOrderEndpoint implements EndpointInterface { $wc_order = null; if ( 'pay-now' === $data['context'] ) { $wc_order = wc_get_order( (int) $data['order_id'] ); - if ( ! is_a( $wc_order, \WC_Order::class ) ) { + if ( ! is_a( $wc_order, WC_Order::class ) ) { wp_send_json_error( array( 'name' => 'order-not-found', @@ -336,7 +360,7 @@ class CreateOrderEndpoint implements EndpointInterface { $this->early_order_handler->register_for_order( $order ); } - if ( 'pay-now' === $data['context'] && is_a( $wc_order, \WC_Order::class ) ) { + if ( 'pay-now' === $data['context'] && is_a( $wc_order, WC_Order::class ) ) { $wc_order->update_meta_data( PayPalGateway::ORDER_ID_META_KEY, $order->id() ); $wc_order->update_meta_data( PayPalGateway::INTENT_META_KEY, $order->intent() ); @@ -392,56 +416,12 @@ class CreateOrderEndpoint implements EndpointInterface { return false; } - /** - * Once the checkout has been validated we execute this method. - * - * @param array $data The data. - * @param \WP_Error $errors The errors, which occurred. - * - * @return array - * @throws Exception On Error. - */ - public function after_checkout_validation( array $data, \WP_Error $errors ): array { - if ( ! $errors->errors ) { - try { - $order = $this->create_paypal_order(); - } catch ( Exception $exception ) { - $this->logger->error( 'Order creation failed: ' . $exception->getMessage() ); - throw $exception; - } - - /** - * In case we are onboarded and everything is fine with the \WC_Order - * we want this order to be created. We will intercept it and leave it - * in the "Pending payment" status though, which than later will change - * during the "onApprove"-JS callback or the webhook listener. - */ - if ( ! $this->early_order_handler->should_create_early_order() ) { - wp_send_json_success( $this->make_response( $order ) ); - } - $this->early_order_handler->register_for_order( $order ); - return $data; - } - - $this->logger->error( 'Checkout validation failed: ' . $errors->get_error_message() ); - - wp_send_json_error( - array( - 'name' => '', - 'message' => $errors->get_error_message(), - 'code' => (int) $errors->get_error_code(), - 'details' => array(), - ) - ); - return $data; - } - /** * Creates the order in the PayPal, uses data from WC order if provided. * - * @param \WC_Order|null $wc_order WC order to get data from. - * @param string $payment_method WC payment method. - * @param array $data Request data. + * @param WC_Order|null $wc_order WC order to get data from. + * @param string $payment_method WC payment method. + * @param array $data Request data. * * @return Order Created PayPal order. * @@ -450,7 +430,7 @@ class CreateOrderEndpoint implements EndpointInterface { * * phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber */ - private function create_paypal_order( \WC_Order $wc_order = null, string $payment_method = '', array $data = array() ): Order { + private function create_paypal_order( ?WC_Order $wc_order = null, string $payment_method = '', array $data = array() ): Order { assert( $this->purchase_unit instanceof PurchaseUnit ); $funding_source = $this->parsed_request_data['funding_source'] ?? ''; @@ -485,12 +465,38 @@ class CreateOrderEndpoint implements EndpointInterface { } } + if ( 'venmo' === $funding_source ) { + $payment_source_key = 'venmo'; + } else { + $payment_source_key = 'paypal'; + } + + $contact_preference = $this->contact_preference_factory->from_state( + $payment_source_key + ); + + $experience_context = $this->experience_context_builder + ->with_default_paypal_config( $shipping_preference, $action ) + ->with_contact_preference( $contact_preference ); + + if ( $this->server_side_shipping_callback_enabled + && $shipping_preference === ExperienceContext::SHIPPING_PREFERENCE_GET_FROM_FILE ) { + $experience_context = $experience_context->with_shipping_callback(); + } + + $return_url = $this->return_url_factory->from_context( + $this->parsed_request_data['context'], + $this->parsed_request_data + ); + $payment_source = new PaymentSource( - 'paypal', + $payment_source_key, (object) array( - 'experience_context' => $this->experience_context_builder - ->with_default_paypal_config( $shipping_preference, $action ) - ->build()->to_array(), + 'experience_context' => $experience_context + ->with_custom_return_url( $return_url ) + ->with_custom_cancel_url( $return_url ) + ->build() + ->to_array(), ) ); @@ -535,12 +541,12 @@ class CreateOrderEndpoint implements EndpointInterface { /** * Returns the Payer entity based on the request data. * - * @param array $data The request data. - * @param \WC_Order|null $wc_order The order. + * @param array $data The request data. + * @param WC_Order|null $wc_order The order. * * @return Payer|null */ - private function payer( array $data, \WC_Order $wc_order = null ) { + private function payer( array $data, ?WC_Order $wc_order = null ) { if ( 'pay-now' === $data['context'] ) { $payer = $this->payer_factory->from_wc_order( $wc_order ); return $payer; diff --git a/modules/ppcp-button/src/Endpoint/GetOrderEndpoint.php b/modules/ppcp-button/src/Endpoint/GetOrderEndpoint.php new file mode 100644 index 000000000..55704ebee --- /dev/null +++ b/modules/ppcp-button/src/Endpoint/GetOrderEndpoint.php @@ -0,0 +1,84 @@ +request_data = $request_data; + $this->api_endpoint = $order_endpoint; + $this->logger = $logger; + } + + public static function nonce(): string { + return self::ENDPOINT; + } + public function handle_request(): bool { + try { + $data = $this->request_data->read_request( $this->nonce() ); + $order_id = $data['order_id'] ?? ''; + + if ( empty( $order_id ) ) { + wp_send_json_error( + array( + 'message' => __( 'Order ID is required', 'woocommerce-paypal-payments' ), + ) + ); + return false; + } + + $order = $this->api_endpoint->order( $order_id ); + + wp_send_json_success( $order->to_array() ); + return true; + } catch ( RuntimeException $error ) { + $this->logger->error( 'Get order failed: ' . $error->getMessage() ); + + wp_send_json_error( + array( + 'name' => is_a( $error, PayPalApiException::class ) ? $error->name() : '', + 'message' => $error->getMessage(), + 'code' => $error->getCode(), + 'details' => is_a( $error, PayPalApiException::class ) ? $error->details() : array(), + ) + ); + } catch ( Exception $exception ) { + $this->logger->error( 'Get order failed: ' . $exception->getMessage() ); + + wp_send_json_error( + array( + 'message' => $exception->getMessage(), + ) + ); + } + + return false; + } +} diff --git a/modules/ppcp-button/src/Endpoint/RequestData.php b/modules/ppcp-button/src/Endpoint/RequestData.php index a6716e77c..465700979 100644 --- a/modules/ppcp-button/src/Endpoint/RequestData.php +++ b/modules/ppcp-button/src/Endpoint/RequestData.php @@ -47,9 +47,7 @@ class RequestData { || ! wp_verify_nonce( $json['nonce'], $nonce ) ) { remove_filter( 'nonce_user_logged_out', array( $this, 'nonce_fix' ), 100 ); - throw new RuntimeException( - __( 'Could not validate nonce.', 'woocommerce-paypal-payments' ) - ); + throw new RuntimeException( 'Could not validate nonce.' ); } $this->dequeue_nonce_fix(); diff --git a/modules/ppcp-button/src/Helper/ContextTrait.php b/modules/ppcp-button/src/Helper/ContextTrait.php index 122908b36..cb96143b6 100644 --- a/modules/ppcp-button/src/Helper/ContextTrait.php +++ b/modules/ppcp-button/src/Helper/ContextTrait.php @@ -301,8 +301,9 @@ trait ContextTrait { * @return bool */ private function is_subscription_change_payment_method_page(): bool { - if ( isset( $_GET['change_payment_method'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification - return wcs_is_subscription( wc_clean( wp_unslash( $_GET['change_payment_method'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification + // phpcs:disable WordPress.Security.NonceVerification + if ( isset( $_GET['change_payment_method'] ) ) { + return wcs_is_subscription( wc_clean( wp_unslash( $_GET['change_payment_method'] ) ) ); } return false; @@ -325,12 +326,14 @@ trait ContextTrait { * @return bool */ protected function is_wc_settings_payments_tab(): bool { - if ( ! is_admin() || isset( $_GET['section'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + // phpcs:disable WordPress.Security.NonceVerification + if ( ! is_admin() || isset( $_GET['section'] ) ) { return false; } - $page = wc_clean( wp_unslash( $_GET['page'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification - $tab = wc_clean( wp_unslash( $_GET['tab'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification + $page = wc_clean( wp_unslash( $_GET['page'] ?? '' ) ); + $tab = wc_clean( wp_unslash( $_GET['tab'] ?? '' ) ); + // phpcs:enable WordPress.Security.NonceVerification return $page === 'wc-settings' && $tab === 'checkout'; } diff --git a/modules/ppcp-button/src/Helper/EarlyOrderHandler.php b/modules/ppcp-button/src/Helper/EarlyOrderHandler.php index 112ebd9d0..f1672bb66 100644 --- a/modules/ppcp-button/src/Helper/EarlyOrderHandler.php +++ b/modules/ppcp-button/src/Helper/EarlyOrderHandler.php @@ -78,7 +78,7 @@ class EarlyOrderHandler { * * @return int|null */ - public function determine_wc_order_id( int $value = null ) { + public function determine_wc_order_id( ?int $value = null ) { if ( ! isset( $_REQUEST['ppcp-resume-order'] ) ) { return $value; diff --git a/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php b/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php index f3e5b7def..39a0a5dcb 100644 --- a/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php +++ b/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php @@ -89,8 +89,9 @@ class WooCommerceOrderCreator { } try { - $payer = $order->payer(); - $shipping = $order->purchase_units()[0]->shipping(); + $payer = $order->payer(); + $purchase_units = $order->purchase_units(); + $shipping = ! empty( $purchase_units ) ? $purchase_units[0]->shipping() : null; $this->configure_payment_source( $wc_order ); $this->configure_customer( $wc_order ); @@ -133,6 +134,16 @@ class WooCommerceOrderCreator { $item->set_product_id( $product_id ); $item->set_quantity( $quantity ); + if ( isset( $cart_item['bundled_by'] ) ) { + $item->add_meta_data( '_bundled_by', $cart_item['bundled_by'], true ); + } + if ( isset( $cart_item['bundled_item_id'] ) ) { + $item->add_meta_data( '_bundled_item_id', $cart_item['bundled_item_id'], true ); + } + if ( isset( $cart_item['key'] ) ) { + $item->add_meta_data( '_bundle_cart_key', $cart_item['key'], true ); + } + if ( $variation_id ) { $item->set_variation_id( $variation_id ); $item->set_variation( $variation_attributes ); @@ -143,14 +154,13 @@ class WooCommerceOrderCreator { return; } - $subtotal = wc_get_price_excluding_tax( $product, array( 'qty' => $quantity ) ); - $subtotal = apply_filters( 'woocommerce_paypal_payments_shipping_callback_cart_line_item_total', $subtotal, $cart_item ); + $subtotal = apply_filters( 'woocommerce_paypal_payments_shipping_callback_cart_line_item_total', $cart_item['line_subtotal'], $cart_item ); $item->set_name( $product->get_name() ); $item->set_subtotal( $subtotal ); - $item->set_total( $subtotal ); + $item->set_total( $cart_item['line_total'] ); - $this->configure_taxes( $product, $item, $subtotal ); + $this->configure_taxes( $product, $item, $item->get_total() ); $product_id = $product->get_id(); diff --git a/modules/ppcp-card-fields/src/CardFieldsModule.php b/modules/ppcp-card-fields/src/CardFieldsModule.php index 617e2cd8b..2acf19dd7 100644 --- a/modules/ppcp-card-fields/src/CardFieldsModule.php +++ b/modules/ppcp-card-fields/src/CardFieldsModule.php @@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\CardFields; use DomainException; use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder; use WooCommerce\PayPalCommerce\CardFields\Service\CardCaptureValidator; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; @@ -150,6 +151,15 @@ class CardFieldsModule implements ServiceModule, ExtendingModule, ExecutableModu $settings = $c->get( 'wcgateway.settings' ); assert( $settings instanceof Settings ); + $experience_context_builder = $c->get( 'wcgateway.builder.experience-context' ); + assert( $experience_context_builder instanceof ExperienceContextBuilder ); + + $payment_source_data = array( + 'experience_context' => $experience_context_builder + ->with_endpoint_return_urls() + ->build()->to_array(), + ); + $three_d_secure_contingency = $settings->has( '3d_secure_contingency' ) ? apply_filters( 'woocommerce_paypal_payments_three_d_secure_contingency', $settings->get( '3d_secure_contingency' ) ) @@ -159,15 +169,15 @@ class CardFieldsModule implements ServiceModule, ExtendingModule, ExecutableModu $three_d_secure_contingency === 'SCA_ALWAYS' || $three_d_secure_contingency === 'SCA_WHEN_REQUIRED' ) { - $data['payment_source']['card'] = array( - 'attributes' => array( - 'verification' => array( - 'method' => $three_d_secure_contingency, - ), + $payment_source_data['attributes'] = array( + 'verification' => array( + 'method' => $three_d_secure_contingency, ), ); } + $data['payment_source'] = array( 'card' => $payment_source_data ); + return $data; }, 10, diff --git a/modules/ppcp-compat/src/AdminContextTrait.php b/modules/ppcp-compat/src/AdminContextTrait.php index ed5583774..caaafb69a 100644 --- a/modules/ppcp-compat/src/AdminContextTrait.php +++ b/modules/ppcp-compat/src/AdminContextTrait.php @@ -20,7 +20,8 @@ trait AdminContextTrait { * @return bool */ private function is_paypal_order_edit_page(): bool { - $post_id = wc_clean( wp_unslash( $_GET['id'] ?? $_GET['post'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + // phpcs:ignore WordPress.Security.NonceVerification + $post_id = wc_clean( wp_unslash( $_GET['id'] ?? $_GET['post'] ?? '' ) ); if ( ! $post_id ) { return false; } diff --git a/modules/ppcp-compat/src/CompatModule.php b/modules/ppcp-compat/src/CompatModule.php index 81b603feb..aec79f6a3 100644 --- a/modules/ppcp-compat/src/CompatModule.php +++ b/modules/ppcp-compat/src/CompatModule.php @@ -71,6 +71,7 @@ class CompatModule implements ServiceModule, ExtendingModule, ExecutableModule { $this->migrate_pay_later_settings( $c ); $this->migrate_smart_button_settings( $c ); + $this->migrate_three_d_secure_setting(); $this->fix_page_builders(); $this->exclude_cache_plugins_js_minification( $c ); @@ -274,6 +275,35 @@ class CompatModule implements ServiceModule, ExtendingModule, ExecutableModule { ); } + + /** + * Migrates the old Three D Secure setting located in PaymentSettings to the new location in SettingsModel. + * + * The migration will be done on plugin update if it hasn't already done. + */ + protected function migrate_three_d_secure_setting(): void { + add_action( + 'woocommerce_paypal_payments_gateway_migrate_on_update', + function () { + $payment_settings = get_option( 'woocommerce-ppcp-data-payment' ) ?: array(); + $data_settings = get_option( 'woocommerce-ppcp-data-settings' ) ?: array(); + + // Skip if payment settings don't have the setting but data settings do. + if ( ! isset( $payment_settings['three_d_secure'] ) || isset( $data_settings['three_d_secure'] ) ) { + return; + } + + // Move the setting. + $data_settings['three_d_secure'] = $payment_settings['three_d_secure']; + unset( $payment_settings['three_d_secure'] ); + + // Save both. + update_option( 'woocommerce-ppcp-data-settings', $data_settings ); + update_option( 'woocommerce-ppcp-data-payment', $payment_settings ); + } + ); + } + /** * Changes the button rendering place for page builders * that do not work well with our default places. diff --git a/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php b/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php index 217374007..640ab607f 100644 --- a/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php +++ b/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php @@ -150,6 +150,8 @@ class SubscriptionsHandler { return true; } + // phpcs:disable WordPress.Security.NonceVerification + // Checks that require Subscriptions. if ( class_exists( \WC_Subscriptions::class ) ) { // My Account > Subscriptions > (Subscription). @@ -160,15 +162,15 @@ class SubscriptionsHandler { } // Changing payment method? - if ( is_wc_endpoint_url( 'order-pay' ) && isset( $_GET['change_payment_method'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( is_wc_endpoint_url( 'order-pay' ) && isset( $_GET['change_payment_method'] ) ) { $subscription = wcs_get_subscription( absint( get_query_var( 'order-pay' ) ) ); return ( $subscription && PPECHelper::PPEC_GATEWAY_ID === $subscription->get_payment_method() ); } // Early renew (via modal). - if ( isset( $_GET['process_early_renewal'], $_GET['subscription_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $subscription = wcs_get_subscription( absint( $_GET['subscription_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['process_early_renewal'], $_GET['subscription_id'] ) ) { + $subscription = wcs_get_subscription( absint( $_GET['subscription_id'] ) ); return ( $subscription && PPECHelper::PPEC_GATEWAY_ID === $subscription->get_payment_method() ); } @@ -185,7 +187,6 @@ class SubscriptionsHandler { } // Are we editing an order or subscription tied to PPEC? - // phpcs:ignore WordPress.Security.NonceVerification $order_id = wc_clean( wp_unslash( $_GET['id'] ?? $_GET['post'] ?? $_POST['post_ID'] ?? '' ) ); if ( $order_id ) { $order = wc_get_order( $order_id ); @@ -199,9 +200,7 @@ class SubscriptionsHandler { * @psalm-suppress UndefinedClass */ $post_type_or_page = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() - // phpcs:ignore WordPress.Security.NonceVerification ? wc_clean( wp_unslash( $_GET['page'] ?? '' ) ) - // phpcs:ignore WordPress.Security.NonceVerification : wc_clean( wp_unslash( $_GET['post_type'] ?? $_POST['post_type'] ?? '' ) ); if ( $post_type_or_page === 'shop_subscription' || $post_type_or_page === 'wc-orders--shop_subscription' ) { return true; diff --git a/modules/ppcp-compat/src/Settings/PaymentMethodSettingsMapHelper.php b/modules/ppcp-compat/src/Settings/PaymentMethodSettingsMapHelper.php index d1efea41f..51f769ccb 100644 --- a/modules/ppcp-compat/src/Settings/PaymentMethodSettingsMapHelper.php +++ b/modules/ppcp-compat/src/Settings/PaymentMethodSettingsMapHelper.php @@ -11,7 +11,6 @@ namespace WooCommerce\PayPalCommerce\Compat\Settings; use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway; use WooCommerce\PayPalCommerce\Settings\Data\AbstractDataModel; -use WooCommerce\PayPalCommerce\Settings\Data\PaymentSettings; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; /** @@ -22,15 +21,6 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; */ class PaymentMethodSettingsMapHelper { - /** - * A map of new to old 3d secure values. - */ - protected const THREE_D_SECURE_VALUES_MAP = array( - 'no-3d-secure' => 'NO_3D_SECURE', - 'only-required-3d-secure' => 'SCA_WHEN_REQUIRED', - 'always-3d-secure' => 'SCA_ALWAYS', - ); - /** * Maps old setting keys to new payment method settings names. * @@ -38,9 +28,8 @@ class PaymentMethodSettingsMapHelper { */ public function map(): array { return array( - 'dcc_enabled' => CreditCardGateway::ID, - 'axo_enabled' => AxoGateway::ID, - '3d_secure_contingency' => 'three_d_secure', + 'dcc_enabled' => CreditCardGateway::ID, + 'axo_enabled' => AxoGateway::ID, ); } @@ -52,25 +41,13 @@ class PaymentMethodSettingsMapHelper { * @return mixed The value of the mapped setting, (null if not found). */ public function mapped_value( string $old_key, ?AbstractDataModel $payment_settings ) { - switch ( $old_key ) { - case '3d_secure_contingency': - if ( is_null( $payment_settings ) ) { - return null; - } + $payment_method = $this->map()[ $old_key ] ?? false; - assert( $payment_settings instanceof PaymentSettings ); - $selected_three_d_secure = $payment_settings->get_three_d_secure(); - return self::THREE_D_SECURE_VALUES_MAP[ $selected_three_d_secure ] ?? null; - - default: - $payment_method = $this->map()[ $old_key ] ?? false; - - if ( ! $payment_method ) { - return null; - } - - return $this->is_gateway_enabled( $payment_method ); + if ( ! $payment_method ) { + return null; } + + return $this->is_gateway_enabled( $payment_method ); } /** diff --git a/modules/ppcp-compat/src/Settings/SettingsTabMapHelper.php b/modules/ppcp-compat/src/Settings/SettingsTabMapHelper.php index 74829a712..a4504e76b 100644 --- a/modules/ppcp-compat/src/Settings/SettingsTabMapHelper.php +++ b/modules/ppcp-compat/src/Settings/SettingsTabMapHelper.php @@ -23,6 +23,17 @@ class SettingsTabMapHelper { use ContextTrait; + /** + * A map of new to old 3d secure values. + * + * @var array + */ + public const THREE_D_SECURE_VALUES_MAP = array( + 'no-3d-secure' => 'NO_3D_SECURE', + 'only-required-3d-secure' => 'SCA_WHEN_REQUIRED', + 'always-3d-secure' => 'SCA_ALWAYS', + ); + /** * Maps old setting keys to new setting keys. * @@ -43,6 +54,7 @@ class SettingsTabMapHelper { 'blocks_final_review_enabled' => 'enable_pay_now', 'logging_enabled' => 'enable_logging', 'vault_enabled' => 'save_paypal_and_venmo', + '3d_secure_contingency' => 'three_d_secure', ); } @@ -69,11 +81,30 @@ class SettingsTabMapHelper { case 'blocks_final_review_enabled': return $this->mapped_pay_now_value( $settings_model ); + case '3d_secure_contingency': + return $this->mapped_3d_secure_value( $settings_model ); + default: return $settings_model[ $new_key ] ?? null; } } + /** + * Retrieves the mapped value for the '3d_secure_contingency' from the new settings. + * + * @param array $settings_model The new settings model data. + * @return string|null The mapped '3d_secure_contingency' setting value. + */ + protected function mapped_3d_secure_value( array $settings_model ): ?string { + $three_d_secure = $settings_model['three_d_secure'] ?? null; + + if ( ! is_string( $three_d_secure ) ) { + return null; + } + + return self::THREE_D_SECURE_VALUES_MAP[ $three_d_secure ] ?? null; + } + /** * Retrieves the mapped value for the 'mismatch_behavior' from the new settings. * diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index 627038c83..4ec292a7a 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -189,7 +189,8 @@ class GooglepayButton extends PaymentButton { buttonConfig, ppcpConfig, contextHandler, - buttonAttributes + buttonAttributes, + onClick = null ) { // Disable debug output in the browser console: // buttonConfig.is_debug = false; @@ -200,12 +201,14 @@ class GooglepayButton extends PaymentButton { buttonConfig, ppcpConfig, contextHandler, - buttonAttributes + buttonAttributes, + onClick ); this.init = this.init.bind( this ); this.onPaymentDataChanged = this.onPaymentDataChanged.bind( this ); this.onButtonClick = this.onButtonClick.bind( this ); + this.onClick = onClick; this.log( 'Create instance' ); } @@ -552,6 +555,10 @@ class GooglepayButton extends PaymentButton { const initiatePaymentRequest = async () => { window.ppcpFundingSource = 'googlepay'; + + // Sets paymentMethodId from registerExpressPaymentMethod as active payment method. + this.onClick?.(); + const paymentDataRequest = this.paymentDataRequest(); this.log( diff --git a/modules/ppcp-googlepay/resources/js/GooglepayManager.js b/modules/ppcp-googlepay/resources/js/GooglepayManager.js index 8db6c762c..d5f478f03 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayManager.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayManager.js @@ -3,11 +3,19 @@ import GooglepayButton from './GooglepayButton'; import ContextHandlerFactory from './Context/ContextHandlerFactory'; class GooglepayManager { - constructor( namespace, buttonConfig, ppcpConfig, buttonAttributes = {} ) { + constructor( + namespace, + buttonConfig, + ppcpConfig, + buttonAttributes = {}, + onClick = null + ) { this.namespace = namespace; this.buttonConfig = buttonConfig; this.ppcpConfig = ppcpConfig; this.buttonAttributes = buttonAttributes; + this.onClick = onClick; + this.googlePayConfig = null; this.transactionInfo = null; this.contextHandler = null; @@ -28,7 +36,8 @@ class GooglepayManager { buttonConfig, ppcpConfig, this.contextHandler, - this.buttonAttributes + this.buttonAttributes, + this.onClick ); this.buttons.push( button ); diff --git a/modules/ppcp-googlepay/resources/js/boot-block.js b/modules/ppcp-googlepay/resources/js/boot-block.js index 22f681db7..735dd9df2 100644 --- a/modules/ppcp-googlepay/resources/js/boot-block.js +++ b/modules/ppcp-googlepay/resources/js/boot-block.js @@ -20,7 +20,7 @@ if ( typeof window.PayPalCommerceGateway === 'undefined' ) { window.PayPalCommerceGateway = ppcpConfig; } -const GooglePayComponent = ( { isEditing, buttonAttributes } ) => { +const GooglePayComponent = ( { isEditing, buttonAttributes, onClick } ) => { const [ paypalLoaded, setPaypalLoaded ] = useState( false ); const [ googlePayLoaded, setGooglePayLoaded ] = useState( false ); const [ manager, setManager ] = useState( null ); @@ -49,7 +49,8 @@ const GooglePayComponent = ( { isEditing, buttonAttributes } ) => { namespace, buttonConfig, ppcpConfig, - buttonAttributes + buttonAttributes, + onClick ); setManager( newManager ); } @@ -90,6 +91,7 @@ if ( buttonConfig?.is_enabled ) { 'woocommerce-paypal-payments' ), gatewayId: 'ppcp-gateway', + paymentMethodId: 'ppcp-gateway', label:
, content: , edit: , diff --git a/modules/ppcp-googlepay/src/Endpoint/UpdatePaymentDataEndpoint.php b/modules/ppcp-googlepay/src/Endpoint/UpdatePaymentDataEndpoint.php index e489bc771..ad74622de 100644 --- a/modules/ppcp-googlepay/src/Endpoint/UpdatePaymentDataEndpoint.php +++ b/modules/ppcp-googlepay/src/Endpoint/UpdatePaymentDataEndpoint.php @@ -145,7 +145,9 @@ class UpdatePaymentDataEndpoint { 'description' => html_entity_decode( wp_strip_all_tags( wc_price( (float) $rate->get_cost(), array( 'currency' => get_woocommerce_currency() ) ) - ) + ), + ENT_QUOTES, + 'UTF-8' ), 'cost' => $rate->get_cost(), ); diff --git a/modules/ppcp-googlepay/src/GooglepayModule.php b/modules/ppcp-googlepay/src/GooglepayModule.php index b2b6a93d4..166a42884 100644 --- a/modules/ppcp-googlepay/src/GooglepayModule.php +++ b/modules/ppcp-googlepay/src/GooglepayModule.php @@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Googlepay; use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry; use WC_Payment_Gateway; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder; use WooCommerce\PayPalCommerce\Button\Assets\ButtonInterface; use WooCommerce\PayPalCommerce\Button\Assets\SmartButtonInterface; use WooCommerce\PayPalCommerce\Googlepay\Endpoint\UpdatePaymentDataEndpoint; @@ -52,7 +53,7 @@ class GooglepayModule implements ServiceModule, ExtendingModule, ExecutableModul // Clears product status when appropriate. add_action( 'woocommerce_paypal_payments_clear_apm_product_status', - function( Settings $settings = null ) use ( $c ): void { + function( ?Settings $settings = null ) use ( $c ): void { $apm_status = $c->get( 'googlepay.helpers.apm-product-status' ); assert( $apm_status instanceof ApmProductStatus ); $apm_status->clear( $settings ); @@ -261,6 +262,15 @@ class GooglepayModule implements ServiceModule, ExtendingModule, ExecutableModul $settings = $c->get( 'wcgateway.settings' ); assert( $settings instanceof Settings ); + $experience_context_builder = $c->get( 'wcgateway.builder.experience-context' ); + assert( $experience_context_builder instanceof ExperienceContextBuilder ); + + $payment_source_data = array( + 'experience_context' => $experience_context_builder + ->with_endpoint_return_urls() + ->build()->to_array(), + ); + $three_d_secure_contingency = $settings->has( '3d_secure_contingency' ) ? apply_filters( 'woocommerce_paypal_payments_three_d_secure_contingency', $settings->get( '3d_secure_contingency' ) ) @@ -270,15 +280,15 @@ class GooglepayModule implements ServiceModule, ExtendingModule, ExecutableModul $three_d_secure_contingency === 'SCA_ALWAYS' || $three_d_secure_contingency === 'SCA_WHEN_REQUIRED' ) { - $data['payment_source']['google_pay'] = array( - 'attributes' => array( - 'verification' => array( - 'method' => $three_d_secure_contingency, - ), + $payment_source_data['attributes'] = array( + 'verification' => array( + 'method' => $three_d_secure_contingency, ), ); } + $data['payment_source'] = array( 'google_pay' => $payment_source_data ); + return $data; }, 10, diff --git a/modules/ppcp-googlepay/src/Helper/ApmProductStatus.php b/modules/ppcp-googlepay/src/Helper/ApmProductStatus.php index 20917eac7..a59298dd0 100644 --- a/modules/ppcp-googlepay/src/Helper/ApmProductStatus.php +++ b/modules/ppcp-googlepay/src/Helper/ApmProductStatus.php @@ -101,7 +101,7 @@ class ApmProductStatus extends ProductStatus { } /** {@inheritDoc} */ - protected function clear_state( Settings $settings = null ) : void { + protected function clear_state( ?Settings $settings = null ) : void { if ( null === $settings ) { $settings = $this->settings; } diff --git a/modules/ppcp-local-alternative-payment-methods/src/LocalApmProductStatus.php b/modules/ppcp-local-alternative-payment-methods/src/LocalApmProductStatus.php index af891220e..411750ebd 100644 --- a/modules/ppcp-local-alternative-payment-methods/src/LocalApmProductStatus.php +++ b/modules/ppcp-local-alternative-payment-methods/src/LocalApmProductStatus.php @@ -86,7 +86,7 @@ class LocalApmProductStatus extends ProductStatus { } /** {@inheritDoc} */ - protected function clear_state( Settings $settings = null ) : void { + protected function clear_state( ?Settings $settings = null ) : void { if ( null === $settings ) { $settings = $this->settings; } diff --git a/modules/ppcp-onboarding/services.php b/modules/ppcp-onboarding/services.php index 8a87e4288..71bab9570 100644 --- a/modules/ppcp-onboarding/services.php +++ b/modules/ppcp-onboarding/services.php @@ -21,6 +21,7 @@ use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\WcGateway\Helper\ConnectionState; use WooCommerce\PayPalCommerce\Settings\Data\GeneralSettings; +use WooCommerce\PayPalCommerce\WcGateway\Helper\MerchantDetails; return array( 'api.paypal-host' => function( ContainerInterface $container ) : string { @@ -102,7 +103,12 @@ return array( return $state->get_environment(); }, + 'settings.merchant-details' => static function ( ContainerInterface $container ) : MerchantDetails { + $woo_country = $container->get( 'api.shop.country' ); + $eligibility_checks = $container->get( 'wcgateway.feature-eligibility.list' ); + return new MerchantDetails( $woo_country, $woo_country, $eligibility_checks ); + }, 'onboarding.assets' => function( ContainerInterface $container ) : OnboardingAssets { $state = $container->get( 'onboarding.state' ); $login_seller_endpoint = $container->get( 'onboarding.endpoint.login-seller' ); diff --git a/modules/ppcp-onboarding/src/Render/OnboardingRenderer.php b/modules/ppcp-onboarding/src/Render/OnboardingRenderer.php index 13189fd77..bc5ab58ff 100644 --- a/modules/ppcp-onboarding/src/Render/OnboardingRenderer.php +++ b/modules/ppcp-onboarding/src/Render/OnboardingRenderer.php @@ -81,7 +81,7 @@ class OnboardingRenderer { PartnerReferrals $sandbox_partner_referrals, PartnerReferralsData $partner_referrals_data, Cache $cache, - LoggerInterface $logger = null + ?LoggerInterface $logger = null ) { $this->settings = $settings; $this->production_partner_referrals = $production_partner_referrals; diff --git a/modules/ppcp-order-tracking/src/TrackingAvailabilityTrait.php b/modules/ppcp-order-tracking/src/TrackingAvailabilityTrait.php index b149259a3..3accf383b 100644 --- a/modules/ppcp-order-tracking/src/TrackingAvailabilityTrait.php +++ b/modules/ppcp-order-tracking/src/TrackingAvailabilityTrait.php @@ -24,7 +24,8 @@ trait TrackingAvailabilityTrait { * @return bool */ protected function is_tracking_enabled( Bearer $bearer ): bool { - $post_id = (int) wc_clean( wp_unslash( $_GET['id'] ?? $_GET['post'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + // phpcs:ignore WordPress.Security.NonceVerification + $post_id = (int) wc_clean( wp_unslash( $_GET['id'] ?? $_GET['post'] ?? '' ) ); if ( ! $post_id ) { return false; } diff --git a/modules/ppcp-paypal-subscriptions/resources/js/paypal-subscription.js b/modules/ppcp-paypal-subscriptions/resources/js/paypal-subscription.js index 876cbf796..80c45f4e7 100644 --- a/modules/ppcp-paypal-subscriptions/resources/js/paypal-subscription.js +++ b/modules/ppcp-paypal-subscriptions/resources/js/paypal-subscription.js @@ -180,7 +180,18 @@ document.addEventListener( 'DOMContentLoaded', () => { } } ); - jQuery( '.wc_input_subscription_price' ).trigger( 'change' ); + const $productType = jQuery( '#product-type' ); + const $subscriptionInput = jQuery( '.wc_input_subscription_price' ); + + if ( + $productType.length && + $subscriptionInput.length && + [ 'subscription', 'variable-subscription' ].includes( + $productType.val() + ) + ) { + $subscriptionInput.trigger( 'change' ); + } const variationProductIds = [ PayPalCommerceGatewayPayPalSubscriptionProducts.product_id, diff --git a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php index 00014efd4..4dea48817 100644 --- a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php +++ b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php @@ -167,6 +167,7 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu return; } + // phpcs:ignore WordPress.Security.NonceVerification $nonce = wc_clean( wp_unslash( $_POST['_wcsnonce'] ?? '' ) ); if ( $subscriptions_mode !== 'subscriptions_api' @@ -250,6 +251,7 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu * @psalm-suppress MissingClosureParamType */ function( $variation_id ) use ( $c ) { + // phpcs:ignore WordPress.Security.NonceVerification $wcsnonce_save_variations = wc_clean( wp_unslash( $_POST['_wcsnonce_save_variations'] ?? '' ) ); if ( @@ -501,9 +503,10 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu if ( ! is_string( $hook ) || wcs_is_manual_renewal_enabled() ) { return; } + $settings = $c->get( 'wcgateway.settings' ); $subscription_mode = $settings->has( 'subscriptions_mode' ) ? $settings->get( 'subscriptions_mode' ) : ''; - if ( $hook !== 'post.php' && $hook !== 'post-new.php' && $subscription_mode !== 'subscriptions_api' ) { + if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) || $subscription_mode !== 'subscriptions_api' ) { return; } diff --git a/modules/ppcp-save-payment-methods/services.php b/modules/ppcp-save-payment-methods/services.php index e4e39d54c..b448142cc 100644 --- a/modules/ppcp-save-payment-methods/services.php +++ b/modules/ppcp-save-payment-methods/services.php @@ -78,6 +78,11 @@ return array( 'SE', 'GB', 'US', + 'YT', + 'RE', + 'GP', + 'GF', + 'MQ', ) ); }, diff --git a/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php b/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php index c5a8776d0..d47c5e3e8 100644 --- a/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php +++ b/modules/ppcp-save-payment-methods/src/SavePaymentMethodsModule.php @@ -12,21 +12,21 @@ namespace WooCommerce\PayPalCommerce\SavePaymentMethods; use Psr\Log\LoggerInterface; use WC_Order; use WooCommerce\PayPalCommerce\ApiClient\Authentication\UserIdToken; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentSource; use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; +use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus; use WooCommerce\PayPalCommerce\Button\Helper\ContextTrait; use WooCommerce\PayPalCommerce\SavePaymentMethods\Endpoint\CreatePaymentToken; use WooCommerce\PayPalCommerce\SavePaymentMethods\Endpoint\CreatePaymentTokenForGuest; use WooCommerce\PayPalCommerce\SavePaymentMethods\Endpoint\CreateSetupToken; +use WooCommerce\PayPalCommerce\Vaulting\WooCommercePaymentTokens; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; -use WooCommerce\PayPalCommerce\Vaulting\WooCommercePaymentTokens; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; @@ -69,10 +69,10 @@ class SavePaymentMethodsModule implements ServiceModule, ExtendingModule, Execut $settings = $c->get( 'wcgateway.settings' ); assert( $settings instanceof Settings ); - $billing_agreements_endpoint = $c->get( 'api.endpoint.billing-agreements' ); - assert( $billing_agreements_endpoint instanceof BillingAgreementsEndpoint ); - $reference_transaction_enabled = $billing_agreements_endpoint->reference_transaction_enabled(); - if ( $reference_transaction_enabled !== true ) { + $reference_transaction_status = $c->get( 'api.reference-transaction-status' ); + assert( $reference_transaction_status instanceof ReferenceTransactionStatus ); + + if ( ! $reference_transaction_status->reference_transaction_enabled() ) { $settings->set( 'vault_enabled', false ); $settings->persist(); } @@ -115,87 +115,67 @@ class SavePaymentMethodsModule implements ServiceModule, ExtendingModule, Execut function ( array $data, string $payment_method, array $request_data ) use ( $c ): array { $settings = $c->get( 'wcgateway.settings' ); assert( $settings instanceof Settings ); + + $new_attributes = array( + 'vault' => array( + 'store_in_vault' => 'ON_SUCCESS', + ), + ); + + $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); + if ( ! $target_customer_id ) { + $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); + } + if ( $target_customer_id ) { + $new_attributes['customer'] = array( + 'id' => $target_customer_id, + ); + } + + $funding_source = (string) ( $request_data['funding_source'] ?? '' ); + if ( $payment_method === CreditCardGateway::ID ) { if ( ! $settings->has( 'vault_enabled_dcc' ) || ! $settings->get( 'vault_enabled_dcc' ) ) { return $data; } $save_payment_method = $request_data['save_payment_method'] ?? false; - if ( $save_payment_method ) { - $data['payment_source'] = array( - 'card' => array( - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - ), - ), - ), - ); - - $target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true ); - if ( ! $target_customer_id ) { - $target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true ); - } - - if ( $target_customer_id ) { - $data['payment_source']['card']['attributes']['customer'] = array( - 'id' => $target_customer_id, - ); - } + if ( ! $save_payment_method ) { + return $data; } - } - - if ( $payment_method === PayPalGateway::ID ) { + } elseif ( $payment_method === PayPalGateway::ID ) { if ( ! $settings->has( 'vault_enabled' ) || ! $settings->get( 'vault_enabled' ) ) { return $data; } - $funding_source = $request_data['funding_source'] ?? null; - - if ( $funding_source && $funding_source === 'venmo' ) { - $data['payment_source'] = array( - 'venmo' => array( - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - 'usage_type' => 'MERCHANT', - 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), - ), - ), - ), - ); - } elseif ( $funding_source && $funding_source === 'apple_pay' ) { - $data['payment_source'] = array( - 'apple_pay' => array( - 'stored_credential' => array( - 'payment_initiator' => 'CUSTOMER', - 'payment_type' => 'RECURRING', - ), - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - ), - ), - ), - ); - } else { - $data['payment_source'] = array( - 'paypal' => array( - 'attributes' => array( - 'vault' => array( - 'store_in_vault' => 'ON_SUCCESS', - 'usage_type' => 'MERCHANT', - 'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ), - ), - ), - ), - ); + if ( ! in_array( $funding_source, array( 'paypal', 'venmo' ), true ) ) { + return $data; } + + $new_attributes['vault']['usage_type'] = 'MERCHANT'; + $new_attributes['vault']['permit_multiple_payment_tokens'] = apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ); + } else { + return $data; } + $payment_source = (array) ( $data['payment_source'] ?? array() ); + $key = array_key_first( $payment_source ); + if ( ! is_string( $key ) || empty( $key ) ) { + $key = $payment_method; + if ( $payment_method === PayPalGateway::ID && $funding_source ) { + $key = $funding_source; + } + $payment_source[ $key ] = array(); + } + $payment_source[ $key ] = (array) $payment_source[ $key ]; + $attributes = (array) ( $payment_source[ $key ]['attributes'] ?? array() ); + $payment_source[ $key ]['attributes'] = array_merge( $attributes, $new_attributes ); + + $data['payment_source'] = $payment_source; + return $data; }, - 10, + 20, 3 ); @@ -305,7 +285,8 @@ class SavePaymentMethodsModule implements ServiceModule, ExtendingModule, Execut ? apply_filters( 'woocommerce_paypal_payments_three_d_secure_contingency', $settings->get( '3d_secure_contingency' ) ) : ''; - $change_payment_method = wc_clean( wp_unslash( $_GET['change_payment_method'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification + // phpcs:ignore WordPress.Security.NonceVerification + $change_payment_method = wc_clean( wp_unslash( $_GET['change_payment_method'] ?? '' ) ); wp_localize_script( 'ppcp-add-payment-method', diff --git a/modules/ppcp-settings/resources/css/components/reusable-components/_elements.scss b/modules/ppcp-settings/resources/css/components/reusable-components/_elements.scss index 7b1cad358..931d7af9b 100644 --- a/modules/ppcp-settings/resources/css/components/reusable-components/_elements.scss +++ b/modules/ppcp-settings/resources/css/components/reusable-components/_elements.scss @@ -69,3 +69,23 @@ margin-top: var(--block-action-gap, 16px); } } + +.ppcp--notice { + display: block; + padding: 10px; + margin: 10px 0; + line-height: 1.5714285714; + font-size: 0.8125rem; + background: var(--notice-background); + color: var(--notice-text); + + &.type--info { + --notice-background: var(--color-success-background); + --notice-text: var(--color-success-text); + } + + &.type--error { + --notice-background: var(--color-failure-background); + --notice-text: var(--color-failure-text); + } +} diff --git a/modules/ppcp-settings/resources/css/components/screens/_onboarding.scss b/modules/ppcp-settings/resources/css/components/screens/_onboarding.scss index a6d085e3b..33f3c9862 100644 --- a/modules/ppcp-settings/resources/css/components/screens/_onboarding.scss +++ b/modules/ppcp-settings/resources/css/components/screens/_onboarding.scss @@ -12,6 +12,10 @@ .ppcp-r-inner-container { max-width: var(--max-width-onboarding-content); + + &.ppcp--wide { + --max-width-onboarding-content: none; + } } .ppcp-r-payment-method--separator { diff --git a/modules/ppcp-settings/resources/js/Components/App.js b/modules/ppcp-settings/resources/js/Components/App.js index 74e20bd87..e32f705ce 100644 --- a/modules/ppcp-settings/resources/js/Components/App.js +++ b/modules/ppcp-settings/resources/js/Components/App.js @@ -8,6 +8,8 @@ import OnboardingScreen from './Screens/Onboarding'; import SettingsScreen from './Screens/Settings'; import { getQuery, cleanUrlQueryParams } from '../utils/navigation'; +import { initializeTracking } from '../services/tracking'; + const SettingsApp = () => { const { isReady: onboardingIsReady, completed: onboardingCompleted } = OnboardingHooks.useSteps(); @@ -16,6 +18,10 @@ const SettingsApp = () => { merchant: { isSendOnlyCountry }, } = CommonHooks.useMerchantInfo(); + useEffect( () => { + initializeTracking(); + }, [] ); + // Disable the "Changes you made might not be saved" browser warning. useEffect( () => { const suppressBeforeUnload = ( event ) => { diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Elements/Notice.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Elements/Notice.js new file mode 100644 index 000000000..8a606340f --- /dev/null +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Elements/Notice.js @@ -0,0 +1,17 @@ +import classNames from 'classnames'; + +const Notice = ( { children, type = 'info', className = '' } ) => { + if ( ! children ) { + return null; + } + + const elementClasses = classNames( + 'ppcp--notice', + `type--${ type }`, + className + ); + + return { children }; +}; + +export default Notice; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Elements/index.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Elements/index.js index c56a70b8a..71809cfd0 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/Elements/index.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/Elements/index.js @@ -9,6 +9,7 @@ export { default as ContentWrapper } from './ContentWrapper'; export { default as Description } from './Description'; export { default as Header } from './Header'; export { default as LearnMore } from './LearnMore'; +export { default as Notice } from './Notice'; export { default as Separator } from './Separator'; export { default as Title } from './Title'; export { default as TitleExtra } from './TitleExtra'; diff --git a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlock.js b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlock.js index 28cd50590..9407fb61b 100644 --- a/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlock.js +++ b/modules/ppcp-settings/resources/js/Components/ReusableComponents/SettingsBlock.js @@ -10,7 +10,12 @@ const SettingsBlock = ( { description, horizontalLayout = false, separatorAndGap = true, + visible = true, } ) => { + if ( ! visible ) { + return null; + } + const blockClassName = classNames( 'ppcp-r-settings-block', className, { 'ppcp--no-gap': ! separatorAndGap, 'ppcp--horizontal': horizontalLayout, diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js index 55932143c..63b47dc46 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ConnectionButton.js @@ -1,21 +1,32 @@ import { Button } from '@wordpress/components'; -import { useEffect } from '@wordpress/element'; +import { useEffect, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; import classNames from 'classnames'; import { OpenSignup } from '../../../ReusableComponents/Icons'; import { useHandleOnboardingButton } from '../../../../hooks/useHandleConnections'; +import { OnboardingHooks } from '../../../../data/onboarding/hooks'; import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper'; +import { Notice } from '../../../ReusableComponents/Elements'; + +const useIsFirefox = () => { + if ( typeof window === 'undefined' ) { + return false; + } + return window.navigator.userAgent.toLowerCase().indexOf( 'firefox' ) > -1; +}; /** * Button component that outputs a placeholder button when no onboardingUrl is present yet - the * placeholder button looks identical to the working button, but has no href, target, or * custom connection attributes. * - * @param {Object} props - * @param {string} props.className - * @param {string} props.variant - * @param {boolean} props.showIcon - * @param {?string} props.href - * @param {Element} props.children + * @param {Object} props + * @param {string} props.className + * @param {string} props.variant + * @param {boolean} props.showIcon + * @param {?string} props.href + * @param {Element} props.children + * @param {Function} props.onClick */ const ButtonOrPlaceholder = ( { className, @@ -23,11 +34,15 @@ const ButtonOrPlaceholder = ( { showIcon, href, children, + onClick, } ) => { + const isFirefox = useIsFirefox(); + const buttonProps = { className, variant, icon: showIcon ? OpenSignup : null, + onClick, }; if ( href ) { @@ -36,6 +51,20 @@ const ButtonOrPlaceholder = ( { buttonProps[ 'data-paypal-onboard-button' ] = 'true'; } + if ( isFirefox ) { + return ( + <> + + + { __( + 'This button may not work in Firefox. Please use another browser, like Chrome, to complete this step.', + 'woocommerce-paypal-payments' + ) } + + + ); + } + return ; }; @@ -52,12 +81,28 @@ const ConnectionButton = ( { setCompleteHandler, removeCompleteHandler, } = useHandleOnboardingButton( isSandbox ); + + const { connectionButtonClicked, setConnectionButtonClicked } = + OnboardingHooks.useConnectionButton(); + const buttonClassName = classNames( 'ppcp-r-connection-button', className, { 'ppcp--mode-sandbox': isSandbox, 'ppcp--mode-live': ! isSandbox, + 'ppcp--button-clicked': connectionButtonClicked, } ); const environment = isSandbox ? 'sandbox' : 'production'; + const handleButtonClick = useCallback( () => { + setConnectionButtonClicked( true ); + }, [ setConnectionButtonClicked ] ); + + // Reset button clicked state when onboardingUrl becomes available. + useEffect( () => { + if ( onboardingUrl && connectionButtonClicked ) { + setConnectionButtonClicked( false ); + } + }, [ onboardingUrl, connectionButtonClicked, setConnectionButtonClicked ] ); + useEffect( () => { if ( scriptLoaded && onboardingUrl ) { window.PAYPAL.apps.Signup.render(); @@ -82,6 +127,7 @@ const ConnectionButton = ( { variant={ variant } showIcon={ showIcon } href={ onboardingUrl } + onClick={ handleButtonClick } > { title } diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js index 2c65e21e8..2c9d54ed5 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Components/ManualConnectionForm.js @@ -17,6 +17,7 @@ import { useSandboxConnection, } from '../../../../hooks/useHandleConnections'; import { OnboardingHooks } from '../../../../data'; +import { useManualConnection } from '../../../../data/common/hooks'; const FORM_ERRORS = { noClientId: __( @@ -43,14 +44,19 @@ const ManualConnectionForm = () => { manualClientSecret, setManualClientSecret, } = OnboardingHooks.useManualConnectionForm(); - const { - handleDirectAuthentication, - isManualConnectionMode, - setManualConnectionMode, - } = useDirectAuthentication(); + + const { handleDirectAuthentication } = useDirectAuthentication(); + + const { isManualConnectionMode, setManualConnectionMode } = + useManualConnection(); + const refClientId = useRef( null ); const refClientSecret = useRef( null ); + const handleToggle = ( isEnabled ) => { + setManualConnectionMode( isEnabled, 'user' ); + }; + // Form data validation and sanitation. const getManualConnectionDetails = useCallback( () => { const checks = [ @@ -148,7 +154,7 @@ const ManualConnectionForm = () => { ) } description={ advancedUsersDescription } isToggled={ !! isManualConnectionMode } - setToggled={ setManualConnectionMode } + setToggled={ handleToggle } > { const { isSandboxMode, setSandboxMode } = useSandboxConnection(); + const handleToggle = ( isEnabled ) => { + setSandboxMode( isEnabled, 'user' ); + }; + return ( { 'woocommerce-paypal-payments' ) } isToggled={ !! isSandboxMode } - setToggled={ setSandboxMode } + setToggled={ handleToggle } > { return; } - setIsCasualSeller( BUSINESS_TYPES.CASUAL_SELLER === businessChoice ); + setIsCasualSeller( + BUSINESS_TYPES.CASUAL_SELLER === businessChoice, + 'user' + ); }, [ businessChoice, setIsCasualSeller ] ); const { canUseSubscriptions } = OnboardingHooks.useFlags(); diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepCompleteSetup.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepCompleteSetup.js index 8c2fed3f0..544847e40 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepCompleteSetup.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepCompleteSetup.js @@ -16,7 +16,7 @@ const StepCompleteSetup = () => { 'woocommerce-paypal-payments' ) } /> -
+
{ }, ]; + const handleMethodChange = ( value ) => { + setOptionalMethods( value, 'user' ); + }; + return (
{ diff --git a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepProducts.js b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepProducts.js index 7512e5304..663cae4d8 100644 --- a/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepProducts.js +++ b/modules/ppcp-settings/resources/js/Components/Screens/Onboarding/Steps/StepProducts.js @@ -14,6 +14,47 @@ const StepProducts = () => { const { isCasualSeller } = OnboardingHooks.useBusiness(); useEffect( () => { + const productChoicesFull = [ + { + value: PRODUCT_TYPES.VIRTUAL, + title: __( 'Virtual', 'woocommerce-paypal-payments' ), + description: __( + 'Items do not require shipping.', + 'woocommerce-paypal-payments' + ), + contents: , + }, + { + value: PRODUCT_TYPES.PHYSICAL, + title: __( 'Physical Goods', 'woocommerce-paypal-payments' ), + description: __( + 'Items require shipping.', + 'woocommerce-paypal-payments' + ), + contents: , + }, + { + value: PRODUCT_TYPES.SUBSCRIPTIONS, + title: __( 'Subscriptions', 'woocommerce-paypal-payments' ), + description: __( + 'Recurring payments for either physical goods or services.', + 'woocommerce-paypal-payments' + ), + isDisabled: isCasualSeller, + contents: ( + /* + * Note: The link should be only displayed if the subscriptions plugin is not installed. + * But when the plugin is not active, this option is completely hidden; + * This means: In the current configuration, we never show the link. + */ + + ), + }, + ]; + const initChoices = () => { const choices = productChoicesFull.map( ( choice ) => { if ( @@ -39,13 +80,7 @@ const StepProducts = () => { }; initChoices(); - }, [ - canUseSubscriptions, - optionState, - isCasualSeller, - products, - setProducts, - ] ); + }, [ canUseSubscriptions, isCasualSeller ] ); const handleChange = ( key, checked ) => { const getNewValue = () => { @@ -55,48 +90,9 @@ const StepProducts = () => { return products.filter( ( val ) => val !== key ); }; - setProducts( getNewValue() ); + setProducts( getNewValue(), 'user' ); }; - const productChoicesFull = [ - { - value: PRODUCT_TYPES.VIRTUAL, - title: __( 'Virtual', 'woocommerce-paypal-payments' ), - description: __( - 'Items do not require shipping.', - 'woocommerce-paypal-payments' - ), - contents: , - }, - { - value: PRODUCT_TYPES.PHYSICAL, - title: __( 'Physical Goods', 'woocommerce-paypal-payments' ), - description: __( - 'Items require shipping.', - 'woocommerce-paypal-payments' - ), - contents: , - }, - { - value: PRODUCT_TYPES.SUBSCRIPTIONS, - title: __( 'Subscriptions', 'woocommerce-paypal-payments' ), - description: __( - 'Recurring payments for either physical goods or services.', - 'woocommerce-paypal-payments' - ), - isDisabled: isCasualSeller, - contents: ( - /* - * Note: The link should be only displayed if the subscriptions plugin is not installed. - * But when the plugin is not active, this option is completely hidden; - * This means: In the current configuration, we never show the link. - */ - - ), - }, - ]; + return (
{ 'woocommerce-paypal-payments' ); + const handleActivatePayPal = () => { + const nextStep = currentStep + 1; + setStep( nextStep, 'user' ); + }; + return (
{ %s', + esc_html__( 'Switch to new settings UI', 'woocommerce-paypal-payments' ), + esc_html__( 'This action will permanently switch to the new settings interface and cannot be undone', 'woocommerce-paypal-payments' ) ) ); + /** + * Adds new settings discovery notice. + * + * @param Message[] $notices + * @return Message[] + */ + add_filter( + Repository::NOTICES_FILTER, + static function ( array $notices ) use ( $container ): array { + if ( ! $container->get( 'wcgateway.is-ppcp-settings-page' ) ) { + return $notices; + } + + $message = sprintf( + // translators: %1$s is the URL for the startup guide. + __( + '🎉 Discover the new PayPal Payments settings! Enjoy a cleaner, faster interface. Check out the Startup Guide, then click Switch to New Settings to activate it.', + 'woocommerce-paypal-payments' + ), + 'https://woocommerce.com/document/woocommerce-paypal-payments/paypal-payments-startup-guide/' + ); + + $notices[] = new Message( $message, 'info', false, 'ppcp-notice-wrapper' ); + return $notices; + } + ); + add_action( 'admin_enqueue_scripts', static function () use ( $container ) { @@ -122,8 +163,12 @@ class SettingsModule implements ServiceModule, ExecutableModule { 'ppcp-switch-settings-ui', 'ppcpSwitchSettingsUi', array( - 'endpoint' => \WC_AJAX::get_endpoint( SwitchSettingsUiEndpoint::ENDPOINT ), - 'nonce' => wp_create_nonce( SwitchSettingsUiEndpoint::nonce() ), + 'endpoint' => \WC_AJAX::get_endpoint( SwitchSettingsUiEndpoint::ENDPOINT ), + 'nonce' => wp_create_nonce( SwitchSettingsUiEndpoint::nonce() ), + 'confirmMessage' => __( + 'Are you sure you want to switch to the new settings interface?This action cannot be undone.', + 'woocommerce-paypal-payments' + ), ) ); @@ -135,15 +180,14 @@ class SettingsModule implements ServiceModule, ExecutableModule { } ); - $endpoint = $container->get( 'settings.ajax.switch_ui' ) ? $container->get( 'settings.ajax.switch_ui' ) : null; - assert( $endpoint instanceof SwitchSettingsUiEndpoint ); - add_action( 'wc_ajax_' . SwitchSettingsUiEndpoint::ENDPOINT, - array( - $endpoint, - 'handle_request', - ) + static function () use ( $container ): void { + $endpoint = $container->get( 'settings.ajax.switch_ui' ) ? $container->get( 'settings.ajax.switch_ui' ) : null; + assert( $endpoint instanceof SwitchSettingsUiEndpoint ); + + $endpoint->handle_request(); + } ); return true; @@ -576,7 +620,7 @@ class SettingsModule implements ServiceModule, ExecutableModule { // Enable APMs after onboarding if the country is compatible. add_action( 'woocommerce_paypal_payments_toggle_payment_gateways_apms', - function ( PaymentSettings $payment_methods, array $methods_apm ) use ( $container ) { + function ( PaymentSettings $payment_methods, array $methods_apm, ConfigurationFlagsDTO $flags ) use ( $container ) { $general_settings = $container->get( 'settings.data.general' ); assert( $general_settings instanceof GeneralSettings ); @@ -586,6 +630,11 @@ class SettingsModule implements ServiceModule, ExecutableModule { // Enable all APM methods. foreach ( $methods_apm as $method ) { + if ( $flags->use_card_payments === false ) { + $payment_methods->toggle_method_state( $method['id'], $flags->use_card_payments ); + continue; + } + // Skip PayUponInvoice if merchant is not in Germany. if ( PayUponInvoiceGateway::ID === $method['id'] && 'DE' !== $merchant_country ) { continue; @@ -606,7 +655,7 @@ class SettingsModule implements ServiceModule, ExecutableModule { } }, 10, - 2 + 3 ); // Toggle payment gateways after onboarding based on flags. @@ -635,6 +684,25 @@ class SettingsModule implements ServiceModule, ExecutableModule { } ); + // Migration code to update BN code of merchants that are on whitelabel mode (own_brand_only false) to use the whitelabel BN code (direct). + add_action( + 'woocommerce_paypal_payments_gateway_migrate_on_update', + static function() use ( $container ) { + $general_settings = $container->get( 'settings.data.general' ); + assert( $general_settings instanceof GeneralSettings ); + + $partner_attribution = $container->get( 'api.helper.partner-attribution' ); + assert( $partner_attribution instanceof PartnerAttribution ); + + $own_brand_only = $general_settings->own_brand_only(); + $installation_path = $general_settings->get_installation_path(); + + if ( ! $own_brand_only && $installation_path !== InstallationPathEnum::DIRECT ) { + $partner_attribution->initialize_bn_code( InstallationPathEnum::DIRECT, true ); + } + } + ); + return true; } diff --git a/modules/ppcp-status-report/src/StatusReportModule.php b/modules/ppcp-status-report/src/StatusReportModule.php index 10159f8c7..7150b703b 100644 --- a/modules/ppcp-status-report/src/StatusReportModule.php +++ b/modules/ppcp-status-report/src/StatusReportModule.php @@ -9,18 +9,18 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\StatusReport; +use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; +use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; +use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus; +use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; +use WooCommerce\PayPalCommerce\Button\Helper\MessagesApply; +use WooCommerce\PayPalCommerce\Compat\PPEC\PPECHelper; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; -use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; -use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint; -use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; -use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; -use WooCommerce\PayPalCommerce\Button\Helper\MessagesApply; -use WooCommerce\PayPalCommerce\Compat\PPEC\PPECHelper; +use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; use WooCommerce\PayPalCommerce\Webhooks\WebhookEventStorage; /** @@ -75,8 +75,8 @@ class StatusReportModule implements ServiceModule, ExtendingModule, ExecutableMo $last_webhook_storage = $c->get( 'webhook.last-webhook-storage' ); assert( $last_webhook_storage instanceof WebhookEventStorage ); - $billing_agreements_endpoint = $c->get( 'api.endpoint.billing-agreements' ); - assert( $billing_agreements_endpoint instanceof BillingAgreementsEndpoint ); + $reference_transaction_status = $c->get( 'api.reference-transaction-status' ); + assert( $reference_transaction_status instanceof ReferenceTransactionStatus ); /* @var Renderer $renderer The renderer. */ $renderer = $c->get( 'status-report.renderer' ); @@ -85,6 +85,8 @@ class StatusReportModule implements ServiceModule, ExtendingModule, ExecutableMo $subscription_mode_options = $c->get( 'wcgateway.settings.fields.subscriptions_mode_options' ); + // Feature flag convention. + // phpcs:disable WordPress.NamingConventions.ValidHookName.UseUnderscores $items = array( array( 'label' => esc_html__( 'Onboarded', 'woocommerce-paypal-payments' ), @@ -170,7 +172,7 @@ class StatusReportModule implements ServiceModule, ExtendingModule, ExecutableMo 'exported_label' => 'Reference Transactions', 'description' => esc_html__( 'Whether Reference Transactions are enabled for the connected account', 'woocommerce-paypal-payments' ), 'value' => $this->bool_to_html( - $this->reference_transaction_enabled( $billing_agreements_endpoint ) + $reference_transaction_status->reference_transaction_enabled() ), ), array( @@ -275,19 +277,6 @@ class StatusReportModule implements ServiceModule, ExtendingModule, ExecutableMo return $field_settings['options'][ $subscriptions_mode ] ?? $subscriptions_mode; } - /** - * Checks if reference transactions are enabled in account. - * - * @param BillingAgreementsEndpoint $billing_agreements_endpoint The endpoint. - */ - private function reference_transaction_enabled( BillingAgreementsEndpoint $billing_agreements_endpoint ): bool { - try { - return $billing_agreements_endpoint->reference_transaction_enabled(); - } catch ( RuntimeException $exception ) { - return false; - } - } - /** * Converts the bool value to "yes" icon or dash. * diff --git a/modules/ppcp-vaulting/src/PaymentTokenHelper.php b/modules/ppcp-vaulting/src/PaymentTokenHelper.php index 368e146b1..bf294d167 100644 --- a/modules/ppcp-vaulting/src/PaymentTokenHelper.php +++ b/modules/ppcp-vaulting/src/PaymentTokenHelper.php @@ -24,7 +24,7 @@ class PaymentTokenHelper { * @param ?string $class_name Class name of the token. * @return bool */ - public function token_exist( array $wc_tokens, string $token_id, string $class_name = null ): bool { + public function token_exist( array $wc_tokens, string $token_id, ?string $class_name = null ): bool { foreach ( $wc_tokens as $wc_token ) { if ( $wc_token->get_token() === $token_id ) { if ( null !== $class_name ) { diff --git a/modules/ppcp-vaulting/src/VaultingModule.php b/modules/ppcp-vaulting/src/VaultingModule.php index 427ff79c1..b820fb516 100644 --- a/modules/ppcp-vaulting/src/VaultingModule.php +++ b/modules/ppcp-vaulting/src/VaultingModule.php @@ -207,6 +207,7 @@ class VaultingModule implements ServiceModule, ExtendingModule, ExecutableModule return; } + // phpcs:ignore WordPress.Security.NonceVerification $wpnonce = wc_clean( wp_unslash( $_REQUEST['_wpnonce'] ?? '' ) ); $token_id_string = (string) $token_id; $action = 'delete-payment-method-' . $token_id_string; diff --git a/modules/ppcp-wc-gateway/services.php b/modules/ppcp-wc-gateway/services.php index d7e3d471a..ebc29f60f 100644 --- a/modules/ppcp-wc-gateway/services.php +++ b/modules/ppcp-wc-gateway/services.php @@ -11,44 +11,37 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\WcGateway; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PayUponInvoiceOrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\ExperienceContext; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; +use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; +use WooCommerce\PayPalCommerce\Applepay\ApplePayGateway; use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway; +use WooCommerce\PayPalCommerce\Axo\Helper\PropertiesDictionary; use WooCommerce\PayPalCommerce\Button\Helper\MessagesDisclaimers; use WooCommerce\PayPalCommerce\Common\Pattern\SingletonDecorator; use WooCommerce\PayPalCommerce\Googlepay\GooglePayGateway; -use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingOptionsRenderer; use WooCommerce\PayPalCommerce\Onboarding\State; use WooCommerce\PayPalCommerce\Settings\SettingsModule; -use WooCommerce\PayPalCommerce\WcGateway\Admin\RenderReauthorizeAction; -use WooCommerce\PayPalCommerce\WcGateway\Assets\VoidButtonAssets; -use WooCommerce\PayPalCommerce\WcGateway\Endpoint\CaptureCardPayment; -use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint; -use WooCommerce\PayPalCommerce\WcGateway\Endpoint\VoidOrderEndpoint; -use WooCommerce\PayPalCommerce\WcGateway\Helper\CartCheckoutDetector; -use WooCommerce\PayPalCommerce\WcGateway\Helper\FeesUpdater; -use WooCommerce\PayPalCommerce\WcGateway\Notice\SendOnlyCountryNotice; -use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Factory\SimpleRedirectTaskFactory; -use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Factory\SimpleRedirectTaskFactoryInterface; -use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Registrar\TaskRegistrar; -use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Registrar\TaskRegistrarInterface; -use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Tasks\SimpleRedirectTask; -use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Admin\FeesRenderer; use WooCommerce\PayPalCommerce\WcGateway\Admin\OrderTablePaymentStatusColumn; use WooCommerce\PayPalCommerce\WcGateway\Admin\PaymentStatusOrderDetail; use WooCommerce\PayPalCommerce\WcGateway\Admin\RenderAuthorizeAction; +use WooCommerce\PayPalCommerce\WcGateway\Admin\RenderReauthorizeAction; use WooCommerce\PayPalCommerce\WcGateway\Assets\FraudNetAssets; +use WooCommerce\PayPalCommerce\WcGateway\Assets\VoidButtonAssets; use WooCommerce\PayPalCommerce\WcGateway\Checkout\CheckoutPayPalAddressPreset; use WooCommerce\PayPalCommerce\WcGateway\Checkout\DisableGateways; use WooCommerce\PayPalCommerce\WcGateway\Cli\SettingsCommand; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\CaptureCardPayment; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ShippingCallbackEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\VoidOrderEndpoint; use WooCommerce\PayPalCommerce\WcGateway\FraudNet\FraudNet; use WooCommerce\PayPalCommerce\WcGateway\FraudNet\FraudNetSourceWebsiteId; use WooCommerce\PayPalCommerce\WcGateway\FundingSource\FundingSourceRenderer; @@ -62,17 +55,24 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayUponInvoice\PaymentSourceFac use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayUponInvoice\PayUponInvoice; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayUponInvoice\PayUponInvoiceGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\TransactionUrlProvider; +use WooCommerce\PayPalCommerce\WcGateway\Helper\CardPaymentsConfiguration; +use WooCommerce\PayPalCommerce\WcGateway\Helper\CartCheckoutDetector; use WooCommerce\PayPalCommerce\WcGateway\Helper\CheckoutHelper; +use WooCommerce\PayPalCommerce\WcGateway\Helper\ConnectionState; use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCProductStatus; use WooCommerce\PayPalCommerce\WcGateway\Helper\DisplayManager; +use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; +use WooCommerce\PayPalCommerce\WcGateway\Helper\FeesUpdater; +use WooCommerce\PayPalCommerce\WcGateway\Helper\InstallmentsProductStatus; +use WooCommerce\PayPalCommerce\WcGateway\Helper\MerchantDetails; use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceHelper; use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceProductStatus; -use WooCommerce\PayPalCommerce\WcGateway\Helper\InstallmentsProductStatus; use WooCommerce\PayPalCommerce\WcGateway\Helper\RefundFeesUpdater; use WooCommerce\PayPalCommerce\WcGateway\Helper\SettingsStatus; use WooCommerce\PayPalCommerce\WcGateway\Notice\AuthorizeOrderActionNotice; use WooCommerce\PayPalCommerce\WcGateway\Notice\ConnectAdminNotice; use WooCommerce\PayPalCommerce\WcGateway\Notice\GatewayWithoutPayPalAdminNotice; +use WooCommerce\PayPalCommerce\WcGateway\Notice\SendOnlyCountryNotice; use WooCommerce\PayPalCommerce\WcGateway\Notice\UnsupportedCurrencyAdminNotice; use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor; use WooCommerce\PayPalCommerce\WcGateway\Processor\OrderProcessor; @@ -82,10 +82,18 @@ use WooCommerce\PayPalCommerce\WcGateway\Settings\SectionsRenderer; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\WcGateway\Settings\SettingsListener; use WooCommerce\PayPalCommerce\WcGateway\Settings\SettingsRenderer; -use WooCommerce\PayPalCommerce\Axo\Helper\PropertiesDictionary; -use WooCommerce\PayPalCommerce\Applepay\ApplePayGateway; -use WooCommerce\PayPalCommerce\WcGateway\Helper\CardPaymentsConfiguration; -use WooCommerce\PayPalCommerce\WcGateway\Helper\ConnectionState; +use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Factory\SimpleRedirectTaskFactory; +use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Factory\SimpleRedirectTaskFactoryInterface; +use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Registrar\TaskRegistrar; +use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Registrar\TaskRegistrarInterface; +use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Tasks\SimpleRedirectTask; +use WooCommerce\PayPalCommerce\WcGateway\Shipping\ShippingCallbackUrlFactory; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Endpoint\CartEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\CartFactory; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\CartTotalsFactory; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\MoneyFactory; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\ShippingRatesFactory; +use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; return array( 'wcgateway.paypal-gateway' => static function ( ContainerInterface $container ): PayPalGateway { @@ -539,9 +547,10 @@ return array( $container->get( 'http.redirector' ), $container->get( 'api.partner_merchant_id-production' ), $container->get( 'api.partner_merchant_id-sandbox' ), - $container->get( 'api.endpoint.billing-agreements' ), + $container->get( 'api.reference-transaction-status' ), $container->get( 'woocommerce.logger.woocommerce' ), - new Cache( 'ppcp-client-credentials-cache' ) + new Cache( 'ppcp-client-credentials-cache' ), + $container->get( 'api.reference-transaction-status-cache' ) ); }, 'wcgateway.order-processor' => static function ( ContainerInterface $container ): OrderProcessor { @@ -643,9 +652,10 @@ return array( 'wcgateway.settings.fields.subscriptions_mode' => static function ( ContainerInterface $container ): array { $subscription_mode_options = $container->get( 'wcgateway.settings.fields.subscriptions_mode_options' ); - $billing_agreements_endpoint = $container->get( 'api.endpoint.billing-agreements' ); - $reference_transaction_enabled = $billing_agreements_endpoint->reference_transaction_enabled(); - if ( $reference_transaction_enabled !== true ) { + $reference_transaction_status = $container->get( 'api.reference-transaction-status' ); + assert( $reference_transaction_status instanceof ReferenceTransactionStatus ); + + if ( ! $reference_transaction_status->reference_transaction_enabled() ) { unset( $subscription_mode_options['vaulting_api'] ); } @@ -1765,10 +1775,10 @@ return array( $environment = $container->get( 'settings.environment' ); assert( $environment instanceof Environment ); - $billing_agreements_endpoint = $container->get( 'api.endpoint.billing-agreements' ); - assert( $billing_agreements_endpoint instanceof BillingAgreementsEndpoint ); + $reference_transaction_status = $container->get( 'api.reference-transaction-status' ); + assert( $reference_transaction_status instanceof ReferenceTransactionStatus ); - $enabled = $billing_agreements_endpoint->reference_transaction_enabled(); + $enabled = $reference_transaction_status->reference_transaction_enabled(); $enabled_status_text = esc_html__( 'Status: Available', 'woocommerce-paypal-payments' ); $disabled_status_text = esc_html__( 'Status: Not yet enabled', 'woocommerce-paypal-payments' ); @@ -1921,6 +1931,7 @@ return array( assert( $settings instanceof Settings ); if ( apply_filters( + // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores -- feature flags use this convention 'woocommerce.feature-flags.woocommerce_paypal_payments.settings_enabled', getenv( 'PCP_SETTINGS_ENABLED' ) === '1' ) ) { @@ -2021,6 +2032,49 @@ return array( return new TaskRegistrar(); }, + 'wcgateway.settings.wc-tasks.pay-later-task-config' => static function( ContainerInterface $container ): array { + $section_id = PayPalGateway::ID; + $pay_later_tab_id = Settings::PAY_LATER_TAB_ID; + + if ( $container->has( 'paylater-configurator.is-available' ) && $container->get( 'paylater-configurator.is-available' ) ) { + return array( + array( + 'id' => 'pay-later-messaging-task', + 'title' => __( 'Configure PayPal Pay Later messaging', 'woocommerce-paypal-payments' ), + 'description' => __( 'Decide where you want dynamic Pay Later messaging to show up and how you want it to look on your site.', 'woocommerce-paypal-payments' ), + 'redirect_url' => admin_url( "admin.php?page=wc-settings&tab=checkout§ion={$section_id}&ppcp-tab={$pay_later_tab_id}" ), + ), + ); + } + + return array(); + }, + + 'wcgateway.settings.wc-tasks.connect-task-config' => static function( ContainerInterface $container ): array { + $is_connected = $container->get( 'settings.flag.is-connected' ); + $is_current_country_send_only = $container->get( 'wcgateway.is-send-only-country' ); + + if ( ! $is_connected && ! $is_current_country_send_only ) { + return array( + array( + 'id' => 'connect-to-paypal-task', + 'title' => __( 'Connect PayPal to complete setup', 'woocommerce-paypal-payments' ), + 'description' => __( 'PayPal Payments is almost ready. To get started, connect your account with the Activate PayPal Payments button.', 'woocommerce-paypal-payments' ), + 'redirect_url' => admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=ppcp-gateway&ppcp-tab=' . Settings::CONNECTION_TAB_ID ), + ), + ); + } + + return array(); + }, + + 'wcgateway.settings.wc-tasks.task-config-services' => static function(): array { + return array( + 'wcgateway.settings.wc-tasks.pay-later-task-config', + 'wcgateway.settings.wc-tasks.connect-task-config', + ); + }, + /** * A configuration for simple redirect wc tasks. * @@ -2032,18 +2086,14 @@ return array( * }> */ 'wcgateway.settings.wc-tasks.simple-redirect-tasks-config' => static function( ContainerInterface $container ): array { - $section_id = PayPalGateway::ID; - $pay_later_tab_id = Settings::PAY_LATER_TAB_ID; - $list_of_config = array(); + $task_config_services = $container->get( 'wcgateway.settings.wc-tasks.task-config-services' ); - if ( $container->has( 'paylater-configurator.is-available' ) && $container->get( 'paylater-configurator.is-available' ) ) { - $list_of_config[] = array( - 'id' => 'pay-later-messaging-task', - 'title' => __( 'Configure PayPal Pay Later messaging', 'woocommerce-paypal-payments' ), - 'description' => __( 'Decide where you want dynamic Pay Later messaging to show up and how you want it to look on your site.', 'woocommerce-paypal-payments' ), - 'redirect_url' => admin_url( "admin.php?page=wc-settings&tab=checkout§ion={$section_id}&ppcp-tab={$pay_later_tab_id}" ), - ); + foreach ( $task_config_services as $service_id ) { + if ( $container->has( $service_id ) ) { + $task_config = $container->get( $service_id ); + $list_of_config = array_merge( $list_of_config, $task_config ); + } } return $list_of_config; @@ -2092,4 +2142,135 @@ return array( 'wcgateway.settings.admin-settings-enabled' => static function( ContainerInterface $container ): bool { return $container->has( 'settings.url' ) && ! SettingsModule::should_use_the_old_ui(); }, + + 'wcgateway.contact-module.eligibility.check' => static function ( ContainerInterface $container ): callable { + $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' ) !== '0' + ); + + /** + * Decides, whether the current merchant is eligible to use the + * "Contact Module" feature on this site. + */ + return static function () use ( $feature_enabled, $container ) { + if ( ! $feature_enabled ) { + return false; + } + + $details = $container->get( 'settings.merchant-details' ); + assert( $details instanceof MerchantDetails ); + + $enable_contact_module = 'US' === $details->get_merchant_country(); + + /** + * The contact module is enabled for US-based merchants by default. + * This filter provides the official way to opt-out of using it on this store. + */ + return (bool) apply_filters( + 'woocommerce_paypal_payments_contact_module_enabled', + $enable_contact_module + ); + }; + }, + + /** + * Returns a centralized list of feature eligibility checks. + * + * This is a helper service which is used by the `MerchantDetails` class and + * should not be directly accessed. + */ + 'wcgateway.feature-eligibility.list' => static function( ContainerInterface $container ): array { + return array( + MerchantDetails::FEATURE_SAVE_PAYPAL_VENMO => $container->get( 'save-payment-methods.eligibility.check' ), + MerchantDetails::FEATURE_ADVANCED_CARD_PROCESSING => $container->get( 'card-fields.eligibility.check' ), + MerchantDetails::FEATURE_GOOGLE_PAY => $container->get( 'googlepay.eligibility.check' ), + MerchantDetails::FEATURE_APPLE_PAY => $container->get( 'applepay.eligibility.check' ), + MerchantDetails::FEATURE_CONTACT_MODULE => $container->get( 'wcgateway.contact-module.eligibility.check' ), + ); + }, + + /** + * Returns a prefix for the site, ensuring the same site always gets the same prefix (unless the URL changes). + */ + 'wcgateway.settings.invoice-prefix' => static function( ContainerInterface $container ): string { + $site_url = get_site_url( get_current_blog_id() ); + $hash = md5( $site_url ); + $letters = preg_replace( '~\d~', '', $hash ) ?? ''; + $prefix = substr( $letters, 0, 6 ); + + return $prefix ? $prefix . '-' : ''; + }, + + /** + * Returns random 6 characters length alphabetic prefix, followed by a hyphen. + */ + 'wcgateway.settings.invoice-prefix-random' => static function( ContainerInterface $container ): string { + $characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $prefix = ''; + for ( $i = 0; $i < 6; $i++ ) { + $prefix .= $characters[ wp_rand( 0, strlen( $characters ) - 1 ) ]; + } + + return $prefix . '-'; + }, + + 'wcgateway.store-api.endpoint.cart' => static function( ContainerInterface $container ) : CartEndpoint { + return new CartEndpoint( + $container->get( 'wcgateway.store-api.factory.cart' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + + 'wcgateway.store-api.factory.cart' => static function( ContainerInterface $container ) : CartFactory { + return new CartFactory( + $container->get( 'wcgateway.store-api.factory.cart-totals' ), + $container->get( 'wcgateway.store-api.factory.shipping-rates' ) + ); + }, + 'wcgateway.store-api.factory.cart-totals' => static function( ContainerInterface $container ) : CartTotalsFactory { + return new CartTotalsFactory( + $container->get( 'wcgateway.store-api.factory.money' ) + ); + }, + 'wcgateway.store-api.factory.shipping-rates' => static function( ContainerInterface $container ) : ShippingRatesFactory { + return new ShippingRatesFactory( + $container->get( 'wcgateway.store-api.factory.money' ) + ); + }, + 'wcgateway.store-api.factory.money' => static function( ContainerInterface $container ) : MoneyFactory { + return new MoneyFactory(); + }, + + 'wcgateway.shipping.callback.endpoint' => static function( ContainerInterface $container ) : ShippingCallbackEndpoint { + return new ShippingCallbackEndpoint( + $container->get( 'wcgateway.store-api.endpoint.cart' ), + $container->get( 'api.factory.amount' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + + 'wcgateway.shipping.callback.factory.url' => static function( ContainerInterface $container ) : ShippingCallbackUrlFactory { + return new ShippingCallbackUrlFactory( + $container->get( 'wcgateway.store-api.endpoint.cart' ), + $container->get( 'wcgateway.shipping.callback.endpoint' ) + ); + }, + + 'wcgateway.server-side-shipping-callback-enabled' => static function( ContainerInterface $container ) : bool { + return apply_filters( + // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + 'woocommerce.feature-flags.woocommerce_paypal_payments.server_side_shipping_callback_enabled', + getenv( 'PCP_SERVER_SIDE_SHIPPING_CALLBACK_ENABLED' ) === '1' + ); + }, + + 'wcgateway.appswitch-enabled' => static function( ContainerInterface $container ) : bool { + return apply_filters( + // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + 'woocommerce.feature-flags.woocommerce_paypal_payments.appswitch_enabled', + getenv( 'PCP_APPSWITCH_ENABLED' ) === '1' + ); + }, ); diff --git a/modules/ppcp-wc-gateway/src/Assets/SettingsPageAssets.php b/modules/ppcp-wc-gateway/src/Assets/SettingsPageAssets.php index 4ab12b13b..c8b39f61a 100644 --- a/modules/ppcp-wc-gateway/src/Assets/SettingsPageAssets.php +++ b/modules/ppcp-wc-gateway/src/Assets/SettingsPageAssets.php @@ -9,10 +9,10 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\WcGateway\Assets; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus; use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter; -use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; /** @@ -104,12 +104,7 @@ class SettingsPageAssets { */ private $is_acdc_enabled; - /** - * Billing Agreements endpoint. - * - * @var BillingAgreementsEndpoint - */ - private $billing_agreements_endpoint; + private $reference_transaction_status; /** * Whether we're on a settings page for our plugin's payment methods. @@ -121,20 +116,20 @@ class SettingsPageAssets { /** * Assets constructor. * - * @param string $module_url The url of this module. - * @param string $version The assets version. - * @param SubscriptionHelper $subscription_helper The subscription helper. - * @param string $client_id The PayPal SDK client ID. - * @param CurrencyGetter $currency The getter of the 3-letter currency code of the shop. - * @param string $country 2-letter country code of the shop. - * @param Environment $environment The environment object. - * @param bool $is_pay_later_button_enabled Whether Pay Later button is enabled either for checkout, cart or product page. - * @param array $disabled_sources The list of disabled funding sources. - * @param array $all_funding_sources The list of all existing funding sources. - * @param bool $is_settings_page Whether it's a settings page of this plugin. - * @param bool $is_acdc_enabled Whether the ACDC gateway is enabled. - * @param BillingAgreementsEndpoint $billing_agreements_endpoint Billing Agreements endpoint. - * @param bool $is_paypal_payment_method_page Whether we're on a settings page for our plugin's payment methods. + * @param string $module_url The url of this module. + * @param string $version The assets version. + * @param SubscriptionHelper $subscription_helper The subscription helper. + * @param string $client_id The PayPal SDK client ID. + * @param CurrencyGetter $currency The getter of the 3-letter currency code of the shop. + * @param string $country 2-letter country code of the shop. + * @param Environment $environment The environment object. + * @param bool $is_pay_later_button_enabled Whether Pay Later button is enabled either for checkout, cart or product page. + * @param array $disabled_sources The list of disabled funding sources. + * @param array $all_funding_sources The list of all existing funding sources. + * @param bool $is_settings_page Whether it's a settings page of this plugin. + * @param bool $is_acdc_enabled Whether the ACDC gateway is enabled. + * @param ReferenceTransactionStatus $reference_transaction_status + * @param bool $is_paypal_payment_method_page Whether we're on a settings page for our plugin's payment methods. */ public function __construct( string $module_url, @@ -149,7 +144,7 @@ class SettingsPageAssets { array $all_funding_sources, bool $is_settings_page, bool $is_acdc_enabled, - BillingAgreementsEndpoint $billing_agreements_endpoint, + ReferenceTransactionStatus $reference_transaction_status, bool $is_paypal_payment_method_page ) { $this->module_url = $module_url; @@ -164,7 +159,7 @@ class SettingsPageAssets { $this->all_funding_sources = $all_funding_sources; $this->is_settings_page = $is_settings_page; $this->is_acdc_enabled = $is_acdc_enabled; - $this->billing_agreements_endpoint = $billing_agreements_endpoint; + $this->reference_transaction_status = $reference_transaction_status; $this->is_paypal_payment_method_page = $is_paypal_payment_method_page; } @@ -239,7 +234,7 @@ class SettingsPageAssets { ), ), ), - 'reference_transaction_enabled' => $this->billing_agreements_endpoint->reference_transaction_enabled(), + 'reference_transaction_enabled' => $this->reference_transaction_status->reference_transaction_enabled(), 'vaulting_must_enable_advanced_wallet_message' => sprintf( // translators: %1$s and %2$s are the opening and closing of HTML tag. esc_html__( 'Your PayPal account must be eligible to %1$ssave PayPal and Venmo payment methods%2$s to enable PayPal Vaulting.', 'woocommerce-paypal-payments' ), diff --git a/modules/ppcp-wc-gateway/src/Endpoint/CaptureCardPayment.php b/modules/ppcp-wc-gateway/src/Endpoint/CaptureCardPayment.php index 3b7436f4b..274a34d61 100644 --- a/modules/ppcp-wc-gateway/src/Endpoint/CaptureCardPayment.php +++ b/modules/ppcp-wc-gateway/src/Endpoint/CaptureCardPayment.php @@ -155,7 +155,7 @@ class CaptureCardPayment { 'intent' => $intent, 'purchase_units' => array_map( static function ( PurchaseUnit $item ): array { - return $item->to_array( true, false ); + return $item->to_array(); }, $items ), diff --git a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php index ff28bedee..39b69ba5d 100644 --- a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php +++ b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php @@ -9,10 +9,14 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\WcGateway\Endpoint; +use DomainException; use Psr\Log\LoggerInterface; +use Exception; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; use WooCommerce\PayPalCommerce\Session\SessionHandler; +use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\WcGateway\Gateway\OXXO\OXXOGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; @@ -75,20 +79,34 @@ class ReturnUrlEndpoint { * Handles the incoming request. */ public function handle_request(): void { - // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! isset( $_GET['token'] ) ) { + wc_add_notice( __( 'Payment session expired. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' ); + wp_safe_redirect( wc_get_checkout_url() ); + exit(); + } + $token = sanitize_text_field( wp_unslash( $_GET['token'] ) ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + try { + $order = $this->order_endpoint->order( $token ); + } catch ( Exception $exception ) { + $this->logger->warning( "Return URL endpoint failed to fetch order $token: " . $exception->getMessage() ); + wc_add_notice( __( 'Could not retrieve payment information. Please try again.', 'woocommerce-paypal-payments' ), 'error' ); + wp_safe_redirect( wc_get_checkout_url() ); exit(); } - $token = sanitize_text_field( wp_unslash( $_GET['token'] ) ); - // phpcs:enable WordPress.Security.NonceVerification.Recommended - $order = $this->order_endpoint->order( $token ); - - if ( $order->status()->is( OrderStatus::APPROVED ) - || $order->status()->is( OrderStatus::COMPLETED ) - ) { - $this->session_handler->replace_order( $order ); + // Handle 3DS completion if needed. + if ( $this->needs_3ds_completion( $order ) ) { + try { + $order = $this->complete_3ds_verification( $order ); + } catch ( Exception $e ) { + $this->logger->warning( "3DS completion failed for order $token: " . $e->getMessage() ); + wc_add_notice( $this->get_3ds_error_message( $e ), 'error' ); + wp_safe_redirect( wc_get_checkout_url() ); + exit(); + } } $wc_order_id = (int) $order->purchase_units()[0]->custom_id(); @@ -102,12 +120,17 @@ class ReturnUrlEndpoint { } $this->logger->warning( "Return URL endpoint $token: no WC order ID." ); + wc_add_notice( __( 'Order information is missing. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' ); + wp_safe_redirect( wc_get_checkout_url() ); exit(); } $wc_order = wc_get_order( $wc_order_id ); if ( ! is_a( $wc_order, \WC_Order::class ) ) { $this->logger->warning( "Return URL endpoint $token: WC order $wc_order_id not found." ); + + wc_add_notice( __( 'Order not found. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' ); + wp_safe_redirect( wc_get_checkout_url() ); exit(); } @@ -117,7 +140,15 @@ class ReturnUrlEndpoint { exit(); } - $success = $this->gateway->process_payment( $wc_order_id ); + $payment_gateway = $this->get_payment_gateway( $wc_order->get_payment_method() ); + if ( ! $payment_gateway ) { + wc_add_notice( __( 'Payment gateway is unavailable. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' ); + wp_safe_redirect( wc_get_checkout_url() ); + exit(); + } + + $success = $payment_gateway->process_payment( $wc_order_id ); + if ( isset( $success['result'] ) && 'success' === $success['result'] ) { add_filter( 'allowed_redirect_hosts', @@ -130,7 +161,95 @@ class ReturnUrlEndpoint { wp_safe_redirect( $success['redirect'] ); exit(); } + + wc_add_notice( __( 'Payment processing failed. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' ); wp_safe_redirect( wc_get_checkout_url() ); exit(); } + + /** + * Check if order needs 3DS completion. + * + * @param Order $order The PayPal order. + * @return bool + */ + private function needs_3ds_completion( Order $order ): bool { + // If order is still CREATED after 3DS redirect, it needs to be captured. + return $order->status()->is( OrderStatus::CREATED ); + } + + /** + * Complete 3DS verification by capturing the order. + * + * @param mixed $order The PayPal order. + * @return mixed The processed order. + * @throws Exception When 3DS completion fails. + * @throws RuntimeException When API errors occur that don't match decline patterns. + */ + private function complete_3ds_verification( $order ) { + try { + $captured_order = $this->order_endpoint->capture( $order ); + + // Check if capture actually succeeded vs. payment declined. + if ( $captured_order->status()->is( OrderStatus::COMPLETED ) ) { + return $captured_order; + } else { + // Capture API succeeded but payment was declined. + throw new Exception( __( 'Payment was declined by the payment provider. Please try a different payment method.', 'woocommerce-paypal-payments' ) ); + } + } catch ( DomainException $e ) { + throw new Exception( __( '3D Secure authentication was unavailable or failed. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ) ); + } catch ( RuntimeException $e ) { + if ( strpos( $e->getMessage(), 'declined' ) !== false || + strpos( $e->getMessage(), 'PAYMENT_DENIED' ) !== false || + strpos( $e->getMessage(), 'INSTRUMENT_DECLINED' ) !== false || + strpos( $e->getMessage(), 'Payment provider declined' ) !== false ) { + throw new Exception( __( 'Your payment was declined after 3D Secure verification. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ) ); + } + throw $e; + } + } + + /** + * Get user-friendly error message for 3DS failures. + * + * @param Exception $exception The exception. + * @return string + */ + private function get_3ds_error_message( Exception $exception ): string { + $error_message = $exception->getMessage(); + + if ( strpos( $error_message, '3D Secure' ) !== false ) { + return $error_message; + } + + if ( strpos( $error_message, 'declined' ) !== false ) { + return __( 'Your payment was declined after 3D Secure verification. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ); + } + + return __( 'There was an error processing your payment. Please try again or contact support.', 'woocommerce-paypal-payments' ); + } + + /** + * Gets the appropriate payment gateway for the given payment method. + * + * @param string $payment_method The payment method ID. + * @return \WC_Payment_Gateway|null + */ + private function get_payment_gateway( string $payment_method ) { + + // For regular PayPal payments, use the injected gateway. + if ( $payment_method === $this->gateway->id ) { + return $this->gateway; + } + + // For other payment methods (like AXO), get from WooCommerce. + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + + if ( isset( $available_gateways[ $payment_method ] ) ) { + return $available_gateways[ $payment_method ]; + } + + return null; + } } diff --git a/modules/ppcp-wc-gateway/src/Endpoint/ShippingCallbackEndpoint.php b/modules/ppcp-wc-gateway/src/Endpoint/ShippingCallbackEndpoint.php new file mode 100644 index 000000000..60e281764 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Endpoint/ShippingCallbackEndpoint.php @@ -0,0 +1,156 @@ +cart_endpoint = $cart_endpoint; + $this->amount_factory = $amount_factory; + $this->logger = $logger; + } + + /** + * Registers the endpoint. + */ + public function register(): bool { + return (bool) register_rest_route( + self::NAMESPACE, + self::ROUTE, + array( + 'methods' => array( + 'POST', + ), + 'callback' => array( + $this, + 'handle_request', + ), + 'permission_callback' => array( + $this, + 'verify_request', + ), + ) + ); + } + + public function verify_request( \WP_REST_Request $request ): bool { + return true; + } + + public function handle_request( \WP_REST_Request $request ): WP_REST_Response { + $cart_token = (string) $request->get_param( 'cart_token' ); + + $request_data = $request->get_params(); + + $this->logger->debug( 'Shipping callback received: ' . $request->get_body() ); + + $request_id = $request_data['id']; + $pu_id = $request_data['purchase_units'][0]['reference_id']; + + $address = $this->convert_address_to_wc( $request_data['shipping_address'] ); + + $cart_response = $this->cart_endpoint->update_customer( + $cart_token, + array( + 'shipping_address' => $address, + ) + ); + + if ( empty( $cart_response->cart()->shipping_rates() ) ) { + $this->logger->debug( 'Shipping callback response: ADDRESS_ERROR (no shipping rates found).' ); + + return new WP_REST_Response( + array( + 'name' => 'UNPROCESSABLE_ENTITY', + 'details' => array( + array( + 'issue' => 'ADDRESS_ERROR', + ), + ), + ), + 422 + ); + } + + if ( isset( $request_data['shipping_option'] ) ) { + $selected_shipping_method_id = $request_data['shipping_option']['id']; + + $cart_response = $this->cart_endpoint->select_shipping_rate( $cart_token, 0, $selected_shipping_method_id ); + } + + $cart = $cart_response->cart(); + + $amount = $this->amount_factory->from_store_api_cart( $cart->totals() ); + + $shipping_options = array_map( + function ( ShippingRate $rate ): ShippingOption { + return $rate->to_paypal(); + }, + $cart->shipping_rates() + ); + + $response = array( + 'id' => $request_id, + 'purchase_units' => array( + array( + 'reference_id' => $pu_id, + 'amount' => $amount->to_array(), + 'shipping_options' => array_map( + function ( ShippingOption $shipping_option ): array { + return $shipping_option->to_array(); + }, + $shipping_options + ), + ), + ), + ); + + $this->logger->debug( 'Shipping callback response: ' . (string) wp_json_encode( $response ) ); + + return new WP_REST_Response( + $response, + 200 + ); + } + + /** + * Returns the URL to the endpoint. + */ + public function url(): string { + $url = rest_url( self::NAMESPACE . '/' . self::ROUTE ); + + return $url; + } + + private function convert_address_to_wc( array $address ): array { + return array( + 'country' => $address['country_code'] ?? '', + 'state' => $address['admin_area_1'] ?? '', + 'city' => $address['admin_area_2'] ?? '', + 'postcode' => $address['postal_code'] ?? '', + 'address_line_1' => '', + 'address_line_2' => '', + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php b/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php index 20c9783e8..094e650fa 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/CreditCardGateway.php @@ -460,9 +460,14 @@ class CreditCardGateway extends \WC_Payment_Gateway_CC { $tokens = WC_Payment_Tokens::get_customer_tokens( get_current_user_id() ); foreach ( $tokens as $token ) { if ( $token->get_id() === (int) $card_payment_token_id ) { - $custom_id = (string) $wc_order->get_id(); - $invoice_id = $this->prefix . $wc_order->get_order_number(); - $create_order = $this->capture_card_payment->create_order( $token->get_token(), $custom_id, $invoice_id, $wc_order ); + $custom_id = (string) $wc_order->get_id(); + $invoice_id = $this->prefix . $wc_order->get_order_number(); + + try { + $create_order = $this->capture_card_payment->create_order( $token->get_token(), $custom_id, $invoice_id, $wc_order ); + } catch ( RuntimeException $exception ) { + $this->logger->error( $exception->getMessage() ); + } $order = $this->order_endpoint->order( $create_order->id ); $wc_order->update_meta_data( PayPalGateway::INTENT_META_KEY, $order->intent() ); diff --git a/modules/ppcp-wc-gateway/src/Gateway/OXXO/OXXOGateway.php b/modules/ppcp-wc-gateway/src/Gateway/OXXO/OXXOGateway.php index 107952cf0..5efdd461a 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/OXXO/OXXOGateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/OXXO/OXXOGateway.php @@ -192,15 +192,19 @@ class OXXOGateway extends WC_Payment_Gateway { 'country_code' => $wc_order->get_billing_country(), 'experience_context' => $this->experience_context_builder ->with_default_paypal_config( $shipping_preference ) - ->build()->to_array(), + ->build() + ->with_locale( 'en-MX' ) + ->to_array(), ); $order = $this->order_endpoint->create( array( $purchase_unit ), $shipping_preference, null, - '', - array(), + self::ID, + array( + 'processing_instruction' => 'ORDER_COMPLETE_ON_PAYMENT_APPROVAL', + ), new PaymentSource( 'oxxo', (object) $payment_source_data diff --git a/modules/ppcp-wc-gateway/src/Gateway/PayPalGateway.php b/modules/ppcp-wc-gateway/src/Gateway/PayPalGateway.php index 439e27029..60a350c8b 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/PayPalGateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/PayPalGateway.php @@ -44,17 +44,21 @@ class PayPalGateway extends \WC_Payment_Gateway { use ProcessPaymentTrait, FreeTrialHandlerTrait, GatewaySettingsRendererTrait, OrderMetaTrait, TransactionIdHandlingTrait, PaymentsStatusHandlingTrait; - const ID = 'ppcp-gateway'; - const INTENT_META_KEY = '_ppcp_paypal_intent'; - const ORDER_ID_META_KEY = '_ppcp_paypal_order_id'; - const ORDER_PAYMENT_MODE_META_KEY = '_ppcp_paypal_payment_mode'; - const ORDER_PAYMENT_SOURCE_META_KEY = '_ppcp_paypal_payment_source'; - const ORDER_PAYER_EMAIL_META_KEY = '_ppcp_paypal_payer_email'; - const FEES_META_KEY = '_ppcp_paypal_fees'; - const REFUND_FEES_META_KEY = '_ppcp_paypal_refund_fees'; - const REFUNDS_META_KEY = '_ppcp_refunds'; - const THREE_D_AUTH_RESULT_META_KEY = '_ppcp_paypal_3DS_auth_result'; - const FRAUD_RESULT_META_KEY = '_ppcp_paypal_fraud_result'; + public const ID = 'ppcp-gateway'; + public const INTENT_META_KEY = '_ppcp_paypal_intent'; + public const ORDER_ID_META_KEY = '_ppcp_paypal_order_id'; + public const ORDER_PAYMENT_MODE_META_KEY = '_ppcp_paypal_payment_mode'; + public const ORDER_PAYMENT_SOURCE_META_KEY = '_ppcp_paypal_payment_source'; + public const ORDER_PAYER_EMAIL_META_KEY = '_ppcp_paypal_payer_email'; + public const FEES_META_KEY = '_ppcp_paypal_fees'; + public const REFUND_FEES_META_KEY = '_ppcp_paypal_refund_fees'; + public const REFUNDS_META_KEY = '_ppcp_refunds'; + public const THREE_D_AUTH_RESULT_META_KEY = '_ppcp_paypal_3DS_auth_result'; + public const FRAUD_RESULT_META_KEY = '_ppcp_paypal_fraud_result'; + + // Used by the Contact Module integration to store the original details. + public const ORIGINAL_EMAIL_META_KEY = '_ppcp_paypal_billing_email'; + public const ORIGINAL_PHONE_META_KEY = '_ppcp_paypal_billing_phone'; /** * List of payment sources for which we are expected to store the payer email in the WC Order metadata. diff --git a/modules/ppcp-wc-gateway/src/Gateway/PayUponInvoice/PayUponInvoiceGateway.php b/modules/ppcp-wc-gateway/src/Gateway/PayUponInvoice/PayUponInvoiceGateway.php index 1622123bc..952df2c8a 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/PayUponInvoice/PayUponInvoiceGateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/PayUponInvoice/PayUponInvoiceGateway.php @@ -247,9 +247,8 @@ class PayUponInvoiceGateway extends WC_Payment_Gateway { */ public function process_payment( $order_id ) { $wc_order = wc_get_order( $order_id ); - // phpcs:disable WordPress.Security.NonceVerification.Missing + // phpcs:disable WordPress.Security.NonceVerification $birth_date = wc_clean( wp_unslash( $_POST['billing_birth_date'] ?? '' ) ); - // phpcs:disable WordPress.Security.NonceVerification.Recommended $pay_for_order = wc_clean( wp_unslash( $_GET['pay_for_order'] ?? '' ) ); if ( 'true' === $pay_for_order ) { if ( ! $this->checkout_helper->validate_birth_date( $birth_date ) ) { @@ -261,7 +260,7 @@ class PayUponInvoiceGateway extends WC_Payment_Gateway { } $phone_number = wc_clean( wp_unslash( $_POST['billing_phone'] ?? '' ) ); - // phpcs:enable WordPress.Security.NonceVerification.Missing + // phpcs:enable WordPress.Security.NonceVerification if ( $phone_number ) { $wc_order->set_billing_phone( $phone_number ); $wc_order->save(); diff --git a/modules/ppcp-wc-gateway/src/Gateway/ProcessPaymentTrait.php b/modules/ppcp-wc-gateway/src/Gateway/ProcessPaymentTrait.php index cd82967cc..55769be91 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/ProcessPaymentTrait.php +++ b/modules/ppcp-wc-gateway/src/Gateway/ProcessPaymentTrait.php @@ -60,7 +60,7 @@ trait ProcessPaymentTrait { * @param string|null $url The redirect URL. * @return array The data that can be returned by the gateway process_payment method. */ - protected function handle_payment_success( ?WC_Order $wc_order, string $url = null ): array { + protected function handle_payment_success( ?WC_Order $wc_order, ?string $url = null ): array { if ( ! $url ) { $url = $this->get_return_url( $wc_order ); } diff --git a/modules/ppcp-wc-gateway/src/Helper/CardPaymentsConfiguration.php b/modules/ppcp-wc-gateway/src/Helper/CardPaymentsConfiguration.php index 347f7aebb..278807597 100644 --- a/modules/ppcp-wc-gateway/src/Helper/CardPaymentsConfiguration.php +++ b/modules/ppcp-wc-gateway/src/Helper/CardPaymentsConfiguration.php @@ -332,7 +332,9 @@ class CardPaymentsConfiguration { public function is_bcdc_enabled() : bool { if ( 'MX' === $this->store_country ) { $bcdc_setting = get_option( 'woocommerce_ppcp-card-button-gateway_settings' ); - return 'yes' === $bcdc_setting['enabled']; + $enabled = $bcdc_setting['enabled'] ?? ''; + + return 'yes' === $enabled; } return $this->is_enabled() && ! $this->use_acdc(); diff --git a/modules/ppcp-wc-gateway/src/Helper/DCCProductStatus.php b/modules/ppcp-wc-gateway/src/Helper/DCCProductStatus.php index 980b4d2d0..1e26d7bc7 100644 --- a/modules/ppcp-wc-gateway/src/Helper/DCCProductStatus.php +++ b/modules/ppcp-wc-gateway/src/Helper/DCCProductStatus.php @@ -126,7 +126,7 @@ class DCCProductStatus extends ProductStatus { } /** {@inheritDoc} */ - protected function clear_state( Settings $settings = null ): void { + protected function clear_state( ?Settings $settings = null ): void { if ( null === $settings ) { $settings = $this->settings; } diff --git a/modules/ppcp-wc-gateway/src/Helper/DisplayManager.php b/modules/ppcp-wc-gateway/src/Helper/DisplayManager.php index 8114c6848..a0ddbaf2b 100644 --- a/modules/ppcp-wc-gateway/src/Helper/DisplayManager.php +++ b/modules/ppcp-wc-gateway/src/Helper/DisplayManager.php @@ -46,7 +46,7 @@ class DisplayManager { * @param string|null $key The rule key. * @return DisplayRule */ - public function rule( string $key = null ): DisplayRule { + public function rule( ?string $key = null ): DisplayRule { if ( null === $key ) { $key = '_rule_' . ( (string) count( $this->rules ) ); } diff --git a/modules/ppcp-wc-gateway/src/Helper/InstallmentsProductStatus.php b/modules/ppcp-wc-gateway/src/Helper/InstallmentsProductStatus.php index f6d4b2448..c2c00ea02 100644 --- a/modules/ppcp-wc-gateway/src/Helper/InstallmentsProductStatus.php +++ b/modules/ppcp-wc-gateway/src/Helper/InstallmentsProductStatus.php @@ -98,7 +98,7 @@ class InstallmentsProductStatus extends ProductStatus { } /** {@inheritDoc} */ - protected function clear_state( Settings $settings = null ) : void { + protected function clear_state( ?Settings $settings = null ) : void { if ( null === $settings ) { $settings = $this->settings; } diff --git a/modules/ppcp-wc-gateway/src/Helper/MerchantDetails.php b/modules/ppcp-wc-gateway/src/Helper/MerchantDetails.php new file mode 100644 index 000000000..4d112a291 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Helper/MerchantDetails.php @@ -0,0 +1,135 @@ +merchant_country = $merchant_country; + $this->store_country = $store_country; + $this->eligibility_checks = $eligibility_checks; + } + + /** + * Returns the merchant's country. This country is used by PayPal to decide + * which features the merchant can access. + * + * This country is provided by PayPal and defines the operating country of + * the merchant. + * + * @return string + */ + public function get_merchant_country() : string { + return $this->merchant_country; + } + + /** + * The WooCommerce store's country, which could be different from the + * merchant's country in some cases. This country is used by WooCommerce. + * + * @return string + */ + public function get_shop_country() : string { + return $this->store_country; + } + + /** + * Tests, if the merchant is eligible to use a certain feature. + * Feature checks are reliable _after_ the "plugins_loaded" action finished. + * + * Note: + * To register features for detection by this method, the features must be + * present in the service `wcgateway.contact-module.eligibility.check`, and + * also define a public FEATURE_* const in the class header. + * Adding all features is an ongoing task. + * + * @param string $feature One of the public self::FEATURE_* values. + * @return bool Whether the merchant can use the relevant feature. + */ + public function is_eligible_for( string $feature ) : bool { + if ( ! array_key_exists( $feature, $this->eligibility_checks ) ) { + return false; + } + + $check = $this->eligibility_checks[ $feature ]; + if ( is_bool( $check ) ) { + return $check; + } + if ( is_callable( $check ) ) { + return (bool) $check(); + } + + return false; + } +} diff --git a/modules/ppcp-wc-gateway/src/Helper/PayUponInvoiceProductStatus.php b/modules/ppcp-wc-gateway/src/Helper/PayUponInvoiceProductStatus.php index 64192bd87..d62317c39 100644 --- a/modules/ppcp-wc-gateway/src/Helper/PayUponInvoiceProductStatus.php +++ b/modules/ppcp-wc-gateway/src/Helper/PayUponInvoiceProductStatus.php @@ -111,7 +111,7 @@ class PayUponInvoiceProductStatus extends ProductStatus { } /** {@inheritDoc} */ - protected function clear_state( Settings $settings = null ) : void { + protected function clear_state( ?Settings $settings = null ) : void { if ( null === $settings ) { $settings = $this->settings; } diff --git a/modules/ppcp-wc-gateway/src/Notice/ConnectAdminNotice.php b/modules/ppcp-wc-gateway/src/Notice/ConnectAdminNotice.php index c667277c6..f46a57cde 100644 --- a/modules/ppcp-wc-gateway/src/Notice/ConnectAdminNotice.php +++ b/modules/ppcp-wc-gateway/src/Notice/ConnectAdminNotice.php @@ -69,7 +69,7 @@ class ConnectAdminNotice { $message = sprintf( /* translators: %1$s the gateway name. */ __( - 'PayPal Payments is almost ready. To get started, connect your account with the Activate PayPal button on the Account Setup page.', + 'PayPal Payments is almost ready. To get started, connect your account with the Activate PayPal Payments button on the Account Setup page.', 'woocommerce-paypal-payments' ), admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=ppcp-gateway&ppcp-tab=' . Settings::CONNECTION_TAB_ID ) @@ -77,6 +77,16 @@ class ConnectAdminNotice { return new Message( $message, 'warning' ); } + /** + * Returns whether the current page is plugins.php. + * + * @return bool + */ + private function is_current_page_plugins_page(): bool { + global $pagenow; + return isset( $pagenow ) && $pagenow === 'plugins.php'; + } + /** * Whether the message should display. * @@ -87,6 +97,8 @@ class ConnectAdminNotice { * @return bool */ protected function should_display(): bool { - return ! $this->is_connected && ! $this->is_current_country_send_only; + return $this->is_current_page_plugins_page() + && ! $this->is_connected + && ! $this->is_current_country_send_only; } } diff --git a/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php b/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php index c730136ab..5ba9713f5 100644 --- a/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php +++ b/modules/ppcp-wc-gateway/src/Processor/CreditCardOrderInfoHandlingTrait.php @@ -13,6 +13,7 @@ use WC_Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\FraudProcessorResponse; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory; +use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; /** @@ -24,7 +25,6 @@ trait CreditCardOrderInfoHandlingTrait { * Handles the 3DS details. * * Adds the order note with 3DS details. - * Adds the order meta with 3DS details. * * @param Order $order The PayPal order. * @param WC_Order $wc_order The WC order. @@ -35,7 +35,7 @@ trait CreditCardOrderInfoHandlingTrait { ): void { $payment_source = $order->payment_source(); - if ( ! $payment_source || $payment_source->name() !== 'card' ) { + if ( ! $payment_source || ( $payment_source->name() !== 'card' && $payment_source->name() !== AxoGateway::ID ) ) { return; } diff --git a/modules/ppcp-wc-gateway/src/Processor/OrderMetaTrait.php b/modules/ppcp-wc-gateway/src/Processor/OrderMetaTrait.php index d534a4302..35882609b 100644 --- a/modules/ppcp-wc-gateway/src/Processor/OrderMetaTrait.php +++ b/modules/ppcp-wc-gateway/src/Processor/OrderMetaTrait.php @@ -14,6 +14,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient; use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Shipping; /** * Trait OrderMetaTrait. @@ -32,7 +33,7 @@ trait OrderMetaTrait { WC_Order $wc_order, Order $order, Environment $environment, - OrderTransient $order_transient = null + ?OrderTransient $order_transient = null ): void { $wc_order->update_meta_data( PayPalGateway::ORDER_ID_META_KEY, $order->id() ); $wc_order->update_meta_data( PayPalGateway::INTENT_META_KEY, $order->intent() ); @@ -57,11 +58,74 @@ trait OrderMetaTrait { } } + $this->add_contact_details_to_wc_order( $wc_order, $order ); + $wc_order->save(); do_action( 'woocommerce_paypal_payments_woocommerce_order_created', $wc_order, $order ); } + /** + * Swaps out the billing details with the custom contact details provided by PayPal via the + * "Contact Module" integration. + * + * The contact module can provide a custom email and phone number via the shipping details; + * Though it's part of the shipping object, these two properties are intended to be treated + * as primary contact details. + * + * @param WC_Order $wc_order The WooCommerce order to update. + * @param Order $order The PayPal order which provides the details. + * @return void + */ + private function add_contact_details_to_wc_order( WC_Order $wc_order, Order $order ) : void { + $shipping_details = $this->get_shipping_details( $order ); + + if ( ! $shipping_details ) { + return; + } + + $contact_email = $shipping_details->email_address(); + $contact_phone = $shipping_details->phone_number(); + + if ( $contact_email && is_email( $contact_email ) ) { + $billing_email = $wc_order->get_billing_email(); + + if ( $billing_email && $billing_email !== $contact_email ) { + $wc_order->update_meta_data( PayPalGateway::ORIGINAL_EMAIL_META_KEY, $billing_email ); + } + + $wc_order->set_billing_email( $contact_email ); + } + + if ( $contact_phone ) { + $billing_phone = $wc_order->get_billing_phone(); + $contact_phone_number = $contact_phone->national_number(); + + if ( $billing_phone && $billing_phone !== $contact_phone_number ) { + $wc_order->update_meta_data( PayPalGateway::ORIGINAL_PHONE_META_KEY, $billing_phone ); + } + + $wc_order->set_billing_phone( $contact_phone_number ); + } + } + + /** + * Returns the shipping address details for the order. + * + * @param Order $order The PayPal order that contains potential shipping information. + * @return ?Shipping The shipping details, or null if none present. + */ + private function get_shipping_details( Order $order ) : ?Shipping { + foreach ( $order->purchase_units() as $unit ) { + $shipping = $unit->shipping(); + if ( $shipping ) { + return $shipping; + } + } + + return null; + } + /** * Returns the payment source type or null, * diff --git a/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php b/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php index c890e7542..77044251b 100644 --- a/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php +++ b/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php @@ -107,7 +107,7 @@ class RefundProcessor { * * @phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing */ - public function process( WC_Order $wc_order, float $amount = null, string $reason = '' ) : bool { + public function process( WC_Order $wc_order, ?float $amount = null, string $reason = '' ) : bool { try { $payment_gateways = WC()->payment_gateways()->payment_gateways(); if ( ! isset( $payment_gateways[ $wc_order->get_payment_method() ] ) || ! $payment_gateways[ $wc_order->get_payment_method() ]->supports( 'refunds' ) ) { diff --git a/modules/ppcp-wc-gateway/src/Processor/TransactionIdHandlingTrait.php b/modules/ppcp-wc-gateway/src/Processor/TransactionIdHandlingTrait.php index 1b0b4c0f0..8d2c1b50e 100644 --- a/modules/ppcp-wc-gateway/src/Processor/TransactionIdHandlingTrait.php +++ b/modules/ppcp-wc-gateway/src/Processor/TransactionIdHandlingTrait.php @@ -31,7 +31,7 @@ trait TransactionIdHandlingTrait { public function update_transaction_id( string $transaction_id, WC_Order $wc_order, - LoggerInterface $logger = null + ?LoggerInterface $logger = null ): bool { try { $wc_order->set_transaction_id( $transaction_id ); diff --git a/modules/ppcp-wc-gateway/src/Settings/Fields/connection-tab-fields.php b/modules/ppcp-wc-gateway/src/Settings/Fields/connection-tab-fields.php index 8fd27887c..5b2a11bd2 100644 --- a/modules/ppcp-wc-gateway/src/Settings/Fields/connection-tab-fields.php +++ b/modules/ppcp-wc-gateway/src/Settings/Fields/connection-tab-fields.php @@ -49,6 +49,9 @@ return function ( ContainerInterface $container, array $fields ): array { $onboarding_send_only_notice_renderer = $container->get( 'onboarding.render-send-only-notice' ); assert( $onboarding_send_only_notice_renderer instanceof OnboardingSendOnlyNoticeRenderer ); + $environment = $container->get( 'settings.environment' ); + assert( $environment instanceof Environment ); + $is_send_only_country = $container->get( 'wcgateway.is-send-only-country' ); $onboarding_elements_class = $is_send_only_country ? 'hide' : 'ppcp-onboarding-element'; $send_only_country_notice_class = $is_send_only_country ? 'ppcp-onboarding-element' : 'hide'; @@ -510,13 +513,7 @@ return function ( ContainerInterface $container, array $fields ): array { 'custom_attributes' => array( 'pattern' => '[a-zA-Z_\\-]+', ), - 'default' => ( static function (): string { - $site_url = get_site_url( get_current_blog_id() ); - $hash = md5( $site_url ); - $letters = preg_replace( '~\d~', '', $hash ) ?? ''; - $prefix = substr( $letters, 0, 6 ); - return $prefix ? $prefix . '-' : ''; - } )(), + 'default' => $environment->is_sandbox() ? $container->get( 'wcgateway.settings.invoice-prefix-random' ) : $container->get( 'wcgateway.settings.invoice-prefix' ), 'screens' => array( State::STATE_START, State::STATE_ONBOARDED, diff --git a/modules/ppcp-wc-gateway/src/Settings/SettingsListener.php b/modules/ppcp-wc-gateway/src/Settings/SettingsListener.php index 9eb5990c2..4761d8a32 100644 --- a/modules/ppcp-wc-gateway/src/Settings/SettingsListener.php +++ b/modules/ppcp-wc-gateway/src/Settings/SettingsListener.php @@ -13,13 +13,12 @@ use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\SdkClientToken; -use WooCommerce\PayPalCommerce\ApiClient\Authentication\UserIdToken; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; +use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\Http\RedirectorInterface; -use WooCommerce\PayPalCommerce\Onboarding\State; use WooCommerce\PayPalCommerce\Onboarding\Helper\OnboardingUrl; +use WooCommerce\PayPalCommerce\Onboarding\State; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCProductStatus; use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceProductStatus; @@ -152,12 +151,7 @@ class SettingsListener { */ private $partner_merchant_id_sandbox; - /** - * Billing Agreements endpoint. - * - * @var BillingAgreementsEndpoint - */ - private $billing_agreements_endpoint; + private ReferenceTransactionStatus $reference_transaction_status; /** * The logger. @@ -173,26 +167,29 @@ class SettingsListener { */ private $client_credentials_cache; + protected Cache $reference_transaction_status_cache; + /** * SettingsListener constructor. * - * @param Settings $settings The settings. - * @param array $setting_fields The setting fields. - * @param WebhookRegistrar $webhook_registrar The Webhook Registrar. - * @param Cache $cache The Cache. - * @param State $state The state. - * @param Bearer $bearer The bearer. - * @param string $page_id ID of the current PPCP gateway settings page, or empty if it is not such page. - * @param Cache $signup_link_cache The signup link cache. - * @param array $signup_link_ids Signup link ids. - * @param Cache $pui_status_cache The PUI status cache. - * @param Cache $dcc_status_cache The DCC status cache. - * @param RedirectorInterface $redirector The HTTP redirector. - * @param string $partner_merchant_id_production Partner merchant ID production. - * @param string $partner_merchant_id_sandbox Partner merchant ID sandbox. - * @param BillingAgreementsEndpoint $billing_agreements_endpoint Billing Agreements endpoint. - * @param ?LoggerInterface $logger The logger. - * @param Cache $client_credentials_cache The client credentials cache. + * @param Settings $settings The settings. + * @param array $setting_fields The setting fields. + * @param WebhookRegistrar $webhook_registrar The Webhook Registrar. + * @param Cache $cache The Cache. + * @param State $state The state. + * @param Bearer $bearer The bearer. + * @param string $page_id ID of the current PPCP gateway settings page, or empty if it is not such page. + * @param Cache $signup_link_cache The signup link cache. + * @param array $signup_link_ids Signup link ids. + * @param Cache $pui_status_cache The PUI status cache. + * @param Cache $dcc_status_cache The DCC status cache. + * @param RedirectorInterface $redirector The HTTP redirector. + * @param string $partner_merchant_id_production Partner merchant ID production. + * @param string $partner_merchant_id_sandbox Partner merchant ID sandbox. + * @param ReferenceTransactionStatus $reference_transaction_status + * @param ?LoggerInterface $logger The logger. + * @param Cache $client_credentials_cache The client credentials cache. + * @param Cache $reference_transaction_status_cache The client credentials cache. */ public function __construct( Settings $settings, @@ -209,30 +206,32 @@ class SettingsListener { RedirectorInterface $redirector, string $partner_merchant_id_production, string $partner_merchant_id_sandbox, - BillingAgreementsEndpoint $billing_agreements_endpoint, - LoggerInterface $logger = null, - Cache $client_credentials_cache + ReferenceTransactionStatus $reference_transaction_status, + ?LoggerInterface $logger, + Cache $client_credentials_cache, + Cache $reference_transaction_status_cache ) { // This is a legacy settings class, it's correctly relying on the `Status` class. - $this->settings = $settings; - $this->setting_fields = $setting_fields; - $this->webhook_registrar = $webhook_registrar; - $this->cache = $cache; - $this->state = $state; - $this->bearer = $bearer; - $this->page_id = $page_id; - $this->signup_link_cache = $signup_link_cache; - $this->signup_link_ids = $signup_link_ids; - $this->pui_status_cache = $pui_status_cache; - $this->dcc_status_cache = $dcc_status_cache; - $this->redirector = $redirector; - $this->partner_merchant_id_production = $partner_merchant_id_production; - $this->partner_merchant_id_sandbox = $partner_merchant_id_sandbox; - $this->billing_agreements_endpoint = $billing_agreements_endpoint; - $this->logger = $logger ?: new NullLogger(); - $this->client_credentials_cache = $client_credentials_cache; + $this->settings = $settings; + $this->setting_fields = $setting_fields; + $this->webhook_registrar = $webhook_registrar; + $this->cache = $cache; + $this->state = $state; + $this->bearer = $bearer; + $this->page_id = $page_id; + $this->signup_link_cache = $signup_link_cache; + $this->signup_link_ids = $signup_link_ids; + $this->pui_status_cache = $pui_status_cache; + $this->dcc_status_cache = $dcc_status_cache; + $this->redirector = $redirector; + $this->partner_merchant_id_production = $partner_merchant_id_production; + $this->partner_merchant_id_sandbox = $partner_merchant_id_sandbox; + $this->reference_transaction_status = $reference_transaction_status; + $this->logger = $logger ?: new NullLogger(); + $this->client_credentials_cache = $client_credentials_cache; + $this->reference_transaction_status_cache = $reference_transaction_status_cache; } /** @@ -394,9 +393,7 @@ class SettingsListener { $vault_enabled = wc_clean( wp_unslash( $_POST['ppcp']['vault_enabled'] ?? '' ) ); $subscription_mode = wc_clean( wp_unslash( $_POST['ppcp']['subscriptions_mode'] ?? '' ) ); - $reference_transaction_enabled = $this->billing_agreements_endpoint->reference_transaction_enabled(); - - if ( $reference_transaction_enabled !== true ) { + if ( ! $this->reference_transaction_status->reference_transaction_enabled() ) { $this->settings->set( 'vault_enabled', false ); /** @@ -413,7 +410,9 @@ class SettingsListener { $this->settings->persist(); } - if ( $subscription_mode === 'vaulting_api' && $vault_enabled !== '1' && $reference_transaction_enabled === true ) { + $reference_transaction_enabled = $this->reference_transaction_status->reference_transaction_enabled(); + + if ( $subscription_mode === 'vaulting_api' && $vault_enabled !== '1' && $reference_transaction_enabled ) { $this->settings->set( 'vault_enabled', true ); $this->settings->persist(); } @@ -529,9 +528,8 @@ class SettingsListener { */ do_action( 'woocommerce_paypal_payments_on_listening_request' ); - $ppcp_reference_transaction_enabled = get_transient( 'ppcp_reference_transaction_enabled' ) ?? ''; - if ( $ppcp_reference_transaction_enabled ) { - delete_transient( 'ppcp_reference_transaction_enabled' ); + if ( $this->reference_transaction_status_cache->has( ReferenceTransactionStatus::CACHE_KEY ) ) { + $this->reference_transaction_status_cache->delete( ReferenceTransactionStatus::CACHE_KEY ); } $redirect_url = false; diff --git a/modules/ppcp-wc-gateway/src/Shipping/ShippingCallbackUrlFactory.php b/modules/ppcp-wc-gateway/src/Shipping/ShippingCallbackUrlFactory.php new file mode 100644 index 000000000..f7bc99f51 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Shipping/ShippingCallbackUrlFactory.php @@ -0,0 +1,34 @@ +cart_endpoint = $cart_endpoint; + $this->shipping_callback_endpoint = $shipping_callback_endpoint; + } + + /** + * Creates the callback URL. + */ + public function create() : string { + $cart_response = $this->cart_endpoint->get_cart(); + + $url = $this->shipping_callback_endpoint->url(); + $url = add_query_arg( 'cart_token', $cart_response->cart_token(), $url ); + + return $url; + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Endpoint/CartEndpoint.php b/modules/ppcp-wc-gateway/src/StoreApi/Endpoint/CartEndpoint.php new file mode 100644 index 000000000..ec516cbab --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Endpoint/CartEndpoint.php @@ -0,0 +1,143 @@ +cart_factory = $cart_factory; + $this->logger = $logger; + } + + /** + * Returns the cart of the current user (based on the current cookies). + * + * @throws Exception When request fails. + */ + public function get_cart(): CartResponse { + return $this->perform_cart_request( + 'cart', + array( + 'method' => 'GET', + 'cookies' => $_COOKIE, + ) + ); + } + + /** + * Updates the customer address to the specified data and returns the cart. + * + * @see https://developer.woocommerce.com/docs/apis/store-api/resources-endpoints/cart#update-customer + * @param string $cart_token The cart token from the get_cart response. + * @param array $fields The address fields to set and their new values. + * @throws Exception When request fails. + */ + public function update_customer( string $cart_token, array $fields ): CartResponse { + return $this->perform_cart_request( + 'cart/update-customer', + array( + 'method' => 'POST', + 'headers' => array( + 'cart-token' => $cart_token, + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( $fields, JSON_FORCE_OBJECT ), + ) + ); + } + + /** + * Sets the shipping rate and returns the cart. + * + * @param string $cart_token The cart token from the get_cart response. + * @param int $package_id The package number, normally should be 0. + * @param string $rate_id The rate id, like "flat_rate:1". + * @throws Exception When request fails. + */ + public function select_shipping_rate( string $cart_token, int $package_id, string $rate_id ): CartResponse { + return $this->perform_cart_request( + 'cart/select-shipping-rate', + array( + 'method' => 'POST', + 'headers' => array( + 'cart-token' => $cart_token, + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'package_id' => $package_id, + 'rate_id' => $rate_id, + ), + JSON_FORCE_OBJECT + ), + ) + ); + } + + protected function perform_cart_request( string $path, array $args ): CartResponse { + $response = $this->request( $this->cart_endpoint_url( $path ), $args ); + + if ( is_wp_error( $response ) ) { + $error = new Exception( "$path request failed: " . $response->get_error_message() ); + $this->logger->warning( + $error->getMessage(), + array( + 'args' => $args, + 'response' => $response, + ) + ); + throw $error; + } + + $json = json_decode( $response['body'], true ); + + $error_code = $json['code'] ?? null; + $error_message = $json['message'] ?? null; + if ( $error_code ) { + $error = new Exception( "$path request return error: $error_code - $error_message" ); + $this->logger->warning( + $error->getMessage(), + array( + 'args' => $args, + 'response' => $response, + ) + ); + throw $error; + } + + $cart = $this->cart_factory->from_response( $json ); + + $cart_token = $response['headers']['cart-token'] ?? ''; + + return new CartResponse( $cart, $cart_token ); + } + + protected function cart_endpoint_url( string $path ): string { + return $this->base_api_url() . $path; + } + + protected function base_api_url(): string { + return rest_url( '/wc/store/v1/' ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Entity/Cart.php b/modules/ppcp-wc-gateway/src/StoreApi/Entity/Cart.php new file mode 100644 index 000000000..215975c56 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Entity/Cart.php @@ -0,0 +1,36 @@ +totals = $totals; + $this->shipping_rates = $shipping_rates; + } + + public function totals(): CartTotals { + return $this->totals; + } + + public function shipping_rates(): array { + return $this->shipping_rates; + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartResponse.php b/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartResponse.php new file mode 100644 index 000000000..5c534f0de --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartResponse.php @@ -0,0 +1,30 @@ +cart = $cart; + $this->cart_token = $cart_token; + } + + public function cart(): Cart { + return $this->cart; + } + + /** + * The token required for the API requests (except Get Cart). + */ + public function cart_token(): string { + return $this->cart_token; + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartTotals.php b/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartTotals.php new file mode 100644 index 000000000..632236483 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartTotals.php @@ -0,0 +1,96 @@ +total_items = $total_items; + $this->total_items_tax = $total_items_tax; + $this->total_fees = $total_fees; + $this->total_fees_tax = $total_fees_tax; + $this->total_discount = $total_discount; + $this->total_discount_tax = $total_discount_tax; + $this->total_shipping = $total_shipping; + $this->total_shipping_tax = $total_shipping_tax; + $this->total_price = $total_price; + $this->total_tax = $total_tax; + } + + public function total_items(): Money { + return $this->total_items; + } + + public function total_items_tax(): Money { + return $this->total_items_tax; + } + + public function total_fees(): Money { + return $this->total_fees; + } + + public function total_fees_tax(): Money { + return $this->total_fees_tax; + } + + public function total_discount(): Money { + return $this->total_discount; + } + + public function total_discount_tax(): Money { + return $this->total_discount_tax; + } + + public function total_shipping(): Money { + return $this->total_shipping; + } + + public function total_shipping_tax(): Money { + return $this->total_shipping_tax; + } + + public function total_price(): Money { + return $this->total_price; + } + + public function total_tax(): Money { + return $this->total_tax; + } + + +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Entity/Money.php b/modules/ppcp-wc-gateway/src/StoreApi/Entity/Money.php new file mode 100644 index 000000000..00c333e30 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Entity/Money.php @@ -0,0 +1,59 @@ +value = $value; + $this->currency_code = $currency_code; + $this->currency_minor_unit = $currency_minor_unit; + } + + public function value(): string { + return $this->value; + } + + public function currency_code(): string { + return $this->currency_code; + } + + /** + * The number of digits after ".". For most currencies it is 2. + */ + public function currency_minor_unit(): int { + return $this->currency_minor_unit; + } + + /** + * Converts to float, e.g. value=123, currency_minor_unit=2 --> 1.23. + */ + public function to_float(): float { + return round( ( (int) $this->value ) / 10 ** $this->currency_minor_unit, $this->currency_minor_unit ); + } + + /** + * Returns the Money object for the PayPal API. + */ + public function to_paypal(): \WooCommerce\PayPalCommerce\ApiClient\Entity\Money { + return new \WooCommerce\PayPalCommerce\ApiClient\Entity\Money( + $this->to_float(), + $this->currency_code + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartFactory.php b/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartFactory.php new file mode 100644 index 000000000..0cc2ef951 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartFactory.php @@ -0,0 +1,31 @@ +cart_totals_factory = $cart_totals_factory; + $this->shipping_rates_factory = $shipping_rate_factory; + } + + public function from_response( array $obj ): Cart { + return new Cart( + $this->cart_totals_factory->from_response_obj( (array) ( $obj['totals'] ?? array() ) ), + $this->shipping_rates_factory->from_response_obj( (array) ( $obj['shipping_rates'] ?? array() ) ) + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartTotalsFactory.php b/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartTotalsFactory.php new file mode 100644 index 000000000..bd50b6160 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartTotalsFactory.php @@ -0,0 +1,36 @@ +money_factory = $money_factory; + } + + /** + * Parses the 'totals' object from the cart response. + */ + public function from_response_obj( array $obj ): CartTotals { + return new CartTotals( + $this->money_factory->from_response_values( $obj, 'total_items' ), + $this->money_factory->from_response_values( $obj, 'total_items_tax' ), + $this->money_factory->from_response_values( $obj, 'total_fees' ), + $this->money_factory->from_response_values( $obj, 'total_fees_tax' ), + $this->money_factory->from_response_values( $obj, 'total_discount' ), + $this->money_factory->from_response_values( $obj, 'total_discount_tax' ), + $this->money_factory->from_response_values( $obj, 'total_shipping' ), + $this->money_factory->from_response_values( $obj, 'total_shipping_tax' ), + $this->money_factory->from_response_values( $obj, 'total_price' ), + $this->money_factory->from_response_values( $obj, 'total_tax' ) + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Factory/MoneyFactory.php b/modules/ppcp-wc-gateway/src/StoreApi/Factory/MoneyFactory.php new file mode 100644 index 000000000..1edb2d1b1 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Factory/MoneyFactory.php @@ -0,0 +1,27 @@ +rate_id = $rate_id; + $this->name = $name; + $this->selected = $selected; + $this->price = $price; + $this->taxes = $taxes; + } + + public function rate_id(): string { + return $this->rate_id; + } + + public function name(): string { + return $this->name; + } + + public function selected(): bool { + return $this->selected; + } + + public function price(): Money { + return $this->price; + } + + public function taxes(): Money { + return $this->taxes; + } + + /** + * Returns the ShippingOption object for the PayPal API. + */ + public function to_paypal(): ShippingOption { + return new ShippingOption( + $this->rate_id, + $this->name, + $this->selected, + $this->price->to_paypal(), + ShippingOption::TYPE_SHIPPING + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Factory/ShippingRatesFactory.php b/modules/ppcp-wc-gateway/src/StoreApi/Factory/ShippingRatesFactory.php new file mode 100644 index 000000000..7895e1e42 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Factory/ShippingRatesFactory.php @@ -0,0 +1,39 @@ +money_factory = $money_factory; + } + + /** + * Extracts shipping rates from the 'shipping_rates' object in the cart response. + */ + public function from_response_obj( array $obj ): array { + $rates = array(); + foreach ( $obj as $package ) { + foreach ( $package['shipping_rates'] as $item ) { + $rates[] = $this->parse_shipping_rate( $item ); + } + } + return $rates; + } + + private function parse_shipping_rate( array $obj ): ShippingRate { + return new ShippingRate( + $obj['rate_id'], + $obj['name'], + $obj['selected'], + $this->money_factory->from_response_values( $obj, 'price' ), + $this->money_factory->from_response_values( $obj, 'taxes' ) + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/WCGatewayModule.php b/modules/ppcp-wc-gateway/src/WCGatewayModule.php index 41f5fdab8..08e4b6460 100644 --- a/modules/ppcp-wc-gateway/src/WCGatewayModule.php +++ b/modules/ppcp-wc-gateway/src/WCGatewayModule.php @@ -12,55 +12,57 @@ namespace WooCommerce\PayPalCommerce\WcGateway; use Exception; use Psr\Log\LoggerInterface; use Throwable; +use WC_Order; use WooCommerce\PayPalCommerce\AdminNotices\Entity\Message; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint; +use WooCommerce\PayPalCommerce\AdminNotices\Repository\Repository; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture; +use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; +use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; +use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; +use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\LocalApmProductStatus; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; -use WooCommerce\PayPalCommerce\WcGateway\Assets\VoidButtonAssets; -use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint; -use WooCommerce\PayPalCommerce\WcGateway\Endpoint\VoidOrderEndpoint; -use WooCommerce\PayPalCommerce\WcGateway\Helper\InstallmentsProductStatus; -use WooCommerce\PayPalCommerce\WcGateway\Notice\SendOnlyCountryNotice; -use WooCommerce\PayPalCommerce\WcGateway\Processor\CreditCardOrderInfoHandlingTrait; -use WC_Order; -use WooCommerce\PayPalCommerce\AdminNotices\Repository\Repository; -use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture; -use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; -use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; +use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Admin\FeesRenderer; use WooCommerce\PayPalCommerce\WcGateway\Admin\OrderTablePaymentStatusColumn; use WooCommerce\PayPalCommerce\WcGateway\Admin\PaymentStatusOrderDetail; use WooCommerce\PayPalCommerce\WcGateway\Admin\RenderAuthorizeAction; use WooCommerce\PayPalCommerce\WcGateway\Assets\FraudNetAssets; use WooCommerce\PayPalCommerce\WcGateway\Assets\SettingsPageAssets; +use WooCommerce\PayPalCommerce\WcGateway\Assets\VoidButtonAssets; use WooCommerce\PayPalCommerce\WcGateway\Checkout\CheckoutPayPalAddressPreset; use WooCommerce\PayPalCommerce\WcGateway\Checkout\DisableGateways; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ShippingCallbackEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\VoidOrderEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\GatewayRepository; +use WooCommerce\PayPalCommerce\WcGateway\Gateway\OXXO\OXXOGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; +use WooCommerce\PayPalCommerce\WcGateway\Helper\CardPaymentsConfiguration; use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCProductStatus; +use WooCommerce\PayPalCommerce\WcGateway\Helper\InstallmentsProductStatus; use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceProductStatus; use WooCommerce\PayPalCommerce\WcGateway\Helper\SettingsStatus; use WooCommerce\PayPalCommerce\WcGateway\Notice\ConnectAdminNotice; use WooCommerce\PayPalCommerce\WcGateway\Notice\GatewayWithoutPayPalAdminNotice; +use WooCommerce\PayPalCommerce\WcGateway\Notice\SendOnlyCountryNotice; use WooCommerce\PayPalCommerce\WcGateway\Notice\UnsupportedCurrencyAdminNotice; use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor; +use WooCommerce\PayPalCommerce\WcGateway\Processor\CreditCardOrderInfoHandlingTrait; use WooCommerce\PayPalCommerce\WcGateway\Settings\HeaderRenderer; use WooCommerce\PayPalCommerce\WcGateway\Settings\SectionsRenderer; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\WcGateway\Settings\SettingsListener; use WooCommerce\PayPalCommerce\WcGateway\Settings\SettingsRenderer; -use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Registrar\TaskRegistrarInterface; -use WooCommerce\PayPalCommerce\WcGateway\Helper\CardPaymentsConfiguration; -use WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\LocalApmProductStatus; /** * Class WcGatewayModule @@ -212,7 +214,7 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul $c->get( 'wcgateway.settings.funding-sources' ), $c->get( 'wcgateway.is-ppcp-settings-page' ), $dcc_configuration->is_enabled(), - $c->get( 'api.endpoint.billing-agreements' ), + $c->get( 'api.reference-transaction-status' ), $c->get( 'wcgateway.is-ppcp-settings-payment-methods-page' ) ); $assets->register_assets(); @@ -473,7 +475,7 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul // Clears product status when appropriate. add_action( 'woocommerce_paypal_payments_clear_apm_product_status', - function( Settings $settings = null ) use ( $c ): void { + function( ?Settings $settings = null ) use ( $c ): void { // Clear DCC Product status. $dcc_product_status = $c->get( 'wcgateway.helper.dcc-product-status' ); @@ -487,8 +489,12 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul $pui_product_status->clear( $settings ); } + $reference_transaction_status_cache = $c->get( 'api.reference-transaction-status-cache' ); + assert( $reference_transaction_status_cache instanceof Cache ); // Clear Reference Transaction status. - delete_transient( 'ppcp_reference_transaction_enabled' ); + if ( $reference_transaction_status_cache->has( ReferenceTransactionStatus::CACHE_KEY ) ) { + $reference_transaction_status_cache->delete( ReferenceTransactionStatus::CACHE_KEY ); + } } ); @@ -499,44 +505,17 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul */ add_filter( 'woocommerce_admin_billing_fields', - function ( $fields ) { - global $theorder; + fn( $fields ) => $this->insert_custom_fields_into_order_details( $fields ) + ); - if ( ! apply_filters( 'woocommerce_paypal_payments_order_details_show_paypal_email', true ) ) { - return $fields; - } - - if ( ! is_array( $fields ) ) { - return $fields; - } - - if ( ! $theorder instanceof WC_Order ) { - return $fields; - } - - $email = $theorder->get_meta( PayPalGateway::ORDER_PAYER_EMAIL_META_KEY ) ?: ''; - - if ( ! $email ) { - return $fields; - } - - // Is payment source is paypal exclude all non paypal funding sources. - $payment_source = $theorder->get_meta( PayPalGateway::ORDER_PAYMENT_SOURCE_META_KEY ) ?: ''; - $is_paypal_funding_source = ( strpos( $theorder->get_payment_method_title(), '(via PayPal)' ) === false ); - - if ( $payment_source === 'paypal' && ! $is_paypal_funding_source ) { - return $fields; - } - - $fields['paypal_email'] = array( - 'label' => __( 'PayPal email address', 'woocommerce-paypal-payments' ), - 'value' => $email, - 'wrapper_class' => 'form-field-wide', - 'custom_attributes' => array( 'disabled' => 'disabled' ), - ); - - return $fields; - } + /** + * Param types removed to avoid third-party issues. + * + * @psalm-suppress MissingClosureParamType + */ + add_action( + 'woocommerce_admin_order_data_after_shipping_address', + fn( $order ) => $this->display_original_contact_in_order_details( $order ) ); add_action( @@ -561,8 +540,8 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul return $features; } - $billing_agreements_endpoint = $c->get( 'api.endpoint.billing-agreements' ); - assert( $billing_agreements_endpoint instanceof BillingAgreementsEndpoint ); + $reference_transaction_status = $c->get( 'api.reference-transaction-status' ); + assert( $reference_transaction_status instanceof ReferenceTransactionStatus ); $dcc_product_status = $c->get( 'wcgateway.helper.dcc-product-status' ); assert( $dcc_product_status instanceof DCCProductStatus ); @@ -573,8 +552,11 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul $installments_product_status = $c->get( 'wcgateway.installments-product-status' ); assert( $installments_product_status instanceof InstallmentsProductStatus ); + $contact_module_check = $c->get( 'wcgateway.contact-module.eligibility.check' ); + assert( is_callable( $contact_module_check ) ); + $features['save_paypal_and_venmo'] = array( - 'enabled' => $billing_agreements_endpoint->reference_transaction_enabled(), + 'enabled' => $reference_transaction_status->reference_transaction_enabled(), ); $features['advanced_credit_and_debit_cards'] = array( @@ -592,10 +574,43 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul 'enabled' => $installments_product_status->is_active(), ); + $features['contact_module'] = array( + 'enabled' => $contact_module_check(), + ); + return $features; } ); + add_action( + 'rest_api_init', + static function () use ( $c ) { + $endpoint = $c->get( 'wcgateway.shipping.callback.endpoint' ); + assert( $endpoint instanceof ShippingCallbackEndpoint ); + + $endpoint->register(); + } + ); + + // Add processing instruction request data for OXXO payment. + add_filter( + 'ppcp_create_order_request_body_data', + static function ( array $data, string $payment_method, array $request ) use ( $c ) : array { + if ( $payment_method !== OXXOGateway::ID ) { + return $data; + } + + $processing_instruction = $request['processing_instruction'] ?? ''; + if ( $processing_instruction ) { + $data['processing_instruction'] = $processing_instruction; + } + + return $data; + }, + 10, + 3 + ); + return true; } @@ -970,4 +985,123 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul } ); } + + /** + * Checks, if the provided argument is a WC_Order which was paid directly by PayPal. + * + * Only considers direct PayPal payments, and returns false for orders that were paid "via" + * PayPal, like wallets (Google Pay, ...) or local APMs. + * + * @param WC_Order|mixed $order The order to verify. + * @return bool True, if it's a valid order that was paid via PayPal. + */ + private function is_order_paid_by_paypal( $order ) : bool { + if ( ! $order instanceof WC_Order ) { + return false; + } + + if ( ! $order->get_meta( PayPalGateway::ORDER_PAYER_EMAIL_META_KEY ) ) { + return false; + } + + if ( 'paypal' !== $order->get_meta( PayPalGateway::ORDER_PAYMENT_SOURCE_META_KEY ) ) { + return false; + } + + return false === strpos( $order->get_payment_method_title(), '(via PayPal)' ); + } + + /** + * Inserts custom fields into the order-detail view. + * + * @param mixed $fields The field-list provided by WooCommerce, should be an array. + * @return array|mixed The filtered field list. + * + * @psalm-suppress MissingClosureParamType + */ + private function insert_custom_fields_into_order_details( $fields ) { + global $theorder; + + if ( ! is_array( $fields ) ) { + return $fields; + } + + if ( ! $this->is_order_paid_by_paypal( $theorder ) ) { + return $fields; + } + + /** + * We use this filter to de-customize the order details - 'billing' and 'shipping' section. + */ + if ( ! apply_filters( 'woocommerce_paypal_payments_order_details_show_paypal_email', true ) ) { + return $fields; + } + + $email = $theorder->get_meta( PayPalGateway::ORDER_PAYER_EMAIL_META_KEY ) ?: ''; + + $fields['paypal_email'] = array( + 'label' => __( 'PayPal email address', 'woocommerce-paypal-payments' ), + 'value' => $email, + 'wrapper_class' => 'form-field-wide', + 'custom_attributes' => array( 'disabled' => 'disabled' ), + ); + + return $fields; + } + + /** + * Displays a custom section in the order details page with the original contact details entered + * during checkout. + * + * When the Contact module is active, those contact details are replaced with details provided + * by PayPal; this section shows the (unused) details which the user originally entered. + * + * @param WC_Order|mixed $order The order which is rendered. + * @return void + */ + private function display_original_contact_in_order_details( $order ) : void { + if ( ! $this->is_order_paid_by_paypal( $order ) ) { + return; + } + + if ( ! apply_filters( 'woocommerce_paypal_payments_order_details_show_original_contact', true ) ) { + return; + } + + assert( $order instanceof WC_Order ); + $contact_email = $order->get_meta( PayPalGateway::ORIGINAL_EMAIL_META_KEY ); + $contact_phone = $order->get_meta( PayPalGateway::ORIGINAL_PHONE_META_KEY ); + + if ( ! $contact_email && ! $contact_phone ) { + return; + } + + ?> +
+

+ + +

+ +

+ + : + + +

+ + +

+ + : + + +

+ +
+ + + + - + + + + + + + + + api diff --git a/readme.txt b/readme.txt index fe13ec28b..7d005e851 100644 --- a/readme.txt +++ b/readme.txt @@ -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.8 License: GPLv2 License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -156,6 +156,37 @@ If you encounter issues with the PayPal buttons not appearing after an update, p == Changelog == += 3.0.8 - 2025-07-28 = +* Enhancement - Migration from Legacy Settings to New Settings as opt-in via banner & button #3491 +* Enhancement - Replace call to `billing-agreements/agreement-tokens` with checking the capabilities for Reference Transactions #3495 +* Enhancement - Add Fastlane 3D Secure support #3493 +* Enhancement - Improved PHP 8.4 compatibility #3534 +* Fix - `INVALID_REQUEST` error due to wrong `landing_page` value after upgrade to 3.0.7 #3521 +* Fix - Incorrect Amount via Express Payment for WooCommerce Product Bundles #3516 +* Fix - Onboarding failed via "Connect to PayPal" in new UI due to race condition #3385 +* Fix - Fatal error when PayPal Payments is active without WooCommerce #3502 +* Fix - PayPal Subscription transaction failed in various scenarios #3515 +* Fix - Rounding differences potentially lead to order failure (author @luzat) #3373 +* Fix - Google Pay payment on block checkout may fail when ACDC is default payment selection #3506 +* Fix - Product Prices Disappear in some cases when WooCommerce Subscriptions is active #3519 + += 3.0.7 - 2025-07-01 = +* Enhancement - Remove `application_context` in favor of `experience_context` object #3431 + **NOTE**: If you were 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 +* Fix - billing-agreements endpoint called too frequently when not enabled for Reference Transactions #3459 + = 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 diff --git a/tests/PHPUnit/ApiClient/Entity/ExperienceContextTest.php b/tests/PHPUnit/ApiClient/Entity/ExperienceContextTest.php index bf074fc67..b6fe199c1 100644 --- a/tests/PHPUnit/ApiClient/Entity/ExperienceContextTest.php +++ b/tests/PHPUnit/ApiClient/Entity/ExperienceContextTest.php @@ -19,7 +19,12 @@ class ExperienceContextTest extends TestCase ->with_landing_page('NO_PREFERENCE') ->with_shipping_preference('NO_SHIPPING') ->with_user_action('CONTINUE') - ->with_payment_method_preference('UNRESTRICTED'); + ->with_payment_method_preference('UNRESTRICTED') + ->with_contact_preference('NO_CONTACT_INFO') + ->with_order_update_callback_config(new CallbackConfig( + [CallbackConfig::EVENT_SHIPPING_ADDRESS, CallbackConfig::EVENT_SHIPPING_OPTIONS], + 'example.com/callback', + )); $this->assertEmpty($empty->to_array()); @@ -32,6 +37,11 @@ class ExperienceContextTest extends TestCase 'shipping_preference' => 'NO_SHIPPING', 'user_action' => 'CONTINUE', 'payment_method_preference' => 'UNRESTRICTED', + 'contact_preference' => 'NO_CONTACT_INFO', + 'order_update_callback_config' => [ + 'callback_events' => [CallbackConfig::EVENT_SHIPPING_ADDRESS, CallbackConfig::EVENT_SHIPPING_OPTIONS], + 'callback_url' => 'example.com/callback', + ] ], $result->to_array()); } } diff --git a/tests/PHPUnit/ApiClient/Factory/ContactPreferenceFactoryTest.php b/tests/PHPUnit/ApiClient/Factory/ContactPreferenceFactoryTest.php new file mode 100644 index 000000000..93f057fa3 --- /dev/null +++ b/tests/PHPUnit/ApiClient/Factory/ContactPreferenceFactoryTest.php @@ -0,0 +1,94 @@ +createMerchantDetails($isEligibleForContactModule); + + $testee = new ContactPreferenceFactory($isContactModuleActive, $md); + + $result = $testee->from_state($paymentSourceKey); + + self::assertEquals($expectedResult, $result); + } + + public function forStateData() + { + // Ensure UPDATE_CONTACT response for paypal and venmo. + yield [ + 'paypal', + true, + true, + ExperienceContext::CONTACT_PREFERENCE_UPDATE_CONTACT_INFO + ]; + yield [ + 'venmo', + true, + true, + ExperienceContext::CONTACT_PREFERENCE_UPDATE_CONTACT_INFO + ]; + + // Ensure NO_CONTACT response when not eligible or feature is disabled. + yield [ + 'paypal', + false, + true, + ExperienceContext::CONTACT_PREFERENCE_NO_CONTACT_INFO + ]; + yield [ + 'paypal', + true, + false, + ExperienceContext::CONTACT_PREFERENCE_NO_CONTACT_INFO + ]; + yield [ + 'paypal', + false, + false, + ExperienceContext::CONTACT_PREFERENCE_NO_CONTACT_INFO + ]; + + // Ensure NULL response when using an unsupported payment-source. + yield [ + 'oxxo', + true, + true, + null + ]; + yield [ + 'oxxo', + false, + true, + null + ]; + + yield [ + 'oxxo', + false, + false, + null + ]; + } + + private function createMerchantDetails($isEligible): MerchantDetails { + $md = Mockery::mock(MerchantDetails::class); + $md->shouldReceive('is_eligible_for')->andReturn($isEligible); + return $md; + } +} diff --git a/tests/PHPUnit/ApiClient/Factory/ExperienceContextBuilderTest.php b/tests/PHPUnit/ApiClient/Factory/ExperienceContextBuilderTest.php index e179f93f6..ec80565b5 100644 --- a/tests/PHPUnit/ApiClient/Factory/ExperienceContextBuilderTest.php +++ b/tests/PHPUnit/ApiClient/Factory/ExperienceContextBuilderTest.php @@ -8,6 +8,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder; use WooCommerce\PayPalCommerce\TestCase; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; +use WooCommerce\PayPalCommerce\WcGateway\Shipping\ShippingCallbackUrlFactory; use function Brain\Monkey\Functions\expect; use Mockery; @@ -15,6 +16,8 @@ class ExperienceContextBuilderTest extends TestCase { private $settings; + private $shipping_callback_url_factory; + private $sut; public function setUp(): void @@ -22,8 +25,9 @@ class ExperienceContextBuilderTest extends TestCase parent::setUp(); $this->settings = Mockery::mock(Settings::class); + $this->shipping_callback_url_factory = Mockery::mock(ShippingCallbackUrlFactory::class); - $this->sut = new ExperienceContextBuilder($this->settings); + $this->sut = new ExperienceContextBuilder($this->settings, $this->shipping_callback_url_factory); } public function testOrderReturnUrls() @@ -196,4 +200,27 @@ class ExperienceContextBuilderTest extends TestCase ExperienceContext::PAYMENT_METHOD_IMMEDIATE_PAYMENT_REQUIRED, ]; } + + /** + * @dataProvider contactPreferenceDataProvider + */ + public function testContactPreference($preference) { + $result = $this->sut + ->with_contact_preference($preference) + ->build(); + + self::assertEquals([ + 'contact_preference' => $preference, + ], $result->to_array()); + } + + public function contactPreferenceDataProvider() + { + yield [ + ExperienceContext::CONTACT_PREFERENCE_NO_CONTACT_INFO, + ]; + yield [ + ExperienceContext::CONTACT_PREFERENCE_UPDATE_CONTACT_INFO, + ]; + } } diff --git a/tests/PHPUnit/ApiClient/Factory/OrderFactoryTest.php b/tests/PHPUnit/ApiClient/Factory/OrderFactoryTest.php index 7e9afe1a7..9c92f9da4 100644 --- a/tests/PHPUnit/ApiClient/Factory/OrderFactoryTest.php +++ b/tests/PHPUnit/ApiClient/Factory/OrderFactoryTest.php @@ -28,6 +28,7 @@ class OrderFactoryTest extends TestCase $order->expects('create_time')->andReturn($createTime); $order->expects('update_time')->andReturn($updateTime); $order->expects('payment_source')->andReturnNull(); + $order->expects('links')->andReturnNull(); $wcOrder = Mockery::mock(\WC_Order::class); $purchaseUnitFactory = Mockery::mock(PurchaseUnitFactory::class); $purchaseUnit = Mockery::mock(PurchaseUnit::class); @@ -89,6 +90,11 @@ class OrderFactoryTest extends TestCase } else { $this->assertEquals($orderData->update_time, $order->update_time()->format(\DateTime::ISO8601)); } + if ( isset($orderData->links) ) { + $this->assertEquals($orderData->links, $order->links()); + } else { + $this->assertNull($order->links()); + } } public function dataForTestFromPayPalResponseTest() : array @@ -135,6 +141,20 @@ class OrderFactoryTest extends TestCase 'update_time' => '2005-09-15T15:52:01+0000', ], ], + 'with_links' => [ + (object) [ + 'id' => 'id', + 'purchase_units' => [new \stdClass(), new \stdClass()], + 'status' => OrderStatus::PAYER_ACTION_REQUIRED, + 'intent' => 'CAPTURE', + 'create_time' => '2005-08-15T15:52:01+0000', + 'update_time' => '2005-09-15T15:52:01+0000', + 'payer' => new \stdClass(), + 'links' => [ + (object) ['rel' => 'payer-action', 'href' => 'https://example.com/3ds'] + ], + ], + ], ]; } @@ -181,13 +201,6 @@ class OrderFactoryTest extends TestCase 'intent' => '', ], ], - 'no_status' => [ - (object) [ - 'id' => '', - 'purchase_units' => [], - 'intent' => '', - ], - ], 'no_intent' => [ (object) [ 'id' => '', diff --git a/tests/PHPUnit/ApiClient/Factory/ReturnUrlFactoryTest.php b/tests/PHPUnit/ApiClient/Factory/ReturnUrlFactoryTest.php new file mode 100644 index 000000000..734856357 --- /dev/null +++ b/tests/PHPUnit/ApiClient/Factory/ReturnUrlFactoryTest.php @@ -0,0 +1,123 @@ +testee = new ReturnUrlFactory(); + } + + /** + * @dataProvider cartContextProvider + */ + public function testFromContextReturnsCartUrl(string $context) + { + when('wc_get_cart_url')->justReturn('https://example.com/cart'); + + $result = $this->testee->from_context($context); + + $this->assertEquals('https://example.com/cart', $result); + } + + public function testFromContextProductReturnsProductUrl() + { + $request_data = [ + 'purchase_units' => [ + [ + 'items' => [ + [ + 'url' => 'https://example.com/product/123' + ] + ] + ] + ] + ]; + + $result = $this->testee->from_context('product', $request_data); + + $this->assertEquals('https://example.com/product/123', $result); + } + + public function testFromContextProductThrowsExceptionWhenNoUrl() + { + $request_data = [ + 'purchase_units' => [ + [ + 'items' => [ + [ + 'name' => 'Product without URL' + ] + ] + ] + ] + ]; + + $this->expectException(RuntimeException::class); + + $this->testee->from_context('product', $request_data); + } + + public function testFromContextPayNowReturnsOrderPaymentUrl() + { + $order = Mockery::mock(\WC_Order::class); + $order->expects('get_checkout_payment_url') + ->andReturn('https://example.com/checkout/pay/123?key=abc'); + + when('wc_get_order')->justReturn($order); + + $request_data = ['order_id' => 123]; + + $result = $this->testee->from_context('pay-now', $request_data); + + $this->assertEquals('https://example.com/checkout/pay/123?key=abc', $result); + } + + public function testFromContextPayNowThrowsExceptionWhenOrderNotFound() + { + when('wc_get_order')->justReturn(false); + + $request_data = ['order_id' => 999]; + + $this->expectException(RuntimeException::class); + + $this->testee->from_context('pay-now', $request_data); + } + + public function testFromContextCheckoutReturnsCheckoutUrl() + { + when('wc_get_checkout_url')->justReturn('https://example.com/checkout'); + + $result = $this->testee->from_context('checkout'); + + $this->assertEquals('https://example.com/checkout', $result); + } + + public function testFromContextDefaultReturnsCheckoutUrl() + { + when('wc_get_checkout_url')->justReturn('https://example.com/checkout'); + + $result = $this->testee->from_context('unknown-context'); + + $this->assertEquals('https://example.com/checkout', $result); + } + + public function cartContextProvider(): array + { + return [ + 'cart context' => ['cart'], + 'cart-block context' => ['cart-block'], + 'mini-cart context' => ['mini-cart'], + ]; + } +} diff --git a/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php b/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php index 22df8c5c5..ce07a84cd 100644 --- a/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php +++ b/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php @@ -12,6 +12,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder; use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ReturnUrlFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ContactPreferenceFactory; use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler; use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\TestCase; @@ -148,6 +150,8 @@ class CreateOrderEndpointTest extends TestCase { $request_data = Mockery::mock(RequestData::class); $shippingPreferenceFactory = Mockery::mock(ShippingPreferenceFactory::class); + $returnUrlFactory = Mockery::mock(ReturnUrlFactory::class); + $contactPreferenceFactory = Mockery::mock(ContactPreferenceFactory::class); $experienceContextBuilder = Mockery::mock(ExperienceContextBuilder::class); $purchase_unit_factory = Mockery::mock(PurchaseUnitFactory::class); $order_endpoint = Mockery::mock(OrderEndpoint::class); @@ -161,6 +165,8 @@ class CreateOrderEndpointTest extends TestCase $request_data, $purchase_unit_factory, $shippingPreferenceFactory, + $returnUrlFactory, + $contactPreferenceFactory, $experienceContextBuilder, $order_endpoint, $payer_factory, @@ -172,6 +178,7 @@ class CreateOrderEndpointTest extends TestCase false, ['checkout'], false, + false, ['paypal'], new NullLogger() ); diff --git a/tests/PHPUnit/ModularTestCase.php b/tests/PHPUnit/ModularTestCase.php index 7b77eee00..63b662dd6 100644 --- a/tests/PHPUnit/ModularTestCase.php +++ b/tests/PHPUnit/ModularTestCase.php @@ -32,6 +32,8 @@ class ModularTestCase extends TestCase when('WC')->justReturn((object) [ 'session' => null, ]); + when('is_admin')->justReturn(true); + when('sanitize_key')->returnArg(); global $wpdb; $wpdb = \Mockery::mock(\stdClass::class); diff --git a/tests/PHPUnit/Vaulting/VaultedCreditCardHandlerTest.php b/tests/PHPUnit/Vaulting/VaultedCreditCardHandlerTest.php index fca3bf453..f158872e7 100644 --- a/tests/PHPUnit/Vaulting/VaultedCreditCardHandlerTest.php +++ b/tests/PHPUnit/Vaulting/VaultedCreditCardHandlerTest.php @@ -24,8 +24,6 @@ use WooCommerce\PayPalCommerce\TestCase; use WooCommerce\PayPalCommerce\Vaulting\PaymentTokenRepository; use WooCommerce\PayPalCommerce\Vaulting\VaultedCreditCardHandler; use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor; -use function Brain\Monkey\Functions\expect; -use function Brain\Monkey\Functions\when; class VaultedCreditCardHandlerTest extends TestCase { @@ -123,6 +121,7 @@ class VaultedCreditCardHandlerTest extends TestCase $capture->shouldReceive('status')->andReturn($captureStatus); $payments->shouldReceive('captures')->andReturn([$capture]); $purchaseUnit->shouldReceive('payments')->andReturn($payments); + $purchaseUnit->shouldReceive('shipping')->andReturn(null); $this->orderEndpoint->shouldReceive('create') ->with([$purchaseUnit], 'some_preference', $payer, '', array(), $requestPaymentSource) diff --git a/tests/PHPUnit/WcGateway/Gateway/OXXO/OXXOGatewayTest.php b/tests/PHPUnit/WcGateway/Gateway/OXXO/OXXOGatewayTest.php index 0a8a5d8f6..23a958b58 100644 --- a/tests/PHPUnit/WcGateway/Gateway/OXXO/OXXOGatewayTest.php +++ b/tests/PHPUnit/WcGateway/Gateway/OXXO/OXXOGatewayTest.php @@ -9,7 +9,6 @@ use WC_Order; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\ExperienceContext; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; -use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentSource; use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder; @@ -70,6 +69,10 @@ private $testee; $this->experienceContextBuilder ->shouldReceive('build') ->andReturn($experienceContext); + $experienceContext + ->shouldReceive('with_locale') + ->with('en-MX') + ->andReturn($experienceContext); $experienceContext ->shouldReceive('to_array') ->andReturn(['foo' => 'bar']); @@ -111,10 +114,18 @@ private $testee; $order->shouldReceive('intent'); $order->shouldReceive('payment_source'); $order->shouldReceive('payer'); + $order->shouldReceive('purchase_units')->andReturn([]); $this->orderEndpoint ->shouldReceive('create') - ->with([$purchaseUnit], $shippingPreference, null, '', [], Mockery::any()) + ->with( + [$purchaseUnit], + $shippingPreference, + null, + OXXOGateway::ID, + ['processing_instruction' => 'ORDER_COMPLETE_ON_PAYMENT_APPROVAL'], + Mockery::any() + ) ->andReturn($order); $this->wcOrder diff --git a/tests/PHPUnit/WcGateway/Processor/OrderProcessorTest.php b/tests/PHPUnit/WcGateway/Processor/OrderProcessorTest.php index 6f5862e81..89925b230 100644 --- a/tests/PHPUnit/WcGateway/Processor/OrderProcessorTest.php +++ b/tests/PHPUnit/WcGateway/Processor/OrderProcessorTest.php @@ -23,13 +23,11 @@ use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderHelper; use WooCommerce\PayPalCommerce\Button\Helper\ThreeDSecure; use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; use WooCommerce\PayPalCommerce\Session\SessionHandler; -use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Container\ReadOnlyContainer; use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; use WooCommerce\PayPalCommerce\TestCase; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use Mockery; -use function Brain\Monkey\Functions\when; class OrderProcessorTest extends TestCase { @@ -59,6 +57,8 @@ class OrderProcessorTest extends TestCase $purchaseUnit = Mockery::mock(PurchaseUnit::class); $purchaseUnit->shouldReceive('payments') ->andReturn($payments); + $purchaseUnit->shouldReceive('shipping') + ->andReturn(null); $wcOrder = Mockery::mock(\WC_Order::class); $wcOrder->expects('get_items')->andReturn([]); @@ -213,6 +213,8 @@ class OrderProcessorTest extends TestCase $purchaseUnit = Mockery::mock(PurchaseUnit::class); $purchaseUnit->shouldReceive('payments') ->andReturn($payments); + $purchaseUnit->shouldReceive('shipping') + ->andReturn(null); $wcOrder = Mockery::mock(\WC_Order::class); $wcOrder->expects('get_items')->andReturn([]); @@ -348,7 +350,9 @@ class OrderProcessorTest extends TestCase $purchaseUnit = Mockery::mock(PurchaseUnit::class); $purchaseUnit->shouldReceive('payments') - ->andReturn($payments); + ->andReturn($payments);; + $purchaseUnit->shouldReceive('shipping') + ->andReturn(null); $wcOrder = Mockery::mock(\WC_Order::class); diff --git a/tests/PHPUnit/WcGateway/Settings/SettingsListenerTest.php b/tests/PHPUnit/WcGateway/Settings/SettingsListenerTest.php index 74f6ed074..a28e445bb 100644 --- a/tests/PHPUnit/WcGateway/Settings/SettingsListenerTest.php +++ b/tests/PHPUnit/WcGateway/Settings/SettingsListenerTest.php @@ -2,14 +2,14 @@ namespace WooCommerce\PayPalCommerce\WcGateway\Settings; +use Mockery; use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\Helper\RedirectorStub; use WooCommerce\PayPalCommerce\ModularTestCase; use WooCommerce\PayPalCommerce\Onboarding\State; -use Mockery; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; use WooCommerce\PayPalCommerce\Webhooks\WebhookRegistrar; @@ -43,10 +43,11 @@ class SettingsListenerTest extends ModularTestCase $signup_link_ids = array(); $pui_status_cache = Mockery::mock(Cache::class); $dcc_status_cache = Mockery::mock(Cache::class); - $billing_agreement_endpoint = Mockery::mock(BillingAgreementsEndpoint::class); + $reference_transaction_status = Mockery::mock(ReferenceTransactionStatus::class); $subscription_helper = Mockery::mock(SubscriptionHelper::class); $logger = Mockery::mock(LoggerInterface::class); $client_credentials_cache = Mockery::mock(Cache::class); + $reference_transaction_status_cache = Mockery::mock(Cache::class); $testee = new SettingsListener( $settings, @@ -63,9 +64,10 @@ class SettingsListenerTest extends ModularTestCase new RedirectorStub(), '', '', - $billing_agreement_endpoint, + $reference_transaction_status, $logger, - $client_credentials_cache + $client_credentials_cache, + $reference_transaction_status_cache ); $_GET['section'] = PayPalGateway::ID; @@ -99,6 +101,8 @@ class SettingsListenerTest extends ModularTestCase ->andReturn(false); $dcc_status_cache->shouldReceive('has') ->andReturn(false); + $reference_transaction_status_cache->shouldReceive('has') + ->andReturn(false); $client_credentials_cache->shouldReceive('has')->andReturn(true); $client_credentials_cache->shouldReceive('delete'); diff --git a/tests/PHPUnit/bootstrap.php b/tests/PHPUnit/bootstrap.php index 647473e12..bd416aa6e 100644 --- a/tests/PHPUnit/bootstrap.php +++ b/tests/PHPUnit/bootstrap.php @@ -11,5 +11,6 @@ require_once TESTS_ROOT_DIR . '/stubs/WC_Payment_Gateway_CC.php'; require_once TESTS_ROOT_DIR . '/stubs/WC_Ajax.php'; require_once TESTS_ROOT_DIR . '/stubs/WC_Checkout.php'; require_once TESTS_ROOT_DIR . '/stubs/Task.php'; +require_once TESTS_ROOT_DIR . '/stubs/DefaultPaymentGateways.php'; Hamcrest\Util::registerGlobalFunctions(); diff --git a/tests/integration/PHPUnit/Factories/CouponFactory.php b/tests/integration/PHPUnit/Factories/CouponFactory.php new file mode 100644 index 000000000..b1b53cd30 --- /dev/null +++ b/tests/integration/PHPUnit/Factories/CouponFactory.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/tests/integration/PHPUnit/Factories/OrderFactory.php b/tests/integration/PHPUnit/Factories/OrderFactory.php new file mode 100644 index 000000000..ebeaca71b --- /dev/null +++ b/tests/integration/PHPUnit/Factories/OrderFactory.php @@ -0,0 +1,242 @@ +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; + } +} diff --git a/tests/integration/PHPUnit/Factories/ProductFactory.php b/tests/integration/PHPUnit/Factories/ProductFactory.php new file mode 100644 index 000000000..caaaabde3 --- /dev/null +++ b/tests/integration/PHPUnit/Factories/ProductFactory.php @@ -0,0 +1,166 @@ +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 = []; + } +} diff --git a/tests/integration/PHPUnit/Fixtures/DiscountPresets.php b/tests/integration/PHPUnit/Fixtures/DiscountPresets.php new file mode 100644 index 000000000..c0a4e2320 --- /dev/null +++ b/tests/integration/PHPUnit/Fixtures/DiscountPresets.php @@ -0,0 +1,26 @@ + [ + '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] + ], + ]; + } +} diff --git a/tests/integration/PHPUnit/Fixtures/ProductPresets.php b/tests/integration/PHPUnit/Fixtures/ProductPresets.php new file mode 100644 index 000000000..3a447ba27 --- /dev/null +++ b/tests/integration/PHPUnit/Fixtures/ProductPresets.php @@ -0,0 +1,49 @@ + [ + '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, + ] + ]; + } +} diff --git a/tests/integration/PHPUnit/IntegrationMockedTestCase.php b/tests/integration/PHPUnit/IntegrationMockedTestCase.php new file mode 100644 index 000000000..8394f1179 --- /dev/null +++ b/tests/integration/PHPUnit/IntegrationMockedTestCase.php @@ -0,0 +1,275 @@ +customer_id = $this->createCustomerIfNotExists(); + $this->createTestProducts(); + $this->createTestCoupons(); + } + + public function tearDown(): void + { + // This cleans up everything created during tests + //$this->cleanupTestData(); + parent::tearDown(); + } + + /** + * @param array $overriddenServices + * @return ContainerInterface + */ + protected function bootstrapModule(array $overriddenServices = []): ContainerInterface + { + $overriddenServices = array_merge([ + 'http.redirector' => function () { + return new RedirectorStub(); + } + ], $overriddenServices); + + + $module = new class ($overriddenServices) implements ServiceModule, ExecutableModule { + use ModuleClassNameIdTrait; + + public function __construct(array $services) + { + $this->services = $services; + } + + public function services(): array + { + return $this->services; + } + + public function run(ContainerInterface $c): bool + { + return true; + } + }; + + $rootDir = ROOT_DIR; + $bootstrap = require("$rootDir/bootstrap.php"); + $appContainer = $bootstrap($rootDir, [], [$module]); + + PPCP::init($appContainer); + + return $appContainer; + } + + public function createCustomerIfNotExists(int $customer_id = 1): int + { + $customer = new \WC_Customer($customer_id); + if (empty($customer->get_email())) { + $customer->set_email('customer' . $customer_id . '@example.com'); + $customer->set_first_name('John'); + $customer->set_last_name('Doe'); + $customer->save(); + } + return $customer->get_id(); + } + + /** + * Creates a payment token for a customer. + * + * @param int $customer_id The customer ID. + * @return WC_Payment_Token_CC The created payment token. + * @throws \Exception + */ + public function createAPaymentTokenForTheCustomer(int $customer_id = 1, $gateway_id = 'ppcp-gateway'): WC_Payment_Token_CC + { + $this->createCustomerIfNotExists($customer_id); + + $token = new WC_Payment_Token_CC(); + $token->set_token('test_token_' . uniqid()); // Unique token ID + $token->set_gateway_id($gateway_id); + $token->set_user_id($customer_id); + + // These fields are required for WC_Payment_Token_CC + $token->set_card_type('visa'); // lowercase is often expected + $token->set_last4('1234'); + $token->set_expiry_month('12'); + $token->set_expiry_year('2030'); // Missing expiry year in your original code + + $result = $token->save(); + + if (!$result || is_wp_error($result)) { + throw new \Exception('Failed to save payment token: ' . + (is_wp_error($result) ? $result->get_error_message() : 'Unknown error')); + } + + $saved_token = \WC_Payment_Tokens::get($token->get_id()); + if (!$saved_token || $saved_token->get_id() !== $token->get_id()) { + throw new \Exception('Token was not saved correctly'); + } + + return $token; + } + + /** + * Helper method to create a subscription for testing. + * + * @param int $customer_id The customer ID + * @param string $payment_method The payment method + * @param string $sku + * @return WC_Subscription + * @throws \WC_Data_Exception + */ + public function createSubscription(int $customer_id = 1, string $payment_method = 'ppcp-gateway', $sku = 'DUMMY SUB SKU'): WC_Subscription + { + $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); + $subscription->set_status('active'); + $subscription->set_parent_id($order->get_id()); + $subscription->set_billing_period('month'); + $subscription->set_billing_interval(1); + + // Add a product to the subscription + $subscription_item = new WC_Order_Item_Product(); + $subscription_item->set_props([ + 'product_id' => $product_id, + 'quantity' => 1, + 'subtotal' => 10, + 'total' => 10, + ]); + $subscription->add_item($subscription_item); + $subscription->set_date_created(current_time('mysql')); + $subscription->set_start_date(current_time('mysql')); + $subscription->set_next_payment_date(date('Y-m-d H:i:s', strtotime('+1 month', current_time('timestamp')))); + $subscription->save(); + + return $subscription; + } + + /** + * Creates a renewal order for testing + * + * @param int $customer_id + * @param string $gateway_id + * @param int $subscription_id + * @return WC_Order + */ + protected function createRenewalOrder(int $customer_id, string $gateway_id, int $subscription_id): WC_Order + { + $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(); + + return $renewal_order; + } + + /** + * Mocks the OrderEndpoint to return a successful/failed order. + * + * @param string $intent The order intent (CAPTURE or AUTHORIZE) + * @param bool $success Whether the order was successful + * @return object The mocked OrderEndpoint + */ + public function mockOrderEndpoint(string $intent = 'CAPTURE', bool $order_success = true, bool $capture_success = true): object + { + $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); + $order_status->shouldReceive('is')->andReturn($order_success); + $order_status->shouldReceive('name')->andReturn($order_success ? 'COMPLETED' : 'FAILED'); + $order->shouldReceive('status')->andReturn($order_status); + $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(); + $payments = \Mockery::mock(Payments::class)->shouldIgnoreMissing(); + $capture = \Mockery::mock(Capture::class)->shouldIgnoreMissing(); + + $capture->shouldReceive('id')->andReturn('TEST-CAPTURE-' . uniqid()); + $capture_status = \Mockery::mock(CaptureStatus::class)->shouldIgnoreMissing(); + + $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 + if ($intent === 'AUTHORIZE') { + $authorization = \Mockery::mock(\WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization::class)->shouldIgnoreMissing(); + + $authorization->shouldReceive('id')->andReturn('TEST-AUTH-' . uniqid()); + $auth_status = \Mockery::mock(\WooCommerce\PayPalCommerce\ApiClient\Entity\AuthorizationStatus::class)->shouldIgnoreMissing(); + + $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([]); + } else { + // For CAPTURE intent, set up captures but no authorizations + $payments->shouldReceive('captures')->andReturn([$capture]); + $payments->shouldReceive('authorizations')->andReturn([]); + } + + $purchase_unit->shouldReceive('payments')->andReturn($payments); + $order->shouldReceive('purchase_units')->andReturn([$purchase_unit]); + + // Set up the order endpoint methods + $order_endpoint->shouldReceive('create')->andReturn($order); + if ($intent === 'AUTHORIZE') { + $order_endpoint->shouldReceive('authorize')->andReturn($order); + } else { + $order_endpoint->shouldReceive('capture')->andReturn($order); + } + $order_endpoint->shouldReceive('order')->andReturn($order); + $order_endpoint->shouldReceive('patch_order_with')->andReturn($order); + return $order_endpoint; + } +} diff --git a/tests/integration/PHPUnit/PayPalSubscriptionsRenewalTest.php b/tests/integration/PHPUnit/PayPalSubscriptionsRenewalTest.php index e40eea149..93823646e 100644 --- a/tests/integration/PHPUnit/PayPalSubscriptionsRenewalTest.php +++ b/tests/integration/PHPUnit/PayPalSubscriptionsRenewalTest.php @@ -2,79 +2,390 @@ namespace WooCommerce\PayPalCommerce\Tests\Integration; +use Psr\Log\LoggerInterface; use WC_Product_Simple; use WooCommerce\PayPalCommerce\PayPalSubscriptions\RenewalHandler; /** + * @group subscriptions + * @group subscription-paypal * @group skip-ci */ -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' ) ); +class PayPalSubscriptionsRenewalTest extends TestCase +{ + + /** + * Tests that renewal orders are not created for recent subscriptions. + * + * GIVEN a subscription created 1 minute ago + * WHEN the process method is called with this subscription + * THEN no renewal order should be created + */ + 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' ); + $subscription = $this->createSubscription('-1 minute'); - $handler->process( [ $subscription ], 'TRANSACTION-ID' ); - $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); - $this->assertEquals( count( $renewal ), 0 ); + $handler->process([$subscription], 'TRANSACTION-ID'); + $renewal = $subscription->get_related_orders('ids', array('renewal')); + $this->assertEquals(0, count($renewal), 'No renewal order should be created for a subscription that is only 1 minute old'); } - public function test_renewal_order_is_created_when_receiving_webhook_nine_hours_later() { - $c = $this->getContainer(); - $handler = new RenewalHandler( $c->get( 'woocommerce.logger.woocommerce' ) ); + /** + * Tests that renewal orders are created for subscriptions older than 8 hours. + * + * GIVEN a subscription created 9 hours ago + * WHEN the process method is called with this subscription + * THEN a renewal order should be created + */ + 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' ); + $subscription = $this->createSubscription('-9 hour'); - $handler->process( [ $subscription ], 'TRANSACTION-ID' ); - $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); - $this->assertEquals( count( $renewal ), 1 ); + $handler->process([$subscription], 'TRANSACTION-ID'); + $renewal = $subscription->get_related_orders('ids', array('renewal')); + $this->assertEquals(1, count($renewal), 'A renewal order should be created for a subscription that is 9 hours old'); } - private function createSubscription( string $startDate ) { - $order = wc_create_order( [ - 'customer_id' => 1, - 'set_paid' => true, + /** + * Tests that renewal orders are created when subscription has renewal meta. + * + * GIVEN a subscription created 5 minutes ago + * AND the subscription has the _ppcp_is_subscription_renewal meta set to 'true' + * WHEN the process method is called with this subscription + * THEN a renewal order should be created + */ + public function test_renewal_order_is_created_when_subscription_has_renewal_meta() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + // Create a subscription that's only 5 minutes old (would normally not trigger renewal) + $subscription = $this->createSubscription('-5 minute'); + + // But mark it as needing renewal + $subscription->update_meta_data('_ppcp_is_subscription_renewal', 'true'); + $subscription->save_meta_data(); + + $handler->process([$subscription], 'TRANSACTION-ID'); + $renewal = $subscription->get_related_orders('ids', array('renewal')); + $this->assertEquals(1, count($renewal), 'A renewal order should be created when subscription has _ppcp_is_subscription_renewal meta set to true, regardless of age'); + } + + /** + * Tests that renewal order payment method matches the subscription. + * + * GIVEN a subscription created 9 hours ago + * AND the subscription has a specific payment method + * WHEN the process method is called with this subscription + * THEN a renewal order should be created + * AND the renewal order should have the same payment method as the subscription + */ + public function test_renewal_order_payment_method_matches_subscription() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + $subscription = $this->createSubscription('-9 hour'); + $payment_method = 'ppcp-gateway'; + $subscription->set_payment_method($payment_method); + $subscription->save(); + + $handler->process([$subscription], 'TRANSACTION-ID'); + $renewal_ids = $subscription->get_related_orders('ids', array('renewal')); + $this->assertEquals(1, count($renewal_ids), 'A renewal order should be created for a subscription that is 9 hours old'); + + $renewal_order = wc_get_order(reset($renewal_ids)); + $this->assertEquals($payment_method, $renewal_order->get_payment_method(), 'The renewal order should have the same payment method as the subscription'); + } + + /** + * Tests that renewal orders are marked as paid. + * + * GIVEN a subscription created 9 hours ago + * WHEN the process method is called with this subscription + * THEN a renewal order should be created + * AND the renewal order should be marked as paid + */ + public function test_renewal_order_is_marked_as_paid() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + $subscription = $this->createSubscription('-9 hour'); + + $handler->process([$subscription], 'TRANSACTION-ID'); + $renewal_ids = $subscription->get_related_orders('ids', array('renewal')); + $this->assertEquals(1, count($renewal_ids), 'A renewal order should be created for a subscription that is 9 hours old'); + + $renewal_order = wc_get_order(reset($renewal_ids)); + $this->assertTrue($renewal_order->is_paid(), 'The renewal order should be marked as paid'); + } + + /** + * Tests that transaction ID is set on renewal orders. + * + * GIVEN a subscription created 9 hours ago + * AND a unique transaction ID + * WHEN the process method is called with this subscription and transaction ID + * THEN a renewal order should be created + * AND the renewal order should have the transaction ID set + */ + public function test_transaction_id_is_set_on_renewal_order() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + $subscription = $this->createSubscription('-9 hour'); + $transaction_id = 'TEST-TRANSACTION-ID-' . uniqid(); + + $handler->process([$subscription], $transaction_id); + $renewal_ids = $subscription->get_related_orders('ids', array('renewal')); + $this->assertEquals(1, count($renewal_ids), 'A renewal order should be created for a subscription that is 9 hours old'); + + $renewal_order = wc_get_order(reset($renewal_ids)); + $this->assertEquals($transaction_id, $renewal_order->get_transaction_id(), 'The renewal order should have the transaction ID set correctly'); + } + + /** + * Tests that subscription status is set to on-hold before renewal. + * + * GIVEN a subscription created 9 hours ago with 'active' status + * WHEN the process method is called with this subscription + * THEN the subscription status should be changed to 'on-hold' + */ + public function test_subscription_status_is_set_to_on_hold_before_renewal() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + $subscription = $this->createSubscription('-9 hour'); + $initial_status = $subscription->get_status(); + $this->assertEquals('active', $initial_status, 'The subscription should start with active status'); + + $handler->process([$subscription], 'TRANSACTION-ID'); + + // Status should be on-hold before the renewal order is created + $this->assertEquals('on-hold', $subscription->get_status(), 'The subscription status should be changed to on-hold before renewal'); + } + + /** + * Tests that transaction ID is set on parent order when no renewal is created. + * + * GIVEN a subscription created 1 minute ago + * AND a unique transaction ID + * WHEN the process method is called with this subscription and transaction ID + * THEN no renewal order should be created + * AND the transaction ID should be set on the parent order + */ + public function test_transaction_id_is_set_on_parent_order_when_no_renewal() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + $subscription = $this->createSubscription('-1 minute'); + + $transaction_id = 'PARENT-TRANSACTION-ID-' . uniqid(); + $parent_order_id = $subscription->get_parent_id(); + + $handler->process([$subscription], $transaction_id); + + // No renewal order should be created + $renewal = $subscription->get_related_orders('ids', array('renewal')); + $this->assertEquals(0, count($renewal), 'No renewal order should be created for a subscription that is only 1 minute old'); + + //use latest order to get the updated status + $parent_order = wc_get_order($parent_order_id); + // Transaction ID should be set on parent order + $this->assertEquals($transaction_id, $parent_order->get_transaction_id(), 'The transaction ID should be set on the parent order when no renewal is created'); + } + + /** + * Tests that subscription meta is set when processing parent order. + * + * GIVEN a subscription created 1 minute ago + * AND the subscription has no _ppcp_is_subscription_renewal meta + * WHEN the process method is called with this subscription + * THEN the _ppcp_is_subscription_renewal meta should be set to 'true' + */ + public function test_subscription_meta_is_set_when_processing_parent_order() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + $subscription = $this->createSubscription('-1 minute'); + + // Meta should not exist before processing + $this->assertEmpty($subscription->get_meta('_ppcp_is_subscription_renewal'), 'The subscription should not have _ppcp_is_subscription_renewal meta before processing'); + + $handler->process([$subscription], 'TRANSACTION-ID'); + + // Meta should be set after processing + $this->assertEquals('true', $subscription->get_meta('_ppcp_is_subscription_renewal'), 'The _ppcp_is_subscription_renewal meta should be set to true after processing'); + } + + /** + * Tests handling subscriptions without valid parent orders. + * + * GIVEN a subscription created 9 hours ago + * AND the parent order is not available + * WHEN the process method is called with this subscription + * THEN a renewal order should still be created + * AND the renewal order should be properly set up with transaction ID + * AND the subscription status should be set to 'on-hold' + */ + public function test_subscription_without_valid_parent_order() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + $subscription = $this->createSubscription('-9 hour'); + $transaction_id = 'TEST-TRANSACTION-ID-' . uniqid(); + + // Simulate a scenario where the parent order doesn't exist or is not a WC_Order + // Mock wc_get_order to return false instead of a WC_Order instance + add_filter('woocommerce_get_shop_order_args', function ($args) use ($subscription) { + if (isset($args['id']) && $args['id'] === $subscription->get_parent_id()) { + return ['return' => false]; // This causes wc_get_order to return false + } + return $args; + }); + + // Process should not throw any errors + $handler->process([$subscription], $transaction_id); + + // Verify that a renewal order was created (as the subscription is 9 hours old) + $renewal_ids = $subscription->get_related_orders('ids', array('renewal')); + $this->assertEquals(1, count($renewal_ids), 'A renewal order should be created even when the parent order is not available'); + + // Verify the renewal order was properly set up + $renewal_order = wc_get_order(reset($renewal_ids)); + $this->assertTrue($renewal_order->is_paid(), 'The renewal order should be marked as paid even when the parent order is not available'); + $this->assertEquals($transaction_id, $renewal_order->get_transaction_id(), 'The renewal order should have the transaction ID set correctly even when the parent order is not available'); + + // Verify no errors occurred due to invalid parent order + $this->assertEquals('on-hold', $subscription->get_status(), 'The subscription status should be set to on-hold even when the parent order is not available'); + + // Remove the filter + remove_all_filters('woocommerce_get_shop_order_args'); + } + + + /** + * Tests that parent order transaction ID is updated for non-renewal subscriptions. + * + * GIVEN a subscription created 1 minute ago + * AND the parent order has no transaction ID + * WHEN the process method is called with this subscription and a unique transaction ID + * THEN the parent order's transaction ID should be updated + * AND the subscription should be marked for future renewal + * AND no renewal order should be created + */ + public function test_parent_order_transaction_id_is_updated_when_processing_non_renewal_subscription() + { + $c = $this->getContainer(); + $logger = $c->get('woocommerce.logger.woocommerce'); + $handler = new RenewalHandler($logger); + + // Create a subscription that's not ready for renewal + $subscription = $this->createSubscription('-1 minute'); + + // Get the parent order + $parent_order_id = $subscription->get_parent_id(); + $parent_order = wc_get_order($parent_order_id); + + $this->assertEmpty($parent_order->get_transaction_id(), 'The parent order should not have a transaction ID before processing'); + + $transaction_id = 'PARENT-ORDER-TRANSACTION-' . uniqid(); + $handler->process([$subscription], $transaction_id); + $parent_order = wc_get_order($parent_order_id); + + $this->assertEquals($transaction_id, $parent_order->get_transaction_id(), 'The parent order transaction ID should be updated correctly'); + + $this->assertEquals('true', $subscription->get_meta('_ppcp_is_subscription_renewal'), 'The subscription should be marked for future renewal after processing'); + + $renewal_orders = $subscription->get_related_orders('ids', array('renewal')); + $this->assertEquals(0, count($renewal_orders), 'No renewal order should be created for an empty array of subscriptions'); + } + + /** + * Tests that the RenewalHandler correctly handles an empty array of subscriptions. + * + * GIVEN the RenewalHandler with a mocked logger + * WHEN the process method is called with an empty array of subscriptions + * THEN no exceptions should be thrown + * AND the logger should not be called + */ + public function test_process_empty_subscriptions_array() + { + // Create a logger mock that expects no operations if no subscriptions + $logger_mock = \Mockery::mock(LoggerInterface::class); + // The logger should not be called at all with an empty array + $logger_mock->shouldNotReceive('info'); + + $handler = new RenewalHandler($logger_mock); + $transaction_id = 'TEST-TRANSACTION-EMPTY-ARRAY'; + + // Process an empty array of subscriptions + $handler->process([], $transaction_id); + + // Test is successful if no exceptions are thrown + // and the mock expectations are met (logger not called) + $this->assertTrue(true, 'No exceptions were thrown when processing an empty array of subscriptions'); + } + + private function createSubscription(string $startDate) + { + $order = wc_create_order([ + 'customer_id' => 1, + 'set_paid' => true, 'payment_method' => 'ppcp-gateway', - 'billing' => [ + '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' + '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' => [ + 'line_items' => [ [ 'product_id' => 42, - 'quantity' => 1 + 'quantity' => 1 ] ], - ] ); + ]); + // Make sure the order is properly saved + $order->save(); $product = new WC_Product_Simple(); $product->set_props([ - 'name' => 'Dummy Product', + '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', + '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(), + $subscription = wcs_create_subscription([ + 'start_date' => gmdate('Y-m-d H:i:s', strtotime($startDate)), + 'order_id' => $order->get_id(), 'customer_id' => 1, 'status' => 'active', 'billing_period' => 'day', @@ -87,5 +398,9 @@ class PayPalSubscriptionsRenewalTest extends TestCase { ] ], ]); + + // Make sure the subscription is properly saved + $subscription->save(); + return $subscription; } } diff --git a/tests/integration/PHPUnit/Traits/CleansTestData.php b/tests/integration/PHPUnit/Traits/CleansTestData.php new file mode 100644 index 000000000..8f0d911c8 --- /dev/null +++ b/tests/integration/PHPUnit/Traits/CleansTestData.php @@ -0,0 +1,25 @@ +order_factory)) { + $this->order_factory->cleanup(); + } + + if (isset($this->product_factory)) { + $this->product_factory->cleanup(); + } + + if (isset($this->coupon_factory)) { + $this->coupon_factory->cleanup(); + } + } +} diff --git a/tests/integration/PHPUnit/Traits/CreateTestOrders.php b/tests/integration/PHPUnit/Traits/CreateTestOrders.php new file mode 100644 index 000000000..273f90977 --- /dev/null +++ b/tests/integration/PHPUnit/Traits/CreateTestOrders.php @@ -0,0 +1,50 @@ +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 + ); + } +} diff --git a/tests/integration/PHPUnit/Traits/CreateTestProducts.php b/tests/integration/PHPUnit/Traits/CreateTestProducts.php new file mode 100644 index 000000000..4020b8289 --- /dev/null +++ b/tests/integration/PHPUnit/Traits/CreateTestProducts.php @@ -0,0 +1,60 @@ +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); + } + } + } +} diff --git a/tests/integration/PHPUnit/Transaction_tests/CreditcardTransactionTest.php b/tests/integration/PHPUnit/Transaction_tests/CreditcardTransactionTest.php new file mode 100644 index 000000000..6be9de4d5 --- /dev/null +++ b/tests/integration/PHPUnit/Transaction_tests/CreditcardTransactionTest.php @@ -0,0 +1,247 @@ +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']); + } + + /** + * Tests payment processing with a saved/vaulted credit card. + * + * GIVEN a WooCommerce order + * AND a saved credit card token for the customer + * AND the saved card ID in POST data + * WHEN the payment is processed through the credit card gateway + * THEN the payment should be successfully captured using the vaulted card + * AND the order status should change to processing + * AND a transaction ID should be set on the order + */ + public function testProcessPaymentVaultedCard() + { + $mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', false, true); + $order = $this->getConfiguredOrder( + $this->customer_id, + 'ppcp-credit-card-gateway', + ['simple'], + [], + false + ); + $paypal_order_id = 'TEST-PAYPAL-ORDER-' . uniqid(); + + $_POST['paypal_order_id'] = $paypal_order_id; + $order->update_meta_data('_paypal_order_id', $paypal_order_id); + $order->save(); + + $paymentToken = $this->setupPaymentToken($this->customer_id, 'ppcp-credit-card-gateway'); + + // Set the saved card in POST data (simulating frontend selection) + $_POST['saved_credit_card'] = $paymentToken->get_token(); + + $sessionHandler = \Mockery::mock(\WooCommerce\PayPalCommerce\Session\SessionHandler::class); + $sessionHandler->shouldReceive('order')->andReturn(null); + $sessionHandler->shouldReceive('destroy_session_data')->once(); + + $additionalServices = [ + 'session.handler' => function() use ($sessionHandler) { + return $sessionHandler; + }, + 'api.endpoint.payment-tokens' => function() { + return $this->mockPaymentTokensEndpoint; + } + ]; + + $c = $this->setupTestContainer($mockOrderEndpoint, $additionalServices); + $gateway = $c->get('wcgateway.credit-card-gateway'); + + $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('processing', $order->get_status()); + $this->assertNotEmpty($order->get_transaction_id()); + + // Clean up POST data + unset($_POST['saved_credit_card']); + } +} diff --git a/tests/integration/PHPUnit/Transaction_tests/OxxoTransactionTest.php b/tests/integration/PHPUnit/Transaction_tests/OxxoTransactionTest.php new file mode 100644 index 000000000..13ae98aee --- /dev/null +++ b/tests/integration/PHPUnit/Transaction_tests/OxxoTransactionTest.php @@ -0,0 +1,199 @@ + function () use ($orderEndpoint) { + return $orderEndpoint; + }, + ]; + + return $this->bootstrapModule(array_merge($services, $additionalServices)); + } + + /** + * Data provider for different product combinations + * + * @return array + */ + public function paymentProcessingDataProvider(): array + { + // A webhook will put the pending payments to on-hold status, + // but we are not testing webhooks here. + // look at PaymentCapturePending::handle_request + return [ + 'simple product only' => [ + 'products' => ['simple'], + 'expected_status' => 'pending' + ], + 'expensive product' => [ + 'products' => ['simple_expensive'], + 'expected_status' => 'pending' + ], + 'multiple products' => [ + 'products' => [ + ['preset' => 'simple', 'quantity' => 2], + 'simple_expensive' + ], + 'expected_status' => 'pending' + ], + ]; + } + + /** + * Tests OXXO payment processing with different product combinations. + * + * @scenario Process OXXO payment for order + * @given a WooCommerce order with various product combinations + * @and valid billing information for Mexico + * @when the OXXO payment is processed + * @then the PayPal API should be called to create and confirm payment + * @and a payer action link should be stored in order meta + * @and the order status should change to on-hold + * @and the cart should be emptied + * + * @dataProvider paymentProcessingDataProvider + * + * @param array $products Product configuration + * @param string $expected_status Expected order status after payment + * + * @return void + */ + public function testProcessPayment(array $products, string $expected_status): void + { + $mockOrderEndpoint = $this->mockOrderEndpointForOXXO(); + $container = $this->setupTestContainer($mockOrderEndpoint); + + $order = $this->getConfiguredOrder( + $this->customer_id, + OXXOGateway::ID, + $products, + [], // No discounts for OXXO tests + false + ); + + $order->set_billing_country('MX'); + $order->set_billing_first_name('Juan'); + $order->set_billing_last_name('Pérez'); + $order->set_billing_email('juan.perez@example.com'); + $order->save(); + + $gateway = $container->get('wcgateway.oxxo-gateway'); + $result = $gateway->process_payment($order->get_id()); + + $this->assertEquals('success', $result['result']); + $this->assertArrayHasKey('redirect', $result); + + $order = wc_get_order($order->get_id()); + $this->assertEquals($expected_status, $order->get_status()); + + $payer_action_link = $order->get_meta('ppcp_oxxo_payer_action'); + $this->assertNotEmpty($payer_action_link); + $this->assertStringContainsString('paypal.com/payment/oxxo', $payer_action_link); + } + + /** + * Tests OXXO payment processing failure scenarios. + * + * @scenario Process OXXO payment with API failure + * @given a WooCommerce order with valid billing information + * @when the PayPal API fails during order creation + * @then the payment should fail gracefully + * @and the order status should be updated to failed + * @and an error notice should be displayed + * + * @return void + */ + public function testProcessPaymentFailure(): void + { + $mockOrderEndpoint = $this->mockOrderEndpointWithFailure(); + $container = $this->setupTestContainer($mockOrderEndpoint); + + $order = $this->getConfiguredOrder( + $this->customer_id, + OXXOGateway::ID, + ['simple'], + [], + false + ); + $order->set_billing_country('MX'); + $order->set_billing_first_name('Juan'); + $order->set_billing_last_name('Pérez'); + $order->set_billing_email('juan.perez@example.com'); + $order->save(); + + $gateway = $container->get('wcgateway.oxxo-gateway'); + $result = $gateway->process_payment($order->get_id()); + + $this->assertEquals('failure', $result['result']); + } + + /** + * Mock OrderEndpoint for successful OXXO payment processing + * + * @return OrderEndpoint + */ + private function mockOrderEndpointForOXXO(): OrderEndpoint + { + $mockEndpoint = $this->mockOrderEndpoint('CAPTURE', true, true); + + // Add OXXO-specific confirm_payment_source method + $confirmResponse = (object)[ + 'links' => [ + (object)[ + 'rel' => 'payer-action', + 'href' => 'https://sandbox.paypal.com/payment/oxxo?token=TEST_TOKEN_123', + ], + ] + ]; + + $mockEndpoint->shouldReceive('confirm_payment_source') + ->andReturn($confirmResponse); + + return $mockEndpoint; + } + + /** + * Mock OrderEndpoint for failed payment processing + * + * @return OrderEndpoint + */ + private function mockOrderEndpointWithFailure(): OrderEndpoint + { + $mockEndpoint = $this->mockOrderEndpoint('CAPTURE', false, false); + + // Override the create method to throw exception (OXXO fails at order creation) + $mockEndpoint->shouldReceive('confirm_payment_source') + ->andThrow(RuntimeException::class, 'API Error'); + + return $mockEndpoint; + } +} diff --git a/tests/integration/PHPUnit/VaultingSubscriptionsTest.php b/tests/integration/PHPUnit/VaultingSubscriptionsTest.php new file mode 100644 index 000000000..54acc4de8 --- /dev/null +++ b/tests/integration/PHPUnit/VaultingSubscriptionsTest.php @@ -0,0 +1,260 @@ +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; + }, + 'api.endpoint.payment-tokens' => function () { + return $this->mockPaymentTokensEndpoint; + } + ]; + + 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; + } + + + /** + * Tests that vaulting is automatically enabled when subscription mode is set to vaulting_api. + * + * GIVEN a PayPal account with Reference Transactions enabled + * WHEN the subscription mode is set to "vaulting_api" + * THEN vaulting should be automatically enabled for the PayPal gateway + */ + public function test_vaulting_is_enabled_when_subscription_mode_is_vaulting_api() + { + $user_has_cap_callback = function ($allcaps, $caps, $args) { + if (isset($args[0]) && $args[0] === 'manage_woocommerce') { + $allcaps['manage_woocommerce'] = true; + } + return $allcaps; + }; + add_filter('user_has_cap', $user_has_cap_callback, 10, 3); + + // Convert to Mockery mocks + $reference_transaction_status = \Mockery::mock(ReferenceTransactionStatus::class); + $reference_transaction_status->shouldReceive('reference_transaction_enabled') + ->andReturn(true); + + $state_mock = \Mockery::mock(State::class); + $state_mock->shouldReceive('current_state') + ->andReturn(State::STATE_ONBOARDED); + + $token_mock = \Mockery::mock(Token::class); + $token_mock->shouldReceive('vaulting_available') + ->andReturn(true); + + $bearer_mock = \Mockery::mock(Bearer::class); + $bearer_mock->shouldReceive('bearer') + ->andReturn($token_mock); + + // Create and configure the SettingsListener + $c = $this->bootstrapModule([ + 'api.endpoint.billing-agreements' => function () use ($reference_transaction_status) { + return $reference_transaction_status; + }, + 'onboarding.state' => function () use ($state_mock) { + return $state_mock; + }, + 'wcgateway.current-ppcp-settings-page-id' => function () { + return '123'; + }, + 'api.bearer' => function () use ($bearer_mock) { + return $bearer_mock; + }, + ]); + + $settings = $c->get('wcgateway.settings'); + + // Store original settings to restore later + $original_subscription_mode = $settings->get('subscriptions_mode'); + $original_vault_enabled = $settings->get('vault_enabled'); + + try { + $settings_listener = $c->get('wcgateway.settings.listener'); + $settings_listener->listen_for_vaulting_enabled(); + $_POST['ppcp'] = [ + 'subscriptions_mode' => 'vaulting_api', + 'vault_enabled' => '0' // Explicitly set to disabled + ]; + $_REQUEST['_wpnonce'] = wp_create_nonce('ppcp-settings'); + $settings_listener->listen_for_vaulting_enabled(); + + // THEN vaulting should be automatically enabled for the PayPal gateway + $this->assertTrue( + get_option('woocommerce-ppcp-settings')['vault_enabled'], + 'Vaulting should be automatically enabled when subscription mode is set to vaulting_api' + ); + + } finally { + unset($_POST['ppcp']); + $settings->set('subscriptions_mode', $original_subscription_mode); + $settings->set('vault_enabled', $original_vault_enabled); + $settings->persist(); + remove_filter('user_has_cap', $user_has_cap_callback, 10); + } + + } + + /** + * Data provider for payment gateway tests + */ + public function paymentGatewayProvider(): array + { + return [ + 'PayPal Gateway' => [PayPalGateway::ID], + 'Credit Card Gateway' => [CreditCardGateway::ID] + ]; + } + + /** + * Tests PayPal renewal payment processing. + * + * GIVEN a subscription with a saved PayPal payment token due for renewal + * WHEN the renewal process is triggered + * THEN a new PayPal order should be created using the customer token + * + * @dataProvider paymentGatewayProvider + */ + public function test_renewal_payment_processing(string $gateway_id) + { + $mockOrderEndpoint = $this->mockOrderEndpoint(); + $c = $this->setupTestContainer($mockOrderEndpoint); + $this->setupPaymentToken($this->customer_id, $gateway_id); + $subscription = $this->createSubscription($this->customer_id, $gateway_id); + $renewal_order = $this->createRenewalOrder($this->customer_id, $gateway_id, $subscription->get_id()); + + $renewal_handler = $c->get('wc-subscriptions.renewal-handler'); + $renewal_handler->renew($renewal_order); + + // Check that the order was processed + $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. + * + * GIVEN a subscription due for renewal + * WHEN the payment process fails with an exception + * THEN the renewal order should be marked as failed + */ + public function test_renewal_handles_failed_payment() + { + $mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', false, false); + $c = $this->setupTestContainer($mockOrderEndpoint); + $this->setupPaymentToken($this->customer_id); + $subscription = $this->createSubscription($this->customer_id, PayPalGateway::ID); + $renewal_order = $this->createRenewalOrder($this->customer_id, PayPalGateway::ID, $subscription->get_id()); + $renewal_handler = $c->get('wc-subscriptions.renewal-handler'); + $renewal_handler->renew($renewal_order); + + // Check that the order status is failed + $this->assertEquals('failed', $renewal_order->get_status(), 'The renewal order should be marked as failed when payment fails'); + } + + /** + * Tests authorization-only subscription renewals. + * + * GIVEN the payment intent is set to "AUTHORIZE" + * WHEN a subscription renewal payment is processed + * THEN the payment should be authorized but not captured + */ + public function test_authorize_only_subscription_renewal() + { + // Mock the OrderEndpoint with AUTHORIZE intent + $mockOrderEndpoint = $this->mockOrderEndpoint('AUTHORIZE', false, true); + $c = $this->setupTestContainer($mockOrderEndpoint); + + // Setup payment token and subscription + $this->setupPaymentToken($this->customer_id); + $subscription = $this->createSubscription($this->customer_id, PayPalGateway::ID); + $renewal_order = $this->createRenewalOrder($this->customer_id, PayPalGateway::ID, $subscription->get_id()); + + // Override the intent setting to ensure it's set to AUTHORIZE + $settings = $c->get('wcgateway.settings'); + $original_intent = $settings->get('intent'); + $settings->set('intent', 'authorize'); + $settings->persist(); + + try { + // Process the renewal + $renewal_handler = $c->get('wc-subscriptions.renewal-handler'); + $renewal_handler->renew($renewal_order); + + // Check that the order was processed with authorization + $this->assertEquals('on-hold', $renewal_order->get_status(), 'The renewal order should be on-hold after successful authorization'); + $this->assertNotEmpty($renewal_order->get_transaction_id(), 'The renewal order should have a transaction ID'); + $this->assertEquals('AUTHORIZE', $mockOrderEndpoint->order('')->intent(), 'The order intent should be AUTHORIZE'); + } finally { + // Restore original settings + $settings->set('intent', $original_intent); + $settings->persist(); + } + } +} diff --git a/tests/integration/PHPUnit/bootstrap.php b/tests/integration/PHPUnit/bootstrap.php index ebefc09f9..1eb4c53c8 100644 --- a/tests/integration/PHPUnit/bootstrap.php +++ b/tests/integration/PHPUnit/bootstrap.php @@ -21,3 +21,6 @@ define('WP_ROOT_DIR', $wpRootDir); $_SERVER['HTTP_HOST'] = ''; // just to avoid a warning require_once WP_ROOT_DIR . '/wp-load.php'; +// Ensure the TestCase class is loaded +require_once __DIR__ . '/TestCase.php'; +require_once __DIR__ . '/IntegrationMockedTestCase.php'; diff --git a/tests/stubs/DefaultPaymentGateways.php b/tests/stubs/DefaultPaymentGateways.php new file mode 100644 index 000000000..a9bb2ba4b --- /dev/null +++ b/tests/stubs/DefaultPaymentGateways.php @@ -0,0 +1,17 @@ +get( 'settings.data.general' ); assert( $general_settings instanceof GeneralSettings ); diff --git a/woocommerce-paypal-payments.php b/woocommerce-paypal-payments.php index 320a4f64a..d23173970 100644 --- a/woocommerce-paypal-payments.php +++ b/woocommerce-paypal-payments.php @@ -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.8 * 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: 10.0 * 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-07-21' ); define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); ! defined( 'CONNECT_WOO_CLIENT_ID' ) && define( 'CONNECT_WOO_CLIENT_ID', 'AcCAsWta_JTL__OfpjspNyH7c1GGHH332fLwonA5CwX4Y10mhybRZmHLA0GdRbwKwjQIhpDQy0pluX_P' ); @@ -43,6 +43,42 @@ define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); require $autoload_filepath; } + /** + * Displays an admin notice and optionally deactivates the current plugin. + * + * This function registers a callback to display administrative notices on both + * single-site and network admin areas. It's typically used to show error messages + * when plugin requirements are not met, followed by automatic plugin deactivation. + * + * @param callable $notice_callback The callback function that outputs the admin notice HTML. + * Should echo/print the notice markup directly. + * @param bool $auto_deactivate Optional. Whether to automatically deactivate the plugin + * after displaying the notice. Default true. + * + * @return void + */ + function show_admin_notice_and_deactivate( callable $notice_callback, bool $auto_deactivate = true ): void { + if ( ! is_callable( $notice_callback ) ) { + return; + } + + $admin_notice_hooks = array( 'admin_notices', 'network_admin_notices' ); + + foreach ( $admin_notice_hooks as $hook ) { + add_action( + $hook, + static function () use ( $notice_callback, $auto_deactivate ) { + $notice_callback(); + + if ( $auto_deactivate ) { + deactivate_plugins( plugin_basename( __FILE__ ) ); + unset( $_GET['activate'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + } + ); + } + } + /** * Initialize the plugin and its modules. */ @@ -50,22 +86,45 @@ define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); $root_dir = __DIR__; if ( ! is_woocommerce_activated() ) { - add_action( - 'admin_notices', - function() { - /* translators: 1. URL link. */ - echo '

' . sprintf( esc_html__( 'WooCommerce PayPal Payments requires WooCommerce to be installed and active. You can download %s here.', 'woocommerce-paypal-payments' ), 'WooCommerce' ) . '

'; - } + show_admin_notice_and_deactivate( + static fn() => printf( + '
%1$s

%2$s

', + esc_html__( + 'The plugin WooCommerce PayPal Payments has been deactivated', + 'woocommerce-paypal-payments' + ), + wp_kses( + sprintf( + // translators: %s is a link to install WooCommerce. + esc_html__( 'WooCommerce PayPal Payments requires WooCommerce to be installed and active. %s', 'woocommerce-paypal-payments' ), + sprintf( + '%s', + esc_url( network_admin_url( 'plugin-install.php?tab=plugin-information&plugin=woocommerce' ) ), + esc_html__( 'You can download WooCommerce here.', 'woocommerce-paypal-payments' ) + ) + ), + array( + 'a' => array( + 'href' => array(), + 'target' => array(), + ), + ) + ) + ) ); return; } if ( version_compare( PHP_VERSION, '7.4', '<' ) ) { - add_action( - 'admin_notices', - function() { - echo '

' . esc_html__( 'WooCommerce PayPal Payments requires PHP 7.4 or above.', 'woocommerce-paypal-payments' ), '

'; - } + show_admin_notice_and_deactivate( + static fn() => printf( + '
%1$s

%2$s

', + esc_html__( + 'The plugin WooCommerce PayPal Payments has been deactivated', + 'woocommerce-paypal-payments' + ), + esc_html__( 'WooCommerce PayPal Payments requires PHP 7.4 or above.', 'woocommerce-paypal-payments' ) + ) ); return; @@ -91,6 +150,11 @@ define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); 'plugins_loaded', function () { init(); + + if ( ! is_woocommerce_activated() ) { + return; + } + add_action( 'init', function () {